diff --git a/changelog/unreleased/space-templates.md b/changelog/unreleased/space-templates.md new file mode 100644 index 0000000000..a2076dff68 --- /dev/null +++ b/changelog/unreleased/space-templates.md @@ -0,0 +1,5 @@ +Enhancement: Make server side space templates production ready + +Fixes several smaller bugs and adds some improvements to space templates, introduced with https://github.com/owncloud/ocis/pull/8558 + +https://github.com/owncloud/ocis/pull/8723 diff --git a/ocis-pkg/l10n/l10n.go b/ocis-pkg/l10n/l10n.go new file mode 100644 index 0000000000..f9b89528f1 --- /dev/null +++ b/ocis-pkg/l10n/l10n.go @@ -0,0 +1,90 @@ +// package l10n holds translation mechanics that are used by user facing services (notifications, userlog, graph) +package l10n + +import ( + "context" + "errors" + "io/fs" + "os" + + "github.com/leonelquinteros/gotext" + "github.com/owncloud/ocis/v2/ocis-pkg/middleware" + settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0" + "github.com/owncloud/ocis/v2/services/settings/pkg/store/defaults" + micrometadata "go-micro.dev/v4/metadata" +) + +// Template marks a string as translatable +func Template(s string) string { return s } + +// Translator is able to translate strings +type Translator struct { + fs fs.FS + defaultLocale string + domain string +} + +// NewTranslator creates a Translator with library path and language code and load default domain +func NewTranslator(defaultLocale string, domain string, fsys fs.FS) Translator { + return Translator{ + fs: fsys, + defaultLocale: defaultLocale, + domain: domain, + } +} + +// NewTranslatorFromCommonConfig creates a new Translator from legacy config +func NewTranslatorFromCommonConfig(defaultLocale string, domain string, path string, fsys fs.FS, fsSubPath string) Translator { + var filesystem fs.FS + if path == "" { + filesystem, _ = fs.Sub(fsys, fsSubPath) + } else { // use custom path instead + filesystem = os.DirFS(path) + } + return NewTranslator(defaultLocale, domain, filesystem) +} + +// Translate translates a string to the locale +func (t Translator) Translate(str, locale string) string { + return t.Locale(locale).Get(str) +} + +// Locale returns the gotext.Locale, use `.Get` method to translate strings +func (t Translator) Locale(locale string) *gotext.Locale { + l := gotext.NewLocaleFS(locale, t.fs) + l.AddDomain(t.domain) // make domain configurable only if needed + if locale != "en" && len(l.GetTranslations()) == 0 { + l = gotext.NewLocaleFS(t.defaultLocale, t.fs) + l.AddDomain(t.domain) // make domain configurable only if needed + } + return l +} + +// MustGetUserLocale returns the locale the user wants to use, omitting errors +func MustGetUserLocale(ctx context.Context, userID string, preferedLang string, vc settingssvc.ValueService) string { + if preferedLang != "" { + return preferedLang + } + + locale, _ := GetUserLocale(ctx, userID, vc) + return locale +} + +// GetUserLocale returns the locale of the user +func GetUserLocale(ctx context.Context, userID string, vc settingssvc.ValueService) (string, error) { + resp, err := vc.GetValueByUniqueIdentifiers( + micrometadata.Set(ctx, middleware.AccountID, userID), + &settingssvc.GetValueByUniqueIdentifiersRequest{ + AccountUuid: userID, + SettingId: defaults.SettingUUIDProfileLanguage, + }, + ) + if err != nil { + return "", err + } + val := resp.GetValue().GetValue().GetListValue().GetValues() + if len(val) == 0 { + return "", errors.New("no language setting found") + } + return val[0].GetStringValue(), nil +} diff --git a/services/graph/Makefile b/services/graph/Makefile index fe8a84f023..60a640b069 100644 --- a/services/graph/Makefile +++ b/services/graph/Makefile @@ -1,6 +1,10 @@ SHELL := bash NAME := graph +# Where to write the files generated by this makefile. +OUTPUT_DIR = ./pkg/service/v0/l10n +TEMPLATE_FILE = ./pkg/service/v0/l10n/graph.pot + include ../../.make/recursion.mk ############ tooling ############ @@ -30,6 +34,27 @@ ci-go-generate: $(MOCKERY) # CI runs ci-node-generate automatically before this .PHONY: ci-node-generate ci-node-generate: +############ translations ######## +.PHONY: l10n-pull +l10n-pull: + cd $(OUTPUT_DIR) && tx pull --all --force --skip --minimum-perc=75 + +.PHONY: l10n-push +l10n-push: + cd $(OUTPUT_DIR) && tx push -s --skip + +.PHONY: l10n-read +l10n-read: $(GO_XGETTEXT) + go-xgettext -o $(OUTPUT_DIR)/graph.pot --keyword=l10n.Template --add-comments -s pkg/service/v0/spacetemplates.go + +.PHONY: l10n-write +l10n-write: + +.PHONY: l10n-clean +l10n-clean: + rm -f $(TEMPLATE_FILE); + + ############ licenses ############ .PHONY: ci-node-check-licenses ci-node-check-licenses: diff --git a/services/graph/pkg/config/config.go b/services/graph/pkg/config/config.go index 950760825e..2198ddcacd 100644 --- a/services/graph/pkg/config/config.go +++ b/services/graph/pkg/config/config.go @@ -46,6 +46,7 @@ type Spaces struct { UsersCacheTTL int `yaml:"users_cache_ttl" env:"GRAPH_SPACES_USERS_CACHE_TTL" desc:"Max TTL in seconds for the spaces users cache." introductionVersion:"pre5.0"` GroupsCacheTTL int `yaml:"groups_cache_ttl" env:"GRAPH_SPACES_GROUPS_CACHE_TTL" desc:"Max TTL in seconds for the spaces groups cache." introductionVersion:"pre5.0"` StorageUsersAddress string `yaml:"storage_users_address" env:"GRAPH_SPACES_STORAGE_USERS_ADDRESS" desc:"The address of the storage-users service." introductionVersion:"5.0"` + DefaultLanguage string `yaml:"default_language" env:"OCIS_DEFAULT_LANGUAGE" desc:"The default language used by services and the WebUI. If not defined, English will be used as default. See the documentation for more details." introductionVersion:"5.0"` } type LDAP struct { diff --git a/services/graph/pkg/service/v0/drives.go b/services/graph/pkg/service/v0/drives.go index 02d54f1f8f..464cd1c702 100644 --- a/services/graph/pkg/service/v0/drives.go +++ b/services/graph/pkg/service/v0/drives.go @@ -29,6 +29,7 @@ import ( "github.com/cs3org/reva/v2/pkg/storagespace" "github.com/cs3org/reva/v2/pkg/utils" "github.com/owncloud/ocis/v2/ocis-pkg/conversions" + "github.com/owncloud/ocis/v2/ocis-pkg/l10n" "github.com/owncloud/ocis/v2/ocis-pkg/service/grpc" v0 "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/settings/v0" settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0" @@ -326,7 +327,10 @@ func (g Graph) canCreateSpace(ctx context.Context, ownPersonalHome bool) bool { func (g Graph) CreateDrive(w http.ResponseWriter, r *http.Request) { logger := g.logger.SubloggerWithRequestID(r.Context()) logger.Info().Msg("calling create drive") - us, ok := revactx.ContextGetUser(r.Context()) + + ctx := r.Context() + + us, ok := revactx.ContextGetUser(ctx) if !ok { logger.Debug().Msg("could not create drive: invalid user") errorcode.NotAllowed.Render(w, r, http.StatusUnauthorized, "invalid user") @@ -334,7 +338,7 @@ func (g Graph) CreateDrive(w http.ResponseWriter, r *http.Request) { } // TODO determine if the user tries to create his own personal space and pass that as a boolean - canCreateSpace := g.canCreateSpace(r.Context(), false) + canCreateSpace := g.canCreateSpace(ctx, false) if !canCreateSpace { logger.Debug().Bool("cancreatespace", canCreateSpace).Msg("could not create drive: insufficient permissions") // if the permission is not existing for the user in context we can assume we don't have it. Return 401. @@ -393,7 +397,7 @@ func (g Graph) CreateDrive(w http.ResponseWriter, r *http.Request) { csr.Owner = us } - resp, err := gatewayClient.CreateStorageSpace(r.Context(), &csr) + resp, err := gatewayClient.CreateStorageSpace(ctx, &csr) if err != nil { logger.Error().Err(err).Msg("could not create drive: transport error") errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) @@ -423,27 +427,38 @@ func (g Graph) CreateDrive(w http.ResponseWriter, r *http.Request) { return } - if driveType == _spaceTypeProject { - opaque, err := g.applySpaceTemplate(gatewayClient, resp.GetStorageSpace().GetRoot(), r.URL.Query().Get("template")) - if err != nil { + space := resp.GetStorageSpace() + if t := r.URL.Query().Get(TemplateParameter); t != "" && driveType == _spaceTypeProject { + loc := l10n.MustGetUserLocale(ctx, us.GetId().GetOpaqueId(), r.Header.Get(HeaderAcceptLanguage), g.valueService) + if err := g.applySpaceTemplate(ctx, gatewayClient, space.GetRoot(), t, loc); err != nil { logger.Error().Err(err).Msg("could not apply template to space") errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) return } - resp.StorageSpace.Opaque = utils.MergeOpaques(resp.GetStorageSpace().GetOpaque(), opaque) + // refetch the drive to get quota information - should we calculate this ourselves to avoid the extra call? + space, err = utils.GetSpace(ctx, space.GetId().GetOpaqueId(), gatewayClient) + if err != nil { + logger.Error().Err(err).Msg("could not refetch space") + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) + return + } } - newDrive, err := g.cs3StorageSpaceToDrive(r.Context(), webDavBaseURL, resp.GetStorageSpace(), APIVersion_1) + spaces, err := g.formatDrives(ctx, webDavBaseURL, []*storageprovider.StorageSpace{space}, APIVersion_1) if err != nil { - logger.Debug().Err(err).Msg("could not create drive: error parsing drive") + logger.Debug().Err(err).Msg("could not get drive: error parsing grpc response") errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error()) return } - newDrive.Special = g.getSpecialDriveItems(r.Context(), webDavBaseURL, resp.GetStorageSpace()) + if len(spaces) == 0 { + logger.Error().Msg("could not convert space") + errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not convert space") + return + } render.Status(r, http.StatusCreated) - render.JSON(w, r, newDrive) + render.JSON(w, r, spaces[0]) } // UpdateDrive updates the properties of a storage drive (space). diff --git a/services/graph/pkg/service/v0/graph_test.go b/services/graph/pkg/service/v0/graph_test.go index cb56ee268d..7468db8497 100644 --- a/services/graph/pkg/service/v0/graph_test.go +++ b/services/graph/pkg/service/v0/graph_test.go @@ -764,6 +764,12 @@ var _ = Describe("Graph", func() { Constraint: v0.Permission_CONSTRAINT_ALL, }, }, nil) + + gatewayClient.On("GetQuota", mock.Anything, mock.Anything).Return(&provider.GetQuotaResponse{ + Status: status.NewOK(ctx), + TotalBytes: 500, + }, nil) + jsonBody := []byte(`{"Name": "Test Space", "DriveType": "project", "Description": "This space is for testing", "DriveAlias": "project/testspace"}`) r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/drives", bytes.NewBuffer(jsonBody)).WithContext(ctx) rr := httptest.NewRecorder() diff --git a/services/graph/pkg/service/v0/l10n/.tx/config b/services/graph/pkg/service/v0/l10n/.tx/config new file mode 100644 index 0000000000..c25695aa8a --- /dev/null +++ b/services/graph/pkg/service/v0/l10n/.tx/config @@ -0,0 +1,9 @@ +[main] +host = https://www.transifex.com + +[o:owncloud-org:p:owncloud:r:ocis-graph] +file_filter = locale//LC_MESSAGES/graph.po +minimum_perc = 75 +source_file = graph.pot +source_lang = en +type = PO diff --git a/services/graph/pkg/service/v0/l10n/locale/en_GB/LC_MESSAGES/graph.po b/services/graph/pkg/service/v0/l10n/locale/en_GB/LC_MESSAGES/graph.po new file mode 100644 index 0000000000..fbf642e9c4 --- /dev/null +++ b/services/graph/pkg/service/v0/l10n/locale/en_GB/LC_MESSAGES/graph.po @@ -0,0 +1,22 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "Project-Id-Version: \n" + "Report-Msgid-Bugs-To: EMAIL\n" + "POT-Creation-Date: 2024-03-26 15:11+0100\n" + "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" + "Last-Translator: FULL NAME \n" + "Language-Team: LANGUAGE \n" + "Language: \n" + "MIME-Version: 1.0\n" + "Content-Type: text/plain; charset=CHARSET\n" + "Content-Transfer-Encoding: 8bit\n" + +#: pkg/service/v0/spacetemplates.go:29 +msgid "Here you can add a description for this Space." +msgstr "" + diff --git a/services/graph/pkg/service/v0/service.go b/services/graph/pkg/service/v0/service.go index ff1a28ea37..25c19b2dce 100644 --- a/services/graph/pkg/service/v0/service.go +++ b/services/graph/pkg/service/v0/service.go @@ -3,7 +3,6 @@ package svc import ( "crypto/tls" "crypto/x509" - "embed" "errors" "fmt" "net/http" @@ -36,11 +35,6 @@ const ( displayNameAttr = "displayName" ) -var ( - //go:embed spacetemplate/* - _spaceTemplateFS embed.FS -) - // Service defines the service handlers. type Service interface { ServeHTTP(http.ResponseWriter, *http.Request) diff --git a/services/graph/pkg/service/v0/spacetemplate/.space/image.png b/services/graph/pkg/service/v0/spacetemplate/.space/image.png deleted file mode 100644 index 3b03d6554d..0000000000 Binary files a/services/graph/pkg/service/v0/spacetemplate/.space/image.png and /dev/null differ diff --git a/services/graph/pkg/service/v0/spacetemplate/.space/readme.md b/services/graph/pkg/service/v0/spacetemplate/.space/readme.md deleted file mode 100644 index b96145d09b..0000000000 --- a/services/graph/pkg/service/v0/spacetemplate/.space/readme.md +++ /dev/null @@ -1 +0,0 @@ -Here you can add a description for this Space. diff --git a/services/graph/pkg/service/v0/spacetemplate/image.png b/services/graph/pkg/service/v0/spacetemplate/image.png new file mode 100644 index 0000000000..0a9dc3f761 Binary files /dev/null and b/services/graph/pkg/service/v0/spacetemplate/image.png differ diff --git a/services/graph/pkg/service/v0/spacetemplates.go b/services/graph/pkg/service/v0/spacetemplates.go new file mode 100644 index 0000000000..b9d41438aa --- /dev/null +++ b/services/graph/pkg/service/v0/spacetemplates.go @@ -0,0 +1,133 @@ +package svc + +import ( + "context" + "embed" + "fmt" + "io/fs" + "path/filepath" + + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + v1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" + "github.com/cs3org/reva/v2/pkg/storage/utils/metadata" + "github.com/cs3org/reva/v2/pkg/storagespace" + "github.com/cs3org/reva/v2/pkg/utils" + "github.com/owncloud/ocis/v2/ocis-pkg/l10n" +) + +var ( + //go:embed spacetemplate/* + _spaceTemplateFS embed.FS + + //go:embed l10n/locale + _localeFS embed.FS + + // subfolder where the translation files are stored + _localeSubPath = "l10n/locale" + + // name of the secret space folder + _spaceFolderName = ".space" + + // path to the image file + _imagepath = "spacetemplate/image.png" + + // text for the readme.md file + _readmeText = l10n.Template("Here you can add a description for this Space.") + + // name of the readme.md file + _readmeName = "readme.md" + + // domain of the graph service (transifex) + _domain = "graph" + + // HeaderAcceptLanguage is the header key for the accept-language header + HeaderAcceptLanguage = "Accept-Language" + + // TemplateParameter is the key for the template parameter in the request + TemplateParameter = "template" +) + +func (g Graph) applySpaceTemplate(ctx context.Context, gwc gateway.GatewayAPIClient, root *storageprovider.ResourceId, template string, locale string) error { + switch template { + default: + fallthrough + case "none": + return nil + case "default": + return g.applyDefaultTemplate(ctx, gwc, root, locale) + } +} + +func (g Graph) applyDefaultTemplate(ctx context.Context, gwc gateway.GatewayAPIClient, root *storageprovider.ResourceId, locale string) error { + mdc := metadata.NewCS3(g.config.Reva.Address, g.config.Spaces.StorageUsersAddress) + mdc.SpaceRoot = root + + var opaque *v1beta1.Opaque + + // create .space folder + if err := mdc.MakeDirIfNotExist(ctx, _spaceFolderName); err != nil { + return err + } + + // upload space image + iid, err := imageUpload(ctx, mdc) + if err != nil { + return err + } + opaque = utils.AppendPlainToOpaque(opaque, SpaceImageSpecialFolderName, iid) + + // upload readme.md + rid, err := readmeUpload(ctx, mdc, locale, g.config.Spaces.DefaultLanguage) + if err != nil { + return err + } + opaque = utils.AppendPlainToOpaque(opaque, ReadmeSpecialFolderName, rid) + + // update space + resp, err := gwc.UpdateStorageSpace(ctx, &storageprovider.UpdateStorageSpaceRequest{ + StorageSpace: &storageprovider.StorageSpace{ + Id: &storageprovider.StorageSpaceId{ + OpaqueId: storagespace.FormatResourceID(*root), + }, + Root: root, + Opaque: opaque, + }, + }) + switch { + case err != nil: + return err + case resp.GetStatus().GetCode() != rpc.Code_CODE_OK: + return fmt.Errorf("could not update storage space: %s", resp.GetStatus().GetMessage()) + default: + return nil + } +} + +func imageUpload(ctx context.Context, mdc *metadata.CS3) (string, error) { + b, err := fs.ReadFile(_spaceTemplateFS, _imagepath) + if err != nil { + return "", err + } + res, err := mdc.Upload(ctx, metadata.UploadRequest{ + Path: filepath.Join(_spaceFolderName, filepath.Base(_imagepath)), + Content: b, + }) + if err != nil { + return "", err + } + return res.FileID, nil +} + +func readmeUpload(ctx context.Context, mdc *metadata.CS3, locale string, defaultLocale string) (string, error) { + t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, "", _localeFS, _localeSubPath) + res, err := mdc.Upload(ctx, metadata.UploadRequest{ + Path: filepath.Join(_spaceFolderName, _readmeName), + Content: []byte(t.Translate(_readmeText, locale)), + }) + if err != nil { + return "", err + } + return res.FileID, nil +} diff --git a/services/graph/pkg/service/v0/utils.go b/services/graph/pkg/service/v0/utils.go index ee5ccd03e9..ec8e6ce6a8 100644 --- a/services/graph/pkg/service/v0/utils.go +++ b/services/graph/pkg/service/v0/utils.go @@ -3,22 +3,14 @@ package svc import ( "context" "encoding/json" - "fmt" "io" - "io/fs" "net/http" - "os" - "path/filepath" "reflect" - "strings" gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" cs3User "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" - rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1" storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" - v1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" - "github.com/cs3org/reva/v2/pkg/storage/utils/metadata" "github.com/cs3org/reva/v2/pkg/storagespace" "github.com/cs3org/reva/v2/pkg/utils" "golang.org/x/sync/errgroup" @@ -445,104 +437,3 @@ func cs3ReceivedShareToLibreGraphPermissions(ctx context.Context, logger *log.Lo return permission, nil } - -func (g Graph) applySpaceTemplate(gwc gateway.GatewayAPIClient, root *storageprovider.ResourceId, template string) (*v1beta1.Opaque, error) { - var fsys fs.ReadDirFS - - switch template { - default: - fallthrough - case "none": - return &v1beta1.Opaque{}, nil - case "default": - f, err := fs.Sub(_spaceTemplateFS, "spacetemplate") - if err != nil { - return nil, err - } - fsys = f.(fs.ReadDirFS) - } - - mdc := metadata.NewCS3(g.config.Reva.Address, g.config.Spaces.StorageUsersAddress) - mdc.SpaceRoot = root - - ctx, err := utils.GetServiceUserContext(g.config.ServiceAccount.ServiceAccountID, gwc, g.config.ServiceAccount.ServiceAccountSecret) - if err != nil { - return nil, err - } - - opaque, err := uploadFolder(ctx, mdc, ".", "", nil, fsys) - if err != nil { - return nil, err - } - - resp, err := gwc.UpdateStorageSpace(ctx, &storageprovider.UpdateStorageSpaceRequest{ - StorageSpace: &storageprovider.StorageSpace{ - Id: &storageprovider.StorageSpaceId{ - OpaqueId: storagespace.FormatResourceID(*root), - }, - Root: root, - Opaque: opaque, - }, - }) - switch { - case err != nil: - return nil, err - case resp.GetStatus().GetCode() != rpc.Code_CODE_OK: - return nil, fmt.Errorf("could not update storage space: %s", resp.GetStatus().GetMessage()) - default: - return opaque, nil - } -} - -func uploadFolder(ctx context.Context, mdc *metadata.CS3, pathOnDisc, pathOnSpace string, opaque *v1beta1.Opaque, fsys fs.ReadDirFS) (*v1beta1.Opaque, error) { - entries, err := fsys.ReadDir(pathOnDisc) - if err != nil { - return nil, err - } - - for _, entry := range entries { - opaque, err = uploadEntry(ctx, mdc, pathOnDisc, pathOnSpace, opaque, fsys, entry) - if err != nil { - return nil, err - } - } - - return opaque, nil -} - -func uploadEntry(ctx context.Context, mdc *metadata.CS3, pathOnDisc, pathOnSpace string, opaque *v1beta1.Opaque, fsys fs.ReadDirFS, entry os.DirEntry) (*v1beta1.Opaque, error) { - spacePath := filepath.Join(pathOnSpace, entry.Name()) - discPath := filepath.Join(pathOnDisc, entry.Name()) - - switch { - case entry.IsDir(): - err := mdc.MakeDirIfNotExist(ctx, spacePath) - if err != nil { - return nil, err - } - - return uploadFolder(ctx, mdc, discPath, spacePath, opaque, fsys) - default: - b, err := fs.ReadFile(fsys, discPath) - if err != nil { - return nil, err - } - - res, err := mdc.Upload(ctx, metadata.UploadRequest{ - Path: spacePath, - Content: b, - }) - if err != nil { - return nil, err - } - - identifier := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name())) - for _, special := range []string{ReadmeSpecialFolderName, SpaceImageSpecialFolderName} { - if special == identifier { - opaque = utils.AppendPlainToOpaque(opaque, identifier, res.FileID) - break - } - } - return opaque, nil - } -} diff --git a/services/notifications/Makefile b/services/notifications/Makefile index dff7c0f58e..08f9b2820a 100644 --- a/services/notifications/Makefile +++ b/services/notifications/Makefile @@ -44,7 +44,7 @@ l10n-push: .PHONY: l10n-read l10n-read: $(GO_XGETTEXT) - go-xgettext -o $(OUTPUT_DIR)/notifications.pot --keyword=Template --add-comments -s pkg/email/templates.go + go-xgettext -o $(OUTPUT_DIR)/notifications.pot --keyword=l10n.Template --add-comments -s pkg/email/templates.go .PHONY: l10n-write l10n-write: diff --git a/services/notifications/pkg/email/composer.go b/services/notifications/pkg/email/composer.go index 81f4cab1ac..b3f33f0bbb 100644 --- a/services/notifications/pkg/email/composer.go +++ b/services/notifications/pkg/email/composer.go @@ -2,29 +2,36 @@ package email import ( "bytes" + "embed" "strings" "text/template" - "github.com/owncloud/ocis/v2/services/notifications/pkg/email/l10n" + "github.com/owncloud/ocis/v2/ocis-pkg/l10n" +) + +var ( + //go:embed l10n/locale + _translationFS embed.FS + _domain = "notifications" ) // NewTextTemplate replace the body message template placeholders with the translated template func NewTextTemplate(mt MessageTemplate, locale, defaultLocale string, translationPath string, vars map[string]string) (MessageTemplate, error) { var err error - t := l10n.NewTranslator(locale, defaultLocale, translationPath) - mt.Subject, err = composeMessage(t.Translate(mt.Subject), vars) + t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, translationPath, _translationFS, "l10n/locale").Locale(locale) + mt.Subject, err = composeMessage(t.Get(mt.Subject), vars) if err != nil { return mt, err } - mt.Greeting, err = composeMessage(t.Translate(mt.Greeting), vars) + mt.Greeting, err = composeMessage(t.Get(mt.Greeting), vars) if err != nil { return mt, err } - mt.MessageBody, err = composeMessage(t.Translate(mt.MessageBody), vars) + mt.MessageBody, err = composeMessage(t.Get(mt.MessageBody), vars) if err != nil { return mt, err } - mt.CallToAction, err = composeMessage(t.Translate(mt.CallToAction), vars) + mt.CallToAction, err = composeMessage(t.Get(mt.CallToAction), vars) if err != nil { return mt, err } @@ -34,20 +41,20 @@ func NewTextTemplate(mt MessageTemplate, locale, defaultLocale string, translati // NewHTMLTemplate replace the body message template placeholders with the translated template func NewHTMLTemplate(mt MessageTemplate, locale, defaultLocale string, translationPath string, vars map[string]string) (MessageTemplate, error) { var err error - t := l10n.NewTranslator(locale, defaultLocale, translationPath) - mt.Subject, err = composeMessage(t.Translate(mt.Subject), vars) + t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, translationPath, _translationFS, "l10n/locale").Locale(locale) + mt.Subject, err = composeMessage(t.Get(mt.Subject), vars) if err != nil { return mt, err } - mt.Greeting, err = composeMessage(newlineToBr(t.Translate(mt.Greeting)), vars) + mt.Greeting, err = composeMessage(newlineToBr(t.Get(mt.Greeting)), vars) if err != nil { return mt, err } - mt.MessageBody, err = composeMessage(newlineToBr(t.Translate(mt.MessageBody)), vars) + mt.MessageBody, err = composeMessage(newlineToBr(t.Get(mt.MessageBody)), vars) if err != nil { return mt, err } - mt.CallToAction, err = composeMessage(callToActionToHTML(t.Translate(mt.CallToAction)), vars) + mt.CallToAction, err = composeMessage(callToActionToHTML(t.Get(mt.CallToAction)), vars) if err != nil { return mt, err } diff --git a/services/notifications/pkg/email/l10n/locate.go b/services/notifications/pkg/email/l10n/locate.go deleted file mode 100644 index 65ced72103..0000000000 --- a/services/notifications/pkg/email/l10n/locate.go +++ /dev/null @@ -1,51 +0,0 @@ -// Package l10n implements utility for translation the text templates. -// -// The l10n package use transifex translation for text templates. -package l10n - -import ( - "embed" - "io/fs" - - "github.com/leonelquinteros/gotext" -) - -var ( - //go:embed locale - _translationFS embed.FS - _domain = "notifications" -) - -// Translator is the interface to the translation -type Translator interface { - Translate(str string) string -} - -type translator struct { - locale *gotext.Locale -} - -// NewTranslator Create Translator with library path and language code and load default domain -func NewTranslator(locale, defaultLocale string, path string) Translator { - l := newLocate(locale, path) - if locale != "en" && len(l.GetTranslations()) == 0 { - l = newLocate(defaultLocale, path) - } - return &translator{locale: l} -} - -func newLocate(local string, path string) *gotext.Locale { - var l *gotext.Locale - if path == "" { - filesystem, _ := fs.Sub(_translationFS, "locale") - l = gotext.NewLocaleFS(local, filesystem) - } else { // use custom path instead - l = gotext.NewLocale(path, local) - } - l.AddDomain(_domain) // make domain configurable only if needed - return l -} - -func (t *translator) Translate(str string) string { - return t.locale.Get(str) -} diff --git a/services/notifications/pkg/email/templates.go b/services/notifications/pkg/email/templates.go index c90eee4444..615e8409b6 100644 --- a/services/notifications/pkg/email/templates.go +++ b/services/notifications/pkg/email/templates.go @@ -1,7 +1,6 @@ package email -// Template marks the string as a translatable template -func Template(s string) string { return s } +import "github.com/owncloud/ocis/v2/ocis-pkg/l10n" // the available templates var ( @@ -10,24 +9,24 @@ var ( textTemplate: "templates/text/email.text.tmpl", htmlTemplate: "templates/html/email.html.tmpl", // ShareCreated email template, Subject field (resolves directly) - Subject: Template(`{ShareSharer} shared '{ShareFolder}' with you`), + Subject: l10n.Template(`{ShareSharer} shared '{ShareFolder}' with you`), // ShareCreated email template, resolves via {{ .Greeting }} - Greeting: Template(`Hello {ShareGrantee}`), + Greeting: l10n.Template(`Hello {ShareGrantee}`), // ShareCreated email template, resolves via {{ .MessageBody }} - MessageBody: Template(`{ShareSharer} has shared "{ShareFolder}" with you.`), + MessageBody: l10n.Template(`{ShareSharer} has shared "{ShareFolder}" with you.`), // ShareCreated email template, resolves via {{ .CallToAction }} - CallToAction: Template(`Click here to view it: {ShareLink}`), + CallToAction: l10n.Template(`Click here to view it: {ShareLink}`), } ShareExpired = MessageTemplate{ textTemplate: "templates/text/email.text.tmpl", htmlTemplate: "templates/html/email.html.tmpl", // ShareExpired email template, Subject field (resolves directly) - Subject: Template(`Share to '{ShareFolder}' expired at {ExpiredAt}`), + Subject: l10n.Template(`Share to '{ShareFolder}' expired at {ExpiredAt}`), // ShareExpired email template, resolves via {{ .Greeting }} - Greeting: Template(`Hello {ShareGrantee},`), + Greeting: l10n.Template(`Hello {ShareGrantee},`), // ShareExpired email template, resolves via {{ .MessageBody }} - MessageBody: Template(`Your share to {ShareFolder} has expired at {ExpiredAt} + MessageBody: l10n.Template(`Your share to {ShareFolder} has expired at {ExpiredAt} Even though this share has been revoked you still might have access through other shares and/or space memberships.`), } @@ -37,39 +36,39 @@ Even though this share has been revoked you still might have access through othe textTemplate: "templates/text/email.text.tmpl", htmlTemplate: "templates/html/email.html.tmpl", // SharedSpace email template, Subject field (resolves directly) - Subject: Template("{SpaceSharer} invited you to join {SpaceName}"), + Subject: l10n.Template("{SpaceSharer} invited you to join {SpaceName}"), // SharedSpace email template, resolves via {{ .Greeting }} - Greeting: Template(`Hello {SpaceGrantee},`), + Greeting: l10n.Template(`Hello {SpaceGrantee},`), // SharedSpace email template, resolves via {{ .MessageBody }} - MessageBody: Template(`{SpaceSharer} has invited you to join "{SpaceName}".`), + MessageBody: l10n.Template(`{SpaceSharer} has invited you to join "{SpaceName}".`), // SharedSpace email template, resolves via {{ .CallToAction }} - CallToAction: Template(`Click here to view it: {ShareLink}`), + CallToAction: l10n.Template(`Click here to view it: {ShareLink}`), } UnsharedSpace = MessageTemplate{ textTemplate: "templates/text/email.text.tmpl", htmlTemplate: "templates/html/email.html.tmpl", // UnsharedSpace email template, Subject field (resolves directly) - Subject: Template(`{SpaceSharer} removed you from {SpaceName}`), + Subject: l10n.Template(`{SpaceSharer} removed you from {SpaceName}`), // UnsharedSpace email template, resolves via {{ .Greeting }} - Greeting: Template(`Hello {SpaceGrantee},`), + Greeting: l10n.Template(`Hello {SpaceGrantee},`), // UnsharedSpace email template, resolves via {{ .MessageBody }} - MessageBody: Template(`{SpaceSharer} has removed you from "{SpaceName}". + MessageBody: l10n.Template(`{SpaceSharer} has removed you from "{SpaceName}". You might still have access through your other groups or direct membership.`), // UnsharedSpace email template, resolves via {{ .CallToAction }} - CallToAction: Template(`Click here to check it: {ShareLink}`), + CallToAction: l10n.Template(`Click here to check it: {ShareLink}`), } MembershipExpired = MessageTemplate{ textTemplate: "templates/text/email.text.tmpl", htmlTemplate: "templates/html/email.html.tmpl", // MembershipExpired email template, Subject field (resolves directly) - Subject: Template(`Membership of '{SpaceName}' expired at {ExpiredAt}`), + Subject: l10n.Template(`Membership of '{SpaceName}' expired at {ExpiredAt}`), // MembershipExpired email template, resolves via {{ .Greeting }} - Greeting: Template(`Hello {SpaceGrantee},`), + Greeting: l10n.Template(`Hello {SpaceGrantee},`), // MembershipExpired email template, resolves via {{ .MessageBody }} - MessageBody: Template(`Your membership of space {SpaceName} has expired at {ExpiredAt} + MessageBody: l10n.Template(`Your membership of space {SpaceName} has expired at {ExpiredAt} Even though this membership has expired you still might have access through other shares and/or space memberships`), } diff --git a/services/notifications/pkg/service/service.go b/services/notifications/pkg/service/service.go index 8b4de702fb..3a48f788cd 100644 --- a/services/notifications/pkg/service/service.go +++ b/services/notifications/pkg/service/service.go @@ -18,6 +18,7 @@ import ( provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" "github.com/cs3org/reva/v2/pkg/events" "github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool" + "github.com/owncloud/ocis/v2/ocis-pkg/l10n" "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/ocis-pkg/middleware" settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0" @@ -28,8 +29,6 @@ import ( "google.golang.org/protobuf/types/known/fieldmaskpb" ) -var _defaultLocale = "en" - // Service should be named `Runner` type Service interface { Run() error @@ -108,7 +107,7 @@ func (s eventsNotifier) render(ctx context.Context, template email.MessageTempla // Render the Email Template for each user messageList := make([]*channels.Message, len(granteeList)) for i, usr := range granteeList { - locale := s.getUserLang(ctx, usr.GetId()) + locale := l10n.MustGetUserLocale(ctx, usr.GetId().GetOpaqueId(), "", s.valueService) fields[granteeFieldName] = usr.GetDisplayName() rendered, err := email.RenderEmailTemplate(template, locale, s.defaultLanguage, s.emailTemplatePath, s.translationPath, fields) @@ -215,22 +214,6 @@ func (s eventsNotifier) getUser(ctx context.Context, u *user.UserId) (*user.User return r.GetUser(), nil } -func (s eventsNotifier) getUserLang(ctx context.Context, u *user.UserId) string { - granteeCtx := metadata.Set(ctx, middleware.AccountID, u.GetOpaqueId()) - if resp, err := s.valueService.GetValueByUniqueIdentifiers(granteeCtx, - &settingssvc.GetValueByUniqueIdentifiersRequest{ - AccountUuid: u.GetOpaqueId(), - SettingId: defaults.SettingUUIDProfileLanguage, - }, - ); err == nil { - val := resp.GetValue().GetValue().GetListValue().GetValues() - if len(val) > 0 && val[0] != nil { - return val[0].GetStringValue() - } - } - return _defaultLocale -} - func (s eventsNotifier) disableEmails(ctx context.Context, u *user.UserId) bool { granteeCtx := metadata.Set(ctx, middleware.AccountID, u.OpaqueId) if resp, err := s.valueService.GetValueByUniqueIdentifiers(granteeCtx, diff --git a/services/userlog/Makefile b/services/userlog/Makefile index eb2a175efa..4be42ab76b 100644 --- a/services/userlog/Makefile +++ b/services/userlog/Makefile @@ -44,7 +44,7 @@ l10n-push: .PHONY: l10n-read l10n-read: $(GO_XGETTEXT) - go-xgettext -o $(OUTPUT_DIR)/userlog.pot --keyword=Template -s pkg/service/templates.go + go-xgettext -o $(OUTPUT_DIR)/userlog.pot --keyword=l10n.Template -s pkg/service/templates.go .PHONY: l10n-write l10n-write: diff --git a/services/userlog/pkg/service/conversion.go b/services/userlog/pkg/service/conversion.go index d84dd7bca5..967e75cdcf 100644 --- a/services/userlog/pkg/service/conversion.go +++ b/services/userlog/pkg/service/conversion.go @@ -6,7 +6,6 @@ import ( "embed" "encoding/json" "fmt" - "io/fs" "strings" "text/template" "time" @@ -18,7 +17,7 @@ import ( "github.com/cs3org/reva/v2/pkg/events" "github.com/cs3org/reva/v2/pkg/storagespace" "github.com/cs3org/reva/v2/pkg/utils" - "github.com/leonelquinteros/gotext" + "github.com/owncloud/ocis/v2/ocis-pkg/l10n" ) //go:embed l10n/locale @@ -338,7 +337,7 @@ func (c *Converter) getResource(ctx context.Context, resourceID *storageprovider return resource, err } -func (c *Converter) getUser(ctx context.Context, userID *user.UserId) (*user.User, error) { +func (c *Converter) getUser(_ context.Context, userID *user.UserId) (*user.User, error) { if u, ok := c.users[userID.GetOpaqueId()]; ok { return u, nil } @@ -361,25 +360,9 @@ func composeMessage(nt NotificationTemplate, locale, defaultLocale, path string, return subject, subjectraw, message, messageraw, err } -func newLocate(locale string, path string) *gotext.Locale { - // Create Locale with library path and language code and load default domain - var l *gotext.Locale - if path == "" { - filesystem, _ := fs.Sub(_translationFS, "l10n/locale") - l = gotext.NewLocaleFS(locale, filesystem) - } else { // use custom path instead - l = gotext.NewLocale(path, locale) - } - l.AddDomain(_domain) // make domain configurable only if needed - return l -} - func loadTemplates(nt NotificationTemplate, locale, defaultLocale, path string) (string, string) { - l := newLocate(locale, path) - if locale != "en" && len(l.GetTranslations()) == 0 { - l = newLocate(defaultLocale, path) - } - return l.Get(nt.Subject), l.Get(nt.Message) + t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, path, _translationFS, "l10n/locale").Locale(locale) + return t.Get(nt.Subject), t.Get(nt.Message) } func executeTemplate(raw string, vars map[string]interface{}) (string, error) { diff --git a/services/userlog/pkg/service/service.go b/services/userlog/pkg/service/service.go index 696055de2d..f34e3ca157 100644 --- a/services/userlog/pkg/service/service.go +++ b/services/userlog/pkg/service/service.go @@ -11,20 +11,18 @@ import ( gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" "github.com/go-chi/chi/v5" - micrometadata "go-micro.dev/v4/metadata" "go-micro.dev/v4/store" "go.opentelemetry.io/otel/trace" "github.com/cs3org/reva/v2/pkg/events" "github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool" "github.com/cs3org/reva/v2/pkg/utils" + "github.com/owncloud/ocis/v2/ocis-pkg/l10n" "github.com/owncloud/ocis/v2/ocis-pkg/log" - "github.com/owncloud/ocis/v2/ocis-pkg/middleware" "github.com/owncloud/ocis/v2/ocis-pkg/roles" ehmsg "github.com/owncloud/ocis/v2/protogen/gen/ocis/messages/eventhistory/v0" ehsvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/eventhistory/v0" settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0" - "github.com/owncloud/ocis/v2/services/settings/pkg/store/defaults" "github.com/owncloud/ocis/v2/services/userlog/pkg/config" ) @@ -343,7 +341,7 @@ func (ul *UserlogService) sendSSE(ctx context.Context, userIDs []string, event e m := make(map[string]events.SendSSE) for _, userid := range userIDs { - loc := ul.getUserLocale(userid) + loc := l10n.MustGetUserLocale(ctx, userid, "", ul.valueClient) if ev, ok := m[loc]; ok { ev.UserIDs = append(m[loc].UserIDs, userid) m[loc] = ev @@ -455,25 +453,6 @@ func (ul *UserlogService) alterGlobalEvents(ctx context.Context, alter func(map[ }) } -func (ul *UserlogService) getUserLocale(userid string) string { - resp, err := ul.valueClient.GetValueByUniqueIdentifiers( - micrometadata.Set(context.Background(), middleware.AccountID, userid), - &settingssvc.GetValueByUniqueIdentifiersRequest{ - AccountUuid: userid, - SettingId: defaults.SettingUUIDProfileLanguage, - }, - ) - if err != nil { - ul.log.Error().Err(err).Str("userid", userid).Msg("cannot get users locale") - return "" - } - val := resp.GetValue().GetValue().GetListValue().GetValues() - if len(val) == 0 { - return "" - } - return val[0].GetStringValue() -} - func removeExecutant(users []string, executant *user.UserId) []string { var usrs []string for _, u := range users { diff --git a/services/userlog/pkg/service/templates.go b/services/userlog/pkg/service/templates.go index 6acf6fb8ca..087f39d9f3 100644 --- a/services/userlog/pkg/service/templates.go +++ b/services/userlog/pkg/service/templates.go @@ -1,63 +1,62 @@ package service -// Template marks the string as a translatable template -func Template(s string) string { return s } +import "github.com/owncloud/ocis/v2/ocis-pkg/l10n" // the available templates var ( VirusFound = NotificationTemplate{ - Subject: Template("Virus found"), - Message: Template("Virus found in {resource}. Upload not possible. Virus: {virus}"), + Subject: l10n.Template("Virus found"), + Message: l10n.Template("Virus found in {resource}. Upload not possible. Virus: {virus}"), } PoliciesEnforced = NotificationTemplate{ - Subject: Template("Policies enforced"), - Message: Template("File {resource} was deleted because it violates the policies"), + Subject: l10n.Template("Policies enforced"), + Message: l10n.Template("File {resource} was deleted because it violates the policies"), } SpaceShared = NotificationTemplate{ - Subject: Template("Space shared"), - Message: Template("{user} added you to Space {space}"), + Subject: l10n.Template("Space shared"), + Message: l10n.Template("{user} added you to Space {space}"), } SpaceUnshared = NotificationTemplate{ - Subject: Template("Removed from Space"), - Message: Template("{user} removed you from Space {space}"), + Subject: l10n.Template("Removed from Space"), + Message: l10n.Template("{user} removed you from Space {space}"), } SpaceDisabled = NotificationTemplate{ - Subject: Template("Space disabled"), - Message: Template("{user} disabled Space {space}"), + Subject: l10n.Template("Space disabled"), + Message: l10n.Template("{user} disabled Space {space}"), } SpaceDeleted = NotificationTemplate{ - Subject: Template("Space deleted"), - Message: Template("{user} deleted Space {space}"), + Subject: l10n.Template("Space deleted"), + Message: l10n.Template("{user} deleted Space {space}"), } SpaceMembershipExpired = NotificationTemplate{ - Subject: Template("Membership expired"), - Message: Template("Access to Space {space} lost"), + Subject: l10n.Template("Membership expired"), + Message: l10n.Template("Access to Space {space} lost"), } ShareCreated = NotificationTemplate{ - Subject: Template("Resource shared"), - Message: Template("{user} shared {resource} with you"), + Subject: l10n.Template("Resource shared"), + Message: l10n.Template("{user} shared {resource} with you"), } ShareRemoved = NotificationTemplate{ - Subject: Template("Resource unshared"), - Message: Template("{user} unshared {resource} with you"), + Subject: l10n.Template("Resource unshared"), + Message: l10n.Template("{user} unshared {resource} with you"), } ShareExpired = NotificationTemplate{ - Subject: Template("Share expired"), - Message: Template("Access to {resource} expired"), + Subject: l10n.Template("Share expired"), + Message: l10n.Template("Access to {resource} expired"), } PlatformDeprovision = NotificationTemplate{ - Subject: Template("Instance will be shut down and deprovisioned"), - Message: Template("Attention! The instance will be shut down and deprovisioned on {date}. Download all your data before that date as no access past that date is possible."), + Subject: l10n.Template("Instance will be shut down and deprovisioned"), + Message: l10n.Template("Attention! The instance will be shut down and deprovisioned on {date}. Download all your data before that date as no access past that date is possible."), } )