diff --git a/changelog/unreleased/expiration-notifications.md b/changelog/unreleased/expiration-notifications.md new file mode 100644 index 0000000000..06b16c0808 --- /dev/null +++ b/changelog/unreleased/expiration-notifications.md @@ -0,0 +1,5 @@ +Enhancement: Expiration Notifications + +Send emails to the user informing that a share or a space membership expires. + +https://github.com/owncloud/ocis/pull/5330 diff --git a/go.mod b/go.mod index 7518b1b1eb..025589d17d 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/blevesearch/bleve/v2 v2.3.5 github.com/coreos/go-oidc/v3 v3.4.0 github.com/cs3org/go-cs3apis v0.0.0-20221012090518-ef2996678965 - github.com/cs3org/reva/v2 v2.12.1-0.20230110081316-da324d280315 + github.com/cs3org/reva/v2 v2.12.1-0.20230112100946-f8c784168ef7 github.com/disintegration/imaging v1.6.2 github.com/ggwhite/go-masker v1.0.9 github.com/go-chi/chi/v5 v5.0.7 diff --git a/go.sum b/go.sum index 1d9711e038..e05558aecc 100644 --- a/go.sum +++ b/go.sum @@ -344,8 +344,8 @@ github.com/crewjam/saml v0.4.9 h1:X2jDv4dv3IvfT9t+RhADavzNFAcq3fVxzTCIH3G605U= github.com/crewjam/saml v0.4.9/go.mod h1:9Zh6dWPtB3MSzTRt8fIFH60Z351QQ+s7hCU3J/tTlA4= github.com/cs3org/go-cs3apis v0.0.0-20221012090518-ef2996678965 h1:y4n2j68LLnvac+zw/al8MfPgO5aQiIwLmHM/JzYN8AM= github.com/cs3org/go-cs3apis v0.0.0-20221012090518-ef2996678965/go.mod h1:UXha4TguuB52H14EMoSsCqDj7k8a/t7g4gVP+bgY5LY= -github.com/cs3org/reva/v2 v2.12.1-0.20230110081316-da324d280315 h1:pfOiXbiYW9ZPwpLOxtzSOuocanSoplhi8NE4KbQxhbk= -github.com/cs3org/reva/v2 v2.12.1-0.20230110081316-da324d280315/go.mod h1:GpocVB1w6yxeSr1VBsO9jztmt1SyNC4lCwudLwDzxHQ= +github.com/cs3org/reva/v2 v2.12.1-0.20230112100946-f8c784168ef7 h1:xULPUJdydKJbZ8z0MD4zkOqnz2oE+SXKw+3GzSYr+yY= +github.com/cs3org/reva/v2 v2.12.1-0.20230112100946-f8c784168ef7/go.mod h1:GpocVB1w6yxeSr1VBsO9jztmt1SyNC4lCwudLwDzxHQ= github.com/cubewise-code/go-mime v0.0.0-20200519001935-8c5762b177d8 h1:Z9lwXumT5ACSmJ7WGnFl+OMLLjpz5uR2fyz7dC255FI= github.com/cubewise-code/go-mime v0.0.0-20200519001935-8c5762b177d8/go.mod h1:4abs/jPXcmJzYoYGF91JF9Uq9s/KL5n1jvFDix8KcqY= github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4= @@ -873,6 +873,8 @@ github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02 github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.1.0 h1:eyi1Ad2aNJMW95zcSbmGg7Cg6cq3ADwLpMAP96d8rF0= github.com/klauspost/cpuid/v2 v2.1.0/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/kobergj/reva/v2 v2.0.0-20230112095039-1aba27929ce4 h1:jvN+oN9vHYXhrYv76OJ6bhXx7ldDpErblPZSwu0UzmA= +github.com/kobergj/reva/v2 v2.0.0-20230112095039-1aba27929ce4/go.mod h1:GpocVB1w6yxeSr1VBsO9jztmt1SyNC4lCwudLwDzxHQ= github.com/kolo/xmlrpc v0.0.0-20200310150728-e0350524596b/go.mod h1:o03bZfuBwAXHetKXuInt4S7omeXUu62/A845kiycsSQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= diff --git a/services/notifications/pkg/command/server.go b/services/notifications/pkg/command/server.go index 93998f7c0b..d5eff83224 100644 --- a/services/notifications/pkg/command/server.go +++ b/services/notifications/pkg/command/server.go @@ -35,8 +35,10 @@ func Server(cfg *config.Config) *cli.Command { // evs defines a list of events to subscribe to evs := []events.Unmarshaller{ events.ShareCreated{}, + events.ShareExpired{}, events.SpaceShared{}, events.SpaceUnshared{}, + events.SpaceMembershipExpired{}, } evtsCfg := cfg.Notifications.Events diff --git a/services/notifications/pkg/email/templates/shares/shareExpired.email.body.tmpl b/services/notifications/pkg/email/templates/shares/shareExpired.email.body.tmpl new file mode 100644 index 0000000000..322f28e520 --- /dev/null +++ b/services/notifications/pkg/email/templates/shares/shareExpired.email.body.tmpl @@ -0,0 +1,18 @@ +Hello {{ .ShareGrantee }}, + +Your share to {{ .ShareFolder }} has expired at {{ .ExpiredAt }} + +Even though this share has been revoked you still might have access through other shares and/or space memberships + +---------------------------------------------------------- + +Hallo {{ .ShareGrantee }}, + +Deine Freigabe zu {{ .ShareFolder }} ist am {{ .ExpiredAt }} abgelaufen. + +Obwohl diese Freigabe nicht mehr zur Verfügung steht, könntest du immer noch Zugriff über andere Freigaben und/oder Space Mitgliedschaften haben. + + +--- +ownCloud - Store. Share. Work. +https://owncloud.com diff --git a/services/notifications/pkg/email/templates/shares/shareExpired.email.subject.tmpl b/services/notifications/pkg/email/templates/shares/shareExpired.email.subject.tmpl new file mode 100644 index 0000000000..e9593dcd5a --- /dev/null +++ b/services/notifications/pkg/email/templates/shares/shareExpired.email.subject.tmpl @@ -0,0 +1 @@ +Share to '{{ .ShareFolder }}' expired at {{ .ExpiredAt }} \ No newline at end of file diff --git a/services/notifications/pkg/email/templates/spaces/membershipExpired.email.body.tmpl b/services/notifications/pkg/email/templates/spaces/membershipExpired.email.body.tmpl new file mode 100644 index 0000000000..95ba3897ba --- /dev/null +++ b/services/notifications/pkg/email/templates/spaces/membershipExpired.email.body.tmpl @@ -0,0 +1,18 @@ +Hello {{ .SpaceGrantee }}, + +Your membership of space {{ .SpaceName }} has expired at {{ .ExpiredAt }} + +Even though this membership has expired you still might have access through other shares and/or space memberships + +---------------------------------------------------------- + +Hallo {{ .SpaceGrantee }}, + +Deine Mitgliedschaft zu dem Space {{ .SpaceName }} ist am {{ .ExpiredAt }} abgelaufen. + +Obwohl diese Mitgliedschaft nicht mehr zur Verfügung steht, könntest du immer noch Zugriff über andere Freigaben und/oder Space Mitgliedschaften haben. + + +--- +ownCloud - Store. Share. Work. +https://owncloud.com diff --git a/services/notifications/pkg/email/templates/spaces/membershipExpired.email.subject.tmpl b/services/notifications/pkg/email/templates/spaces/membershipExpired.email.subject.tmpl new file mode 100644 index 0000000000..9365818388 --- /dev/null +++ b/services/notifications/pkg/email/templates/spaces/membershipExpired.email.subject.tmpl @@ -0,0 +1 @@ +Membership of '{{ .SpaceName }}' expired at {{ .ExpiredAt }} \ No newline at end of file diff --git a/services/notifications/pkg/email/templates/spaces/unsharedSpace.email.subject.tmpl b/services/notifications/pkg/email/templates/spaces/unsharedSpace.email.subject.tmpl index b4a526abde..c6628dfb3d 100644 --- a/services/notifications/pkg/email/templates/spaces/unsharedSpace.email.subject.tmpl +++ b/services/notifications/pkg/email/templates/spaces/unsharedSpace.email.subject.tmpl @@ -1 +1 @@ -{{ .SpaceSharer }} removed you from {{ .SpaceName }} +{{ .SpaceSharer }} removed you from {{ .SpaceName }} \ No newline at end of file diff --git a/services/notifications/pkg/service/notification_suite_test.go b/services/notifications/pkg/service/notification_suite_test.go new file mode 100644 index 0000000000..5735c73ff4 --- /dev/null +++ b/services/notifications/pkg/service/notification_suite_test.go @@ -0,0 +1,13 @@ +package service_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestNotifications(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Notification Suite") +} diff --git a/services/notifications/pkg/service/service.go b/services/notifications/pkg/service/service.go index b9a10734ca..676f3dfa06 100644 --- a/services/notifications/pkg/service/service.go +++ b/services/notifications/pkg/service/service.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "fmt" "net/url" "os" @@ -10,19 +11,18 @@ import ( "syscall" gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" - groupv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1" - userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" - rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" - providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" - revactx "github.com/cs3org/reva/v2/pkg/ctx" + 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/cs3org/reva/v2/pkg/events" "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/services/notifications/pkg/channels" "github.com/owncloud/ocis/v2/services/notifications/pkg/email" - "google.golang.org/grpc/metadata" "google.golang.org/protobuf/types/known/fieldmaskpb" ) +// Service should be named `Runner` type Service interface { Run() error } @@ -70,8 +70,12 @@ func (s eventsNotifier) Run() error { s.handleSpaceShared(e) case events.SpaceUnshared: s.handleSpaceUnshared(e) + case events.SpaceMembershipExpired: + s.handleSpaceMembershipExpired(e) case events.ShareCreated: s.handleShareCreated(e) + case events.ShareExpired: + s.handleShareExpired(e) } }() case <-s.signals: @@ -82,138 +86,80 @@ func (s eventsNotifier) Run() error { } } -func (s eventsNotifier) handleShareCreated(e events.ShareCreated) { - logger := s.logger.With(). - Str("event", "ShareCreated"). - Str("itemid", e.ItemID.OpaqueId). - Logger() - - impersonateRes, err := s.impersonate(e.Sharer) +func (s eventsNotifier) render(bodyTemplate string, subjTemplate string, values map[string]string) (string, string, error) { + msg, err := email.RenderEmailTemplate(bodyTemplate, values, s.emailTemplatePath) if err != nil { - logger.Error(). - Err(err). - Msg("Could not impersonate sharer") - return + return "", "", err } - ownerCtx := metadata.AppendToOutgoingContext(context.Background(), revactx.TokenHeader, impersonateRes.Token) - resourceInfo, err := s.getResourceInfo(ownerCtx, e.ItemID, &fieldmaskpb.FieldMask{Paths: []string{"name"}}) + sub, err := email.RenderEmailTemplate(subjTemplate, values, s.emailTemplatePath) if err != nil { - logger.Error(). - Err(err). - Msg("could not stat resource") - return + return "", "", err } - shareLink, err := urlJoinPath(s.ocisURL, "files/shares/with-me") - if err != nil { - logger.Error(). - Err(err). - Msg("could not create link to the share") - return - } - - shareGrantee := "" - switch { - // Note: We're using the 'ownerCtx' (authenticated as the share owner) here for requesting - // the Grantees of the shares. Ideally the notfication service would use some kind of service - // user for this. - case e.GranteeUserID != nil: - granteeUserResponse, err := s.gwClient.GetUser(ownerCtx, &userv1beta1.GetUserRequest{ - UserId: e.GranteeUserID, - }) - if err != nil || granteeUserResponse.Status.Code != rpcv1beta1.Code_CODE_OK { - s.logger.Error(). - Err(err). - Str("event", "ShareCreated"). - Msg("Could not get user response from gatway client") - return - } - shareGrantee = granteeUserResponse.GetUser().DisplayName - case e.GranteeGroupID != nil: - granteeGroupResponse, err := s.gwClient.GetGroup(ownerCtx, &groupv1beta1.GetGroupRequest{ - GroupId: e.GranteeGroupID, - }) - if err != nil || granteeGroupResponse.Status.Code != rpcv1beta1.Code_CODE_OK { - s.logger.Error(). - Err(err). - Str("event", "ShareCreated"). - Msg("Could not get group response from gatway client") - return - } - shareGrantee = granteeGroupResponse.GetGroup().DisplayName - default: - s.logger.Error(). - Str("event", "ShareCreated"). - Msg("Event 'ShareCreated' has no grantee") - return - } - - sharerDisplayName := impersonateRes.GetUser().DisplayName - msg, err := email.RenderEmailTemplate("shares/shareCreated.email.body.tmpl", map[string]string{ - "ShareGrantee": shareGrantee, - "ShareSharer": sharerDisplayName, - "ShareFolder": resourceInfo.Name, - "ShareLink": shareLink, - }, s.emailTemplatePath) - - if err != nil { - s.logger.Error(). - Err(err). - Str("event", "ShareCreated"). - Msg("Could not render E-Mail body template for shares") - } - - emailSubject, err := email.RenderEmailTemplate("shares/shareCreated.email.subject.tmpl", map[string]string{ - "ShareSharer": sharerDisplayName, - "ShareFolder": resourceInfo.Name, - }, s.emailTemplatePath) - - if err != nil { - s.logger.Error(). - Err(err). - Str("event", "SpaceCreated"). - Msg("Could not render E-Mail subject template for shares") - } - - if e.GranteeUserID != nil { - err = s.channel.SendMessage(ownerCtx, []string{e.GranteeUserID.OpaqueId}, msg, emailSubject, sharerDisplayName) - } else if e.GranteeGroupID != nil { - err = s.channel.SendMessageToGroup(ownerCtx, e.GranteeGroupID, msg, emailSubject, sharerDisplayName) - } - if err != nil { - s.logger.Error(). - Err(err). - Str("event", "ShareCreated"). - Msg("failed to send a message") - } + return msg, sub, nil } -func (s eventsNotifier) impersonate(userID *userv1beta1.UserId) (*gateway.AuthenticateResponse, error) { - getUserResponse, err := s.gwClient.GetUser(context.Background(), &userv1beta1.GetUserRequest{ - UserId: userID, - }) - if err != nil { - return nil, err - } - if getUserResponse.Status.Code != rpcv1beta1.Code_CODE_OK { - return nil, fmt.Errorf("error getting user: %s", getUserResponse.Status.Message) +func (s eventsNotifier) send(ctx context.Context, u *user.UserId, g *group.GroupId, msg, subj, sender string) error { + if u != nil { + return s.channel.SendMessage(ctx, []string{u.GetOpaqueId()}, msg, subj, sender) + } - // Get auth context - ownerCtx := revactx.ContextSetUser(context.Background(), getUserResponse.User) - authRes, err := s.gwClient.Authenticate(ownerCtx, &gateway.AuthenticateRequest{ - Type: "machine", - ClientId: "userid:" + userID.OpaqueId, - ClientSecret: s.machineAuthAPIKey, + if g != nil { + return s.channel.SendMessageToGroup(ctx, g, msg, subj, sender) + } + + return nil +} + +func (s eventsNotifier) getGranteeName(ctx context.Context, u *user.UserId, g *group.GroupId) (string, error) { + switch { + case u != nil: + r, err := s.gwClient.GetUser(ctx, &user.GetUserRequest{UserId: u}) + if err != nil { + return "", err + } + + if r.Status.Code != rpc.Code_CODE_OK { + return "", fmt.Errorf("unexpected status code from gateway client: %d", r.GetStatus().GetCode()) + } + + return r.GetUser().GetDisplayName(), nil + case g != nil: + r, err := s.gwClient.GetGroup(ctx, &group.GetGroupRequest{GroupId: g}) + if err != nil { + return "", err + } + + if r.GetStatus().GetCode() != rpc.Code_CODE_OK { + return "", fmt.Errorf("unexpected status code from gateway client: %d", r.GetStatus().GetCode()) + } + + return r.GetGroup().GetDisplayName(), nil + default: + return "", errors.New("Need at least one non-nil grantee") + } + +} + +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 + md, err := s.gwClient.Stat(ctx, &provider.StatRequest{ + Ref: &provider.Reference{ + ResourceId: resourceID, + }, + FieldMask: fieldmask, }) + if err != nil { return nil, err } - if authRes.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { - return nil, fmt.Errorf("error impersonating user: %s", authRes.Status.Message) + + if md.Status.Code != rpc.Code_CODE_OK { + return nil, fmt.Errorf("could not resource info: %s", md.Status.Message) } - return authRes, nil + return md.GetInfo(), nil } // TODO: this function is a backport for go1.19 url.JoinPath, upon go bump, replace this @@ -225,22 +171,3 @@ func urlJoinPath(base string, elements ...string) (string, error) { u.Path = path.Join(append([]string{u.Path}, elements...)...) return u.String(), nil } - -func (s eventsNotifier) getResourceInfo(ctx context.Context, resourceID *providerv1beta1.ResourceId, fieldmask *fieldmaskpb.FieldMask) (*providerv1beta1.ResourceInfo, error) { - // TODO: maybe cache this stat to reduce storage iops - md, err := s.gwClient.Stat(ctx, &providerv1beta1.StatRequest{ - Ref: &providerv1beta1.Reference{ - ResourceId: resourceID, - }, - FieldMask: fieldmask, - }) - - if err != nil { - return nil, err - } - - if md.Status.Code != rpcv1beta1.Code_CODE_OK { - return nil, fmt.Errorf("could not resource info: %s", md.Status.Message) - } - return md.GetInfo(), nil -} diff --git a/services/notifications/pkg/service/service_test.go b/services/notifications/pkg/service/service_test.go new file mode 100644 index 0000000000..340b2f1492 --- /dev/null +++ b/services/notifications/pkg/service/service_test.go @@ -0,0 +1,153 @@ +package service_test + +import ( + "context" + "time" + + 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/cs3org/reva/v2/pkg/events" + "github.com/cs3org/reva/v2/pkg/utils" + cs3mocks "github.com/cs3org/reva/v2/tests/cs3mocks/mocks" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/services/notifications/pkg/service" + "github.com/test-go/testify/mock" +) + +var _ = Describe("Notifications", func() { + var ( + gwc *cs3mocks.GatewayAPIClient + sharer = &user.User{ + Id: &user.UserId{ + OpaqueId: "sharer", + }, + DisplayName: "Dr. S. Harer", + } + sharee = &user.User{ + Id: &user.UserId{ + OpaqueId: "sharee", + }, + DisplayName: "Eric Expireling", + } + resourceid = &provider.ResourceId{ + StorageId: "storageid", + SpaceId: "spaceid", + OpaqueId: "itemid", + } + ) + + BeforeEach(func() { + gwc = &cs3mocks.GatewayAPIClient{} + gwc.On("GetUser", mock.Anything, mock.Anything).Return(&user.GetUserResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}, User: sharer}, nil) + gwc.On("Authenticate", mock.Anything, mock.Anything).Return(&gateway.AuthenticateResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}, User: sharer}, nil) + gwc.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) + }) + + DescribeTable("Sending notifications", + func(tc testChannel, ev interface{}) { + ch := make(chan interface{}) + evts := service.NewEventsNotifier(ch, tc, log.NewLogger(), gwc, "", "", "") + 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: map[string]bool{sharee.GetId().GetOpaqueId(): true}, + expectedSubject: "Dr. S. Harer shared 'secrets of the board' with you", + expectedSender: sharer.GetDisplayName(), + done: make(chan struct{}), + }, 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: map[string]bool{sharee.GetId().GetOpaqueId(): true}, + expectedSubject: "Share to 'secrets of the board' expired at 2023-04-17 16:42:00", + expectedSender: sharer.GetDisplayName(), + done: make(chan struct{}), + }, 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: map[string]bool{sharee.GetId().GetOpaqueId(): true}, + expectedSubject: "Dr. S. Harer invited you to join secret space", + expectedSender: sharer.GetDisplayName(), + done: make(chan struct{}), + }, events.SpaceShared{ + Executant: sharer.GetId(), + Creator: sharer.GetId(), + GranteeUserID: sharee.GetId(), + ID: &provider.StorageSpaceId{OpaqueId: "spaceid"}, + }), + + Entry("Removed from Space", testChannel{ + expectedReceipients: map[string]bool{sharee.GetId().GetOpaqueId(): true}, + expectedSubject: "Dr. S. Harer removed you from secret space", + expectedSender: sharer.GetDisplayName(), + done: make(chan struct{}), + }, events.SpaceUnshared{ + Executant: sharer.GetId(), + GranteeUserID: sharee.GetId(), + ID: &provider.StorageSpaceId{OpaqueId: "spaceid"}, + }), + + Entry("Space Expired", testChannel{ + expectedReceipients: map[string]bool{sharee.GetId().GetOpaqueId(): true}, + expectedSubject: "Membership of 'secret space' expired at 2023-04-17 16:42:00", + expectedSender: sharer.GetDisplayName(), + done: make(chan struct{}), + }, 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), + }), + ) +}) + +// NOTE: This is explictitly not testing the message itself. Should we? +type testChannel struct { + expectedReceipients map[string]bool + expectedSubject string + expectedSender string + done chan struct{} +} + +func (tc testChannel) SendMessage(ctx context.Context, userIDs []string, msg, subject, senderDisplayName string) error { + defer GinkgoRecover() + + for _, u := range userIDs { + Expect(tc.expectedReceipients[u]).To(Equal(true)) + } + + // TODO: test the message? + //Expect(msg).To(Equal(tc.expectedMessage)) + Expect(subject).To(Equal(tc.expectedSubject)) + Expect(senderDisplayName).To(Equal(tc.expectedSender)) + tc.done <- struct{}{} + return nil +} + +func (tc testChannel) SendMessageToGroup(ctx context.Context, groupID *group.GroupId, msg, subject, senderDisplayName string) error { + return tc.SendMessage(ctx, []string{groupID.GetOpaqueId()}, msg, subject, senderDisplayName) +} diff --git a/services/notifications/pkg/service/shares.go b/services/notifications/pkg/service/shares.go new file mode 100644 index 0000000000..6758849436 --- /dev/null +++ b/services/notifications/pkg/service/shares.go @@ -0,0 +1,101 @@ +package service + +import ( + "github.com/cs3org/reva/v2/pkg/events" + "github.com/cs3org/reva/v2/pkg/utils" + "google.golang.org/protobuf/types/known/fieldmaskpb" +) + +func (s eventsNotifier) handleShareCreated(e events.ShareCreated) { + logger := s.logger.With(). + Str("event", "ShareCreated"). + Str("itemid", e.ItemID.OpaqueId). + Logger() + + ownerCtx, owner, err := utils.Impersonate(e.Sharer, s.gwClient, s.machineAuthAPIKey) + if err != nil { + logger.Error().Err(err).Msg("Could not impersonate sharer") + return + } + + resourceInfo, err := s.getResourceInfo(ownerCtx, e.ItemID, &fieldmaskpb.FieldMask{Paths: []string{"name"}}) + if err != nil { + logger.Error(). + Err(err). + Msg("could not stat resource") + return + } + + shareLink, err := urlJoinPath(s.ocisURL, "files/shares/with-me") + if err != nil { + logger.Error(). + Err(err). + Msg("could not create link to the share") + return + } + + shareGrantee, err := s.getGranteeName(ownerCtx, e.GranteeUserID, e.GranteeGroupID) + if err != nil { + s.logger.Error().Err(err).Str("event", "ShareCreated").Msg("Could not get grantee name") + return + } + + sharerDisplayName := owner.GetDisplayName() + msg, subj, err := s.render("shares/shareCreated.email.body.tmpl", "shares/shareCreated.email.subject.tmpl", map[string]string{ + "ShareGrantee": shareGrantee, + "ShareSharer": sharerDisplayName, + "ShareFolder": resourceInfo.Name, + "ShareLink": shareLink, + }) + + if err != nil { + s.logger.Error().Err(err).Str("event", "ShareCreated").Msg("Could not render E-Mail body template for shares") + } + + if err := s.send(ownerCtx, e.GranteeUserID, e.GranteeGroupID, msg, subj, sharerDisplayName); err != nil { + s.logger.Error().Err(err).Str("event", "ShareCreated").Msg("failed to send a message") + } + +} + +func (s eventsNotifier) handleShareExpired(e events.ShareExpired) { + logger := s.logger.With(). + Str("event", "ShareExpired"). + Str("itemid", e.ItemID.GetOpaqueId()). + Logger() + + ctx, owner, err := utils.Impersonate(e.ShareOwner, s.gwClient, s.machineAuthAPIKey) + if err != nil { + logger.Error().Err(err).Msg("Could not impersonate sharer") + return + } + + 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 + } + + shareGrantee, err := s.getGranteeName(ctx, e.GranteeUserID, e.GranteeGroupID) + if err != nil { + s.logger.Error().Err(err).Str("event", "ShareCreated").Msg("Could not get grantee name") + return + } + + msg, subj, err := s.render("shares/shareExpired.email.body.tmpl", "shares/shareExpired.email.subject.tmpl", map[string]string{ + "ShareGrantee": shareGrantee, + "ShareFolder": resourceInfo.GetName(), + "ExpiredAt": e.ExpiredAt.Format("2006-01-02 15:04:05"), + }) + + if err != nil { + s.logger.Error().Err(err).Str("event", "ShareCreated").Msg("Could not render E-Mail body template for shares") + } + + if err := s.send(ctx, e.GranteeUserID, e.GranteeGroupID, msg, subj, owner.GetDisplayName()); err != nil { + s.logger.Error().Err(err).Str("event", "ShareCreated").Msg("failed to send a message") + } + +} diff --git a/services/notifications/pkg/service/spaces.go b/services/notifications/pkg/service/spaces.go index 9fd01342b6..44affdfcc4 100644 --- a/services/notifications/pkg/service/spaces.go +++ b/services/notifications/pkg/service/spaces.go @@ -1,16 +1,9 @@ package service import ( - "context" - - groupv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1" - userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" - rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" - revactx "github.com/cs3org/reva/v2/pkg/ctx" "github.com/cs3org/reva/v2/pkg/events" "github.com/cs3org/reva/v2/pkg/storagespace" - "github.com/owncloud/ocis/v2/services/notifications/pkg/email" - "google.golang.org/grpc/metadata" + "github.com/cs3org/reva/v2/pkg/utils" ) func (s eventsNotifier) handleSpaceShared(e events.SpaceShared) { @@ -19,14 +12,13 @@ func (s eventsNotifier) handleSpaceShared(e events.SpaceShared) { Str("itemid", e.ID.OpaqueId). Logger() - impersonateRes, err := s.impersonate(e.Executant) + ownerCtx, owner, err := utils.Impersonate(e.Executant, s.gwClient, s.machineAuthAPIKey) if err != nil { logger.Error(). Err(err). Msg("could not handle space shared event") return } - ownerCtx := metadata.AppendToOutgoingContext(context.Background(), revactx.TokenHeader, impersonateRes.Token) resourceID, err := storagespace.ParseID(e.ID.OpaqueId) if err != nil { @@ -52,73 +44,29 @@ func (s eventsNotifier) handleSpaceShared(e events.SpaceShared) { return } - spaceGrantee := "" - switch { // Note: We're using the 'ownerCtx' (authenticated as the share owner) here for requesting // the Grantees of the shares. Ideally the notfication service would use some kind of service // user for this. - case e.GranteeUserID != nil: - granteeUserResponse, err := s.gwClient.GetUser(ownerCtx, &userv1beta1.GetUserRequest{ - UserId: e.GranteeUserID, - }) - if err != nil || granteeUserResponse.Status.Code != rpcv1beta1.Code_CODE_OK { - logger.Error(). - Err(err). - Msg("Could not get user response from gatway client") - return - } - spaceGrantee = granteeUserResponse.GetUser().GetDisplayName() - case e.GranteeGroupID != nil: - granteeGroupResponse, err := s.gwClient.GetGroup(ownerCtx, &groupv1beta1.GetGroupRequest{ - GroupId: e.GranteeGroupID, - }) - if err != nil || granteeGroupResponse.Status.Code != rpcv1beta1.Code_CODE_OK { - logger.Error(). - Err(err). - Msg("Could not get group response from gatway client") - return - } - spaceGrantee = granteeGroupResponse.GetGroup().GetDisplayName() - default: - logger.Error(). - Msg("Event 'SpaceShared' has no grantee") + spaceGrantee, err := s.getGranteeName(ownerCtx, e.GranteeUserID, e.GranteeGroupID) + if err != nil { + logger.Error().Err(err).Msg("Could not get grantee name") return } - sharerDisplayName := impersonateRes.GetUser().GetDisplayName() - msg, err := email.RenderEmailTemplate("spaces/sharedSpace.email.body.tmpl", map[string]string{ + sharerDisplayName := owner.GetDisplayName() + msg, subj, err := s.render("spaces/sharedSpace.email.body.tmpl", "spaces/sharedSpace.email.subject.tmpl", map[string]string{ "SpaceGrantee": spaceGrantee, "SpaceSharer": sharerDisplayName, - "SpaceName": resourceInfo.GetSpace().Name, + "SpaceName": resourceInfo.GetSpace().GetName(), "ShareLink": shareLink, - }, s.emailTemplatePath) - + }) if err != nil { - logger.Error(). - Err(err). - Msg("Could not render E-Mail body template for spaces") + logger.Error().Err(err).Msg("Could not render E-Mail template for spaces") + return } - emailSubject, err := email.RenderEmailTemplate("spaces/sharedSpace.email.subject.tmpl", map[string]string{ - "SpaceSharer": sharerDisplayName, - "SpaceName": resourceInfo.GetSpace().Name, - }, s.emailTemplatePath) - - if err != nil { - logger.Error(). - Err(err). - Msg("Could not render E-Mail subject template for spaces") - } - - if e.GranteeUserID != nil { - err = s.channel.SendMessage(ownerCtx, []string{e.GranteeUserID.OpaqueId}, msg, emailSubject, sharerDisplayName) - } else if e.GranteeGroupID != nil { - err = s.channel.SendMessageToGroup(ownerCtx, e.GranteeGroupID, msg, emailSubject, sharerDisplayName) - } - if err != nil { - logger.Error(). - Err(err). - Msg("failed to send a message") + if err := s.send(ownerCtx, e.GranteeUserID, e.GranteeGroupID, msg, subj, sharerDisplayName); err != nil { + logger.Error().Err(err).Msg("failed to send a message") } } @@ -128,14 +76,11 @@ func (s eventsNotifier) handleSpaceUnshared(e events.SpaceUnshared) { Str("itemid", e.ID.OpaqueId). Logger() - impersonateRes, err := s.impersonate(e.Executant) + ownerCtx, owner, err := utils.Impersonate(e.Executant, s.gwClient, s.machineAuthAPIKey) if err != nil { - logger.Error(). - Err(err). - Msg("could not handle space unshared event") + logger.Error().Err(err).Msg("could not handle space unshared event") return } - ownerCtx := metadata.AppendToOutgoingContext(context.Background(), revactx.TokenHeader, impersonateRes.Token) resourceID, err := storagespace.ParseID(e.ID.OpaqueId) if err != nil { @@ -161,72 +106,63 @@ func (s eventsNotifier) handleSpaceUnshared(e events.SpaceUnshared) { return } - spaceGrantee := "" - switch { // Note: We're using the 'ownerCtx' (authenticated as the share owner) here for requesting // the Grantees of the shares. Ideally the notfication service would use some kind of service // user for this. - case e.GranteeUserID != nil: - granteeUserResponse, err := s.gwClient.GetUser(ownerCtx, &userv1beta1.GetUserRequest{ - UserId: e.GranteeUserID, - }) - if err != nil || granteeUserResponse.Status.Code != rpcv1beta1.Code_CODE_OK { - logger.Error(). - Err(err). - Msg("Could not get user response from gatway client") - return - } - spaceGrantee = granteeUserResponse.GetUser().GetDisplayName() - case e.GranteeGroupID != nil: - granteeGroupResponse, err := s.gwClient.GetGroup(ownerCtx, &groupv1beta1.GetGroupRequest{ - GroupId: e.GranteeGroupID, - }) - if err != nil || granteeGroupResponse.Status.Code != rpcv1beta1.Code_CODE_OK { - logger.Error(). - Err(err). - Msg("Could not get group response from gatway client") - return - } - spaceGrantee = granteeGroupResponse.GetGroup().GetDisplayName() - default: - logger.Error(). - Msg("Event 'SpaceShared' has no grantee") + spaceGrantee, err := s.getGranteeName(ownerCtx, e.GranteeUserID, e.GranteeGroupID) + if err != nil { + logger.Error().Err(err).Msg("Could not get grantee name") return } - sharerDisplayName := impersonateRes.GetUser().GetDisplayName() - msg, err := email.RenderEmailTemplate("spaces/unsharedSpace.email.body.tmpl", map[string]string{ + sharerDisplayName := owner.GetDisplayName() + msg, subj, err := s.render("spaces/unsharedSpace.email.body.tmpl", "spaces/unsharedSpace.email.subject.tmpl", map[string]string{ "SpaceGrantee": spaceGrantee, "SpaceSharer": sharerDisplayName, "SpaceName": resourceInfo.GetSpace().Name, "ShareLink": shareLink, - }, s.emailTemplatePath) + }) if err != nil { - logger.Error(). - Err(err). - Msg("Could not render E-Mail body template for spaces") + logger.Error().Err(err).Msg("Could not render E-Mail template for spaces") + return } - emailSubject, err := email.RenderEmailTemplate("spaces/unsharedSpace.email.subject.tmpl", map[string]string{ - "SpaceSharer": sharerDisplayName, - "SpaceName": resourceInfo.GetSpace().Name, - }, s.emailTemplatePath) - - if err != nil { - logger.Error(). - Err(err). - Msg("Could not render E-Mail subject template for spaces") - } - - if e.GranteeUserID != nil { - err = s.channel.SendMessage(ownerCtx, []string{e.GranteeUserID.OpaqueId}, msg, emailSubject, sharerDisplayName) - } else if e.GranteeGroupID != nil { - err = s.channel.SendMessageToGroup(ownerCtx, e.GranteeGroupID, msg, emailSubject, sharerDisplayName) - } - if err != nil { - logger.Error(). - Err(err). - Msg("failed to send a message") + if err := s.send(ownerCtx, e.GranteeUserID, e.GranteeGroupID, msg, subj, sharerDisplayName); err != nil { + logger.Error().Err(err).Msg("failed to send a message") } } + +func (s eventsNotifier) handleSpaceMembershipExpired(e events.SpaceMembershipExpired) { + logger := s.logger.With(). + Str("event", "SpaceMembershipExpired"). + Str("itemid", e.SpaceID.GetOpaqueId()). + Logger() + + ctx, owner, err := utils.Impersonate(e.SpaceOwner, s.gwClient, s.machineAuthAPIKey) + if err != nil { + logger.Error().Err(err).Msg("Could not impersonate sharer") + return + } + + shareGrantee, err := s.getGranteeName(ctx, e.GranteeUserID, e.GranteeGroupID) + if err != nil { + s.logger.Error().Err(err).Str("event", "ShareCreated").Msg("Could not get grantee name") + return + } + + msg, subj, err := s.render("spaces/membershipExpired.email.body.tmpl", "spaces/membershipExpired.email.subject.tmpl", map[string]string{ + "SpaceGrantee": shareGrantee, + "SpaceName": e.SpaceName, + "ExpiredAt": e.ExpiredAt.Format("2006-01-02 15:04:05"), + }) + + if err != nil { + s.logger.Error().Err(err).Str("event", "ShareCreated").Msg("Could not render E-Mail body template for shares") + } + + if err := s.send(ctx, e.GranteeUserID, e.GranteeGroupID, msg, subj, owner.GetDisplayName()); err != nil { + s.logger.Error().Err(err).Str("event", "ShareCreated").Msg("failed to send a message") + } + +}