Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
@@ -0,0 +1,63 @@
package service
import (
"context"
"errors"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/middleware"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
micrometadata "go-micro.dev/v4/metadata"
)
type notificationFilter struct {
log log.Logger
valueClient settingssvc.ValueService
}
func newNotificationFilter(l log.Logger, vc settingssvc.ValueService) *notificationFilter {
return &notificationFilter{log: l, valueClient: vc}
}
// execute removes users who have disabled mail notifications for the event
func (nf notificationFilter) execute(ctx context.Context, users []*user.User, settingId string) []*user.User {
var filteredUsers []*user.User
for _, u := range users {
userId := u.GetId().GetOpaqueId()
enabled, err := getSetting(ctx, nf.valueClient, userId, settingId)
if err != nil {
nf.log.Error().Err(err).Str("userId", userId).Str("settingId", settingId).Msg("cannot get user event setting")
filteredUsers = append(filteredUsers, u)
continue
}
if enabled {
filteredUsers = append(filteredUsers, u)
}
}
return filteredUsers
}
func getSetting(ctx context.Context, vc settingssvc.ValueService, userId string, settingId string) (bool, error) {
resp, err := vc.GetValueByUniqueIdentifiers(
micrometadata.Set(ctx, middleware.AccountID, userId),
&settingssvc.GetValueByUniqueIdentifiersRequest{
AccountUuid: userId,
SettingId: settingId,
},
)
if err != nil {
return false, err
}
val := resp.GetValue().GetValue().GetCollectionValue().GetValues()
for _, option := range val {
if option.GetKey() == "mail" {
return option.GetBoolValue(), nil
}
}
return false, errors.New("no setting found")
}
@@ -0,0 +1,91 @@
package service
import (
"context"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/qsfera/server/pkg/log"
settingsmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/settings/v0"
settings "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
settingsmocks "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0/mocks"
"github.com/pkg/errors"
"github.com/stretchr/testify/mock"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("NotificationFilter", func() {
var (
testLogger = log.NewLogger()
vs = &settingsmocks.ValueService{}
)
setupMockValueService := func(mail bool) *settingsmocks.ValueService {
m := &settingsmocks.ValueService{}
m.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(&settings.GetValueResponse{
Value: &settingsmsg.ValueWithIdentifier{
Value: &settingsmsg.Value{
Value: &settingsmsg.Value_CollectionValue{
CollectionValue: &settingsmsg.CollectionValue{
Values: []*settingsmsg.CollectionOption{
{
Key: "mail",
Option: &settingsmsg.CollectionOption_BoolValue{BoolValue: mail},
},
},
},
},
},
},
}, nil)
return m
}
Describe("execute", func() {
It("handles connection errors", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(nil, errors.New("no connection to ValueService"))
ulf := notificationFilter{
log: testLogger,
valueClient: vs,
}
Expect(ulf.execute(context.TODO(), []*user.User{{Id: &user.UserId{OpaqueId: "foo"}}}, "bar")).To(ConsistOf(&user.User{Id: &user.UserId{OpaqueId: "foo"}}))
})
It("handles no setting in ValueService response", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(&settings.GetValueResponse{}, nil)
ulf := notificationFilter{
log: testLogger,
valueClient: vs,
}
Expect(ulf.execute(context.TODO(), []*user.User{{Id: &user.UserId{OpaqueId: "foo"}}}, "bar")).To(ConsistOf(&user.User{Id: &user.UserId{OpaqueId: "foo"}}))
})
It("handles nil responses", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(nil, nil)
ulf := notificationFilter{
log: testLogger,
valueClient: vs,
}
Expect(ulf.execute(context.TODO(), []*user.User{{Id: &user.UserId{OpaqueId: "foo"}}}, "bar")).To(ConsistOf(&user.User{Id: &user.UserId{OpaqueId: "foo"}}))
})
It("return users when events are enabled", func() {
vs = setupMockValueService(true)
ulf := notificationFilter{
log: testLogger,
valueClient: vs,
}
Expect(ulf.execute(context.TODO(), []*user.User{{Id: &user.UserId{OpaqueId: "foo"}}}, "bar")).To(ConsistOf(&user.User{Id: &user.UserId{OpaqueId: "foo"}}))
})
It("return no users when events are disabled", func() {
vs = setupMockValueService(false)
ulf := notificationFilter{
log: testLogger,
valueClient: vs,
}
Expect(ulf.execute(context.TODO(), []*user.User{{Id: &user.UserId{OpaqueId: "foo"}}}, "bar")).To(BeEmpty())
})
})
})
@@ -0,0 +1,162 @@
package service
import (
"context"
"github.com/qsfera/server/pkg/l10n"
ehmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/eventhistory/v0"
"github.com/qsfera/server/services/notifications/pkg/channels"
"github.com/qsfera/server/services/notifications/pkg/email"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/rs/zerolog"
)
func (s eventsNotifier) sendGroupedEmailsJob(sendEmailsEvent events.SendEmailsEvent, eventId string) {
logger := s.logger.With().
Str("event", "SendEmailsEvent").
Str("eventId", eventId).
Logger()
if sendEmailsEvent.Interval != _intervalDaily && sendEmailsEvent.Interval != _intervalWeekly {
logger.Error().Str("interval", sendEmailsEvent.Interval).Msg("unsupported email sending interval")
return
}
keys, err := s.userEventStore.listKeys(sendEmailsEvent.Interval)
if err != nil {
logger.Error().Err(err).Msg("could not get list of keys")
return
}
ctx := context.Background()
jobs := make(chan string, 10)
go func() {
for _, key := range keys {
jobs <- key
}
close(jobs)
}()
for job := range jobs {
go s.createGroupedMail(ctx, logger, job)
}
}
func (s eventsNotifier) createGroupedMail(ctx context.Context, logger zerolog.Logger, key string) {
userEvents, err := s.userEventStore.pop(ctx, key)
if err != nil {
logger.Error().Err(err).Str("key", key).Msg("could not pop user events")
return
}
var mts []email.MessageTemplate
var mtsVars []map[string]string
locale := l10n.MustGetUserLocale(ctx, userEvents.User.GetId().GetOpaqueId(), "", s.valueService)
for _, e := range userEvents.Events {
switch te := s.unwrapEvent(logger, e).(type) {
case events.SpaceShared:
logger := logger.With().
Str("event", "SpaceShared").
Str("eventId", te.ID.OpaqueId).
Logger()
executant, spaceName, shareLink, _, err := s.prepareSpaceShared(logger, te)
if err != nil {
logger.Error().Err(err).Msg("could not prepare vars for grouped email")
continue
}
mts = append(mts, email.SharedSpace)
mtsVars = append(mtsVars, map[string]string{
"SpaceSharer": executant.GetDisplayName(),
"SpaceName": spaceName,
"ShareLink": shareLink,
})
case events.SpaceUnshared:
logger := logger.With().
Str("event", "SpaceUnshared").
Str("eventId", te.ID.OpaqueId).
Logger()
executant, spaceName, shareLink, _, err := s.prepareSpaceUnshared(logger, te)
if err != nil {
logger.Error().Err(err).Msg("could not prepare vars for grouped email")
continue
}
mts = append(mts, email.UnsharedSpace)
mtsVars = append(mtsVars, map[string]string{
"SpaceSharer": executant.GetDisplayName(),
"SpaceName": spaceName,
"ShareLink": shareLink,
})
case events.SpaceMembershipExpired:
mts = append(mts, email.MembershipExpired)
mtsVars = append(mtsVars, map[string]string{
"SpaceName": te.SpaceName,
"ExpiredAt": te.ExpiredAt.Format("2006-01-02 15:04:05"),
})
case events.ShareCreated:
logger := logger.With().
Str("event", "ShareCreated").
Str("eventId", te.ItemID.OpaqueId).
Logger()
owner, shareFolder, shareLink, _, err := s.prepareShareCreated(logger, te)
if err != nil {
logger.Error().Err(err).Msg("could not prepare vars for grouped email")
continue
}
mts = append(mts, email.ShareCreated)
mtsVars = append(mtsVars, map[string]string{
"ShareSharer": owner.GetDisplayName(),
"ShareFolder": shareFolder,
"ShareLink": shareLink,
})
case events.ShareExpired:
logger := logger.With().
Str("event", "ShareCreated").
Str("eventId", te.ItemID.OpaqueId).
Logger()
shareFolder, _, err := s.prepareShareExpired(logger, te)
if err != nil {
logger.Error().Err(err).Msg("could not prepare vars for grouped email")
continue
}
mts = append(mts, email.ShareExpired)
mtsVars = append(mtsVars, map[string]string{
"ShareFolder": shareFolder,
"ExpiredAt": te.ExpiredAt.Format("2006-01-02 15:04:05"),
})
}
}
rendered, err := email.RenderGroupedEmailTemplate(email.Grouped, map[string]string{
"DisplayName": userEvents.User.GetDisplayName(),
}, locale, s.defaultLanguage, s.emailTemplatePath, s.translationPath, mts, mtsVars)
if err != nil {
logger.Error().Err(err).Msg("could not render template")
return
}
rendered.Sender = s.defaultEmailSender
rendered.Recipient = []string{userEvents.User.GetMail()}
s.send(ctx, []*channels.Message{rendered})
}
func (s eventsNotifier) unwrapEvent(logger zerolog.Logger, e *ehmsg.Event) any {
etype, ok := s.registeredEvents[e.GetType()]
if !ok {
logger.Error().Str("eventId", e.GetId()).Str("eventType", e.GetType()).Msg("event not registered")
return nil
}
ue, err := etype.Unmarshal(e.GetEvent())
if err != nil {
logger.Error().Str("eventId", e.GetId()).Str("eventType", e.GetType()).Msg("failed to umarshal event")
return nil
}
return ue
}
@@ -0,0 +1,26 @@
package service_test
import (
"testing"
"github.com/qsfera/server/pkg/registry"
mRegistry "go-micro.dev/v4/registry"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func init() {
r := registry.GetRegistry(registry.Inmemory())
service := registry.BuildGRPCService("qsfera.api.gateway", "", "", "")
service.Nodes = []*mRegistry.Node{{
Address: "any",
}}
_ = r.Register(service)
}
func TestNotifications(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Notification Suite")
}
@@ -0,0 +1,117 @@
package service
import (
"context"
"encoding/json"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/qsfera/server/pkg/log"
v0 "github.com/qsfera/server/protogen/gen/qsfera/messages/eventhistory/v0"
ehsvc "github.com/qsfera/server/protogen/gen/qsfera/services/eventhistory/v0"
"github.com/pkg/errors"
"go-micro.dev/v4/store"
)
type userEventStore struct {
log log.Logger
store store.Store
historyClient ehsvc.EventHistoryService
}
type userEventIds struct {
User *user.User `json:"user"`
EventIds []string `json:"event_ids"`
}
type userEvents struct {
User *user.User
Events []*v0.Event
}
const (
_intervalDaily = "daily"
_intervalWeekly = "weekly"
)
func newUserEventStore(l log.Logger, s store.Store, hc ehsvc.EventHistoryService) *userEventStore {
return &userEventStore{log: l, store: s, historyClient: hc}
}
func (s *userEventStore) persist(interval string, eventId string, users []*user.User) []*user.User {
var errorUsers []*user.User
for _, u := range users {
key := interval + u.Id.OpaqueId
// Note: This is not thread safe and can result in missing events
records, err := s.store.Read(key)
if err != nil && err != store.ErrNotFound {
s.log.Error().Err(err).Str("eventId", eventId).Str("userId", u.Id.OpaqueId).Msg("cannot read record")
errorUsers = append(errorUsers, u)
continue
}
var record userEventIds
if len(records) == 0 {
record = userEventIds{}
} else {
if err = json.Unmarshal(records[0].Value, &record); err != nil {
s.log.Warn().Err(err).Str("eventId", eventId).Str("userId", u.Id.OpaqueId).Msg("cannot unmarshal json")
errorUsers = append(errorUsers, u)
continue
}
}
record.User = u
record.EventIds = append(record.EventIds, eventId)
b, err := json.Marshal(record)
if err != nil {
s.log.Warn().Err(err).Str("eventId", eventId).Str("userId", u.Id.OpaqueId).Msg("cannot marshal record")
errorUsers = append(errorUsers, u)
continue
}
err = s.store.Write(&store.Record{
Key: key,
Value: b,
})
if err != nil {
s.log.Error().Err(err).Str("eventId", eventId).Str("userId", u.Id.OpaqueId).Msg("cannot write record")
errorUsers = append(errorUsers, u)
continue
}
}
return errorUsers
}
func (s *userEventStore) listKeys(interval string) ([]string, error) {
return s.store.List(store.ListPrefix(interval))
}
func (s *userEventStore) pop(ctx context.Context, key string) (*userEvents, error) {
records, err := s.store.Read(key)
if err != nil && err != store.ErrNotFound {
return nil, errors.New("cannot get records")
}
if len(records) == 0 {
return nil, errors.New("no records found")
}
var record userEventIds
err = json.Unmarshal(records[0].Value, &record)
if err != nil {
s.log.Warn().Err(err).Str("key", key).Msg("cannot unmarshal json")
return nil, err
}
res, err := s.historyClient.GetEvents(ctx, &ehsvc.GetEventsRequest{Ids: record.EventIds})
if err != nil {
s.log.Error().Err(err).Strs("eventIds", record.EventIds).Msg("cannot get events")
return nil, err
}
err = s.store.Delete(key)
if err != nil {
s.log.Error().Err(err).Strs("eventIds", record.EventIds).Msg("cannot delete records")
return nil, err
}
return &userEvents{
User: record.User,
Events: res.GetEvents(),
}, nil
}
@@ -0,0 +1,82 @@
package service
import (
"context"
"github.com/qsfera/server/services/notifications/pkg/channels"
"github.com/qsfera/server/services/notifications/pkg/email"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/utils"
)
func (s eventsNotifier) handleScienceMeshInviteTokenGenerated(e events.ScienceMeshInviteTokenGenerated) {
logger := s.logger.With().
Str("event", "ScienceMeshInviteTokenGenerated").
Logger()
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
logger.Error().Err(err).Msg("could not select next gateway client")
return
}
ctx, err := utils.GetServiceUserContextWithContext(context.Background(), gatewayClient, s.serviceAccountID, s.serviceAccountSecret)
if err != nil {
logger.Error().Err(err).Msg("Could not impersonate service user")
return
}
owner, err := utils.GetUserNoGroups(ctx, e.Sharer, gatewayClient)
if err != nil {
logger.Error().Err(err).Msg("unable to get user")
return
}
msgENV := map[string]string{
"ShareSharer": owner.GetDisplayName(),
"ShareSharerMail": owner.GetMail(),
"ShareLink": e.InviteLink,
"Token": e.Token,
"ProviderDomain": owner.GetId().GetIdp(),
"RecipientMail": e.RecipientMail,
}
// validate the message, we only need recipient mail at the moment,
// event that is optional when the event got triggered...
// this means if we get a validation error, we can't send the message and skip it
{
validationEnv := make(map[string]any, len(msgENV))
for k, v := range msgENV {
validationEnv[k] = v
}
if errs := validate.ValidateMap(validationEnv,
map[string]any{
"RecipientMail": "required,email", // only recipient mail is required to send the message
}); len(errs) > 0 {
return // no mail, no message
}
}
emailTpl := email.ScienceMeshInviteTokenGenerated
if e.InviteLink == "" {
emailTpl = email.ScienceMeshInviteTokenGeneratedWithoutShareLink
}
msg, err := email.RenderEmailTemplate(
emailTpl,
s.defaultLanguage, // fixMe: the recipient is unknown, should it be the defaultLocale?,
s.defaultLanguage, // fixMe: the defaultLocale is not set by default, shouldn't it be?,
s.emailTemplatePath,
s.translationPath,
msgENV,
)
if err != nil {
logger.Error().Err(err).Msg("building the message has failed")
return
}
msg.Sender = owner.GetDisplayName()
msg.Recipient = []string{e.RecipientMail}
s.send(ctx, []*channels.Message{msg})
}
@@ -0,0 +1,314 @@
package service
import (
"context"
"errors"
"fmt"
"net/url"
"path"
"strings"
"sync"
"sync/atomic"
ehsvc "github.com/qsfera/server/protogen/gen/qsfera/services/eventhistory/v0"
"go-micro.dev/v4/store"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
group "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/go-playground/validator/v10"
"go-micro.dev/v4/metadata"
"google.golang.org/protobuf/types/known/fieldmaskpb"
"github.com/qsfera/server/pkg/l10n"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/middleware"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/notifications/pkg/channels"
"github.com/qsfera/server/services/notifications/pkg/email"
"github.com/qsfera/server/services/settings/pkg/store/defaults"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
)
// validate is the package level validator instance
var validate *validator.Validate
func init() {
validate = validator.New()
}
// Service should be named `Runner`
type Service interface {
Run() error
Close()
}
// NewEventsNotifier provides a new eventsNotifier
func NewEventsNotifier(
events <-chan events.Event,
channel channels.Channel,
logger log.Logger,
gatewaySelector pool.Selectable[gateway.GatewayAPIClient],
valueService settingssvc.ValueService,
serviceAccountID, serviceAccountSecret, emailTemplatePath, defaultLanguage, qsferaURL, translationPath, emailSender string,
store store.Store,
historyClient ehsvc.EventHistoryService,
registeredEvents map[string]events.Unmarshaller) Service {
return eventsNotifier{
logger: logger,
channel: channel,
events: events,
gatewaySelector: gatewaySelector,
valueService: valueService,
serviceAccountID: serviceAccountID,
serviceAccountSecret: serviceAccountSecret,
emailTemplatePath: emailTemplatePath,
defaultLanguage: defaultLanguage,
defaultEmailSender: emailSender,
qsferaURL: qsferaURL,
translationPath: translationPath,
filter: newNotificationFilter(logger, valueService),
splitter: newIntervalSplitter(logger, valueService),
userEventStore: newUserEventStore(logger, store, historyClient),
registeredEvents: registeredEvents,
stopCh: make(chan struct{}, 1),
stopped: new(atomic.Bool),
}
}
type eventsNotifier struct {
logger log.Logger
channel channels.Channel
events <-chan events.Event
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
valueService settingssvc.ValueService
emailTemplatePath string
translationPath string
defaultLanguage string
defaultEmailSender string
qsferaURL string
serviceAccountID string
serviceAccountSecret string
filter *notificationFilter
splitter *intervalSplitter
userEventStore *userEventStore
registeredEvents map[string]events.Unmarshaller
stopCh chan struct{}
stopped *atomic.Bool
}
func (s eventsNotifier) Run() error {
var wg sync.WaitGroup
s.logger.Debug().
Msg("eventsNotifier started")
EventLoop:
for {
select {
case evt, ok := <-s.events:
if !ok {
break EventLoop
}
// TODO: needs to be replaced with a worker pool
wg.Go(func() {
switch e := evt.Event.(type) {
case events.SpaceShared:
s.handleSpaceShared(e, evt.ID)
case events.SpaceUnshared:
s.handleSpaceUnshared(e, evt.ID)
case events.SpaceMembershipExpired:
s.handleSpaceMembershipExpired(e, evt.ID)
case events.ShareCreated:
s.handleShareCreated(e, evt.ID)
case events.ShareExpired:
s.handleShareExpired(e, evt.ID)
case events.ScienceMeshInviteTokenGenerated:
s.handleScienceMeshInviteTokenGenerated(e)
case events.SendEmailsEvent:
s.sendGroupedEmailsJob(e, evt.ID)
}
})
if s.stopped.Load() {
break EventLoop
}
case <-s.stopCh:
s.logger.Debug().
Msg("eventsNotifier stopped")
break EventLoop
}
}
// wait until all the goroutines processing events have finished
wg.Wait()
return nil
}
func (s eventsNotifier) Close() {
if s.stopped.CompareAndSwap(false, true) {
close(s.stopCh)
}
}
func (s eventsNotifier) render(ctx context.Context, template email.MessageTemplate,
granteeFieldName string, fields map[string]string, granteeList []*user.User, sender string) ([]*channels.Message, error) {
// Render the Email Template for each user
messageList := make([]*channels.Message, len(granteeList))
for i, usr := range granteeList {
locale := l10n.MustGetUserLocale(ctx, usr.GetId().GetOpaqueId(), "", s.valueService)
fields[granteeFieldName] = usr.GetDisplayName()
rendered, err := email.RenderEmailTemplate(template, locale, s.defaultLanguage, s.emailTemplatePath, s.translationPath, fields)
if err != nil {
return nil, err
}
rendered.Sender = sender
rendered.Recipient = []string{usr.GetMail()}
messageList[i] = rendered
}
return messageList, nil
}
func (s eventsNotifier) send(ctx context.Context, emails []*channels.Message) {
for _, r := range emails {
err := s.channel.SendMessage(ctx, r)
if err != nil {
s.logger.Error().Err(err).Str("event", "SendEmail").Msg("failed to send a message")
}
}
}
func (s eventsNotifier) ensureGranteeList(ctx context.Context, executant, u *user.UserId, g *group.GroupId) []*user.User {
granteeList, err := s.getGranteeList(ctx, executant, u, g)
if err != nil {
s.logger.Error().Err(err).Str("event", "ensureGranteeList").Msg("Could not get grantee list")
return nil
} else if len(granteeList) == 0 {
return nil
}
return granteeList
}
func (s eventsNotifier) getGranteeList(ctx context.Context, executant, u *user.UserId, g *group.GroupId) ([]*user.User, error) {
switch {
case u != nil:
if s.disableEmails(ctx, u) {
return nil, nil
}
usr, err := s.getUser(ctx, u)
if err != nil {
return nil, err
}
if strings.TrimSpace(usr.GetMail()) == "" {
s.logger.Debug().Str("event", "getGranteeList").Msgf("User %s has no email, skipped", usr.GetUsername())
return nil, nil
}
return []*user.User{usr}, nil
case g != nil:
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return nil, err
}
res, err := gatewayClient.GetGroup(ctx, &group.GetGroupRequest{GroupId: g})
if err != nil {
return nil, err
}
if res.GetStatus().GetCode() != rpc.Code_CODE_OK {
return nil, errors.New("could not get group")
}
userList := make([]*user.User, 0, len(res.GetGroup().GetMembers()))
for _, userID := range res.GetGroup().GetMembers() {
// don't add the executant
if userID.GetOpaqueId() == executant.GetOpaqueId() {
continue
}
// don't add users who opted out
if s.disableEmails(ctx, userID) {
continue
}
usr, err := s.getUser(ctx, userID)
if err != nil {
return nil, err
}
if strings.TrimSpace(usr.GetMail()) == "" {
s.logger.Debug().Str("event", "getGranteeList").Msgf("User %s has no email, skipped", usr.GetUsername())
continue
}
userList = append(userList, usr)
}
return userList, nil
default:
return nil, errors.New("need at least one non-nil grantee")
}
}
func (s eventsNotifier) getUser(ctx context.Context, u *user.UserId) (*user.User, error) {
if u == nil {
return nil, errors.New("need at least one non-nil grantee")
}
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return nil, err
}
r, err := gatewayClient.GetUser(ctx, &user.GetUserRequest{UserId: u})
if err != nil {
return nil, err
}
if r.GetStatus().GetCode() != rpc.Code_CODE_OK {
return nil, fmt.Errorf("unexpected status code from gateway client: %d", r.GetStatus().GetCode())
}
return r.GetUser(), nil
}
func (s eventsNotifier) disableEmails(ctx context.Context, u *user.UserId) bool {
granteeCtx := metadata.Set(ctx, middleware.AccountID, u.OpaqueId)
if resp, err := s.valueService.GetValueByUniqueIdentifiers(granteeCtx,
&settingssvc.GetValueByUniqueIdentifiersRequest{
AccountUuid: u.OpaqueId,
SettingId: defaults.SettingUUIDProfileDisableNotifications,
},
); err == nil {
return resp.GetValue().GetValue().GetBoolValue()
}
return false
}
func (s eventsNotifier) getResourceInfo(ctx context.Context, resourceID *provider.ResourceId, fieldmask *fieldmaskpb.FieldMask) (*provider.ResourceInfo, error) {
// TODO: maybe cache this stat to reduce storage iops
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return nil, err
}
md, err := gatewayClient.Stat(ctx, &provider.StatRequest{
Ref: &provider.Reference{
ResourceId: resourceID,
},
FieldMask: fieldmask,
})
if err != nil {
return nil, err
}
if md.GetStatus().GetCode() != rpc.Code_CODE_OK {
return nil, fmt.Errorf("could not resource info: %s", md.Status.Message)
}
return md.GetInfo(), nil
}
// TODO: this function is a backport for go1.19 url.JoinPath, upon go bump, replace this
func urlJoinPath(base string, elements ...string) (string, error) {
u, err := url.Parse(base)
if err != nil {
return "", err
}
u.Path = path.Join(append([]string{u.Path}, elements...)...)
return u.String(), nil
}
@@ -0,0 +1,482 @@
package service_test
import (
"context"
"time"
settingsmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/settings/v0"
"github.com/opencloud-eu/reva/v2/pkg/store"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/shared"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
settingsmocks "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0/mocks"
"github.com/qsfera/server/services/graph/pkg/config/defaults"
"github.com/qsfera/server/services/notifications/pkg/channels"
"github.com/qsfera/server/services/notifications/pkg/service"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/utils"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
)
var _ = Describe("Notifications", func() {
var (
gatewayClient *cs3mocks.GatewayAPIClient
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
vs *settingsmocks.ValueService
sharer = &user.User{
Id: &user.UserId{
OpaqueId: "sharer",
},
Mail: "sharer@qsfera.eu",
DisplayName: "Dr. S. Harer",
}
sharee = &user.User{
Id: &user.UserId{
OpaqueId: "sharee",
},
Mail: "sharee@qsfera.eu",
DisplayName: "Eric Expireling",
}
resourceid = &provider.ResourceId{
StorageId: "storageid",
SpaceId: "spaceid",
OpaqueId: "itemid",
}
)
BeforeEach(func() {
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
gatewayClient = &cs3mocks.GatewayAPIClient{}
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return gatewayClient
},
)
gatewayClient.On("GetUser", mock.Anything, mock.Anything).Return(&user.GetUserResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}, User: sharer}, nil).Once()
gatewayClient.On("GetUser", mock.Anything, mock.Anything).Return(&user.GetUserResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}, User: sharee}, nil).Once()
gatewayClient.On("Authenticate", mock.Anything, mock.Anything).Return(&gateway.AuthenticateResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}, User: sharer}, nil)
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&provider.StatResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}, Info: &provider.ResourceInfo{Name: "secrets of the board", Space: &provider.StorageSpace{Name: "secret space"}}}, nil)
vs = &settingsmocks.ValueService{}
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(&settingssvc.GetValueResponse{
Value: &settingsmsg.ValueWithIdentifier{
Value: &settingsmsg.Value{
Value: &settingsmsg.Value_CollectionValue{
CollectionValue: &settingsmsg.CollectionValue{
Values: []*settingsmsg.CollectionOption{
{
Key: "mail",
Option: &settingsmsg.CollectionOption_BoolValue{BoolValue: true},
},
},
},
},
},
},
}, nil)
})
DescribeTable("Sending userEventIds",
func(tc testChannel, ev events.Event) {
cfg := defaults.FullDefaultConfig()
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
ch := make(chan events.Event)
evts := service.NewEventsNotifier(ch, tc, log.NewLogger(), gatewaySelector, vs, "",
"", "", "", "", "", "",
store.Create(), nil, nil)
go evts.Run()
ch <- ev
select {
case <-tc.done:
// finished
case <-time.Tick(3 * time.Second):
Fail("timeout waiting for notification")
}
},
Entry("Share Created", testChannel{
expectedReceipients: []string{sharee.GetMail()},
expectedSubject: "Dr. S. Harer shared 'secrets of the board' with you",
expectedTextBody: `Hello Eric Expireling
Dr. S. Harer has shared "secrets of the board" with you.
Click here to view it: files/shares/with-me
---
КуСфера - a safe home for all your data
https://qsfera.eu
`,
expectedSender: sharer.GetDisplayName(),
done: make(chan struct{}),
}, events.Event{
Event: events.ShareCreated{
Sharer: sharer.GetId(),
GranteeUserID: sharee.GetId(),
CTime: utils.TimeToTS(time.Date(2023, 4, 17, 16, 42, 0, 0, time.UTC)),
ItemID: resourceid,
},
}),
Entry("Share Expired", testChannel{
expectedReceipients: []string{sharee.GetMail()},
expectedSubject: "Share to 'secrets of the board' expired at 2023-04-17 16:42:00",
expectedTextBody: `Hello Eric Expireling,
Your share to secrets of the board has expired at 2023-04-17 16:42:00
Even though this share has been revoked you still might have access through other shares and/or space memberships.
---
КуСфера - a safe home for all your data
https://qsfera.eu
`,
expectedSender: sharer.GetDisplayName(),
done: make(chan struct{}),
}, events.Event{
Event: events.ShareExpired{
ShareOwner: sharer.GetId(),
GranteeUserID: sharee.GetId(),
ExpiredAt: time.Date(2023, 4, 17, 16, 42, 0, 0, time.UTC),
ItemID: resourceid,
},
}),
Entry("Added to Space", testChannel{
expectedReceipients: []string{sharee.GetMail()},
expectedSubject: "Dr. S. Harer invited you to join secret space",
expectedTextBody: `Hello Eric Expireling,
Dr. S. Harer has invited you to join "secret space".
Click here to view it: f/spaceid
---
КуСфера - a safe home for all your data
https://qsfera.eu
`,
expectedSender: sharer.GetDisplayName(),
done: make(chan struct{}),
}, events.Event{
Event: events.SpaceShared{
Executant: sharer.GetId(),
Creator: sharer.GetId(),
GranteeUserID: sharee.GetId(),
ID: &provider.StorageSpaceId{OpaqueId: "spaceid"},
},
}),
Entry("Removed from Space", testChannel{
expectedReceipients: []string{sharee.GetMail()},
expectedSubject: "Dr. S. Harer removed you from secret space",
expectedTextBody: `Hello Eric Expireling,
Dr. S. Harer has removed you from "secret space".
You might still have access through your other groups or direct membership.
Click here to check it: f/spaceid
---
КуСфера - a safe home for all your data
https://qsfera.eu
`,
expectedSender: sharer.GetDisplayName(),
done: make(chan struct{}),
}, events.Event{
Event: events.SpaceUnshared{
Executant: sharer.GetId(),
GranteeUserID: sharee.GetId(),
ID: &provider.StorageSpaceId{OpaqueId: "spaceid"},
},
}),
Entry("Space Expired", testChannel{
expectedReceipients: []string{sharee.GetMail()},
expectedSubject: "Membership of 'secret space' expired at 2023-04-17 16:42:00",
expectedTextBody: `Hello Eric Expireling,
Your membership of space secret space has expired at 2023-04-17 16:42:00
Even though this membership has expired you still might have access through other shares and/or space memberships
---
КуСфера - a safe home for all your data
https://qsfera.eu
`,
expectedSender: sharer.GetDisplayName(),
done: make(chan struct{}),
}, events.Event{
Event: events.SpaceMembershipExpired{
SpaceOwner: sharer.GetId(),
GranteeUserID: sharee.GetId(),
SpaceID: &provider.StorageSpaceId{OpaqueId: "spaceid"},
SpaceName: "secret space",
ExpiredAt: time.Date(2023, 4, 17, 16, 42, 0, 0, time.UTC),
},
}),
)
})
var _ = Describe("Notifications X-Site Scripting", func() {
var (
gatewayClient *cs3mocks.GatewayAPIClient
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
vs *settingsmocks.ValueService
sharer = &user.User{
Id: &user.UserId{
OpaqueId: "sharer",
},
Mail: "sharer@qsfera.eu",
DisplayName: "Dr. O'reilly",
}
sharee = &user.User{
Id: &user.UserId{
OpaqueId: "sharee",
},
Mail: "sharee@qsfera.eu",
DisplayName: "<script>alert('Eric Expireling');</script>",
}
resourceid = &provider.ResourceId{
StorageId: "storageid",
SpaceId: "spaceid",
OpaqueId: "itemid",
}
)
BeforeEach(func() {
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
gatewayClient = &cs3mocks.GatewayAPIClient{}
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return gatewayClient
},
)
gatewayClient.On("GetUser", mock.Anything, mock.Anything).Return(&user.GetUserResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}, User: sharer}, nil).Once()
gatewayClient.On("GetUser", mock.Anything, mock.Anything).Return(&user.GetUserResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}, User: sharee}, nil).Once()
gatewayClient.On("Authenticate", mock.Anything, mock.Anything).Return(&gateway.AuthenticateResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}, User: sharer}, nil)
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(&provider.StatResponse{
Status: &rpc.Status{Code: rpc.Code_CODE_OK},
Info: &provider.ResourceInfo{
Name: "<script>alert('secrets of the board');</script>",
Space: &provider.StorageSpace{Name: "<script>alert('secret space');</script>"}},
}, nil)
vs = &settingsmocks.ValueService{}
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(&settingssvc.GetValueResponse{
Value: &settingsmsg.ValueWithIdentifier{
Value: &settingsmsg.Value{
Value: &settingsmsg.Value_CollectionValue{
CollectionValue: &settingsmsg.CollectionValue{
Values: []*settingsmsg.CollectionOption{
{
Key: "mail",
Option: &settingsmsg.CollectionOption_BoolValue{BoolValue: true},
},
},
},
},
},
},
}, nil)
})
DescribeTable("Sending userEventIds",
func(tc testChannel, ev events.Event) {
cfg := defaults.FullDefaultConfig()
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
ch := make(chan events.Event)
evts := service.NewEventsNotifier(ch, tc, log.NewLogger(), gatewaySelector, vs, "",
"", "", "", "", "", "",
store.Create(), nil, nil)
go evts.Run()
ch <- ev
select {
case <-tc.done:
// finished
case <-time.Tick(3 * time.Second):
Fail("timeout waiting for notification")
}
},
Entry("Share Created", testChannel{
expectedReceipients: []string{sharee.GetMail()},
expectedSubject: "Dr. O'reilly shared '<script>alert('secrets of the board');</script>' with you",
expectedTextBody: `Hello <script>alert('Eric Expireling');</script>
Dr. O'reilly has shared "<script>alert('secrets of the board');</script>" with you.
Click here to view it: files/shares/with-me
---
КуСфера - a safe home for all your data
https://qsfera.eu
`,
expectedHTMLBody: `<!DOCTYPE html>
<html>
<body>
<table cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td>
<table cellspacing="0" cellpadding="0" border="0" width="600px">
<tr>
<td width="20px">&nbsp;</td>
<td style="font-weight:normal; font-size:0.8em; line-height:1.2em; font-family:verdana,'arial',sans;">
Hello &lt;script&gt;alert(&#39;Eric Expireling&#39;);&lt;/script&gt;
<br><br>
Dr. O&#39;reilly has shared "&lt;script&gt;alert(&#39;secrets of the board&#39;);&lt;/script&gt;" with you.
<br><br>
Click here to view it: <a href="files/shares/with-me">files/shares/with-me</a>
</td>
</tr>
<tr>
<td colspan="2">&nbsp;</td>
</tr>
<tr>
<td width="20px">&nbsp;</td>
<td style="font-weight:normal; font-size:0.8em; line-height:1.2em; font-family:verdana,'arial',sans;">
<footer>
<br>
<br>
--- <br>
КуСфера - a safe home for all your data<br>
<a href="https://qsfera.eu">https://qsfera.eu</a>
</footer>
</td>
</tr>
<tr>
<td colspan="2">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`,
expectedSender: sharer.GetDisplayName(),
done: make(chan struct{}),
}, events.Event{
Event: events.ShareCreated{
Sharer: sharer.GetId(),
GranteeUserID: sharee.GetId(),
CTime: utils.TimeToTS(time.Date(2023, 4, 17, 16, 42, 0, 0, time.UTC)),
ItemID: resourceid,
},
}),
Entry("Added to Space", testChannel{
expectedReceipients: []string{sharee.GetMail()},
expectedSubject: "Dr. O'reilly invited you to join <script>alert('secret space');</script>",
expectedTextBody: `Hello <script>alert('Eric Expireling');</script>,
Dr. O'reilly has invited you to join "<script>alert('secret space');</script>".
Click here to view it: f/spaceid
---
КуСфера - a safe home for all your data
https://qsfera.eu
`,
expectedSender: sharer.GetDisplayName(),
expectedHTMLBody: `<!DOCTYPE html>
<html>
<body>
<table cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td>
<table cellspacing="0" cellpadding="0" border="0" width="600px">
<tr>
<td width="20px">&nbsp;</td>
<td style="font-weight:normal; font-size:0.8em; line-height:1.2em; font-family:verdana,'arial',sans;">
Hello &lt;script&gt;alert(&#39;Eric Expireling&#39;);&lt;/script&gt;,
<br><br>
Dr. O&#39;reilly has invited you to join "&lt;script&gt;alert(&#39;secret space&#39;);&lt;/script&gt;".
<br><br>
Click here to view it: <a href="f/spaceid">f/spaceid</a>
</td>
</tr>
<tr>
<td colspan="2">&nbsp;</td>
</tr>
<tr>
<td width="20px">&nbsp;</td>
<td style="font-weight:normal; font-size:0.8em; line-height:1.2em; font-family:verdana,'arial',sans;">
<footer>
<br>
<br>
--- <br>
КуСфера - a safe home for all your data<br>
<a href="https://qsfera.eu">https://qsfera.eu</a>
</footer>
</td>
</tr>
<tr>
<td colspan="2">&nbsp;</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`,
done: make(chan struct{}),
}, events.Event{
Event: events.SpaceShared{
Executant: sharer.GetId(),
Creator: sharer.GetId(),
GranteeUserID: sharee.GetId(),
ID: &provider.StorageSpaceId{OpaqueId: "spaceid"},
},
}),
)
})
// NOTE: This is explictitly not testing the message itself. Should we?
type testChannel struct {
expectedReceipients []string
expectedSubject string
expectedTextBody string
expectedHTMLBody string
expectedSender string
done chan struct{}
}
func (tc testChannel) SendMessage(ctx context.Context, m *channels.Message) error {
defer GinkgoRecover()
Expect(tc.expectedReceipients).To(Equal(m.Recipient))
Expect(tc.expectedSubject).To(Equal(m.Subject))
Expect(tc.expectedTextBody).To(Equal(m.TextBody))
Expect(tc.expectedSender).To(Equal(m.Sender))
if tc.expectedHTMLBody != "" {
Expect(tc.expectedHTMLBody).To(Equal(m.HTMLBody))
}
tc.done <- struct{}{}
return nil
}
@@ -0,0 +1,163 @@
package service
import (
"context"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/qsfera/server/services/notifications/pkg/email"
"github.com/qsfera/server/services/settings/pkg/store/defaults"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"github.com/rs/zerolog"
"google.golang.org/protobuf/types/known/fieldmaskpb"
)
func (s eventsNotifier) handleShareCreated(e events.ShareCreated, eventId string) {
logger := s.logger.With().
Str("event", "ShareCreated").
Str("itemid", e.ItemID.OpaqueId).
Logger()
owner, shareFolder, shareLink, ctx, err := s.prepareShareCreated(logger, e)
if err != nil {
logger.Error().Err(err).Msg("could not prepare vars for email")
return
}
granteeList := s.ensureGranteeList(ctx, owner.GetId(), e.GranteeUserID, e.GranteeGroupID)
filteredGrantees := s.filter.execute(ctx, granteeList, defaults.SettingUUIDProfileEventShareCreated)
recipientsInstant, recipientsDaily, recipientsInstantWeekly := s.splitter.execute(ctx, filteredGrantees)
recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalDaily, eventId, recipientsDaily)...)
recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalWeekly, eventId, recipientsInstantWeekly)...)
if recipientsInstant == nil {
return
}
sharerDisplayName := owner.GetDisplayName()
emails, err := s.render(ctx, email.ShareCreated,
"ShareGrantee",
map[string]string{
"ShareSharer": sharerDisplayName,
"ShareFolder": shareFolder,
"ShareLink": shareLink,
}, recipientsInstant, sharerDisplayName)
if err != nil {
logger.Error().Err(err).Msg("could not get render the email")
return
}
s.send(ctx, emails)
}
func (s eventsNotifier) prepareShareCreated(logger zerolog.Logger, e events.ShareCreated) (owner *user.User, shareFolder, shareLink string, ctx context.Context, err error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
logger.Error().Err(err).Msg("could not select next gateway client")
return owner, shareFolder, shareLink, ctx, err
}
ctx, err = utils.GetServiceUserContextWithContext(context.Background(), gatewayClient, s.serviceAccountID, s.serviceAccountSecret)
if err != nil {
logger.Error().Err(err).Msg("could not get service user context")
return owner, shareFolder, shareLink, ctx, err
}
resourceInfo, err := s.getResourceInfo(ctx, e.ItemID, &fieldmaskpb.FieldMask{Paths: []string{"name"}})
if err != nil {
logger.Error().
Err(err).
Msg("could not stat resource")
return owner, shareFolder, shareLink, ctx, err
}
shareFolder = resourceInfo.Name
shareLink, err = urlJoinPath(s.qsferaURL, "files/shares/with-me")
if err != nil {
logger.Error().
Err(err).
Msg("could not create link to the share")
return owner, shareFolder, shareLink, ctx, err
}
owner, err = utils.GetUserNoGroups(ctx, e.Sharer, gatewayClient)
if err != nil {
logger.Error().
Err(err).
Msg("could not get user")
return owner, shareFolder, shareLink, ctx, err
}
return owner, shareFolder, shareLink, ctx, err
}
func (s eventsNotifier) handleShareExpired(e events.ShareExpired, eventId string) {
logger := s.logger.With().
Str("event", "ShareExpired").
Str("itemid", e.ItemID.GetOpaqueId()).
Logger()
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
logger.Error().Err(err).Msg("could not select next gateway client")
return
}
shareFolder, ctx, err := s.prepareShareExpired(logger, e)
if err != nil {
logger.Error().Err(err).Msg("could not prepare vars for email")
return
}
owner, err := utils.GetUserNoGroups(ctx, e.ShareOwner, gatewayClient)
if err != nil {
logger.Error().Err(err).Msg("Could not get user")
return
}
granteeList := s.ensureGranteeList(ctx, owner.GetId(), e.GranteeUserID, e.GranteeGroupID)
filteredGrantees := s.filter.execute(ctx, granteeList, defaults.SettingUUIDProfileEventShareExpired)
recipientsInstant, recipientsDaily, recipientsInstantWeekly := s.splitter.execute(ctx, filteredGrantees)
recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalDaily, eventId, recipientsDaily)...)
recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalWeekly, eventId, recipientsInstantWeekly)...)
if recipientsInstant == nil {
return
}
emails, err := s.render(ctx, email.ShareExpired,
"ShareGrantee",
map[string]string{
"ShareFolder": shareFolder,
"ExpiredAt": e.ExpiredAt.Format("2006-01-02 15:04:05"),
}, recipientsInstant, owner.GetDisplayName())
if err != nil {
logger.Error().Err(err).Msg("could not get render the email")
return
}
s.send(ctx, emails)
}
func (s eventsNotifier) prepareShareExpired(logger zerolog.Logger, e events.ShareExpired) (shareFolder string, ctx context.Context, err error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
logger.Error().Err(err).Msg("could not select next gateway client")
return shareFolder, ctx, err
}
ctx, err = utils.GetServiceUserContextWithContext(context.Background(), gatewayClient, s.serviceAccountID, s.serviceAccountSecret)
if err != nil {
logger.Error().Err(err).Msg("could not get service user context")
return shareFolder, ctx, err
}
resourceInfo, err := s.getResourceInfo(ctx, e.ItemID, &fieldmaskpb.FieldMask{Paths: []string{"name"}})
if err != nil {
logger.Error().
Err(err).
Msg("could not stat resource")
return shareFolder, ctx, err
}
shareFolder = resourceInfo.GetName()
return shareFolder, ctx, err
}
@@ -0,0 +1,234 @@
package service
import (
"context"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/qsfera/server/services/notifications/pkg/email"
"github.com/qsfera/server/services/settings/pkg/store/defaults"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"github.com/rs/zerolog"
)
func (s eventsNotifier) handleSpaceShared(e events.SpaceShared, eventId string) {
logger := s.logger.With().
Str("event", "SpaceShared").
Str("itemid", e.ID.OpaqueId).
Logger()
executant, spaceName, shareLink, ctx, err := s.prepareSpaceShared(logger, e)
if err != nil {
logger.Error().Err(err).Msg("could not prepare vars for email")
return
}
granteeList := s.ensureGranteeList(ctx, executant.GetId(), e.GranteeUserID, e.GranteeGroupID)
filteredGrantees := s.filter.execute(ctx, granteeList, defaults.SettingUUIDProfileEventSpaceShared)
recipientsInstant, recipientsDaily, recipientsInstantWeekly := s.splitter.execute(ctx, filteredGrantees)
recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalDaily, eventId, recipientsDaily)...)
recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalWeekly, eventId, recipientsInstantWeekly)...)
if recipientsInstant == nil {
return
}
sharerDisplayName := executant.GetDisplayName()
emails, err := s.render(ctx, email.SharedSpace,
"SpaceGrantee",
map[string]string{
"SpaceSharer": sharerDisplayName,
"SpaceName": spaceName,
"ShareLink": shareLink,
}, recipientsInstant, sharerDisplayName)
if err != nil {
logger.Error().Err(err).Msg("could not get render the email")
return
}
s.send(ctx, emails)
}
func (s eventsNotifier) prepareSpaceShared(logger zerolog.Logger, e events.SpaceShared) (executant *user.User, spaceName, shareLink string, ctx context.Context, err error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
logger.Error().Err(err).Msg("could not select next gateway client")
return executant, spaceName, shareLink, ctx, err
}
ctx, err = utils.GetServiceUserContextWithContext(context.Background(), gatewayClient, s.serviceAccountID, s.serviceAccountSecret)
if err != nil {
logger.Error().Err(err).Msg("could not get service user context")
return executant, spaceName, shareLink, ctx, err
}
executant, err = utils.GetUserNoGroups(ctx, e.Executant, gatewayClient)
if err != nil {
logger.Error().
Err(err).
Msg("could not get user")
return executant, spaceName, shareLink, ctx, err
}
resourceID, err := storagespace.ParseID(e.ID.OpaqueId)
if err != nil {
logger.Error().
Err(err).
Msg("could not parse resourceid from ItemID ")
return executant, spaceName, shareLink, ctx, err
}
resourceInfo, err := s.getResourceInfo(ctx, &resourceID, nil)
if err != nil {
logger.Error().
Err(err).
Msg("could not get space info")
return executant, spaceName, shareLink, ctx, err
}
spaceName = resourceInfo.GetSpace().GetName()
shareLink, err = urlJoinPath(s.qsferaURL, "f", e.ID.OpaqueId)
if err != nil {
logger.Error().
Err(err).
Msg("could not create link to the share")
return executant, spaceName, shareLink, ctx, err
}
return executant, spaceName, shareLink, ctx, err
}
func (s eventsNotifier) handleSpaceUnshared(e events.SpaceUnshared, eventId string) {
logger := s.logger.With().
Str("event", "SpaceUnshared").
Str("itemid", e.ID.OpaqueId).
Logger()
executant, spaceName, shareLink, ctx, err := s.prepareSpaceUnshared(logger, e)
if err != nil {
logger.Error().Err(err).Msg("could not prepare vars for email")
return
}
granteeList := s.ensureGranteeList(ctx, executant.GetId(), e.GranteeUserID, e.GranteeGroupID)
filteredGrantees := s.filter.execute(ctx, granteeList, defaults.SettingUUIDProfileEventSpaceUnshared)
recipientsInstant, recipientsDaily, recipientsInstantWeekly := s.splitter.execute(ctx, filteredGrantees)
recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalDaily, eventId, recipientsDaily)...)
recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalWeekly, eventId, recipientsInstantWeekly)...)
if recipientsInstant == nil {
return
}
sharerDisplayName := executant.GetDisplayName()
emails, err := s.render(ctx, email.UnsharedSpace,
"SpaceGrantee",
map[string]string{
"SpaceSharer": sharerDisplayName,
"SpaceName": spaceName,
"ShareLink": shareLink,
}, recipientsInstant, sharerDisplayName)
if err != nil {
logger.Error().Err(err).Msg("Could not get render the email")
return
}
s.send(ctx, emails)
}
func (s eventsNotifier) prepareSpaceUnshared(logger zerolog.Logger, e events.SpaceUnshared) (executant *user.User, spaceName, shareLink string, ctx context.Context, err error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
logger.Error().Err(err).Msg("could not select next gateway client")
return executant, spaceName, shareLink, ctx, err
}
ctx, err = utils.GetServiceUserContextWithContext(context.Background(), gatewayClient, s.serviceAccountID, s.serviceAccountSecret)
if err != nil {
logger.Error().Err(err).Msg("could not get service user context")
return executant, spaceName, shareLink, ctx, err
}
executant, err = utils.GetUserNoGroups(ctx, e.Executant, gatewayClient)
if err != nil {
logger.Error().
Err(err).
Msg("could not get user")
return executant, spaceName, shareLink, ctx, err
}
resourceID, err := storagespace.ParseID(e.ID.OpaqueId)
if err != nil {
logger.Error().
Err(err).
Msg("could not parse resourceid from ItemID ")
return executant, spaceName, shareLink, ctx, err
}
resourceInfo, err := s.getResourceInfo(ctx, &resourceID, nil)
if err != nil {
logger.Error().
Err(err).
Msg("could not get space info")
return executant, spaceName, shareLink, ctx, err
}
spaceName = resourceInfo.GetSpace().GetName()
shareLink, err = urlJoinPath(s.qsferaURL, "f", e.ID.OpaqueId)
if err != nil {
logger.Error().
Err(err).
Msg("could not create link to the share")
return executant, spaceName, shareLink, ctx, err
}
return executant, spaceName, shareLink, ctx, err
}
func (s eventsNotifier) handleSpaceMembershipExpired(e events.SpaceMembershipExpired, eventId string) {
logger := s.logger.With().
Str("event", "SpaceMembershipExpired").
Str("itemid", e.SpaceID.GetOpaqueId()).
Logger()
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
logger.Error().Err(err).Msg("could not select next gateway client")
return
}
ctx, err := utils.GetServiceUserContext(s.serviceAccountID, gatewayClient, s.serviceAccountSecret)
if err != nil {
logger.Error().Err(err).Msg("Could not impersonate sharer")
return
}
owner, err := utils.GetUserNoGroups(ctx, e.SpaceOwner, gatewayClient)
if err != nil {
logger.Error().
Err(err).
Msg("could not get user")
return
}
granteeList := s.ensureGranteeList(ctx, owner.GetId(), e.GranteeUserID, e.GranteeGroupID)
if granteeList == nil {
return
}
filteredGrantees := s.filter.execute(ctx, granteeList, defaults.SettingUUIDProfileEventSpaceMembershipExpired)
recipientsInstant, recipientsDaily, recipientsInstantWeekly := s.splitter.execute(ctx, filteredGrantees)
recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalDaily, eventId, recipientsDaily)...)
recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalWeekly, eventId, recipientsInstantWeekly)...)
if recipientsInstant == nil {
return
}
emails, err := s.render(ctx, email.MembershipExpired,
"SpaceGrantee",
map[string]string{
"SpaceName": e.SpaceName,
"ExpiredAt": e.ExpiredAt.Format("2006-01-02 15:04:05"),
}, recipientsInstant, owner.GetDisplayName())
if err != nil {
logger.Error().Err(err).Msg("could not get render the email")
return
}
s.send(ctx, emails)
}
@@ -0,0 +1,61 @@
package service
import (
"context"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/middleware"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/settings/pkg/store/defaults"
"github.com/pkg/errors"
micrometadata "go-micro.dev/v4/metadata"
)
type intervalSplitter struct {
log log.Logger
valueClient settingssvc.ValueService
}
func newIntervalSplitter(l log.Logger, vc settingssvc.ValueService) *intervalSplitter {
return &intervalSplitter{log: l, valueClient: vc}
}
// execute splits users into 3 lists depending on their email sending interval settings
func (s intervalSplitter) execute(ctx context.Context, users []*user.User) (instant, daily, weekly []*user.User) {
for _, u := range users {
userId := u.GetId().GetOpaqueId()
interval, err := getEmailSendingInterval(ctx, s.valueClient, userId)
if err != nil {
s.log.Error().Err(err).Str("userId", userId).Msg("cannot get user email sending interval")
instant = append(instant, u)
} else if interval == "instant" {
instant = append(instant, u)
} else if interval == _intervalDaily {
daily = append(daily, u)
} else if interval == _intervalWeekly {
weekly = append(weekly, u)
}
}
return
}
func getEmailSendingInterval(ctx context.Context, vc settingssvc.ValueService, userId string) (string, error) {
resp, err := vc.GetValueByUniqueIdentifiers(
micrometadata.Set(ctx, middleware.AccountID, userId),
&settingssvc.GetValueByUniqueIdentifiersRequest{
AccountUuid: userId,
SettingId: defaults.SettingUUIDProfileEmailSendingInterval,
},
)
if err != nil {
return "", err
}
val := resp.GetValue().GetValue().GetStringValue()
if val == "" {
return "", errors.New("email sending interval is empty")
}
return val, nil
}
@@ -0,0 +1,146 @@
package service
import (
"context"
"strings"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/pkg/errors"
"github.com/stretchr/testify/mock"
"go-micro.dev/v4/client"
"github.com/qsfera/server/pkg/log"
settingsmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/settings/v0"
settings "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
settingsmocks "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0/mocks"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("NotificationFilter", func() {
var (
testLogger = log.NewLogger()
vs *settingsmocks.ValueService
s intervalSplitter
)
BeforeEach(func() {
vs = &settingsmocks.ValueService{}
s = intervalSplitter{
log: testLogger,
valueClient: vs,
}
})
It("handles connection errors", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(nil, errors.New("no connection to ValueService"))
instant, daily, weekly := s.execute(context.TODO(), newUsers("foo"))
Expect(instant).To(Equal(newUsers("foo")))
Expect(daily).To(BeEmpty())
Expect(weekly).To(BeEmpty())
})
It("handles no setting in ValueService response", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(&settings.GetValueResponse{}, nil)
instant, daily, weekly := s.execute(context.TODO(), newUsers("foo"))
Expect(instant).To(Equal(newUsers("foo")))
Expect(daily).To(BeEmpty())
Expect(weekly).To(BeEmpty())
})
It("handles nil response", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(nil, nil)
instant, daily, weekly := s.execute(context.TODO(), newUsers("foo"))
Expect(instant).To(Equal(newUsers("foo")))
Expect(daily).To(BeEmpty())
Expect(weekly).To(BeEmpty())
})
It("handles nil input user", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(nil, nil)
instant, daily, weekly := s.execute(context.TODO(), nil)
Expect(instant).To(BeEmpty())
Expect(daily).To(BeEmpty())
Expect(weekly).To(BeEmpty())
})
It("handles never interval", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(newGetValueResponseStringValue("never"), nil)
instant, daily, weekly := s.execute(context.TODO(), newUsers("foo"))
Expect(instant).To(BeEmpty())
Expect(daily).To(BeEmpty())
Expect(weekly).To(BeEmpty())
})
It("handles instant interval", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(newGetValueResponseStringValue("instant"), nil)
instant, daily, weekly := s.execute(context.TODO(), newUsers("foo"))
Expect(instant).To(Equal(newUsers("foo")))
Expect(daily).To(BeEmpty())
Expect(weekly).To(BeEmpty())
})
It("handles daily interval", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(newGetValueResponseStringValue("daily"), nil)
instant, daily, weekly := s.execute(context.TODO(), newUsers("foo"))
Expect(instant).To(BeEmpty())
Expect(daily).To(Equal(newUsers("foo")))
Expect(weekly).To(BeEmpty())
})
It("handles weekly interval", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(newGetValueResponseStringValue("weekly"), nil)
instant, daily, weekly := s.execute(context.TODO(), newUsers("foo"))
Expect(instant).To(BeEmpty())
Expect(daily).To(BeEmpty())
Expect(weekly).To(Equal(newUsers("foo")))
})
It("handles multiple users and intervals", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(func(ctx context.Context, req *settings.GetValueByUniqueIdentifiersRequest, opts ...client.CallOption) *settings.GetValueResponse {
if strings.Contains(req.AccountUuid, "never") {
return newGetValueResponseStringValue("never")
} else if strings.Contains(req.AccountUuid, "instant") {
return newGetValueResponseStringValue("instant")
} else if strings.Contains(req.AccountUuid, "daily") {
return newGetValueResponseStringValue("daily")
} else if strings.Contains(req.AccountUuid, "weekly") {
return newGetValueResponseStringValue("weekly")
}
return nil
}, nil)
instant, daily, weekly := s.execute(context.TODO(), newUsers("never1", "instant1", "daily1", "weekly1", "never2", "instant2", "daily2", "weekly2"))
Expect(instant).To(Equal(newUsers("instant1", "instant2")))
Expect(daily).To(Equal(newUsers("daily1", "daily2")))
Expect(weekly).To(Equal(newUsers("weekly1", "weekly2")))
})
})
func newGetValueResponseStringValue(strVal string) *settings.GetValueResponse {
return &settings.GetValueResponse{Value: &settingsmsg.ValueWithIdentifier{
Value: &settingsmsg.Value{
Value: &settingsmsg.Value_StringValue{
StringValue: strVal,
},
},
}}
}
func newUsers(ids ...string) []*user.User {
var users []*user.User
for _, s := range ids {
users = append(users, &user.User{Id: &user.UserId{OpaqueId: s}})
}
return users
}