diff --git a/.gitignore b/.gitignore index 4444c17049..79d46d1d89 100644 --- a/.gitignore +++ b/.gitignore @@ -65,4 +65,10 @@ go.work.sum .DS_Store # example deployments -**/opencloud-sandbox-* \ No newline at end of file +**/opencloud-sandbox-* + +# web apps +!./services/web/assets/ +!./services/web/assets/apps/ +!./services/web/assets/apps/collaboration-settings +!./services/web/assets/apps/collaboration-settings/** diff --git a/services/collaboration/pkg/collaboration/collaboration.go b/services/collaboration/pkg/collaboration/collaboration.go new file mode 100644 index 0000000000..970846be62 --- /dev/null +++ b/services/collaboration/pkg/collaboration/collaboration.go @@ -0,0 +1,39 @@ +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" +) + +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..38de01be32 100644 --- a/services/collaboration/pkg/command/server.go +++ b/services/collaboration/pkg/command/server.go @@ -4,27 +4,32 @@ import ( "context" "fmt" "net" + "net/url" "os/signal" "time" + "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/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/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/rgrpc/todo/pool" - "github.com/opencloud-eu/reva/v2/pkg/store" - - "github.com/spf13/cobra" - "go-micro.dev/v4/selector" - microstore "go-micro.dev/v4/store" ) // Server is the entrypoint for the server command. @@ -137,6 +142,35 @@ 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 := fontFS.MkdirAll("/", 0o755); err != nil { + logger.Error().Err(err).Msg("Failed to build font service root uri") + return err + } + + service, err := font.NewService( + font.ServiceOptions{}. + WithFontFS(fontFS). + WithRootURI(fontServiceRootURI). + WithGatewaySelector(gatewaySelector). + WithLogger(logger). + WithPreviewText(cfg.Font.PreviewText), + ) + if err != nil { + return err + } + + fontService = service + } + // start HTTP server httpServer, err := http.Server( http.Adapter(connector.NewHttpAdapter(gatewaySelector, cfg, st, selector.NewSelector(selector.Registry(registry.GetRegistry())))), @@ -145,6 +179,7 @@ func Server(cfg *config.Config) *cobra.Command { http.Context(ctx), http.TracerProvider(traceProvider), http.Store(st), + http.FontService(fontService), ) 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..bee6021ccb 100644 --- a/services/collaboration/pkg/config/config.go +++ b/services/collaboration/pkg/config/config.go @@ -12,6 +12,7 @@ type Config struct { Service Service `yaml:"-"` App App `yaml:"app"` + Font Font `yaml:"font"` Store Store `yaml:"store"` TokenManager *TokenManager `yaml:"token_manager"` diff --git a/services/collaboration/pkg/config/defaults/defaultconfig.go b/services/collaboration/pkg/config/defaults/defaultconfig.go index 20aaf45b86..bc81fd5511 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,10 @@ func DefaultConfig() *config.Config { Duration: "12h", }, }, + Font: config.Font{ + AssetPath: filepath.Join(defaults.BaseDataPath(), "collaboration/fonts"), + PreviewText: "OpenCloud", + }, Store: config.Store{ Store: "nats-js-kv", Nodes: []string{"127.0.0.1:9233"}, 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..d6ed2ca528 --- /dev/null +++ b/services/collaboration/pkg/font/service.go @@ -0,0 +1,350 @@ +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) + } + + _, 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) + } + + _, 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/server/http/option.go b/services/collaboration/pkg/server/http/option.go index ce7dd58ebc..7d13bd7d61 100644 --- a/services/collaboration/pkg/server/http/option.go +++ b/services/collaboration/pkg/server/http/option.go @@ -3,17 +3,19 @@ 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" ) // 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 @@ -21,6 +23,7 @@ type Options struct { Config *config.Config TracerProvider trace.TracerProvider Store microstore.Store + FontService font.Service } // newOptions initializes the available default options. @@ -34,7 +37,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 +78,10 @@ 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 + } +} diff --git a/services/collaboration/pkg/server/http/server.go b/services/collaboration/pkg/server/http/server.go index bd8953532e..8a315823f5 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,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 adapter := options.Adapter logger := options.Logger // prepare basic logger for the request @@ -209,5 +211,21 @@ func prepareRoutes(r *chi.Mux, options Options) { adapter.GetAvatar(w, r) }) }) + + }) + r.Route("/collaboration", func(r chi.Router) { + 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.Post("/", fontService.UploadFont) + r.Delete("/{id}", fontService.DeleteFont) + }) + }) }) } diff --git a/services/proxy/pkg/config/defaults/defaultconfig.go b/services/proxy/pkg/config/defaults/defaultconfig.go index 187e52be6f..5dd3541e18 100644 --- a/services/proxy/pkg/config/defaults/defaultconfig.go +++ b/services/proxy/pkg/config/defaults/defaultconfig.go @@ -291,6 +291,16 @@ 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", + 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/store/defaults/defaults.go b/services/settings/pkg/store/defaults/defaults.go index cc7a9bacee..c65a3548c1 100644 --- a/services/settings/pkg/store/defaults/defaults.go +++ b/services/settings/pkg/store/defaults/defaults.go @@ -79,6 +79,7 @@ func ServiceAccountBundle() *settingsmsg.Bundle { Settings: []*settingsmsg.Setting{ AccountManagementPermission(All), ChangeLogoPermission(All), + CollaborationManageFontsPermission(All), CreatePublicLinkPermission(All), CreateSharePermission(All), CreateSpacesPermission(All), @@ -115,6 +116,7 @@ func generateBundleAdminRole() *settingsmsg.Bundle { AccountManagementPermission(All), AutoAcceptSharesPermission(Own), ChangeLogoPermission(All), + CollaborationManageFontsPermission(All), CreatePublicLinkPermission(All), CreateSharePermission(All), CreateSpacesPermission(All), diff --git a/services/settings/pkg/store/defaults/permissions.go b/services/settings/pkg/store/defaults/permissions.go index fb661fd20d..dec6aed3f0 100644 --- a/services/settings/pkg/store/defaults/permissions.go +++ b/services/settings/pkg/store/defaults/permissions.go @@ -67,6 +67,25 @@ func ChangeLogoPermission(c settingsmsg.Permission_Constraint) *settingsmsg.Sett } } +// ManageFontsPermission 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, + }, + }, + } +} + // CreatePublicLinkPermission is the permission to create public links func CreatePublicLinkPermission(c settingsmsg.Permission_Constraint) *settingsmsg.Setting { return &settingsmsg.Setting{ diff --git a/services/web/Makefile b/services/web/Makefile index 43eda45a3b..78995313e3 100644 --- a/services/web/Makefile +++ b/services/web/Makefile @@ -13,11 +13,10 @@ include ../../.make/release.mk include ../../.make/docs.mk .PHONY: node-generate-dev -node-generate-dev: pull-assets +node-generate-dev: pull-assets build-apps .PHONY: node-generate-prod -node-generate-prod: download-assets - +node-generate-prod: download-assets build-apps .PHONY: pull-assets pull-assets: @@ -43,6 +42,13 @@ download-assets: git clean -xfd assets curl --fail -slL -o- https://github.com/opencloud-eu/web/releases/download/$(WEB_ASSETS_VERSION)/web.tar.gz | tar xzf - -C assets/core/ +.PHONY: build-apps +build-apps: + @for dir in ./assets/apps/*; do \ + echo "📦 Installing in $$dir"; \ + (cd "$$dir" && pnpm install && pnpm build) || exit 1; \ + done + .PHONY: ci-node-save-licenses ci-node-save-licenses: @mkdir -p ../../third-party-licenses/node/web 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 + } + + // everything is fine, add the application to the list of applications + registry[name] = application + + // application found, done here + break } - - 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 } } @@ -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] }