Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
SHELL := bash
NAME := userlog
OUTPUT_DIR = ./pkg/service/l10n
TEMPLATE_FILE = ./pkg/service/l10n/userlog.pot
ifneq (, $(shell command -v go 2> /dev/null)) # suppress `command not found warnings` for non go targets in CI
include ../../.bingo/Variables.mk
endif
include ../../.make/default.mk
include ../../.make/go.mk
include ../../.make/release.mk
include ../../.make/docs.mk
.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)/userlog.pot --keyword=l10n.Template -s pkg/service/templates.go
.PHONY: l10n-clean
l10n-clean:
rm -f $(TEMPLATE_FILE);
+85
View File
@@ -0,0 +1,85 @@
# Userlog
The `userlog` service is a mediator between the `eventhistory` service and clients who want to be informed about user related events. It provides an API to retrieve those.
## The Log Service Ecosystem
Log services like the `userlog`, `clientlog` and `sse` are responsible for composing notifications for a certain audience.
- The `userlog` service translates and adjusts messages to be human readable.
- The `clientlog` service composes machine readable messages, so clients can act without the need to query the server.
- The `sse` service is only responsible for sending these messages. It does not care about their form or language.
## Prerequisites
Running the `userlog` service without running the `eventhistory` service is not possible.
## Storing
The `userlog` service persists information via the configured store in `USERLOG_STORE`. Possible stores are:
- `memory`: Basic in-memory store and the default.
- `redis-sentinel`: Stores data in a configured Redis Sentinel cluster.
- `nats-js-kv`: Stores data using key-value-store feature of [nats jetstream](https://docs.nats.io/nats-concepts/jetstream/key-value-store)
- `noop`: Stores nothing. Useful for testing. Not recommended in production environments.
Other store types may work but are not supported currently.
Note: The service can only be scaled if not using `memory` store and the stores are configured identically over all instances!
Note that if you have used one of the deprecated stores, you should reconfigure to one of the supported ones as the deprecated stores will be removed in a later version.
Store specific notes:
- When using `redis-sentinel`, the Redis master to use is configured via e.g. `OC_CACHE_STORE_NODES` in the form of `<sentinel-host>:<sentinel-port>/<redis-master>` like `10.10.0.200:26379/mymaster`.
- When using `nats-js-kv` it is recommended to set `OC_CACHE_STORE_NODES` to the same value as `OC_EVENTS_ENDPOINT`. That way the cache uses the same nats instance as the event bus.
- When using the `nats-js-kv` store, it is possible to set `OC_CACHE_DISABLE_PERSISTENCE` to instruct nats to not persist cache data on disc.
## Configuring
For the time being, the configuration which user related events are of interest is hardcoded and cannot be changed.
## Retrieving
The `userlog` service provides an API to retrieve configured events. For now, this API is mostly following the [oc10 notification GET API](https://doc.owncloud.com/server/next/developer_manual/core/apis/ocs-notification-endpoint-v1.html#get-user-notifications).
## Posting
The userlog service is able to store global messages that will be displayed in the Web UI to all users. If a user deletes the message in the Web UI, it reappears on reload. Global messages use the endpoint `/ocs/v2.php/apps/notifications/api/v1/notifications/global` and are activated by sending a `POST` request. Note that sending another `POST` request of the same type overwrites the previous one. For the time being, only the type `deprovision` is supported.
### Authentication
`POST` and `DELETE` endpoints provide notifications to all users. Therefore only certain users can configure them. Two authentication methods for this endpoint are provided. Users with the `admin` role can always access these endpoints. Additionally, a static secret via the `USERLOG_GLOBAL_NOTIFICATIONS_SECRET` can be defined to enable access for users knowing this secret, which has to be sent with the header containing the request.
### Deprovisioning
Deprovision messages announce a deprovision text including a deprovision date of the instance to all users. With this message, users get informed that the instance will be shut down and deprovisioned and no further access to their data is possible past the given date. This implies that users must download their data before the given date. The text shown to users refers to this information. Note that the task to deprovision the instance does not depend on the message. The text of the message can be translated according to the translation settings, see section [Translations](#translations). The endpoint only expects a `deprovision_date` parameter in the `POST` request body as the final text is assembled automatically. The string hast to be in `RFC3339` format, however, this format can be changed by using `deprovision_date_format`. See the [go time formating](https://pkg.go.dev/time#pkg-constants) for more details.
## Deleting
To delete events for an user, use a `DELETE` request to `ocs/v2.php/apps/notifications/api/v1/notifications` containing the IDs to delete.
Sending a `DELETE` request to the `ocs/v2.php/apps/notifications/api/v1/notifications/global` endpoint to remove a global message is a restricted action, see the [Authentication](#authentication) section for more details.)
## Translations
The `userlog` service has embedded translations sourced via transifex to provide a basic set of translated languages. These embedded translations are available for all deployment scenarios. In addition, the service supports custom translations, though it is currently not possible to just add custom translations to embedded ones. If custom translations are configured, the embedded ones are not used. To configure custom translations, the `USERLOG_TRANSLATION_PATH` environment variable needs to point to a base folder that will contain the translation files. This path must be available from all instances of the userlog service, a shared storage is recommended. Translation files must be of type [.po](https://www.gnu.org/software/gettext/manual/html_node/PO-Files.html#PO-Files) or [.mo](https://www.gnu.org/software/gettext/manual/html_node/Binaries.html). For each language, the filename needs to be `userlog.po` (or `userlog.mo`) and stored in a folder structure defining the language code. In general the path/name pattern for a translation file needs to be:
```text
{USERLOG_TRANSLATION_PATH}/{language-code}/LC_MESSAGES/userlog.po
```
The language code pattern is composed of `language[_territory]` where `language` is the base language and `_territory` is optional and defines a country.
For example, for the language `de`, one needs to place the corresponding translation files to `{USERLOG_TRANSLATION_PATH}/de_DE/LC_MESSAGES/userlog.po`.
<!-- also see the notifications readme -->
Important: For the time being, the embedded КуСфера Web frontend only supports the main language code but does not handle any territory. When strings are available in the language code `language_territory`, the web frontend does not see it as it only requests `language`. In consequence, any translations made must exist in the requested `language` to avoid a fallback to the default.
### Translation Rules
* If a requested language code is not available, the service tries to fall back to the base language if available. For example, if the requested language-code `de_DE` is not available, the service tries to fall back to translations in the `de` folder.
* If the base language `de` is also not available, the service falls back to the system's default English (`en`),
which is the source of the texts provided by the code.
## Default Language
The default language can be defined via the `OC_DEFAULT_LANGUAGE` environment variable. See the `settings` service for a detailed description.
@@ -0,0 +1,19 @@
package command
import (
"github.com/qsfera/server/services/userlog/pkg/config"
"github.com/spf13/cobra"
)
// Health is the entrypoint for the health command.
func Health(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "health",
Short: "Check health status",
RunE: func(cmd *cobra.Command, args []string) error {
// Not implemented
return nil
},
}
}
@@ -0,0 +1,36 @@
package command
import (
"os"
"github.com/qsfera/server/pkg/clihelper"
"github.com/qsfera/server/services/userlog/pkg/config"
"github.com/spf13/cobra"
)
// GetCommands provides all commands for this service
func GetCommands(cfg *config.Config) []*cobra.Command {
return []*cobra.Command{
// start this service
Server(cfg),
// interaction with this service
// infos about this service
Health(cfg),
Version(cfg),
}
}
// Execute is the entry point for the userlog command.
func Execute(cfg *config.Config) error {
app := clihelper.DefaultApp(&cobra.Command{
Use: "userlog",
Short: "starts userlog service",
})
app.AddCommand(GetCommands(cfg)...)
app.SetArgs(os.Args[1:])
return app.ExecuteContext(cfg.Context)
}
@@ -0,0 +1,166 @@
package command
import (
"context"
"fmt"
"os/signal"
"github.com/qsfera/server/pkg/config/configlog"
"github.com/qsfera/server/pkg/generators"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/registry"
"github.com/qsfera/server/pkg/runner"
ogrpc "github.com/qsfera/server/pkg/service/grpc"
"github.com/qsfera/server/pkg/tracing"
"github.com/qsfera/server/pkg/version"
ehsvc "github.com/qsfera/server/protogen/gen/qsfera/services/eventhistory/v0"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/userlog/pkg/config"
"github.com/qsfera/server/services/userlog/pkg/config/parser"
"github.com/qsfera/server/services/userlog/pkg/metrics"
"github.com/qsfera/server/services/userlog/pkg/server/debug"
"github.com/qsfera/server/services/userlog/pkg/server/http"
"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/reva/v2/pkg/store"
"github.com/spf13/cobra"
microstore "go-micro.dev/v4/store"
)
// all events we care about
var _registeredEvents = []events.Unmarshaller{
// file related
events.PostprocessingStepFinished{},
// space related
events.SpaceDisabled{},
events.SpaceDeleted{},
events.SpaceShared{},
events.SpaceUnshared{},
events.SpaceMembershipExpired{},
// share related
events.ShareCreated{},
events.ShareRemoved{},
events.ShareExpired{},
}
// Server is the entrypoint for the server command.
func Server(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "server",
Short: fmt.Sprintf("start the %s service without runtime (unsupervised mode)", cfg.Service.Name),
PreRunE: func(cmd *cobra.Command, args []string) error {
return configlog.ReturnFatal(parser.ParseConfig(cfg))
},
RunE: func(cmd *cobra.Command, args []string) error {
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
tracerProvider, err := tracing.GetTraceProvider(cmd.Context(), cfg.Commons.TracesExporter, cfg.Service.Name)
if err != nil {
return err
}
grpcClient, err := ogrpc.NewClient(
append(ogrpc.GetClientOptions(cfg.GRPCClientTLS), ogrpc.WithTraceProvider(tracerProvider))...,
)
if err != nil {
return err
}
var cancel context.CancelFunc
if cfg.Context == nil {
cfg.Context, cancel = signal.NotifyContext(context.Background(), runner.StopSignals...)
defer cancel()
}
ctx := cfg.Context
mtrcs := metrics.New()
mtrcs.BuildInfo.WithLabelValues(version.GetString()).Set(1)
connName := generators.GenerateConnectionName(cfg.Service.Name, generators.NTypeBus)
stream, err := stream.NatsFromConfig(connName, false, stream.NatsConfig(cfg.Events))
if err != nil {
return err
}
st := store.Create(
store.Store(cfg.Persistence.Store),
store.TTL(cfg.Persistence.TTL),
microstore.Nodes(cfg.Persistence.Nodes...),
microstore.Database(cfg.Persistence.Database),
microstore.Table(cfg.Persistence.Table),
store.Authentication(cfg.Persistence.AuthUsername, cfg.Persistence.AuthPassword),
)
tm, err := pool.StringToTLSMode(cfg.GRPCClientTLS.Mode)
if err != nil {
return err
}
gatewaySelector, err := pool.GatewaySelector(
cfg.RevaGateway,
pool.WithTLSCACert(cfg.GRPCClientTLS.CACert),
pool.WithTLSMode(tm),
pool.WithRegistry(registry.GetRegistry()),
pool.WithTracerProvider(tracerProvider),
)
if err != nil {
return fmt.Errorf("could not get reva client selector: %s", err)
}
hClient := ehsvc.NewEventHistoryService("qsfera.api.eventhistory", grpcClient)
vClient := settingssvc.NewValueService("qsfera.api.settings", grpcClient)
rClient := settingssvc.NewRoleService("qsfera.api.settings", grpcClient)
gr := runner.NewGroup()
{
server, err := http.Server(
http.Logger(logger),
http.Context(ctx),
http.Config(cfg),
http.Metrics(mtrcs),
http.Store(st),
http.Stream(stream),
http.GatewaySelector(gatewaySelector),
http.History(hClient),
http.Value(vClient),
http.Role(rClient),
http.RegisteredEvents(_registeredEvents),
http.TracerProvider(tracerProvider),
)
if err != nil {
logger.Info().Err(err).Str("transport", "http").Msg("Failed to initialize server")
return err
}
gr.Add(runner.NewGoMicroHttpServerRunner(cfg.Service.Name+".http", server))
}
{
debugServer, err := debug.Server(
debug.Logger(logger),
debug.Context(ctx),
debug.Config(cfg),
)
if err != nil {
logger.Info().Err(err).Str("transport", "debug").Msg("Failed to initialize server")
return err
}
gr.Add(runner.NewGolangHttpServerRunner(cfg.Service.Name+".debug", debugServer))
}
grResults := gr.Run(ctx)
// return the first non-nil error found in the results
for _, grResult := range grResults {
if grResult.RunnerError != nil {
return grResult.RunnerError
}
}
return nil
},
}
}
@@ -0,0 +1,19 @@
package command
import (
"github.com/qsfera/server/services/userlog/pkg/config"
"github.com/spf13/cobra"
)
// Version prints the service versions of all running instances.
func Version(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "print the version of this binary and the running service instances",
RunE: func(cmd *cobra.Command, args []string) error {
// not implemented
return nil
},
}
}
@@ -0,0 +1,88 @@
package config
import (
"context"
"time"
"github.com/qsfera/server/pkg/shared"
)
// Config combines all available configuration parts.
type Config struct {
Commons *shared.Commons `yaml:"-"` // don't use this directly as configuration for a service
Service Service `yaml:"-"`
LogLevel string `yaml:"loglevel" env:"OC_LOG_LEVEL;USERLOG_LOG_LEVEL" desc:"The log level. Valid values are: 'panic', 'fatal', 'error', 'warn', 'info', 'debug', 'trace'." introductionVersion:"1.0.0"`
Debug Debug `yaml:"debug"`
HTTP HTTP `yaml:"http"`
GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"`
TokenManager *TokenManager `yaml:"token_manager"`
RevaGateway string `yaml:"reva_gateway" env:"OC_REVA_GATEWAY" desc:"CS3 gateway used to look up user metadata" introductionVersion:"1.0.0"`
TranslationPath string `yaml:"translation_path" env:"OC_TRANSLATION_PATH;USERLOG_TRANSLATION_PATH" desc:"(optional) Set this to a path with custom translations to overwrite the builtin translations. Note that file and folder naming rules apply, see the documentation for more details." introductionVersion:"1.0.0"`
DefaultLanguage string `yaml:"default_language" env:"OC_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:"1.0.0"`
Events Events `yaml:"events"`
MaxConcurrency int `yaml:"max_concurrency" env:"OC_MAX_CONCURRENCY;USERLOG_MAX_CONCURRENCY" desc:"Maximum number of concurrent go-routines. Higher values can potentially get work done faster but will also cause more load on the system. Values of 0 or below will be ignored and the default value will be used." introductionVersion:"1.0.0"`
Persistence Persistence `yaml:"persistence"`
DisableSSE bool `yaml:"disable_sse" env:"OC_DISABLE_SSE,USERLOG_DISABLE_SSE" desc:"Disables server-sent events (sse). When disabled, clients will no longer receive sse notifications." introductionVersion:"1.0.0"`
GlobalNotificationsSecret string `yaml:"global_notifications_secret" env:"USERLOG_GLOBAL_NOTIFICATIONS_SECRET" desc:"The secret to secure the global notifications endpoint. Only system admins and users knowing that secret can call the global notifications POST/DELETE endpoints." introductionVersion:"1.0.0"`
ServiceAccount ServiceAccount `yaml:"service_account"`
Context context.Context `yaml:"-"`
}
// Persistence configures the store to use
type Persistence struct {
Store string `yaml:"store" env:"OC_PERSISTENT_STORE;USERLOG_STORE" desc:"The type of the store. Supported values are: 'memory', 'nats-js-kv', 'redis-sentinel', 'noop'. See the text description for details." introductionVersion:"1.0.0"`
Nodes []string `yaml:"nodes" env:"OC_PERSISTENT_STORE_NODES;USERLOG_STORE_NODES" desc:"A list of nodes to access the configured store. This has no effect when 'memory' store is configured. Note that the behaviour how nodes are used is dependent on the library of the configured store. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
Database string `yaml:"database" env:"USERLOG_STORE_DATABASE" desc:"The database name the configured store should use." introductionVersion:"1.0.0"`
Table string `yaml:"table" env:"USERLOG_STORE_TABLE" desc:"The database table the store should use." introductionVersion:"1.0.0"`
TTL time.Duration `yaml:"ttl" env:"OC_PERSISTENT_STORE_TTL;USERLOG_STORE_TTL" desc:"Time to live for events in the store. Defaults to '336h' (2 weeks). See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
AuthUsername string `yaml:"username" env:"OC_PERSISTENT_STORE_AUTH_USERNAME;USERLOG_STORE_AUTH_USERNAME" desc:"The username to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
AuthPassword string `yaml:"password" env:"OC_PERSISTENT_STORE_AUTH_PASSWORD;USERLOG_STORE_AUTH_PASSWORD" desc:"The password to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
}
// Events combines the configuration options for the event bus.
type Events struct {
Endpoint string `yaml:"endpoint" env:"OC_EVENTS_ENDPOINT;USERLOG_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:"1.0.0"`
Cluster string `yaml:"cluster" env:"OC_EVENTS_CLUSTER;USERLOG_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:"1.0.0"`
TLSInsecure bool `yaml:"tls_insecure" env:"OC_INSECURE;OC_EVENTS_TLS_INSECURE;USERLOG_EVENTS_TLS_INSECURE" desc:"Whether to verify the server TLS certificates." introductionVersion:"1.0.0"`
TLSRootCACertificate string `yaml:"tls_root_ca_certificate" env:"OC_EVENTS_TLS_ROOT_CA_CERTIFICATE;USERLOG_EVENTS_TLS_ROOT_CA_CERTIFICATE" desc:"The root CA certificate used to validate the server's TLS certificate. If provided NOTIFICATIONS_EVENTS_TLS_INSECURE will be seen as false." introductionVersion:"1.0.0"`
EnableTLS bool `yaml:"enable_tls" env:"OC_EVENTS_ENABLE_TLS;USERLOG_EVENTS_ENABLE_TLS" desc:"Enable TLS for the connection to the events broker. The events broker is the КуСфера service which receives and delivers events between the services." introductionVersion:"1.0.0"`
AuthUsername string `yaml:"username" env:"OC_EVENTS_AUTH_USERNAME;USERLOG_EVENTS_AUTH_USERNAME" desc:"The username to authenticate with the events broker. The events broker is the КуСфера service which receives and delivers events between the services." introductionVersion:"1.0.0"`
AuthPassword string `yaml:"password" env:"OC_EVENTS_AUTH_PASSWORD;USERLOG_EVENTS_AUTH_PASSWORD" desc:"The password to authenticate with the events broker. The events broker is the КуСфера service which receives and delivers events between the services." introductionVersion:"1.0.0"`
}
// CORS defines the available cors configuration.
type CORS struct {
AllowedOrigins []string `yaml:"allow_origins" env:"OC_CORS_ALLOW_ORIGINS;USERLOG_CORS_ALLOW_ORIGINS" desc:"A list of allowed CORS origins. See following chapter for more details: *Access-Control-Allow-Origin* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
AllowedMethods []string `yaml:"allow_methods" env:"OC_CORS_ALLOW_METHODS;USERLOG_CORS_ALLOW_METHODS" desc:"A list of allowed CORS methods. See following chapter for more details: *Access-Control-Request-Method* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Method. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
AllowedHeaders []string `yaml:"allow_headers" env:"OC_CORS_ALLOW_HEADERS;USERLOG_CORS_ALLOW_HEADERS" desc:"A list of allowed CORS headers. See following chapter for more details: *Access-Control-Request-Headers* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Headers. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
AllowCredentials bool `yaml:"allow_credentials" env:"OC_CORS_ALLOW_CREDENTIALS;USERLOG_CORS_ALLOW_CREDENTIALS" desc:"Allow credentials for CORS.See following chapter for more details: *Access-Control-Allow-Credentials* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials." introductionVersion:"1.0.0"`
}
// HTTP defines the available http configuration.
type HTTP struct {
Addr string `yaml:"addr" env:"USERLOG_HTTP_ADDR" desc:"The bind address of the HTTP service." introductionVersion:"1.0.0"`
Namespace string `yaml:"-"`
Root string `yaml:"root" env:"USERLOG_HTTP_ROOT" desc:"Subdirectory that serves as the root for this HTTP service." introductionVersion:"1.0.0"`
CORS CORS `yaml:"cors"`
TLS shared.HTTPServiceTLS `yaml:"tls"`
}
// TokenManager is the config for using the reva token manager
type TokenManager struct {
JWTSecret string `yaml:"jwt_secret" env:"OC_JWT_SECRET;USERLOG_JWT_SECRET" desc:"The secret to mint and validate jwt tokens." introductionVersion:"1.0.0"`
}
// ServiceAccount is the configuration for the used service account
type ServiceAccount struct {
ServiceAccountID string `yaml:"service_account_id" env:"OC_SERVICE_ACCOUNT_ID;USERLOG_SERVICE_ACCOUNT_ID" desc:"The ID of the service account the service should use. See the 'auth-service' service description for more details." introductionVersion:"1.0.0"`
ServiceAccountSecret string `yaml:"service_account_secret" env:"OC_SERVICE_ACCOUNT_SECRET;USERLOG_SERVICE_ACCOUNT_SECRET" desc:"The service account secret." introductionVersion:"1.0.0"`
}
@@ -0,0 +1,9 @@
package config
// Debug defines the available debug configuration.
type Debug struct {
Addr string `yaml:"addr" env:"USERLOG_DEBUG_ADDR" desc:"Bind address of the debug server, where metrics, health, config and debug endpoints will be exposed." introductionVersion:"1.0.0"`
Token string `yaml:"token" env:"USERLOG_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint." introductionVersion:"1.0.0"`
Pprof bool `yaml:"pprof" env:"USERLOG_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling." introductionVersion:"1.0.0"`
Zpages bool `yaml:"zpages" env:"USERLOG_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces." introductionVersion:"1.0.0"`
}
@@ -0,0 +1,92 @@
package defaults
import (
"strings"
"time"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/pkg/structs"
"github.com/qsfera/server/services/userlog/pkg/config"
)
// FullDefaultConfig returns the full default config
func FullDefaultConfig() *config.Config {
cfg := DefaultConfig()
EnsureDefaults(cfg)
Sanitize(cfg)
return cfg
}
// DefaultConfig return the default configuration
func DefaultConfig() *config.Config {
return &config.Config{
Debug: config.Debug{
Addr: "127.0.0.1:9214",
Token: "",
Pprof: false,
Zpages: false,
},
Service: config.Service{
Name: "userlog",
},
Events: config.Events{
Endpoint: "127.0.0.1:9233",
Cluster: "qsfera-cluster",
EnableTLS: false,
},
MaxConcurrency: 1,
Persistence: config.Persistence{
Store: "memory",
Database: "userlog",
Table: "events",
TTL: time.Hour * 336,
},
RevaGateway: shared.DefaultRevaConfig().Address,
HTTP: config.HTTP{
Addr: "127.0.0.1:9210",
Root: "/",
Namespace: "qsfera.web",
CORS: config.CORS{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET"},
AllowedHeaders: []string{"Authorization", "Origin", "Content-Type", "Accept", "X-Requested-With", "X-Request-Id", "Ocs-Apirequest"},
AllowCredentials: true,
},
},
}
}
// EnsureDefaults ensures the config contains default values
func EnsureDefaults(cfg *config.Config) {
if cfg.LogLevel == "" {
cfg.LogLevel = "error"
}
if cfg.GRPCClientTLS == nil && cfg.Commons != nil {
cfg.GRPCClientTLS = structs.CopyOrZeroValue(cfg.Commons.GRPCClientTLS)
}
if cfg.TokenManager == nil && cfg.Commons != nil && cfg.Commons.TokenManager != nil {
cfg.TokenManager = &config.TokenManager{
JWTSecret: cfg.Commons.TokenManager.JWTSecret,
}
} else if cfg.TokenManager == nil {
cfg.TokenManager = &config.TokenManager{}
}
if cfg.Commons != nil {
cfg.HTTP.TLS = cfg.Commons.HTTPServiceTLS
}
}
// Sanitize sanitizes the config
func Sanitize(cfg *config.Config) {
// sanitize config
if cfg.HTTP.Root != "/" {
cfg.HTTP.Root = strings.TrimSuffix(cfg.HTTP.Root, "/")
}
if cfg.MaxConcurrency < 1 {
cfg.MaxConcurrency = 5
}
}
@@ -0,0 +1,50 @@
package parser
import (
"errors"
occfg "github.com/qsfera/server/pkg/config"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/services/userlog/pkg/config"
"github.com/qsfera/server/services/userlog/pkg/config/defaults"
"github.com/qsfera/server/pkg/config/envdecode"
)
// ParseConfig loads configuration from known paths.
func ParseConfig(cfg *config.Config) error {
err := occfg.BindSourcesToStructs(cfg.Service.Name, cfg)
if err != nil {
return err
}
defaults.EnsureDefaults(cfg)
// load all env variables relevant to the config in the current context.
if err := envdecode.Decode(cfg); err != nil {
// no environment variable set for this config is an expected "error"
if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) {
return err
}
}
defaults.Sanitize(cfg)
return Validate(cfg)
}
// Validate validates the config
func Validate(cfg *config.Config) error {
if cfg.TokenManager.JWTSecret == "" {
return shared.MissingJWTTokenError(cfg.Service.Name)
}
if cfg.ServiceAccount.ServiceAccountID == "" {
return shared.MissingServiceAccountID(cfg.Service.Name)
}
if cfg.ServiceAccount.ServiceAccountSecret == "" {
return shared.MissingServiceAccountSecret(cfg.Service.Name)
}
return nil
}
@@ -0,0 +1,6 @@
package config
// Service defines the available service configuration.
type Service struct {
Name string `yaml:"-"`
}
@@ -0,0 +1,35 @@
package metrics
import "github.com/prometheus/client_golang/prometheus"
var (
// Namespace defines the namespace for the defines metrics.
Namespace = "qsfera"
// Subsystem defines the subsystem for the defines metrics.
Subsystem = "userlog"
)
// Metrics defines the available metrics of this service.
type Metrics struct {
BuildInfo *prometheus.GaugeVec
}
// New initializes the available metrics.
func New() *Metrics {
m := &Metrics{
BuildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "build_info",
Help: "Build information",
}, []string{"version"}),
}
_ = prometheus.Register(
m.BuildInfo,
)
// TODO: implement metrics
return m
}
@@ -0,0 +1,50 @@
package debug
import (
"context"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/userlog/pkg/config"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Logger log.Logger
Context context.Context
Config *config.Config
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// Context provides a function to set the context option.
func Context(val context.Context) Option {
return func(o *Options) {
o.Context = val
}
}
// Config provides a function to set the config option.
func Config(val *config.Config) Option {
return func(o *Options) {
o.Config = val
}
}
@@ -0,0 +1,34 @@
package debug
import (
"net/http"
"github.com/qsfera/server/pkg/checks"
"github.com/qsfera/server/pkg/handlers"
"github.com/qsfera/server/pkg/service/debug"
"github.com/qsfera/server/pkg/version"
)
// Server initializes the debug service and server.
func Server(opts ...Option) (*http.Server, error) {
options := newOptions(opts...)
healthHandlerConfiguration := handlers.NewCheckHandlerConfiguration().
WithLogger(options.Logger).
WithCheck("web reachability", checks.NewHTTPCheck(options.Config.HTTP.Addr))
readyHandlerConfiguration := healthHandlerConfiguration.
WithCheck("nats reachability", checks.NewNatsCheck(options.Config.Events.Endpoint))
return debug.NewService(
debug.Logger(options.Logger),
debug.Name(options.Config.Service.Name),
debug.Version(version.GetString()),
debug.Address(options.Config.Debug.Addr),
debug.Token(options.Config.Debug.Token),
debug.Pprof(options.Config.Debug.Pprof),
debug.Zpages(options.Config.Debug.Zpages),
debug.Health(handlers.NewCheckHandler(healthHandlerConfiguration)),
debug.Ready(handlers.NewCheckHandler(readyHandlerConfiguration)),
), nil
}
@@ -0,0 +1,148 @@
package http
import (
"context"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
"github.com/qsfera/server/pkg/log"
ehsvc "github.com/qsfera/server/protogen/gen/qsfera/services/eventhistory/v0"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/userlog/pkg/config"
"github.com/qsfera/server/services/userlog/pkg/metrics"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/spf13/pflag"
"go-micro.dev/v4/store"
"go.opentelemetry.io/otel/trace"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Logger log.Logger
Context context.Context
Config *config.Config
Metrics *metrics.Metrics
Flags []pflag.Flag
Namespace string
Store store.Store
Stream events.Stream
GatewaySelector pool.Selectable[gateway.GatewayAPIClient]
HistoryClient ehsvc.EventHistoryService
ValueClient settingssvc.ValueService
RoleClient settingssvc.RoleService
RegisteredEvents []events.Unmarshaller
TracerProvider trace.TracerProvider
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// Context provides a function to set the context option.
func Context(val context.Context) Option {
return func(o *Options) {
o.Context = val
}
}
// Config provides a function to set the config option.
func Config(val *config.Config) Option {
return func(o *Options) {
o.Config = val
}
}
// Metrics provides a function to set the metrics option.
func Metrics(val *metrics.Metrics) Option {
return func(o *Options) {
o.Metrics = val
}
}
// Flags provides a function to set the flags option.
func Flags(flags ...pflag.Flag) Option {
return func(o *Options) {
o.Flags = append(o.Flags, flags...)
}
}
// Namespace provides a function to set the Namespace option.
func Namespace(val string) Option {
return func(o *Options) {
o.Namespace = val
}
}
// Store provides a function to configure the store
func Store(store store.Store) Option {
return func(o *Options) {
o.Store = store
}
}
// Stream provides a function to configure the stream
func Stream(stream events.Stream) Option {
return func(o *Options) {
o.Stream = stream
}
}
// GatewaySelector provides a function to configure the gateway client selector
func GatewaySelector(gatewaySelector pool.Selectable[gateway.GatewayAPIClient]) Option {
return func(o *Options) {
o.GatewaySelector = gatewaySelector
}
}
// History provides a function to configure the event history client
func History(h ehsvc.EventHistoryService) Option {
return func(o *Options) {
o.HistoryClient = h
}
}
// RegisteredEvents provides a function to register events
func RegisteredEvents(evs []events.Unmarshaller) Option {
return func(o *Options) {
o.RegisteredEvents = evs
}
}
// Value provides a function to configure the value service client
func Value(vs settingssvc.ValueService) Option {
return func(o *Options) {
o.ValueClient = vs
}
}
// Roles provides a function to configure the roles service client
func Role(rs settingssvc.RoleService) Option {
return func(o *Options) {
o.RoleClient = rs
}
}
// TracerProvider provides a function to set the TracerProvider option
func TracerProvider(val trace.TracerProvider) Option {
return func(o *Options) {
o.TracerProvider = val
}
}
@@ -0,0 +1,102 @@
package http
import (
"fmt"
stdhttp "net/http"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/qsfera/server/pkg/account"
"github.com/qsfera/server/pkg/cors"
"github.com/qsfera/server/pkg/middleware"
"github.com/qsfera/server/pkg/service/http"
"github.com/qsfera/server/pkg/tracing"
"github.com/qsfera/server/pkg/version"
svc "github.com/qsfera/server/services/userlog/pkg/service"
"github.com/riandyrn/otelchi"
"go-micro.dev/v4"
)
// Service is the service interface
type Service any
// Server initializes the http service and server.
func Server(opts ...Option) (http.Service, error) {
options := newOptions(opts...)
service, err := http.NewService(
http.TLSConfig(options.Config.HTTP.TLS),
http.Logger(options.Logger),
http.Namespace(options.Config.HTTP.Namespace),
http.Name(options.Config.Service.Name),
http.Version(version.GetString()),
http.Address(options.Config.HTTP.Addr),
http.Context(options.Context),
http.Flags(options.Flags...),
http.TraceProvider(options.TracerProvider),
)
if err != nil {
options.Logger.Error().
Err(err).
Msg("Error initializing http service")
return http.Service{}, fmt.Errorf("could not initialize http service: %w", err)
}
middlewares := []func(stdhttp.Handler) stdhttp.Handler{
chimiddleware.RequestID,
middleware.Version(
options.Config.Service.Name,
version.GetString(),
),
middleware.Logger(
options.Logger,
),
middleware.ExtractAccountUUID(
account.Logger(options.Logger),
account.JWTSecret(options.Config.TokenManager.JWTSecret),
),
middleware.Cors(
cors.Logger(options.Logger),
cors.AllowedOrigins(options.Config.HTTP.CORS.AllowedOrigins),
cors.AllowedMethods(options.Config.HTTP.CORS.AllowedMethods),
cors.AllowedHeaders(options.Config.HTTP.CORS.AllowedHeaders),
cors.AllowCredentials(options.Config.HTTP.CORS.AllowCredentials),
),
}
mux := chi.NewMux()
mux.Use(middlewares...)
mux.Use(
otelchi.Middleware(
"userlog",
otelchi.WithChiRoutes(mux),
otelchi.WithTracerProvider(options.TracerProvider),
otelchi.WithPropagators(tracing.GetPropagator()),
),
)
handle, err := svc.NewUserlogService(
svc.Logger(options.Logger),
svc.Stream(options.Stream),
svc.Mux(mux),
svc.Store(options.Store),
svc.Config(options.Config),
svc.HistoryClient(options.HistoryClient),
svc.GatewaySelector(options.GatewaySelector),
svc.ValueClient(options.ValueClient),
svc.RoleClient(options.RoleClient),
svc.RegisteredEvents(options.RegisteredEvents),
svc.TraceProvider(options.TracerProvider),
)
if err != nil {
return http.Service{}, err
}
if err := micro.RegisterHandler(service.Server(), handle); err != nil {
return http.Service{}, err
}
return service, nil
}
@@ -0,0 +1,430 @@
package service
import (
"bytes"
"context"
"embed"
"encoding/json"
"fmt"
"strings"
"text/template"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
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/qsfera/server/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"
)
//go:embed l10n/locale
var _translationFS embed.FS
var (
_resourceTypeResource = "resource"
_resourceTypeSpace = "storagespace"
_resourceTypeShare = "share"
_resourceTypeGlobal = "global"
_domain = "userlog"
)
// OC10Notification is the oc10 style representation of an event
// some fields are left out for simplicity
type OC10Notification struct {
EventID string `json:"notification_id"`
Service string `json:"app"`
UserName string `json:"user"`
Timestamp string `json:"datetime"`
ResourceID string `json:"object_id"`
ResourceType string `json:"object_type"`
Subject string `json:"subject"`
SubjectRaw string `json:"subjectRich"`
Message string `json:"message"`
MessageRaw string `json:"messageRich"`
MessageDetails map[string]any `json:"messageRichParameters"`
}
// Converter is responsible for converting eventhistory events to OC10Notifications
type Converter struct {
locale string
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
serviceName string
translationPath string
defaultLanguage string
serviceAccountContext context.Context
// cached within one request not to query other service too much
spaces map[string]*storageprovider.StorageSpace
users map[string]*user.User
resources map[string]*storageprovider.ResourceInfo
}
// NewConverter returns a new Converter
func NewConverter(ctx context.Context, loc string, gatewaySelector pool.Selectable[gateway.GatewayAPIClient], name, translationPath, defaultLanguage string) *Converter {
return &Converter{
locale: loc,
gatewaySelector: gatewaySelector,
serviceName: name,
translationPath: translationPath,
defaultLanguage: defaultLanguage,
serviceAccountContext: ctx,
spaces: make(map[string]*storageprovider.StorageSpace),
users: make(map[string]*user.User),
resources: make(map[string]*storageprovider.ResourceInfo),
}
}
// ConvertEvent converts an eventhistory event to an OC10Notification
func (c *Converter) ConvertEvent(eventid string, event any) (OC10Notification, error) {
switch ev := event.(type) {
default:
return OC10Notification{}, fmt.Errorf("unknown event type: %T", ev)
// file related
case events.PostprocessingStepFinished:
switch ev.FinishedStep {
case events.PPStepAntivirus:
res := ev.Result.(events.VirusscanResult)
return c.virusMessage(eventid, VirusFound, ev.ExecutingUser, res.ResourceID, ev.Filename, res.Description, res.Scandate)
case events.PPStepPolicies:
return c.policiesMessage(eventid, PoliciesEnforced, ev.ExecutingUser, ev.Filename, time.Now())
default:
return OC10Notification{}, fmt.Errorf("unknown postprocessing step: %s", ev.FinishedStep)
}
// space related
case events.SpaceDisabled:
return c.spaceMessage(eventid, SpaceDisabled, ev.Executant, ev.ID.GetOpaqueId(), ev.Timestamp)
case events.SpaceDeleted:
return c.spaceDeletedMessage(eventid, ev.Executant, ev.ID.GetOpaqueId(), ev.SpaceName, ev.Timestamp)
case events.SpaceShared:
return c.spaceMessage(eventid, SpaceShared, ev.Executant, ev.ID.GetOpaqueId(), ev.Timestamp)
case events.SpaceUnshared:
return c.spaceMessage(eventid, SpaceUnshared, ev.Executant, ev.ID.GetOpaqueId(), ev.Timestamp)
case events.SpaceMembershipExpired:
return c.spaceMessage(eventid, SpaceMembershipExpired, ev.SpaceOwner, ev.SpaceID.GetOpaqueId(), ev.ExpiredAt)
// share related
case events.ShareCreated:
return c.shareMessage(eventid, ShareCreated, ev.Executant, ev.ItemID, ev.ShareID, utils.TSToTime(ev.CTime))
case events.ShareExpired:
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)
}
}
// ConvertGlobalEvent converts a global event to an OC10Notification
func (c *Converter) ConvertGlobalEvent(typ string, data json.RawMessage) (OC10Notification, error) {
switch typ {
default:
return OC10Notification{}, fmt.Errorf("unknown global event type: %s", typ)
case "deprovision":
var dd DeprovisionData
if err := json.Unmarshal(data, &dd); err != nil {
return OC10Notification{}, err
}
return c.deprovisionMessage(PlatformDeprovision, dd.DeprovisionDate.Format(dd.DeprovisionFormat))
}
}
func (c *Converter) spaceDeletedMessage(eventid string, executant *user.UserId, spaceid string, spacename string, ts time.Time) (OC10Notification, error) {
usr, err := c.getUser(context.Background(), executant)
if err != nil {
return OC10Notification{}, err
}
subj, subjraw, msg, msgraw, err := composeMessage(SpaceDeleted, c.locale, c.defaultLanguage, c.translationPath, map[string]any{
"username": usr.GetDisplayName(),
"spacename": spacename,
})
if err != nil {
return OC10Notification{}, err
}
space := &storageprovider.StorageSpace{Id: &storageprovider.StorageSpaceId{OpaqueId: spaceid}, Name: spacename}
return OC10Notification{
EventID: eventid,
Service: c.serviceName,
UserName: usr.GetUsername(),
Timestamp: ts.Format(time.RFC3339Nano),
ResourceID: spaceid,
ResourceType: _resourceTypeSpace,
Subject: subj,
SubjectRaw: subjraw,
Message: msg,
MessageRaw: msgraw,
MessageDetails: generateDetails(usr, space, nil, nil),
}, nil
}
func (c *Converter) spaceMessage(eventid string, nt NotificationTemplate, executant *user.UserId, spaceid string, ts time.Time) (OC10Notification, error) {
usr, err := c.getUser(context.Background(), executant)
if err != nil {
return OC10Notification{}, err
}
space, err := c.getSpace(c.serviceAccountContext, spaceid)
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(),
"spacename": space.GetName(),
})
if err != nil {
return OC10Notification{}, err
}
return OC10Notification{
EventID: eventid,
Service: c.serviceName,
UserName: usr.GetUsername(),
Timestamp: ts.Format(time.RFC3339Nano),
ResourceID: spaceid,
ResourceType: _resourceTypeSpace,
Subject: subj,
SubjectRaw: subjraw,
Message: msg,
MessageRaw: msgraw,
MessageDetails: generateDetails(usr, space, nil, 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 {
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: _resourceTypeShare,
Subject: subj,
SubjectRaw: subjraw,
Message: msg,
MessageRaw: msgraw,
MessageDetails: generateDetails(usr, nil, info, shareid),
}, nil
}
func (c *Converter) virusMessage(eventid string, nt NotificationTemplate, executant *user.User, rid *storageprovider.ResourceId, filename string, virus string, ts time.Time) (OC10Notification, error) {
subj, subjraw, msg, msgraw, err := composeMessage(nt, c.locale, c.defaultLanguage, c.translationPath, map[string]any{
"resourcename": filename,
"virusdescription": virus,
})
if err != nil {
return OC10Notification{}, err
}
dets := map[string]any{
"resource": map[string]string{
"name": filename,
},
"virus": map[string]any{
"name": virus,
"scandate": ts,
},
}
return OC10Notification{
EventID: eventid,
Service: c.serviceName,
UserName: executant.GetUsername(),
Timestamp: ts.Format(time.RFC3339Nano),
ResourceID: storagespace.FormatResourceID(rid),
ResourceType: _resourceTypeResource,
Subject: subj,
SubjectRaw: subjraw,
Message: msg,
MessageRaw: msgraw,
MessageDetails: dets,
}, nil
}
func (c *Converter) policiesMessage(eventid string, nt NotificationTemplate, executant *user.User, filename string, ts time.Time) (OC10Notification, error) {
subj, subjraw, msg, msgraw, err := composeMessage(nt, c.locale, c.defaultLanguage, c.translationPath, map[string]any{
"resourcename": filename,
})
if err != nil {
return OC10Notification{}, err
}
dets := map[string]any{
"resource": map[string]string{
"name": filename,
},
}
return OC10Notification{
EventID: eventid,
Service: c.serviceName,
UserName: executant.GetUsername(),
Timestamp: ts.Format(time.RFC3339Nano),
ResourceType: _resourceTypeResource,
Subject: subj,
SubjectRaw: subjraw,
Message: msg,
MessageRaw: msgraw,
MessageDetails: dets,
}, nil
}
func (c *Converter) deprovisionMessage(nt NotificationTemplate, deproDate string) (OC10Notification, error) {
subj, subjraw, msg, msgraw, err := composeMessage(nt, c.locale, c.defaultLanguage, c.translationPath, map[string]any{
"date": deproDate,
})
if err != nil {
return OC10Notification{}, err
}
return OC10Notification{
EventID: "deprovision",
Service: c.serviceName,
// UserName: executant.GetUsername(), // TODO: do we need the deprovisioner?
Timestamp: time.Now().Format(time.RFC3339Nano), // Fake timestamp? Or we store one with the event?
ResourceType: _resourceTypeResource,
Subject: subj,
SubjectRaw: subjraw,
Message: msg,
MessageRaw: msgraw,
MessageDetails: map[string]any{},
}, nil
}
func (c *Converter) getSpace(ctx context.Context, spaceID string) (*storageprovider.StorageSpace, error) {
if space, ok := c.spaces[spaceID]; ok {
return space, nil
}
gwc, err := c.gatewaySelector.Next()
if err != nil {
return nil, err
}
space, err := utils.GetSpace(ctx, spaceID, gwc)
if err == nil {
c.spaces[spaceID] = space
}
return space, err
}
func (c *Converter) getResource(ctx context.Context, resourceID *storageprovider.ResourceId) (*storageprovider.ResourceInfo, error) {
if r, ok := c.resources[resourceID.GetOpaqueId()]; ok {
return r, nil
}
gwc, err := c.gatewaySelector.Next()
if err != nil {
return nil, err
}
resource, err := utils.GetResourceByID(ctx, resourceID, gwc)
if err == nil {
c.resources[resourceID.GetOpaqueId()] = resource
}
return resource, err
}
func (c *Converter) getUser(ctx context.Context, userID *user.UserId) (*user.User, error) {
if u, ok := c.users[userID.GetOpaqueId()]; ok {
return u, nil
}
gwc, err := c.gatewaySelector.Next()
if err != nil {
return nil, err
}
usr, err := utils.GetUserNoGroups(ctx, userID, gwc)
if err == nil {
c.users[userID.GetOpaqueId()] = usr
}
return usr, err
}
func composeMessage(nt NotificationTemplate, locale, defaultLocale, path string, vars map[string]any) (string, string, string, string, error) {
subjectraw, messageraw := loadTemplates(nt, locale, defaultLocale, path)
subject, err := executeTemplate(subjectraw, vars)
if err != nil {
return "", "", "", "", err
}
message, err := executeTemplate(messageraw, vars)
return subject, subjectraw, message, messageraw, err
}
func loadTemplates(nt NotificationTemplate, locale, defaultLocale, path string) (string, string) {
t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, path, _translationFS, "l10n/locale").Locale(locale)
return t.Get(nt.Subject, []any{}...), t.Get(nt.Message, []any{}...)
}
func executeTemplate(raw string, vars map[string]any) (string, error) {
for o, n := range _placeholders {
raw = strings.ReplaceAll(raw, o, n)
}
tpl, err := template.New("").Parse(raw)
if err != nil {
return "", err
}
var writer bytes.Buffer
if err := tpl.Execute(&writer, vars); err != nil {
return "", err
}
return writer.String(), nil
}
func generateDetails(user *user.User, space *storageprovider.StorageSpace, item *storageprovider.ResourceInfo, shareid *collaboration.ShareId) map[string]any {
details := make(map[string]any)
if user != nil {
details["user"] = map[string]string{
"id": user.GetId().GetOpaqueId(),
"name": user.GetUsername(),
"displayname": user.GetDisplayName(),
}
}
if space != nil {
details["space"] = map[string]string{
"id": space.GetId().GetOpaqueId(),
"name": space.GetName(),
}
}
if item != nil {
details["resource"] = map[string]string{
"id": storagespace.FormatResourceID(item.GetId()),
"name": item.GetName(),
}
}
if shareid != nil {
details["share"] = map[string]string{
"id": shareid.GetOpaqueId(),
}
}
return details
}
@@ -0,0 +1,103 @@
package service
import (
"context"
"errors"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/middleware"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/settings/pkg/store/defaults"
"github.com/opencloud-eu/reva/v2/pkg/events"
micrometadata "go-micro.dev/v4/metadata"
)
type userlogFilter struct {
log log.Logger
valueClient settingssvc.ValueService
}
func newUserlogFilter(l log.Logger, vc settingssvc.ValueService) *userlogFilter {
return &userlogFilter{log: l, valueClient: vc}
}
// execute removes users who should not receive an in-app notifications for the event
func (ulf userlogFilter) execute(ctx context.Context, event events.Event, executant *user.UserId, users []string) []string {
filteredUsers := ulf.filterExecutant(users, executant)
return ulf.filterUsersBySettings(ctx, filteredUsers, event)
}
// filterExecutant removes the executant
func (ulf userlogFilter) filterExecutant(users []string, executant *user.UserId) []string {
var filteredUsers []string
for _, u := range users {
if u != executant.GetOpaqueId() {
filteredUsers = append(filteredUsers, u)
}
}
return filteredUsers
}
// filterUsersBySettings removes users who have disabled in-app notifications for the event
func (ulf userlogFilter) filterUsersBySettings(ctx context.Context, users []string, event events.Event) []string {
var filteredUsers []string
var settingId string
// map type to settings key
switch event.Event.(type) {
case events.ShareCreated:
settingId = defaults.SettingUUIDProfileEventShareCreated
case events.ShareRemoved:
settingId = defaults.SettingUUIDProfileEventShareRemoved
case events.ShareExpired:
settingId = defaults.SettingUUIDProfileEventShareExpired
case events.SpaceShared:
settingId = defaults.SettingUUIDProfileEventSpaceShared
case events.SpaceUnshared:
settingId = defaults.SettingUUIDProfileEventSpaceUnshared
case events.SpaceMembershipExpired:
settingId = defaults.SettingUUIDProfileEventSpaceMembershipExpired
case events.SpaceDisabled:
settingId = defaults.SettingUUIDProfileEventSpaceDisabled
case events.SpaceDeleted:
settingId = defaults.SettingUUIDProfileEventSpaceDeleted
default:
// event that cannot be disabled
return users
}
for _, u := range users {
enabled, err := getSetting(ctx, ulf.valueClient, u, settingId)
if err != nil {
ulf.log.Error().Err(err).Str("userId", u).Str("settingId", settingId).Msg("cannot get user event setting")
continue
}
if enabled {
filteredUsers = append(filteredUsers, u)
}
}
return filteredUsers
}
func getSetting(ctx context.Context, vc settingssvc.ValueService, userId string, settingId string) (bool, error) {
resp, err := vc.GetValueByUniqueIdentifiers(
micrometadata.Set(ctx, middleware.AccountID, userId),
&settingssvc.GetValueByUniqueIdentifiersRequest{
AccountUuid: userId,
SettingId: settingId,
},
)
if err != nil {
return false, err
}
val := resp.GetValue().GetValue().GetCollectionValue().GetValues()
for _, option := range val {
if option.GetKey() == "in-app" {
return option.GetBoolValue(), nil
}
}
return false, errors.New("no setting found")
}
@@ -0,0 +1,178 @@
package service
import (
"context"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/qsfera/server/pkg/log"
settingsmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/settings/v0"
settings "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
settingsmocks "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0/mocks"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/pkg/errors"
"github.com/stretchr/testify/mock"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("NotificationFilter", func() {
var (
testLogger = log.NewLogger()
vs *settingsmocks.ValueService
ulf userlogFilter
)
BeforeEach(func() {
vs = &settingsmocks.ValueService{}
ulf = userlogFilter{
log: testLogger,
valueClient: vs,
}
})
setupMockValueService := func(inApp bool) *settingsmocks.ValueService {
vs := settingsmocks.ValueService{}
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(&settings.GetValueResponse{
Value: &settingsmsg.ValueWithIdentifier{
Value: &settingsmsg.Value{
Value: &settingsmsg.Value_CollectionValue{
CollectionValue: &settingsmsg.CollectionValue{
Values: []*settingsmsg.CollectionOption{
{
Key: "in-app",
Option: &settingsmsg.CollectionOption_BoolValue{BoolValue: inApp},
},
},
},
},
},
},
}, nil)
return &vs
}
Describe("execute", func() {
It("handles executants", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(nil, nil)
Expect(ulf.execute(context.TODO(), events.Event{}, &user.UserId{OpaqueId: "executant"}, []string{"foo"})).To(ConsistOf("foo"))
})
It("handles connection errors", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(nil, errors.New("no connection to ValueService"))
Expect(ulf.execute(context.TODO(), events.Event{Event: events.ShareCreated{}}, nil, []string{"foo"})).To(BeEmpty())
})
It("handles no setting", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(&settings.GetValueResponse{}, nil)
Expect(ulf.execute(context.TODO(), events.Event{Event: events.ShareCreated{}}, nil, []string{"foo"})).To(BeEmpty())
})
It("handles nil response", func() {
vs.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(nil, nil)
Expect(ulf.execute(context.TODO(), events.Event{Event: events.ShareCreated{}}, nil, []string{"foo"})).To(BeEmpty())
})
It("handles events that can not be disabled", func() {
ulf.valueClient = setupMockValueService(true)
Expect(ulf.execute(context.TODO(), events.Event{Event: events.BytesReceived{}}, nil, []string{"foo"})).To(ConsistOf("foo"))
})
It("handles ShareCreated events", func() {
ulf.valueClient = setupMockValueService(true)
Expect(ulf.execute(context.TODO(), events.Event{Event: events.ShareCreated{}}, nil, []string{"foo"})).To(ConsistOf("foo"))
})
It("handles ShareRemoved events", func() {
ulf.valueClient = setupMockValueService(true)
Expect(ulf.execute(context.TODO(), events.Event{Event: events.ShareRemoved{}}, nil, []string{"foo"})).To(ConsistOf("foo"))
})
It("handles ShareExpired events", func() {
ulf.valueClient = setupMockValueService(true)
Expect(ulf.execute(context.TODO(), events.Event{Event: events.ShareExpired{}}, nil, []string{"foo"})).To(ConsistOf("foo"))
})
It("handles SpaceShared enabled", func() {
ulf.valueClient = setupMockValueService(true)
Expect(ulf.execute(context.TODO(), events.Event{Event: events.SpaceShared{}}, nil, []string{"foo"})).To(ConsistOf("foo"))
})
It("handles SpaceUnshared enabled", func() {
ulf.valueClient = setupMockValueService(true)
Expect(ulf.execute(context.TODO(), events.Event{Event: events.SpaceUnshared{}}, nil, []string{"foo"})).To(ConsistOf("foo"))
})
It("handles SpaceMembershipExpired enabled", func() {
ulf.valueClient = setupMockValueService(true)
Expect(ulf.execute(context.TODO(), events.Event{Event: events.SpaceMembershipExpired{}}, nil, []string{"foo"})).To(ConsistOf("foo"))
})
It("handles SpaceDisabled enabled", func() {
ulf.valueClient = setupMockValueService(true)
Expect(ulf.execute(context.TODO(), events.Event{Event: events.SpaceDisabled{}}, nil, []string{"foo"})).To(ConsistOf("foo"))
})
It("handles SpaceDeleted enabled", func() {
ulf.valueClient = setupMockValueService(true)
Expect(ulf.execute(context.TODO(), events.Event{Event: events.SpaceDeleted{}}, nil, []string{"foo"})).To(ConsistOf("foo"))
})
It("handles ShareCreated disabled", func() {
ulf.valueClient = setupMockValueService(false)
Expect(ulf.execute(context.TODO(), events.Event{Event: events.ShareCreated{}}, nil, []string{"foo"})).To(BeEmpty())
})
It("handles ShareRemoved disabled", func() {
ulf.valueClient = setupMockValueService(false)
Expect(ulf.execute(context.TODO(), events.Event{Event: events.ShareRemoved{}}, nil, []string{"foo"})).To(BeEmpty())
})
It("handles ShareExpired disabled", func() {
ulf.valueClient = setupMockValueService(false)
Expect(ulf.execute(context.TODO(), events.Event{Event: events.ShareExpired{}}, nil, []string{"foo"})).To(BeEmpty())
})
It("handles SpaceShared disabled", func() {
ulf.valueClient = setupMockValueService(false)
Expect(ulf.execute(context.TODO(), events.Event{Event: events.SpaceShared{}}, nil, []string{"foo"})).To(BeEmpty())
})
It("handles SpaceUnshared disabled", func() {
ulf.valueClient = setupMockValueService(false)
Expect(ulf.execute(context.TODO(), events.Event{Event: events.SpaceUnshared{}}, nil, []string{"foo"})).To(BeEmpty())
})
It("handles SpaceMembershipExpired disabled", func() {
ulf.valueClient = setupMockValueService(false)
Expect(ulf.execute(context.TODO(), events.Event{Event: events.SpaceMembershipExpired{}}, nil, []string{"foo"})).To(BeEmpty())
})
It("handles SpaceDisabled disabled", func() {
ulf.valueClient = setupMockValueService(false)
Expect(ulf.execute(context.TODO(), events.Event{Event: events.SpaceDisabled{}}, nil, []string{"foo"})).To(BeEmpty())
})
It("handles SpaceDeleted disabled", func() {
ulf.valueClient = setupMockValueService(false)
Expect(ulf.execute(context.TODO(), events.Event{Event: events.SpaceDeleted{}}, nil, []string{"foo"})).To(BeEmpty())
})
})
})
@@ -0,0 +1,17 @@
package service
import "time"
var (
_globalEventsKey = "global-events"
)
// DeprovisionData is the data needed for the deprovision global event
type DeprovisionData struct {
// The deprovision date
DeprovisionDate time.Time `json:"deprovision_date"`
// The Format of the deprvision date
DeprovisionFormat string
// The user who stored the deprovision message
Deprovisioner string
}
+265
View File
@@ -0,0 +1,265 @@
package service
import (
"context"
"encoding/json"
"errors"
"net/http"
"github.com/qsfera/server/pkg/roles"
"github.com/qsfera/server/services/graph/pkg/errorcode"
settings "github.com/qsfera/server/services/settings/pkg/service/v0"
"github.com/opencloud-eu/reva/v2/pkg/appctx"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"go.opentelemetry.io/otel/attribute"
)
// HeaderAcceptLanguage is the header where the client can set the locale
var HeaderAcceptLanguage = "Accept-Language"
// ServeHTTP fulfills Handler interface
func (ul *UserlogService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ul.m.ServeHTTP(w, r)
}
// HandleGetEvents is the GET handler for events
func (ul *UserlogService) HandleGetEvents(w http.ResponseWriter, r *http.Request) {
ctx, span := ul.tracer.Start(r.Context(), "HandleGetEvents")
defer span.End()
u, ok := revactx.ContextGetUser(ctx)
if !ok {
ul.log.Error().Int("returned statuscode", http.StatusUnauthorized).Msg("user unauthorized")
w.WriteHeader(http.StatusUnauthorized)
return
}
evs, err := ul.GetEvents(ctx, u.GetId().GetOpaqueId())
if err != nil {
ul.log.Error().Err(err).Int("returned statuscode", http.StatusInternalServerError).Msg("get events failed")
w.WriteHeader(http.StatusInternalServerError)
return
}
span.SetAttributes(attribute.KeyValue{
Key: "events",
Value: attribute.IntValue(len(evs)),
})
gwc, err := ul.gatewaySelector.Next()
if err != nil {
ul.log.Error().Err(err).Msg("cant get gateway client")
w.WriteHeader(http.StatusInternalServerError)
return
}
ctx, err = utils.GetServiceUserContext(ul.cfg.ServiceAccount.ServiceAccountID, gwc, ul.cfg.ServiceAccount.ServiceAccountSecret)
if err != nil {
ul.log.Error().Err(err).Msg("cant get service account")
w.WriteHeader(http.StatusInternalServerError)
return
}
conv := NewConverter(ctx, r.Header.Get(HeaderAcceptLanguage), ul.gatewaySelector, ul.cfg.Service.Name, ul.cfg.TranslationPath, ul.cfg.DefaultLanguage)
var outdatedEvents []string
resp := GetEventResponseOC10{}
for _, e := range evs {
etype, ok := ul.registeredEvents[e.Type]
if !ok {
ul.log.Error().Str("eventid", e.Id).Str("eventtype", e.Type).Msg("event not registered")
continue
}
einterface, err := etype.Unmarshal(e.Event)
if err != nil {
ul.log.Error().Str("eventid", e.Id).Str("eventtype", e.Type).Msg("failed to umarshal event")
continue
}
noti, err := conv.ConvertEvent(e.Id, einterface)
if err != nil {
if utils.IsErrNotFound(err) || utils.IsErrPermissionDenied(err) {
outdatedEvents = append(outdatedEvents, e.Id)
continue
}
ul.log.Error().Err(err).Str("eventid", e.Id).Str("eventtype", e.Type).Msg("failed to convert event")
continue
}
resp.OCS.Data = append(resp.OCS.Data, noti)
}
// delete outdated events asynchronously
if len(outdatedEvents) > 0 {
go func() {
err := ul.DeleteEvents(u.GetId().GetOpaqueId(), outdatedEvents)
if err != nil {
ul.log.Error().Err(err).Msg("failed to delete events")
}
}()
}
glevs, err := ul.GetGlobalEvents(ctx)
if err != nil {
ul.log.Error().Err(err).Int("returned statuscode", http.StatusInternalServerError).Msg("get global events failed")
w.WriteHeader(http.StatusInternalServerError)
return
}
for t, data := range glevs {
noti, err := conv.ConvertGlobalEvent(t, data)
if err != nil {
ul.log.Error().Err(err).Str("eventtype", t).Msg("failed to convert event")
continue
}
resp.OCS.Data = append(resp.OCS.Data, noti)
}
resp.OCS.Meta.StatusCode = http.StatusOK
b, _ := json.Marshal(resp)
w.Write(b)
}
// HandlePostGlobaelEvent is the POST handler for global events
func (ul *UserlogService) HandlePostGlobalEvent(w http.ResponseWriter, r *http.Request) {
var req PostEventsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ul.log.Error().Err(err).Int("returned statuscode", http.StatusBadRequest).Msg("request body is malformed")
w.WriteHeader(http.StatusBadRequest)
return
}
if err := ul.StoreGlobalEvent(r.Context(), req.Type, req.Data); err != nil {
ul.log.Error().Err(err).Msg("post: error storing global event")
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// HandleDeleteGlobalEvent is the DELETE handler for global events
func (ul *UserlogService) HandleDeleteGlobalEvent(w http.ResponseWriter, r *http.Request) {
var req DeleteEventsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ul.log.Error().Err(err).Int("returned statuscode", http.StatusBadRequest).Msg("request body is malformed")
w.WriteHeader(http.StatusBadRequest)
return
}
if err := ul.DeleteGlobalEvents(r.Context(), req.IDs); err != nil {
ul.log.Error().Err(err).Int("returned statuscode", http.StatusInternalServerError).Msg("delete events failed")
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// HandleDeleteEvents is the DELETE handler for events
func (ul *UserlogService) HandleDeleteEvents(w http.ResponseWriter, r *http.Request) {
u, ok := revactx.ContextGetUser(r.Context())
if !ok {
ul.log.Error().Int("returned statuscode", http.StatusUnauthorized).Msg("user unauthorized")
w.WriteHeader(http.StatusUnauthorized)
return
}
var req DeleteEventsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ul.log.Error().Err(err).Int("returned statuscode", http.StatusBadRequest).Msg("request body is malformed")
w.WriteHeader(http.StatusBadRequest)
return
}
if err := ul.DeleteEvents(u.GetId().GetOpaqueId(), req.IDs); err != nil {
ul.log.Error().Err(err).Int("returned statuscode", http.StatusInternalServerError).Msg("delete events failed")
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
// GetEventResponseOC10 is the response from GET events endpoint in oc10 style
type GetEventResponseOC10 struct {
OCS struct {
Meta struct {
Message string `json:"message"`
Status string `json:"status"`
StatusCode int `json:"statuscode"`
} `json:"meta"`
Data []OC10Notification `json:"data"`
} `json:"ocs"`
}
// DeleteEventsRequest is the expected body for the delete request
type DeleteEventsRequest struct {
IDs []string `json:"ids"`
}
// PostEventsRequest is the expected body for the post request
type PostEventsRequest struct {
// the event type, e.g. "deprovision"
Type string `json:"type"`
// arbitray data for the event
Data map[string]string `json:"data"`
}
// RequireAdminOrSecret middleware allows only requests if the requesting user is an admin or knows the static secret
func RequireAdminOrSecret(rm *roles.Manager, secret string) func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// allow bypassing admin requirement by sending the correct secret
if secret != "" && r.Header.Get("secret") == secret {
next.ServeHTTP(w, r)
return
}
isadmin, err := isAdmin(r.Context(), rm)
if err != nil {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "")
return
}
if isadmin {
next.ServeHTTP(w, r)
return
}
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "Not found")
return
}
}
}
// isAdmin determines if the user in the context is an admin / has account management permissions
func isAdmin(ctx context.Context, rm *roles.Manager) (bool, error) {
logger := appctx.GetLogger(ctx)
u, ok := revactx.ContextGetUser(ctx)
uid := u.GetId().GetOpaqueId()
if !ok || uid == "" {
logger.Error().Str("userid", uid).Msg("user not in context")
return false, errors.New("no user in context")
}
// get roles from context
roleIDs, ok := roles.ReadRoleIDsFromContext(ctx)
if !ok {
logger.Debug().Str("userid", uid).Msg("No roles in context, contacting settings service")
var err error
roleIDs, err = rm.FindRoleIDsForUser(ctx, uid)
if err != nil {
logger.Err(err).Str("userid", uid).Msg("failed to get roles for user")
return false, err
}
if len(roleIDs) == 0 {
logger.Err(err).Str("userid", uid).Msg("user has no roles")
return false, errors.New("user has no roles")
}
}
// check if permission is present in roles of the authenticated account
return rm.FindPermissionByID(ctx, roleIDs, settings.AccountManagementPermissionID) != nil, nil
}
@@ -0,0 +1,11 @@
[main]
host = https://www.transifex.com
[o:qsfera-eu:p:qsfera-eu:r:qsfera-userlog]
file_filter = locale/<lang>/LC_MESSAGES/userlog.po
minimum_perc = 75
resource_name = qsfera-userlog
source_file = userlog.pot
source_lang = en
type = PO
@@ -0,0 +1,119 @@
# 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 <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Ivan Fustero, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-07 00:02+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Ivan Fustero, 2025\n"
"Language-Team: Catalan (https://app.transifex.com/qsfera-eu/teams/204053/ca/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: ca\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: pkg/service/templates.go:39
msgid "Access to Space {space} lost"
msgstr "S'ha perdut l'accés a l'espai {space}"
#: pkg/service/templates.go:54
msgid "Access to {resource} expired"
msgstr "L'accés a {resource} ha caducat"
#: pkg/service/templates.go:59
msgid ""
"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."
msgstr ""
"Atenció! La instància es tancarà el {date}. Descarrega les dades abans, "
"perquè després no shi podrà accedir."
#: pkg/service/templates.go:58
msgid "Instance will be shut down and deprovisioned"
msgstr "La instància es tancarà i es desactivarà"
#: pkg/service/templates.go:38
msgid "Membership expired"
msgstr "La pertinença ha expirat"
#: pkg/service/templates.go:13
msgid "Policies enforced"
msgstr "Polítiques aplicades"
#: pkg/service/templates.go:23
msgid "Removed from Space"
msgstr "Eliminat de l'espai"
#: pkg/service/templates.go:43
msgid "Resource shared"
msgstr "Recurs compartit"
#: pkg/service/templates.go:48
msgid "Resource unshared"
msgstr "Recurs sense compartir"
#: pkg/service/templates.go:53
msgid "Share expired"
msgstr "La compartició ha caducat"
#: pkg/service/templates.go:33
msgid "Space deleted"
msgstr "Espai eliminat"
#: pkg/service/templates.go:28
msgid "Space disabled"
msgstr "Espai desactivat"
#: pkg/service/templates.go:18
msgid "Space shared"
msgstr "Espai compartit"
#: pkg/service/templates.go:14
msgid ""
"The file {resource} was deleted because it violates the restrictions of this"
" cloud. This could be due to an unsupported file type, potentially harmful "
"or prohibited content, or file size limits."
msgstr ""
#: pkg/service/templates.go:8
msgid "Virus found"
msgstr "S'ha trobat un virus"
#: pkg/service/templates.go:9
msgid "Virus found in {resource}. Upload not possible. Virus: {virus}"
msgstr ""
"S'ha trobat un virus a {resource}. La càrrega no és possible. Virus: {virus}"
#: pkg/service/templates.go:19
msgid "{user} added you to Space {space}"
msgstr "{user} us ha afegit a l'espai {space}"
#: pkg/service/templates.go:34
msgid "{user} deleted Space {space}"
msgstr "{user} ha suprimit l'espai {space}"
#: pkg/service/templates.go:29
msgid "{user} disabled Space {space}"
msgstr "{user} ha desactvat l'espai {space}"
#: pkg/service/templates.go:24
msgid "{user} removed you from Space {space}"
msgstr "{user} us ha tret de l'espai {space}"
#: pkg/service/templates.go:44
msgid "{user} shared {resource} with you"
msgstr "{user} ha compartit {resource} amb vós"
#: pkg/service/templates.go:49
msgid "{user} unshared {resource} with you"
msgstr "{user} ha deixat de compartir {resource} amb vós"
@@ -0,0 +1,126 @@
# 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 <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Jörn Friedrich Dreyer <jfd@butonic.de>, 2025
# Jannick Kuhr, 2026
# Christian Richter, 2026
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-08 00:02+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Christian Richter, 2026\n"
"Language-Team: German (https://app.transifex.com/qsfera-eu/teams/204053/de/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: de\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: pkg/service/templates.go:39
msgid "Access to Space {space} lost"
msgstr "Zugriff auf Space {space} verloren"
#: pkg/service/templates.go:54
msgid "Access to {resource} expired"
msgstr "Zugriff auf {resource} abgelaufen"
#: pkg/service/templates.go:59
msgid ""
"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."
msgstr ""
"Achtung! Diese Instanz wird am {date} heruntergefahren und außer Betrieb "
"genommen werden. Laden Sie Ihre Daten vor diesem Tag herunter, da Sie danach"
" nicht mehr darauf zugreifen können."
#: pkg/service/templates.go:58
msgid "Instance will be shut down and deprovisioned"
msgstr "Instanz wird heruntergefahren und außer Betrieb genommen werden."
#: pkg/service/templates.go:38
msgid "Membership expired"
msgstr "Mitgliedschaft abgelaufen"
#: pkg/service/templates.go:13
msgid "Policies enforced"
msgstr "Verstoß gegen Richtlinien"
#: pkg/service/templates.go:23
msgid "Removed from Space"
msgstr "Aus Space entfernt"
#: pkg/service/templates.go:43
msgid "Resource shared"
msgstr "Neue Freigabe"
#: pkg/service/templates.go:48
msgid "Resource unshared"
msgstr "Freigabe entfernt"
#: pkg/service/templates.go:53
msgid "Share expired"
msgstr "Freigabe abgelaufen"
#: pkg/service/templates.go:33
msgid "Space deleted"
msgstr "Space gelöscht"
#: pkg/service/templates.go:28
msgid "Space disabled"
msgstr "Space deaktiviert"
#: pkg/service/templates.go:18
msgid "Space shared"
msgstr "Space freigegeben"
#: pkg/service/templates.go:14
msgid ""
"The file {resource} was deleted because it violates the restrictions of this"
" cloud. This could be due to an unsupported file type, potentially harmful "
"or prohibited content, or file size limits."
msgstr ""
"\"Die Datei {resource} wurde gelöscht, weil sie gegen die Begrenzungen "
"dieser Cloud verstößt. Das kann ein nicht zugelassener Dateityp sein, "
"potentiell gefährliche oder verbotene Inhalte oder Dateigrößenbegrenzungen\""
#: pkg/service/templates.go:8
msgid "Virus found"
msgstr "Virus gefunden"
#: pkg/service/templates.go:9
msgid "Virus found in {resource}. Upload not possible. Virus: {virus}"
msgstr ""
"In {resource} wurde potenziell schädlicher Code gefunden. Das Hochladen wurde abgebrochen.\n"
"Grund: {virus}"
#: pkg/service/templates.go:19
msgid "{user} added you to Space {space}"
msgstr "{user} hat Sie zu Space {space} hinzugefügt"
#: pkg/service/templates.go:34
msgid "{user} deleted Space {space}"
msgstr "{user} hat Space {space} gelöscht"
#: pkg/service/templates.go:29
msgid "{user} disabled Space {space}"
msgstr "{user} hat Space {space} deaktiviert"
#: pkg/service/templates.go:24
msgid "{user} removed you from Space {space}"
msgstr "{user} hat Sie aus Space {space} entfernt."
#: pkg/service/templates.go:44
msgid "{user} shared {resource} with you"
msgstr "{user} hat {resource} mit Ihnen geteilt"
#: pkg/service/templates.go:49
msgid "{user} unshared {resource} with you"
msgstr "{user} hat die Freigabe auf {resource} für Sie aufgehoben"
@@ -0,0 +1,124 @@
# 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 <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Efstathios Iosifidis <eiosifidis@gmail.com>, 2026
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-08 00:02+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Efstathios Iosifidis <eiosifidis@gmail.com>, 2026\n"
"Language-Team: Greek (https://app.transifex.com/qsfera-eu/teams/204053/el/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: el\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: pkg/service/templates.go:39
msgid "Access to Space {space} lost"
msgstr "Η πρόσβαση στον Χώρο {space} χάθηκε"
#: pkg/service/templates.go:54
msgid "Access to {resource} expired"
msgstr "Η πρόσβαση στο στοιχείο {resource} έληξε"
#: pkg/service/templates.go:59
msgid ""
"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."
msgstr ""
"Προσοχή! Η παρουσία (instance) θα απενεργοποιηθεί και θα αποδεσμευτεί στις "
"{date}. Κατεβάστε όλα τα δεδομένα σας πριν από αυτή την ημερομηνία, καθώς "
"μετά δεν θα είναι δυνατή η πρόσβαση."
#: pkg/service/templates.go:58
msgid "Instance will be shut down and deprovisioned"
msgstr "Η παρουσία (instance) θα τερματιστεί και θα αποδεσμευτεί"
#: pkg/service/templates.go:38
msgid "Membership expired"
msgstr "Η ιδιότητα μέλους έληξε"
#: pkg/service/templates.go:13
msgid "Policies enforced"
msgstr "Εφαρμογή πολιτικών"
#: pkg/service/templates.go:23
msgid "Removed from Space"
msgstr "Αφαίρεση από τον Χώρο"
#: pkg/service/templates.go:43
msgid "Resource shared"
msgstr "Διαμοιρασμός πόρου"
#: pkg/service/templates.go:48
msgid "Resource unshared"
msgstr "Διακοπή διαμοιρασμού πόρου"
#: pkg/service/templates.go:53
msgid "Share expired"
msgstr "Ο διαμοιρασμός έληξε"
#: pkg/service/templates.go:33
msgid "Space deleted"
msgstr "Διαγραφή χώρου"
#: pkg/service/templates.go:28
msgid "Space disabled"
msgstr "Απενεργοποίηση χώρου"
#: pkg/service/templates.go:18
msgid "Space shared"
msgstr "Διαμοιρασμός χώρου"
#: pkg/service/templates.go:14
msgid ""
"The file {resource} was deleted because it violates the restrictions of this"
" cloud. This could be due to an unsupported file type, potentially harmful "
"or prohibited content, or file size limits."
msgstr ""
"Το αρχείο {resource} διαγράφηκε επειδή παραβιάζει τους περιορισμούς αυτού "
"του cloud. Αυτό μπορεί να οφείλεται σε μη υποστηριζόμενο τύπο αρχείου, σε "
"περιεχόμενο που ενδέχεται να είναι επιβλαβές ή απαγορευμένο, ή σε όρια "
"μεγέθους αρχείου."
#: pkg/service/templates.go:8
msgid "Virus found"
msgstr "Βρέθηκε ιός"
#: pkg/service/templates.go:9
msgid "Virus found in {resource}. Upload not possible. Virus: {virus}"
msgstr ""
"Βρέθηκε ιός στο {resource}. Η μεταφόρτωση δεν είναι δυνατή. Ιός: {virus}"
#: pkg/service/templates.go:19
msgid "{user} added you to Space {space}"
msgstr "Ο/Η {user} σάς πρόσθεσε στον Χώρο {space}"
#: pkg/service/templates.go:34
msgid "{user} deleted Space {space}"
msgstr "Ο/Η {user} διέγραψε τον Χώρο {space}"
#: pkg/service/templates.go:29
msgid "{user} disabled Space {space}"
msgstr "Ο/Η {user} απενεργοποίησε τον Χώρο {space}"
#: pkg/service/templates.go:24
msgid "{user} removed you from Space {space}"
msgstr "Ο/Η {user} σάς αφαίρεσε από τον Χώρο {space}"
#: pkg/service/templates.go:44
msgid "{user} shared {resource} with you"
msgstr "Ο/Η {user} διαμοιράστηκε το {resource} μαζί σας"
#: pkg/service/templates.go:49
msgid "{user} unshared {resource} with you"
msgstr "Ο/Η {user} διέκοψε τον διαμοιρασμό του {resource} μαζί σας"
@@ -0,0 +1,120 @@
# 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 <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Elías Martín, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-07 00:02+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Elías Martín, 2025\n"
"Language-Team: Spanish (https://app.transifex.com/qsfera-eu/teams/204053/es/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: es\n"
"Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
#: pkg/service/templates.go:39
msgid "Access to Space {space} lost"
msgstr "Acceso al Espacio {space} perdido"
#: pkg/service/templates.go:54
msgid "Access to {resource} expired"
msgstr "Acceso a {resource} expirado"
#: pkg/service/templates.go:59
msgid ""
"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."
msgstr ""
"¡Atención! Esta instancia se dará se parará y deprovisonará el {date}. "
"Descarga todos tus datos antes de la fecha límmite, transcurrido el plazo no"
" se podrá acceder más a los datos."
#: pkg/service/templates.go:58
msgid "Instance will be shut down and deprovisioned"
msgstr "La instancia será apagada y se desaprovisionará de sus recursos"
#: pkg/service/templates.go:38
msgid "Membership expired"
msgstr "Membresía expirada"
#: pkg/service/templates.go:13
msgid "Policies enforced"
msgstr "Políticas implementadas"
#: pkg/service/templates.go:23
msgid "Removed from Space"
msgstr "Eliminado de Espacio"
#: pkg/service/templates.go:43
msgid "Resource shared"
msgstr "Recurso compartido"
#: pkg/service/templates.go:48
msgid "Resource unshared"
msgstr "Recurso no compartido"
#: pkg/service/templates.go:53
msgid "Share expired"
msgstr "La compartición ha expirado"
#: pkg/service/templates.go:33
msgid "Space deleted"
msgstr "Espacio borrado"
#: pkg/service/templates.go:28
msgid "Space disabled"
msgstr "Espacio deshabilitado"
#: pkg/service/templates.go:18
msgid "Space shared"
msgstr "Espacio compartido"
#: pkg/service/templates.go:14
msgid ""
"The file {resource} was deleted because it violates the restrictions of this"
" cloud. This could be due to an unsupported file type, potentially harmful "
"or prohibited content, or file size limits."
msgstr ""
#: pkg/service/templates.go:8
msgid "Virus found"
msgstr "Virus encontrado"
#: pkg/service/templates.go:9
msgid "Virus found in {resource}. Upload not possible. Virus: {virus}"
msgstr ""
"Se encontró un visrus en {resource}. No se permite la subida. Virus: {virus}"
#: pkg/service/templates.go:19
msgid "{user} added you to Space {space}"
msgstr "{user} te añadió al Espacio {space}"
#: pkg/service/templates.go:34
msgid "{user} deleted Space {space}"
msgstr "{user} borró el Espacio {space}"
#: pkg/service/templates.go:29
msgid "{user} disabled Space {space}"
msgstr "{user} deshabilitó el Espacio {space}"
#: pkg/service/templates.go:24
msgid "{user} removed you from Space {space}"
msgstr "{user} te eliminó del Espacio {space}"
#: pkg/service/templates.go:44
msgid "{user} shared {resource} with you"
msgstr "{user} compartió {resource} contigo"
#: pkg/service/templates.go:49
msgid "{user} unshared {resource} with you"
msgstr "{user} dejó de compartir {resource} contigo"
@@ -0,0 +1,123 @@
# 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 <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Jiri Grönroos <jiri.gronroos@iki.fi>, 2026
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-23 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Jiri Grönroos <jiri.gronroos@iki.fi>, 2026\n"
"Language-Team: Finnish (https://app.transifex.com/qsfera-eu/teams/204053/fi/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: fi\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: pkg/service/templates.go:39
msgid "Access to Space {space} lost"
msgstr "Pääsy avaruuteen {space} menetetty"
#: pkg/service/templates.go:54
msgid "Access to {resource} expired"
msgstr "Pääsy resurssiin {resource} vanheni"
#: pkg/service/templates.go:59
msgid ""
"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."
msgstr ""
"Huomio! Tämä instanssi sammutetaan ja poistetaan {date}. Lataa datasi ennen "
"kyseistä päivää, sillä pääsy sen jälkeen ei ole mahdollista."
#: pkg/service/templates.go:58
msgid "Instance will be shut down and deprovisioned"
msgstr "Instanssi sammutetaan ja poistetaan käytöstä"
#: pkg/service/templates.go:38
msgid "Membership expired"
msgstr "Jäsenyys vanhentunut"
#: pkg/service/templates.go:13
msgid "Policies enforced"
msgstr "Käytännöt pakotettu"
#: pkg/service/templates.go:23
msgid "Removed from Space"
msgstr "Poistettu avaruudesta"
#: pkg/service/templates.go:43
msgid "Resource shared"
msgstr "Resurssi jaettu"
#: pkg/service/templates.go:48
msgid "Resource unshared"
msgstr "Resurssin jako lopetettu"
#: pkg/service/templates.go:53
msgid "Share expired"
msgstr "Jako vanhentunut"
#: pkg/service/templates.go:33
msgid "Space deleted"
msgstr "Jako poistettu"
#: pkg/service/templates.go:28
msgid "Space disabled"
msgstr "Avaruus poistettu käytöstä"
#: pkg/service/templates.go:18
msgid "Space shared"
msgstr "Avaruus jaettu"
#: pkg/service/templates.go:14
msgid ""
"The file {resource} was deleted because it violates the restrictions of this"
" cloud. This could be due to an unsupported file type, potentially harmful "
"or prohibited content, or file size limits."
msgstr ""
"Tiedosto {resource} poistettiin, koska se loukkaa tämän pilven rajoituksia. "
"Syy saattaa olla ei-tuettu tiedostomuoto, mahdollisesti haitallinen tai "
"kielletty sisältö tai tiedostojen kokorajat."
#: pkg/service/templates.go:8
msgid "Virus found"
msgstr "Virus löydetty"
#: pkg/service/templates.go:9
msgid "Virus found in {resource}. Upload not possible. Virus: {virus}"
msgstr ""
"Virus paikannettu resurssiin {resource}. Lähetys ei ole mahdollista. Virus: "
"{virus}"
#: pkg/service/templates.go:19
msgid "{user} added you to Space {space}"
msgstr "{user} lisäsi sinut avaruuteen {space}"
#: pkg/service/templates.go:34
msgid "{user} deleted Space {space}"
msgstr "{user} poisti avaruuden {space}"
#: pkg/service/templates.go:29
msgid "{user} disabled Space {space}"
msgstr "{user} poisti käytöstä avaruuden {space}"
#: pkg/service/templates.go:24
msgid "{user} removed you from Space {space}"
msgstr "{user} poisti sinut avaruudesta {space}"
#: pkg/service/templates.go:44
msgid "{user} shared {resource} with you"
msgstr "{user} jakoi {resource} kanssasi"
#: pkg/service/templates.go:49
msgid "{user} unshared {resource} with you"
msgstr "{user} lopettu resurssin {resource} jakamisen kanssasi"
@@ -0,0 +1,125 @@
# 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 <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# eric_G <junk.eg@free.fr>, 2025
# Benoît Aguesse, 2026
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-23 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Benoît Aguesse, 2026\n"
"Language-Team: French (https://app.transifex.com/qsfera-eu/teams/204053/fr/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: fr\n"
"Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
#: pkg/service/templates.go:39
msgid "Access to Space {space} lost"
msgstr "Accès à l'Espace {space} perdu"
#: pkg/service/templates.go:54
msgid "Access to {resource} expired"
msgstr "L'accès à {resource} a expiré"
#: pkg/service/templates.go:59
msgid ""
"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."
msgstr ""
"Attention ! L'instance sera fermée et déprovisionnée le {date}. Téléchargez "
"toutes vos données avant cette date car aucun accès ne sera possible après "
"cette date."
#: pkg/service/templates.go:58
msgid "Instance will be shut down and deprovisioned"
msgstr "L'instance sera fermée et déprovisionnée."
#: pkg/service/templates.go:38
msgid "Membership expired"
msgstr "Adhésion expirée"
#: pkg/service/templates.go:13
msgid "Policies enforced"
msgstr "Politiques appliquées"
#: pkg/service/templates.go:23
msgid "Removed from Space"
msgstr "Retiré de l'Espace"
#: pkg/service/templates.go:43
msgid "Resource shared"
msgstr "Ressource partagée"
#: pkg/service/templates.go:48
msgid "Resource unshared"
msgstr "Ressource non partagée"
#: pkg/service/templates.go:53
msgid "Share expired"
msgstr "Partage expiré"
#: pkg/service/templates.go:33
msgid "Space deleted"
msgstr "Espace supprimé"
#: pkg/service/templates.go:28
msgid "Space disabled"
msgstr "Espace désactivé"
#: pkg/service/templates.go:18
msgid "Space shared"
msgstr "Espace partagé"
#: pkg/service/templates.go:14
msgid ""
"The file {resource} was deleted because it violates the restrictions of this"
" cloud. This could be due to an unsupported file type, potentially harmful "
"or prohibited content, or file size limits."
msgstr ""
"Le fichier {resource} a été supprimé car il violait les restrictions de ce "
"cloud. Cela peut être dû à un type de fichier non pris en charge, du contenu"
" potentiellement malveillant ou prohibé ou des limites de taille de "
"fichiers."
#: pkg/service/templates.go:8
msgid "Virus found"
msgstr "Virus détecté"
#: pkg/service/templates.go:9
msgid "Virus found in {resource}. Upload not possible. Virus: {virus}"
msgstr ""
"Virus trouvé dans {resource}. Téléversement impossible. Virus : {virus}"
#: pkg/service/templates.go:19
msgid "{user} added you to Space {space}"
msgstr "{user} vous a ajouté à l'Espace {space}"
#: pkg/service/templates.go:34
msgid "{user} deleted Space {space}"
msgstr "{user} a supprimé l'Espace {space}"
#: pkg/service/templates.go:29
msgid "{user} disabled Space {space}"
msgstr "{utilisateur} a désactivé Espace {espace}"
#: pkg/service/templates.go:24
msgid "{user} removed you from Space {space}"
msgstr "{user} vous a retiré de l'Espace {space}"
#: pkg/service/templates.go:44
msgid "{user} shared {resource} with you"
msgstr "{user} a partagé {resource} avec vous"
#: pkg/service/templates.go:49
msgid "{user} unshared {resource} with you"
msgstr "{user} a annulé le partage de {resource} avec vous"
@@ -0,0 +1,121 @@
# 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 <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# mitibor, 2026
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-07 00:02+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: mitibor, 2026\n"
"Language-Team: Hungarian (https://app.transifex.com/qsfera-eu/teams/204053/hu/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: hu\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: pkg/service/templates.go:39
msgid "Access to Space {space} lost"
msgstr "A hozzáférés megszűnt a(z) {space} tárhelyhez"
#: pkg/service/templates.go:54
msgid "Access to {resource} expired"
msgstr "A hozzáférés a(z) {resource} erőforráshoz lejárt"
#: pkg/service/templates.go:59
msgid ""
"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."
msgstr ""
"Figyelem! A példány {date} napon leállításra és felszámolásra kerül. Kérjük,"
" ezen időpont előtt töltse le minden adatát, mivel ezt követően a hozzáférés"
" már nem lesz lehetséges."
#: pkg/service/templates.go:58
msgid "Instance will be shut down and deprovisioned"
msgstr "A példány leállításra és felszámolásra kerül"
#: pkg/service/templates.go:38
msgid "Membership expired"
msgstr "A tagság lejárt"
#: pkg/service/templates.go:13
msgid "Policies enforced"
msgstr "Irányelvek érvényesítve"
#: pkg/service/templates.go:23
msgid "Removed from Space"
msgstr "Eltávolítva a tárhelyről"
#: pkg/service/templates.go:43
msgid "Resource shared"
msgstr "Erőforrás megosztva"
#: pkg/service/templates.go:48
msgid "Resource unshared"
msgstr "Erőforrás megosztása visszavonva"
#: pkg/service/templates.go:53
msgid "Share expired"
msgstr "A megosztás lejárt"
#: pkg/service/templates.go:33
msgid "Space deleted"
msgstr "Tárhely törölve"
#: pkg/service/templates.go:28
msgid "Space disabled"
msgstr "Tárhely letiltva"
#: pkg/service/templates.go:18
msgid "Space shared"
msgstr "Tárhely megosztva"
#: pkg/service/templates.go:14
msgid ""
"The file {resource} was deleted because it violates the restrictions of this"
" cloud. This could be due to an unsupported file type, potentially harmful "
"or prohibited content, or file size limits."
msgstr ""
#: pkg/service/templates.go:8
msgid "Virus found"
msgstr "Vírus található"
#: pkg/service/templates.go:9
msgid "Virus found in {resource}. Upload not possible. Virus: {virus}"
msgstr ""
"Vírus található a(z) {resource} fájlban. A feltöltés nem lehetséges. Vírus: "
"{virus}"
#: pkg/service/templates.go:19
msgid "{user} added you to Space {space}"
msgstr "{user} hozzáadta Önt a(z) {space} tárhelyhez"
#: pkg/service/templates.go:34
msgid "{user} deleted Space {space}"
msgstr "{user} törölte a(z) {space} tárhelyet"
#: pkg/service/templates.go:29
msgid "{user} disabled Space {space}"
msgstr "{user} letiltotta a(z) {space} tárhelyet"
#: pkg/service/templates.go:24
msgid "{user} removed you from Space {space}"
msgstr "{user} eltávolította Önt a(z) {space} tárhelyről"
#: pkg/service/templates.go:44
msgid "{user} shared {resource} with you"
msgstr "{user} megosztotta Önnel a következő erőforrást: {resource}"
#: pkg/service/templates.go:49
msgid "{user} unshared {resource} with you"
msgstr "{user} visszavonta a(z) {resource} erőforrás megosztását Önnel"
@@ -0,0 +1,120 @@
# 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 <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Simone Broglia, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-07 00:02+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Simone Broglia, 2025\n"
"Language-Team: Italian (https://app.transifex.com/qsfera-eu/teams/204053/it/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: it\n"
"Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
#: pkg/service/templates.go:39
msgid "Access to Space {space} lost"
msgstr "Accesso allo Spazio {space} perso."
#: pkg/service/templates.go:54
msgid "Access to {resource} expired"
msgstr "Accesso allo Spazio {space} scaduto."
#: pkg/service/templates.go:59
msgid ""
"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."
msgstr ""
"Attenzione! L'istanza verrà disattivata e deprovisionata il {date}. Scarica "
"tutti i tuoi dati prima di tale data, poiché non sarà più possibile "
"accedervi successivamente."
#: pkg/service/templates.go:58
msgid "Instance will be shut down and deprovisioned"
msgstr "L'istanza verrà disattivata e deprovisionata."
#: pkg/service/templates.go:38
msgid "Membership expired"
msgstr "Iscrizione scaduta"
#: pkg/service/templates.go:13
msgid "Policies enforced"
msgstr "Regole applicate"
#: pkg/service/templates.go:23
msgid "Removed from Space"
msgstr "Rimosso dallo Spazio"
#: pkg/service/templates.go:43
msgid "Resource shared"
msgstr "Risorsa condivisa"
#: pkg/service/templates.go:48
msgid "Resource unshared"
msgstr "Risorsa non più condivisa"
#: pkg/service/templates.go:53
msgid "Share expired"
msgstr "Risorsa scaduta"
#: pkg/service/templates.go:33
msgid "Space deleted"
msgstr "Spazio eliminato"
#: pkg/service/templates.go:28
msgid "Space disabled"
msgstr "Spazio disabilitato"
#: pkg/service/templates.go:18
msgid "Space shared"
msgstr "Spazio condiviso"
#: pkg/service/templates.go:14
msgid ""
"The file {resource} was deleted because it violates the restrictions of this"
" cloud. This could be due to an unsupported file type, potentially harmful "
"or prohibited content, or file size limits."
msgstr ""
#: pkg/service/templates.go:8
msgid "Virus found"
msgstr "Virus trovato"
#: pkg/service/templates.go:9
msgid "Virus found in {resource}. Upload not possible. Virus: {virus}"
msgstr ""
"Virus trovato in {resource}. Caricamento non possibile. Virus: {virus}"
#: pkg/service/templates.go:19
msgid "{user} added you to Space {space}"
msgstr "{user} ti ha aggiunto allo Spazio {space}"
#: pkg/service/templates.go:34
msgid "{user} deleted Space {space}"
msgstr "{user} ha eliminato lo Spazio {space}."
#: pkg/service/templates.go:29
msgid "{user} disabled Space {space}"
msgstr "{user} ha disattivato lo Spazio {space}."
#: pkg/service/templates.go:24
msgid "{user} removed you from Space {space}"
msgstr "{user} ha rimosso lo Spazio {space}."
#: pkg/service/templates.go:44
msgid "{user} shared {resource} with you"
msgstr "{user} ha condiviso {resource} con te."
#: pkg/service/templates.go:49
msgid "{user} unshared {resource} with you"
msgstr "{user} ha revocato la condivisione di {resource} con te"
@@ -0,0 +1,118 @@
# 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 <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# iikaka88, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-07 00:02+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: iikaka88, 2025\n"
"Language-Team: Japanese (https://app.transifex.com/qsfera-eu/teams/204053/ja/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: ja\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#: pkg/service/templates.go:39
msgid "Access to Space {space} lost"
msgstr "スペース {space} へのアクセス権が失われました"
#: pkg/service/templates.go:54
msgid "Access to {resource} expired"
msgstr "{resource} へのアクセス期限が切れました"
#: pkg/service/templates.go:59
msgid ""
"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."
msgstr ""
"【重要】このインスタンスは {date} "
"に停止および削除されます。その日以降はアクセスできなくなるため、期日までにすべてのデータをダウンロードしてください。"
#: pkg/service/templates.go:58
msgid "Instance will be shut down and deprovisioned"
msgstr "インスタンスは停止および削除されます"
#: pkg/service/templates.go:38
msgid "Membership expired"
msgstr "メンバーシップの期限が切れました"
#: pkg/service/templates.go:13
msgid "Policies enforced"
msgstr "ポリシーが適用されました"
#: pkg/service/templates.go:23
msgid "Removed from Space"
msgstr "スペースから削除されました"
#: pkg/service/templates.go:43
msgid "Resource shared"
msgstr "リソースが共有されました"
#: pkg/service/templates.go:48
msgid "Resource unshared"
msgstr "リソースの共有が解除されました"
#: pkg/service/templates.go:53
msgid "Share expired"
msgstr "共有期限が切れました"
#: pkg/service/templates.go:33
msgid "Space deleted"
msgstr "スペースが削除されました"
#: pkg/service/templates.go:28
msgid "Space disabled"
msgstr "スペースが無効化されました"
#: pkg/service/templates.go:18
msgid "Space shared"
msgstr "スペースが共有されました"
#: pkg/service/templates.go:14
msgid ""
"The file {resource} was deleted because it violates the restrictions of this"
" cloud. This could be due to an unsupported file type, potentially harmful "
"or prohibited content, or file size limits."
msgstr ""
#: pkg/service/templates.go:8
msgid "Virus found"
msgstr "ウイルスが検出されました"
#: pkg/service/templates.go:9
msgid "Virus found in {resource}. Upload not possible. Virus: {virus}"
msgstr "{resource} 内にウイルスが検出されました。アップロードできません。ウイルス名: {virus}"
#: pkg/service/templates.go:19
msgid "{user} added you to Space {space}"
msgstr "{user} があなたをスペース「{space}」に追加しました"
#: pkg/service/templates.go:34
msgid "{user} deleted Space {space}"
msgstr "{user} がスペース「{space}」を削除しました"
#: pkg/service/templates.go:29
msgid "{user} disabled Space {space}"
msgstr "{user} がスペース「{space}」を無効にしました"
#: pkg/service/templates.go:24
msgid "{user} removed you from Space {space}"
msgstr "{user} があなたをスペース「{space}」から削除しました"
#: pkg/service/templates.go:44
msgid "{user} shared {resource} with you"
msgstr "{user} が {resource} をあなたと共有しました"
#: pkg/service/templates.go:49
msgid "{user} unshared {resource} with you"
msgstr "{user} が {resource} の共有を解除しました"
@@ -0,0 +1,118 @@
# 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 <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# gapho shin, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-07 00:02+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: gapho shin, 2025\n"
"Language-Team: Korean (https://app.transifex.com/qsfera-eu/teams/204053/ko/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: ko\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#: pkg/service/templates.go:39
msgid "Access to Space {space} lost"
msgstr "{space}에 대한 액세스가 손실되었습니다"
#: pkg/service/templates.go:54
msgid "Access to {resource} expired"
msgstr "{resource}에 대한 액세스가 만료되었습니다"
#: pkg/service/templates.go:59
msgid ""
"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."
msgstr ""
"주의하세요! 인스턴스는 {date}에 종료되고 프로비저닝이 해제됩니다. 해당 날짜 이후에는 액세스할 수 없으므로 해당 날짜 이전에 모든 "
"데이터를 다운로드하세요."
#: pkg/service/templates.go:58
msgid "Instance will be shut down and deprovisioned"
msgstr "인스턴스가 종료되고 프로비저닝 해제됩니다"
#: pkg/service/templates.go:38
msgid "Membership expired"
msgstr "멤버십 만료"
#: pkg/service/templates.go:13
msgid "Policies enforced"
msgstr "시행되는 정책"
#: pkg/service/templates.go:23
msgid "Removed from Space"
msgstr "스페이스로부터 제거"
#: pkg/service/templates.go:43
msgid "Resource shared"
msgstr "자원 공유"
#: pkg/service/templates.go:48
msgid "Resource unshared"
msgstr "리소스 비공유"
#: pkg/service/templates.go:53
msgid "Share expired"
msgstr "공유 만료"
#: pkg/service/templates.go:33
msgid "Space deleted"
msgstr "삭제된 스페이스"
#: pkg/service/templates.go:28
msgid "Space disabled"
msgstr "스페이스 비활성화"
#: pkg/service/templates.go:18
msgid "Space shared"
msgstr "스페이스 공유"
#: pkg/service/templates.go:14
msgid ""
"The file {resource} was deleted because it violates the restrictions of this"
" cloud. This could be due to an unsupported file type, potentially harmful "
"or prohibited content, or file size limits."
msgstr ""
#: pkg/service/templates.go:8
msgid "Virus found"
msgstr "바이러스 발견"
#: pkg/service/templates.go:9
msgid "Virus found in {resource}. Upload not possible. Virus: {virus}"
msgstr "{resource}에서 바이러스를 발견했습니다. 업로드할 수 없습니다. 바이러스: {virus}"
#: pkg/service/templates.go:19
msgid "{user} added you to Space {space}"
msgstr "{user}가 스페이스 {space}를 추가했습니다"
#: pkg/service/templates.go:34
msgid "{user} deleted Space {space}"
msgstr "{user} 가 스페이스 {space} 삭제했습니다"
#: pkg/service/templates.go:29
msgid "{user} disabled Space {space}"
msgstr "{user}가 스페이스 {space} 비활성화했습니다"
#: pkg/service/templates.go:24
msgid "{user} removed you from Space {space}"
msgstr "{user}가 스페이스 {space}를 제거했습니다"
#: pkg/service/templates.go:44
msgid "{user} shared {resource} with you"
msgstr "{user}가 {resource}을 공유했습니다"
#: pkg/service/templates.go:49
msgid "{user} unshared {resource} with you"
msgstr "{user}가 {resource}을 공유해제 했습니다"
@@ -0,0 +1,124 @@
# 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 <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Stephan Paternotte <stephan@paternottes.net>, 2026
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-01 00:02+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Stephan Paternotte <stephan@paternottes.net>, 2026\n"
"Language-Team: Dutch (https://app.transifex.com/qsfera-eu/teams/204053/nl/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: nl\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: pkg/service/templates.go:39
msgid "Access to Space {space} lost"
msgstr "Toegang tot ruimte {space} kwijtgeraakt"
#: pkg/service/templates.go:54
msgid "Access to {resource} expired"
msgstr "Toegang tot {resource} verlopen"
#: pkg/service/templates.go:59
msgid ""
"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."
msgstr ""
"Let op! De instantie wordt op {date} afgesloten en afgeschakeld. Download "
"voor deze datum al je gegevens, aangezien erna geen toegang meer mogelijk "
"is."
#: pkg/service/templates.go:58
msgid "Instance will be shut down and deprovisioned"
msgstr "De instantie zal worden afgesloten en afgeschakeld"
#: pkg/service/templates.go:38
msgid "Membership expired"
msgstr "Lidmaatschap verlopen"
#: pkg/service/templates.go:13
msgid "Policies enforced"
msgstr "Beleid afgedwongen"
#: pkg/service/templates.go:23
msgid "Removed from Space"
msgstr "Verwijderd uit ruimte"
#: pkg/service/templates.go:43
msgid "Resource shared"
msgstr "Hulpbron gedeeld"
#: pkg/service/templates.go:48
msgid "Resource unshared"
msgstr "Delen van hulpbron beëindigd"
#: pkg/service/templates.go:53
msgid "Share expired"
msgstr "Share verlopen"
#: pkg/service/templates.go:33
msgid "Space deleted"
msgstr "Ruimte verwijderd"
#: pkg/service/templates.go:28
msgid "Space disabled"
msgstr "Ruimte uitgeschakeld"
#: pkg/service/templates.go:18
msgid "Space shared"
msgstr "Ruimte gedeeld"
#: pkg/service/templates.go:14
msgid ""
"The file {resource} was deleted because it violates the restrictions of this"
" cloud. This could be due to an unsupported file type, potentially harmful "
"or prohibited content, or file size limits."
msgstr ""
"Het bestand {resource} is verwijderd omdat het de beperkingen van deze cloud"
" schendt. Dit kan te wijten zijn aan een niet-ondersteund bestandstype, "
"potentieel schadelijke of verboden inhoud of limieten voor de "
"bestandsgrootte."
#: pkg/service/templates.go:8
msgid "Virus found"
msgstr "Virus aangetroffen"
#: pkg/service/templates.go:9
msgid "Virus found in {resource}. Upload not possible. Virus: {virus}"
msgstr ""
"Virus aangetroffen in {resource}. Uploaden niet mogelijk. Virus: {virus}"
#: pkg/service/templates.go:19
msgid "{user} added you to Space {space}"
msgstr "{user} heeft jou toegevoegd aan ruimte {space}"
#: pkg/service/templates.go:34
msgid "{user} deleted Space {space}"
msgstr "{user} heeft ruimte {space} verwijderd"
#: pkg/service/templates.go:29
msgid "{user} disabled Space {space}"
msgstr "{user} heeft ruimte {space} uitgeschakeld"
#: pkg/service/templates.go:24
msgid "{user} removed you from Space {space}"
msgstr "{user} heeft jou verwijderd uit ruimte {space}"
#: pkg/service/templates.go:44
msgid "{user} shared {resource} with you"
msgstr "{user} heeft {resource} met jou gedeeld"
#: pkg/service/templates.go:49
msgid "{user} unshared {resource} with you"
msgstr "{user} heeft het delen van {resource} met jou beëindigd"
@@ -0,0 +1,122 @@
# 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 <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# skrzat <skrzacikus@gmail.com>, 2025
# Maksymilian Styżej, 2025
# Radoslaw Posim, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-07 00:02+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Radoslaw Posim, 2025\n"
"Language-Team: Polish (https://app.transifex.com/qsfera-eu/teams/204053/pl/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: pl\n"
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
#: pkg/service/templates.go:39
msgid "Access to Space {space} lost"
msgstr "Dostęp do Przestrzeni {space} utracony"
#: pkg/service/templates.go:54
msgid "Access to {resource} expired"
msgstr "Dostęp do {resource} wygasł"
#: pkg/service/templates.go:59
msgid ""
"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."
msgstr ""
"Uwaga! Ta instancja zostanie wyłączona i usunięta w dniu {date}. Pobierz "
"wszystkie swoje dane przed tym terminem, ponieważ po upływie tej daty dostęp"
" nie będzie możliwy."
#: pkg/service/templates.go:58
msgid "Instance will be shut down and deprovisioned"
msgstr "Instancja zostanie wyłączona i usunięta."
#: pkg/service/templates.go:38
msgid "Membership expired"
msgstr "Członkostwo wygasło"
#: pkg/service/templates.go:13
msgid "Policies enforced"
msgstr "Zastosowano obowiązujące polityki"
#: pkg/service/templates.go:23
msgid "Removed from Space"
msgstr "Usunięto z Przestrzeni"
#: pkg/service/templates.go:43
msgid "Resource shared"
msgstr "Zasób udostępniony"
#: pkg/service/templates.go:48
msgid "Resource unshared"
msgstr "Udostępnianie zasobu zostało zakończone"
#: pkg/service/templates.go:53
msgid "Share expired"
msgstr "Udostępnienie wygasło"
#: pkg/service/templates.go:33
msgid "Space deleted"
msgstr "Przestrzeń usunięta"
#: pkg/service/templates.go:28
msgid "Space disabled"
msgstr "Przestrzeń wyłączona"
#: pkg/service/templates.go:18
msgid "Space shared"
msgstr "Przestrzeń udostępniona"
#: pkg/service/templates.go:14
msgid ""
"The file {resource} was deleted because it violates the restrictions of this"
" cloud. This could be due to an unsupported file type, potentially harmful "
"or prohibited content, or file size limits."
msgstr ""
#: pkg/service/templates.go:8
msgid "Virus found"
msgstr "Znaleziono wirusa"
#: pkg/service/templates.go:9
msgid "Virus found in {resource}. Upload not possible. Virus: {virus}"
msgstr ""
"Znaleziono wirusa w {resource}. Przesłanie jest niemożliwe. Wirus: {virus}"
#: pkg/service/templates.go:19
msgid "{user} added you to Space {space}"
msgstr "{user} dodał Ciebie do Przestrzeni {space}"
#: pkg/service/templates.go:34
msgid "{user} deleted Space {space}"
msgstr "{user} usunął Przestrzeń {space}"
#: pkg/service/templates.go:29
msgid "{user} disabled Space {space}"
msgstr "{user} wyłączył Przestrzeń {space}"
#: pkg/service/templates.go:24
msgid "{user} removed you from Space {space}"
msgstr "{user} usunął Ciebie z Przestrzeni {space}"
#: pkg/service/templates.go:44
msgid "{user} shared {resource} with you"
msgstr "{user} udostępnił Ci zasób {resource}"
#: pkg/service/templates.go:49
msgid "{user} unshared {resource} with you"
msgstr "{user} wyłączył udostępnianie zasobu {resource} dla Ciebie"
@@ -0,0 +1,120 @@
# 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 <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Mário Machado, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-07 00:02+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Mário Machado, 2025\n"
"Language-Team: Portuguese (https://app.transifex.com/qsfera-eu/teams/204053/pt/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: pt\n"
"Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
#: pkg/service/templates.go:39
msgid "Access to Space {space} lost"
msgstr "Acesso ao Espaço {space} perdido"
#: pkg/service/templates.go:54
msgid "Access to {resource} expired"
msgstr "Acesso a {resource} expirou"
#: pkg/service/templates.go:59
msgid ""
"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."
msgstr ""
"Atenção! A instância será encerrada e desprovisionada a {date}. Descarregue "
"todos os seus dados antes dessa data, pois o acesso não será possível depois"
" desse prazo."
#: pkg/service/templates.go:58
msgid "Instance will be shut down and deprovisioned"
msgstr "A instância será encerrada e desprovisionada"
#: pkg/service/templates.go:38
msgid "Membership expired"
msgstr "Subscrição expirada"
#: pkg/service/templates.go:13
msgid "Policies enforced"
msgstr "Políticas aplicadas"
#: pkg/service/templates.go:23
msgid "Removed from Space"
msgstr "Removido do Espaço"
#: pkg/service/templates.go:43
msgid "Resource shared"
msgstr "Recurso partilhado"
#: pkg/service/templates.go:48
msgid "Resource unshared"
msgstr "Recurso cuja partilha foi removida"
#: pkg/service/templates.go:53
msgid "Share expired"
msgstr "Partilha expirada"
#: pkg/service/templates.go:33
msgid "Space deleted"
msgstr "Espaço eliminado"
#: pkg/service/templates.go:28
msgid "Space disabled"
msgstr "Espaço desativado"
#: pkg/service/templates.go:18
msgid "Space shared"
msgstr "Espaço partilhado"
#: pkg/service/templates.go:14
msgid ""
"The file {resource} was deleted because it violates the restrictions of this"
" cloud. This could be due to an unsupported file type, potentially harmful "
"or prohibited content, or file size limits."
msgstr ""
#: pkg/service/templates.go:8
msgid "Virus found"
msgstr "Vírus encontrado"
#: pkg/service/templates.go:9
msgid "Virus found in {resource}. Upload not possible. Virus: {virus}"
msgstr ""
"Vírus encontrado em {resource}. Carregamento não é possível. Vírus: {virus}"
#: pkg/service/templates.go:19
msgid "{user} added you to Space {space}"
msgstr "{user} adicionou-o(a) ao Espaço {space}"
#: pkg/service/templates.go:34
msgid "{user} deleted Space {space}"
msgstr "{user} eliminou o Espaço {space}"
#: pkg/service/templates.go:29
msgid "{user} disabled Space {space}"
msgstr "{user} desativou o Espaço {space}"
#: pkg/service/templates.go:24
msgid "{user} removed you from Space {space}"
msgstr "{user} removeu-o(a) do Espaço {space}"
#: pkg/service/templates.go:44
msgid "{user} shared {resource} with you"
msgstr "{user} partilhou {resource} consigo"
#: pkg/service/templates.go:49
msgid "{user} unshared {resource} with you"
msgstr "{user} removeu a partilha de {resource} consigo"
@@ -0,0 +1,123 @@
# 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 <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Lulufox, 2025
# Danila Kononov, 2026
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-28 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Danila Kononov, 2026\n"
"Language-Team: Russian (https://app.transifex.com/qsfera-eu/teams/204053/ru/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: ru\n"
"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n%100>=11 && n%100<=14)? 2 : 3);\n"
#: pkg/service/templates.go:39
msgid "Access to Space {space} lost"
msgstr "Доступ к пространству {space} потерян"
#: pkg/service/templates.go:54
msgid "Access to {resource} expired"
msgstr "Доступ к {resource} больше не действителен"
#: pkg/service/templates.go:59
msgid ""
"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."
msgstr ""
"Внимание! Система будет отключена {date}. Выгрузите все ваши данные до этой "
"даты - после этого сделать это будет невозможно."
#: pkg/service/templates.go:58
msgid "Instance will be shut down and deprovisioned"
msgstr "Система будет отключена"
#: pkg/service/templates.go:38
msgid "Membership expired"
msgstr "Членство истекло"
#: pkg/service/templates.go:13
msgid "Policies enforced"
msgstr "Правила применены"
#: pkg/service/templates.go:23
msgid "Removed from Space"
msgstr "Удалить из пространства"
#: pkg/service/templates.go:43
msgid "Resource shared"
msgstr "Ресурс доступен для совместного использования"
#: pkg/service/templates.go:48
msgid "Resource unshared"
msgstr "Ресурс не доступен для совместного использования"
#: pkg/service/templates.go:53
msgid "Share expired"
msgstr "Срок совместного использования истек"
#: pkg/service/templates.go:33
msgid "Space deleted"
msgstr "Пространство удалено"
#: pkg/service/templates.go:28
msgid "Space disabled"
msgstr "Пространство отключено"
#: pkg/service/templates.go:18
msgid "Space shared"
msgstr "Пространство доступно для совместного использования"
#: pkg/service/templates.go:14
msgid ""
"The file {resource} was deleted because it violates the restrictions of this"
" cloud. This could be due to an unsupported file type, potentially harmful "
"or prohibited content, or file size limits."
msgstr ""
"Файл {resource} был удален за нарушение правил этого облака. Вероятные "
"причины: неподдерживаемый формат файла, потенциально опасное содержимое или "
"превышен лимит объёма файла."
#: pkg/service/templates.go:8
msgid "Virus found"
msgstr "Найден вирус"
#: pkg/service/templates.go:9
msgid "Virus found in {resource}. Upload not possible. Virus: {virus}"
msgstr ""
"В ресурсе {resource} обнаружен вирус. Загрузка невозможна. Вирус: {virus}"
#: pkg/service/templates.go:19
msgid "{user} added you to Space {space}"
msgstr "{user} добавил Вас в пространство {space}"
#: pkg/service/templates.go:34
msgid "{user} deleted Space {space}"
msgstr "{user} удалил(-ла) пространство {space}"
#: pkg/service/templates.go:29
msgid "{user} disabled Space {space}"
msgstr "{user} отключил пространство {space}"
#: pkg/service/templates.go:24
msgid "{user} removed you from Space {space}"
msgstr "{user} удалил вас из пространства {space}"
#: pkg/service/templates.go:44
msgid "{user} shared {resource} with you"
msgstr "{user} поделился(-лась) с вами {resource}"
#: pkg/service/templates.go:49
msgid "{user} unshared {resource} with you"
msgstr "{user} забрал у вас доступ к {resource}"
@@ -0,0 +1,119 @@
# 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 <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Daniel Nylander <po@danielnylander.se>, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-07 00:02+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Daniel Nylander <po@danielnylander.se>, 2025\n"
"Language-Team: Swedish (https://app.transifex.com/qsfera-eu/teams/204053/sv/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: sv\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: pkg/service/templates.go:39
msgid "Access to Space {space} lost"
msgstr "Tillgång till arbetsytan {space} förlorades"
#: pkg/service/templates.go:54
msgid "Access to {resource} expired"
msgstr "Åtkomst till {resource} har gått ut"
#: pkg/service/templates.go:59
msgid ""
"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."
msgstr ""
"Observera! Instansen kommer att stängas ned och avprovisioneras den {date}. "
"Ladda ner alla dina data före det datumet, eftersom det inte går att komma "
"åt dem efter det datumet."
#: pkg/service/templates.go:58
msgid "Instance will be shut down and deprovisioned"
msgstr "Instansen kommer att stängas av och avprovisioneras"
#: pkg/service/templates.go:38
msgid "Membership expired"
msgstr "Medlemskapet har gått ut"
#: pkg/service/templates.go:13
msgid "Policies enforced"
msgstr "Genomdrivna riktlinjer"
#: pkg/service/templates.go:23
msgid "Removed from Space"
msgstr "Togs bort från arbetsytan"
#: pkg/service/templates.go:43
msgid "Resource shared"
msgstr "Resurs delad"
#: pkg/service/templates.go:48
msgid "Resource unshared"
msgstr "Resursen delas inte"
#: pkg/service/templates.go:53
msgid "Share expired"
msgstr "Delning har löpt ut"
#: pkg/service/templates.go:33
msgid "Space deleted"
msgstr "Arbetsytan togs bort"
#: pkg/service/templates.go:28
msgid "Space disabled"
msgstr "Arbetsytan inaktiverad"
#: pkg/service/templates.go:18
msgid "Space shared"
msgstr "Arbetsytan delad"
#: pkg/service/templates.go:14
msgid ""
"The file {resource} was deleted because it violates the restrictions of this"
" cloud. This could be due to an unsupported file type, potentially harmful "
"or prohibited content, or file size limits."
msgstr ""
#: pkg/service/templates.go:8
msgid "Virus found"
msgstr "Virus hittades"
#: pkg/service/templates.go:9
msgid "Virus found in {resource}. Upload not possible. Virus: {virus}"
msgstr "Virus hittades i {resource}. Uppladdning omöjlig. Virus: {virus}"
#: pkg/service/templates.go:19
msgid "{user} added you to Space {space}"
msgstr "{user} har lagt till dig i arbetsytan {space}"
#: pkg/service/templates.go:34
msgid "{user} deleted Space {space}"
msgstr "{user} tog bort arbetsytan {space}"
#: pkg/service/templates.go:29
msgid "{user} disabled Space {space}"
msgstr "{user} inaktiverade arbetsytan {space}"
#: pkg/service/templates.go:24
msgid "{user} removed you from Space {space}"
msgstr "{user} har tagit bort dig från arbetsytan {space}"
#: pkg/service/templates.go:44
msgid "{user} shared {resource} with you"
msgstr "{user} delade {resource} med dig"
#: pkg/service/templates.go:49
msgid "{user} unshared {resource} with you"
msgstr "{user} tog bort delning av {resource} med dig"
@@ -0,0 +1,116 @@
# 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 <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# YQS Yang, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-07 00:02+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: YQS Yang, 2025\n"
"Language-Team: Chinese (https://app.transifex.com/qsfera-eu/teams/204053/zh/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: zh\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#: pkg/service/templates.go:39
msgid "Access to Space {space} lost"
msgstr "无法访问空间{space}"
#: pkg/service/templates.go:54
msgid "Access to {resource} expired"
msgstr "对{resource}的访问权限已过期"
#: pkg/service/templates.go:59
msgid ""
"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."
msgstr "请注意!实例将于{date}关闭并解除配置。请在此日期前下载所有数据,之后将无法访问。"
#: pkg/service/templates.go:58
msgid "Instance will be shut down and deprovisioned"
msgstr "实例将被关闭并解除配置"
#: pkg/service/templates.go:38
msgid "Membership expired"
msgstr "成员资格已过期"
#: pkg/service/templates.go:13
msgid "Policies enforced"
msgstr "策略已执行"
#: pkg/service/templates.go:23
msgid "Removed from Space"
msgstr "已从空间中移除"
#: pkg/service/templates.go:43
msgid "Resource shared"
msgstr "资源已共享"
#: pkg/service/templates.go:48
msgid "Resource unshared"
msgstr "资源已取消共享"
#: pkg/service/templates.go:53
msgid "Share expired"
msgstr "共享已过期"
#: pkg/service/templates.go:33
msgid "Space deleted"
msgstr "空间已删除"
#: pkg/service/templates.go:28
msgid "Space disabled"
msgstr "空间已禁用"
#: pkg/service/templates.go:18
msgid "Space shared"
msgstr "空间已共享"
#: pkg/service/templates.go:14
msgid ""
"The file {resource} was deleted because it violates the restrictions of this"
" cloud. This could be due to an unsupported file type, potentially harmful "
"or prohibited content, or file size limits."
msgstr ""
#: pkg/service/templates.go:8
msgid "Virus found"
msgstr "发现病毒"
#: pkg/service/templates.go:9
msgid "Virus found in {resource}. Upload not possible. Virus: {virus}"
msgstr "在{resource}中发现病毒。无法上传。病毒名称:{virus}"
#: pkg/service/templates.go:19
msgid "{user} added you to Space {space}"
msgstr "{user}已将您添加到空间{space}"
#: pkg/service/templates.go:34
msgid "{user} deleted Space {space}"
msgstr "{user}已删除空间{space}"
#: pkg/service/templates.go:29
msgid "{user} disabled Space {space}"
msgstr "{user}已禁用空间{space}"
#: pkg/service/templates.go:24
msgid "{user} removed you from Space {space}"
msgstr "{user}已将您从空间{space}中移除"
#: pkg/service/templates.go:44
msgid "{user} shared {resource} with you"
msgstr "{user}已与您共享{resource}"
#: pkg/service/templates.go:49
msgid "{user} unshared {resource} with you"
msgstr "{user}已取消与您共享{resource}"
@@ -0,0 +1,109 @@
package service
import (
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
"github.com/go-chi/chi/v5"
"github.com/qsfera/server/pkg/log"
ehsvc "github.com/qsfera/server/protogen/gen/qsfera/services/eventhistory/v0"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/userlog/pkg/config"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"go-micro.dev/v4/store"
"go.opentelemetry.io/otel/trace"
)
// Option for the userlog service
type Option func(*Options)
// Options for the userlog service
type Options struct {
Logger log.Logger
Stream events.Stream
Mux *chi.Mux
Store store.Store
Config *config.Config
HistoryClient ehsvc.EventHistoryService
GatewaySelector pool.Selectable[gateway.GatewayAPIClient]
ValueClient settingssvc.ValueService
RoleClient settingssvc.RoleService
RegisteredEvents []events.Unmarshaller
TraceProvider trace.TracerProvider
}
// Logger configures a logger for the userlog service
func Logger(log log.Logger) Option {
return func(o *Options) {
o.Logger = log
}
}
// Stream configures an event stream for the userlog service
func Stream(s events.Stream) Option {
return func(o *Options) {
o.Stream = s
}
}
// Mux defines the muxer for the userlog service
func Mux(m *chi.Mux) Option {
return func(o *Options) {
o.Mux = m
}
}
// Store defines the store for the userlog service
func Store(s store.Store) Option {
return func(o *Options) {
o.Store = s
}
}
// Config adds the config for the userlog service
func Config(c *config.Config) Option {
return func(o *Options) {
o.Config = c
}
}
// HistoryClient adds a grpc client for the eventhistory service
func HistoryClient(hc ehsvc.EventHistoryService) Option {
return func(o *Options) {
o.HistoryClient = hc
}
}
// GatewaySelector adds a grpc client selector for the gateway service
func GatewaySelector(gatewaySelector pool.Selectable[gateway.GatewayAPIClient]) Option {
return func(o *Options) {
o.GatewaySelector = gatewaySelector
}
}
// RegisteredEvents registers the events the service should listen to
func RegisteredEvents(e []events.Unmarshaller) Option {
return func(o *Options) {
o.RegisteredEvents = e
}
}
// ValueClient adds a grpc client for the value service
func ValueClient(vs settingssvc.ValueService) Option {
return func(o *Options) {
o.ValueClient = vs
}
}
// RoleClient adds a grpc client for the role service
func RoleClient(rs settingssvc.RoleService) Option {
return func(o *Options) {
o.RoleClient = rs
}
}
// TraceProvider adds a tracer provider for the userlog service
func TraceProvider(tp trace.TracerProvider) Option {
return func(o *Options) {
o.TraceProvider = tp
}
}
@@ -0,0 +1,462 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"time"
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"
"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/utils"
"go-micro.dev/v4/store"
"go.opentelemetry.io/otel/trace"
"github.com/qsfera/server/pkg/l10n"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/roles"
ehmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/eventhistory/v0"
ehsvc "github.com/qsfera/server/protogen/gen/qsfera/services/eventhistory/v0"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/userlog/pkg/config"
)
// UserlogService is the service responsible for user activities
type UserlogService struct {
log log.Logger
m *chi.Mux
store store.Store
cfg *config.Config
historyClient ehsvc.EventHistoryService
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
valueClient settingssvc.ValueService
registeredEvents map[string]events.Unmarshaller
tp trace.TracerProvider
tracer trace.Tracer
publisher events.Publisher
filter *userlogFilter
}
// NewUserlogService returns an EventHistory service
func NewUserlogService(opts ...Option) (*UserlogService, error) {
o := &Options{}
for _, opt := range opts {
opt(o)
}
if o.Stream == nil || o.Store == nil {
return nil, fmt.Errorf("need non nil stream (%v) and store (%v) to work properly", o.Stream, o.Store)
}
ch, err := events.Consume(o.Stream, "userlog", o.RegisteredEvents...)
if err != nil {
return nil, err
}
ul := &UserlogService{
log: o.Logger,
m: o.Mux,
store: o.Store,
cfg: o.Config,
historyClient: o.HistoryClient,
gatewaySelector: o.GatewaySelector,
valueClient: o.ValueClient,
registeredEvents: make(map[string]events.Unmarshaller),
tp: o.TraceProvider,
tracer: o.TraceProvider.Tracer("github.com/qsfera/server/services/userlog/pkg/service"),
publisher: o.Stream,
filter: newUserlogFilter(o.Logger, o.ValueClient),
}
for _, e := range o.RegisteredEvents {
typ := reflect.TypeOf(e)
ul.registeredEvents[typ.String()] = e
}
m := roles.NewManager(
// TODO: caching?
roles.Logger(o.Logger),
roles.RoleService(o.RoleClient),
)
ul.m.Route("/ocs/v2.php/apps/notifications/api/v1/notifications", func(r chi.Router) {
r.Get("/", ul.HandleGetEvents)
r.Delete("/", ul.HandleDeleteEvents)
r.Post("/global", RequireAdminOrSecret(&m, o.Config.GlobalNotificationsSecret)(ul.HandlePostGlobalEvent))
r.Delete("/global", RequireAdminOrSecret(&m, o.Config.GlobalNotificationsSecret)(ul.HandleDeleteGlobalEvent))
})
go ul.MemorizeEvents(ch)
return ul, nil
}
// MemorizeEvents stores eventIDs a user wants to receive
func (ul *UserlogService) MemorizeEvents(ch <-chan events.Event) {
for i := 0; i < ul.cfg.MaxConcurrency; i++ {
go func(ch <-chan events.Event) {
for event := range ch {
ul.processEvent(event)
}
}(ch)
}
}
func (ul *UserlogService) processEvent(event events.Event) {
// for each event we need to:
// I) find users eligible to receive the event
var (
users []string
executant *user.UserId
err error
)
gwc, err := ul.gatewaySelector.Next()
if err != nil {
ul.log.Error().Err(err).Msg("cannot get gateway client")
return
}
ctx, err := utils.GetServiceUserContext(ul.cfg.ServiceAccount.ServiceAccountID, gwc, ul.cfg.ServiceAccount.ServiceAccountSecret)
if err != nil {
ul.log.Error().Err(err).Msg("cannot get service account")
return
}
gwc, err = ul.gatewaySelector.Next()
if err != nil {
ul.log.Error().Err(err).Msg("cannot get gateway client")
return
}
switch e := event.Event.(type) {
default:
err = errors.New("unhandled event")
// file related
case events.PostprocessingStepFinished:
switch e.FinishedStep {
case events.PPStepAntivirus:
result := e.Result.(events.VirusscanResult)
if !result.Infected {
return
}
// TODO: should space mangers also be informed?
users = append(users, e.ExecutingUser.GetId().GetOpaqueId())
case events.PPStepPolicies:
if e.Outcome == events.PPOutcomeContinue {
return
}
users = append(users, e.ExecutingUser.GetId().GetOpaqueId())
default:
return
}
// space related // TODO: how to find spaceadmins?
case events.SpaceDisabled:
executant = e.Executant
users, err = utils.GetSpaceMembers(ctx, e.ID.GetOpaqueId(), gwc, utils.ViewerRole)
case events.SpaceDeleted:
executant = e.Executant
for u := range e.FinalMembers {
users = append(users, u)
}
case events.SpaceShared:
executant = e.Executant
users, err = utils.ResolveID(ctx, e.GranteeUserID, e.GranteeGroupID, gwc)
case events.SpaceUnshared:
executant = e.Executant
users, err = utils.ResolveID(ctx, e.GranteeUserID, e.GranteeGroupID, gwc)
case events.SpaceMembershipExpired:
users, err = utils.ResolveID(ctx, e.GranteeUserID, e.GranteeGroupID, gwc)
// share related
case events.ShareCreated:
executant = e.Executant
users, err = utils.ResolveID(ctx, e.GranteeUserID, e.GranteeGroupID, gwc)
case events.ShareRemoved:
executant = e.Executant
users, err = utils.ResolveID(ctx, e.GranteeUserID, e.GranteeGroupID, gwc)
case events.ShareExpired:
users, err = utils.ResolveID(ctx, e.GranteeUserID, e.GranteeGroupID, gwc)
}
if err != nil {
// TODO: Find out why this errors on ci pipeline
ul.log.Debug().Err(err).Interface("event", event).Msg("error gathering members for event")
return
}
// II) filter users who want to receive the event
users = ul.filter.execute(ctx, event, executant, users)
// III) store the eventID for each user
for _, id := range users {
if err := ul.addEventToUser(id, event); err != nil {
ul.log.Error().Err(err).Str("userID", id).Str("eventid", event.ID).Msg("failed to store event for user")
return
}
}
// IV) send sses
if !ul.cfg.DisableSSE {
if err := ul.sendSSE(ctx, users, event, ul.gatewaySelector); err != nil {
ul.log.Error().Err(err).Interface("userid", users).Str("eventid", event.ID).Msg("cannot create sse event")
}
}
}
// GetEvents allows retrieving events from the eventhistory by userid
func (ul *UserlogService) GetEvents(ctx context.Context, userid string) ([]*ehmsg.Event, error) {
ctx, span := ul.tracer.Start(ctx, "GetEvents")
defer span.End()
rec, err := ul.store.Read(userid)
if err != nil && err != store.ErrNotFound {
ul.log.Error().Err(err).Str("userid", userid).Msg("failed to read record from store")
return nil, err
}
if len(rec) == 0 {
// no events available
return []*ehmsg.Event{}, nil
}
var eventIDs []string
if err := json.Unmarshal(rec[0].Value, &eventIDs); err != nil {
ul.log.Error().Err(err).Str("userid", userid).Msg("failed to umarshal record from store")
return nil, err
}
resp, err := ul.historyClient.GetEvents(ctx, &ehsvc.GetEventsRequest{Ids: eventIDs})
if err != nil {
return nil, err
}
// remove expired events from list asynchronously
go func() {
if err := ul.removeExpiredEvents(userid, eventIDs, resp.GetEvents()); err != nil {
ul.log.Error().Err(err).Str("userid", userid).Msg("could not remove expired events from user")
}
}()
return resp.GetEvents(), nil
}
// DeleteEvents will delete the specified events
func (ul *UserlogService) DeleteEvents(userid string, evids []string) error {
toDelete := make(map[string]struct{})
for _, e := range evids {
toDelete[e] = struct{}{}
}
return ul.alterUserEventList(userid, func(ids []string) []string {
var newids []string
for _, id := range ids {
if _, del := toDelete[id]; del {
continue
}
newids = append(newids, id)
}
return newids
})
}
// StoreGlobalEvent will store a global event that will be returned with each `GetEvents` request
func (ul *UserlogService) StoreGlobalEvent(ctx context.Context, typ string, data map[string]string) error {
ctx, span := ul.tracer.Start(ctx, "StoreGlobalEvent")
defer span.End()
switch typ {
default:
return fmt.Errorf("unknown event type: %s", typ)
case "deprovision":
dps, ok := data["deprovision_date"]
if !ok {
return errors.New("need 'deprovision_date' in request body")
}
format := data["deprovision_date_format"]
if format == "" {
format = time.RFC3339
}
date, err := time.Parse(format, dps)
if err != nil {
fmt.Println("", format, "\n", dps)
return fmt.Errorf("cannot parse time to format. time: '%s' format: '%s'", dps, format)
}
ev := DeprovisionData{
DeprovisionDate: date,
DeprovisionFormat: format,
}
b, err := json.Marshal(ev)
if err != nil {
return err
}
return ul.alterGlobalEvents(ctx, func(evs map[string]json.RawMessage) error {
evs[typ] = b
return nil
})
}
}
// GetGlobalEvents will return all global events
func (ul *UserlogService) GetGlobalEvents(ctx context.Context) (map[string]json.RawMessage, error) {
_, span := ul.tracer.Start(ctx, "GetGlobalEvents")
defer span.End()
out := make(map[string]json.RawMessage)
recs, err := ul.store.Read(_globalEventsKey)
if err != nil && err != store.ErrNotFound {
return out, err
}
if len(recs) > 0 {
if err := json.Unmarshal(recs[0].Value, &out); err != nil {
return out, err
}
}
return out, nil
}
// DeleteGlobalEvents will delete the specified event
func (ul *UserlogService) DeleteGlobalEvents(ctx context.Context, evnames []string) error {
_, span := ul.tracer.Start(ctx, "DeleteGlobalEvents")
defer span.End()
return ul.alterGlobalEvents(ctx, func(evs map[string]json.RawMessage) error {
for _, name := range evnames {
delete(evs, name)
}
return nil
})
}
func (ul *UserlogService) addEventToUser(userid string, event events.Event) error {
return ul.alterUserEventList(userid, func(ids []string) []string {
return append(ids, event.ID)
})
}
func (ul *UserlogService) sendSSE(ctx context.Context, userIDs []string, event events.Event, gatewaySelector pool.Selectable[gateway.GatewayAPIClient]) error {
m := make(map[string]events.SendSSE)
for _, userid := range userIDs {
loc := l10n.MustGetUserLocale(ctx, userid, "", ul.valueClient)
if ev, ok := m[loc]; ok {
ev.UserIDs = append(m[loc].UserIDs, userid)
m[loc] = ev
continue
}
ev, err := NewConverter(ctx, loc, gatewaySelector, ul.cfg.Service.Name, ul.cfg.TranslationPath, ul.cfg.DefaultLanguage).ConvertEvent(event.ID, event.Event)
if err != nil {
if utils.IsErrNotFound(err) || utils.IsErrPermissionDenied(err) {
// the resource was not found, we assume it is deleted
continue
}
return err
}
b, err := json.Marshal(ev)
if err != nil {
return err
}
m[loc] = events.SendSSE{
UserIDs: []string{userid},
Type: "userlog-notification",
Message: b,
}
}
for _, ev := range m {
if err := events.Publish(ctx, ul.publisher, ev); err != nil {
return err
}
}
return nil
}
func (ul *UserlogService) removeExpiredEvents(userid string, all []string, received []*ehmsg.Event) error {
exists := make(map[string]struct{}, len(received))
for _, e := range received {
exists[e.Id] = struct{}{}
}
var toDelete []string
for _, eid := range all {
if _, ok := exists[eid]; !ok {
toDelete = append(toDelete, eid)
}
}
if len(toDelete) == 0 {
return nil
}
return ul.DeleteEvents(userid, toDelete)
}
func (ul *UserlogService) alterUserEventList(userid string, alter func([]string) []string) error {
recs, err := ul.store.Read(userid)
if err != nil && err != store.ErrNotFound {
return err
}
var ids []string
if len(recs) > 0 {
if err := json.Unmarshal(recs[0].Value, &ids); err != nil {
return err
}
}
ids = alter(ids)
// store reacts unforseeable when trying to store nil values
if len(ids) == 0 {
return ul.store.Delete(userid)
}
b, err := json.Marshal(ids)
if err != nil {
return err
}
return ul.store.Write(&store.Record{
Key: userid,
Value: b,
})
}
func (ul *UserlogService) alterGlobalEvents(ctx context.Context, alter func(map[string]json.RawMessage) error) error {
_, span := ul.tracer.Start(ctx, "alterGlobalEvents")
defer span.End()
evs, err := ul.GetGlobalEvents(ctx)
if err != nil && err != store.ErrNotFound {
return err
}
if err := alter(evs); err != nil {
return err
}
val, err := json.Marshal(evs)
if err != nil {
return err
}
return ul.store.Write(&store.Record{
Key: "global-events",
Value: val,
})
}
@@ -0,0 +1,25 @@
package service_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
mRegistry "go-micro.dev/v4/registry"
"github.com/qsfera/server/pkg/registry"
)
func init() {
r := registry.GetRegistry(registry.Inmemory())
service := registry.BuildGRPCService("qsfera.api.gateway", "", "", "")
service.Nodes = []*mRegistry.Node{{
Address: "any",
}}
_ = r.Register(service)
}
func TestSearch(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Userlog service Suite")
}
@@ -0,0 +1,190 @@
package service_test
import (
"context"
"encoding/json"
"reflect"
"time"
settingsmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/settings/v0"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"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/store"
"github.com/opencloud-eu/reva/v2/pkg/utils"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/mock"
microevents "go-micro.dev/v4/events"
microstore "go-micro.dev/v4/store"
"go.opentelemetry.io/otel/trace"
"google.golang.org/grpc"
"github.com/qsfera/server/pkg/log"
ehmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/eventhistory/v0"
ehsvc "github.com/qsfera/server/protogen/gen/qsfera/services/eventhistory/v0"
"github.com/qsfera/server/protogen/gen/qsfera/services/eventhistory/v0/mocks"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
settingsmocks "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0/mocks"
"github.com/qsfera/server/services/userlog/pkg/config"
"github.com/qsfera/server/services/userlog/pkg/service"
)
var _ = Describe("UserlogService", func() {
var (
cfg = &config.Config{
MaxConcurrency: 5,
}
ul *service.UserlogService
bus testBus
sto microstore.Store
gatewayClient *cs3mocks.GatewayAPIClient
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
ehc mocks.EventHistoryService
vc settingsmocks.ValueService
)
BeforeEach(func() {
var err error
sto = store.Create()
bus = testBus(make(chan events.Event))
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
gatewayClient = &cs3mocks.GatewayAPIClient{}
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return gatewayClient
},
)
o := utils.AppendJSONToOpaque(nil, "grants", map[string]*provider.ResourcePermissions{"userid": {Stat: true}})
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{StorageSpaces: []*provider.StorageSpace{
{
Opaque: o,
SpaceType: "project",
},
}, Status: &rpc.Status{Code: rpc.Code_CODE_OK}}, nil)
gatewayClient.On("GetUser", mock.Anything, mock.Anything).Return(&user.GetUserResponse{User: &user.User{Id: &user.UserId{OpaqueId: "userid"}}, Status: &rpc.Status{Code: rpc.Code_CODE_OK}}, nil)
gatewayClient.On("Authenticate", mock.Anything, mock.Anything).Return(&gateway.AuthenticateResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}}, nil)
vc.On("GetValueByUniqueIdentifiers", mock.Anything, mock.Anything).Return(&settingssvc.GetValueResponse{
Value: &settingsmsg.ValueWithIdentifier{
Value: &settingsmsg.Value{
Value: &settingsmsg.Value_CollectionValue{
CollectionValue: &settingsmsg.CollectionValue{
Values: []*settingsmsg.CollectionOption{
{
Key: "in-app",
Option: &settingsmsg.CollectionOption_BoolValue{BoolValue: true},
},
},
},
},
},
},
}, nil)
ul, err = service.NewUserlogService(
service.Config(cfg),
service.Stream(bus),
service.Store(sto),
service.Logger(log.NewLogger()),
service.Mux(chi.NewMux()),
service.GatewaySelector(gatewaySelector),
service.HistoryClient(&ehc),
service.ValueClient(&vc),
service.RegisteredEvents([]events.Unmarshaller{
events.SpaceDisabled{},
}),
service.TraceProvider(trace.NewNoopTracerProvider()),
)
Expect(err).ToNot(HaveOccurred())
})
It("it stores, returns and deletes a couple of events", func() {
ids := make(map[string]struct{})
ids[bus.publish(events.SpaceDisabled{Executant: &user.UserId{OpaqueId: "executinguserid"}})] = struct{}{}
ids[bus.publish(events.SpaceDisabled{Executant: &user.UserId{OpaqueId: "executinguserid"}})] = struct{}{}
ids[bus.publish(events.SpaceDisabled{Executant: &user.UserId{OpaqueId: "executinguserid"}})] = struct{}{}
// ids[bus.Publish(events.SpaceMembershipExpired{SpaceOwner: &user.UserId{OpaqueId: "userid"}})] = struct{}{}
// ids[bus.Publish(events.ShareCreated{Executant: &user.UserId{OpaqueId: "userid"}})] = struct{}{}
time.Sleep(500 * time.Millisecond)
var events []*ehmsg.Event
for id := range ids {
events = append(events, &ehmsg.Event{Id: id})
}
ehc.On("GetEvents", mock.Anything, mock.Anything).Return(&ehsvc.GetEventsResponse{Events: events}, nil)
evs, err := ul.GetEvents(context.Background(), "userid")
Expect(err).ToNot(HaveOccurred())
Expect(len(evs)).To(Equal(len(ids)))
var evids []string
for _, e := range evs {
_, exists := ids[e.Id]
Expect(exists).To(BeTrue())
delete(ids, e.Id)
evids = append(evids, e.Id)
}
Expect(len(ids)).To(Equal(0))
err = ul.DeleteEvents("userid", evids)
Expect(err).ToNot(HaveOccurred())
evs, err = ul.GetEvents(context.Background(), "userid")
Expect(err).ToNot(HaveOccurred())
Expect(len(evs)).To(Equal(0))
})
AfterEach(func() {
close(bus)
})
})
type testBus chan events.Event
func (tb testBus) Consume(_ string, _ ...microevents.ConsumeOption) (<-chan microevents.Event, error) {
ch := make(chan microevents.Event)
go func() {
for ev := range tb {
b, _ := json.Marshal(ev.Event)
ch <- microevents.Event{
Payload: b,
Metadata: map[string]string{
events.MetadatakeyEventID: ev.ID,
events.MetadatakeyEventType: ev.Type,
},
}
}
}()
return ch, nil
}
func (tb testBus) Publish(_ string, _ any, _ ...microevents.PublishOption) error {
return nil
}
func (tb testBus) publish(e any) string {
ev := events.Event{
ID: uuid.New().String(),
Type: reflect.TypeOf(e).String(),
Event: e,
}
tb <- ev
return ev.ID
}
@@ -0,0 +1,76 @@
package service
import "github.com/qsfera/server/pkg/l10n"
// the available templates
var (
VirusFound = NotificationTemplate{
Subject: l10n.Template("Virus found"),
Message: l10n.Template("Virus found in {resource}. Upload not possible. Virus: {virus}"),
}
PoliciesEnforced = NotificationTemplate{
Subject: l10n.Template("Policies enforced"),
Message: l10n.Template("The file {resource} was deleted because it violates the restrictions of this cloud. This could be due to an unsupported file type, potentially harmful or prohibited content, or file size limits."),
}
SpaceShared = NotificationTemplate{
Subject: l10n.Template("Space shared"),
Message: l10n.Template("{user} added you to Space {space}"),
}
SpaceUnshared = NotificationTemplate{
Subject: l10n.Template("Removed from Space"),
Message: l10n.Template("{user} removed you from Space {space}"),
}
SpaceDisabled = NotificationTemplate{
Subject: l10n.Template("Space disabled"),
Message: l10n.Template("{user} disabled Space {space}"),
}
SpaceDeleted = NotificationTemplate{
Subject: l10n.Template("Space deleted"),
Message: l10n.Template("{user} deleted Space {space}"),
}
SpaceMembershipExpired = NotificationTemplate{
Subject: l10n.Template("Membership expired"),
Message: l10n.Template("Access to Space {space} lost"),
}
ShareCreated = NotificationTemplate{
Subject: l10n.Template("Resource shared"),
Message: l10n.Template("{user} shared {resource} with you"),
}
ShareRemoved = NotificationTemplate{
Subject: l10n.Template("Resource unshared"),
Message: l10n.Template("{user} unshared {resource} with you"),
}
ShareExpired = NotificationTemplate{
Subject: l10n.Template("Share expired"),
Message: l10n.Template("Access to {resource} expired"),
}
PlatformDeprovision = NotificationTemplate{
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."),
}
)
// holds the information to turn the raw template into a parseable go template
var _placeholders = map[string]string{
"{user}": "{{ .username }}",
"{space}": "{{ .spacename }}",
"{resource}": "{{ .resourcename }}",
"{virus}": "{{ .virusdescription }}",
"{date}": "{{ .date }}",
}
// NotificationTemplate is the data structure for the notifications
type NotificationTemplate struct {
Subject string
Message string
}