From f1208cfa329e6728529fa97c7037eb52292143b8 Mon Sep 17 00:00:00 2001 From: Florian Schade Date: Tue, 9 Jun 2026 17:15:23 +0200 Subject: [PATCH] enhancement: make collaboration mention functionality public --- .woodpecker.star | 1 + pkg/events/events.go | 22 +++ .../pkg/collaboration/collaboration.go | 3 +- services/collaboration/pkg/command/server.go | 22 +++ services/collaboration/pkg/config/config.go | 3 + .../pkg/config/defaults/defaultconfig.go | 8 + services/collaboration/pkg/config/event.go | 12 ++ .../pkg/notification/notification.go | 10 ++ .../collaboration/pkg/notification/service.go | 152 ++++++++++++++++++ .../collaboration/pkg/server/http/option.go | 23 ++- .../collaboration/pkg/server/http/server.go | 14 +- services/notifications/pkg/command/server.go | 12 +- services/notifications/pkg/email/composer.go | 10 +- services/notifications/pkg/email/templates.go | 13 ++ .../notifications/pkg/service/resource.go | 99 ++++++++++++ services/notifications/pkg/service/service.go | 20 ++- .../pkg/config/defaults/defaultconfig.go | 7 +- services/settings/pkg/service/v0/service.go | 12 +- .../pkg/service/v0/servicedecorator.go | 1 + .../settings/pkg/store/defaults/defaults.go | 23 +++ .../pkg/store/defaults/permissions.go | 21 ++- .../settings/pkg/store/defaults/templates.go | 4 + services/userlog/pkg/command/server.go | 4 + services/userlog/pkg/service/conversion.go | 43 ++++- services/userlog/pkg/service/filter.go | 8 +- services/userlog/pkg/service/service.go | 6 + services/userlog/pkg/service/templates.go | 5 + 27 files changed, 521 insertions(+), 37 deletions(-) create mode 100644 pkg/events/events.go create mode 100644 services/collaboration/pkg/config/event.go create mode 100644 services/collaboration/pkg/notification/notification.go create mode 100644 services/collaboration/pkg/notification/service.go create mode 100644 services/notifications/pkg/service/resource.go diff --git a/.woodpecker.star b/.woodpecker.star index a7225d986b..be3f297434 100644 --- a/.woodpecker.star +++ b/.woodpecker.star @@ -3215,6 +3215,7 @@ def wopiCollaborationService(name): "COLLABORATION_CS3API_DATAGATEWAY_INSECURE": True, "OC_JWT_SECRET": "some-opencloud-jwt-secret", "COLLABORATION_WOPI_SECRET": "some-wopi-secret", + "COLLABORATION_EVENTS_ENDPOINT": "%s:9233" % OC_SERVER_NAME, } if name == "collabora": diff --git a/pkg/events/events.go b/pkg/events/events.go new file mode 100644 index 0000000000..3af491210f --- /dev/null +++ b/pkg/events/events.go @@ -0,0 +1,22 @@ +package events + +import ( + "encoding/json" + "time" + + user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" +) + +type ResourceMention struct { + Executant *user.UserId + UserIDs []*user.UserId + Ref *provider.Reference + Timestamp time.Time +} + +func (ResourceMention) Unmarshal(v []byte) (interface{}, error) { + e := ResourceMention{} + err := json.Unmarshal(v, &e) + return e, err +} diff --git a/services/collaboration/pkg/collaboration/collaboration.go b/services/collaboration/pkg/collaboration/collaboration.go index 970846be62..13bc628ae0 100644 --- a/services/collaboration/pkg/collaboration/collaboration.go +++ b/services/collaboration/pkg/collaboration/collaboration.go @@ -14,7 +14,8 @@ import ( type Permission string const ( - PermissionCollaborationManageFonts Permission = "Collaboration.Fonts.Manage" + PermissionCollaborationManageFonts Permission = "Collaboration.Fonts.Manage" + PermissionCollaborationPublishNotification Permission = "Collaboration.Notification.Publish" ) func CheckPermissions(gatewayClient gateway.GatewayAPIClient, ctx context.Context, permission Permission) (*userpb.User, bool, error) { diff --git a/services/collaboration/pkg/command/server.go b/services/collaboration/pkg/command/server.go index 38de01be32..f028e36ded 100644 --- a/services/collaboration/pkg/command/server.go +++ b/services/collaboration/pkg/command/server.go @@ -8,6 +8,7 @@ import ( "os/signal" "time" + "github.com/opencloud-eu/reva/v2/pkg/events/stream" "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" "github.com/opencloud-eu/reva/v2/pkg/store" "github.com/spf13/afero" @@ -17,6 +18,7 @@ import ( microstore "go-micro.dev/v4/store" "github.com/opencloud-eu/opencloud/pkg/config/configlog" + "github.com/opencloud-eu/opencloud/pkg/generators" "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/pkg/registry" "github.com/opencloud-eu/opencloud/pkg/runner" @@ -27,6 +29,7 @@ import ( "github.com/opencloud-eu/opencloud/services/collaboration/pkg/connector" "github.com/opencloud-eu/opencloud/services/collaboration/pkg/font" "github.com/opencloud-eu/opencloud/services/collaboration/pkg/helpers" + "github.com/opencloud-eu/opencloud/services/collaboration/pkg/notification" "github.com/opencloud-eu/opencloud/services/collaboration/pkg/server/debug" "github.com/opencloud-eu/opencloud/services/collaboration/pkg/server/grpc" "github.com/opencloud-eu/opencloud/services/collaboration/pkg/server/http" @@ -171,6 +174,24 @@ func Server(cfg *config.Config) *cobra.Command { fontService = service } + var notificationService notification.Service + { + connName := generators.GenerateConnectionName(cfg.Service.Name, generators.NTypeBus) + natsStream, err := stream.NatsFromConfig(connName, true, stream.NatsConfig(cfg.Events)) + if err != nil { + return err + } + service, err := notification.NewService( + notification.ServiceOptions{}. + WithLogger(logger). + WithGatewaySelector(gatewaySelector). + WithEventPublisher(natsStream). + WithMachineAuthAPIKey(cfg.MachineAuthAPIKey), + ) + + notificationService = service + } + // start HTTP server httpServer, err := http.Server( http.Adapter(connector.NewHttpAdapter(gatewaySelector, cfg, st, selector.NewSelector(selector.Registry(registry.GetRegistry())))), @@ -180,6 +201,7 @@ func Server(cfg *config.Config) *cobra.Command { http.TracerProvider(traceProvider), http.Store(st), http.FontService(fontService), + http.NotificationService(notificationService), ) if err != nil { logger.Info().Err(err).Str("transport", "http").Msg("Failed to initialize server") diff --git a/services/collaboration/pkg/config/config.go b/services/collaboration/pkg/config/config.go index bee6021ccb..c71bebdb48 100644 --- a/services/collaboration/pkg/config/config.go +++ b/services/collaboration/pkg/config/config.go @@ -14,6 +14,7 @@ type Config struct { App App `yaml:"app"` Font Font `yaml:"font"` Store Store `yaml:"store"` + Events Events `yaml:"events"` TokenManager *TokenManager `yaml:"token_manager"` @@ -27,4 +28,6 @@ type Config struct { Debug Debug `yaml:"debug"` Context context.Context `yaml:"-"` + + MachineAuthAPIKey string `yaml:"machine_auth_api_key" env:"OC_MACHINE_AUTH_API_KEY;COLLABORATION_MACHINE_AUTH_API_KEY" desc:"The machine auth API key used to validate internal requests necessary to access resources from other services." introductionVersion:"%%NEXT%%"` } diff --git a/services/collaboration/pkg/config/defaults/defaultconfig.go b/services/collaboration/pkg/config/defaults/defaultconfig.go index bc81fd5511..df4ea57679 100644 --- a/services/collaboration/pkg/config/defaults/defaultconfig.go +++ b/services/collaboration/pkg/config/defaults/defaultconfig.go @@ -39,6 +39,10 @@ func DefaultConfig() *config.Config { AssetPath: filepath.Join(defaults.BaseDataPath(), "collaboration/fonts"), PreviewText: "OpenCloud", }, + Events: config.Events{ + Endpoint: "127.0.0.1:9233", + Cluster: "opencloud-cluster", + }, Store: config.Store{ Store: "nats-js-kv", Nodes: []string{"127.0.0.1:9233"}, @@ -92,6 +96,10 @@ func EnsureDefaults(cfg *config.Config) { if cfg.CS3Api.GRPCClientTLS == nil && cfg.Commons != nil { cfg.CS3Api.GRPCClientTLS = structs.CopyOrZeroValue(cfg.Commons.GRPCClientTLS) } + + if cfg.MachineAuthAPIKey == "" && cfg.Commons != nil && cfg.Commons.MachineAuthAPIKey != "" { + cfg.MachineAuthAPIKey = cfg.Commons.MachineAuthAPIKey + } } // Sanitize sanitized the configuration diff --git a/services/collaboration/pkg/config/event.go b/services/collaboration/pkg/config/event.go new file mode 100644 index 0000000000..eccc8b7422 --- /dev/null +++ b/services/collaboration/pkg/config/event.go @@ -0,0 +1,12 @@ +package config + +// Events combines the configuration options for the event bus. +type Events struct { + Endpoint string `yaml:"endpoint" env:"OC_EVENTS_ENDPOINT;COLLABORATION_EVENTS_ENDPOINT" desc:"The address of the event system. The event system is the message queuing service. It is used as message broker for the microservice architecture." introductionVersion:"%%NEXT%%"` + Cluster string `yaml:"cluster" env:"OC_EVENTS_CLUSTER;COLLABORATION_EVENTS_CLUSTER" desc:"The clusterID of the event system. The event system is the message queuing service. It is used as message broker for the microservice architecture. Mandatory when using NATS as event system." introductionVersion:"%%NEXT%%"` + TLSInsecure bool `yaml:"tls_insecure" env:"OC_INSECURE;OC_EVENTS_TLS_INSECURE;COLLABORATION_EVENTS_TLS_INSECURE" desc:"Whether to verify the server TLS certificates." introductionVersion:"%%NEXT%%"` + TLSRootCACertificate string `yaml:"tls_root_ca_certificate" env:"OC_EVENTS_TLS_ROOT_CA_CERTIFICATE;COLLABORATION_EVENTS_TLS_ROOT_CA_CERTIFICATE" desc:"The root CA certificate used to validate the server's TLS certificate. If provided COLLABORATION_EVENTS_TLS_INSECURE will be seen as false." introductionVersion:"%%NEXT%%"` + EnableTLS bool `yaml:"enable_tls" env:"OC_EVENTS_ENABLE_TLS;COLLABORATION_EVENTS_ENABLE_TLS" desc:"Enable TLS for the connection to the events broker. The events broker is the OpenCloud service which receives and delivers events between the services." introductionVersion:"%%NEXT%%"` + AuthUsername string `yaml:"username" env:"OC_EVENTS_AUTH_USERNAME;COLLABORATION_EVENTS_AUTH_USERNAME" desc:"The username to authenticate with the events broker. The events broker is the OpenCloud service which receives and delivers events between the services." introductionVersion:"%%NEXT%%"` + AuthPassword string `yaml:"password" env:"OC_EVENTS_AUTH_PASSWORD;COLLABORATION_EVENTS_AUTH_PASSWORD" desc:"The password to authenticate with the events broker. The events broker is the OpenCloud service which receives and delivers events between the services." introductionVersion:"%%NEXT%%"` +} diff --git a/services/collaboration/pkg/notification/notification.go b/services/collaboration/pkg/notification/notification.go new file mode 100644 index 0000000000..3b446b5b08 --- /dev/null +++ b/services/collaboration/pkg/notification/notification.go @@ -0,0 +1,10 @@ +package notification + +import ( + "github.com/go-playground/validator/v10" +) + +var validate = validator.New( + validator.WithPrivateFieldValidation(), + validator.WithRequiredStructEnabled(), +) diff --git a/services/collaboration/pkg/notification/service.go b/services/collaboration/pkg/notification/service.go new file mode 100644 index 0000000000..97eff22e95 --- /dev/null +++ b/services/collaboration/pkg/notification/service.go @@ -0,0 +1,152 @@ +package notification + +import ( + "context" + "encoding/json" + "io" + "net/http" + "time" + + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + revactx "github.com/opencloud-eu/reva/v2/pkg/ctx" + "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/storagespace" + "google.golang.org/grpc/metadata" + + ocEvents "github.com/opencloud-eu/opencloud/pkg/events" + "github.com/opencloud-eu/opencloud/pkg/log" + "github.com/opencloud-eu/opencloud/services/collaboration/pkg/collaboration" +) + +type ServiceOptions struct { + logger log.Logger `validate:"required"` + eventPublisher events.Publisher `validate:"required"` + gatewaySelector pool.Selectable[gateway.GatewayAPIClient] `validate:"required"` + machineAuthAPIKey string `validate:"required,min=1"` +} + +func (o ServiceOptions) WithLogger(logger log.Logger) ServiceOptions { + o.logger = logger + return o +} + +func (o ServiceOptions) WithEventPublisher(eventPublisher events.Publisher) ServiceOptions { + o.eventPublisher = eventPublisher + return o +} + +func (o ServiceOptions) WithMachineAuthAPIKey(key string) ServiceOptions { + o.machineAuthAPIKey = key + return o +} + +func (o ServiceOptions) WithGatewaySelector(gws pool.Selectable[gateway.GatewayAPIClient]) ServiceOptions { + o.gatewaySelector = gws + return o +} + +type Service struct { + log log.Logger + eventPublisher events.Publisher + gatewaySelector pool.Selectable[gateway.GatewayAPIClient] + machineAuthAPIKey string +} + +func NewService(options ServiceOptions) (Service, error) { + if err := validate.Struct(options); err != nil { + return Service{}, err + } + + return Service{ + log: options.logger, + eventPublisher: options.eventPublisher, + gatewaySelector: options.gatewaySelector, + machineAuthAPIKey: options.machineAuthAPIKey, + }, nil +} + +func (s Service) HandleNotification(w http.ResponseWriter, r *http.Request) { + gatewayClient, err := s.gatewaySelector.Next() + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + } + + requestUser, canManage, err := collaboration.CheckPermissions(gatewayClient, r.Context(), collaboration.PermissionCollaborationPublishNotification) + switch { + case err != nil: + w.WriteHeader(http.StatusInternalServerError) + return + case !canManage: + w.WriteHeader(http.StatusForbidden) + return + } + + defer func() { _ = r.Body.Close() }() + body, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + var data = struct { + Type string `json:"type" validate:"required"` + UserIDs []string `json:"userIDs" validate:"required"` + FileID string `json:"fileID" validate:"required"` + }{} + if err := json.Unmarshal(body, &data); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + if err := validate.Struct(data); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + event := ocEvents.ResourceMention{ + Executant: requestUser.GetId(), + Timestamp: time.Now(), + } + + for _, userID := range data.UserIDs { + authResponse, err := gatewayClient.Authenticate(context.Background(), &gateway.AuthenticateRequest{ + Type: "machine", + ClientId: "userid:" + userID, + ClientSecret: s.machineAuthAPIKey, + }) + if err != nil || authResponse.Status.Code != rpcv1beta1.Code_CODE_OK { + w.WriteHeader(http.StatusInternalServerError) + return + } + + resourceID, err := storagespace.ParseID(data.FileID) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + statResponse, err := gatewayClient.Stat( + metadata.AppendToOutgoingContext(context.Background(), revactx.TokenHeader, authResponse.GetToken()), + &storageprovider.StatRequest{Ref: &storageprovider.Reference{ResourceId: &resourceID}}, + ) + if err != nil || statResponse.Status.Code != rpcv1beta1.Code_CODE_OK { + w.WriteHeader(http.StatusInternalServerError) + return + } + + event.UserIDs = append(event.UserIDs, authResponse.User.GetId()) + event.Ref = &storageprovider.Reference{ + ResourceId: statResponse.GetInfo().GetId(), + } + } + + if err := events.Publish(r.Context(), s.eventPublisher, event); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/services/collaboration/pkg/server/http/option.go b/services/collaboration/pkg/server/http/option.go index 7d13bd7d61..de009249b4 100644 --- a/services/collaboration/pkg/server/http/option.go +++ b/services/collaboration/pkg/server/http/option.go @@ -10,6 +10,7 @@ import ( "github.com/opencloud-eu/opencloud/services/collaboration/pkg/config" "github.com/opencloud-eu/opencloud/services/collaboration/pkg/connector" "github.com/opencloud-eu/opencloud/services/collaboration/pkg/font" + "github.com/opencloud-eu/opencloud/services/collaboration/pkg/notification" ) // Option defines a single option function. @@ -17,13 +18,14 @@ type Option func(o *Options) // Options define the available options for this package. type Options struct { - Adapter *connector.HttpAdapter - Logger log.Logger - Context context.Context - Config *config.Config - TracerProvider trace.TracerProvider - Store microstore.Store - FontService font.Service + Adapter *connector.HttpAdapter + Logger log.Logger + Context context.Context + Config *config.Config + TracerProvider trace.TracerProvider + Store microstore.Store + FontService font.Service + NotificationService notification.Service } // newOptions initializes the available default options. @@ -85,3 +87,10 @@ func FontService(val font.Service) Option { o.FontService = val } } + +// NotificationService provides a function to set the NotificationService option +func NotificationService(val notification.Service) Option { + return func(o *Options) { + o.NotificationService = val + } +} diff --git a/services/collaboration/pkg/server/http/server.go b/services/collaboration/pkg/server/http/server.go index 8a315823f5..a2f4282c35 100644 --- a/services/collaboration/pkg/server/http/server.go +++ b/services/collaboration/pkg/server/http/server.go @@ -96,6 +96,7 @@ func Server(opts ...Option) (http.Service, error) { // prepareRoutes will prepare all the implemented routes func prepareRoutes(r *chi.Mux, options Options) { fontService := options.FontService + notificationService := options.NotificationService adapter := options.Adapter logger := options.Logger // prepare basic logger for the request @@ -214,15 +215,18 @@ func prepareRoutes(r *chi.Mux, options Options) { }) r.Route("/collaboration", func(r chi.Router) { + auth := middleware.ExtractAccountUUID( + account.Logger(options.Logger), + account.JWTSecret(options.Config.TokenManager.JWTSecret), + ) + r.With(auth).Route("/notify", func(r chi.Router) { + r.Post("/", notificationService.HandleNotification) + }) r.Route("/fonts", func(r chi.Router) { r.Get("/", fontService.ListFonts) r.Get("/{id}", fontService.GetFont) r.Get("/preview/{id}", fontService.PreviewFont) - r.Route("/manage", func(r chi.Router) { - r.Use(middleware.ExtractAccountUUID( - account.Logger(options.Logger), - account.JWTSecret(options.Config.TokenManager.JWTSecret), - )) + r.With(auth).Route("/manage", func(r chi.Router) { r.Post("/", fontService.UploadFont) r.Delete("/{id}", fontService.DeleteFont) }) diff --git a/services/notifications/pkg/command/server.go b/services/notifications/pkg/command/server.go index e512286a6a..0ca677be77 100644 --- a/services/notifications/pkg/command/server.go +++ b/services/notifications/pkg/command/server.go @@ -6,7 +6,12 @@ import ( "os/signal" "reflect" + "github.com/opencloud-eu/reva/v2/pkg/events" + "github.com/opencloud-eu/reva/v2/pkg/events/stream" + "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" + "github.com/opencloud-eu/opencloud/pkg/config/configlog" + ocEvents "github.com/opencloud-eu/opencloud/pkg/events" "github.com/opencloud-eu/opencloud/pkg/generators" "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/pkg/registry" @@ -19,14 +24,12 @@ import ( "github.com/opencloud-eu/opencloud/services/notifications/pkg/config/parser" "github.com/opencloud-eu/opencloud/services/notifications/pkg/server/debug" "github.com/opencloud-eu/opencloud/services/notifications/pkg/service" - "github.com/opencloud-eu/reva/v2/pkg/events" - "github.com/opencloud-eu/reva/v2/pkg/events/stream" - "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" - ehsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/eventhistory/v0" "github.com/opencloud-eu/reva/v2/pkg/store" "github.com/spf13/cobra" microstore "go-micro.dev/v4/store" + + ehsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/eventhistory/v0" ) // Server is the entrypoint for the server command. @@ -86,6 +89,7 @@ func Server(cfg *config.Config) *cobra.Command { events.SpaceMembershipExpired{}, events.ScienceMeshInviteTokenGenerated{}, events.SendEmailsEvent{}, + ocEvents.ResourceMention{}, } registeredEvents := make(map[string]events.Unmarshaller) for _, e := range evs { diff --git a/services/notifications/pkg/email/composer.go b/services/notifications/pkg/email/composer.go index 1f81d87f0b..7af545b31c 100644 --- a/services/notifications/pkg/email/composer.go +++ b/services/notifications/pkg/email/composer.go @@ -3,6 +3,7 @@ package email import ( "bytes" "embed" + "fmt" "strings" "text/template" @@ -150,6 +151,11 @@ func callToActionToHTML(s string) string { if strings.TrimSpace(s) == "" { return "" } - s = strings.TrimSuffix(s, "{ShareLink}") - return s + `{ShareLink}` + + // substitute links + for _, token := range []string{"ShareLink", "ResourceLink"} { + s = strings.ReplaceAll(s, "{"+token+"}", fmt.Sprintf(`{%s}`, token, token)) + } + + return s } diff --git a/services/notifications/pkg/email/templates.go b/services/notifications/pkg/email/templates.go index 83eff3da21..99091b44b1 100644 --- a/services/notifications/pkg/email/templates.go +++ b/services/notifications/pkg/email/templates.go @@ -118,6 +118,15 @@ Please visit your federation settings and use the following details: Greeting: l10n.Template(`Hi {DisplayName},`), MessageBody: "", // is generated using the GroupedTemplates } + + Mention = MessageTemplate{ + textTemplate: _textTemplate, + htmlTemplate: _htmlTemplate, + Subject: l10n.Template(`You were mentioned in '{ResourceName}'`), + Greeting: l10n.Template(`Hello {RecipientName},`), + MessageBody: l10n.Template(`{AuthorName} mentioned you in "{ResourceName}".`), + CallToAction: l10n.Template(`You can view the mention here: {ResourceLink}`), + } ) // holds the information to turn the raw template into a parseable go template @@ -134,6 +143,10 @@ var _placeholders = map[string]string{ "{ProviderDomain}": "{{ .ProviderDomain }}", "{Token}": "{{ .Token }}", "{DisplayName}": "{{ .DisplayName }}", + "{AuthorName}": "{{ .AuthorName }}", + "{RecipientName}": "{{ .RecipientName }}", + "{ResourceName}": "{{ .ResourceName }}", + "{ResourceLink}": "{{ .ResourceLink }}", } // MessageTemplate is the data structure for the email diff --git a/services/notifications/pkg/service/resource.go b/services/notifications/pkg/service/resource.go new file mode 100644 index 0000000000..3138f51ebc --- /dev/null +++ b/services/notifications/pkg/service/resource.go @@ -0,0 +1,99 @@ +package service + +import ( + "context" + + user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + "github.com/opencloud-eu/reva/v2/pkg/storagespace" + "github.com/opencloud-eu/reva/v2/pkg/utils" + + ocEvents "github.com/opencloud-eu/opencloud/pkg/events" + "github.com/opencloud-eu/opencloud/pkg/l10n" + "github.com/opencloud-eu/opencloud/services/notifications/pkg/channels" + "github.com/opencloud-eu/opencloud/services/notifications/pkg/email" + "github.com/opencloud-eu/opencloud/services/settings/pkg/store/defaults" +) + +func (s eventsNotifier) handleResourceMention(e ocEvents.ResourceMention, eventId string) { + logger := s.logger.With(). + Str("event", "Mention"). + Str("resourceid", e.Ref.GetResourceId().GetOpaqueId()). + Logger() + _ = logger + gatewayClient, err := s.gatewaySelector.Next() + if err != nil { + return + } + + ctx, err := utils.GetServiceUserContextWithContext(context.Background(), gatewayClient, s.serviceAccountID, s.serviceAccountSecret) + if err != nil { + logger.Error().Err(err).Msg("could not select next gateway client") + return + } + + var data = struct { + resourceLink string `validate:"required,url"` + resourceName string `validate:"required,min=1"` + author *user.User `validate:"required"` + recipients []*user.User `validate:"required,min=1"` + }{} + + // fill the data struct with the info we need to render the email + { + resourceInfo, err := s.getResourceInfo(ctx, e.Ref.GetResourceId(), nil) + if err != nil { + return + } + data.resourceName = resourceInfo.GetName() + + data.resourceLink, err = urlJoinPath(s.openCloudURL, "f", storagespace.FormatResourceID(resourceInfo.GetId())) + if err != nil { + logger.Error().Err(err).Msg("failed to generate resource link.") + return + } + + for _, userID := range append([]*user.UserId{e.Executant}, e.UserIDs...) { + switch u, err := s.getUser(ctx, userID); { + case err != nil: + logger.Error().Err(err).Msg("could not get user") + return + case userID.GetOpaqueId() == e.Executant.GetOpaqueId(): + data.author = u + default: + data.recipients = append(data.recipients, u) + } + } + + recipients := s.filter.execute(ctx, data.recipients, defaults.SettingUUIDProfileEventResourceMention) + recipientsInstant, recipientsDaily, recipientsInstantWeekly := s.splitter.execute(ctx, recipients) + recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalDaily, eventId, recipientsDaily)...) + recipientsInstant = append(recipientsInstant, s.userEventStore.persist(_intervalWeekly, eventId, recipientsInstantWeekly)...) + data.recipients = recipientsInstant + } + + if err := validate.Struct(data); err != nil { + logger.Error().Err(err).Msg("data struct validation failed") + return + } + + messages := make([]*channels.Message, len(data.recipients)) + for i, recipient := range data.recipients { + locale := l10n.MustGetUserLocale(ctx, recipient.GetId().GetOpaqueId(), "", s.valueService) + message, err := email.RenderEmailTemplate(email.Mention, locale, s.defaultLanguage, s.emailTemplatePath, s.translationPath, map[string]string{ + "AuthorName": data.author.GetDisplayName(), + "RecipientName": recipient.GetDisplayName(), + "ResourceName": data.resourceName, + "ResourceLink": data.resourceLink, + }) + if err != nil { + logger.Error().Err(err).Msg("could not render email-template") + return + } + + message.Sender = data.author.GetDisplayName() + message.Recipient = []string{recipient.GetMail()} + messages[i] = message + } + + s.send(ctx, messages) +} diff --git a/services/notifications/pkg/service/service.go b/services/notifications/pkg/service/service.go index 96d03cb2c0..65707bb355 100644 --- a/services/notifications/pkg/service/service.go +++ b/services/notifications/pkg/service/service.go @@ -10,9 +10,11 @@ import ( "sync" "sync/atomic" - ehsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/eventhistory/v0" "go-micro.dev/v4/store" + ocEvents "github.com/opencloud-eu/opencloud/pkg/events" + ehsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/eventhistory/v0" + 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" @@ -22,6 +24,9 @@ import ( "go-micro.dev/v4/metadata" "google.golang.org/protobuf/types/known/fieldmaskpb" + "github.com/opencloud-eu/reva/v2/pkg/events" + "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool" + "github.com/opencloud-eu/opencloud/pkg/l10n" "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/pkg/middleware" @@ -29,16 +34,13 @@ import ( "github.com/opencloud-eu/opencloud/services/notifications/pkg/channels" "github.com/opencloud-eu/opencloud/services/notifications/pkg/email" "github.com/opencloud-eu/opencloud/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() -} +var validate = validator.New( + validator.WithPrivateFieldValidation(), + validator.WithRequiredStructEnabled(), +) // Service should be named `Runner` type Service interface { @@ -131,6 +133,8 @@ EventLoop: s.handleScienceMeshInviteTokenGenerated(e) case events.SendEmailsEvent: s.sendGroupedEmailsJob(e, evt.ID) + case ocEvents.ResourceMention: + s.handleResourceMention(e, evt.ID) } }) diff --git a/services/proxy/pkg/config/defaults/defaultconfig.go b/services/proxy/pkg/config/defaults/defaultconfig.go index 5dd3541e18..e3f2e380c5 100644 --- a/services/proxy/pkg/config/defaults/defaultconfig.go +++ b/services/proxy/pkg/config/defaults/defaultconfig.go @@ -292,7 +292,12 @@ func DefaultPolicies() []config.Policy { SkipXAccessToken: true, }, { - Endpoint: "/collaboration/fonts/manage/", + Endpoint: "/collaboration/fonts/manage", + Service: "eu.opencloud.web.collaboration", + // Method: "POST" // toDo: fails with method, WHY??? + }, + { + Endpoint: "/collaboration/notify", Service: "eu.opencloud.web.collaboration", // Method: "POST" // toDo: fails with method, WHY??? }, diff --git a/services/settings/pkg/service/v0/service.go b/services/settings/pkg/service/v0/service.go index 37336393f6..bf8256de36 100644 --- a/services/settings/pkg/service/v0/service.go +++ b/services/settings/pkg/service/v0/service.go @@ -10,6 +10,12 @@ import ( cs3permissions "github.com/cs3org/go-cs3apis/cs3/permissions/v1beta1" rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" "github.com/leonelquinteros/gotext" + ctxpkg "github.com/opencloud-eu/reva/v2/pkg/ctx" + "github.com/opencloud-eu/reva/v2/pkg/rgrpc/status" + merrors "go-micro.dev/v4/errors" + "go-micro.dev/v4/metadata" + "google.golang.org/protobuf/types/known/emptypb" + "github.com/opencloud-eu/opencloud/pkg/l10n" "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/pkg/middleware" @@ -20,11 +26,6 @@ import ( "github.com/opencloud-eu/opencloud/services/settings/pkg/settings" "github.com/opencloud-eu/opencloud/services/settings/pkg/store/defaults" metastore "github.com/opencloud-eu/opencloud/services/settings/pkg/store/metadata" - ctxpkg "github.com/opencloud-eu/reva/v2/pkg/ctx" - "github.com/opencloud-eu/reva/v2/pkg/rgrpc/status" - merrors "go-micro.dev/v4/errors" - "go-micro.dev/v4/metadata" - "google.golang.org/protobuf/types/known/emptypb" ) //go:embed l10n/locale @@ -708,6 +709,7 @@ func translateBundle(bundle *settingsmsg.Bundle, t *gotext.Locale) *settingsmsg. defaults.SettingUUIDProfileEventSpaceUnshared, defaults.SettingUUIDProfileEventSpaceMembershipExpired, defaults.SettingUUIDProfileEventSpaceDisabled, + defaults.SettingUUIDProfileEventResourceMention, defaults.SettingUUIDProfileEventSpaceDeleted: // translate event names ('Share Received', 'Share Removed', ...) set.DisplayName = t.Get(set.GetDisplayName(), []any{}...) diff --git a/services/settings/pkg/service/v0/servicedecorator.go b/services/settings/pkg/service/v0/servicedecorator.go index d639b73c90..4a41e4ad09 100644 --- a/services/settings/pkg/service/v0/servicedecorator.go +++ b/services/settings/pkg/service/v0/servicedecorator.go @@ -193,5 +193,6 @@ func getDefaultValueList() map[string]*settingsmsg.ValueWithIdentifier { defaults.SettingUUIDProfileEventSpaceDeleted: nil, defaults.SettingUUIDProfileEventPostprocessingStepFinished: nil, defaults.SettingUUIDProfileEmailSendingInterval: nil, + defaults.SettingUUIDProfileEventResourceMention: nil, } } diff --git a/services/settings/pkg/store/defaults/defaults.go b/services/settings/pkg/store/defaults/defaults.go index c65a3548c1..628c07e581 100644 --- a/services/settings/pkg/store/defaults/defaults.go +++ b/services/settings/pkg/store/defaults/defaults.go @@ -47,6 +47,8 @@ const ( SettingUUIDProfileEventSpaceDeleted = "094ceca9-5a00-40ba-bb1a-bbc7bccd39ee" // SettingUUIDProfileEventPostprocessingStepFinished is the hardcoded setting UUID for the send in mail setting SettingUUIDProfileEventPostprocessingStepFinished = "fe0a3011-d886-49c8-b797-33d02fa426ef" + // SettingUUIDProfileEventResourceMention is the hardcoded setting UUID for the send in mail setting + SettingUUIDProfileEventResourceMention = "08aaa973-a622-449d-97dc-3857160d1e97" ) // GenerateBundlesDefaultRoles bootstraps the default roles. @@ -79,6 +81,7 @@ func ServiceAccountBundle() *settingsmsg.Bundle { Settings: []*settingsmsg.Setting{ AccountManagementPermission(All), ChangeLogoPermission(All), + CollaborationPublishNotificationPermission(All), CollaborationManageFontsPermission(All), CreatePublicLinkPermission(All), CreateSharePermission(All), @@ -116,6 +119,7 @@ func generateBundleAdminRole() *settingsmsg.Bundle { AccountManagementPermission(All), AutoAcceptSharesPermission(Own), ChangeLogoPermission(All), + CollaborationPublishNotificationPermission(All), CollaborationManageFontsPermission(All), CreatePublicLinkPermission(All), CreateSharePermission(All), @@ -180,6 +184,7 @@ func generateBundleSpaceAdminRole() *settingsmsg.Bundle { ProfileEventPostprocessingStepFinishedPermission(Own), LanguageManagementPermission(Own), ListFavoritesPermission(Own), + CollaborationPublishNotificationPermission(All), ListSpacesPermission(All), ManageSpacePropertiesPermission(All), SelfManagementPermission(Own), @@ -218,6 +223,7 @@ func generateBundleUserRole() *settingsmsg.Bundle { ProfileEventPostprocessingStepFinishedPermission(Own), LanguageManagementPermission(Own), ListFavoritesPermission(Own), + CollaborationPublishNotificationPermission(All), SelfManagementPermission(Own), WriteFavoritesPermission(Own), }, @@ -345,6 +351,23 @@ func generateBundleProfileRequest() *settingsmsg.Bundle { }, }, }, + { + Id: SettingUUIDProfileEventResourceMention, + Name: "event-resource-mention-options", + DisplayName: TemplateResourceMention, + Description: TemplateResourceMentionDescription, + Resource: &settingsmsg.Resource{ + Type: settingsmsg.Resource_TYPE_USER, + }, + Value: &settingsmsg.Setting_MultiChoiceCollectionValue{ + MultiChoiceCollectionValue: &settingsmsg.MultiChoiceCollection{ + Options: []*settingsmsg.MultiChoiceCollectionOption{ + &optionInAppTrue, + &optionMailTrue, + }, + }, + }, + }, { Id: SettingUUIDProfileEventSpaceShared, Name: "event-space-shared-options", diff --git a/services/settings/pkg/store/defaults/permissions.go b/services/settings/pkg/store/defaults/permissions.go index dec6aed3f0..ba81dd7914 100644 --- a/services/settings/pkg/store/defaults/permissions.go +++ b/services/settings/pkg/store/defaults/permissions.go @@ -67,7 +67,7 @@ func ChangeLogoPermission(c settingsmsg.Permission_Constraint) *settingsmsg.Sett } } -// ManageFontsPermission is the permission to manage fonts +// CollaborationManageFontsPermission is the permission to manage fonts func CollaborationManageFontsPermission(c settingsmsg.Permission_Constraint) *settingsmsg.Setting { return &settingsmsg.Setting{ Id: "ed83fc10-1f54-4a9e-b5a7-fb517f5f3e01", @@ -86,6 +86,25 @@ func CollaborationManageFontsPermission(c settingsmsg.Permission_Constraint) *se } } +// CollaborationPublishNotificationPermission is the permission to manage fonts +func CollaborationPublishNotificationPermission(c settingsmsg.Permission_Constraint) *settingsmsg.Setting { + return &settingsmsg.Setting{ + Id: "43e5948e-8238-41d6-9ef1-f259f00591db", + Name: "Collaboration.Notification.Publish", + DisplayName: "Publish collaboration notifications", + Description: "This permission permits to publish collaboration related notifications.", + Resource: &settingsmsg.Resource{ + Type: settingsmsg.Resource_TYPE_SYSTEM, + }, + Value: &settingsmsg.Setting_PermissionValue{ + PermissionValue: &settingsmsg.Permission{ + Operation: settingsmsg.Permission_OPERATION_READWRITE, + Constraint: c, + }, + }, + } +} + // CreatePublicLinkPermission is the permission to create public links func CreatePublicLinkPermission(c settingsmsg.Permission_Constraint) *settingsmsg.Setting { return &settingsmsg.Setting{ diff --git a/services/settings/pkg/store/defaults/templates.go b/services/settings/pkg/store/defaults/templates.go index 57252abef0..bc07a0d255 100644 --- a/services/settings/pkg/store/defaults/templates.go +++ b/services/settings/pkg/store/defaults/templates.go @@ -12,6 +12,10 @@ var ( TemplateShareRemoved = l10n.Template("Share Removed") // description of the notification option 'Share Removed' TemplateShareRemovedDescription = l10n.Template("Notify when a received share has been removed") + // name of the notification option 'Resource Mention' + TemplateResourceMention = l10n.Template("Resource Mention") + // description of the notification option 'Resource Mention' + TemplateResourceMentionDescription = l10n.Template("Notify on resource mentions") // name of the notification option 'Share Expired' TemplateShareExpired = l10n.Template("Share Expired") // description of the notification option 'Share Expired' diff --git a/services/userlog/pkg/command/server.go b/services/userlog/pkg/command/server.go index 445bb6810e..4aacb7a8f0 100644 --- a/services/userlog/pkg/command/server.go +++ b/services/userlog/pkg/command/server.go @@ -6,6 +6,7 @@ import ( "os/signal" "github.com/opencloud-eu/opencloud/pkg/config/configlog" + ocEvents "github.com/opencloud-eu/opencloud/pkg/events" "github.com/opencloud-eu/opencloud/pkg/generators" "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/pkg/registry" @@ -45,6 +46,9 @@ var _registeredEvents = []events.Unmarshaller{ events.ShareCreated{}, events.ShareRemoved{}, events.ShareExpired{}, + + // misc + ocEvents.ResourceMention{}, } // Server is the entrypoint for the server command. diff --git a/services/userlog/pkg/service/conversion.go b/services/userlog/pkg/service/conversion.go index f235baecc8..5a6547ee91 100644 --- a/services/userlog/pkg/service/conversion.go +++ b/services/userlog/pkg/service/conversion.go @@ -14,11 +14,13 @@ import ( user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1" storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" - "github.com/opencloud-eu/opencloud/pkg/l10n" "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/storagespace" "github.com/opencloud-eu/reva/v2/pkg/utils" + + ocEvents "github.com/opencloud-eu/opencloud/pkg/events" + "github.com/opencloud-eu/opencloud/pkg/l10n" ) //go:embed l10n/locale @@ -115,7 +117,12 @@ func (c *Converter) ConvertEvent(eventid string, event any) (OC10Notification, e return c.shareMessage(eventid, ShareExpired, ev.ShareOwner, ev.ItemID, ev.ShareID, ev.ExpiredAt) case events.ShareRemoved: return c.shareMessage(eventid, ShareRemoved, ev.Executant, ev.ItemID, ev.ShareID, ev.Timestamp) + + // misc + case ocEvents.ResourceMention: + return c.resourceMention(eventid, Mention, ev.Executant, ev.Ref.GetResourceId(), ev.Timestamp) } + } // ConvertGlobalEvent converts a global event to an OC10Notification @@ -199,6 +206,40 @@ func (c *Converter) spaceMessage(eventid string, nt NotificationTemplate, execut }, nil } +func (c *Converter) resourceMention(eventid string, nt NotificationTemplate, executant *user.UserId, resourceid *storageprovider.ResourceId, ts time.Time) (OC10Notification, error) { + usr, err := c.getUser(context.Background(), executant) + if err != nil { + return OC10Notification{}, err + } + + info, err := c.getResource(c.serviceAccountContext, resourceid) + if err != nil { + return OC10Notification{}, err + } + + subj, subjraw, msg, msgraw, err := composeMessage(nt, c.locale, c.defaultLanguage, c.translationPath, map[string]any{ + "username": usr.GetDisplayName(), + "resourcename": info.GetName(), + }) + if err != nil { + return OC10Notification{}, err + } + + return OC10Notification{ + EventID: eventid, + Service: c.serviceName, + UserName: usr.GetUsername(), + Timestamp: ts.Format(time.RFC3339Nano), + ResourceID: storagespace.FormatResourceID(info.GetId()), + ResourceType: "mention", + Subject: subj, + SubjectRaw: subjraw, + Message: msg, + MessageRaw: msgraw, + MessageDetails: generateDetails(usr, nil, info, nil), + }, nil +} + func (c *Converter) shareMessage(eventid string, nt NotificationTemplate, executant *user.UserId, resourceid *storageprovider.ResourceId, shareid *collaboration.ShareId, ts time.Time) (OC10Notification, error) { usr, err := c.getUser(context.Background(), executant) if err != nil { diff --git a/services/userlog/pkg/service/filter.go b/services/userlog/pkg/service/filter.go index a7727d85db..f45ed18a8d 100644 --- a/services/userlog/pkg/service/filter.go +++ b/services/userlog/pkg/service/filter.go @@ -5,12 +5,14 @@ import ( "errors" user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + "github.com/opencloud-eu/reva/v2/pkg/events" + micrometadata "go-micro.dev/v4/metadata" + + ocEvents "github.com/opencloud-eu/opencloud/pkg/events" "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/pkg/middleware" settingssvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/settings/v0" "github.com/opencloud-eu/opencloud/services/settings/pkg/store/defaults" - "github.com/opencloud-eu/reva/v2/pkg/events" - micrometadata "go-micro.dev/v4/metadata" ) type userlogFilter struct { @@ -61,6 +63,8 @@ func (ulf userlogFilter) filterUsersBySettings(ctx context.Context, users []stri settingId = defaults.SettingUUIDProfileEventSpaceDisabled case events.SpaceDeleted: settingId = defaults.SettingUUIDProfileEventSpaceDeleted + case ocEvents.ResourceMention: + settingId = defaults.SettingUUIDProfileEventResourceMention default: // event that cannot be disabled return users diff --git a/services/userlog/pkg/service/service.go b/services/userlog/pkg/service/service.go index b58538aad7..957a84446f 100644 --- a/services/userlog/pkg/service/service.go +++ b/services/userlog/pkg/service/service.go @@ -17,6 +17,7 @@ import ( "go-micro.dev/v4/store" "go.opentelemetry.io/otel/trace" + ocEvents "github.com/opencloud-eu/opencloud/pkg/events" "github.com/opencloud-eu/opencloud/pkg/l10n" "github.com/opencloud-eu/opencloud/pkg/log" "github.com/opencloud-eu/opencloud/pkg/roles" @@ -168,6 +169,11 @@ func (ul *UserlogService) processEvent(event events.Event) { case events.SpaceShared: executant = e.Executant users, err = utils.ResolveID(ctx, e.GranteeUserID, e.GranteeGroupID, gwc) + case ocEvents.ResourceMention: + executant = e.Executant + for _, userID := range e.UserIDs { + users = append(users, userID.GetOpaqueId()) + } case events.SpaceUnshared: executant = e.Executant users, err = utils.ResolveID(ctx, e.GranteeUserID, e.GranteeGroupID, gwc) diff --git a/services/userlog/pkg/service/templates.go b/services/userlog/pkg/service/templates.go index e7de3b1c9d..6b244f2da5 100644 --- a/services/userlog/pkg/service/templates.go +++ b/services/userlog/pkg/service/templates.go @@ -49,6 +49,11 @@ var ( Message: l10n.Template("{user} unshared {resource} with you"), } + Mention = NotificationTemplate{ + Subject: l10n.Template("You have been mentioned"), + Message: l10n.Template("{user} mentioned you in {resource}"), + } + ShareExpired = NotificationTemplate{ Subject: l10n.Template("Share expired"), Message: l10n.Template("Access to {resource} expired"),