diff --git a/.bingo/go-xgettext.mod b/.bingo/go-xgettext.mod
index 8e3c6210b5..b14946a0b8 100644
--- a/.bingo/go-xgettext.mod
+++ b/.bingo/go-xgettext.mod
@@ -3,3 +3,5 @@ module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT
go 1.23.4
require github.com/gosexy/gettext v0.0.0-20160830220431-74466a0a0c4a // go-xgettext
+
+require github.com/jessevdk/go-flags v1.6.1 // indirect
diff --git a/.gitignore b/.gitignore
index 4444c17049..917b48e04c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -65,4 +65,4 @@ go.work.sum
.DS_Store
# example deployments
-**/opencloud-sandbox-*
\ No newline at end of file
+**/opencloud-sandbox-*
diff --git a/.woodpecker.star b/.woodpecker.star
index a7225d986b..f51cc45b8e 100644
--- a/.woodpecker.star
+++ b/.woodpecker.star
@@ -71,6 +71,7 @@ OC_DOMAIN = "%s:9200" % OC_SERVER_NAME
FED_OC_SERVER_NAME = "federation-opencloud-server"
OC_FED_URL = "https://%s:10200" % FED_OC_SERVER_NAME
OC_FED_DOMAIN = "%s:10200" % FED_OC_SERVER_NAME
+MACHINE_AUTH_API_KEY = "fjsdlfgkjsdlktgersoiulersiltjlekir5[345;lesirtuwe542345wert"
event = {
"base": {
@@ -2337,6 +2338,7 @@ def opencloudServer(storage = "decomposed", depends_on = [], deploy_type = "", e
"WEBDAV_DEBUG_ADDR": "0.0.0.0:9119",
"WEBFINGER_DEBUG_ADDR": "0.0.0.0:9279",
"STORAGE_USERS_POSIX_SCAN_DEBOUNCE_DELAY": 0,
+ "OC_MACHINE_AUTH_API_KEY": MACHINE_AUTH_API_KEY,
}
if storage == "posix":
@@ -3215,6 +3217,8 @@ 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,
+ "OC_MACHINE_AUTH_API_KEY": MACHINE_AUTH_API_KEY,
}
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
new file mode 100644
index 0000000000..13bc628ae0
--- /dev/null
+++ b/services/collaboration/pkg/collaboration/collaboration.go
@@ -0,0 +1,40 @@
+package collaboration
+
+import (
+ "context"
+ "fmt"
+
+ gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
+ userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
+ permissionsapi "github.com/cs3org/go-cs3apis/cs3/permissions/v1beta1"
+ rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
+ revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
+)
+
+type Permission string
+
+const (
+ PermissionCollaborationManageFonts Permission = "Collaboration.Fonts.Manage"
+ PermissionCollaborationPublishNotification Permission = "Collaboration.Notification.Publish"
+)
+
+func CheckPermissions(gatewayClient gateway.GatewayAPIClient, ctx context.Context, permission Permission) (*userpb.User, bool, error) {
+ user, ok := revactx.ContextGetUser(ctx)
+ if !ok {
+ return nil, false, fmt.Errorf("could not get user from context")
+ }
+
+ rsp, err := gatewayClient.CheckPermission(ctx, &permissionsapi.CheckPermissionRequest{
+ Permission: string(permission),
+ SubjectRef: &permissionsapi.SubjectReference{
+ Spec: &permissionsapi.SubjectReference_UserId{
+ UserId: user.GetId(),
+ },
+ },
+ })
+ if err != nil {
+ return user, false, fmt.Errorf("could not check permissions: %w", err)
+ }
+
+ return user, rsp.GetStatus().GetCode() == rpc.Code_CODE_OK, nil
+}
diff --git a/services/collaboration/pkg/command/server.go b/services/collaboration/pkg/command/server.go
index afbd1ebf7f..e5196fb0a3 100644
--- a/services/collaboration/pkg/command/server.go
+++ b/services/collaboration/pkg/command/server.go
@@ -4,27 +4,35 @@ import (
"context"
"fmt"
"net"
+ "net/url"
"os/signal"
"time"
- "github.com/opencloud-eu/opencloud/pkg/config/configlog"
- "github.com/opencloud-eu/opencloud/pkg/log"
- "github.com/opencloud-eu/opencloud/pkg/registry"
- "github.com/opencloud-eu/opencloud/pkg/runner"
- "github.com/opencloud-eu/opencloud/pkg/tracing"
- "github.com/opencloud-eu/opencloud/services/collaboration/pkg/config"
- "github.com/opencloud-eu/opencloud/services/collaboration/pkg/config/parser"
- "github.com/opencloud-eu/opencloud/services/collaboration/pkg/connector"
- "github.com/opencloud-eu/opencloud/services/collaboration/pkg/helpers"
- "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"
+ "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"
"github.com/spf13/cobra"
"go-micro.dev/v4/selector"
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"
+ "github.com/opencloud-eu/opencloud/pkg/tracing"
+ "github.com/opencloud-eu/opencloud/pkg/x/io/fsx"
+ "github.com/opencloud-eu/opencloud/services/collaboration/pkg/config"
+ "github.com/opencloud-eu/opencloud/services/collaboration/pkg/config/parser"
+ "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"
)
// Server is the entrypoint for the server command.
@@ -137,6 +145,52 @@ func Server(cfg *config.Config) *cobra.Command {
}
gr.Add(runner.NewGolangHttpServerRunner(cfg.Service.Name+".debug", debugServer))
+ var fontService font.Service
+ {
+ fontFS := afero.NewBasePathFs(fsx.NewOsFs(), cfg.Font.AssetPath)
+ if err := fontFS.MkdirAll("/", 0o755); err != nil {
+ logger.Error().Err(err).Msg("Failed to initialize the fonts directory")
+ return err
+ }
+
+ fontServiceRootURI, err := url.JoinPath(cfg.Commons.OpenCloudURL, "/collaboration/fonts")
+ if err != nil {
+ logger.Error().Err(err).Msg("Failed to build font service root uri")
+ return err
+ }
+
+ fontService, err = font.NewService(
+ font.ServiceOptions{}.
+ WithFontFS(fontFS).
+ WithRootURI(fontServiceRootURI).
+ WithGatewaySelector(gatewaySelector).
+ WithLogger(logger).
+ WithPreviewText(cfg.Font.PreviewText),
+ )
+ if err != nil {
+ return err
+ }
+ }
+
+ 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
+ }
+ notificationService, err = notification.NewService(
+ notification.ServiceOptions{}.
+ WithLogger(logger).
+ WithGatewaySelector(gatewaySelector).
+ WithEventPublisher(natsStream).
+ WithMachineAuthAPIKey(cfg.MachineAuthAPIKey),
+ )
+ if err != nil {
+ return err
+ }
+ }
+
// start HTTP server
httpServer, err := http.Server(
http.Adapter(connector.NewHttpAdapter(gatewaySelector, cfg, st, selector.NewSelector(selector.Registry(registry.GetRegistry())))),
@@ -145,6 +199,8 @@ func Server(cfg *config.Config) *cobra.Command {
http.Context(ctx),
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 9b4594bd93..c71bebdb48 100644
--- a/services/collaboration/pkg/config/config.go
+++ b/services/collaboration/pkg/config/config.go
@@ -12,7 +12,9 @@ type Config struct {
Service Service `yaml:"-"`
App App `yaml:"app"`
+ Font Font `yaml:"font"`
Store Store `yaml:"store"`
+ Events Events `yaml:"events"`
TokenManager *TokenManager `yaml:"token_manager"`
@@ -26,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 20aaf45b86..df4ea57679 100644
--- a/services/collaboration/pkg/config/defaults/defaultconfig.go
+++ b/services/collaboration/pkg/config/defaults/defaultconfig.go
@@ -1,8 +1,10 @@
package defaults
import (
+ "path/filepath"
"time"
+ "github.com/opencloud-eu/opencloud/pkg/config/defaults"
"github.com/opencloud-eu/opencloud/pkg/shared"
"github.com/opencloud-eu/opencloud/pkg/structs"
"github.com/opencloud-eu/opencloud/services/collaboration/pkg/config"
@@ -33,6 +35,14 @@ func DefaultConfig() *config.Config {
Duration: "12h",
},
},
+ Font: config.Font{
+ 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"},
@@ -86,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/config/font.go b/services/collaboration/pkg/config/font.go
new file mode 100644
index 0000000000..5782470788
--- /dev/null
+++ b/services/collaboration/pkg/config/font.go
@@ -0,0 +1,6 @@
+package config
+
+type Font struct {
+ AssetPath string `yaml:"asset_path" env:"COLLABORATION_FONT_ASSET_PATH" desc:"Serve fonts from a path on the filesystem instead of the builtin assets. If not defined, the root directory derives from $OC_BASE_DATA_PATH/collaboration/fonts" introductionVersion:"%%NEXT%%"`
+ PreviewText string `yaml:"preview_text" env:"COLLABORATION_FONT_PREVIEW_TEXT" desc:"The text that will be displayed in the font preview." introductionVersion:"%%NEXT%%"`
+}
diff --git a/services/collaboration/pkg/font/service.go b/services/collaboration/pkg/font/service.go
new file mode 100644
index 0000000000..20300853b2
--- /dev/null
+++ b/services/collaboration/pkg/font/service.go
@@ -0,0 +1,352 @@
+package font
+
+import (
+ "crypto/sha256"
+ "encoding/json"
+ "fmt"
+ "image"
+ "image/color"
+ "image/png"
+ "io"
+ "mime"
+ "net/http"
+ "net/url"
+ "path"
+ "path/filepath"
+ "strconv"
+
+ gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
+ "github.com/go-playground/validator/v10"
+ "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
+ "github.com/pkg/errors"
+ "github.com/spf13/afero"
+ "golang.org/x/image/font"
+ "golang.org/x/image/font/opentype"
+ "golang.org/x/image/font/sfnt"
+ "golang.org/x/image/math/fixed"
+
+ "github.com/opencloud-eu/opencloud/pkg/log"
+ "github.com/opencloud-eu/opencloud/services/collaboration/pkg/collaboration"
+)
+
+type ServiceOptions struct {
+ logger log.Logger `validate:"required"`
+ fontFS afero.Fs `validate:"required"`
+ rootURI string `validate:"required,url"`
+ previewText string `validate:"required,min=1"`
+ gatewaySelector pool.Selectable[gateway.GatewayAPIClient] `validate:"required"`
+}
+
+func (o ServiceOptions) WithFontFS(fSys afero.Fs) ServiceOptions {
+ o.fontFS = fSys
+ return o
+}
+
+func (o ServiceOptions) WithRootURI(rootURI string) ServiceOptions {
+ o.rootURI = rootURI
+ return o
+}
+
+func (o ServiceOptions) WithLogger(logger log.Logger) ServiceOptions {
+ o.logger = logger
+ return o
+}
+
+func (o ServiceOptions) WithPreviewText(txt string) ServiceOptions {
+ o.previewText = txt
+ return o
+}
+
+func (o ServiceOptions) WithGatewaySelector(gws pool.Selectable[gateway.GatewayAPIClient]) ServiceOptions {
+ o.gatewaySelector = gws
+ return o
+}
+
+func (o ServiceOptions) validate() error {
+ return validator.New(
+ validator.WithPrivateFieldValidation(),
+ validator.WithRequiredStructEnabled(),
+ ).Struct(o)
+}
+
+type Service struct {
+ logger log.Logger
+ fontFS afero.Fs
+ rootURI string
+ previewText string
+ gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
+}
+
+func NewService(options ServiceOptions) (Service, error) {
+ if err := options.validate(); err != nil {
+ return Service{}, err
+ }
+
+ return Service(options), nil
+}
+
+func (s Service) DeleteFont(w http.ResponseWriter, r *http.Request) {
+ gatewayClient, err := s.gatewaySelector.Next()
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ _, canManage, err := collaboration.CheckPermissions(gatewayClient, r.Context(), collaboration.PermissionCollaborationManageFonts)
+ switch {
+ case err != nil:
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ case !canManage:
+ w.WriteHeader(http.StatusForbidden)
+ return
+ }
+
+ fontName := r.PathValue("id")
+ if fontName == "" {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ err = s.fontFS.Remove(fontName)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ w.WriteHeader(http.StatusOK)
+}
+
+func (s Service) GetFont(w http.ResponseWriter, r *http.Request) {
+ fontName := r.PathValue("id")
+ if fontName == "" {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ b, err := s.getFont(fontName)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ contentType := mime.TypeByExtension(filepath.Ext(fontName))
+ if contentType == "" {
+ contentType = "application/octet-stream"
+ }
+
+ w.Header().Set("Content-Type", contentType)
+ w.Header().Set("Content-Length", strconv.Itoa(len(b)))
+ w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+
+ _, _ = w.Write(b)
+}
+
+func (s Service) PreviewFont(w http.ResponseWriter, r *http.Request) {
+ fontName := r.PathValue("id")
+ if fontName == "" {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ b, err := s.getFont(fontName)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ f, err := opentype.Parse(b)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ face, err := opentype.NewFace(f, &opentype.FaceOptions{
+ Size: 55,
+ DPI: 72,
+ Hinting: font.HintingFull,
+ })
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ defer func() {
+ _ = face.Close()
+ }()
+
+ width := 300
+ height := 70
+ img := image.NewRGBA(image.Rect(0, 0, width, height))
+
+ bg := color.RGBA{R: 255, G: 255, B: 255, A: 255}
+ for i := range img.Pix {
+ img.Pix[i] = bg.R
+ }
+
+ d := &font.Drawer{
+ Dst: img,
+ Src: image.NewUniform(color.RGBA{0, 0, 0, 255}),
+ Face: face,
+ Dot: fixed.Point26_6{X: fixed.I(10), Y: fixed.I(50)},
+ }
+
+ d.DrawString(s.previewText)
+
+ w.Header().Set("Content-Type", "image/png")
+ w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
+
+ if err := png.Encode(w, img); err != nil {
+ http.Error(w, "Failed to encode image", http.StatusInternalServerError)
+ return
+ }
+}
+
+func (s Service) ListFonts(w http.ResponseWriter, _ *http.Request) {
+ fontFiles, err := afero.NewIOFS(s.fontFS).ReadDir(".")
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ fontMap := []map[string]any{}
+ for _, fontFile := range fontFiles {
+ func() {
+ uri, err := url.JoinPath(s.rootURI, path.Base(fontFile.Name()))
+ if err != nil {
+ return
+ }
+
+ fontLogger := s.logger.Debug().Str("font", fontFile.Name())
+
+ b, err := s.getFont(fontFile.Name())
+ if err != nil {
+ fontLogger.Err(err).Msg("could not get font")
+ return
+ }
+
+ fnt, err := sfnt.Parse(b)
+ if err != nil {
+ fontLogger.Err(err).Msg("could not parse font")
+ return
+ }
+
+ buf := new(sfnt.Buffer)
+ nameID := func(id sfnt.NameID) string {
+ name, err := fnt.Name(buf, id)
+ if err != nil {
+ fontLogger.Err(err).Msg("could not extract font details")
+ }
+
+ return name
+ }
+
+ fontMap = append(fontMap, map[string]any{
+ "file": path.Base(fontFile.Name()),
+ "copyright": nameID(sfnt.NameIDCopyright),
+ "family": nameID(sfnt.NameIDFamily),
+ "version": nameID(sfnt.NameIDVersion),
+ "trademark": nameID(sfnt.NameIDTrademark),
+ "manufacturer": nameID(sfnt.NameIDManufacturer),
+ "designer": nameID(sfnt.NameIDDesigner),
+ "description": nameID(sfnt.NameIDDescription),
+ "vendor_url": nameID(sfnt.NameIDVendorURL),
+ "designer_url": nameID(sfnt.NameIDDesignerURL),
+ "license": nameID(sfnt.NameIDLicense),
+ "license_url": nameID(sfnt.NameIDLicenseURL),
+ "uri": uri,
+ // if stamp property changes, the font file will be re-downloaded by collabora
+ "stamp": fmt.Sprintf("%x", sha256.Sum256(b)),
+ })
+ }()
+ }
+
+ b, err := json.Marshal(map[string]any{
+ "kind": "fontconfiguration",
+ "server": "OpenCloud Fonts",
+ "fonts": fontMap,
+ })
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _, err = w.Write(b)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+}
+
+func (s Service) UploadFont(w http.ResponseWriter, r *http.Request) {
+ gatewayClient, err := s.gatewaySelector.Next()
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ _, canManage, err := collaboration.CheckPermissions(gatewayClient, r.Context(), collaboration.PermissionCollaborationManageFonts)
+ switch {
+ case err != nil:
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ case !canManage:
+ w.WriteHeader(http.StatusForbidden)
+ return
+ }
+
+ file, fileHeader, err := r.FormFile("font")
+ switch {
+ case err != nil && errors.Is(err, http.ErrMissingFile):
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ case err != nil:
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ defer func() {
+ _ = file.Close()
+ }()
+
+ b, err := io.ReadAll(file)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ if _, err = sfnt.Parse(b); err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ err = afero.WriteFile(s.fontFS, fileHeader.Filename, b, 0o666)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ w.WriteHeader(http.StatusOK)
+}
+
+func (s Service) getFont(name string) ([]byte, error) {
+ fontFile, err := s.fontFS.Open(name)
+ if err != nil {
+ return nil, fmt.Errorf("could not open font file: %w", err)
+ }
+ defer func() {
+ _ = fontFile.Close()
+ }()
+
+ fontStat, err := fontFile.Stat()
+ if err != nil || fontStat.IsDir() {
+ return nil, fmt.Errorf("could not stat font file: %w", err)
+ }
+
+ b, err := io.ReadAll(fontFile)
+ if err != nil || len(b) == 0 {
+ return nil, fmt.Errorf("could not read font file: %w", err)
+ }
+
+ return b, nil
+}
diff --git a/services/collaboration/pkg/font/service_test.go b/services/collaboration/pkg/font/service_test.go
new file mode 100644
index 0000000000..102ff0c2c6
--- /dev/null
+++ b/services/collaboration/pkg/font/service_test.go
@@ -0,0 +1,224 @@
+package font_test
+
+import (
+ "bytes"
+ "embed"
+ "io"
+ "io/fs"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
+ userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
+ cs3Permissions "github.com/cs3org/go-cs3apis/cs3/permissions/v1beta1"
+ cs3RPC "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
+ revaCtx "github.com/opencloud-eu/reva/v2/pkg/ctx"
+ "github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
+ cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
+ "github.com/spf13/afero"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+ "github.com/tidwall/gjson"
+ "google.golang.org/grpc"
+
+ "github.com/opencloud-eu/opencloud/pkg/log"
+ "github.com/opencloud-eu/opencloud/services/collaboration/pkg/font"
+)
+
+//go:embed testdata/*
+var testdata embed.FS
+
+// Helper to create a gatewaySelector
+func newGatewaySelector() pool.Selectable[gateway.GatewayAPIClient] {
+ gatewayAPIClient := &cs3mocks.GatewayAPIClient{}
+ gatewayAPIClient.On("CheckPermission", mock.Anything, mock.Anything).Return(
+ &cs3Permissions.CheckPermissionResponse{
+ Status: &cs3RPC.Status{
+ Code: cs3RPC.Code_CODE_OK,
+ },
+ }, nil)
+ gatewaySelector := pool.GetSelector[gateway.GatewayAPIClient](
+ "GatewaySelector",
+ "eu.opencloud.api.gateway",
+ func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
+ return gatewayAPIClient
+ },
+ )
+
+ return gatewaySelector
+}
+
+func TestService_PreviewFont(t *testing.T) {
+ testFS := afero.NewMemMapFs()
+ svc, err := font.NewService(
+ font.ServiceOptions{}.
+ WithFontFS(testFS).
+ WithPreviewText("a").
+ WithLogger(log.NopLogger()).
+ WithRootURI("http://test.local").
+ WithGatewaySelector(newGatewaySelector()),
+ )
+ require.NoError(t, err)
+
+ testDataFontB, err := testdata.ReadFile("testdata/arimo-regular.ttf")
+ require.NoError(t, err)
+
+ testDataFontPNG, err := testdata.ReadFile("testdata/arimo-regular.png")
+ require.NoError(t, err)
+
+ _ = afero.WriteFile(testFS, "arimo-regular.ttf", testDataFontB, 0644)
+ defer func() {
+ require.NoError(t, testFS.Remove("arimo-regular.ttf"))
+ }()
+
+ req, _ := http.NewRequest(http.MethodGet, "/", nil)
+ req.SetPathValue("id", "arimo-regular.ttf")
+ resp := httptest.NewRecorder()
+ svc.PreviewFont(resp, req)
+ require.Equal(t, resp.Body.Bytes(), testDataFontPNG)
+}
+
+func TestService_DeleteFont(t *testing.T) {
+ testFS := afero.NewMemMapFs()
+ svc, err := font.NewService(
+ font.ServiceOptions{}.
+ WithFontFS(testFS).
+ WithPreviewText("a").
+ WithLogger(log.NopLogger()).
+ WithRootURI("http://test.local").
+ WithGatewaySelector(newGatewaySelector()),
+ )
+ require.NoError(t, err)
+
+ testDataFontB, err := testdata.ReadFile("testdata/arimo-regular.ttf")
+ require.NoError(t, err)
+
+ _ = afero.WriteFile(testFS, "arimo-regular.ttf", testDataFontB, 0644)
+
+ _, err = testFS.Stat("arimo-regular.ttf") // ensure the file exists before deletion
+ require.NoError(t, err)
+
+ req, _ := http.NewRequest(http.MethodGet, "/", nil)
+ req.SetPathValue("id", "arimo-regular.ttf")
+ req = req.WithContext(revaCtx.ContextSetUser(req.Context(), &userpb.User{
+ Id: &userpb.UserId{
+ OpaqueId: "user",
+ },
+ }))
+
+ resp := httptest.NewRecorder()
+ svc.DeleteFont(resp, req)
+
+ _, err = testFS.Stat("arimo-regular.ttf") // ensure the file exists before deletion
+ require.ErrorIs(t, err, fs.ErrNotExist)
+}
+
+func TestService_GetFont(t *testing.T) {
+ testFS := afero.NewMemMapFs()
+ svc, err := font.NewService(
+ font.ServiceOptions{}.
+ WithFontFS(testFS).
+ WithPreviewText("a").
+ WithLogger(log.NopLogger()).
+ WithRootURI("http://test.local").
+ WithGatewaySelector(newGatewaySelector()),
+ )
+ require.NoError(t, err)
+
+ testDataFontB, err := testdata.ReadFile("testdata/arimo-regular.ttf")
+ require.NoError(t, err)
+
+ _ = afero.WriteFile(testFS, "arimo-regular.ttf", testDataFontB, 0644)
+ defer func() {
+ require.NoError(t, testFS.Remove("arimo-regular.ttf"))
+ }()
+
+ req, _ := http.NewRequest(http.MethodGet, "/", nil)
+ req.SetPathValue("id", "arimo-regular.ttf")
+ resp := httptest.NewRecorder()
+ svc.GetFont(resp, req)
+ require.Equal(t, resp.Body.Bytes(), testDataFontB)
+}
+
+func TestService_ListFonts(t *testing.T) {
+ testFS := afero.NewMemMapFs()
+ svc, err := font.NewService(
+ font.ServiceOptions{}.
+ WithFontFS(testFS).
+ WithPreviewText("a").
+ WithLogger(log.NopLogger()).
+ WithRootURI("http://test.local").
+ WithGatewaySelector(newGatewaySelector()),
+ )
+ require.NoError(t, err)
+
+ t.Run("no fonts", func(t *testing.T) {
+ req, _ := http.NewRequest(http.MethodGet, "/", nil)
+ resp := httptest.NewRecorder()
+ svc.ListFonts(resp, req)
+
+ jsonData := gjson.Parse(resp.Body.String())
+ require.Equal(t, jsonData.Get("fonts").String(), "[]") // empty array, not `null`
+ })
+
+ t.Run("with fonts", func(t *testing.T) {
+ testDataFontB, err := testdata.ReadFile("testdata/arimo-regular.ttf")
+ require.NoError(t, err)
+
+ fontconfigurationB, err := testdata.ReadFile("testdata/fontconfiguration.json")
+ require.NoError(t, err)
+
+ _ = afero.WriteFile(testFS, "arimo-regular.ttf", testDataFontB, 0644)
+ defer func() {
+ require.NoError(t, testFS.Remove("arimo-regular.ttf"))
+ }()
+
+ req, _ := http.NewRequest(http.MethodGet, "/", nil)
+ resp := httptest.NewRecorder()
+ svc.ListFonts(resp, req)
+
+ jsonData := gjson.Parse(resp.Body.String())
+ require.JSONEq(t, jsonData.String(), string(fontconfigurationB))
+ })
+}
+
+func TestService_UploadFont(t *testing.T) {
+ testFS := afero.NewMemMapFs()
+ svc, err := font.NewService(
+ font.ServiceOptions{}.
+ WithFontFS(testFS).
+ WithPreviewText("a").
+ WithLogger(log.NopLogger()).
+ WithRootURI("http://test.local").
+ WithGatewaySelector(newGatewaySelector()),
+ )
+ require.NoError(t, err)
+
+ testDataFontB, err := testdata.ReadFile("testdata/arimo-regular.ttf")
+ require.NoError(t, err)
+
+ var b bytes.Buffer
+ w := multipart.NewWriter(&b)
+ part, _ := w.CreateFormFile("font", "arimo-regular.ttf")
+ _, _ = part.Write(testDataFontB)
+ _ = w.Close()
+
+ req, _ := http.NewRequest(http.MethodPost, "/", &b)
+ req.Header.Set("Content-Type", w.FormDataContentType())
+ req = req.WithContext(revaCtx.ContextSetUser(req.Context(), &userpb.User{
+ Id: &userpb.UserId{
+ OpaqueId: "user",
+ },
+ }))
+ resp := httptest.NewRecorder()
+
+ svc.UploadFont(resp, req)
+ testFSFontF, err := testFS.Open("arimo-regular.ttf")
+ require.NoError(t, err)
+
+ testFSFontB, err := io.ReadAll(testFSFontF)
+ require.NoError(t, err)
+ require.Equal(t, testDataFontB, testFSFontB)
+}
diff --git a/services/collaboration/pkg/font/testdata/arimo-regular.png b/services/collaboration/pkg/font/testdata/arimo-regular.png
new file mode 100644
index 0000000000..9d843850c7
Binary files /dev/null and b/services/collaboration/pkg/font/testdata/arimo-regular.png differ
diff --git a/services/collaboration/pkg/font/testdata/arimo-regular.ttf b/services/collaboration/pkg/font/testdata/arimo-regular.ttf
new file mode 100644
index 0000000000..22d58b65bd
Binary files /dev/null and b/services/collaboration/pkg/font/testdata/arimo-regular.ttf differ
diff --git a/services/collaboration/pkg/font/testdata/fontconfiguration.json b/services/collaboration/pkg/font/testdata/fontconfiguration.json
new file mode 100644
index 0000000000..e429a646aa
--- /dev/null
+++ b/services/collaboration/pkg/font/testdata/fontconfiguration.json
@@ -0,0 +1,22 @@
+{
+ "fonts": [
+ {
+ "copyright": "Copyright 2020 The Arimo Project Authors (https://github.com/googlefonts/arimo)",
+ "description": "Arimo was designed by Steve Matteson as an innovative, refreshing sans serif design that is metrically compatible with Arial(tm). Arimo offers improved on-screen readability characteristics and the pan-European WGL character set and solves the needs of developers looking for width-compatible fonts to address document portability across platforms.",
+ "designer": "Steve Matteson",
+ "designer_url": "http://www.monotype.com/studio",
+ "family": "Arimo",
+ "file": "arimo-regular.ttf",
+ "license": "This Font Software is licensed under the SIL Open Font License, Version 1.1. This Font Software is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the SIL Open Font License for the specific language, permissions and limitations governing your use of this Font Software.",
+ "license_url": "http://scripts.sil.org/OFL",
+ "manufacturer": "Monotype Imaging Inc.",
+ "stamp": "f405056d25ddc3ad89a852db367f1dc075d8c7212fddcf7c488e212efeb8b176",
+ "trademark": "Arimo is a trademark of Google Inc.",
+ "uri": "http://test.local/arimo-regular.ttf",
+ "vendor_url": "http://www.google.com/get/noto/",
+ "version": "Version 1.33"
+ }
+ ],
+ "kind": "fontconfiguration",
+ "server": "OpenCloud Fonts"
+}
diff --git a/services/collaboration/pkg/font/testdata/props.txt b/services/collaboration/pkg/font/testdata/props.txt
new file mode 100644
index 0000000000..0fbf3453dc
--- /dev/null
+++ b/services/collaboration/pkg/font/testdata/props.txt
@@ -0,0 +1 @@
+arimo-regular.ttf: https://github.com/ryanoasis/nerd-fonts/blob/master/src/unpatched-fonts/Arimo/Regular/Arimo-Regular.ttf
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..bd1bac80a3
--- /dev/null
+++ b/services/collaboration/pkg/notification/service.go
@@ -0,0 +1,153 @@
+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)
+ return
+ }
+
+ 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 ce7dd58ebc..de009249b4 100644
--- a/services/collaboration/pkg/server/http/option.go
+++ b/services/collaboration/pkg/server/http/option.go
@@ -3,24 +3,29 @@ package http
import (
"context"
+ microstore "go-micro.dev/v4/store"
+ "go.opentelemetry.io/otel/trace"
+
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/services/collaboration/pkg/config"
"github.com/opencloud-eu/opencloud/services/collaboration/pkg/connector"
- microstore "go-micro.dev/v4/store"
- "go.opentelemetry.io/otel/trace"
+ "github.com/opencloud-eu/opencloud/services/collaboration/pkg/font"
+ "github.com/opencloud-eu/opencloud/services/collaboration/pkg/notification"
)
// Option defines a single option function.
type Option func(o *Options)
-// Options defines the available options for this package.
+// 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
+ 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.
@@ -34,7 +39,7 @@ func newOptions(opts ...Option) Options {
return opt
}
-// App provides a function to set the logger option.
+// Adapter provides a function to set the Adapter option.
func Adapter(val *connector.HttpAdapter) Option {
return func(o *Options) {
o.Adapter = val
@@ -75,3 +80,17 @@ func Store(val microstore.Store) Option {
o.Store = val
}
}
+
+// FontService provides a function to set the FontService option
+func FontService(val font.Service) Option {
+ return func(o *Options) {
+ 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 bd8953532e..a2f4282c35 100644
--- a/services/collaboration/pkg/server/http/server.go
+++ b/services/collaboration/pkg/server/http/server.go
@@ -6,6 +6,9 @@ import (
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
+ "github.com/riandyrn/otelchi"
+ "go-micro.dev/v4"
+
"github.com/opencloud-eu/opencloud/pkg/account"
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/middleware"
@@ -13,8 +16,6 @@ import (
"github.com/opencloud-eu/opencloud/pkg/tracing"
"github.com/opencloud-eu/opencloud/pkg/version"
colabmiddleware "github.com/opencloud-eu/opencloud/services/collaboration/pkg/middleware"
- "github.com/riandyrn/otelchi"
- "go-micro.dev/v4"
)
// Server initializes the http service and server.
@@ -94,6 +95,8 @@ 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
@@ -209,5 +212,24 @@ func prepareRoutes(r *chi.Mux, options Options) {
adapter.GetAvatar(w, r)
})
})
+
+ })
+ 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.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..6fe13fb23f
--- /dev/null
+++ b/services/notifications/pkg/service/resource.go
@@ -0,0 +1,98 @@
+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()
+ 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 187e52be6f..e3f2e380c5 100644
--- a/services/proxy/pkg/config/defaults/defaultconfig.go
+++ b/services/proxy/pkg/config/defaults/defaultconfig.go
@@ -291,6 +291,21 @@ func DefaultPolicies() []config.Policy {
Unprotected: true,
SkipXAccessToken: true,
},
+ {
+ 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???
+ },
+ {
+ Endpoint: "/collaboration",
+ Service: "eu.opencloud.web.collaboration",
+ Unprotected: true,
+ },
},
},
}
diff --git a/services/proxy/pkg/middleware/authentication.go b/services/proxy/pkg/middleware/authentication.go
index 7703fdf62b..4c7b879699 100644
--- a/services/proxy/pkg/middleware/authentication.go
+++ b/services/proxy/pkg/middleware/authentication.go
@@ -7,12 +7,13 @@ import (
"regexp"
"strings"
- "github.com/opencloud-eu/opencloud/services/proxy/pkg/router"
- "github.com/opencloud-eu/opencloud/services/proxy/pkg/webdav"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"golang.org/x/text/cases"
"golang.org/x/text/language"
+
+ "github.com/opencloud-eu/opencloud/services/proxy/pkg/router"
+ "github.com/opencloud-eu/opencloud/services/proxy/pkg/webdav"
)
var (
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 cc7a9bacee..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,8 @@ func ServiceAccountBundle() *settingsmsg.Bundle {
Settings: []*settingsmsg.Setting{
AccountManagementPermission(All),
ChangeLogoPermission(All),
+ CollaborationPublishNotificationPermission(All),
+ CollaborationManageFontsPermission(All),
CreatePublicLinkPermission(All),
CreateSharePermission(All),
CreateSpacesPermission(All),
@@ -115,6 +119,8 @@ func generateBundleAdminRole() *settingsmsg.Bundle {
AccountManagementPermission(All),
AutoAcceptSharesPermission(Own),
ChangeLogoPermission(All),
+ CollaborationPublishNotificationPermission(All),
+ CollaborationManageFontsPermission(All),
CreatePublicLinkPermission(All),
CreateSharePermission(All),
CreateSpacesPermission(All),
@@ -178,6 +184,7 @@ func generateBundleSpaceAdminRole() *settingsmsg.Bundle {
ProfileEventPostprocessingStepFinishedPermission(Own),
LanguageManagementPermission(Own),
ListFavoritesPermission(Own),
+ CollaborationPublishNotificationPermission(All),
ListSpacesPermission(All),
ManageSpacePropertiesPermission(All),
SelfManagementPermission(Own),
@@ -216,6 +223,7 @@ func generateBundleUserRole() *settingsmsg.Bundle {
ProfileEventPostprocessingStepFinishedPermission(Own),
LanguageManagementPermission(Own),
ListFavoritesPermission(Own),
+ CollaborationPublishNotificationPermission(All),
SelfManagementPermission(Own),
WriteFavoritesPermission(Own),
},
@@ -343,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 fb661fd20d..4f80c1d124 100644
--- a/services/settings/pkg/store/defaults/permissions.go
+++ b/services/settings/pkg/store/defaults/permissions.go
@@ -67,6 +67,44 @@ func ChangeLogoPermission(c settingsmsg.Permission_Constraint) *settingsmsg.Sett
}
}
+// CollaborationManageFontsPermission is the permission to manage fonts
+func CollaborationManageFontsPermission(c settingsmsg.Permission_Constraint) *settingsmsg.Setting {
+ return &settingsmsg.Setting{
+ Id: "ed83fc10-1f54-4a9e-b5a7-fb517f5f3e01",
+ Name: "Collaboration.Fonts.Manage",
+ DisplayName: "Manage fonts",
+ Description: "This permission permits to manage the collaboration fonts.",
+ Resource: &settingsmsg.Resource{
+ Type: settingsmsg.Resource_TYPE_SYSTEM,
+ },
+ Value: &settingsmsg.Setting_PermissionValue{
+ PermissionValue: &settingsmsg.Permission{
+ Operation: settingsmsg.Permission_OPERATION_READWRITE,
+ Constraint: c,
+ },
+ },
+ }
+}
+
+// CollaborationPublishNotificationPermission is the permission to publish collaboration notifications
+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"),
diff --git a/services/web/pkg/apps/apps.go b/services/web/pkg/apps/apps.go
index 9e7f8c90c5..1a6b3bbb84 100644
--- a/services/web/pkg/apps/apps.go
+++ b/services/web/pkg/apps/apps.go
@@ -89,20 +89,28 @@ func List(logger log.Logger, data map[string]config.App, fSystems ...fs.FS) []Ap
appData = data
}
- application, err := build(fSystem, name, appData)
- if err != nil {
- // if app creation fails, log the error and continue with the next app
- logger.Debug().Err(err).Str("path", entry.Name()).Msg("failed to load application")
- continue
- }
+ for _, appRoot := range []string{
+ name,
+ path.Join(name, "dist"), // some applications have their artifacts in the dist/ folder
+ } {
+ application, err := build(fSystem, appRoot, appData)
+ if err != nil {
+ // if app creation fails, log the error and continue with the next app
+ logger.Debug().Err(err).Str("path", entry.Name()).Msg("failed to load application")
+ continue
+ }
- if application.Disabled {
- // if the app is disabled, skip it
- continue
- }
+ if application.Disabled {
+ // if the app is disabled, skip it
+ continue
+ }
- // everything is fine, add the application to the list of applications
- registry[name] = application
+ // everything is fine, add the application to the list of applications
+ registry[name] = application
+
+ // application found, done here
+ break
+ }
}
}
@@ -123,7 +131,9 @@ func build(fSystem fs.FS, id string, globalConfig config.App) (Application, erro
if err != nil {
return Application{}, errors.Join(err, ErrMissingManifest)
}
- defer r.Close()
+ defer func() {
+ _ = r.Close()
+ }()
if json.NewDecoder(r).Decode(&application) != nil {
return Application{}, errors.Join(err, ErrInvalidManifest)
diff --git a/services/web/pkg/apps/apps_test.go b/services/web/pkg/apps/apps_test.go
index f36c3df676..963794e532 100644
--- a/services/web/pkg/apps/apps_test.go
+++ b/services/web/pkg/apps/apps_test.go
@@ -181,8 +181,20 @@ func TestList(t *testing.T) {
"app-3/manifest.json": &fstest.MapFile{
Data: []byte(`{"id":"app-3", "entrypoint":"entrypoint.js", "config": {"foo": "fs2"}}`),
},
+ }, fstest.MapFS{
+ "app-unknown": dir,
+ "app-unknown/bin/entrypoint.js": &fstest.MapFile{},
+ "app-unknown/bin/manifest.json": &fstest.MapFile{
+ Data: []byte(`{"id":"app-unknown", "entrypoint":"entrypoint.js"}`),
+ },
+ }, fstest.MapFS{
+ "app-dist": dir,
+ "app-dist/dist/entrypoint.js": &fstest.MapFile{},
+ "app-dist/dist/manifest.json": &fstest.MapFile{
+ Data: []byte(`{"id":"app-dist", "entrypoint":"entrypoint.js", "config": {"folder": "dist"}}`),
+ },
})
- g.Expect(len(applications)).To(gomega.Equal(3))
+ g.Expect(len(applications)).To(gomega.Equal(4))
for _, application := range applications {
switch {
@@ -193,6 +205,8 @@ func TestList(t *testing.T) {
case application.Entrypoint == "app-3/entrypoint.js":
g.Expect(application.Config["foo"]).To(gomega.Equal("local conf 1"))
g.Expect(application.Config["bar"]).To(gomega.Equal("local conf 2"))
+ case application.Entrypoint == "app-dist/dist/entrypoint.js":
+ g.Expect(application.Config["folder"]).To(gomega.Equal("dist"))
default:
t.Fatalf("unexpected application %s", application.Entrypoint)
}
diff --git a/services/web/pkg/service/v0/service.go b/services/web/pkg/service/v0/service.go
index a16ba5336f..e21e9bc637 100644
--- a/services/web/pkg/service/v0/service.go
+++ b/services/web/pkg/service/v0/service.go
@@ -19,7 +19,6 @@ import (
"github.com/opencloud-eu/opencloud/pkg/log"
"github.com/opencloud-eu/opencloud/pkg/middleware"
"github.com/opencloud-eu/opencloud/pkg/tracing"
- "github.com/opencloud-eu/opencloud/pkg/x/io/fsx"
"github.com/opencloud-eu/opencloud/services/web/pkg/assets"
"github.com/opencloud-eu/opencloud/services/web/pkg/config"
"github.com/opencloud-eu/opencloud/services/web/pkg/theme"
@@ -54,8 +53,6 @@ func NewService(opts ...Option) (Service, error) {
logger: options.Logger,
config: options.Config,
mux: m,
- coreFS: options.CoreFS,
- themeFS: options.ThemeFS,
gatewaySelector: options.GatewaySelector,
}
@@ -92,7 +89,7 @@ func NewService(opts ...Option) (Service, error) {
options.Config.HTTP.CacheTTL,
))
r.Mount("/", svc.Static(
- svc.coreFS,
+ options.CoreFS,
svc.config.HTTP.Root,
options.Config.HTTP.CacheTTL,
))
@@ -110,8 +107,6 @@ type Web struct {
logger log.Logger
config *config.Config
mux *chi.Mux
- coreFS fs.FS
- themeFS *fsx.FallbackFS
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
}