mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-09-10 20:58:32 -04:00
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b306a32dac | ||
|
|
5f67c791cb | ||
|
|
2310a124fa | ||
|
|
b18618ab4a | ||
|
|
65f19b5d71 | ||
|
|
7f687ea288 | ||
|
|
be83ed19e3 | ||
|
|
b5cb1cd83b | ||
|
|
9797ee903f | ||
|
|
973dbbdf1a | ||
|
|
1ce7f07523 | ||
|
|
3b83221a87 | ||
|
|
391705b017 | ||
|
|
8875d062b0 | ||
|
|
d1d20edbd2 | ||
|
|
b50c5e54d6 | ||
|
|
c2a34e8c45 | ||
|
|
d6c6b6fd0d | ||
|
|
7f7a938c3f | ||
|
|
cfefb83d26 | ||
|
|
9357124a91 | ||
|
|
c36af05d14 | ||
|
|
7947c1c98a | ||
|
|
d58c1e3dab | ||
|
|
5a609c98dc | ||
|
|
29913e1d7f | ||
|
|
5028364e2c | ||
|
|
b2b15d44f7 | ||
|
|
6ea363b0b9 | ||
|
|
9594313243 | ||
|
|
62d815abe9 | ||
|
|
5923aa5e5e | ||
|
|
81536bbd0e | ||
|
|
be226ebb4c | ||
|
|
c2b37668e4 | ||
|
|
4b7c6908f0 | ||
|
|
6231a870d5 | ||
|
|
17003fe34b |
No files matched your search
@@ -63,7 +63,7 @@ require (
|
||||
github.com/open-policy-agent/opa v1.19.1
|
||||
github.com/opencloud-eu/icap-client v0.0.0-20250930132611-28a2afe62d89
|
||||
github.com/opencloud-eu/libre-graph-api-go v1.0.8-0.20260902170011-45af3945a067
|
||||
github.com/opencloud-eu/reva/v2 v2.49.0
|
||||
github.com/opencloud-eu/reva/v2 v2.49.1-0.20260903122659-26f34ec05774
|
||||
github.com/opensearch-project/opensearch-go/v4 v4.7.3
|
||||
github.com/orcaman/concurrent-map v1.0.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
@@ -95,7 +95,7 @@ require (
|
||||
go-micro.dev/v4 v4.11.0
|
||||
go.etcd.io/bbolt v1.5.0
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0
|
||||
go.opentelemetry.io/contrib/zpages v0.71.0
|
||||
go.opentelemetry.io/otel v1.46.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0
|
||||
@@ -324,7 +324,6 @@ require (
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/pquerna/cachecontrol v0.2.0 // indirect
|
||||
github.com/prometheus/alertmanager v0.33.1 // indirect
|
||||
github.com/prometheus/common v0.70.1 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
github.com/prometheus/statsd_exporter v0.22.8 // indirect
|
||||
|
||||
@@ -942,8 +942,8 @@ github.com/opencloud-eu/icap-client v0.0.0-20250930132611-28a2afe62d89 h1:W1ms+l
|
||||
github.com/opencloud-eu/icap-client v0.0.0-20250930132611-28a2afe62d89/go.mod h1:vigJkNss1N2QEceCuNw/ullDehncuJNFB6mEnzfq9UI=
|
||||
github.com/opencloud-eu/libre-graph-api-go v1.0.8-0.20260902170011-45af3945a067 h1:UkNMKauyJAzY6RE6mmthz9bQZLYkbvBuApm7ZDCparE=
|
||||
github.com/opencloud-eu/libre-graph-api-go v1.0.8-0.20260902170011-45af3945a067/go.mod h1:lTM8JeGblNpoMySTW7Lui2+c5TTLI95mwxtdUIHHrhU=
|
||||
github.com/opencloud-eu/reva/v2 v2.49.0 h1:AwECMDDth3NUaihZRf9bI9HNpWutvaRijOCvQyQfTT4=
|
||||
github.com/opencloud-eu/reva/v2 v2.49.0/go.mod h1:Frg+UWnVcSy+412UB3l2LcD0KY8ZNu1samimwkNywbg=
|
||||
github.com/opencloud-eu/reva/v2 v2.49.1-0.20260903122659-26f34ec05774 h1:8N42Pwzyafdxq66zYP6kJ1CH/QBRpxovQXq/0Kt6bJY=
|
||||
github.com/opencloud-eu/reva/v2 v2.49.1-0.20260903122659-26f34ec05774/go.mod h1:Frg+UWnVcSy+412UB3l2LcD0KY8ZNu1samimwkNywbg=
|
||||
github.com/opencloud-eu/secure v0.0.0-20260312082735-b6f5cb2244e4 h1:l2oB/RctH+t8r7QBj5p8thfEHCM/jF35aAY3WQ3hADI=
|
||||
github.com/opencloud-eu/secure v0.0.0-20260312082735-b6f5cb2244e4/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
@@ -1001,8 +1001,6 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:Om
|
||||
github.com/pquerna/cachecontrol v0.2.0 h1:vBXSNuE5MYP9IJ5kjsdo8uq+w41jSPgvba2DEnkRx9k=
|
||||
github.com/pquerna/cachecontrol v0.2.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI=
|
||||
github.com/pquerna/otp v1.3.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/prometheus/alertmanager v0.33.1 h1:PJHGvTdb8Q0ZEpJnWff120WxB2kxIScLa707AaIudTo=
|
||||
github.com/prometheus/alertmanager v0.33.1/go.mod h1:V06Uc8EZ5X5wLOJRGhtXx+EE2LgrinFIADbKWMVm1RY=
|
||||
github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
|
||||
@@ -1295,8 +1293,8 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 h1:oECp5f+hN7nkwjU/8BxQ/q23bGPb8FIrD839owX222E=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0/go.mod h1:DqEFwLumhzMBDQv9PcWbyoDxHI/4lAk6CM4nJBH39sc=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU=
|
||||
go.opentelemetry.io/contrib/zpages v0.71.0 h1:OrQSEmdlfknIusEyGj/t30uBR7MnbmGcCRBt5ov8/rA=
|
||||
go.opentelemetry.io/contrib/zpages v0.71.0/go.mod h1:C3IIpqkQbjswcSREqxQLyEapF0SI9bqwJpxpjwtzFrE=
|
||||
go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
|
||||
|
||||
@@ -3,17 +3,18 @@ package command
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/pkg/runner"
|
||||
"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/nats-io/nats.go"
|
||||
"github.com/olekukonko/errors"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/config/configlog"
|
||||
"github.com/opencloud-eu/opencloud/pkg/generators"
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
natspkg "github.com/opencloud-eu/opencloud/pkg/nats"
|
||||
"github.com/opencloud-eu/opencloud/pkg/registry"
|
||||
"github.com/opencloud-eu/opencloud/pkg/runner"
|
||||
ogrpc "github.com/opencloud-eu/opencloud/pkg/service/grpc"
|
||||
"github.com/opencloud-eu/opencloud/pkg/tracing"
|
||||
"github.com/opencloud-eu/opencloud/pkg/version"
|
||||
@@ -24,6 +25,12 @@ import (
|
||||
"github.com/opencloud-eu/opencloud/services/activitylog/pkg/metrics"
|
||||
"github.com/opencloud-eu/opencloud/services/activitylog/pkg/server/debug"
|
||||
"github.com/opencloud-eu/opencloud/services/activitylog/pkg/server/http"
|
||||
"github.com/opencloud-eu/opencloud/services/activitylog/pkg/service/activitylog"
|
||||
svcEvents "github.com/opencloud-eu/opencloud/services/activitylog/pkg/service/events"
|
||||
svcHttp "github.com/opencloud-eu/opencloud/services/activitylog/pkg/service/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"
|
||||
)
|
||||
|
||||
var _registeredEvents = []events.Unmarshaller{
|
||||
@@ -62,19 +69,11 @@ func Server(cfg *config.Config) *cobra.Command {
|
||||
|
||||
gr := runner.NewGroup()
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
defer cancel()
|
||||
|
||||
mtrcs := metrics.New()
|
||||
mtrcs.BuildInfo.WithLabelValues(version.GetString()).Set(1)
|
||||
|
||||
defer cancel()
|
||||
|
||||
connName := generators.GenerateConnectionName(cfg.Service.Name, generators.NTypeBus)
|
||||
evStream, err := stream.NatsFromConfig(connName, false, stream.NatsConfig(cfg.Events))
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("Failed to initialize event stream")
|
||||
return err
|
||||
}
|
||||
|
||||
tm, err := pool.StringToTLSMode(cfg.GRPCClientTLS.Mode)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("Failed to parse tls mode")
|
||||
@@ -99,28 +98,101 @@ func Server(cfg *config.Config) *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
hClient := ehsvc.NewEventHistoryService("eu.opencloud.api.eventhistory", grpcClient)
|
||||
vClient := settingssvc.NewValueService("eu.opencloud.api.settings", grpcClient)
|
||||
kv, err := ConnectNatsKV(cfg.Store)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
activityLog, err := activitylog.New(kv,
|
||||
activitylog.Logger(logger),
|
||||
activitylog.MaxActivities(cfg.MaxActivities),
|
||||
activitylog.WriteBufferDuration(cfg.WriteBufferDuration),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("Failed to initialize activity log")
|
||||
return err
|
||||
}
|
||||
|
||||
{
|
||||
svc, err := http.Server(
|
||||
http.Logger(logger),
|
||||
http.Config(cfg),
|
||||
http.Context(ctx), // NOTE: not passing this "option" leads to a panic in go-micro
|
||||
http.TraceProvider(tracerProvider),
|
||||
http.Stream(evStream),
|
||||
http.GatewaySelector(gatewaySelector),
|
||||
http.HistoryClient(hClient),
|
||||
http.ValueClient(vClient),
|
||||
http.RegisteredEvents(_registeredEvents),
|
||||
if !cfg.HTTP.Disabled {
|
||||
|
||||
hClient := ehsvc.NewEventHistoryService("eu.opencloud.api.eventhistory", grpcClient)
|
||||
|
||||
svc, err := svcHttp.New(
|
||||
activityLog,
|
||||
svcHttp.Logger(logger),
|
||||
svcHttp.GatewaySelector(gatewaySelector),
|
||||
svcHttp.RegisteredEvents(_registeredEvents),
|
||||
//svcHttp.TraceProvider(tracerProvider),
|
||||
svcHttp.HistoryClient(hClient),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Str("transport", "http").Msg("Failed to initialize server")
|
||||
logger.Error().Err(err).Msg("handler init")
|
||||
return err
|
||||
}
|
||||
// TODO svc = service.NewInstrument(svc, metrics)
|
||||
// TODO svc = service.NewLogging(svc, logger) // this logs service specific data
|
||||
// TODO svc = service.NewTracing(svc, traceProvider)
|
||||
vClient := settingssvc.NewValueService("eu.opencloud.api.settings", grpcClient)
|
||||
|
||||
server, err := http.Server(
|
||||
http.ValueClient(vClient),
|
||||
http.Logger(logger),
|
||||
http.Context(ctx),
|
||||
http.Config(cfg),
|
||||
http.Service(svc),
|
||||
)
|
||||
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", svc))
|
||||
gr.Add(runner.NewGoMicroHttpServerRunner(cfg.Service.Name+".http", server))
|
||||
} else {
|
||||
logger.Info().Msg("HTTP server disabled, not starting HTTP service")
|
||||
}
|
||||
|
||||
if !cfg.Events.Disabled {
|
||||
|
||||
connName := generators.GenerateConnectionName(cfg.Service.Name, generators.NTypeBus)
|
||||
evStream, err := stream.NatsFromConfig(connName, false, stream.NatsConfig{
|
||||
Endpoint: cfg.Events.Endpoint,
|
||||
Cluster: cfg.Events.Cluster,
|
||||
EnableTLS: cfg.Events.EnableTLS,
|
||||
TLSInsecure: cfg.Events.TLSInsecure,
|
||||
TLSRootCACertificate: cfg.Events.TLSRootCACertificate,
|
||||
AuthUsername: cfg.Events.AuthUsername,
|
||||
AuthPassword: cfg.Events.AuthPassword,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Msg("Failed to initialize event stream")
|
||||
return err
|
||||
}
|
||||
|
||||
eventSvc, err := svcEvents.New(
|
||||
activityLog,
|
||||
evStream,
|
||||
svcEvents.Context(ctx),
|
||||
svcEvents.Logger(logger),
|
||||
svcEvents.ServiceAccount(cfg.ServiceAccount),
|
||||
svcEvents.GatewaySelector(gatewaySelector),
|
||||
svcEvents.RegisteredEvents(_registeredEvents),
|
||||
svcEvents.NumConsumers(cfg.NumConsumers),
|
||||
)
|
||||
if err != nil {
|
||||
logger.Error().Err(err).Str("transport", "event").Msg("Failed to initialize server")
|
||||
return err
|
||||
}
|
||||
|
||||
gr.Add(runner.New(cfg.Service.Name+".svc", func() error {
|
||||
return eventSvc.Run()
|
||||
}, func() {
|
||||
eventSvc.Close()
|
||||
}))
|
||||
} else {
|
||||
logger.Info().Msg("event listening disabled, not starting event service")
|
||||
}
|
||||
|
||||
{
|
||||
@@ -149,3 +221,33 @@ func Server(cfg *config.Config) *cobra.Command {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ConnectNatsKV(cfg config.Store) (nats.KeyValue, error) {
|
||||
// Connect to NATS servers
|
||||
secureOption := natspkg.Secure(cfg.EnableTLS, cfg.TLSInsecure, cfg.TLSRootCACertificate)
|
||||
conn, err := nats.Connect(strings.Join(cfg.Nodes, ","), secureOption, nats.UserInfo(cfg.AuthUsername, cfg.AuthPassword))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
js, err := conn.JetStream()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
kv, err := js.KeyValue(cfg.Database)
|
||||
if err != nil {
|
||||
if !errors.Is(err, nats.ErrBucketNotFound) {
|
||||
return nil, errors.Wrapf(err, "Failed to get bucket (%s)", cfg.Database)
|
||||
}
|
||||
|
||||
kv, err = js.CreateKeyValue(&nats.KeyValueConfig{
|
||||
Bucket: cfg.Database,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "Failed to create bucket (%s)", cfg.Database)
|
||||
}
|
||||
}
|
||||
|
||||
return kv, nil
|
||||
}
|
||||
@@ -35,10 +35,12 @@ type Config struct {
|
||||
|
||||
WriteBufferDuration time.Duration `yaml:"write_buffer_duration" env:"ACTIVITYLOG_WRITE_BUFFER_DURATION" desc:"The duration to wait before flushing the write buffer. This is used to reduce the number of writes to the store." introductionVersion:"4.0.0"`
|
||||
MaxActivities int `yaml:"max_activities" env:"ACTIVITYLOG_MAX_ACTIVITIES" desc:"The maximum number of activities to keep in the store per resource. If the number of activities exceeds this value, the oldest activities will be removed." introductionVersion:"4.0.0"`
|
||||
NumConsumers int `yaml:"num_consumers" env:"ACTIVITYLOG_NUM_CONSUMERS" desc:"The amount of concurrent event consumers to start. Event consumers are used for updating the list of activities. Multiple consumers increase parallelisation, but will also increase CPU and memory demands." introductionVersion:"%NEXT%"`
|
||||
}
|
||||
|
||||
// Events combines the configuration options for the event bus.
|
||||
type Events struct {
|
||||
Disabled bool `yaml:"disabled" env:"ACTIVITYLOG_EVENTS_DISABLED" desc:"Disables listening for events. Set this to true if the service should only handle HTTP requests." introductionVersion:"%NEXT%"`
|
||||
Endpoint string `yaml:"endpoint" env:"OC_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" 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" desc:"Whether to verify the server TLS certificates." introductionVersion:"1.0.0"`
|
||||
@@ -77,6 +79,7 @@ type CORS struct {
|
||||
|
||||
// HTTP defines the available http configuration.
|
||||
type HTTP struct {
|
||||
Disabled bool `yaml:"disabled" env:"ACTIVITYLOG_HTTP_DISABLED" desc:"Disables the HTTP service. Set this to true if the service should only handle events." introductionVersion:"1.0.0"`
|
||||
Addr string `yaml:"addr" env:"ACTIVITYLOG_HTTP_ADDR" desc:"The bind address of the HTTP service." introductionVersion:"1.0.0"`
|
||||
Namespace string `yaml:"-"`
|
||||
Root string `yaml:"root" env:"ACTIVITYLOG_HTTP_ROOT" desc:"Subdirectory that serves as the root for this HTTP service." introductionVersion:"1.0.0"`
|
||||
|
||||
@@ -52,7 +52,9 @@ func DefaultConfig() *config.Config {
|
||||
},
|
||||
},
|
||||
WriteBufferDuration: 10 * time.Second,
|
||||
MaxActivities: 6000,
|
||||
// Nats runs into max payload exceeded errors at around 7k activities. Let's keep a buffer.
|
||||
MaxActivities: 6000,
|
||||
NumConsumers: 1,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -3,18 +3,12 @@ package http
|
||||
import (
|
||||
"context"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
ehsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/eventhistory/v0"
|
||||
settingssvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/settings/v0"
|
||||
"github.com/opencloud-eu/opencloud/services/activitylog/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/services/activitylog/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"
|
||||
"go.opentelemetry.io/otel/trace/noop"
|
||||
)
|
||||
|
||||
// Option defines a single option function.
|
||||
@@ -22,19 +16,15 @@ 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]
|
||||
TraceProvider trace.TracerProvider
|
||||
HistoryClient ehsvc.EventHistoryService
|
||||
ValueClient settingssvc.ValueService
|
||||
RegisteredEvents []events.Unmarshaller
|
||||
Name string
|
||||
Namespace string
|
||||
Logger log.Logger
|
||||
Context context.Context
|
||||
Config *config.Config
|
||||
Flags []pflag.Flag
|
||||
Service ActivityLogService
|
||||
TraceProvider trace.TracerProvider
|
||||
ValueClient settingssvc.ValueService
|
||||
}
|
||||
|
||||
// newOptions initializes the available default options.
|
||||
@@ -69,10 +59,10 @@ func Config(val *config.Config) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// Metrics provides a function to set the metrics option.
|
||||
func Metrics(val *metrics.Metrics) Option {
|
||||
// Service provides a function to set the service option.
|
||||
func Service(val ActivityLogService) Option {
|
||||
return func(o *Options) {
|
||||
o.Metrics = val
|
||||
o.Service = val
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,58 +73,20 @@ func Flags(flags ...pflag.Flag) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// Namespace provides a function to set the Namespace option.
|
||||
func Namespace(val string) Option {
|
||||
// TraceProvider provides a function to configure the trace provider
|
||||
func TraceProvider(traceProvider trace.TracerProvider) Option {
|
||||
return func(o *Options) {
|
||||
o.Namespace = val
|
||||
if traceProvider != nil {
|
||||
o.TraceProvider = traceProvider
|
||||
} else {
|
||||
o.TraceProvider = noop.NewTracerProvider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store provides a function to configure the store
|
||||
func Store(store store.Store) Option {
|
||||
// ValueClient adds a grpc client for the value service
|
||||
func ValueClient(vs settingssvc.ValueService) 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
|
||||
}
|
||||
}
|
||||
|
||||
// HistoryClient provides a function to configure the event history client
|
||||
func HistoryClient(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
|
||||
}
|
||||
}
|
||||
|
||||
// TraceProvider provides a function to set the TracerProvider option
|
||||
func TraceProvider(val trace.TracerProvider) Option {
|
||||
return func(o *Options) {
|
||||
o.TraceProvider = val
|
||||
}
|
||||
}
|
||||
|
||||
// ValueClient provides a function to set the ValueClient options
|
||||
func ValueClient(val settingssvc.ValueService) Option {
|
||||
return func(o *Options) {
|
||||
o.ValueClient = val
|
||||
o.ValueClient = vs
|
||||
}
|
||||
}
|
||||
@@ -1,49 +1,63 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
stdhttp "net/http"
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
"github.com/opencloud-eu/opencloud/pkg/account"
|
||||
"github.com/opencloud-eu/opencloud/pkg/cors"
|
||||
"github.com/opencloud-eu/opencloud/pkg/l10n"
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/pkg/middleware"
|
||||
"github.com/opencloud-eu/opencloud/pkg/service/http"
|
||||
"github.com/opencloud-eu/opencloud/pkg/tracing"
|
||||
ohttp "github.com/opencloud-eu/opencloud/pkg/service/http"
|
||||
"github.com/opencloud-eu/opencloud/pkg/version"
|
||||
svc "github.com/opencloud-eu/opencloud/services/activitylog/pkg/service"
|
||||
"github.com/riandyrn/otelchi"
|
||||
settingssvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/settings/v0"
|
||||
activityloghttp "github.com/opencloud-eu/opencloud/services/activitylog/pkg/service/http"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"go-micro.dev/v4"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
// Service is the service interface
|
||||
type Service any
|
||||
var (
|
||||
//go:embed l10n/locale
|
||||
_localeFS embed.FS
|
||||
|
||||
// subfolder where the translation files are stored
|
||||
_localeSubPath = "l10n/locale"
|
||||
|
||||
// domain of the activitylog service (transifex)
|
||||
_domain = "activitylog"
|
||||
)
|
||||
|
||||
// Server initializes the http service and server.
|
||||
func Server(opts ...Option) (http.Service, error) {
|
||||
func Server(opts ...Option) (ohttp.Service, error) {
|
||||
options := newOptions(opts...)
|
||||
service := options.Service
|
||||
|
||||
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.TraceProvider),
|
||||
newService, err := ohttp.NewService(
|
||||
ohttp.TLSConfig(options.Config.HTTP.TLS),
|
||||
ohttp.Logger(options.Logger),
|
||||
ohttp.Namespace(options.Config.HTTP.Namespace),
|
||||
ohttp.Name(options.Config.Service.Name),
|
||||
ohttp.Version(version.GetString()),
|
||||
ohttp.Address(options.Config.HTTP.Addr),
|
||||
ohttp.Context(options.Context),
|
||||
ohttp.Flags(options.Flags...),
|
||||
)
|
||||
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)
|
||||
return ohttp.Service{}, err
|
||||
}
|
||||
|
||||
middlewares := []func(stdhttp.Handler) stdhttp.Handler{
|
||||
middlewares := []func(http.Handler) http.Handler{
|
||||
chimiddleware.RequestID,
|
||||
middleware.Version(
|
||||
options.Config.Service.Name,
|
||||
@@ -52,6 +66,7 @@ func Server(opts ...Option) (http.Service, error) {
|
||||
middleware.Logger(
|
||||
options.Logger,
|
||||
),
|
||||
middleware.TraceContext,
|
||||
middleware.ExtractAccountUUID(
|
||||
account.Logger(options.Logger),
|
||||
account.JWTSecret(options.Config.TokenManager.JWTSecret),
|
||||
@@ -68,33 +83,75 @@ func Server(opts ...Option) (http.Service, error) {
|
||||
mux := chi.NewMux()
|
||||
mux.Use(middlewares...)
|
||||
|
||||
mux.Use(
|
||||
otelchi.Middleware(
|
||||
"actitivylog",
|
||||
otelchi.WithChiRoutes(mux),
|
||||
otelchi.WithTracerProvider(options.TraceProvider),
|
||||
otelchi.WithPropagators(tracing.GetPropagator()),
|
||||
),
|
||||
)
|
||||
t := l10n.NewTranslatorFromCommonConfig(options.Config.DefaultLanguage, _domain, options.Config.TranslationPath, _localeFS, _localeSubPath)
|
||||
mux.Route(options.Config.HTTP.Root, func(r chi.Router) {
|
||||
r.Get("/graph/v1beta1/extensions/org.libregraph/activities", GetItemActivitiesHandler(options.Logger, service, options.ValueClient, t))
|
||||
})
|
||||
|
||||
handle, err := svc.New(
|
||||
svc.Logger(options.Logger),
|
||||
svc.Stream(options.Stream),
|
||||
svc.Mux(mux),
|
||||
svc.Config(options.Config),
|
||||
svc.GatewaySelector(options.GatewaySelector),
|
||||
svc.TraceProvider(options.TraceProvider),
|
||||
svc.HistoryClient(options.HistoryClient),
|
||||
svc.ValueClient(options.ValueClient),
|
||||
svc.RegisteredEvents(options.RegisteredEvents),
|
||||
)
|
||||
err = micro.RegisterHandler(newService.Server(), mux)
|
||||
if err != nil {
|
||||
return http.Service{}, err
|
||||
options.Logger.Fatal().Err(err).Msg("failed to register the handler")
|
||||
}
|
||||
|
||||
if err := micro.RegisterHandler(service.Server(), handle); err != nil {
|
||||
return http.Service{}, err
|
||||
}
|
||||
newService.Init()
|
||||
return newService, nil
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// Service defines the business logic implementations need to provide.
|
||||
type ActivityLogService interface {
|
||||
GetItemActivities(ctx context.Context, query, loc string, t l10n.Translator) ([]libregraph.Activity, error)
|
||||
}
|
||||
|
||||
// GetActivitiesResponse is the response on GET activities requests
|
||||
type GetActivitiesResponse struct {
|
||||
Activities []libregraph.Activity `json:"value"`
|
||||
}
|
||||
|
||||
func GetItemActivitiesHandler(log log.Logger, s ActivityLogService, vc settingssvc.ValueService, t l10n.Translator) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, revactx.TokenHeader, r.Header.Get(revactx.TokenHeader))
|
||||
|
||||
activeUser, ok := revactx.ContextGetUser(ctx)
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
loc := l10n.MustGetUserLocale(ctx, activeUser.GetId().GetOpaqueId(), r.Header.Get(l10n.HeaderAcceptLanguage), vc)
|
||||
|
||||
activities, err := s.GetItemActivities(ctx, r.URL.Query().Get("kql"), loc, t)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, activityloghttp.ErrBadRequest):
|
||||
log.Debug().Str("query", r.URL.Query().Get("kql")).Err(err).Msg("error getting activities")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
case errors.Is(err, activityloghttp.ErrForbidden):
|
||||
log.Debug().Err(err).Msg("error getting activities")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
default:
|
||||
log.Error().Err(err).Msg("error getting activities")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
res := GetActivitiesResponse{
|
||||
Activities: activities,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; odata.metadata=minimal")
|
||||
w.Header().Set("OData-Version", "4.0")
|
||||
if reqID := chimiddleware.GetReqID(ctx); reqID != "" {
|
||||
w.Header().Set("request-id", reqID)
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||
log.Error().Err(err).Msg("error encoding activities")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package activitylog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base32"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/jellydator/ttlcache/v2"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
"github.com/vmihailenco/msgpack/v5"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
var tracer trace.Tracer
|
||||
|
||||
func init() {
|
||||
tracer = otel.Tracer("github.com/opencloud-eu/opencloud/services/activitylog/pkg/service/activitylog")
|
||||
}
|
||||
|
||||
var (
|
||||
_maxActivitiesDefault = 6000
|
||||
_writeBufferDuration = 10 * time.Second
|
||||
)
|
||||
|
||||
// Activitylog stores and retrieves activities for resources and their parents from a nats kv
|
||||
type ActivityLog struct {
|
||||
log log.Logger
|
||||
// FIXME the lock does not protect agains concurrent resource activities on multiple instances
|
||||
// known since https://github.com/owncloud/ocis/pull/9361#pullrequestreview-2135350157
|
||||
// current ocis discussion in https://github.com/owncloud/ocis/issues/12475
|
||||
lock sync.RWMutex
|
||||
debouncer *Debouncer
|
||||
parentIdCache *ttlcache.Cache
|
||||
natskv nats.KeyValue
|
||||
|
||||
maxActivities int
|
||||
}
|
||||
|
||||
type batchInfo struct {
|
||||
key string
|
||||
count int
|
||||
timestamp time.Time
|
||||
}
|
||||
|
||||
// New creates a new ActivitylogService
|
||||
func New(kv nats.KeyValue, opts ...Option) (*ActivityLog, error) {
|
||||
o := &Options{
|
||||
MaxActivities: _maxActivitiesDefault,
|
||||
WriteBufferDuration: _writeBufferDuration,
|
||||
Logger: log.NopLogger(),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(o)
|
||||
}
|
||||
|
||||
cache := ttlcache.NewCache()
|
||||
err := cache.SetTTL(30 * time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &ActivityLog{
|
||||
log: o.Logger,
|
||||
lock: sync.RWMutex{},
|
||||
parentIdCache: cache,
|
||||
maxActivities: o.MaxActivities,
|
||||
natskv: kv,
|
||||
}
|
||||
s.debouncer = NewDebouncer(o.WriteBufferDuration, s.StoreActivity)
|
||||
|
||||
// run migrations
|
||||
err = s.runMigrations(context.Background(), kv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// RemoveResource removes the resource from the store
|
||||
func (a *ActivityLog) RemoveResource(rid *provider.ResourceId) error {
|
||||
if rid == nil {
|
||||
return fmt.Errorf("resource id is required")
|
||||
}
|
||||
|
||||
a.lock.Lock()
|
||||
defer a.lock.Unlock()
|
||||
|
||||
err := a.natskv.Delete(storagespace.FormatResourceID(rid))
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not delete resource %s: %w", rid.OpaqueId, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RawActivity represents an activity as it is stored in the activitylog store
|
||||
type RawActivity struct {
|
||||
EventID string `json:"event_id"`
|
||||
Depth int `json:"depth"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
func (a *ActivityLog) AddActivity(ctx context.Context, initRef *provider.Reference, parentId *provider.ResourceId, eventID string, timestamp time.Time, getResource func(context.Context, *provider.Reference) (*provider.ResourceInfo, error)) error {
|
||||
var (
|
||||
err error
|
||||
depth int
|
||||
ref = initRef
|
||||
)
|
||||
ctx, span := tracer.Start(ctx, "AddActivity")
|
||||
defer span.End()
|
||||
for {
|
||||
var info *provider.ResourceInfo
|
||||
id := ref.GetResourceId()
|
||||
if ref.Path != "" {
|
||||
// Path based reference, we need to resolve the resource id
|
||||
ctx, span = tracer.Start(ctx, "AddActivity.getResource")
|
||||
info, err = getResource(ctx, ref)
|
||||
span.End()
|
||||
if err != nil {
|
||||
// TODO If the resource was deleted should we still log an activity in the parent?
|
||||
return fmt.Errorf("could not get resource info for reference %v: %w", ref, err)
|
||||
}
|
||||
id = info.GetId()
|
||||
}
|
||||
if id == nil {
|
||||
return fmt.Errorf("resource id is required")
|
||||
}
|
||||
|
||||
key := storagespace.FormatResourceID(id)
|
||||
a.debouncer.Debounce(key, RawActivity{
|
||||
EventID: eventID,
|
||||
Depth: depth,
|
||||
Timestamp: timestamp,
|
||||
})
|
||||
|
||||
if id.OpaqueId == id.SpaceId {
|
||||
// we are at the root of the space, no need to go further
|
||||
break
|
||||
}
|
||||
|
||||
// check if parent id is cached
|
||||
// parent id is cached in the format <storageid>$<spaceid>!<resourceid>
|
||||
// if it is not cached, get the resource info and cache it
|
||||
if parentId == nil {
|
||||
if v, err := a.parentIdCache.Get(key); err != nil {
|
||||
if info == nil {
|
||||
ctx, span := tracer.Start(ctx, "AddActivity.getResource parent")
|
||||
info, err = getResource(ctx, ref)
|
||||
span.End()
|
||||
if err != nil || info.GetParentId() == nil || info.GetParentId().GetOpaqueId() == "" {
|
||||
return fmt.Errorf("could not get parent id: %w", err)
|
||||
}
|
||||
}
|
||||
parentId = info.GetParentId()
|
||||
a.parentIdCache.Set(key, parentId)
|
||||
} else {
|
||||
parentId = v.(*provider.ResourceId)
|
||||
}
|
||||
} else {
|
||||
a.log.Debug().Msg("parent id is cached")
|
||||
}
|
||||
|
||||
depth++
|
||||
ref = &provider.Reference{ResourceId: parentId}
|
||||
parentId = nil // reset parent id so it's not reused in the next iteration
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ActivityLog) StoreActivity(resourceID string, activities []RawActivity) error {
|
||||
a.lock.Lock()
|
||||
defer a.lock.Unlock()
|
||||
|
||||
ctx, span := tracer.Start(context.Background(), "storeActivity")
|
||||
defer span.End()
|
||||
|
||||
_, subspan := tracer.Start(ctx, "storeActivity.Marshal")
|
||||
b, err := msgpack.Marshal(activities)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
subspan.End()
|
||||
|
||||
_, subspan = tracer.Start(ctx, "storeActivity.natskv.Put")
|
||||
key := natsKey(resourceID, len(activities))
|
||||
_, err = a.natskv.Put(key, b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
subspan.End()
|
||||
|
||||
ctx, subspan = tracer.Start(ctx, "storeActivity.enforceMaxActivities")
|
||||
a.enforceMaxActivities(ctx, resourceID)
|
||||
subspan.End()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ActivityLog) enforceMaxActivities(ctx context.Context, resourceID string) {
|
||||
if a.maxActivities <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("%s.>", base32.StdEncoding.EncodeToString([]byte(resourceID)))
|
||||
|
||||
_, subspan := tracer.Start(ctx, "enforceMaxActivities.watch")
|
||||
watcher, err := a.natskv.Watch(key, nats.IgnoreDeletes())
|
||||
if err != nil {
|
||||
a.log.Error().Err(err).Str("resourceID", resourceID).Msg("could not watch")
|
||||
return
|
||||
}
|
||||
defer watcher.Stop()
|
||||
|
||||
var keys []string
|
||||
for update := range watcher.Updates() {
|
||||
if update == nil {
|
||||
break
|
||||
}
|
||||
|
||||
var batchActivities []RawActivity
|
||||
if err := msgpack.Unmarshal(update.Value(), &batchActivities); err != nil {
|
||||
a.log.Debug().Err(err).Str("resourceID", resourceID).Msg("could not unmarshal messagepack, trying json")
|
||||
}
|
||||
keys = append(keys, update.Key())
|
||||
}
|
||||
subspan.End()
|
||||
|
||||
_, subspan = tracer.Start(ctx, "enforceMaxActivities.compile")
|
||||
// Parse keys into batches
|
||||
batches := make([]batchInfo, 0)
|
||||
var activitiesCount int
|
||||
for _, k := range keys {
|
||||
parts := strings.SplitN(k, ".", 3)
|
||||
if len(parts) < 3 {
|
||||
a.log.Warn().Str("key", k).Msg("skipping key, not enough parts")
|
||||
continue
|
||||
}
|
||||
|
||||
c, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
a.log.Warn().Str("key", k).Msg("skipping key, can not parse count")
|
||||
continue
|
||||
}
|
||||
|
||||
// parse timestamp
|
||||
nano, err := strconv.ParseInt(parts[2], 10, 64)
|
||||
if err != nil {
|
||||
a.log.Warn().Str("key", k).Msg("skipping key, can not parse timestamp")
|
||||
continue
|
||||
}
|
||||
|
||||
batches = append(batches, batchInfo{
|
||||
key: k,
|
||||
count: c,
|
||||
timestamp: time.Unix(0, nano),
|
||||
})
|
||||
activitiesCount += c
|
||||
}
|
||||
|
||||
// sort batches by timestamp
|
||||
sort.Slice(batches, func(i, j int) bool {
|
||||
return batches[i].timestamp.Before(batches[j].timestamp)
|
||||
})
|
||||
subspan.End()
|
||||
|
||||
_, subspan = tracer.Start(ctx, "enforceMaxActivities.delete")
|
||||
// remove oldest keys until we are at max activities
|
||||
for _, b := range batches {
|
||||
if activitiesCount-b.count < a.maxActivities {
|
||||
break
|
||||
}
|
||||
|
||||
activitiesCount -= b.count
|
||||
err = a.natskv.Delete(b.key)
|
||||
if err != nil {
|
||||
a.log.Error().Err(err).Str("key", b.key).Msg("could not delete key")
|
||||
break
|
||||
}
|
||||
}
|
||||
subspan.End()
|
||||
}
|
||||
|
||||
func (a *ActivityLog) InvalidateCachedParentID(purgeId *provider.ResourceId) {
|
||||
// The parent id cache is populated lazily and its entries expire, so a
|
||||
// missing key is the expected case rather than an error.
|
||||
if err := a.parentIdCache.Remove(storagespace.FormatResourceID(purgeId)); err != nil {
|
||||
a.log.Debug().Interface("event", purgeId).Err(err).Msg("could not delete parent id cache")
|
||||
}
|
||||
}
|
||||
|
||||
func natsKey(resourceID string, activitiesCount int) string {
|
||||
return fmt.Sprintf("%s.%d.%d",
|
||||
base32.StdEncoding.EncodeToString([]byte(resourceID)),
|
||||
activitiesCount,
|
||||
time.Now().UnixNano())
|
||||
}
|
||||
|
||||
func (a *ActivityLog) Activities(rid *provider.ResourceId) ([]RawActivity, error) {
|
||||
a.lock.RLock()
|
||||
defer a.lock.RUnlock()
|
||||
|
||||
return a.activities(rid)
|
||||
}
|
||||
|
||||
func (a *ActivityLog) activities(rid *provider.ResourceId) ([]RawActivity, error) {
|
||||
resourceID := storagespace.FormatResourceID(rid)
|
||||
|
||||
glob := fmt.Sprintf("%s.>", base32.StdEncoding.EncodeToString([]byte(resourceID)))
|
||||
|
||||
watcher, err := a.natskv.Watch(glob, nats.IgnoreDeletes())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer watcher.Stop()
|
||||
|
||||
var activities []RawActivity
|
||||
for update := range watcher.Updates() {
|
||||
if update == nil {
|
||||
break
|
||||
}
|
||||
|
||||
var batchActivities []RawActivity
|
||||
if err := msgpack.Unmarshal(update.Value(), &batchActivities); err != nil {
|
||||
a.log.Debug().Err(err).Str("resourceID", resourceID).Msg("could not unmarshal messagepack")
|
||||
}
|
||||
activities = append(activities, batchActivities...)
|
||||
}
|
||||
|
||||
return activities, nil
|
||||
}
|
||||
|
||||
// RemoveActivities removes the activities from the given resource
|
||||
func (a *ActivityLog) RemoveActivities(rid *provider.ResourceId, toDelete map[string]struct{}) error {
|
||||
a.lock.Lock()
|
||||
defer a.lock.Unlock()
|
||||
|
||||
curActivities, err := a.activities(rid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var acts []RawActivity
|
||||
for _, a := range curActivities {
|
||||
if _, ok := toDelete[a.EventID]; !ok {
|
||||
acts = append(acts, a)
|
||||
}
|
||||
}
|
||||
|
||||
b, err := json.Marshal(acts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = a.natskv.Put(storagespace.FormatResourceID(rid), b)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package activitylog_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestActivitylog(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Activitylog Suite")
|
||||
}
|
||||
+34
-81
@@ -1,7 +1,6 @@
|
||||
package service
|
||||
package activitylog_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
@@ -9,17 +8,13 @@ import (
|
||||
"time"
|
||||
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
nserver "github.com/nats-io/nats-server/v2/server"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/services/activitylog/pkg/command"
|
||||
"github.com/opencloud-eu/opencloud/services/activitylog/pkg/config"
|
||||
eventsmocks "github.com/opencloud-eu/reva/v2/pkg/events/mocks"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
"github.com/test-go/testify/mock"
|
||||
"go.opentelemetry.io/otel/trace/noop"
|
||||
"github.com/opencloud-eu/opencloud/services/activitylog/pkg/service/activitylog"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -68,31 +63,25 @@ var _ = SynchronizedAfterSuite(func() {
|
||||
|
||||
var _ = Describe("ActivitylogService", func() {
|
||||
var (
|
||||
alog *ActivitylogService
|
||||
alog *activitylog.ActivityLog
|
||||
getResource func(_ context.Context, ref *provider.Reference) (*provider.ResourceInfo, error)
|
||||
writebufferduration = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
JustBeforeEach(func() {
|
||||
var err error
|
||||
stream := &eventsmocks.Stream{}
|
||||
stream.EXPECT().Consume(mock.Anything, mock.Anything).Return(nil, nil)
|
||||
alog, err = New(
|
||||
Config(&config.Config{
|
||||
Service: config.Service{
|
||||
Name: "activitylog-test",
|
||||
},
|
||||
Store: config.Store{
|
||||
Store: "nats-js-kv",
|
||||
Nodes: []string{server.Addr().String()},
|
||||
Database: "activitylog-test-" + uuid.New().String(),
|
||||
},
|
||||
MaxActivities: 4,
|
||||
WriteBufferDuration: writebufferduration,
|
||||
}),
|
||||
Stream(stream),
|
||||
TraceProvider(noop.NewTracerProvider()),
|
||||
Mux(chi.NewMux()),
|
||||
db := "activitylog-test-" + uuid.New().String()
|
||||
|
||||
kv, err := command.ConnectNatsKV(config.Store{
|
||||
Nodes: []string{server.Addr().String()},
|
||||
Database: db,
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
alog, err = activitylog.New(
|
||||
kv,
|
||||
activitylog.MaxActivities(4),
|
||||
activitylog.WriteBufferDuration(writebufferduration),
|
||||
)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
@@ -107,7 +96,7 @@ var _ = Describe("ActivitylogService", func() {
|
||||
Name string
|
||||
Tree map[string]*provider.ResourceInfo
|
||||
Activities map[string]string
|
||||
Expected map[string][]RawActivity
|
||||
Expected map[string][]activitylog.RawActivity
|
||||
}
|
||||
|
||||
testCases := []testCase{
|
||||
@@ -121,7 +110,7 @@ var _ = Describe("ActivitylogService", func() {
|
||||
Activities: map[string]string{
|
||||
"activity": "base",
|
||||
},
|
||||
Expected: map[string][]RawActivity{
|
||||
Expected: map[string][]activitylog.RawActivity{
|
||||
"base": activitites("activity", 0),
|
||||
"parent": activitites("activity", 1),
|
||||
"spaceid": activitites("activity", 2),
|
||||
@@ -138,7 +127,7 @@ var _ = Describe("ActivitylogService", func() {
|
||||
"activity1": "base",
|
||||
"activity2": "base",
|
||||
},
|
||||
Expected: map[string][]RawActivity{
|
||||
Expected: map[string][]activitylog.RawActivity{
|
||||
"base": activitites("activity1", 0, "activity2", 0),
|
||||
"parent": activitites("activity1", 1, "activity2", 1),
|
||||
"spaceid": activitites("activity1", 2, "activity2", 2),
|
||||
@@ -155,7 +144,7 @@ var _ = Describe("ActivitylogService", func() {
|
||||
}
|
||||
|
||||
for k, v := range tc.Activities {
|
||||
err := alog.addActivity(context.Background(), reference(v), nil, k, time.Time{}, getResource)
|
||||
err := alog.AddActivity(context.Background(), reference(v), nil, k, time.Time{}, getResource)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
}
|
||||
})
|
||||
@@ -194,9 +183,9 @@ var _ = Describe("ActivitylogService", func() {
|
||||
|
||||
It("debounces activities", func() {
|
||||
|
||||
err := alog.addActivity(context.Background(), reference("base"), nil, "activity1", time.Time{}, getResource)
|
||||
err := alog.AddActivity(context.Background(), reference("base"), nil, "activity1", time.Time{}, getResource)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
err = alog.addActivity(context.Background(), reference("base"), nil, "activity2", time.Time{}, getResource)
|
||||
err = alog.AddActivity(context.Background(), reference("base"), nil, "activity2", time.Time{}, getResource)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
Eventually(func(g Gomega) {
|
||||
@@ -207,7 +196,7 @@ var _ = Describe("ActivitylogService", func() {
|
||||
})
|
||||
|
||||
It("adheres to the MaxActivities setting", func() {
|
||||
err := alog.addActivity(context.Background(), reference("base"), nil, "activity1", time.Time{}, getResource)
|
||||
err := alog.AddActivity(context.Background(), reference("base"), nil, "activity1", time.Time{}, getResource)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Eventually(func(g Gomega) {
|
||||
activities, err := alog.Activities(resourceID("base"))
|
||||
@@ -215,7 +204,7 @@ var _ = Describe("ActivitylogService", func() {
|
||||
g.Expect(len(activities)).To(Equal(1))
|
||||
}).Should(Succeed())
|
||||
|
||||
err = alog.addActivity(context.Background(), reference("base"), nil, "activity2", time.Time{}, getResource)
|
||||
err = alog.AddActivity(context.Background(), reference("base"), nil, "activity2", time.Time{}, getResource)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Eventually(func(g Gomega) {
|
||||
activities, err := alog.Activities(resourceID("base"))
|
||||
@@ -223,11 +212,11 @@ var _ = Describe("ActivitylogService", func() {
|
||||
g.Expect(len(activities)).To(Equal(2))
|
||||
}).Should(Succeed())
|
||||
|
||||
err = alog.addActivity(context.Background(), reference("base"), nil, "activity3", time.Time{}, getResource)
|
||||
err = alog.AddActivity(context.Background(), reference("base"), nil, "activity3", time.Time{}, getResource)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
err = alog.addActivity(context.Background(), reference("base"), nil, "activity4", time.Time{}, getResource)
|
||||
err = alog.AddActivity(context.Background(), reference("base"), nil, "activity4", time.Time{}, getResource)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
err = alog.addActivity(context.Background(), reference("base"), nil, "activity5", time.Time{}, getResource)
|
||||
err = alog.AddActivity(context.Background(), reference("base"), nil, "activity5", time.Time{}, getResource)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
Eventually(func(g Gomega) {
|
||||
@@ -244,9 +233,9 @@ var _ = Describe("ActivitylogService", func() {
|
||||
return tree[ref.GetResourceId().GetOpaqueId()], nil
|
||||
}
|
||||
|
||||
err := alog.addActivity(context.Background(), reference("base"), nil, "activity1", time.Time{}, getResource)
|
||||
err := alog.AddActivity(context.Background(), reference("base"), nil, "activity1", time.Time{}, getResource)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
err = alog.addActivity(context.Background(), reference("base"), nil, "activity2", time.Time{}, getResource)
|
||||
err = alog.AddActivity(context.Background(), reference("base"), nil, "activity2", time.Time{}, getResource)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
Eventually(func(g Gomega) {
|
||||
@@ -255,9 +244,9 @@ var _ = Describe("ActivitylogService", func() {
|
||||
g.Expect(activities).To(ConsistOf(activitites("activity1", 0, "activity2", 0)))
|
||||
}).Should(Succeed())
|
||||
|
||||
err = alog.addActivity(context.Background(), reference("base"), nil, "activity3", time.Time{}, getResource)
|
||||
err = alog.AddActivity(context.Background(), reference("base"), nil, "activity3", time.Time{}, getResource)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
err = alog.addActivity(context.Background(), reference("base"), nil, "activity4", time.Time{}, getResource)
|
||||
err = alog.AddActivity(context.Background(), reference("base"), nil, "activity4", time.Time{}, getResource)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
Eventually(func(g Gomega) {
|
||||
@@ -268,47 +257,11 @@ var _ = Describe("ActivitylogService", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("removeCachedParentID", func() {
|
||||
var logBuffer *bytes.Buffer
|
||||
|
||||
newLoggerAtLevel := func(level string) log.Logger {
|
||||
logBuffer = &bytes.Buffer{}
|
||||
return log.Logger{Logger: log.NewLogger(log.Level(level)).Output(logBuffer)}
|
||||
}
|
||||
|
||||
It("does not log an error when the entry was never cached", func() {
|
||||
alog.log = newLoggerAtLevel("error")
|
||||
|
||||
alog.removeCachedParentID(reference("never-cached"))
|
||||
|
||||
Expect(logBuffer.String()).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("logs a missing entry at debug level", func() {
|
||||
alog.log = newLoggerAtLevel("debug")
|
||||
|
||||
alog.removeCachedParentID(reference("never-cached"))
|
||||
|
||||
Expect(logBuffer.String()).To(ContainSubstring("could not delete parent id cache"))
|
||||
Expect(logBuffer.String()).To(ContainSubstring(`"level":"debug"`))
|
||||
})
|
||||
|
||||
It("does not log at all when the entry was cached", func() {
|
||||
alog.log = newLoggerAtLevel("debug")
|
||||
ref := reference("cached")
|
||||
Expect(alog.parentIdCache.Set(storagespace.FormatResourceID(ref.GetResourceId()), resourceID("parent"))).To(Succeed())
|
||||
|
||||
alog.removeCachedParentID(ref)
|
||||
|
||||
Expect(logBuffer.String()).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func activitites(acts ...any) []RawActivity {
|
||||
var activities []RawActivity
|
||||
act := RawActivity{}
|
||||
func activitites(acts ...any) []activitylog.RawActivity {
|
||||
var activities []activitylog.RawActivity
|
||||
act := activitylog.RawActivity{}
|
||||
for _, a := range acts {
|
||||
switch v := a.(type) {
|
||||
case string:
|
||||
@@ -0,0 +1,76 @@
|
||||
package activitylog
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Debouncer is used to debounce writes to the activity log store.
|
||||
type Debouncer struct {
|
||||
after time.Duration
|
||||
f func(id string, ra []RawActivity) error
|
||||
pending sync.Map
|
||||
inProgress sync.Map
|
||||
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
type queueItem struct {
|
||||
activities []RawActivity
|
||||
timer *time.Timer
|
||||
}
|
||||
|
||||
// NewDebouncer returns a new Debouncer instance.
|
||||
func NewDebouncer(d time.Duration, f func(id string, ra []RawActivity) error) *Debouncer {
|
||||
return &Debouncer{
|
||||
after: d,
|
||||
f: f,
|
||||
pending: sync.Map{},
|
||||
inProgress: sync.Map{},
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce restarts the debounce timer for the given space.
|
||||
func (d *Debouncer) Debounce(id string, ra RawActivity) {
|
||||
if d.after == 0 {
|
||||
d.f(id, []RawActivity{ra})
|
||||
return
|
||||
}
|
||||
|
||||
d.mutex.Lock()
|
||||
defer d.mutex.Unlock()
|
||||
|
||||
item := &queueItem{
|
||||
activities: []RawActivity{ra},
|
||||
}
|
||||
|
||||
if i, ok := d.pending.Load(id); ok {
|
||||
// if the item is already in the queue, append the new activities
|
||||
item, ok = i.(*queueItem)
|
||||
if ok {
|
||||
item.activities = append(item.activities, ra)
|
||||
}
|
||||
}
|
||||
|
||||
if item.timer == nil {
|
||||
item.timer = time.AfterFunc(d.after, func() {
|
||||
if _, ok := d.inProgress.Load(id); ok {
|
||||
// Reschedule this run for when the previous run has finished
|
||||
d.mutex.Lock()
|
||||
if i, ok := d.pending.Load(id); ok {
|
||||
i.(*queueItem).timer.Reset(d.after)
|
||||
}
|
||||
|
||||
d.mutex.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
d.pending.Delete(id)
|
||||
d.inProgress.Store(id, true)
|
||||
defer d.inProgress.Delete(id)
|
||||
d.f(id, item.activities)
|
||||
})
|
||||
}
|
||||
|
||||
d.pending.Store(id, item)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package activitylog_test
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/opencloud-eu/opencloud/services/activitylog/pkg/service/activitylog"
|
||||
)
|
||||
|
||||
var _ = Describe("Debouncer", func() {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
callbacks []activitylog.RawActivity
|
||||
newCallback func(id string, ra []activitylog.RawActivity) error
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
mu.Lock()
|
||||
callbacks = nil
|
||||
mu.Unlock()
|
||||
newCallback = func(id string, ra []activitylog.RawActivity) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
callbacks = append(callbacks, ra...)
|
||||
return nil
|
||||
}
|
||||
})
|
||||
|
||||
Context("with zero duration", func() {
|
||||
It("calls the callback immediately", func() {
|
||||
d := activitylog.NewDebouncer(0, newCallback)
|
||||
d.Debounce("space1", activitylog.RawActivity{EventID: "activity1"})
|
||||
Expect(callbacks).To(HaveLen(1))
|
||||
Expect(callbacks[0].EventID).To(Equal("activity1"))
|
||||
})
|
||||
|
||||
It("calls the callback immediately for each event", func() {
|
||||
d := activitylog.NewDebouncer(0, newCallback)
|
||||
d.Debounce("space1", activitylog.RawActivity{EventID: "activity1"})
|
||||
d.Debounce("space2", activitylog.RawActivity{EventID: "activity2"})
|
||||
Expect(callbacks).To(HaveLen(2))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with non-zero duration", func() {
|
||||
It("batches activities with the same id", func() {
|
||||
d := activitylog.NewDebouncer(10*time.Millisecond, newCallback)
|
||||
d.Debounce("space1", activitylog.RawActivity{EventID: "activity1"})
|
||||
d.Debounce("space1", activitylog.RawActivity{EventID: "activity2"})
|
||||
d.Debounce("space1", activitylog.RawActivity{EventID: "activity3"})
|
||||
|
||||
Eventually(func() int {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return len(callbacks)
|
||||
}).Should(Equal(3))
|
||||
})
|
||||
|
||||
It("handles different ids independently", func() {
|
||||
d := activitylog.NewDebouncer(10*time.Millisecond, newCallback)
|
||||
d.Debounce("space1", activitylog.RawActivity{EventID: "activity1"})
|
||||
d.Debounce("space2", activitylog.RawActivity{EventID: "activity2"})
|
||||
|
||||
Eventually(func() int {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return len(callbacks)
|
||||
}).Should(Equal(2))
|
||||
})
|
||||
|
||||
It("batches activities that arrive within the debounce window", func() {
|
||||
d := activitylog.NewDebouncer(100*time.Millisecond, newCallback)
|
||||
d.Debounce("space1", activitylog.RawActivity{EventID: "activity1"})
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
d.Debounce("space1", activitylog.RawActivity{EventID: "activity2"})
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
d.Debounce("space1", activitylog.RawActivity{EventID: "activity3"})
|
||||
|
||||
Eventually(func() int {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return len(callbacks)
|
||||
}).Should(Equal(3))
|
||||
})
|
||||
|
||||
It("processes new batch after previous completes", func() {
|
||||
d := activitylog.NewDebouncer(5*time.Millisecond, newCallback)
|
||||
d.Debounce("space1", activitylog.RawActivity{EventID: "activity1"})
|
||||
time.Sleep(20 * time.Millisecond) // let first batch complete
|
||||
d.Debounce("space1", activitylog.RawActivity{EventID: "activity2"})
|
||||
|
||||
Eventually(func() int {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return len(callbacks)
|
||||
}).Should(Equal(2))
|
||||
})
|
||||
|
||||
It("skips duplicate write when timer fires during in-progress callback", func() {
|
||||
slowCallback := func(id string, ra []activitylog.RawActivity) error {
|
||||
time.Sleep(50 * time.Millisecond) // simulate slow write
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
callbacks = append(callbacks, ra...)
|
||||
return nil
|
||||
}
|
||||
|
||||
d := activitylog.NewDebouncer(10*time.Millisecond, slowCallback)
|
||||
d.Debounce("space1", activitylog.RawActivity{EventID: "activity1"})
|
||||
time.Sleep(20 * time.Millisecond) // timer fires while callback is running
|
||||
|
||||
Eventually(func() int {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return len(callbacks)
|
||||
}).Should(Equal(1))
|
||||
})
|
||||
})
|
||||
})
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
package service
|
||||
package activitylog
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -15,7 +15,7 @@ const currentMigrationVersion = "1"
|
||||
|
||||
// RunMigrations checks the activitylog data version and runs migrations if necessary.
|
||||
// It should be called during service startup, after the NATS KeyValue store is initialized.
|
||||
func (a *ActivitylogService) runMigrations(ctx context.Context, kv nats.KeyValue) error {
|
||||
func (a *ActivityLog) runMigrations(ctx context.Context, kv nats.KeyValue) error {
|
||||
entry, err := kv.Get(activitylogVersionKey)
|
||||
if err == nats.ErrKeyNotFound {
|
||||
a.log.Info().Msg("activitylog version key not found. Running migration to V1...")
|
||||
@@ -40,7 +40,7 @@ func (a *ActivitylogService) runMigrations(ctx context.Context, kv nats.KeyValue
|
||||
// For each such key, it creates a new key in the format "originalKey.count.timestamp"
|
||||
// and stores the original list of strings (re-marshalled to messagepack) as its value.
|
||||
// Finally, it sets the activitylog.version key to "1".
|
||||
func (a *ActivitylogService) migrateToV1(_ context.Context, kv nats.KeyValue) error {
|
||||
func (a *ActivityLog) migrateToV1(_ context.Context, kv nats.KeyValue) error {
|
||||
lister, err := kv.ListKeys()
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrateToV1: failed to list keys from NATS KV store: %w", err)
|
||||
@@ -0,0 +1,35 @@
|
||||
package activitylog
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
)
|
||||
|
||||
// Option for the activitylog service
|
||||
type Option func(*Options)
|
||||
|
||||
// Options for the activitylog service
|
||||
type Options struct {
|
||||
Logger log.Logger
|
||||
MaxActivities int
|
||||
WriteBufferDuration time.Duration
|
||||
}
|
||||
|
||||
// Logger configures a logger for the activitylog service
|
||||
func Logger(log log.Logger) Option {
|
||||
return func(o *Options) {
|
||||
o.Logger = log
|
||||
}
|
||||
}
|
||||
|
||||
func MaxActivities(max int) Option {
|
||||
return func(o *Options) {
|
||||
o.MaxActivities = max
|
||||
}
|
||||
}
|
||||
func WriteBufferDuration(d time.Duration) Option {
|
||||
return func(o *Options) {
|
||||
o.WriteBufferDuration = d
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
package service_test
|
||||
package events_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestService(t *testing.T) {
|
||||
func TestEvents(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Service Suite")
|
||||
RunSpecs(t, "Events Suite")
|
||||
}
|
||||
+16
-38
@@ -1,17 +1,14 @@
|
||||
package service
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
ehsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/eventhistory/v0"
|
||||
settingssvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/settings/v0"
|
||||
"github.com/opencloud-eu/opencloud/services/activitylog/pkg/config"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// Option for the activitylog service
|
||||
@@ -19,17 +16,20 @@ type Option func(*Options)
|
||||
|
||||
// Options for the activitylog service
|
||||
type Options struct {
|
||||
Context context.Context
|
||||
Logger log.Logger
|
||||
Config *config.Config
|
||||
TraceProvider trace.TracerProvider
|
||||
ServiceAccount config.ServiceAccount
|
||||
Stream events.Stream
|
||||
RegisteredEvents []events.Unmarshaller
|
||||
GatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
Mux *chi.Mux
|
||||
HistoryClient ehsvc.EventHistoryService
|
||||
ValueClient settingssvc.ValueService
|
||||
WriteBufferDuration time.Duration
|
||||
MaxActivities int
|
||||
NumConsumers int
|
||||
}
|
||||
|
||||
func Context(ctx context.Context) Option {
|
||||
return func(o *Options) {
|
||||
o.Context = ctx
|
||||
}
|
||||
}
|
||||
|
||||
// Logger configures a logger for the activitylog service
|
||||
@@ -39,17 +39,10 @@ func Logger(log log.Logger) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// Config adds the config for the activitylog service
|
||||
func Config(c *config.Config) Option {
|
||||
// ServiceAccount configures a service account for the activitylog service
|
||||
func ServiceAccount(sa config.ServiceAccount) Option {
|
||||
return func(o *Options) {
|
||||
o.Config = c
|
||||
}
|
||||
}
|
||||
|
||||
// TraceProvider adds a tracer provider for the activitylog service
|
||||
func TraceProvider(tp trace.TracerProvider) Option {
|
||||
return func(o *Options) {
|
||||
o.TraceProvider = tp
|
||||
o.ServiceAccount = sa
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,23 +67,8 @@ func GatewaySelector(gatewaySelector pool.Selectable[gateway.GatewayAPIClient])
|
||||
}
|
||||
}
|
||||
|
||||
// Mux defines the muxer for the service
|
||||
func Mux(m *chi.Mux) Option {
|
||||
func NumConsumers(num int) Option {
|
||||
return func(o *Options) {
|
||||
o.Mux = m
|
||||
}
|
||||
}
|
||||
|
||||
// HistoryClient adds a grpc client for the eventhistory service
|
||||
func HistoryClient(hc ehsvc.EventHistoryService) Option {
|
||||
return func(o *Options) {
|
||||
o.HistoryClient = hc
|
||||
}
|
||||
}
|
||||
|
||||
// ValueClient adds a grpc client for the value service
|
||||
func ValueClient(vs settingssvc.ValueService) Option {
|
||||
return func(o *Options) {
|
||||
o.ValueClient = vs
|
||||
o.NumConsumers = num
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/services/activitylog/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/services/activitylog/pkg/service/activitylog"
|
||||
"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.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
var tracer trace.Tracer
|
||||
|
||||
func init() {
|
||||
tracer = otel.Tracer("github.com/opencloud-eu/opencloud/services/activitylog/pkg/service/events")
|
||||
}
|
||||
|
||||
var (
|
||||
_numConsumersDefault = 1
|
||||
)
|
||||
|
||||
// ActivitylogService logs events per resource
|
||||
type ActivitylogService struct {
|
||||
ctx context.Context
|
||||
sa config.ServiceAccount
|
||||
log log.Logger
|
||||
stream events.Stream
|
||||
gws pool.Selectable[gateway.GatewayAPIClient]
|
||||
al *activitylog.ActivityLog
|
||||
|
||||
numConsumers int
|
||||
|
||||
events []events.Unmarshaller
|
||||
|
||||
stopCh chan struct{}
|
||||
stopped *atomic.Bool
|
||||
}
|
||||
|
||||
// New creates a new ActivitylogService
|
||||
func New(al *activitylog.ActivityLog, stream events.Stream, opts ...Option) (*ActivitylogService, error) {
|
||||
o := &Options{
|
||||
NumConsumers: _numConsumersDefault,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(o)
|
||||
}
|
||||
|
||||
s := &ActivitylogService{
|
||||
ctx: o.Context,
|
||||
log: o.Logger,
|
||||
sa: o.ServiceAccount,
|
||||
stream: stream,
|
||||
gws: o.GatewaySelector,
|
||||
events: o.RegisteredEvents,
|
||||
numConsumers: o.NumConsumers,
|
||||
al: al,
|
||||
stopCh: make(chan struct{}, 1),
|
||||
stopped: new(atomic.Bool),
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Run to fulfil Runner interface
|
||||
func (s *ActivitylogService) Run() error {
|
||||
ch, err := events.Consume(s.stream, "activitylog", s.events...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
ctx, cancel := context.WithCancel(s.ctx)
|
||||
defer cancel()
|
||||
|
||||
s.log.Debug().Int("worker.count", s.numConsumers).
|
||||
Str("messaging.consumer.group.name", "activitylog").
|
||||
Str("messaging.system", "nats").
|
||||
Str("messaging.operation.name", "receive").
|
||||
Msg("starting event processing workers")
|
||||
|
||||
// start workers
|
||||
for i := 0; i < s.numConsumers; i++ {
|
||||
wg.Add(1)
|
||||
go func(workerID int) {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case e, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.processEvent(e); err != nil {
|
||||
s.log.Error().Err(err).
|
||||
Int("worker", workerID).
|
||||
Interface("event", e).
|
||||
Msg("failed to process event")
|
||||
}
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// wait for stop signal
|
||||
<-s.stopCh
|
||||
cancel() // signal workers to stop
|
||||
wg.Wait()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close will make the service to stop processing, so the `Run`
|
||||
// method can finish.
|
||||
func (s *ActivitylogService) Close() {
|
||||
if s.stopped.CompareAndSwap(false, true) {
|
||||
close(s.stopCh)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ActivitylogService) processEvent(e events.Event) error {
|
||||
ctx := e.GetTraceContext(s.ctx)
|
||||
ctx, span := tracer.Start(ctx, "processEvent")
|
||||
defer span.End()
|
||||
|
||||
s.log.Debug().Interface("event", e).Msg("updating activitylog")
|
||||
|
||||
switch ev := e.Event.(type) {
|
||||
case events.UploadReady:
|
||||
return s.AddActivity(ctx, ev.FileRef, ev.ParentID, e.ID, utils.TSToTime(ev.Timestamp))
|
||||
case events.FileTouched:
|
||||
return s.AddActivity(ctx, ev.Ref, ev.ParentID, e.ID, utils.TSToTime(ev.Timestamp))
|
||||
// Disabled https://github.com/owncloud/ocis/issues/10293
|
||||
//case events.FileDownloaded:
|
||||
// we are only interested in public link downloads - so no need to store others.
|
||||
//if ev.ImpersonatingUser.GetDisplayName() == "Public" {
|
||||
// err = a.AddActivity(ev.Ref, e.ID, utils.TSToTime(ev.Timestamp))
|
||||
//}
|
||||
case events.ContainerCreated:
|
||||
return s.AddActivity(ctx, ev.Ref, ev.ParentID, e.ID, utils.TSToTime(ev.Timestamp))
|
||||
case events.ItemTrashed:
|
||||
return s.AddActivityTrashed(ctx, ev.ID, ev.Ref, nil, e.ID, utils.TSToTime(ev.Timestamp))
|
||||
case events.ItemPurged:
|
||||
return s.al.RemoveResource(ev.ID)
|
||||
case events.ItemMoved:
|
||||
// remove the cached parent id for this resource
|
||||
s.removeCachedParentID(ctx, ev.Ref)
|
||||
|
||||
return s.AddActivity(ctx, ev.Ref, nil, e.ID, utils.TSToTime(ev.Timestamp))
|
||||
case events.ShareCreated:
|
||||
return s.AddActivity(ctx, toRef(ev.ItemID), nil, e.ID, utils.TSToTime(ev.CTime))
|
||||
case events.ShareUpdated:
|
||||
if ev.Sharer != nil && ev.ItemID != nil && ev.Sharer.GetOpaqueId() != ev.ItemID.GetSpaceId() {
|
||||
return s.AddActivity(ctx, toRef(ev.ItemID), nil, e.ID, utils.TSToTime(ev.MTime))
|
||||
}
|
||||
case events.ShareRemoved:
|
||||
return s.AddActivity(ctx, toRef(ev.ItemID), nil, e.ID, ev.Timestamp)
|
||||
case events.LinkCreated:
|
||||
return s.AddActivity(ctx, toRef(ev.ItemID), nil, e.ID, utils.TSToTime(ev.CTime))
|
||||
case events.LinkUpdated:
|
||||
if ev.Sharer != nil && ev.ItemID != nil && ev.Sharer.GetOpaqueId() != ev.ItemID.GetSpaceId() {
|
||||
return s.AddActivity(ctx, toRef(ev.ItemID), nil, e.ID, utils.TSToTime(ev.MTime))
|
||||
}
|
||||
case events.LinkRemoved:
|
||||
return s.AddActivity(ctx, toRef(ev.ItemID), nil, e.ID, utils.TSToTime(ev.Timestamp))
|
||||
case events.SpaceShared:
|
||||
return s.AddSpaceActivity(ctx, ev.ID, e.ID, ev.Timestamp)
|
||||
case events.SpaceUnshared:
|
||||
return s.AddSpaceActivity(ctx, ev.ID, e.ID, ev.Timestamp)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddActivity adds the activity to the given resource and all its parents
|
||||
func (a *ActivitylogService) AddActivity(ctx context.Context, initRef *provider.Reference, parentId *provider.ResourceId, eventID string, timestamp time.Time) error {
|
||||
ctx, span := tracer.Start(ctx, "AddActivity")
|
||||
defer span.End()
|
||||
|
||||
gwc, err := a.gws.Next()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cant get gateway client: %w", err)
|
||||
}
|
||||
|
||||
ctx, err = utils.GetServiceUserContextWithContext(ctx, gwc, a.sa.ServiceAccountID, a.sa.ServiceAccountSecret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cant get service user context: %w", err)
|
||||
|
||||
}
|
||||
return a.al.AddActivity(ctx, initRef, parentId, eventID, timestamp, func(ctx context.Context, ref *provider.Reference) (*provider.ResourceInfo, error) {
|
||||
return utils.GetResource(ctx, ref, gwc)
|
||||
})
|
||||
}
|
||||
|
||||
// AddActivityTrashed adds the activity to given trashed resource and all its former parents
|
||||
func (a *ActivitylogService) AddActivityTrashed(ctx context.Context, resourceID *provider.ResourceId, reference *provider.Reference, parentId *provider.ResourceId, eventID string, timestamp time.Time) error {
|
||||
ctx, span := tracer.Start(ctx, "AddActivityTrashed")
|
||||
defer span.End()
|
||||
|
||||
gwc, err := a.gws.Next()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cant get gateway client: %w", err)
|
||||
}
|
||||
|
||||
ctx, err = utils.GetServiceUserContextWithContext(ctx, gwc, a.sa.ServiceAccountID, a.sa.ServiceAccountSecret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cant get service user context: %w", err)
|
||||
}
|
||||
|
||||
// store activity on trashed item
|
||||
if err := a.al.StoreActivity(storagespace.FormatResourceID(resourceID), []activitylog.RawActivity{
|
||||
{
|
||||
EventID: eventID,
|
||||
Depth: 0,
|
||||
Timestamp: timestamp,
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("could not store activity: %w", err)
|
||||
}
|
||||
|
||||
// get previous parent
|
||||
ref := &provider.Reference{
|
||||
ResourceId: reference.GetResourceId(),
|
||||
Path: filepath.Dir(reference.GetPath()),
|
||||
}
|
||||
|
||||
return a.al.AddActivity(ctx, ref, parentId, eventID, timestamp, func(ctx context.Context, ref *provider.Reference) (*provider.ResourceInfo, error) {
|
||||
return utils.GetResource(ctx, ref, gwc)
|
||||
})
|
||||
}
|
||||
|
||||
// AddSpaceActivity adds the activity to the given spaceroot
|
||||
func (a *ActivitylogService) AddSpaceActivity(ctx context.Context, spaceID *provider.StorageSpaceId, eventID string, timestamp time.Time) error {
|
||||
_, span := tracer.Start(ctx, "AddSpaceActivity")
|
||||
defer span.End()
|
||||
// spaceID is in format <providerid>$<spaceid>
|
||||
// activitylog service uses format <providerid>$<spaceid>!<resourceid>
|
||||
// lets do some converting, shall we?
|
||||
rid, err := storagespace.ParseID(spaceID.GetOpaqueId())
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not parse space id: %w", err)
|
||||
}
|
||||
rid.OpaqueId = rid.GetSpaceId()
|
||||
err = a.al.StoreActivity(storagespace.FormatResourceID(&rid), []activitylog.RawActivity{
|
||||
{
|
||||
EventID: eventID,
|
||||
Depth: 0,
|
||||
Timestamp: timestamp,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not store activity: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func toRef(r *provider.ResourceId) *provider.Reference {
|
||||
return &provider.Reference{
|
||||
ResourceId: r,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ActivitylogService) removeCachedParentID(ctx context.Context, ref *provider.Reference) {
|
||||
var span trace.Span
|
||||
ctx, span = tracer.Start(ctx, "removeCachedParentID")
|
||||
defer span.End()
|
||||
|
||||
purgeId := ref.GetResourceId()
|
||||
if ref.GetPath() != "" {
|
||||
gwc, err := a.gws.Next()
|
||||
if err != nil {
|
||||
a.log.Error().Err(err).Msg("could not get gateway client")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, err = utils.GetServiceUserContextWithContext(ctx, gwc, a.sa.ServiceAccountID, a.sa.ServiceAccountSecret)
|
||||
if err != nil {
|
||||
a.log.Error().Err(err).Msg("could not get service user context")
|
||||
return
|
||||
}
|
||||
|
||||
info, err := utils.GetResource(ctx, ref, gwc)
|
||||
if err != nil {
|
||||
a.log.Error().Err(err).Msg("could not get resource info")
|
||||
return
|
||||
}
|
||||
purgeId = info.GetId()
|
||||
}
|
||||
a.al.InvalidateCachedParentID(purgeId)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package events_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/services/activitylog/pkg/config"
|
||||
"github.com/opencloud-eu/opencloud/services/activitylog/pkg/service/activitylog"
|
||||
eventssvc "github.com/opencloud-eu/opencloud/services/activitylog/pkg/service/events"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
)
|
||||
|
||||
var _ = Describe("ActivitylogService", func() {
|
||||
Describe("New", func() {
|
||||
var (
|
||||
al *activitylog.ActivityLog
|
||||
stream events.Stream
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
al = &activitylog.ActivityLog{}
|
||||
stream = nil
|
||||
})
|
||||
|
||||
It("creates a service with minimal options", func() {
|
||||
svc, err := eventssvc.New(al, stream)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(svc).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("creates a service with context option", func() {
|
||||
ctx := context.Background()
|
||||
svc, err := eventssvc.New(al, stream, eventssvc.Context(ctx))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(svc).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("creates a service with logger option", func() {
|
||||
logger := log.NopLogger()
|
||||
svc, err := eventssvc.New(al, stream, eventssvc.Logger(logger))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(svc).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("creates a service with service account option", func() {
|
||||
sa := config.ServiceAccount{
|
||||
ServiceAccountID: "sa-id",
|
||||
ServiceAccountSecret: "sa-secret",
|
||||
}
|
||||
svc, err := eventssvc.New(al, stream, eventssvc.ServiceAccount(sa))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(svc).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("creates a service with registered events", func() {
|
||||
evts := []events.Unmarshaller{&events.UploadReady{}, &events.FileTouched{}}
|
||||
svc, err := eventssvc.New(al, stream, eventssvc.RegisteredEvents(evts))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(svc).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("creates a service with gateway selector", func() {
|
||||
var gs pool.Selectable[gateway.GatewayAPIClient]
|
||||
svc, err := eventssvc.New(al, stream, eventssvc.GatewaySelector(gs))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(svc).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("creates a service with num consumers option", func() {
|
||||
svc, err := eventssvc.New(al, stream, eventssvc.NumConsumers(5))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(svc).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("creates a service with all options", func() {
|
||||
ctx := context.Background()
|
||||
logger := log.NopLogger()
|
||||
sa := config.ServiceAccount{
|
||||
ServiceAccountID: "sa-id",
|
||||
ServiceAccountSecret: "sa-secret",
|
||||
}
|
||||
evts := []events.Unmarshaller{&events.UploadReady{}, &events.ContainerCreated{}}
|
||||
var gs pool.Selectable[gateway.GatewayAPIClient]
|
||||
|
||||
svc, err := eventssvc.New(
|
||||
al,
|
||||
stream,
|
||||
eventssvc.Context(ctx),
|
||||
eventssvc.Logger(logger),
|
||||
eventssvc.ServiceAccount(sa),
|
||||
eventssvc.RegisteredEvents(evts),
|
||||
eventssvc.GatewaySelector(gs),
|
||||
eventssvc.NumConsumers(3),
|
||||
)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(svc).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Close", func() {
|
||||
It("can be called without panic on a new service", func() {
|
||||
al := &activitylog.ActivityLog{}
|
||||
svc, err := eventssvc.New(al, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(func() { svc.Close() }).ToNot(Panic())
|
||||
})
|
||||
|
||||
It("can be called multiple times without panic", func() {
|
||||
al := &activitylog.ActivityLog{}
|
||||
svc, err := eventssvc.New(al, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
svc.Close()
|
||||
svc.Close()
|
||||
svc.Close()
|
||||
})
|
||||
})
|
||||
})
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
package thumbnail_test
|
||||
package http_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestThumbnail(t *testing.T) {
|
||||
func TestHTTP(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Thumbnail Suite")
|
||||
RunSpecs(t, "HTTP Suite")
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
ehsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/eventhistory/v0"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
)
|
||||
|
||||
// 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
|
||||
RegisteredEvents []events.Unmarshaller
|
||||
GatewaySelector pool.Selectable[gateway.GatewayAPIClient]
|
||||
HistoryClient ehsvc.EventHistoryService
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// RegisteredEvents registers the events the service should listen to
|
||||
func RegisteredEvents(e []events.Unmarshaller) Option {
|
||||
return func(o *Options) {
|
||||
o.RegisteredEvents = e
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// HistoryClient adds a grpc client for the eventhistory service
|
||||
func HistoryClient(hc ehsvc.EventHistoryService) Option {
|
||||
return func(o *Options) {
|
||||
o.HistoryClient = hc
|
||||
}
|
||||
}
|
||||
+8
-7
@@ -1,4 +1,4 @@
|
||||
package service
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -44,11 +44,6 @@ var (
|
||||
StrDescription = l10n.Template("description")
|
||||
)
|
||||
|
||||
// GetActivitiesResponse is the response on GET activities requests
|
||||
type GetActivitiesResponse struct {
|
||||
Activities []libregraph.Activity `json:"value"`
|
||||
}
|
||||
|
||||
// Resource represents an item such as a file or folder
|
||||
type Resource struct {
|
||||
ID string `json:"id"`
|
||||
@@ -311,7 +306,7 @@ func NewActivity(message string, ts time.Time, eventID string, vars map[string]a
|
||||
}
|
||||
|
||||
// GetVars calls other service to gather the required data for the activity variables
|
||||
func (s *ActivitylogService) GetVars(ctx context.Context, opts ...ActivityOption) (map[string]any, error) {
|
||||
func (s *svc) GetVars(ctx context.Context, opts ...ActivityOption) (map[string]any, error) {
|
||||
gwc, err := s.gws.Next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -327,6 +322,12 @@ func (s *ActivitylogService) GetVars(ctx context.Context, opts ...ActivityOption
|
||||
return vars, nil
|
||||
}
|
||||
|
||||
func toSpace(r *provider.Reference) *provider.StorageSpaceId {
|
||||
return &provider.StorageSpaceId{
|
||||
OpaqueId: storagespace.FormatStorageID(r.GetResourceId().GetStorageId(), r.GetResourceId().GetSpaceId()),
|
||||
}
|
||||
}
|
||||
|
||||
func getFolderName(ctx context.Context, gwc gateway.GatewayAPIClient, ref *provider.Reference) string {
|
||||
n := filepath.Base(filepath.Dir(ref.GetPath()))
|
||||
if n == "." || n == "/" {
|
||||
@@ -0,0 +1,150 @@
|
||||
package http_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/opencloud-eu/opencloud/pkg/l10n"
|
||||
httpsvc "github.com/opencloud-eu/opencloud/services/activitylog/pkg/service/http"
|
||||
)
|
||||
|
||||
var _ = Describe("Response", func() {
|
||||
Describe("NewActivity", func() {
|
||||
It("creates an activity with the given parameters", func() {
|
||||
ts := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)
|
||||
vars := map[string]any{
|
||||
"user": "testuser",
|
||||
"resource": "testfile.txt",
|
||||
}
|
||||
|
||||
act := httpsvc.NewActivity("Test message", ts, "event-123", vars)
|
||||
|
||||
Expect(act.Id).To(Equal("event-123"))
|
||||
Expect(act.Times.RecordedTime).To(Equal(ts))
|
||||
Expect(act.Template.Message).To(Equal("Test message"))
|
||||
Expect(act.Template.Variables).To(HaveKeyWithValue("user", "testuser"))
|
||||
Expect(act.Template.Variables).To(HaveKeyWithValue("resource", "testfile.txt"))
|
||||
})
|
||||
|
||||
It("handles empty variables map", func() {
|
||||
act := httpsvc.NewActivity("", time.Time{}, "", map[string]any{})
|
||||
|
||||
Expect(act.Id).To(BeEmpty())
|
||||
Expect(act.Times.RecordedTime).To(Equal(time.Time{}))
|
||||
Expect(act.Template.Message).To(BeEmpty())
|
||||
Expect(act.Template.Variables).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("WithOldResource", func() {
|
||||
It("sets the oldResource variable from reference path", func() {
|
||||
ref := &provider.Reference{
|
||||
Path: "/old/path/oldname.txt",
|
||||
}
|
||||
vars := make(map[string]any)
|
||||
|
||||
opt := httpsvc.WithOldResource(ref)
|
||||
err := opt(context.Background(), nil, vars)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(vars).To(HaveKey("oldResource"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("WithUser", func() {
|
||||
It("returns error when no user is provided", func() {
|
||||
opt := httpsvc.WithUser(nil, nil, nil)
|
||||
err := opt(context.Background(), nil, make(map[string]any))
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("no user provided"))
|
||||
})
|
||||
|
||||
It("uses impersonator when provided", func() {
|
||||
impersonator := &user.User{
|
||||
Id: &user.UserId{
|
||||
OpaqueId: "imp-user-id",
|
||||
},
|
||||
DisplayName: "Impersonated User",
|
||||
}
|
||||
vars := make(map[string]any)
|
||||
|
||||
opt := httpsvc.WithUser(nil, nil, impersonator)
|
||||
err := opt(context.Background(), nil, vars)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(vars).To(HaveKey("user"))
|
||||
})
|
||||
|
||||
It("uses executing user when no impersonator", func() {
|
||||
execUser := &user.User{
|
||||
Id: &user.UserId{
|
||||
OpaqueId: "exec-user-id",
|
||||
},
|
||||
DisplayName: "Executing User",
|
||||
}
|
||||
vars := make(map[string]any)
|
||||
|
||||
opt := httpsvc.WithUser(nil, execUser, nil)
|
||||
err := opt(context.Background(), nil, vars)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(vars).To(HaveKey("user"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("WithVar", func() {
|
||||
It("sets a simple key-value variable", func() {
|
||||
vars := make(map[string]any)
|
||||
|
||||
opt := httpsvc.WithVar("token", "id123", "My Token")
|
||||
err := opt(context.Background(), nil, vars)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(vars).To(HaveKey("token"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("WithTranslation", func() {
|
||||
It("sets translated field variable", func() {
|
||||
var t l10n.Translator
|
||||
vars := make(map[string]any)
|
||||
|
||||
opt := httpsvc.WithTranslation(&t, "en", "field", []string{"permission"})
|
||||
err := opt(context.Background(), nil, vars)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(vars).To(HaveKey("field"))
|
||||
})
|
||||
|
||||
It("handles empty values slice", func() {
|
||||
var t l10n.Translator
|
||||
vars := make(map[string]any)
|
||||
|
||||
opt := httpsvc.WithTranslation(&t, "en", "field", []string{})
|
||||
err := opt(context.Background(), nil, vars)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(vars).To(HaveKey("field"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ActivityOption type", func() {
|
||||
It("allows composing multiple options", func() {
|
||||
vars := make(map[string]any)
|
||||
ctx := context.Background()
|
||||
var gwc gateway.GatewayAPIClient
|
||||
|
||||
options := []httpsvc.ActivityOption{
|
||||
httpsvc.WithVar("key1", "id1", "name1"),
|
||||
httpsvc.WithVar("key2", "id2", "name2"),
|
||||
}
|
||||
|
||||
for _, opt := range options {
|
||||
err := opt(ctx, gwc, vars)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
|
||||
Expect(vars).To(HaveKey("key1"))
|
||||
Expect(vars).To(HaveKey("key2"))
|
||||
})
|
||||
})
|
||||
})
|
||||
+75
-76
@@ -1,89 +1,97 @@
|
||||
package service
|
||||
package http
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"context"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/utils"
|
||||
"google.golang.org/grpc/metadata"
|
||||
|
||||
"github.com/olekukonko/errors"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
"github.com/opencloud-eu/opencloud/pkg/ast"
|
||||
"github.com/opencloud-eu/opencloud/pkg/kql"
|
||||
"github.com/opencloud-eu/opencloud/pkg/l10n"
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
ehmsg "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/messages/eventhistory/v0"
|
||||
ehsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/eventhistory/v0"
|
||||
"github.com/opencloud-eu/opencloud/services/activitylog/pkg/service/activitylog"
|
||||
"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.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed l10n/locale
|
||||
_localeFS embed.FS
|
||||
var tracer trace.Tracer
|
||||
|
||||
// subfolder where the translation files are stored
|
||||
_localeSubPath = "l10n/locale"
|
||||
|
||||
// domain of the activitylog service (transifex)
|
||||
_domain = "activitylog"
|
||||
)
|
||||
|
||||
// ServeHTTP implements the http.Handler interface.
|
||||
func (s *ActivitylogService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.mux.ServeHTTP(w, r)
|
||||
func init() {
|
||||
tracer = otel.Tracer("github.com/opencloud-eu/opencloud/services/activitylog/pkg/service/http")
|
||||
}
|
||||
|
||||
// HandleGetItemActivities handles the request to get the activities of an item.
|
||||
func (s *ActivitylogService) HandleGetItemActivities(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, revactx.TokenHeader, r.Header.Get(revactx.TokenHeader))
|
||||
// New returns a new instance of Service
|
||||
func New(al *activitylog.ActivityLog, opts ...Option) (*svc, error) {
|
||||
o := newOptions(opts...)
|
||||
|
||||
activeUser, ok := revactx.ContextGetUser(ctx)
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
registeredEvents := make(map[string]events.Unmarshaller)
|
||||
for _, e := range o.RegisteredEvents {
|
||||
typ := reflect.TypeOf(e)
|
||||
registeredEvents[typ.String()] = e
|
||||
}
|
||||
|
||||
return &svc{
|
||||
log: o.Logger,
|
||||
evHistory: o.HistoryClient,
|
||||
al: al,
|
||||
registeredEvents: registeredEvents,
|
||||
gws: o.GatewaySelector,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type svc struct {
|
||||
log log.Logger
|
||||
evHistory ehsvc.EventHistoryService
|
||||
gws pool.Selectable[gateway.GatewayAPIClient]
|
||||
al *activitylog.ActivityLog
|
||||
registeredEvents map[string]events.Unmarshaller
|
||||
}
|
||||
|
||||
var (
|
||||
ErrBadRequest = errors.New("bad request")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
)
|
||||
|
||||
func (s *svc) GetItemActivities(ctx context.Context, query, loc string, t l10n.Translator) ([]libregraph.Activity, error) {
|
||||
gwc, err := s.gws.Next()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rid, limit, rawActivityAccepted, activityAccepted, sort, err := s.getFilters(r.URL.Query().Get("kql"))
|
||||
rid, limit, rawActivityAccepted, activityAccepted, sort, err := s.getFilters(query)
|
||||
if err != nil {
|
||||
s.log.Info().Str("query", r.URL.Query().Get("kql")).Err(err).Msg("error getting filters")
|
||||
_, _ = w.Write([]byte(err.Error()))
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
s.log.Info().Str("query", query).Err(err).Msg("error getting filters")
|
||||
return nil, ErrBadRequest
|
||||
}
|
||||
|
||||
info, err := utils.GetResourceByID(ctx, rid, gwc)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
|
||||
// you need ListGrants to see activities
|
||||
if !info.GetPermissionSet().GetListGrants() {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
|
||||
raw, err := s.Activities(rid)
|
||||
raw, err := s.al.Activities(rid)
|
||||
if err != nil {
|
||||
s.log.Error().Err(err).Msg("error getting activities")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(raw))
|
||||
@@ -96,21 +104,21 @@ func (s *ActivitylogService) HandleGetItemActivities(w http.ResponseWriter, r *h
|
||||
toDelete[a.EventID] = struct{}{}
|
||||
}
|
||||
|
||||
evRes, err := s.evHistory.GetEvents(r.Context(), &ehsvc.GetEventsRequest{Ids: ids})
|
||||
evRes, err := s.evHistory.GetEvents(ctx, &ehsvc.GetEventsRequest{Ids: ids})
|
||||
if err != nil {
|
||||
s.log.Error().Err(err).Msg("error getting events")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
evs := evRes.GetEvents()
|
||||
sort(evs)
|
||||
|
||||
resp := GetActivitiesResponse{Activities: make([]libregraph.Activity, 0, len(evRes.GetEvents()))}
|
||||
// TODO cut the interface here?
|
||||
activities := make([]libregraph.Activity, 0, len(evRes.GetEvents()))
|
||||
for _, e := range evs {
|
||||
delete(toDelete, e.GetId())
|
||||
|
||||
if limit > 0 && limit <= len(resp.Activities) {
|
||||
if limit > 0 && limit <= len(activities) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -124,9 +132,6 @@ func (s *ActivitylogService) HandleGetItemActivities(w http.ResponseWriter, r *h
|
||||
vars map[string]any
|
||||
)
|
||||
|
||||
loc := l10n.MustGetUserLocale(r.Context(), activeUser.GetId().GetOpaqueId(), r.Header.Get(l10n.HeaderAcceptLanguage), s.valService)
|
||||
t := l10n.NewTranslatorFromCommonConfig(s.cfg.DefaultLanguage, _domain, s.cfg.TranslationPath, _localeFS, _localeSubPath)
|
||||
|
||||
switch ev := s.unwrapEvent(e).(type) {
|
||||
case nil:
|
||||
// error already logged in unwrapEvent
|
||||
@@ -224,35 +229,29 @@ func (s *ActivitylogService) HandleGetItemActivities(w http.ResponseWriter, r *h
|
||||
continue
|
||||
}
|
||||
|
||||
resp.Activities = append(resp.Activities, NewActivity(t.Translate(message, loc), ts, e.GetId(), vars))
|
||||
activities = append(activities, NewActivity(t.Translate(message, loc), ts, e.GetId(), vars))
|
||||
}
|
||||
|
||||
// delete activities in separate go routine
|
||||
if len(toDelete) > 0 {
|
||||
go func() {
|
||||
err := s.RemoveActivities(rid, toDelete)
|
||||
err := s.al.RemoveActivities(rid, toDelete)
|
||||
if err != nil {
|
||||
s.log.Error().Err(err).Msg("error removing activities")
|
||||
}
|
||||
}()
|
||||
}
|
||||
return activities, nil
|
||||
|
||||
b, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
s.log.Error().Err(err).Msg("error marshalling activities")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := w.Write(b); err != nil {
|
||||
s.log.Error().Err(err).Msg("error writing response")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (s *ActivitylogService) unwrapEvent(e *ehmsg.Event) any {
|
||||
func toRef(r *provider.ResourceId) *provider.Reference {
|
||||
return &provider.Reference{
|
||||
ResourceId: r,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *svc) unwrapEvent(e *ehmsg.Event) any {
|
||||
etype, ok := s.registeredEvents[e.GetType()]
|
||||
if !ok {
|
||||
s.log.Error().Str("eventid", e.GetId()).Str("eventtype", e.GetType()).Msg("event not registered")
|
||||
@@ -268,13 +267,13 @@ func (s *ActivitylogService) unwrapEvent(e *ehmsg.Event) any {
|
||||
return einterface
|
||||
}
|
||||
|
||||
func (s *ActivitylogService) getFilters(query string) (*provider.ResourceId, int, func(RawActivity) bool, func(*ehmsg.Event) bool, func([]*ehmsg.Event), error) {
|
||||
func (s *svc) getFilters(query string) (*provider.ResourceId, int, func(activitylog.RawActivity) bool, func(*ehmsg.Event) bool, func([]*ehmsg.Event), error) {
|
||||
qast, err := kql.Builder{}.Build(query)
|
||||
if err != nil {
|
||||
return nil, 0, nil, nil, nil, err
|
||||
}
|
||||
|
||||
prefilters := make([]func(RawActivity) bool, 0)
|
||||
prefilters := make([]func(activitylog.RawActivity) bool, 0)
|
||||
postfilters := make([]func(*ehmsg.Event) bool, 0)
|
||||
|
||||
sortby := func(_ []*ehmsg.Event) {}
|
||||
@@ -299,7 +298,7 @@ func (s *ActivitylogService) getFilters(query string) (*provider.ResourceId, int
|
||||
break
|
||||
}
|
||||
|
||||
prefilters = append(prefilters, func(a RawActivity) bool {
|
||||
prefilters = append(prefilters, func(a activitylog.RawActivity) bool {
|
||||
return a.Depth <= depth
|
||||
})
|
||||
case "limit":
|
||||
@@ -322,11 +321,11 @@ func (s *ActivitylogService) getFilters(query string) (*provider.ResourceId, int
|
||||
case *ast.DateTimeNode:
|
||||
switch v.Operator.Value {
|
||||
case "<", "<=":
|
||||
prefilters = append(prefilters, func(a RawActivity) bool {
|
||||
prefilters = append(prefilters, func(a activitylog.RawActivity) bool {
|
||||
return a.Timestamp.Before(v.Value)
|
||||
})
|
||||
case ">", ">=":
|
||||
prefilters = append(prefilters, func(a RawActivity) bool {
|
||||
prefilters = append(prefilters, func(a activitylog.RawActivity) bool {
|
||||
return a.Timestamp.After(v.Value)
|
||||
})
|
||||
}
|
||||
@@ -345,7 +344,7 @@ func (s *ActivitylogService) getFilters(query string) (*provider.ResourceId, int
|
||||
// space root requested - fix format
|
||||
rid.OpaqueId = rid.GetSpaceId()
|
||||
}
|
||||
pref := func(a RawActivity) bool {
|
||||
pref := func(a activitylog.RawActivity) bool {
|
||||
for _, f := range prefilters {
|
||||
if !f(a) {
|
||||
return false
|
||||
@@ -0,0 +1,76 @@
|
||||
package http_test
|
||||
|
||||
import (
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
ehsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/eventhistory/v0"
|
||||
"github.com/opencloud-eu/opencloud/services/activitylog/pkg/service/activitylog"
|
||||
httpsvc "github.com/opencloud-eu/opencloud/services/activitylog/pkg/service/http"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/events"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
|
||||
)
|
||||
|
||||
var _ = Describe("Service", func() {
|
||||
Describe("New", func() {
|
||||
var (
|
||||
al *activitylog.ActivityLog
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
al = &activitylog.ActivityLog{}
|
||||
})
|
||||
|
||||
It("creates a service with default options", func() {
|
||||
svc, err := httpsvc.New(al)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(svc).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("creates a service with logger option", func() {
|
||||
logger := log.NopLogger()
|
||||
svc, err := httpsvc.New(al, httpsvc.Logger(logger))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(svc).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("creates a service with registered events", func() {
|
||||
evts := []events.Unmarshaller{&events.UploadReady{}}
|
||||
svc, err := httpsvc.New(al, httpsvc.RegisteredEvents(evts))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(svc).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("creates a service with gateway selector", func() {
|
||||
var gs pool.Selectable[gateway.GatewayAPIClient]
|
||||
svc, err := httpsvc.New(al, httpsvc.GatewaySelector(gs))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(svc).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("creates a service with history client", func() {
|
||||
var hc ehsvc.EventHistoryService
|
||||
svc, err := httpsvc.New(al, httpsvc.HistoryClient(hc))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(svc).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("creates a service with all options", func() {
|
||||
logger := log.NopLogger()
|
||||
evts := []events.Unmarshaller{&events.UploadReady{}, &events.FileTouched{}}
|
||||
var gs pool.Selectable[gateway.GatewayAPIClient]
|
||||
var hc ehsvc.EventHistoryService
|
||||
|
||||
svc, err := httpsvc.New(
|
||||
al,
|
||||
httpsvc.Logger(logger),
|
||||
httpsvc.RegisteredEvents(evts),
|
||||
httpsvc.GatewaySelector(gs),
|
||||
httpsvc.HistoryClient(hc),
|
||||
)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(svc).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
# 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:
|
||||
# Roy-Magne Mo, 2026
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: EMAIL\n"
|
||||
"POT-Creation-Date: 2026-09-06 23:15+0000\n"
|
||||
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
|
||||
"Last-Translator: Roy-Magne Mo, 2026\n"
|
||||
"Language-Team: Norwegian (https://app.transifex.com/opencloud-eu/teams/204053/no/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: no\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#: pkg/service/response.go:44
|
||||
msgid "description"
|
||||
msgstr "beskrivelse"
|
||||
|
||||
#: pkg/service/response.go:43
|
||||
msgid "display name"
|
||||
msgstr "visningsnavn"
|
||||
|
||||
#: pkg/service/response.go:42
|
||||
msgid "expiration date"
|
||||
msgstr "utløpsdato"
|
||||
|
||||
#: pkg/service/response.go:41
|
||||
msgid "password"
|
||||
msgstr "passord"
|
||||
|
||||
#: pkg/service/response.go:40
|
||||
msgid "permission"
|
||||
msgstr "tillatelse"
|
||||
|
||||
#: pkg/service/response.go:39
|
||||
msgid "some field"
|
||||
msgstr "et felt"
|
||||
|
||||
#: pkg/service/response.go:26
|
||||
msgid "{resource} was downloaded via public link {token}"
|
||||
msgstr "{resource} ble lastet ned via offentlig lenke {token}"
|
||||
|
||||
#: pkg/service/response.go:24
|
||||
msgid "{user} added {resource} to {folder}"
|
||||
msgstr "{user} la til {resource} i {folder}"
|
||||
|
||||
#: pkg/service/response.go:36
|
||||
msgid "{user} added {sharee} as member of {space}"
|
||||
msgstr "{user} la til {sharee} som medlem av {space}"
|
||||
|
||||
#: pkg/service/response.go:27
|
||||
msgid "{user} deleted {resource} from {folder}"
|
||||
msgstr "{user} slettet {resource} fra {folder}"
|
||||
|
||||
#: pkg/service/response.go:28
|
||||
msgid "{user} moved {resource} to {folder}"
|
||||
msgstr "{user} flyttet {resource} til {folder}"
|
||||
|
||||
#: pkg/service/response.go:35
|
||||
msgid "{user} removed link to {resource}"
|
||||
msgstr "{user} fjernet lenke til {resource}"
|
||||
|
||||
#: pkg/service/response.go:32
|
||||
msgid "{user} removed {sharee} from {resource}"
|
||||
msgstr "{user} fjernet {sharee} fra {resource}"
|
||||
|
||||
#: pkg/service/response.go:37
|
||||
msgid "{user} removed {sharee} from {space}"
|
||||
msgstr "{user} fjernet {sharee} fra {space}"
|
||||
|
||||
#: pkg/service/response.go:29
|
||||
msgid "{user} renamed {oldResource} to {resource}"
|
||||
msgstr "{user} ga nytt navn til {oldResource} som {resource}"
|
||||
|
||||
#: pkg/service/response.go:33
|
||||
msgid "{user} shared {resource} via link"
|
||||
msgstr "{user} delte {resource} via lenke"
|
||||
|
||||
#: pkg/service/response.go:30
|
||||
msgid "{user} shared {resource} with {sharee}"
|
||||
msgstr "{user} delte {resource} med {sharee}"
|
||||
|
||||
#: pkg/service/response.go:34
|
||||
msgid "{user} updated {field} for a link {token} on {resource}"
|
||||
msgstr "{user} oppdaterte {field} for en lenke {token} på {resource}"
|
||||
|
||||
#: pkg/service/response.go:31
|
||||
msgid "{user} updated {field} for the {resource}"
|
||||
msgstr "{user} oppdaterte {field} for {resource}"
|
||||
|
||||
#: pkg/service/response.go:25
|
||||
msgid "{user} updated {resource} in {folder}"
|
||||
msgstr "{user} oppdaterte {resource} i {folder}"
|
||||
@@ -1,676 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base32"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jellydator/ttlcache/v2"
|
||||
"github.com/nats-io/nats.go"
|
||||
"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"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/vmihailenco/msgpack/v5"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
ehsvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/eventhistory/v0"
|
||||
settingssvc "github.com/opencloud-eu/opencloud/protogen/gen/opencloud/services/settings/v0"
|
||||
"github.com/opencloud-eu/opencloud/services/activitylog/pkg/config"
|
||||
)
|
||||
|
||||
// Nats runs into max payload exceeded errors at around 7k activities. Let's keep a buffer.
|
||||
var _maxActivitiesDefault = 6000
|
||||
|
||||
// RawActivity represents an activity as it is stored in the activitylog store
|
||||
type RawActivity struct {
|
||||
EventID string `json:"event_id"`
|
||||
Depth int `json:"depth"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// ActivitylogService logs events per resource
|
||||
type ActivitylogService struct {
|
||||
cfg *config.Config
|
||||
log log.Logger
|
||||
events <-chan events.Event
|
||||
gws pool.Selectable[gateway.GatewayAPIClient]
|
||||
mux *chi.Mux
|
||||
evHistory ehsvc.EventHistoryService
|
||||
valService settingssvc.ValueService
|
||||
lock sync.RWMutex
|
||||
tp trace.TracerProvider
|
||||
tracer trace.Tracer
|
||||
debouncer *Debouncer
|
||||
parentIdCache *ttlcache.Cache
|
||||
natskv nats.KeyValue
|
||||
|
||||
maxActivities int
|
||||
|
||||
registeredEvents map[string]events.Unmarshaller
|
||||
}
|
||||
|
||||
type Debouncer struct {
|
||||
after time.Duration
|
||||
f func(id string, ra []RawActivity) error
|
||||
pending sync.Map
|
||||
inProgress sync.Map
|
||||
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
type queueItem struct {
|
||||
activities []RawActivity
|
||||
timer *time.Timer
|
||||
}
|
||||
|
||||
type batchInfo struct {
|
||||
key string
|
||||
count int
|
||||
timestamp time.Time
|
||||
}
|
||||
|
||||
// NewDebouncer returns a new Debouncer instance
|
||||
func NewDebouncer(d time.Duration, f func(id string, ra []RawActivity) error) *Debouncer {
|
||||
return &Debouncer{
|
||||
after: d,
|
||||
f: f,
|
||||
pending: sync.Map{},
|
||||
inProgress: sync.Map{},
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce restarts the debounce timer for the given space
|
||||
func (d *Debouncer) Debounce(id string, ra RawActivity) {
|
||||
if d.after == 0 {
|
||||
d.f(id, []RawActivity{ra})
|
||||
return
|
||||
}
|
||||
|
||||
d.mutex.Lock()
|
||||
defer d.mutex.Unlock()
|
||||
|
||||
activities := []RawActivity{ra}
|
||||
item := &queueItem{
|
||||
activities: activities,
|
||||
}
|
||||
if i, ok := d.pending.Load(id); ok {
|
||||
// if the item is already in the queue, append the new activities
|
||||
item, ok = i.(*queueItem)
|
||||
if ok {
|
||||
item.activities = append(item.activities, ra)
|
||||
}
|
||||
}
|
||||
|
||||
if item.timer == nil {
|
||||
item.timer = time.AfterFunc(d.after, func() {
|
||||
if _, ok := d.inProgress.Load(id); ok {
|
||||
// Reschedule this run for when the previous run has finished
|
||||
d.mutex.Lock()
|
||||
if i, ok := d.pending.Load(id); ok {
|
||||
i.(*queueItem).timer.Reset(d.after)
|
||||
}
|
||||
|
||||
d.mutex.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
d.pending.Delete(id)
|
||||
d.inProgress.Store(id, true)
|
||||
defer d.inProgress.Delete(id)
|
||||
d.f(id, item.activities)
|
||||
})
|
||||
}
|
||||
|
||||
d.pending.Store(id, item)
|
||||
}
|
||||
|
||||
// New creates a new ActivitylogService
|
||||
func New(opts ...Option) (*ActivitylogService, error) {
|
||||
o := &Options{
|
||||
MaxActivities: _maxActivitiesDefault,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(o)
|
||||
}
|
||||
|
||||
if o.Stream == nil {
|
||||
return nil, errors.New("stream is required")
|
||||
}
|
||||
|
||||
ch, err := events.Consume(o.Stream, o.Config.Service.Name, o.RegisteredEvents...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cache := ttlcache.NewCache()
|
||||
err = cache.SetTTL(30 * time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Connect to NATS servers
|
||||
natsOptions := nats.Options{
|
||||
Servers: o.Config.Store.Nodes,
|
||||
}
|
||||
if o.Config.Store.EnableTLS {
|
||||
if o.Config.Store.TLSRootCACertificate != "" {
|
||||
// when root ca is configured use it. an insecure flag is ignored.
|
||||
nats.RootCAs(o.Config.Store.TLSRootCACertificate)(&natsOptions)
|
||||
} else {
|
||||
// enable tls and use insecure flag
|
||||
nats.Secure(&tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: o.Config.Store.TLSInsecure})(&natsOptions)
|
||||
}
|
||||
}
|
||||
if o.Config.Store.AuthUsername != "" && o.Config.Store.AuthPassword != "" {
|
||||
nats.UserInfo(o.Config.Store.AuthUsername, o.Config.Store.AuthPassword)(&natsOptions)
|
||||
}
|
||||
conn, err := natsOptions.Connect()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
js, err := conn.JetStream()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
kv, err := js.KeyValue(o.Config.Store.Database)
|
||||
if err != nil {
|
||||
if !errors.Is(err, nats.ErrBucketNotFound) {
|
||||
return nil, errors.Wrapf(err, "Failed to get bucket (%s)", o.Config.Store.Database)
|
||||
}
|
||||
|
||||
kv, err = js.CreateKeyValue(&nats.KeyValueConfig{
|
||||
Bucket: o.Config.Store.Database,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "Failed to create bucket (%s)", o.Config.Store.Database)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &ActivitylogService{
|
||||
log: o.Logger,
|
||||
cfg: o.Config,
|
||||
events: ch,
|
||||
gws: o.GatewaySelector,
|
||||
mux: o.Mux,
|
||||
evHistory: o.HistoryClient,
|
||||
valService: o.ValueClient,
|
||||
lock: sync.RWMutex{},
|
||||
registeredEvents: make(map[string]events.Unmarshaller),
|
||||
tp: o.TraceProvider,
|
||||
tracer: o.TraceProvider.Tracer("github.com/opencloud-eu/opencloud/services/activitylog/pkg/service"),
|
||||
parentIdCache: cache,
|
||||
maxActivities: o.Config.MaxActivities,
|
||||
natskv: kv,
|
||||
}
|
||||
s.debouncer = NewDebouncer(o.Config.WriteBufferDuration, s.storeActivity)
|
||||
|
||||
// run migrations
|
||||
err = s.runMigrations(context.Background(), kv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.mux.Get("/graph/v1beta1/extensions/org.libregraph/activities", s.HandleGetItemActivities)
|
||||
|
||||
for _, e := range o.RegisteredEvents {
|
||||
typ := reflect.TypeOf(e)
|
||||
s.registeredEvents[typ.String()] = e
|
||||
}
|
||||
|
||||
go s.Run()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Run runs the service
|
||||
func (a *ActivitylogService) Run() {
|
||||
for e := range a.events {
|
||||
var err error
|
||||
switch ev := e.Event.(type) {
|
||||
case events.UploadReady:
|
||||
err = a.AddActivity(ev.FileRef, ev.ParentID, e.ID, utils.TSToTime(ev.Timestamp))
|
||||
case events.FileTouched:
|
||||
err = a.AddActivity(ev.Ref, ev.ParentID, e.ID, utils.TSToTime(ev.Timestamp))
|
||||
// Disabled https://github.com/owncloud/ocis/issues/10293
|
||||
//case events.FileDownloaded:
|
||||
// we are only interested in public link downloads - so no need to store others.
|
||||
//if ev.ImpersonatingUser.GetDisplayName() == "Public" {
|
||||
// err = a.AddActivity(ev.Ref, e.ID, utils.TSToTime(ev.Timestamp))
|
||||
//}
|
||||
case events.ContainerCreated:
|
||||
err = a.AddActivity(ev.Ref, ev.ParentID, e.ID, utils.TSToTime(ev.Timestamp))
|
||||
case events.ItemTrashed:
|
||||
err = a.AddActivityTrashed(ev.ID, ev.Ref, nil, e.ID, utils.TSToTime(ev.Timestamp))
|
||||
case events.ItemPurged:
|
||||
err = a.RemoveResource(ev.ID)
|
||||
case events.ItemMoved:
|
||||
// remove the cached parent id for this resource
|
||||
a.removeCachedParentID(ev.Ref)
|
||||
|
||||
err = a.AddActivity(ev.Ref, nil, e.ID, utils.TSToTime(ev.Timestamp))
|
||||
case events.ShareCreated:
|
||||
err = a.AddActivity(toRef(ev.ItemID), nil, e.ID, utils.TSToTime(ev.CTime))
|
||||
case events.ShareUpdated:
|
||||
if ev.Sharer != nil && ev.ItemID != nil && ev.Sharer.GetOpaqueId() != ev.ItemID.GetSpaceId() {
|
||||
err = a.AddActivity(toRef(ev.ItemID), nil, e.ID, utils.TSToTime(ev.MTime))
|
||||
}
|
||||
case events.ShareRemoved:
|
||||
err = a.AddActivity(toRef(ev.ItemID), nil, e.ID, ev.Timestamp)
|
||||
case events.LinkCreated:
|
||||
err = a.AddActivity(toRef(ev.ItemID), nil, e.ID, utils.TSToTime(ev.CTime))
|
||||
case events.LinkUpdated:
|
||||
if ev.Sharer != nil && ev.ItemID != nil && ev.Sharer.GetOpaqueId() != ev.ItemID.GetSpaceId() {
|
||||
err = a.AddActivity(toRef(ev.ItemID), nil, e.ID, utils.TSToTime(ev.MTime))
|
||||
}
|
||||
case events.LinkRemoved:
|
||||
err = a.AddActivity(toRef(ev.ItemID), nil, e.ID, utils.TSToTime(ev.Timestamp))
|
||||
case events.SpaceShared:
|
||||
err = a.AddSpaceActivity(ev.ID, e.ID, ev.Timestamp)
|
||||
case events.SpaceUnshared:
|
||||
err = a.AddSpaceActivity(ev.ID, e.ID, ev.Timestamp)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
a.log.Error().Err(err).Interface("event", e).Msg("could not process event")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AddActivity adds the activity to the given resource and all its parents
|
||||
func (a *ActivitylogService) AddActivity(initRef *provider.Reference, parentId *provider.ResourceId, eventID string, timestamp time.Time) error {
|
||||
gwc, err := a.gws.Next()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cant get gateway client: %w", err)
|
||||
}
|
||||
|
||||
ctx, err := utils.GetServiceUserContext(a.cfg.ServiceAccount.ServiceAccountID, gwc, a.cfg.ServiceAccount.ServiceAccountSecret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cant get service user context: %w", err)
|
||||
}
|
||||
var span trace.Span
|
||||
ctx, span = a.tracer.Start(ctx, "AddActivity")
|
||||
defer span.End()
|
||||
|
||||
return a.addActivity(ctx, initRef, parentId, eventID, timestamp, func(ctx context.Context, ref *provider.Reference) (*provider.ResourceInfo, error) {
|
||||
return utils.GetResource(ctx, ref, gwc)
|
||||
})
|
||||
}
|
||||
|
||||
// AddActivityTrashed adds the activity to given trashed resource and all its former parents
|
||||
func (a *ActivitylogService) AddActivityTrashed(resourceID *provider.ResourceId, reference *provider.Reference, parentId *provider.ResourceId, eventID string, timestamp time.Time) error {
|
||||
gwc, err := a.gws.Next()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cant get gateway client: %w", err)
|
||||
}
|
||||
|
||||
ctx, err := utils.GetServiceUserContext(a.cfg.ServiceAccount.ServiceAccountID, gwc, a.cfg.ServiceAccount.ServiceAccountSecret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cant get service user context: %w", err)
|
||||
}
|
||||
|
||||
// store activity on trashed item
|
||||
if err := a.storeActivity(storagespace.FormatResourceID(resourceID), []RawActivity{
|
||||
{
|
||||
EventID: eventID,
|
||||
Depth: 0,
|
||||
Timestamp: timestamp,
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("could not store activity: %w", err)
|
||||
}
|
||||
|
||||
// get previous parent
|
||||
ref := &provider.Reference{
|
||||
ResourceId: reference.GetResourceId(),
|
||||
Path: filepath.Dir(reference.GetPath()),
|
||||
}
|
||||
|
||||
var span trace.Span
|
||||
ctx, span = a.tracer.Start(ctx, "AddActivityTrashed")
|
||||
defer span.End()
|
||||
|
||||
return a.addActivity(ctx, ref, parentId, eventID, timestamp, func(ctx context.Context, ref *provider.Reference) (*provider.ResourceInfo, error) {
|
||||
return utils.GetResource(ctx, ref, gwc)
|
||||
})
|
||||
}
|
||||
|
||||
// AddSpaceActivity adds the activity to the given spaceroot
|
||||
func (a *ActivitylogService) AddSpaceActivity(spaceID *provider.StorageSpaceId, eventID string, timestamp time.Time) error {
|
||||
// spaceID is in format <providerid>$<spaceid>
|
||||
// activitylog service uses format <providerid>$<spaceid>!<resourceid>
|
||||
// lets do some converting, shall we?
|
||||
rid, err := storagespace.ParseID(spaceID.GetOpaqueId())
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not parse space id: %w", err)
|
||||
}
|
||||
rid.OpaqueId = rid.GetSpaceId()
|
||||
return a.storeActivity(storagespace.FormatResourceID(&rid), []RawActivity{
|
||||
{
|
||||
EventID: eventID,
|
||||
Depth: 0,
|
||||
Timestamp: timestamp,
|
||||
},
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
// Activities returns the activities for the given resource
|
||||
func (a *ActivitylogService) Activities(rid *provider.ResourceId) ([]RawActivity, error) {
|
||||
a.lock.RLock()
|
||||
defer a.lock.RUnlock()
|
||||
|
||||
return a.activities(rid)
|
||||
}
|
||||
|
||||
// RemoveActivities removes the activities from the given resource
|
||||
func (a *ActivitylogService) RemoveActivities(rid *provider.ResourceId, toDelete map[string]struct{}) error {
|
||||
a.lock.Lock()
|
||||
defer a.lock.Unlock()
|
||||
|
||||
curActivities, err := a.activities(rid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var acts []RawActivity
|
||||
for _, a := range curActivities {
|
||||
if _, ok := toDelete[a.EventID]; !ok {
|
||||
acts = append(acts, a)
|
||||
}
|
||||
}
|
||||
|
||||
b, err := json.Marshal(acts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = a.natskv.Put(storagespace.FormatResourceID(rid), b)
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveResource removes the resource from the store
|
||||
func (a *ActivitylogService) RemoveResource(rid *provider.ResourceId) error {
|
||||
if rid == nil {
|
||||
return fmt.Errorf("resource id is required")
|
||||
}
|
||||
|
||||
a.lock.Lock()
|
||||
defer a.lock.Unlock()
|
||||
|
||||
return a.natskv.Delete(storagespace.FormatResourceID(rid))
|
||||
}
|
||||
|
||||
func (a *ActivitylogService) activities(rid *provider.ResourceId) ([]RawActivity, error) {
|
||||
resourceID := storagespace.FormatResourceID(rid)
|
||||
|
||||
glob := fmt.Sprintf("%s.>", base32.StdEncoding.EncodeToString([]byte(resourceID)))
|
||||
|
||||
watcher, err := a.natskv.Watch(glob, nats.IgnoreDeletes())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer watcher.Stop()
|
||||
|
||||
var activities []RawActivity
|
||||
for update := range watcher.Updates() {
|
||||
if update == nil {
|
||||
break
|
||||
}
|
||||
|
||||
var batchActivities []RawActivity
|
||||
if err := msgpack.Unmarshal(update.Value(), &batchActivities); err != nil {
|
||||
a.log.Debug().Err(err).Str("resourceID", resourceID).Msg("could not unmarshal messagepack, trying json")
|
||||
}
|
||||
activities = append(activities, batchActivities...)
|
||||
}
|
||||
|
||||
return activities, nil
|
||||
}
|
||||
|
||||
// note: getResource is abstracted to allow unit testing, in general this will just be utils.GetResource
|
||||
func (a *ActivitylogService) addActivity(ctx context.Context, initRef *provider.Reference, parentId *provider.ResourceId, eventID string, timestamp time.Time, getResource func(context.Context, *provider.Reference) (*provider.ResourceInfo, error)) error {
|
||||
var (
|
||||
err error
|
||||
depth int
|
||||
ref = initRef
|
||||
)
|
||||
ctx, span := a.tracer.Start(ctx, "addActivity")
|
||||
defer span.End()
|
||||
for {
|
||||
var info *provider.ResourceInfo
|
||||
id := ref.GetResourceId()
|
||||
if ref.Path != "" {
|
||||
// Path based reference, we need to resolve the resource id
|
||||
ctx, span = a.tracer.Start(ctx, "addActivity.getResource")
|
||||
info, err = getResource(ctx, ref)
|
||||
span.End()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not get resource info: %w", err)
|
||||
}
|
||||
id = info.GetId()
|
||||
}
|
||||
if id == nil {
|
||||
return fmt.Errorf("resource id is required")
|
||||
}
|
||||
|
||||
key := storagespace.FormatResourceID(id)
|
||||
a.debouncer.Debounce(key, RawActivity{
|
||||
EventID: eventID,
|
||||
Depth: depth,
|
||||
Timestamp: timestamp,
|
||||
})
|
||||
|
||||
if id.OpaqueId == id.SpaceId {
|
||||
// we are at the root of the space, no need to go further
|
||||
break
|
||||
}
|
||||
|
||||
// check if parent id is cached
|
||||
// parent id is cached in the format <storageid>$<spaceid>!<resourceid>
|
||||
// if it is not cached, get the resource info and cache it
|
||||
if parentId == nil {
|
||||
if v, err := a.parentIdCache.Get(key); err != nil {
|
||||
if info == nil {
|
||||
ctx, span := a.tracer.Start(ctx, "addActivity.getResource parent")
|
||||
info, err = getResource(ctx, ref)
|
||||
span.End()
|
||||
if err != nil || info.GetParentId() == nil || info.GetParentId().GetOpaqueId() == "" {
|
||||
return fmt.Errorf("could not get parent id: %w", err)
|
||||
}
|
||||
}
|
||||
parentId = info.GetParentId()
|
||||
a.parentIdCache.Set(key, parentId)
|
||||
} else {
|
||||
parentId = v.(*provider.ResourceId)
|
||||
}
|
||||
} else {
|
||||
a.log.Debug().Msg("parent id is cached")
|
||||
}
|
||||
|
||||
depth++
|
||||
ref = &provider.Reference{ResourceId: parentId}
|
||||
parentId = nil // reset parent id so it's not reused in the next iteration
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ActivitylogService) storeActivity(resourceID string, activities []RawActivity) error {
|
||||
a.lock.Lock()
|
||||
defer a.lock.Unlock()
|
||||
|
||||
ctx, span := a.tracer.Start(context.Background(), "storeActivity")
|
||||
defer span.End()
|
||||
|
||||
_, subspan := a.tracer.Start(ctx, "storeActivity.Marshal")
|
||||
b, err := msgpack.Marshal(activities)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
subspan.End()
|
||||
|
||||
_, subspan = a.tracer.Start(ctx, "storeActivity.natskv.Put")
|
||||
key := natsKey(resourceID, len(activities))
|
||||
_, err = a.natskv.Put(key, b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
subspan.End()
|
||||
|
||||
ctx, subspan = a.tracer.Start(ctx, "storeActivity.enforceMaxActivities")
|
||||
a.enforceMaxActivities(ctx, resourceID)
|
||||
subspan.End()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *ActivitylogService) enforceMaxActivities(ctx context.Context, resourceID string) {
|
||||
if a.maxActivities <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("%s.>", base32.StdEncoding.EncodeToString([]byte(resourceID)))
|
||||
|
||||
_, subspan := a.tracer.Start(ctx, "enforceMaxActivities.watch")
|
||||
watcher, err := a.natskv.Watch(key, nats.IgnoreDeletes())
|
||||
if err != nil {
|
||||
a.log.Error().Err(err).Str("resourceID", resourceID).Msg("could not watch")
|
||||
return
|
||||
}
|
||||
defer watcher.Stop()
|
||||
|
||||
var keys []string
|
||||
for update := range watcher.Updates() {
|
||||
if update == nil {
|
||||
break
|
||||
}
|
||||
|
||||
var batchActivities []RawActivity
|
||||
if err := msgpack.Unmarshal(update.Value(), &batchActivities); err != nil {
|
||||
a.log.Debug().Err(err).Str("resourceID", resourceID).Msg("could not unmarshal messagepack, trying json")
|
||||
}
|
||||
keys = append(keys, update.Key())
|
||||
}
|
||||
subspan.End()
|
||||
|
||||
_, subspan = a.tracer.Start(ctx, "enforceMaxActivities.compile")
|
||||
// Parse keys into batches
|
||||
batches := make([]batchInfo, 0)
|
||||
var activitiesCount int
|
||||
for _, k := range keys {
|
||||
parts := strings.SplitN(k, ".", 3)
|
||||
if len(parts) < 3 {
|
||||
a.log.Warn().Str("key", k).Msg("skipping key, not enough parts")
|
||||
continue
|
||||
}
|
||||
|
||||
c, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
a.log.Warn().Str("key", k).Msg("skipping key, can not parse count")
|
||||
continue
|
||||
}
|
||||
|
||||
// parse timestamp
|
||||
nano, err := strconv.ParseInt(parts[2], 10, 64)
|
||||
if err != nil {
|
||||
a.log.Warn().Str("key", k).Msg("skipping key, can not parse timestamp")
|
||||
continue
|
||||
}
|
||||
|
||||
batches = append(batches, batchInfo{
|
||||
key: k,
|
||||
count: c,
|
||||
timestamp: time.Unix(0, nano),
|
||||
})
|
||||
activitiesCount += c
|
||||
}
|
||||
|
||||
// sort batches by timestamp
|
||||
sort.Slice(batches, func(i, j int) bool {
|
||||
return batches[i].timestamp.Before(batches[j].timestamp)
|
||||
})
|
||||
subspan.End()
|
||||
|
||||
_, subspan = a.tracer.Start(ctx, "enforceMaxActivities.delete")
|
||||
// remove oldest keys until we are at max activities
|
||||
for _, b := range batches {
|
||||
if activitiesCount-b.count < a.maxActivities {
|
||||
break
|
||||
}
|
||||
|
||||
activitiesCount -= b.count
|
||||
err = a.natskv.Delete(b.key)
|
||||
if err != nil {
|
||||
a.log.Error().Err(err).Str("key", b.key).Msg("could not delete key")
|
||||
break
|
||||
}
|
||||
}
|
||||
subspan.End()
|
||||
}
|
||||
|
||||
func toRef(r *provider.ResourceId) *provider.Reference {
|
||||
return &provider.Reference{
|
||||
ResourceId: r,
|
||||
}
|
||||
}
|
||||
|
||||
func toSpace(r *provider.Reference) *provider.StorageSpaceId {
|
||||
return &provider.StorageSpaceId{
|
||||
OpaqueId: storagespace.FormatStorageID(r.GetResourceId().GetStorageId(), r.GetResourceId().GetSpaceId()),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ActivitylogService) removeCachedParentID(ref *provider.Reference) {
|
||||
purgeId := ref.GetResourceId()
|
||||
if ref.GetPath() != "" {
|
||||
gwc, err := a.gws.Next()
|
||||
if err != nil {
|
||||
a.log.Error().Err(err).Msg("could not get gateway client")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, err := utils.GetServiceUserContext(a.cfg.ServiceAccount.ServiceAccountID, gwc, a.cfg.ServiceAccount.ServiceAccountSecret)
|
||||
if err != nil {
|
||||
a.log.Error().Err(err).Msg("could not get service user context")
|
||||
return
|
||||
}
|
||||
|
||||
info, err := utils.GetResource(ctx, ref, gwc)
|
||||
if err != nil {
|
||||
a.log.Error().Err(err).Msg("could not get resource info")
|
||||
return
|
||||
}
|
||||
purgeId = info.GetId()
|
||||
}
|
||||
// The parent id cache is populated lazily and its entries expire, so a
|
||||
// missing key is the expected case rather than an error.
|
||||
if err := a.parentIdCache.Remove(storagespace.FormatResourceID(purgeId)); err != nil {
|
||||
a.log.Debug().Interface("event", ref).Err(err).Msg("could not delete parent id cache")
|
||||
}
|
||||
}
|
||||
|
||||
func natsKey(resourceID string, activitiesCount int) string {
|
||||
return fmt.Sprintf("%s.%d.%d",
|
||||
base32.StdEncoding.EncodeToString([]byte(resourceID)),
|
||||
activitiesCount,
|
||||
time.Now().UnixNano())
|
||||
}
|
||||
@@ -61,7 +61,7 @@ type Spaces struct {
|
||||
}
|
||||
|
||||
type LDAPMetrics struct {
|
||||
Disabled bool `yaml:"disabled" env:"GRAPH_LDAP_METRICS_DISABLE" desc:"Disables the metrics for outbound LDAP operations." introductionVersion:"%NEXT%"`
|
||||
Disabled bool `yaml:"disabled" env:"GRAPH_LDAP_METRICS_DISABLE" desc:"Disables the metrics for outbound LDAP operations." introductionVersion:"%%NEXT%%"`
|
||||
}
|
||||
|
||||
type LDAP struct {
|
||||
@@ -121,7 +121,7 @@ type LDAPEducationConfig struct {
|
||||
}
|
||||
|
||||
type IdentityMetrics struct {
|
||||
Disabled bool `yaml:"disabled" env:"GRAPH_IDENTITY_BACKEND_METRICS_DISABLE" desc:"Disables the metrics for inbound identity backend operations." introductionVersion:"%NEXT%"`
|
||||
Disabled bool `yaml:"disabled" env:"GRAPH_IDENTITY_BACKEND_METRICS_DISABLE" desc:"Disables the metrics for inbound identity backend operations." introductionVersion:"%%NEXT%%"`
|
||||
}
|
||||
|
||||
type Identity struct {
|
||||
@@ -141,7 +141,7 @@ type API struct {
|
||||
|
||||
// Events combines the configuration options for the event bus.
|
||||
type Events struct {
|
||||
DisabledConsumer bool `yaml:"disabled_consumer" env:"GRAPH_EVENTS_DISABLE_CONSUMER" desc:"Disables consuming events. Set this to true if the service should only handle HTTP requests." introductionVersion:"%NEXT%"`
|
||||
DisabledConsumer bool `yaml:"disabled_consumer" env:"GRAPH_EVENTS_DISABLE_CONSUMER" desc:"Disables consuming events. Set this to true if the service should only handle HTTP requests." introductionVersion:"%%NEXT%%"`
|
||||
Endpoint string `yaml:"endpoint" env:"OC_EVENTS_ENDPOINT;GRAPH_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. Set to a empty string to disable emitting events." introductionVersion:"1.0.0"`
|
||||
Cluster string `yaml:"cluster" env:"OC_EVENTS_CLUSTER;GRAPH_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." introductionVersion:"1.0.0"`
|
||||
TLSInsecure bool `yaml:"tls_insecure" env:"OC_INSECURE;OC_EVENTS_TLS_INSECURE;GRAPH_EVENTS_TLS_INSECURE" desc:"Whether to verify the server TLS certificates." introductionVersion:"1.0.0"`
|
||||
|
||||
@@ -3,12 +3,12 @@ package config
|
||||
import "github.com/opencloud-eu/opencloud/pkg/shared"
|
||||
|
||||
type HTTPMetrics struct {
|
||||
Disabled bool `yaml:"disabled" env:"GRAPH_HTTP_METRICS_DISABLE" desc:"Disables the metrics for the HTTP service." introductionVersion:"%NEXT%"`
|
||||
Disabled bool `yaml:"disabled" env:"GRAPH_HTTP_METRICS_DISABLE" desc:"Disables the metrics for the HTTP service." introductionVersion:"%%NEXT%%"`
|
||||
}
|
||||
|
||||
// HTTP defines the available http configuration.
|
||||
type HTTP struct {
|
||||
Disabled bool `yaml:"disabled" env:"GRAPH_HTTP_DISABLE" desc:"Disables the HTTP service. Set this to true if the service should only consume events." introductionVersion:"%NEXT%"`
|
||||
Disabled bool `yaml:"disabled" env:"GRAPH_HTTP_DISABLE" desc:"Disables the HTTP service. Set this to true if the service should only consume events." introductionVersion:"%%NEXT%%"`
|
||||
Addr string `yaml:"addr" env:"GRAPH_HTTP_ADDR" desc:"The bind address of the HTTP service." introductionVersion:"1.0.0"`
|
||||
Namespace string `yaml:"-"`
|
||||
Root string `yaml:"root" env:"GRAPH_HTTP_ROOT" desc:"Subdirectory that serves as the root for this HTTP service." introductionVersion:"1.0.0"`
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# 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:
|
||||
# Roy-Magne Mo, 2026
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: EMAIL\n"
|
||||
"POT-Creation-Date: 2026-09-07 23:16+0000\n"
|
||||
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
|
||||
"Last-Translator: Roy-Magne Mo, 2026\n"
|
||||
"Language-Team: Norwegian (https://app.transifex.com/opencloud-eu/teams/204053/no/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: no\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#. UnifiedRole Editor, Role DisplayName (resolves directly)
|
||||
#. UnifiedRole Editor, Role DisplayName (resolves directly)
|
||||
#. UnifiedRole EditorListGrants, Role DisplayName (resolves directly)
|
||||
#. UnifiedRole SpaseEditor, Role DisplayName (resolves directly)
|
||||
#. UnifiedRole FileEditor, Role DisplayName (resolves directly)
|
||||
#. UnifiedRole FileEditorWithVersions, Role DisplayName (resolves directly)
|
||||
#. UnifiedRole FileEditorListGrants, Role DisplayName (resolves directly)
|
||||
#: pkg/unifiedrole/roles.go:137 pkg/unifiedrole/roles.go:143
|
||||
#: pkg/unifiedrole/roles.go:149 pkg/unifiedrole/roles.go:155
|
||||
#: pkg/unifiedrole/roles.go:167 pkg/unifiedrole/roles.go:173
|
||||
#: pkg/unifiedrole/roles.go:179
|
||||
msgid "Can edit"
|
||||
msgstr "Kan redigere"
|
||||
|
||||
#. UnifiedRole SpaseEditorWithoutVersions, Role DisplayName (resolves
|
||||
#. directly)
|
||||
#: pkg/unifiedrole/roles.go:161
|
||||
msgid "Can edit without versions"
|
||||
msgstr "Kan redigere uten versjoner"
|
||||
|
||||
#. UnifiedRole Manager, Role DisplayName (resolves directly)
|
||||
#: pkg/unifiedrole/roles.go:191
|
||||
msgid "Can manage"
|
||||
msgstr "Kan håndtere"
|
||||
|
||||
#. UnifiedRole EditorLite, Role DisplayName (resolves directly)
|
||||
#: pkg/unifiedrole/roles.go:185
|
||||
msgid "Can upload"
|
||||
msgstr "Kan laste opp"
|
||||
|
||||
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
|
||||
#. UnifiedRole ViewerWithVersions, Role DisplayName (resolves directly)
|
||||
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
|
||||
#. UnifiedRole SpaseViewer, Role DisplayName (resolves directly)
|
||||
#. UnifiedRole SpaseViewer, Role DisplayName (resolves directly)
|
||||
#: pkg/unifiedrole/roles.go:107 pkg/unifiedrole/roles.go:113
|
||||
#: pkg/unifiedrole/roles.go:119 pkg/unifiedrole/roles.go:125
|
||||
#: pkg/unifiedrole/roles.go:131
|
||||
msgid "Can view"
|
||||
msgstr "Kan se"
|
||||
|
||||
#. UnifiedRole SecureViewer, Role DisplayName (resolves directly)
|
||||
#: pkg/unifiedrole/roles.go:197
|
||||
msgid "Can view (secure)"
|
||||
msgstr "Kan vise (sikker)"
|
||||
|
||||
#. UnifiedRole FullDenial, Role DisplayName (resolves directly)
|
||||
#: pkg/unifiedrole/roles.go:203
|
||||
msgid "Cannot access"
|
||||
msgstr "Kan ikke få tilgang"
|
||||
|
||||
#. UnifiedRole FullDenial, Role Description (resolves directly)
|
||||
#: pkg/unifiedrole/roles.go:200
|
||||
msgid "Deny all access."
|
||||
msgstr "Avvis all tilgang."
|
||||
|
||||
#. default description for new spaces
|
||||
#: pkg/service/v0/spacetemplates.go:32
|
||||
msgid "Here you can add a description for this Space."
|
||||
msgstr "Her kan du legge til en beskrivelse for dette rommet."
|
||||
|
||||
#. UnifiedRole ViewerWithVersions, Role Description (resolves directly)
|
||||
#. UnifiedRole SpaceViewer, Role Description (resolves directly)
|
||||
#: pkg/unifiedrole/roles.go:110 pkg/unifiedrole/roles.go:128
|
||||
msgid "View and download including the history."
|
||||
msgstr "Vis og last ned, inkludert historikken."
|
||||
|
||||
#. UnifiedRole Viewer, Role Description (resolves directly)
|
||||
#. UnifiedRole SpaceViewer, Role Description (resolves directly)
|
||||
#: pkg/unifiedrole/roles.go:104 pkg/unifiedrole/roles.go:122
|
||||
msgid "View and download."
|
||||
msgstr "Vis og last ned."
|
||||
|
||||
#. UnifiedRole SecureViewer, Role Description (resolves directly)
|
||||
#: pkg/unifiedrole/roles.go:194
|
||||
msgid "View only documents, images and PDFs. Watermarks will be applied."
|
||||
msgstr "Vis kun dokumenter, bilder og PDF-er. Vannmerker vil bli brukt."
|
||||
|
||||
#. UnifiedRole FileEditorWithVErsions, Role Description (resolves directly)
|
||||
#: pkg/unifiedrole/roles.go:176
|
||||
msgid "View, download and edit including the history."
|
||||
msgstr "Vis, last ned og rediger, inkludert historikken."
|
||||
|
||||
#. UnifiedRole FileEditor, Role Description (resolves directly)
|
||||
#: pkg/unifiedrole/roles.go:164
|
||||
msgid "View, download and edit."
|
||||
msgstr "Vis, last ned og rediger."
|
||||
|
||||
#. UnifiedRole ViewerListGrants, Role Description (resolves directly)
|
||||
#: pkg/unifiedrole/roles.go:116
|
||||
msgid "View, download and show all invited people."
|
||||
msgstr "Vis, last ned og vis alle inviterte personer."
|
||||
|
||||
#. UnifiedRole EditorLite, Role Description (resolves directly)
|
||||
#: pkg/unifiedrole/roles.go:182
|
||||
msgid "View, download and upload."
|
||||
msgstr "Vis, last ned og last opp."
|
||||
|
||||
#. UnifiedRole FileEditorListGrants, Role Description (resolves directly)
|
||||
#: pkg/unifiedrole/roles.go:170
|
||||
msgid "View, download, edit and show all invited people."
|
||||
msgstr "Vis, last ned, rediger og vis alle inviterte personer."
|
||||
|
||||
#. UnifiedRole Editor, Role Description (resolves directly)
|
||||
#: pkg/unifiedrole/roles.go:140
|
||||
msgid "View, download, upload, edit, add and delete including the history."
|
||||
msgstr ""
|
||||
"Vis, last ned, last opp, rediger, legg til og slett, inkludert historikken."
|
||||
|
||||
#. UnifiedRole Editor, Role Description (resolves directly)
|
||||
#. UnifiedRole SpaseEditorWithoutVersions, Role Description (resolves
|
||||
#. directly)
|
||||
#: pkg/unifiedrole/roles.go:134 pkg/unifiedrole/roles.go:158
|
||||
msgid "View, download, upload, edit, add and delete."
|
||||
msgstr "Vis, last ned, last opp, rediger, legg til og slett."
|
||||
|
||||
#. UnifiedRole Manager, Role Description (resolves directly)
|
||||
#: pkg/unifiedrole/roles.go:188
|
||||
msgid "View, download, upload, edit, add, delete and manage members."
|
||||
msgstr ""
|
||||
"Vis, last ned, last opp, rediger, legg til, slett og administrer medlemmer."
|
||||
|
||||
#. UnifiedRoleListGrants Editor, Role Description (resolves directly)
|
||||
#: pkg/unifiedrole/roles.go:146
|
||||
msgid "View, download, upload, edit, add, delete and show all invited people."
|
||||
msgstr ""
|
||||
"Se, last ned, last opp, rediger, legg til, slett og vis alle inviterte "
|
||||
"personer."
|
||||
|
||||
#. UnifiedRole SpaseEditor, Role Description (resolves directly)
|
||||
#: pkg/unifiedrole/roles.go:152
|
||||
msgid "View, download, upload, edit, add, delete including the history."
|
||||
msgstr ""
|
||||
"Vis, last ned, last opp, rediger, legg til, slett inkludert historikken."
|
||||
@@ -45,7 +45,10 @@ const (
|
||||
var shareTypesFieldMask = &fieldmaskpb.FieldMask{Paths: []string{"share-types"}}
|
||||
|
||||
// opt-in driveItem relations, returned only when requested via $expand
|
||||
const _expandChildren = "children"
|
||||
const (
|
||||
_expandChildren = "children"
|
||||
_expandThumbnails = "thumbnails"
|
||||
)
|
||||
|
||||
func odataListContains(r *http.Request, parameter, value string) bool {
|
||||
for _, values := range r.URL.Query()[parameter] {
|
||||
@@ -253,6 +256,7 @@ func (g Graph) GetRootDriveChildren(w http.ResponseWriter, r *http.Request) {
|
||||
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
g.setDriveItemsThumbnails(r, files, lRes.GetInfos())
|
||||
|
||||
if driveItemPropertySelected(r, _selectAllowedValues) {
|
||||
for i, info := range lRes.GetInfos() {
|
||||
@@ -346,6 +350,10 @@ func (g Graph) GetDriveItem(w http.ResponseWriter, r *http.Request) {
|
||||
driveItem.LibreGraphShareTypes = shareTypesOf(res.GetInfo(), g.listLinkShares(ctx, infos))
|
||||
}
|
||||
|
||||
if driveItemRelationExpanded(r, _expandThumbnails) {
|
||||
setDriveItemThumbnails(driveItem, res.GetInfo(), g.config.Commons.OpenCloudURL)
|
||||
}
|
||||
|
||||
render.Status(r, http.StatusOK)
|
||||
render.JSON(w, r, &driveItem)
|
||||
}
|
||||
@@ -432,6 +440,8 @@ func (g Graph) listDriveItemChildren(w http.ResponseWriter, r *http.Request, dri
|
||||
g.addShareTypes(r.Context(), files, res.GetInfos())
|
||||
}
|
||||
|
||||
g.setDriveItemsThumbnails(r, files, res.GetInfos())
|
||||
|
||||
return files, true
|
||||
}
|
||||
|
||||
|
||||
@@ -208,6 +208,40 @@ var _ = Describe("Driveitems", func() {
|
||||
Expect(res.Value[0].LibreGraphPermissionsActionsAllowedValues).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns the thumbnails when requested via $expand", func() {
|
||||
cfg.Commons.OpenCloudURL = "https://cloud.test"
|
||||
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
StorageSpaces: []*provider.StorageSpace{{Owner: currentUser, Root: &provider.ResourceId{}}},
|
||||
}, nil)
|
||||
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Infos: []*provider.ResourceInfo{
|
||||
{
|
||||
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
|
||||
Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"},
|
||||
MimeType: "image/jpeg",
|
||||
Mtime: utils.TimeToTS(time.Now()),
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drive/root/children?$expand=thumbnails", nil)
|
||||
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
|
||||
svc.GetRootDriveChildren(rr, r)
|
||||
Expect(rr.Code).To(Equal(http.StatusOK))
|
||||
data, err := io.ReadAll(rr.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
res := itemsList{}
|
||||
Expect(json.Unmarshal(data, &res)).To(Succeed())
|
||||
Expect(len(res.Value)).To(Equal(1))
|
||||
Expect(res.Value[0].Thumbnails).To(HaveLen(1))
|
||||
Expect(res.Value[0].Thumbnails[0].Small.GetUrl()).To(Equal(
|
||||
"https://cloud.test/dav/spaces/storageid$spaceid!opaqueid" +
|
||||
"?scalingup=0&preview=1&processor=thumbnail&x=36&y=36",
|
||||
))
|
||||
})
|
||||
|
||||
It("returns the allowed actions when requested via $select", func() {
|
||||
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
@@ -248,6 +282,7 @@ var _ = Describe("Driveitems", func() {
|
||||
Describe("GetDriveItem", func() {
|
||||
var (
|
||||
folderInfo *provider.ResourceInfo
|
||||
childInfo *provider.ResourceInfo
|
||||
mtime = time.Now()
|
||||
)
|
||||
|
||||
@@ -281,16 +316,15 @@ var _ = Describe("Driveitems", func() {
|
||||
Status: status.NewOK(ctx),
|
||||
Info: folderInfo,
|
||||
}, nil)
|
||||
childInfo = &provider.ResourceInfo{
|
||||
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
|
||||
Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"},
|
||||
Etag: "etag",
|
||||
Mtime: utils.TimeToTS(mtime),
|
||||
}
|
||||
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
|
||||
Status: status.NewOK(ctx),
|
||||
Infos: []*provider.ResourceInfo{
|
||||
{
|
||||
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
|
||||
Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"},
|
||||
Etag: "etag",
|
||||
Mtime: utils.TimeToTS(mtime),
|
||||
},
|
||||
},
|
||||
Infos: []*provider.ResourceInfo{childInfo},
|
||||
}, nil)
|
||||
})
|
||||
|
||||
@@ -312,6 +346,49 @@ var _ = Describe("Driveitems", func() {
|
||||
Expect(getItem(newRequest("?$expand=children")).Children).To(BeNil())
|
||||
gatewayClient.AssertNotCalled(GinkgoT(), "ListContainer", mock.Anything, mock.Anything)
|
||||
})
|
||||
|
||||
Context("$expand=thumbnails", func() {
|
||||
const previewURL = "https://cloud.test/dav/spaces/storageid$spaceid!nodeid" +
|
||||
"?scalingup=0&preview=1&processor=thumbnail"
|
||||
|
||||
BeforeEach(func() {
|
||||
cfg.Commons.OpenCloudURL = "https://cloud.test"
|
||||
folderInfo.Type = provider.ResourceType_RESOURCE_TYPE_FILE
|
||||
folderInfo.MimeType = "image/jpeg"
|
||||
})
|
||||
|
||||
It("leaves thumbnails unset without $expand", func() {
|
||||
Expect(getItem(newRequest("")).Thumbnails).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns the thumbnail urls when requested", func() {
|
||||
thumbnails := getItem(newRequest("?$expand=thumbnails")).Thumbnails
|
||||
|
||||
Expect(thumbnails).To(HaveLen(1))
|
||||
Expect(thumbnails[0].Small.GetUrl()).To(Equal(previewURL + "&x=36&y=36"))
|
||||
Expect(thumbnails[0].Medium.GetUrl()).To(Equal(previewURL + "&x=48&y=48"))
|
||||
Expect(thumbnails[0].Large.GetUrl()).To(Equal(previewURL + "&x=96&y=96"))
|
||||
})
|
||||
|
||||
It("leaves thumbnails unset for a mime type the thumbnailer cannot render", func() {
|
||||
folderInfo.MimeType = "application/zip"
|
||||
|
||||
Expect(getItem(newRequest("?$expand=thumbnails")).Thumbnails).To(BeNil())
|
||||
})
|
||||
|
||||
It("adds them to expanded children as well", func() {
|
||||
folderInfo.Type = provider.ResourceType_RESOURCE_TYPE_CONTAINER
|
||||
folderInfo.MimeType = ""
|
||||
childInfo.MimeType = "image/jpeg"
|
||||
|
||||
item := getItem(newRequest("?$expand=children,thumbnails"))
|
||||
|
||||
// a folder has no preview of its own
|
||||
Expect(item.Thumbnails).To(BeNil())
|
||||
Expect(item.Children).To(HaveLen(1))
|
||||
Expect(item.Children[0].Thumbnails).To(HaveLen(1))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetDriveItem $expand=children error", func() {
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
|
||||
"github.com/go-chi/render"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
"github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode"
|
||||
)
|
||||
@@ -44,30 +42,8 @@ func (g Graph) GetSharedByMe(w http.ResponseWriter, r *http.Request) {
|
||||
expandThumbnails := strings.Contains(expand, "thumbnails")
|
||||
if expandThumbnails {
|
||||
for k, item := range driveItems {
|
||||
mt := item.GetFile().MimeType
|
||||
if mt == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
_, match := thumbnail.SupportedMimeTypes[*mt]
|
||||
if match {
|
||||
baseUrl := fmt.Sprintf("%s/dav/spaces/%s?scalingup=0&preview=1&processor=thumbnail",
|
||||
g.config.Commons.OpenCloudURL,
|
||||
item.GetId())
|
||||
smallUrl := baseUrl + "&x=36&y=36"
|
||||
mediumUrl := baseUrl + "&x=48&y=48"
|
||||
largeUrl := baseUrl + "&x=96&y=96"
|
||||
|
||||
item.SetThumbnails([]libregraph.ThumbnailSet{
|
||||
{
|
||||
Small: &libregraph.Thumbnail{Url: &smallUrl},
|
||||
Medium: &libregraph.Thumbnail{Url: &mediumUrl},
|
||||
Large: &libregraph.Thumbnail{Url: &largeUrl},
|
||||
},
|
||||
})
|
||||
|
||||
driveItems[k] = item // assign modified item back to the map
|
||||
}
|
||||
setShareThumbnails(&item, item.GetId(), g.config.Commons.OpenCloudURL)
|
||||
driveItems[k] = item
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -13,7 +12,6 @@ import (
|
||||
"github.com/opencloud-eu/reva/v2/pkg/share"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode"
|
||||
"github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail"
|
||||
)
|
||||
|
||||
// ListSharedWithMe lists the files shared with the current user.
|
||||
@@ -73,30 +71,8 @@ func (g Graph) listSharedWithMe(ctx context.Context, expandThumbnails bool) ([]l
|
||||
|
||||
if expandThumbnails {
|
||||
for k, item := range driveItems {
|
||||
mt := item.GetFile().MimeType
|
||||
if mt == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
_, match := thumbnail.SupportedMimeTypes[*mt]
|
||||
if match {
|
||||
baseUrl := fmt.Sprintf("%s/dav/spaces/%s?scalingup=0&preview=1&processor=thumbnail",
|
||||
g.config.Commons.OpenCloudURL,
|
||||
item.RemoteItem.GetId())
|
||||
smallUrl := baseUrl + "&x=36&y=36"
|
||||
mediumUrl := baseUrl + "&x=48&y=48"
|
||||
largeUrl := baseUrl + "&x=96&y=96"
|
||||
|
||||
item.SetThumbnails([]libregraph.ThumbnailSet{
|
||||
{
|
||||
Small: &libregraph.Thumbnail{Url: &smallUrl},
|
||||
Medium: &libregraph.Thumbnail{Url: &mediumUrl},
|
||||
Large: &libregraph.Thumbnail{Url: &largeUrl},
|
||||
},
|
||||
})
|
||||
|
||||
driveItems[k] = item // assign modified item back to the map
|
||||
}
|
||||
setShareThumbnails(&item, item.RemoteItem.GetId(), g.config.Commons.OpenCloudURL)
|
||||
driveItems[k] = item
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
|
||||
libregraph "github.com/opencloud-eu/libre-graph-api-go"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail"
|
||||
)
|
||||
|
||||
const (
|
||||
thumbnailBoxSmall = 36
|
||||
thumbnailBoxMedium = 48
|
||||
thumbnailBoxLarge = 96
|
||||
)
|
||||
|
||||
func (g Graph) setDriveItemsThumbnails(r *http.Request, items []libregraph.DriveItem, infos []*provider.ResourceInfo) {
|
||||
if !driveItemRelationExpanded(r, _expandThumbnails) {
|
||||
return
|
||||
}
|
||||
for i := range items {
|
||||
if i < len(infos) {
|
||||
setDriveItemThumbnails(&items[i], infos[i], g.config.Commons.OpenCloudURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setDriveItemThumbnails(item *libregraph.DriveItem, res *provider.ResourceInfo, baseURL string) {
|
||||
if set := previewThumbnailSet(res, baseURL); set != nil {
|
||||
item.SetThumbnails([]libregraph.ThumbnailSet{*set})
|
||||
}
|
||||
}
|
||||
|
||||
// previewThumbnailSet returns nil when the thumbnailer cannot render the resource.
|
||||
func previewThumbnailSet(res *provider.ResourceInfo, baseURL string) *libregraph.ThumbnailSet {
|
||||
if !thumbnail.IsMimeTypeSupported(res.GetMimeType()) {
|
||||
return nil
|
||||
}
|
||||
return thumbnailSetFor(baseURL, storagespace.FormatResourceID(res.GetId()))
|
||||
}
|
||||
|
||||
// thumbnailSetFor builds the urls of the WebDAV preview endpoint.
|
||||
func thumbnailSetFor(baseURL, itemID string) *libregraph.ThumbnailSet {
|
||||
base := fmt.Sprintf("%s/dav/spaces/%s?scalingup=0&preview=1&processor=thumbnail", baseURL, itemID)
|
||||
return &libregraph.ThumbnailSet{
|
||||
Small: previewThumbnail(base, thumbnailBoxSmall),
|
||||
Medium: previewThumbnail(base, thumbnailBoxMedium),
|
||||
Large: previewThumbnail(base, thumbnailBoxLarge),
|
||||
}
|
||||
}
|
||||
|
||||
func previewThumbnail(base string, box int32) *libregraph.Thumbnail {
|
||||
url := fmt.Sprintf("%s&x=%d&y=%d", base, box, box)
|
||||
return &libregraph.Thumbnail{Url: &url}
|
||||
}
|
||||
|
||||
// setShareThumbnails works off the driveItem, the share listings have no resource
|
||||
// info. The id comes separately, a received share carries it on its remote item.
|
||||
func setShareThumbnails(item *libregraph.DriveItem, itemID, baseURL string) {
|
||||
mimeType := item.GetFile().MimeType
|
||||
if itemID == "" || mimeType == nil || !thumbnail.IsMimeTypeSupported(*mimeType) {
|
||||
return
|
||||
}
|
||||
item.SetThumbnails([]libregraph.ThumbnailSet{*thumbnailSetFor(baseURL, itemID)})
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
# 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:
|
||||
# Roy-Magne Mo, 2026
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: EMAIL\n"
|
||||
"POT-Creation-Date: 2026-09-07 23:16+0000\n"
|
||||
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
|
||||
"Last-Translator: Roy-Magne Mo, 2026\n"
|
||||
"Language-Team: Norwegian (https://app.transifex.com/opencloud-eu/teams/204053/no/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: no\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#. UnsharedSpace email template, resolves via {{ .CallToAction }}
|
||||
#: pkg/email/templates.go:78
|
||||
msgid "Click here to check it: {ShareLink}"
|
||||
msgstr "Klikk her for å sjekke det: {ShareLink}"
|
||||
|
||||
#. ShareCreated email template, resolves via {{ .CallToAction }}
|
||||
#. SharedSpace email template, resolves via {{ .CallToAction }}
|
||||
#: pkg/email/templates.go:23 pkg/email/templates.go:63
|
||||
msgid "Click here to view it: {ShareLink}"
|
||||
msgstr "Klikk her for å se den: {ShareLink}"
|
||||
|
||||
#: pkg/email/templates.go:139
|
||||
msgid "Hello {RecipientName},"
|
||||
msgstr "Hei {RecipientName},"
|
||||
|
||||
#. ShareCreated email template, resolves via {{ .Greeting }}
|
||||
#: pkg/email/templates.go:19
|
||||
msgid "Hello {ShareGrantee}"
|
||||
msgstr "Hei {ShareGrantee}"
|
||||
|
||||
#. ShareRemoved email template, resolves via {{ .Greeting }}
|
||||
#. ShareExpired email template, resolves via {{ .Greeting }}
|
||||
#: pkg/email/templates.go:32 pkg/email/templates.go:45
|
||||
msgid "Hello {ShareGrantee},"
|
||||
msgstr "Hei {ShareGrantee},"
|
||||
|
||||
#. SharedSpace email template, resolves via {{ .Greeting }}
|
||||
#. UnsharedSpace email template, resolves via {{ .Greeting }}
|
||||
#. MembershipExpired email template, resolves via {{ .Greeting }}
|
||||
#: pkg/email/templates.go:59 pkg/email/templates.go:72
|
||||
#: pkg/email/templates.go:87
|
||||
msgid "Hello {SpaceGrantee},"
|
||||
msgstr "Hei {SpaceGrantee},"
|
||||
|
||||
#. Grouped email template, resolves via {{ .Greeting }}
|
||||
#: pkg/email/templates.go:131
|
||||
msgid "Hi {DisplayName},"
|
||||
msgstr "Hei {DisplayName},"
|
||||
|
||||
#. ScienceMeshInviteTokenGenerated email template, resolves via {{ .Greeting
|
||||
#. }}
|
||||
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
|
||||
#. via {{ .Greeting }}
|
||||
#: pkg/email/templates.go:100 pkg/email/templates.go:117
|
||||
msgid "Hi,"
|
||||
msgstr "Hei,"
|
||||
|
||||
#. MembershipExpired email template, Subject field (resolves directly)
|
||||
#: pkg/email/templates.go:85
|
||||
msgid "Membership of '{SpaceName}' expired at {ExpiredAt}"
|
||||
msgstr "Medlemskap i '{SpaceName}' utløpt {ExpiredAt}"
|
||||
|
||||
#. Grouped email template, Subject field (resolves directly)
|
||||
#: pkg/email/templates.go:129
|
||||
msgid "Report"
|
||||
msgstr "Rapport"
|
||||
|
||||
#. ScienceMeshInviteTokenGenerated email template, Subject field (resolves
|
||||
#. directly)
|
||||
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, Subject
|
||||
#. field (resolves directly)
|
||||
#: pkg/email/templates.go:98 pkg/email/templates.go:115
|
||||
msgid "ScienceMesh: {InitiatorName} wants to collaborate with you"
|
||||
msgstr "ScienceMesh: {InitiatorName} ønsker å samarbeide med deg"
|
||||
|
||||
#. ShareExpired email template, Subject field (resolves directly)
|
||||
#: pkg/email/templates.go:43
|
||||
msgid "Share to '{ShareFolder}' expired at {ExpiredAt}"
|
||||
msgstr "Deling til '{ShareFolder}' utløpt {ExpiredAt}"
|
||||
|
||||
#: pkg/email/templates.go:141
|
||||
msgid "You can view the mention here: {ResourceLink}"
|
||||
msgstr "Du kan se omtalen her: {ResourceLink}"
|
||||
|
||||
#: pkg/email/templates.go:138
|
||||
msgid "You were mentioned in '{ResourceName}'"
|
||||
msgstr "Du ble nevnt i «{ResourceName}»"
|
||||
|
||||
#. MembershipExpired email template, resolves via {{ .MessageBody }}
|
||||
#: pkg/email/templates.go:89
|
||||
msgid ""
|
||||
"Your membership of space {SpaceName} has expired at {ExpiredAt}\n"
|
||||
"\n"
|
||||
"Even though this membership has expired you still might have access through other shares and/or space memberships"
|
||||
msgstr ""
|
||||
"Medlemskapet ditt i rom {SpaceName} utløp {ExpiredAt}\n"
|
||||
"\n"
|
||||
"Selv om dette medlemskapet har utløpt, kan du fortsatt ha tilgang gjennom andre delinger og/eller rommedlemskap"
|
||||
|
||||
#. ShareExpired email template, resolves via {{ .MessageBody }}
|
||||
#: pkg/email/templates.go:47
|
||||
msgid ""
|
||||
"Your share to {ShareFolder} has expired at {ExpiredAt}\n"
|
||||
"\n"
|
||||
"Even though this share has been revoked you still might have access through other shares and/or space memberships."
|
||||
msgstr ""
|
||||
"Deltakelsen din i {ShareFolder} utløp {ExpiredAt}\n"
|
||||
"\n"
|
||||
"Selv om denne deltakelsen er trukket tilbake, kan du fortsatt ha tilgang gjennom andre deltakelser og/eller plassmedlemskap."
|
||||
|
||||
#: pkg/email/templates.go:140
|
||||
msgid "{AuthorName} mentioned you in \"{ResourceName}\"."
|
||||
msgstr "{AuthorName} nevnte deg i «{ResourceName}»."
|
||||
|
||||
#. ScienceMeshInviteTokenGeneratedWithoutShareLink email template, resolves
|
||||
#. via {{ .MessageBody }}
|
||||
#: pkg/email/templates.go:119
|
||||
msgid ""
|
||||
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
|
||||
"Please visit your federation settings and use the following details:\n"
|
||||
" Token: {Token}\n"
|
||||
" ProviderDomain: {ProviderDomain}"
|
||||
msgstr ""
|
||||
"{ShareSharer} ({ShareSharerMail}) ønsker å begynne å dele samarbeidsressurser med deg.\n"
|
||||
"Vennligst besøk føderasjonsinnstillingene dine og bruk følgende detaljer:\n"
|
||||
" Token: {Token}\n"
|
||||
" ProviderDomain: {ProviderDomain}"
|
||||
|
||||
#. ScienceMeshInviteTokenGenerated email template, resolves via {{
|
||||
#. .MessageBody }}
|
||||
#: pkg/email/templates.go:102
|
||||
msgid ""
|
||||
"{ShareSharer} ({ShareSharerMail}) wants to start sharing collaboration resources with you.\n"
|
||||
"To accept the invite, please visit the following URL:\n"
|
||||
"{ShareLink}\n"
|
||||
"\n"
|
||||
"Alternatively, you can visit your federation settings and use the following details:\n"
|
||||
" Token: {Token}\n"
|
||||
" ProviderDomain: {ProviderDomain}"
|
||||
msgstr ""
|
||||
"{ShareSharer} ({ShareSharerMail}) ønsker å begynne å dele samarbeidsressurser med deg.\n"
|
||||
"For å akseptere invitasjonen, besøk følgende URL:\n"
|
||||
"{ShareLink}\n"
|
||||
"\n"
|
||||
"Alternativt kan du besøke dine føderasjonsinnstillinger og bruke følgende detaljer:\n"
|
||||
" Token: {Token}\n"
|
||||
" ProviderDomain: {ProviderDomain}"
|
||||
|
||||
#. ShareCreated email template, resolves via {{ .MessageBody }}
|
||||
#: pkg/email/templates.go:21
|
||||
msgid "{ShareSharer} has shared \"{ShareFolder}\" with you."
|
||||
msgstr "{ShareSharer} har delt «{ShareFolder}» med deg."
|
||||
|
||||
#. ShareRemoved email template, resolves via {{ .MessageBody }}
|
||||
#: pkg/email/templates.go:34
|
||||
msgid ""
|
||||
"{ShareSharer} has unshared '{ShareFolder}' with you.\n"
|
||||
"\n"
|
||||
"Even though this share has been revoked you still might have access through other shares and/or space memberships."
|
||||
msgstr ""
|
||||
"{ShareSharer} har fjernet deling '{ShareFolder}' til deg.\n"
|
||||
"\n"
|
||||
"Selv om denne delingen er trukket tilbake, kan du fortsatt ha tilgang gjennom andre delinger og/eller medlemskap i rommet."
|
||||
|
||||
#. ShareCreated email template, Subject field (resolves directly)
|
||||
#: pkg/email/templates.go:17
|
||||
msgid "{ShareSharer} shared '{ShareFolder}' with you"
|
||||
msgstr "{ShareSharer} delte '{ShareFolder}' med deg"
|
||||
|
||||
#. ShareRemoved email template, Subject field (resolves directly)
|
||||
#: pkg/email/templates.go:30
|
||||
msgid "{ShareSharer} unshared '{ShareFolder}' with you"
|
||||
msgstr "{ShareSharer} deler ikke '{ShareFolder}' med deg"
|
||||
|
||||
#. SharedSpace email template, resolves via {{ .MessageBody }}
|
||||
#: pkg/email/templates.go:61
|
||||
msgid "{SpaceSharer} has invited you to join \"{SpaceName}\"."
|
||||
msgstr "{SpaceSharer} har invitert deg til å bli med i «{SpaceName}»."
|
||||
|
||||
#. UnsharedSpace email template, resolves via {{ .MessageBody }}
|
||||
#: pkg/email/templates.go:74
|
||||
msgid ""
|
||||
"{SpaceSharer} has removed you from \"{SpaceName}\".\n"
|
||||
"\n"
|
||||
"You might still have access through your other groups or direct membership."
|
||||
msgstr ""
|
||||
"{SpaceSharer} har fjernet deg fra «{SpaceName}».\n"
|
||||
"\n"
|
||||
"Du kan fortsatt ha tilgang gjennom andre grupper eller direkte medlemskap."
|
||||
|
||||
#. SharedSpace email template, Subject field (resolves directly)
|
||||
#: pkg/email/templates.go:57
|
||||
msgid "{SpaceSharer} invited you to join {SpaceName}"
|
||||
msgstr "{SpaceSharer} inviterte deg til å bli med i {SpaceName}"
|
||||
|
||||
#. UnsharedSpace email template, Subject field (resolves directly)
|
||||
#: pkg/email/templates.go:70
|
||||
msgid "{SpaceSharer} removed you from {SpaceName}"
|
||||
msgstr "{SpaceSharer} fjernet deg fra {SpaceName}"
|
||||
@@ -29,7 +29,7 @@ type Service struct {
|
||||
|
||||
// GRPC defines the available grpc configuration.
|
||||
type GRPC struct {
|
||||
Disabled bool `yaml:"disabled" env:"POLICIES_GRPC_DISABLED" desc:"Disables listening for GRPC API calls. Set this to true if the service should only handle requests through events." introductionVersion:"%NEXT%"`
|
||||
Disabled bool `yaml:"disabled" env:"POLICIES_GRPC_DISABLED" desc:"Disables listening for GRPC API calls. Set this to true if the service should only handle requests through events." introductionVersion:"%%NEXT%%"`
|
||||
Addr string `yaml:"addr" env:"POLICIES_GRPC_ADDR" desc:"The bind address of the GRPC service." introductionVersion:"1.0.0"`
|
||||
Namespace string `yaml:"-"`
|
||||
TLS *shared.GRPCServiceTLS `yaml:"tls"`
|
||||
@@ -50,7 +50,7 @@ type Postprocessing struct {
|
||||
|
||||
// Events combines the configuration options for the event bus.
|
||||
type Events struct {
|
||||
Disabled bool `yaml:"disabled" env:"POLICIES_EVENTS_DISABLED" desc:"Disables listening for events. Set this to true if the service should only handle GRPC requests." introductionVersion:"%NEXT%"`
|
||||
Disabled bool `yaml:"disabled" env:"POLICIES_EVENTS_DISABLED" desc:"Disables listening for events. Set this to true if the service should only handle GRPC requests." introductionVersion:"%%NEXT%%"`
|
||||
Endpoint string `yaml:"endpoint" env:"OC_EVENTS_ENDPOINT;POLICIES_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;POLICIES_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;POLICIES_EVENTS_TLS_INSECURE" desc:"Whether the server should skip the client certificate verification during the TLS handshake." introductionVersion:"1.0.0"`
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/vmihailenco/msgpack/v5"
|
||||
"go-micro.dev/v4/store"
|
||||
@@ -186,6 +187,13 @@ func verifyExpiresAt(claims map[string]any, cmp time.Time) bool {
|
||||
return cmp.Before(expiry)
|
||||
}
|
||||
|
||||
// isExpectedTokenTimeError reports whether the error is caused by a token that
|
||||
// failed its expiry (or not-before) validation. These are expected as part of
|
||||
// the normal token renewal cycle and should not be logged at error level.
|
||||
func isExpectedTokenTimeError(err error) bool {
|
||||
return errors.Is(err, jwt.ErrTokenExpired) || errors.Is(err, jwt.ErrTokenNotValidYet)
|
||||
}
|
||||
|
||||
func (m OIDCAuthenticator) shouldServe(req *http.Request) bool {
|
||||
if m.OIDCIss == "" {
|
||||
return false
|
||||
@@ -212,7 +220,17 @@ func (m *OIDCAuthenticator) Authenticate(r *http.Request) (*http.Request, bool)
|
||||
claims, newSession, err := m.getClaims(token, r)
|
||||
if err != nil {
|
||||
host, port, _ := net.SplitHostPort(r.RemoteAddr)
|
||||
m.Logger.Error().
|
||||
|
||||
// Expired (or not yet valid) tokens are a normal part of the token
|
||||
// renewal cycle: the client hits the proxy with a token that is just
|
||||
// past its expiry, receives a 401 challenge and refreshes. This is not
|
||||
// a failure of the proxy, so we log it at debug level to avoid noise.
|
||||
logEvent := m.Logger.Debug()
|
||||
if !isExpectedTokenTimeError(err) {
|
||||
logEvent = m.Logger.Error()
|
||||
}
|
||||
|
||||
logEvent.
|
||||
Err(err).
|
||||
Str("authenticator", "oidc").
|
||||
Str("path", r.URL.Path).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"time"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/pkg/oidc"
|
||||
oidcmocks "github.com/opencloud-eu/opencloud/pkg/oidc/mocks"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"go-micro.dev/v4/store"
|
||||
)
|
||||
@@ -106,4 +108,69 @@ var _ = Describe("Authenticating requests", Label("OIDCAuthenticator"), func() {
|
||||
Expect(req2).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
When("the request contains an expired token", func() {
|
||||
var buf bytes.Buffer
|
||||
expiredAuthenticator := &OIDCAuthenticator{
|
||||
OIDCIss: "http://idp.example.com",
|
||||
Logger: log.Logger{zerolog.New(&buf).Level(zerolog.DebugLevel)},
|
||||
oidcClient: &oidcmocks.OIDCClient{},
|
||||
userInfoCache: store.NewMemoryStore(),
|
||||
skipUserInfo: true,
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
expiredAuthenticator.oidcClient.(*oidcmocks.OIDCClient).On("VerifyAccessToken", mock.Anything, mock.Anything).Return(
|
||||
oidc.RegClaimsWithSID{}, jwt.MapClaims{}, jwt.ErrTokenExpired,
|
||||
)
|
||||
})
|
||||
|
||||
It("should reject the request", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/example/path", http.NoBody)
|
||||
req.Header.Set(_headerAuthorization, "Bearer jwt.token.sig")
|
||||
|
||||
req2, valid := expiredAuthenticator.Authenticate(req)
|
||||
|
||||
Expect(valid).To(Equal(false))
|
||||
Expect(req2).To(BeNil())
|
||||
})
|
||||
|
||||
It("should log the authentication failure at debug level", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/example/path", http.NoBody)
|
||||
req.Header.Set(_headerAuthorization, "Bearer jwt.token.sig")
|
||||
|
||||
expiredAuthenticator.Authenticate(req)
|
||||
|
||||
Expect(buf.String()).To(ContainSubstring("failed to authenticate the request"))
|
||||
Expect(buf.String()).To(ContainSubstring(`"level":"debug"`))
|
||||
Expect(buf.String()).ToNot(ContainSubstring(`"level":"error"`))
|
||||
})
|
||||
})
|
||||
|
||||
When("the request contains a token that cannot be verified", func() {
|
||||
var buf bytes.Buffer
|
||||
failingAuthenticator := &OIDCAuthenticator{
|
||||
OIDCIss: "http://idp.example.com",
|
||||
Logger: log.Logger{zerolog.New(&buf).Level(zerolog.DebugLevel)},
|
||||
oidcClient: &oidcmocks.OIDCClient{},
|
||||
userInfoCache: store.NewMemoryStore(),
|
||||
skipUserInfo: true,
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
failingAuthenticator.oidcClient.(*oidcmocks.OIDCClient).On("VerifyAccessToken", mock.Anything, mock.Anything).Return(
|
||||
oidc.RegClaimsWithSID{}, jwt.MapClaims{}, jwt.ErrTokenMalformed,
|
||||
)
|
||||
})
|
||||
|
||||
It("should log the authentication failure at error level", func() {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.com/example/path", http.NoBody)
|
||||
req.Header.Set(_headerAuthorization, "Bearer jwt.token.sig")
|
||||
|
||||
failingAuthenticator.Authenticate(req)
|
||||
|
||||
Expect(buf.String()).To(ContainSubstring("failed to authenticate the request"))
|
||||
Expect(buf.String()).To(ContainSubstring(`"level":"error"`))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -236,6 +236,16 @@ func (m SignedURLAuthenticator) Authenticate(r *http.Request) (*http.Request, bo
|
||||
}
|
||||
|
||||
func (m SignedURLAuthenticator) authenticate(r *http.Request) (*http.Request, bool) {
|
||||
if err := m.requestMethodIsAllowed(r.Method); err != nil {
|
||||
m.Logger.Error().
|
||||
Err(err).
|
||||
Str("authenticator", "signed_url_jwt").
|
||||
Str("path", r.URL.Path).
|
||||
Str("method", r.Method).
|
||||
Msg("Request method not allowed for signed urls")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
u := r.URL.String()
|
||||
if !r.URL.IsAbs() {
|
||||
u = "https://" + r.Host + u
|
||||
|
||||
@@ -2,11 +2,13 @@ package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
|
||||
"github.com/opencloud-eu/opencloud/pkg/log"
|
||||
"github.com/opencloud-eu/opencloud/services/proxy/pkg/config"
|
||||
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/signedurl"
|
||||
@@ -71,6 +73,27 @@ func TestSignedURLAuth_shouldServe(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedURLAuth_authenticateRejectsDisallowedMethods(t *testing.T) {
|
||||
signer, err := signedurl.NewJWTSignedURL(signedurl.WithSecret("secret"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create signer: %v", err)
|
||||
}
|
||||
signed, err := signer.Sign("https://example.com/dav/spaces/file.txt", "userid", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign url: %v", err)
|
||||
}
|
||||
|
||||
pua := SignedURLAuthenticator{Logger: log.NewLogger(), URLVerifier: signer}
|
||||
pua.PreSignedURLConfig.AllowedHTTPMethods = []string{"GET"}
|
||||
|
||||
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, "PROPFIND", "MOVE"} {
|
||||
r := httptest.NewRequest(method, signed, nil)
|
||||
if _, ok := pua.authenticate(r); ok {
|
||||
t.Errorf("expected %s with a signed url to be rejected", method)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedURLAuth_allRequiredParametersPresent(t *testing.T) {
|
||||
pua := SignedURLAuthenticator{}
|
||||
baseURL := "https://example.com/example.jpg?"
|
||||
|
||||
@@ -107,7 +107,7 @@ As soon as Tika is installed and configured, the search service needs to be told
|
||||
The following settings must be set:
|
||||
|
||||
* `SEARCH_EXTRACTOR_TYPE=tika`
|
||||
* `SEARCH_EXTRACTOR_TIKA_TIKA_URL=http://YOUR-TIKA.URL` (or the shared `OC_TIKA_URL`, which the webdav service reads for thumbnails too)
|
||||
* `SEARCH_EXTRACTOR_TIKA_TIKA_URL=http://YOUR-TIKA.URL`
|
||||
|
||||
Additionally, the following optional settings can be set:
|
||||
|
||||
|
||||
@@ -9,6 +9,6 @@ type Extractor struct {
|
||||
|
||||
// ExtractorTika configures the Tika extractor
|
||||
type ExtractorTika struct {
|
||||
TikaURL string `yaml:"tika_url" env:"OC_TIKA_URL;SEARCH_EXTRACTOR_TIKA_TIKA_URL" desc:"URL of the tika server." introductionVersion:"1.0.0"`
|
||||
TikaURL string `yaml:"tika_url" env:"SEARCH_EXTRACTOR_TIKA_TIKA_URL" desc:"URL of the tika server." introductionVersion:"1.0.0"`
|
||||
CleanStopWords bool `yaml:"clean_stop_words" env:"SEARCH_EXTRACTOR_TIKA_CLEAN_STOP_WORDS" desc:"Defines if stop words should be cleaned or not. See the documentation for more details." introductionVersion:"1.0.0"`
|
||||
}
|
||||
@@ -82,6 +82,9 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document,
|
||||
if err != nil {
|
||||
return doc, err
|
||||
}
|
||||
if len(metas) == 0 {
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
for _, meta := range metas {
|
||||
title, err := getFirstValue(meta, "dc:title")
|
||||
@@ -98,40 +101,21 @@ func (t Tika) Extract(ctx context.Context, ri *provider.ResourceInfo) (Document,
|
||||
} else if content, err := getFirstValue(meta, "X-TIKA:content"); err == nil {
|
||||
doc.Content = strings.TrimSpace(fmt.Sprintf("%s %s", doc.Content, content))
|
||||
}
|
||||
|
||||
// keep facets from earlier entries, an embedded resource's meta
|
||||
// (e.g. cover art) must not reset them
|
||||
if v := t.getLocation(meta); v != nil {
|
||||
doc.Location = v
|
||||
}
|
||||
if v := t.getImage(meta); v != nil {
|
||||
doc.Image = v
|
||||
}
|
||||
if v := t.getPhoto(meta); v != nil {
|
||||
doc.Photo = v
|
||||
}
|
||||
if v := t.getAudio(meta); v != nil {
|
||||
doc.Audio = v
|
||||
}
|
||||
if v := t.getLivePhoto(meta); v != nil {
|
||||
doc.LivePhoto = v
|
||||
}
|
||||
}
|
||||
|
||||
if len(metas) > 0 {
|
||||
// the video facet says the file is a video, so it comes from the file
|
||||
// itself: the clip tika extracts from a motion photo must not make its
|
||||
// image look like one
|
||||
doc.Video = t.getVideo(metas[0])
|
||||
}
|
||||
// facets describe the file itself, not its embedded parts (cover art, clips)
|
||||
m0 := metas[0]
|
||||
doc.Location = t.getLocation(m0)
|
||||
doc.Image = t.getImage(m0)
|
||||
doc.Photo = t.getPhoto(m0)
|
||||
doc.Audio = t.getAudio(m0)
|
||||
doc.LivePhoto = t.getLivePhoto(m0)
|
||||
doc.Video = t.getVideo(m0)
|
||||
|
||||
// a motion photo is the xmp on the file itself plus the video tika extracted
|
||||
// from it. The xmp alone proves nothing: a share can keep it and strip the
|
||||
// appended video.
|
||||
if len(metas) > 0 {
|
||||
if i := slices.IndexFunc(metas[1:], isVideo); i >= 0 {
|
||||
doc.MotionPhoto = t.getMotionPhoto(metas[0], metas[i+1])
|
||||
}
|
||||
// a motion photo is the file's own xmp plus the video tika extracted from
|
||||
// it; the xmp alone proves nothing, a share can strip the appended clip
|
||||
if i := slices.IndexFunc(metas[1:], isVideo); i >= 0 {
|
||||
doc.MotionPhoto = t.getMotionPhoto(m0, metas[i+1])
|
||||
}
|
||||
|
||||
if langCode := t.detectLanguage(ctx, doc.Content); langCode != "" && t.CleanStopWords {
|
||||
|
||||
@@ -170,7 +170,9 @@ var _ = Describe("Tika", func() {
|
||||
Expect(doc.Content).To(Equal("body test stop words!!!"))
|
||||
})
|
||||
|
||||
It("keeps the audio facet when an embedded resource follows", func() {
|
||||
It("takes facets from the main document, not from an embedded resource", func() {
|
||||
// metas[0] is the file (audio), metas[1] its embedded cover art. The
|
||||
// cover must not give the track an image facet.
|
||||
fullResponse = `[{"Content-Type": "audio/mpeg", "dc:title": "Sucker", "tk:content": "lyrics"}, {"Content-Type": "image/jpeg", "tiff:ImageWidth": "500"}]`
|
||||
|
||||
doc, err := tika.Extract(context.TODO(), &provider.ResourceInfo{
|
||||
@@ -180,7 +182,7 @@ var _ = Describe("Tika", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(doc.Audio).ToNot(BeNil())
|
||||
Expect(doc.Audio.Title).To(Equal(libregraph.PtrString("Sucker")))
|
||||
Expect(doc.Image).ToNot(BeNil())
|
||||
Expect(doc.Image).To(BeNil())
|
||||
})
|
||||
|
||||
It("adds no audio facet to non-audio documents", func() {
|
||||
|
||||
@@ -11,7 +11,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: EMAIL\n"
|
||||
"POT-Creation-Date: 2026-08-17 23:18+0000\n"
|
||||
"POT-Creation-Date: 2026-09-07 23:16+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/opencloud-eu/teams/204053/fi/)\n"
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# 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:
|
||||
# Roy-Magne Mo, 2026
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: EMAIL\n"
|
||||
"POT-Creation-Date: 2026-09-06 23:15+0000\n"
|
||||
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
|
||||
"Last-Translator: Roy-Magne Mo, 2026\n"
|
||||
"Language-Team: Norwegian (https://app.transifex.com/opencloud-eu/teams/204053/no/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: no\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#. name of the notification option 'Space Shared'
|
||||
#: pkg/store/defaults/templates.go:24
|
||||
msgid "Added as space member"
|
||||
msgstr "Lagt til som medlem av rommet"
|
||||
|
||||
#. translation for the 'daily' email interval option
|
||||
#: pkg/store/defaults/templates.go:54
|
||||
msgid "Daily"
|
||||
msgstr "Daglig"
|
||||
|
||||
#. name of the notification option 'Email Interval'
|
||||
#: pkg/store/defaults/templates.go:48
|
||||
msgid "Email sending interval"
|
||||
msgstr "E-postsendingens intervall"
|
||||
|
||||
#. name of the notification option 'File Rejected'
|
||||
#: pkg/store/defaults/templates.go:44
|
||||
msgid "File rejected"
|
||||
msgstr "Fil avvist"
|
||||
|
||||
#. translation for the 'instant' email interval option
|
||||
#: pkg/store/defaults/templates.go:52
|
||||
msgid "Instant"
|
||||
msgstr "Øyeblikkelig"
|
||||
|
||||
#. translation for the 'never' email interval option
|
||||
#: pkg/store/defaults/templates.go:58
|
||||
msgid "Never"
|
||||
msgstr "Aldri"
|
||||
|
||||
#. description of the notification option 'Resource Mention'
|
||||
#: pkg/store/defaults/templates.go:18
|
||||
msgid "Notify on resource mentions"
|
||||
msgstr "Varsle når ressurs nevnes"
|
||||
|
||||
#. description of the notification option 'Space Shared'
|
||||
#: pkg/store/defaults/templates.go:26
|
||||
msgid "Notify when I have been added as a member to a space"
|
||||
msgstr "Varsle når jeg har blitt lagt til som medlem i et rom"
|
||||
|
||||
#. description of the notification option 'Space Unshared'
|
||||
#: pkg/store/defaults/templates.go:30
|
||||
msgid "Notify when I have been removed as member from a space"
|
||||
msgstr "Varsle når jeg har blitt fjernet som medlem fra et rom"
|
||||
|
||||
#. description of the notification option 'Share Received'
|
||||
#: pkg/store/defaults/templates.go:10
|
||||
msgid "Notify when I have received a share"
|
||||
msgstr "Varsle når jeg har mottatt en deling"
|
||||
|
||||
#. description of the notification option 'File Rejected'
|
||||
#: pkg/store/defaults/templates.go:46
|
||||
msgid ""
|
||||
"Notify when a file I uploaded was rejected because of a virus infection or "
|
||||
"policy violation"
|
||||
msgstr ""
|
||||
"Varsle når en fil jeg lastet opp ble avvist på grunn av et virus eller et "
|
||||
"brudd på retningslinjene"
|
||||
|
||||
#. description of the notification option 'Share Removed'
|
||||
#: pkg/store/defaults/templates.go:14
|
||||
msgid "Notify when a received share has been removed"
|
||||
msgstr "Varsle når en mottatt deling er fjernet"
|
||||
|
||||
#. description of the notification option 'Share Expired'
|
||||
#: pkg/store/defaults/templates.go:22
|
||||
msgid "Notify when a received share has expired"
|
||||
msgstr "Varsle når en mottatt deling er utløpt"
|
||||
|
||||
#. description of the notification option 'Space Deleted'
|
||||
#: pkg/store/defaults/templates.go:42
|
||||
msgid "Notify when a space I am member of has been deleted"
|
||||
msgstr "Varsle når et rom jeg er medlem av har blitt slettet"
|
||||
|
||||
#. description of the notification option 'Space Disabled'
|
||||
#: pkg/store/defaults/templates.go:38
|
||||
msgid "Notify when a space I am member of has been disabled"
|
||||
msgstr "Varsle når et rom jeg er medlem av har blitt deaktivert"
|
||||
|
||||
#. description of the notification option 'Space Membership Expired'
|
||||
#: pkg/store/defaults/templates.go:34
|
||||
msgid "Notify when a space membership has expired"
|
||||
msgstr "Varsle når et medlemskap i et rom har utløpt"
|
||||
|
||||
#. name of the notification option 'Space Unshared'
|
||||
#: pkg/store/defaults/templates.go:28
|
||||
msgid "Removed as space member"
|
||||
msgstr "Fjernet som medlemskap i rommet"
|
||||
|
||||
#. name of the notification option 'Resource Mention'
|
||||
#: pkg/store/defaults/templates.go:16
|
||||
msgid "Resource Mention"
|
||||
msgstr "Ressursnevnt"
|
||||
|
||||
#. description of the notification option 'Email Interval'
|
||||
#: pkg/store/defaults/templates.go:50
|
||||
msgid "Selected value:"
|
||||
msgstr "Valgt verdi:"
|
||||
|
||||
#. name of the notification option 'Share Expired'
|
||||
#: pkg/store/defaults/templates.go:20
|
||||
msgid "Share Expired"
|
||||
msgstr "Deling utløpt"
|
||||
|
||||
#. name of the notification option 'Share Received'
|
||||
#: pkg/store/defaults/templates.go:8
|
||||
msgid "Share Received"
|
||||
msgstr "Deling mottatt"
|
||||
|
||||
#. name of the notification option 'Share Removed'
|
||||
#: pkg/store/defaults/templates.go:12
|
||||
msgid "Share Removed"
|
||||
msgstr "Deling fjernet"
|
||||
|
||||
#. name of the notification option 'Space Deleted'
|
||||
#: pkg/store/defaults/templates.go:40
|
||||
msgid "Space deleted"
|
||||
msgstr "Plass slettet"
|
||||
|
||||
#. name of the notification option 'Space Disabled'
|
||||
#: pkg/store/defaults/templates.go:36
|
||||
msgid "Space disabled"
|
||||
msgstr "Rom deaktivert"
|
||||
|
||||
#. name of the notification option 'Space Membership Expired'
|
||||
#: pkg/store/defaults/templates.go:32
|
||||
msgid "Space membership expired"
|
||||
msgstr "Medlemskap i rommet utløpt"
|
||||
|
||||
#. translation for the 'weekly' email interval option
|
||||
#: pkg/store/defaults/templates.go:56
|
||||
msgid "Weekly"
|
||||
msgstr "Ukentlig"
|
||||
@@ -12,7 +12,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: EMAIL\n"
|
||||
"POT-Creation-Date: 2026-08-16 23:16+0000\n"
|
||||
"POT-Creation-Date: 2026-09-05 23:16+0000\n"
|
||||
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
|
||||
"Last-Translator: Giang Le, 2026\n"
|
||||
"Language-Team: Vietnamese (https://app.transifex.com/opencloud-eu/teams/204053/vi/)\n"
|
||||
|
||||
@@ -1,59 +1,95 @@
|
||||
# Thumbnails
|
||||
|
||||
The thumbnails service is a stateless image resizer. It exposes an imagor-compatible push endpoint that accepts an original image as a multipart upload and returns the resized thumbnail encoded in the requested format.
|
||||
The thumbnails service is a stateless image resizer. It exposes an
|
||||
imagor-compatible push endpoint that accepts an original image as a multipart
|
||||
upload and returns the resized thumbnail encoded in the requested format.
|
||||
|
||||
> **Warning:** This service performs no authentication or authorization. By default it binds to `127.0.0.1:9186` so only local processes (the webdav service) can reach it. If you change `THUMBNAILS_HTTP_ADDR` to a non-loopback address, put it behind your reverse proxy's auth layer or an internal network boundary — anyone who can reach the endpoint can upload and retrieve arbitrary images through it.
|
||||
> **Warning:** This service performs no authentication or authorization. By
|
||||
> default it binds to `127.0.0.1:9186` so only local processes (the webdav
|
||||
> service) can reach it. If you change `THUMBNAILS_HTTP_ADDR` to a non-loopback
|
||||
> address, put it behind your reverse proxy's auth layer or an internal network
|
||||
> boundary — anyone who can reach the endpoint can upload and retrieve
|
||||
> arbitrary images through it.
|
||||
|
||||
## Push Endpoint
|
||||
|
||||
The webdav service (which owns the complete thumbnail workflow) POSTs the source file to this endpoint and receives the processed image back.
|
||||
The webdav service (which owns the complete thumbnail workflow) POSTs the
|
||||
source file to this endpoint and receives the processed image back.
|
||||
|
||||
| Route | Description |
|
||||
|---|---|
|
||||
| `POST /unsafe/{width}x{height}` (optionally `/filters:format({format})`) | Fill the exact width x height (center crop, may upscale) — the default |
|
||||
| `POST /unsafe/fit-in/{width}x{height}` (optionally `/filters:format({format})`) | Scale to fit within width x height, preserving aspect ratio and never upscaling (letterboxed) |
|
||||
| `POST /unsafe/stretch/{width}x{height}` (optionally `/filters:format({format})`) | Resize to the exact width x height without preserving aspect ratio (distorts) |
|
||||
| Route | Description |
|
||||
| -------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| `POST /unsafe/{width}x{height}` (optionally `/filters:format({format})`) | Fill the exact width x height (center crop, may upscale) — the default |
|
||||
| `POST /unsafe/fit-in/{width}x{height}` (optionally `/filters:format({format})`) | Scale to fit within width x height, preserving aspect ratio and never upscaling (letterboxed) |
|
||||
| `POST /unsafe/stretch/{width}x{height}` (optionally `/filters:format({format})`) | Resize to the exact width x height without preserving aspect ratio (distorts) |
|
||||
|
||||
The filters segment is captured whole and parsed; the `format` and `no_upscale` filters are meaningful to this executor, other filters are ignored. The `format` filter is optional: when absent the input's own format is preserved (detected from the image header), matching imagor. Inputs we cannot re-encode (e.g. webp, tiff, bmp) fall back to JPEG, mirroring imagor's default for unsavable sources.
|
||||
The filters segment is captured whole and parsed; the `format` and `no_upscale`
|
||||
filters are meaningful to this executor, other filters are ignored. The
|
||||
`format` filter is optional: when absent the input's own format is preserved
|
||||
(detected from the image header), matching imagor. Inputs we cannot re-encode
|
||||
(e.g. webp, tiff, bmp) fall back to JPEG, mirroring imagor's default for
|
||||
unsavable sources.
|
||||
|
||||
Like real imagor, the default fill resize **upscales** small sources to fill the box exactly (a 100x100 source requested at `320x320` returns a 320x320 image). Adding the `no_upscale()` filter caps the result at the source size instead (the same request returns 100x100). The `fit-in` route never upscales regardless of filters.
|
||||
Like real imagor, the default fill resize **upscales** small sources to fill
|
||||
the box exactly (a 100x100 source requested at `320x320` returns a 320x320
|
||||
image). Adding the `no_upscale()` filter caps the result at the source size
|
||||
instead (the same request returns 100x100). The `fit-in` route never upscales
|
||||
regardless of filters.
|
||||
|
||||
The webdav service selects the route based on the requested processor. The full mapping is:
|
||||
The webdav service selects the route based on the requested processor. The full
|
||||
mapping is:
|
||||
|
||||
| Request | Operation |
|
||||
|---|---|
|
||||
| `processor=resize` | stretch (distort to the exact box) |
|
||||
| `processor=fill` or `processor=thumbnail` | fill (center-crop to the exact box, may upscale) |
|
||||
| `processor=fit` / `processor=fit-in` | fit-in (preserve aspect, fit in box, never upscale) |
|
||||
| no processor, gif source | stretch (resize for gifs) |
|
||||
| no processor, other sources | fill (default = thumbnail = fill) |
|
||||
| Request | Operation |
|
||||
| ----------------------------------------- | --------------------------------------------------- |
|
||||
| `processor=resize` | stretch (distort to the exact box) |
|
||||
| `processor=fill` or `processor=thumbnail` | fill (center-crop to the exact box, may upscale) |
|
||||
| `processor=fit` / `processor=fit-in` | fit-in (preserve aspect, fit in box, never upscale) |
|
||||
| no processor, gif source | stretch (resize for gifs) |
|
||||
| no processor, other sources | fill (default = thumbnail = fill) |
|
||||
|
||||
By default (no processor, non-gif) the fill form is used, which center-crops to the exact box and upscales small sources — this matches the legacy `thumbnail` processor behavior (e.g. a 200x100 image requested at 100x100 returns a square 100x100 image). Requesting `processor=fit` switches to the `fit-in` form, which preserves aspect ratio and never upscales, letterboxing a non-square source into the box (the same image returns 100x50).
|
||||
By default (no processor, non-gif) the fill form is used, which center-crops to
|
||||
the exact box and upscales small sources — this matches the legacy `thumbnail`
|
||||
processor behavior (e.g. a 200x100 image requested at 100x100 returns a square
|
||||
100x100 image). Requesting `processor=fit` switches to the `fit-in` form, which
|
||||
preserves aspect ratio and never upscales, letterboxing a non-square source
|
||||
into the box (the same image returns 100x50).
|
||||
|
||||
### Legacy `a` parameter
|
||||
|
||||
The webdav preview endpoint also accepts a legacy `a` flag: `a=1` (or absent) means "preserve aspect" (fit-in), `a=0` means "fill". An explicit `processor` always wins over `a`. When an explicit processor overrides a contradictory `a`, the thumbnail response includes the header `X-OpenCloud-Thumbnail-Aspect-Ignored` so developers can tell their client to send a consistent request.
|
||||
The webdav preview endpoint also accepts a legacy `a` flag: `a=1` (or absent)
|
||||
means "preserve aspect" (fit-in), `a=0` means "fill". An explicit `processor`
|
||||
always wins over `a`. When an explicit processor overrides a contradictory `a`,
|
||||
the thumbnail response includes the header
|
||||
`X-OpenCloud-Thumbnail-Aspect-Ignored` so developers can tell their client to
|
||||
send a consistent request.
|
||||
|
||||
The request body is a `multipart/form-data` upload with a single file field named `image`. Supported output formats are `jpg`, `png`, and `gif`.
|
||||
The request body is a `multipart/form-data` upload with a single file field
|
||||
named `image`. Supported output formats are `jpg`, `png`, and `gif`.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Environment variable | Description |
|
||||
|---|---|
|
||||
| `THUMBNAILS_HTTP_ADDR` | Bind address of the HTTP service (default `127.0.0.1:9186`) |
|
||||
| `THUMBNAILS_LOG_LEVEL` | Log level (`panic`, `fatal`, `error`, `warn`, `info`, `debug`, `trace`) |
|
||||
| Environment variable | Description |
|
||||
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `THUMBNAILS_HTTP_ADDR` | Bind address of the HTTP service (default `127.0.0.1:9186`) |
|
||||
| `THUMBNAILS_LOG_LEVEL` | Log level (`panic`, `fatal`, `error`, `warn`, `info`, `debug`, `trace`) |
|
||||
| `THUMBNAILS_MAX_CONCURRENT_REQUESTS` | Maximum number of thumbnail requests decoded and resized in parallel. Default is 0 (unlimited). Requests arriving while the limit is reached get HTTP 429 with a `Retry-After` header |
|
||||
|
||||
## Using libvips for Image Processing
|
||||
|
||||
To improve performance and to support a wider range of image formats, the
|
||||
thumbnails service is able to utilize the
|
||||
[libvips library](https://www.libvips.org/) for image processing. Support for
|
||||
libvips needs to be enabled at buildtime and has a couple of implications:
|
||||
|
||||
To improve performance and to support a wider range of image formats, the thumbnails service is able to utilize the [libvips library](https://www.libvips.org/) for image processing. Support for libvips needs to be enabled at buildtime and has a couple of implications:
|
||||
* With libvips support enabled, it is not possible to create a statically
|
||||
linked OpenCloud binary.
|
||||
* Therefore, the libvips shared libraries need to be available at runtime in
|
||||
the same release that was used to build the OpenCloud binary.
|
||||
* When using the OpenCloud docker images, the libvips shared libraries are
|
||||
included in the image and are correctly embedded.
|
||||
|
||||
* With libvips support enabled, it is not possible to create a statically linked OpenCloud binary.
|
||||
* Therefore, the libvips shared libraries need to be available at runtime in the same release that was used to build the OpenCloud binary.
|
||||
* When using the OpenCloud docker images, the libvips shared libraries are included in the image and are correctly embedded.
|
||||
|
||||
Support of libvips is disabled by default. To enable it, make sure libvips and its buildtime dependencies are installed in your build environment. For macOS users, add the build time dependencies via:
|
||||
Support of libvips is disabled by default. To enable it, make sure libvips
|
||||
and its buildtime dependencies are installed in your build environment. For
|
||||
macOS users, add the build time dependencies via:
|
||||
|
||||
```shell
|
||||
brew install vips pkg-config
|
||||
@@ -72,5 +108,6 @@ Or include the `enable_vips` build tag in the `go build` command:
|
||||
go build -tags enable_vips -o opencloud -o bin/opencloud ./cmd/opencloud
|
||||
```
|
||||
|
||||
When building a docker image using the Dockerfile in the top-level directory of OpenCloud, libvips support is enabled and the libvips shared libraries are included
|
||||
in the resulting docker image.
|
||||
When building a docker image using the Dockerfile in the top-level directory of
|
||||
OpenCloud, libvips support is enabled and the libvips shared libraries are
|
||||
included in the resulting docker image.
|
||||
@@ -323,9 +323,8 @@ func isGifReader(r io.Reader) bool {
|
||||
// For gif input it returns a *gif.GIF with every frame resized (preserving
|
||||
// animation); for other inputs it returns a single image.Image. The operation
|
||||
// selects the resize/crop mode (fill, fit-in, stretch). noUpscale mirrors
|
||||
// imagor's no_upscale() filter: when set, the default fill resize must not
|
||||
// enlarge the source (fit-in already never upscales; stretch is exact and
|
||||
// ignores it).
|
||||
// imagor's no_upscale() filter: when set, fill and fit-in must not enlarge the
|
||||
// source (stretch is exact and ignores it).
|
||||
var processImage func(io.Reader, int, int, string, bool) (any, error)
|
||||
|
||||
// encodeJPEG and encodePNG write the processed image to w in the requested
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// craftDimensionBomb encodes a tiny valid grayscale JPEG, then overwrites the
|
||||
// SOF0 width/height so the header declares huge dimensions while the payload
|
||||
// stays tiny. Feeding this to the endpoint must be rejected by the shared
|
||||
// header check in push.go before any pixel buffer is allocated, regardless of
|
||||
// which imaging backend is compiled in.
|
||||
func craftDimensionBomb(t *testing.T, width, height uint16) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, image.NewGray(image.Rect(0, 0, 8, 8)), &jpeg.Options{Quality: 10}); err != nil {
|
||||
t.Fatalf("encode jpeg: %v", err)
|
||||
}
|
||||
b := buf.Bytes()
|
||||
for i := 0; i+9 < len(b); i++ {
|
||||
if b[i] == 0xff && b[i+1] == 0xc0 {
|
||||
b[i+5], b[i+6] = byte(height>>8), byte(height)
|
||||
b[i+7], b[i+8] = byte(width>>8), byte(width)
|
||||
return b
|
||||
}
|
||||
}
|
||||
t.Fatal("no SOF0 marker in encoded jpeg")
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestPushEndpoint_DimensionBombJPEG pins the OOM-prevention property at its new
|
||||
// home: a tiny file whose JPEG header declares dimensions beyond the configured
|
||||
// max must be rejected with 422 by the shared guard, before processImage runs.
|
||||
func TestPushEndpoint_DimensionBombJPEG(t *testing.T) {
|
||||
mux := newTestMuxWithLimits(7680, 7680)
|
||||
|
||||
bomb := craftDimensionBomb(t, 20000, 20000)
|
||||
body, contentType := createMultipartBody(bomb)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/unsafe/64x64/filters:format(png)/", body)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Errorf("expected status 422 for dimension bomb, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushEndpoint_DimensionBombGIF is the gif variant of the same property: a
|
||||
// gif whose logical screen descriptor declares an oversized canvas must be
|
||||
// rejected with 422 before decoding.
|
||||
func TestPushEndpoint_DimensionBombGIF(t *testing.T) {
|
||||
mux := newTestMuxWithLimits(7680, 7680)
|
||||
|
||||
// LSD declares a 20000x20000 logical screen; the rest is minimal padding.
|
||||
g := []byte("GIF89a")
|
||||
g = append(g, 0x20, 0x4e, 0x20, 0x4e, 0xf0, 0x00, 0x00) // 20000x20000, gct flag
|
||||
g = append(g, bytes.Repeat([]byte{0}, 6)...) // minimal gct + terminator-ish
|
||||
|
||||
body, contentType := createMultipartBody(g)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/unsafe/64x64/filters:format(gif)/", body)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Errorf("expected status 422 for oversized gif dimension bomb, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,8 @@ func init() {
|
||||
// processImageImaging resizes the input using the imaging backend. The operation
|
||||
// selects the resize/crop mode: fill (center-crop to the box, upscaling by default
|
||||
// like real imagor's default resize), fit-in (fit within the box without cropping,
|
||||
// never upscaling), or stretch (resize to the exact box). noUpscale caps the
|
||||
// upscaling small sources by default like real imagor), or stretch (resize to the
|
||||
// exact box). noUpscale caps the
|
||||
// default fill at the source size, mirroring imagor's no_upscale() filter.
|
||||
func processImageImaging(r io.Reader, width, height int, operation string, noUpscale bool) (any, error) {
|
||||
if isGifReader(r) {
|
||||
@@ -44,10 +45,22 @@ func processImageImaging(r io.Reader, width, height int, operation string, noUps
|
||||
case OpStretch:
|
||||
return imaging.Resize(img, width, height, imaging.Lanczos), nil
|
||||
case OpFitIn:
|
||||
if srcBounds.Dx() > width || srcBounds.Dy() > height {
|
||||
return imaging.Fit(img, width, height, imaging.Lanczos), nil
|
||||
// Like real imagor, fit-in fits within the box preserving aspect and
|
||||
// upscales small sources by default; no_upscale() caps it at the source
|
||||
// size. imaging.Fit never enlarges, so a small source is scaled up to the
|
||||
// fitted dimensions explicitly.
|
||||
if noUpscale && srcBounds.Dx() <= width && srcBounds.Dy() <= height {
|
||||
return img, nil
|
||||
}
|
||||
return img, nil
|
||||
if srcBounds.Dx() <= width && srcBounds.Dy() <= height {
|
||||
srcAR := float64(srcBounds.Dx()) / float64(srcBounds.Dy())
|
||||
boxAR := float64(width) / float64(height)
|
||||
if srcAR > boxAR {
|
||||
return imaging.Resize(img, width, int(float64(width)/srcAR), imaging.Lanczos), nil
|
||||
}
|
||||
return imaging.Resize(img, int(float64(height)*srcAR), height, imaging.Lanczos), nil
|
||||
}
|
||||
return imaging.Fit(img, width, height, imaging.Lanczos), nil
|
||||
default: // OpFill
|
||||
if noUpscale && srcBounds.Dx() <= width && srcBounds.Dy() <= height {
|
||||
// imagor no_upscale(): never enlarge the source.
|
||||
@@ -78,10 +91,18 @@ func resizeGIF(m *gif.GIF, width, height int, operation string, noUpscale bool)
|
||||
case OpStretch:
|
||||
processed = imaging.Resize(tmp, width, height, imaging.Lanczos)
|
||||
case OpFitIn:
|
||||
if srcX > width || srcY > height {
|
||||
processed = imaging.Fit(tmp, width, height, imaging.Lanczos)
|
||||
} else {
|
||||
if noUpscale && srcX <= width && srcY <= height {
|
||||
processed = tmp
|
||||
} else if srcX <= width && srcY <= height {
|
||||
srcAR := float64(srcX) / float64(srcY)
|
||||
boxAR := float64(width) / float64(height)
|
||||
if srcAR > boxAR {
|
||||
processed = imaging.Resize(tmp, width, int(float64(width)/srcAR), imaging.Lanczos)
|
||||
} else {
|
||||
processed = imaging.Resize(tmp, int(float64(height)*srcAR), height, imaging.Lanczos)
|
||||
}
|
||||
} else {
|
||||
processed = imaging.Fit(tmp, width, height, imaging.Lanczos)
|
||||
}
|
||||
default: // OpFill
|
||||
if noUpscale && srcX <= width && srcY <= height {
|
||||
|
||||
@@ -185,11 +185,12 @@ func TestPushEndpoint_FitIn_GIFPreservesAnimation(t *testing.T) {
|
||||
if len(out.Image) != 4 {
|
||||
t.Errorf("expected 4 frames preserved, got %d", len(out.Image))
|
||||
}
|
||||
// fit-in must not enlarge the 200x100 source to 50x50 (fit-in never upscales).
|
||||
// fit-in of a 200x100 source into a 50x50 box downscales (no upscaling needed),
|
||||
// so every frame must stay within the box.
|
||||
for i, frame := range out.Image {
|
||||
b := frame.Bounds()
|
||||
if b.Dx() > 200 || b.Dy() > 100 {
|
||||
t.Errorf("frame %d exceeds source dimensions: %dx%d", i, b.Dx(), b.Dy())
|
||||
if b.Dx() > 50 || b.Dy() > 50 {
|
||||
t.Errorf("frame %d exceeds requested dimensions: %dx%d", i, b.Dx(), b.Dy())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,7 +290,10 @@ func TestPushEndpoint_FitIn_AspectRatio(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushEndpoint_FitIn_NoUpscale(t *testing.T) {
|
||||
// TestPushEndpoint_FitIn_UpscalesByDefault documents that, like real imagor,
|
||||
// fit-in upscales a small source to fit the box when no_upscale() is absent. A
|
||||
// 50x50 source requested at 200x200 must come back as exactly 200x200.
|
||||
func TestPushEndpoint_FitIn_UpscalesByDefault(t *testing.T) {
|
||||
mux := newTestMux()
|
||||
|
||||
imgBytes := createTestImage(50, 50)
|
||||
@@ -305,13 +309,42 @@ func TestPushEndpoint_FitIn_NoUpscale(t *testing.T) {
|
||||
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
img, err := png.Decode(rec.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decode response image: %v", err)
|
||||
}
|
||||
bounds := img.Bounds()
|
||||
if bounds.Dx() != 200 || bounds.Dy() != 200 {
|
||||
t.Errorf("fit-in should upscale 50x50 to fit a 200x200 box as 200x200, got %dx%d", bounds.Dx(), bounds.Dy())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushEndpoint_FitIn_NoUpscaleFilter pins imagor's no_upscale() filter on
|
||||
// fit-in: with it, a small source must not be enlarged. A 50x50 source requested
|
||||
// at 200x200 comes back as 50x50 instead of 200x200.
|
||||
func TestPushEndpoint_FitIn_NoUpscaleFilter(t *testing.T) {
|
||||
mux := newTestMux()
|
||||
|
||||
imgBytes := createTestImage(50, 50)
|
||||
body, contentType := createMultipartBody(imgBytes)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/unsafe/fit-in/200x200/filters:no_upscale():format(png)/", body)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
img, err := png.Decode(rec.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decode response image: %v", err)
|
||||
}
|
||||
bounds := img.Bounds()
|
||||
if bounds.Dx() > 50 || bounds.Dy() > 50 {
|
||||
t.Errorf("fit-in should not enlarge the image, got %dx%d", bounds.Dx(), bounds.Dy())
|
||||
t.Errorf("fit-in with no_upscale should not enlarge the image, got %dx%d", bounds.Dx(), bounds.Dy())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,8 +439,8 @@ func TestPushEndpoint_Default_NoUpscaleFilter(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestPushEndpoint_FitIn_LetterboxOutcome pins the opt-in "fit" processor's
|
||||
// outcome: aspect-preserving fit with no upscaling. A 200x100 source requested
|
||||
// at 100x100 must come back as exactly 100x50 — centered/letterboxed in the box,
|
||||
// outcome: aspect-preserving fit within the box. A 200x100 source requested at
|
||||
// 100x100 must come back as exactly 100x50 — centered/letterboxed in the box,
|
||||
// NOT center-cropped to 100x100.
|
||||
func TestPushEndpoint_FitIn_LetterboxOutcome(t *testing.T) {
|
||||
mux := newTestMux()
|
||||
|
||||
@@ -21,9 +21,10 @@ func init() {
|
||||
|
||||
// processImageVips resizes the input using libvips. The operation selects the
|
||||
// resize/crop mode: fill (center-crop to the box, upscaling by default like real
|
||||
// imagor's default resize), fit-in (fit within the box without cropping, never
|
||||
// upscaling), or stretch (resize to the exact box). noUpscale caps the default
|
||||
// fill at the source size, mirroring imagor's no_upscale() filter.
|
||||
// imagor's default resize), fit-in (fit within the box without cropping, upscaling
|
||||
// small sources by default like real imagor), or stretch (resize to the exact box).
|
||||
// noUpscale caps the upscaling at the source size, mirroring imagor's no_upscale()
|
||||
// filter.
|
||||
func processImageVips(r io.Reader, width, height int, operation string, noUpscale bool) (any, error) {
|
||||
if isGifReader(r) {
|
||||
g, err := gif.DecodeAll(r)
|
||||
@@ -56,8 +57,13 @@ func processImageVips(r io.Reader, width, height int, operation string, noUpscal
|
||||
return nil, err
|
||||
}
|
||||
case OpFitIn:
|
||||
// Fit within the box without cropping and never upscale (SizeDown).
|
||||
if err := m.ThumbnailWithSize(width, height, vips.InterestingNone, vips.SizeDown); err != nil {
|
||||
// Like real imagor, fit-in upscales small sources to fit the box by
|
||||
// default (SizeBoth); no_upscale() caps it at the source size (SizeDown).
|
||||
size := vips.SizeBoth
|
||||
if noUpscale {
|
||||
size = vips.SizeDown
|
||||
}
|
||||
if err := m.ThumbnailWithSize(width, height, vips.InterestingNone, size); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default: // OpFill
|
||||
@@ -103,10 +109,10 @@ func resizeGIFVips(m *gif.GIF, width, height int, operation string, noUpscale bo
|
||||
case OpStretch:
|
||||
processed = imaging.Resize(tmp, width, height, imaging.Lanczos)
|
||||
case OpFitIn:
|
||||
if srcX > width || srcY > height {
|
||||
processed = imaging.Fit(tmp, width, height, imaging.Lanczos)
|
||||
} else {
|
||||
if noUpscale && srcX <= width && srcY <= height {
|
||||
processed = tmp
|
||||
} else {
|
||||
processed = imaging.Fit(tmp, width, height, imaging.Lanczos)
|
||||
}
|
||||
default: // OpFill
|
||||
if noUpscale && srcX <= width && srcY <= height {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# 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:
|
||||
# Roy-Magne Mo, 2026
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: EMAIL\n"
|
||||
"POT-Creation-Date: 2026-09-07 23:16+0000\n"
|
||||
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
|
||||
"Last-Translator: Roy-Magne Mo, 2026\n"
|
||||
"Language-Team: Norwegian (https://app.transifex.com/opencloud-eu/teams/204053/no/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: no\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#: pkg/service/templates.go:39
|
||||
msgid "Access to Space {space} lost"
|
||||
msgstr "Tilgang til rom {space} mistet"
|
||||
|
||||
#: pkg/service/templates.go:59
|
||||
msgid "Access to {resource} expired"
|
||||
msgstr "Tilgang til {resource} utløpt"
|
||||
|
||||
#: pkg/service/templates.go:64
|
||||
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 ""
|
||||
"Merk! Instansen vil bli slått av og avinstallert {date}. Last ned all data "
|
||||
"før denne datoen, da ingen tilgang er mulig etter denne datoen."
|
||||
|
||||
#: pkg/service/templates.go:63
|
||||
msgid "Instance will be shut down and deprovisioned"
|
||||
msgstr "Instansen vil bli slått av og fjernet"
|
||||
|
||||
#: pkg/service/templates.go:38
|
||||
msgid "Membership expired"
|
||||
msgstr "Medlemskapet utløpt"
|
||||
|
||||
#: pkg/service/templates.go:13
|
||||
msgid "Policies enforced"
|
||||
msgstr "Retningslinjer håndhevet"
|
||||
|
||||
#: pkg/service/templates.go:23
|
||||
msgid "Removed from Space"
|
||||
msgstr "Fjernet fra rom"
|
||||
|
||||
#: pkg/service/templates.go:43
|
||||
msgid "Resource shared"
|
||||
msgstr "Ressurs delt"
|
||||
|
||||
#: pkg/service/templates.go:48
|
||||
msgid "Resource unshared"
|
||||
msgstr "Ressursen deles ikke"
|
||||
|
||||
#: pkg/service/templates.go:58
|
||||
msgid "Share expired"
|
||||
msgstr "Deling utløpt"
|
||||
|
||||
#: pkg/service/templates.go:33
|
||||
msgid "Space deleted"
|
||||
msgstr "Plass slettet"
|
||||
|
||||
#: pkg/service/templates.go:28
|
||||
msgid "Space disabled"
|
||||
msgstr "Rom deaktivert"
|
||||
|
||||
#: pkg/service/templates.go:18
|
||||
msgid "Space shared"
|
||||
msgstr "Delte rom"
|
||||
|
||||
#: 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 ""
|
||||
"Filen {resource} ble slettet fordi den bryter med denne skyens "
|
||||
"restriksjoner. Dette kan skyldes en ulovlig filtype, potensielt skadelig "
|
||||
"eller forbudt innhold, eller filstørrelsesgrenser."
|
||||
|
||||
#: pkg/service/templates.go:8
|
||||
msgid "Virus found"
|
||||
msgstr "Virus funnet"
|
||||
|
||||
#: pkg/service/templates.go:9
|
||||
msgid "Virus found in {resource}. Upload not possible. Virus: {virus}"
|
||||
msgstr "Virus funnet i {resource}. Opplasting ikke mulig. Virus: {virus}"
|
||||
|
||||
#: pkg/service/templates.go:53
|
||||
msgid "You have been mentioned"
|
||||
msgstr "Du har blitt nevnt"
|
||||
|
||||
#: pkg/service/templates.go:19
|
||||
msgid "{user} added you to Space {space}"
|
||||
msgstr "{user} la deg til i rom {space}"
|
||||
|
||||
#: pkg/service/templates.go:34
|
||||
msgid "{user} deleted Space {space}"
|
||||
msgstr "{user} slettet rom {space}"
|
||||
|
||||
#: pkg/service/templates.go:29
|
||||
msgid "{user} disabled Space {space}"
|
||||
msgstr "{user} deaktivert rom {space}"
|
||||
|
||||
#: pkg/service/templates.go:54
|
||||
msgid "{user} mentioned you in {resource}"
|
||||
msgstr "{user} nevnte deg i {resource}"
|
||||
|
||||
#: pkg/service/templates.go:24
|
||||
msgid "{user} removed you from Space {space}"
|
||||
msgstr "{user} fjernet deg fra rom {space}"
|
||||
|
||||
#: pkg/service/templates.go:44
|
||||
msgid "{user} shared {resource} with you"
|
||||
msgstr "{user} delt {resource} med deg"
|
||||
|
||||
#: pkg/service/templates.go:49
|
||||
msgid "{user} unshared {resource} with you"
|
||||
msgstr "{user} delte ikke {resource} med deg"
|
||||
@@ -10,8 +10,6 @@ Currently, the webdav service handles request for two functionalities, which are
|
||||
|
||||
The webdav service provides various `GET` endpoints to get the thumbnails of a file in authenticated and unauthenticated contexts. It also provides thumbnails for spaces on different endpoints.
|
||||
|
||||
Thumbnails for documents, raw photos, audio cover art and other formats the webdav service cannot render itself come from an Apache Tika server (4.1 or newer) when `OC_TIKA_URL` (or `WEBDAV_TIKA_URL`) is set. Every file that is not a directly supported image is sent to Tika; `OC_TIKA_THUMBNAIL_MIME_TYPES` (or `WEBDAV_TIKA_THUMBNAIL_MIME_TYPES`) restricts that to a list of mime types, where an entry like `image/x-raw-samsung:image/x-samsung-srw` maps a type to the one Tika knows the format by. Without Tika, audio cover art and GeoGebra thumbnails are still extracted by the webdav service itself; those built-in extractors are deprecated.
|
||||
|
||||
Generated thumbnails are cached by the webdav service itself. The cache backend defaults to `file`, storing entries under `$OC_BASE_DATA_PATH/thumbnails/files` (override with `WEBDAV_THUMBNAIL_CACHE_BACKEND` and `WEBDAV_THUMBNAIL_CACHE_DIR`). Use the `s3` backend when running multiple instances behind a load balancer so they share one cache.
|
||||
|
||||
#### Thumbnail Query String Parameters
|
||||
@@ -20,15 +18,15 @@ Clients can request thumbnail previews for files by adding `?preview=1` to the f
|
||||
|
||||
The following query parameters are supported:
|
||||
|
||||
| Parameter | Required | Default Value | Description |
|
||||
|-----------|----------|------------------------------------------------------|---------------------------------------------------------------------------------|
|
||||
| preview | YES | 1 | generates preview |
|
||||
| x | YES | first x-value configured in `WEBDAV_THUMBNAIL_RESOLUTIONS` | horizontal target size |
|
||||
| y | YES | first y-value configured in `WEBDAV_THUMBNAIL_RESOLUTIONS` | vertical target size |
|
||||
| scalingup | NO | 0 | accepted for compatibility but ignored; the generator never upscales via this flag |
|
||||
| a | NO | 1 | aspect ratio (legacy; only honored when no explicit `processor` is given) |
|
||||
| c | NO | Caching string | Clients should send the etag, so they get a fresh thumbnail after a file change |
|
||||
| processor | NO | `resize` for gifs and `thumbnail` for all others | preferred thumbnail processor |
|
||||
| Parameter | Required | Default Value | Description |
|
||||
| --------- | -------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| preview | YES | 1 | generates preview |
|
||||
| x | YES | first x-value configured in `WEBDAV_THUMBNAIL_RESOLUTIONS` | horizontal target size |
|
||||
| y | YES | first y-value configured in `WEBDAV_THUMBNAIL_RESOLUTIONS` | vertical target size |
|
||||
| scalingup | NO | 0 | accepted for compatibility but ignored; not forwarded to the generator |
|
||||
| a | NO | 1 | aspect ratio (legacy; only honored when no explicit `processor` is given) |
|
||||
| c | NO | Caching string | Clients should send the etag, so they get a fresh thumbnail after a file change |
|
||||
| processor | NO | `resize` for gifs and `thumbnail` for all others | preferred thumbnail processor |
|
||||
|
||||
#### Thumbnail Resolution
|
||||
|
||||
|
||||
@@ -38,8 +38,6 @@ type Config struct {
|
||||
ThumbnailCacheS3AccessKey string `yaml:"thumbnail_cache_s3_access_key" env:"WEBDAV_THUMBNAIL_CACHE_S3_ACCESS_KEY" desc:"S3 access key for thumbnail cache authentication." introductionVersion:"1.0.0"`
|
||||
ThumbnailCacheS3SecretKey string `yaml:"thumbnail_cache_s3_secret_key" env:"WEBDAV_THUMBNAIL_CACHE_S3_SECRET_KEY" desc:"S3 secret key for thumbnail cache authentication." introductionVersion:"1.0.0"`
|
||||
FontMapFile string `yaml:"font_map_file" env:"WEBDAV_THUMBNAILS_TXT_FONTMAP_FILE;THUMBNAILS_TXT_FONTMAP_FILE" desc:"The path to a font map file for txt thumbnails." introductionVersion:"1.0.0"`
|
||||
TikaURL string `yaml:"tika_url" env:"OC_TIKA_URL;WEBDAV_TIKA_URL" desc:"URL of an Apache Tika server. When set, thumbnails are generated for documents, raw photos, audio cover art and other formats Tika can extract a preview from." introductionVersion:"%%NEXT%%"`
|
||||
TikaThumbnailMimeTypes []string `yaml:"tika_thumbnail_mime_types" env:"OC_TIKA_THUMBNAIL_MIME_TYPES;WEBDAV_TIKA_THUMBNAIL_MIME_TYPES" desc:"Restrict thumbnail generation via Tika to these mime types. Empty means all types that are not handled directly. An entry can map a type to the one Tika knows the format by, e.g. image/x-raw-samsung:image/x-samsung-srw." introductionVersion:"%%NEXT%%"`
|
||||
|
||||
Context context.Context `yaml:"-"`
|
||||
}
|
||||
@@ -3,8 +3,7 @@ package preprocessor
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNoImageFromAudioFile = errors.New("preprocessor: could not extract image from audio file")
|
||||
ErrNoConverterForExtractedImageFromGgsFile = errors.New("preprocessor: could not find converter for image extracted from ggs file")
|
||||
ErrNoImageFromAudioFile = errors.New("preprocessor: could not extract image from audio file")
|
||||
ErrNoConverterForExtractedImageFromGgsFile = errors.New("preprocessor: could not find converter for image extracted from ggs file")
|
||||
ErrNoConverterForExtractedImageFromAudioFile = errors.New("preprocessor: could not find converter for image extracted from audio file")
|
||||
ErrNoThumbnail = errors.New("preprocessor: the document has no thumbnail")
|
||||
)
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/gif"
|
||||
@@ -40,9 +39,7 @@ func (i GifDecoder) Convert(r io.Reader) (any, error) {
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// GgsDecoder is a converter for the geogebra slides file.
|
||||
//
|
||||
// Deprecated: Tika provides the thumbnail; kept for setups without Tika.
|
||||
// GgsDecoder is a converter for the geogebra slides file
|
||||
type GgsDecoder struct{ thumbnailpath string }
|
||||
|
||||
// Convert reads the ggs file and returns the thumbnail image.
|
||||
@@ -82,9 +79,7 @@ func (g GgsDecoder) Convert(r io.Reader) (any, error) {
|
||||
return nil, errors.Errorf("%s not found", g.thumbnailpath)
|
||||
}
|
||||
|
||||
// AudioDecoder is a converter for the audio file.
|
||||
//
|
||||
// Deprecated: Tika provides the cover art; kept for setups without Tika.
|
||||
// AudioDecoder is a converter for the audio file
|
||||
type AudioDecoder struct{}
|
||||
|
||||
// Convert reads the audio file and extracts the thumbnail image from the id3 tag.
|
||||
@@ -214,9 +209,7 @@ type GGPStruct struct {
|
||||
}
|
||||
}
|
||||
|
||||
// GgpDecoder is a converter for the geogebra pinboard file.
|
||||
//
|
||||
// Deprecated: see GgsDecoder.
|
||||
// GgpDecoder is a converter for the geogebra pinboard file
|
||||
type GgpDecoder struct{}
|
||||
|
||||
// Convert reads the ggp file and returns the first thumbnail image.
|
||||
@@ -320,16 +313,6 @@ func drawWord(canvas *font.Drawer, word string, minX, maxX, incY, maxY fixed.Int
|
||||
}
|
||||
}
|
||||
|
||||
// tikaOr prefers a configured Tika server, else the fallback
|
||||
func tikaOr(opts map[string]any, mimeType string, fallback FileConverter) FileConverter {
|
||||
tika, _ := opts["tika"].(thumbnail.Tika)
|
||||
if !tika.Supports(mimeType) {
|
||||
return fallback
|
||||
}
|
||||
filename, _ := opts["filename"].(string)
|
||||
return TikaThumbnail{tikaURL: tika.URL, filename: filename, contentType: tika.ContentType(mimeType)}
|
||||
}
|
||||
|
||||
// ForType returns the converter for the specified mimeType
|
||||
func ForType(mimeType string, opts map[string]any) FileConverter {
|
||||
// We can ignore the error here because we parse it in IsMimeTypeSupported before and if it fails
|
||||
@@ -365,15 +348,19 @@ func ForType(mimeType string, opts map[string]any) FileConverter {
|
||||
return TxtToImageConverter{
|
||||
fontLoader: fontLoader,
|
||||
}
|
||||
case "application/vnd.geogebra.slides":
|
||||
return GgsDecoder{"_slide0/geogebra_thumbnail.png"}
|
||||
case "application/vnd.geogebra.pinboard":
|
||||
return GgpDecoder{}
|
||||
case "image/gif":
|
||||
return GifDecoder{}
|
||||
case "application/vnd.geogebra.slides":
|
||||
return tikaOr(opts, mimeType, GgsDecoder{"_slide0/geogebra_thumbnail.png"})
|
||||
case "application/vnd.geogebra.pinboard":
|
||||
return tikaOr(opts, mimeType, GgpDecoder{})
|
||||
case "audio/flac", "audio/mpeg", "audio/ogg":
|
||||
return tikaOr(opts, mimeType, AudioDecoder{})
|
||||
case "audio/flac":
|
||||
fallthrough
|
||||
case "audio/mpeg":
|
||||
fallthrough
|
||||
case "audio/ogg":
|
||||
return AudioDecoder{}
|
||||
default:
|
||||
return tikaOr(opts, mimeType, ImageDecoder{})
|
||||
return ImageDecoder{}
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
package preprocessor
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// maxTikaResponse bounds what is read back from Tika
|
||||
const maxTikaResponse = 100 * 1024 * 1024
|
||||
|
||||
var tikaHTTPClient = &http.Client{Timeout: 60 * time.Second}
|
||||
|
||||
// TikaThumbnail asks a Tika server for the document's thumbnail and decodes it.
|
||||
type TikaThumbnail struct {
|
||||
tikaURL string
|
||||
// detection hints
|
||||
filename string
|
||||
contentType string
|
||||
}
|
||||
|
||||
// Convert reads the file and returns its thumbnail as an image.
|
||||
func (t TikaThumbnail) Convert(r io.Reader) (any, error) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
contentType, img, err := tikaThumbnail(t.tikaURL, t.filename, t.contentType, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ForType(contentType, nil).Convert(bytes.NewReader(img))
|
||||
}
|
||||
|
||||
// tikaThumbnail returns the thumbnail Tika found in the document and its content
|
||||
// type. Tika 4.1 marks it as a THUMBNAIL embedded document, so /unpack/all is
|
||||
// enough: no preset, no endpoint of our own.
|
||||
func tikaThumbnail(tikaURL, filename, contentType string, data []byte) (string, []byte, error) {
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPut, strings.TrimRight(tikaURL, "/")+"/unpack/all", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
req.Header.Set("Accept", "application/zip")
|
||||
// the extension is what routes a raw image to its parser
|
||||
if filename != "" {
|
||||
req.Header.Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||
}
|
||||
|
||||
resp, err := tikaHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("tika request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
case http.StatusNoContent:
|
||||
return "", nil, ErrNoThumbnail
|
||||
default:
|
||||
return "", nil, fmt.Errorf("tika unpack returned %s", resp.Status)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxTikaResponse))
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("tika unpack response: %w", err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("tika unpack response: %w", err)
|
||||
}
|
||||
return thumbnailFromUnpack(zr)
|
||||
}
|
||||
|
||||
// vectorThumbnailTypes are thumbnail types nothing here can decode. Office
|
||||
// documents carry their preview as a metafile, so the THUMBNAIL entry is a
|
||||
// vector and the usable image is its rendering.
|
||||
var vectorThumbnailTypes = map[string]struct{}{
|
||||
"image/emf": {},
|
||||
"image/wmf": {},
|
||||
"image/x-emf": {},
|
||||
"image/x-wmf": {},
|
||||
"image/svg+xml": {},
|
||||
}
|
||||
|
||||
// unpacked is one entry of an /unpack/all response: the bytes and the metadata
|
||||
// are separate zip entries, "1.jpg" alongside "1.jpg.metadata.json".
|
||||
type unpacked struct {
|
||||
name string
|
||||
kind string
|
||||
mimeType string
|
||||
idPath string
|
||||
}
|
||||
|
||||
// thumbnailFromUnpack picks the image to use out of an unpack response. Tika
|
||||
// marks the document's preview as THUMBNAIL. For office documents that preview
|
||||
// is a metafile, and the raster we want is the RENDERING nested under it, which
|
||||
// tika only emits when its emf/wmf parser has renderImage turned on.
|
||||
func thumbnailFromUnpack(zr *zip.Reader) (string, []byte, error) {
|
||||
var entries []unpacked
|
||||
for _, f := range zr.File {
|
||||
if !strings.HasSuffix(f.Name, ".metadata.json") {
|
||||
continue
|
||||
}
|
||||
meta, err := readZipJSON(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, unpacked{
|
||||
name: strings.TrimSuffix(f.Name, ".metadata.json"),
|
||||
kind: metadataString(meta, "tk:embedded-resource-type"),
|
||||
mimeType: metadataString(meta, "Content-Type"),
|
||||
idPath: metadataString(meta, "tk:embedded-id-path"),
|
||||
})
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.kind != "THUMBNAIL" {
|
||||
continue
|
||||
}
|
||||
if _, vector := vectorThumbnailTypes[e.mimeType]; !vector {
|
||||
return readThumbnail(zr, e)
|
||||
}
|
||||
if r, ok := renderingOf(entries, e); ok {
|
||||
return readThumbnail(zr, r)
|
||||
}
|
||||
return "", nil, ErrNoThumbnail
|
||||
}
|
||||
|
||||
// a document without a preview of its own can still have been rendered,
|
||||
// a pdf page for instance
|
||||
for _, e := range entries {
|
||||
if e.kind == "RENDERING" {
|
||||
return readThumbnail(zr, e)
|
||||
}
|
||||
}
|
||||
return "", nil, ErrNoThumbnail
|
||||
}
|
||||
|
||||
// renderingOf finds the rendering tika nested under a thumbnail. The rendering
|
||||
// is a child of the image it renders, so its id path extends the thumbnail's.
|
||||
func renderingOf(entries []unpacked, thumbnail unpacked) (unpacked, bool) {
|
||||
for _, e := range entries {
|
||||
if e.kind == "RENDERING" && thumbnail.idPath != "" && strings.HasPrefix(e.idPath, thumbnail.idPath+"/") {
|
||||
return e, true
|
||||
}
|
||||
}
|
||||
return unpacked{}, false
|
||||
}
|
||||
|
||||
func readThumbnail(zr *zip.Reader, e unpacked) (string, []byte, error) {
|
||||
img, err := readZipEntry(zr, e.name)
|
||||
if err != nil || len(img) == 0 {
|
||||
return "", nil, ErrNoThumbnail
|
||||
}
|
||||
return e.mimeType, img, nil
|
||||
}
|
||||
|
||||
// readZipJSON decodes a metadata entry. Tika writes either an object or a
|
||||
// single element list.
|
||||
func readZipJSON(f *zip.File) (map[string]any, error) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
var raw json.RawMessage
|
||||
if err := json.NewDecoder(io.LimitReader(rc, maxTikaResponse)).Decode(&raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var meta map[string]any
|
||||
if err := json.Unmarshal(raw, &meta); err == nil {
|
||||
return meta, nil
|
||||
}
|
||||
var list []map[string]any
|
||||
if err := json.Unmarshal(raw, &list); err != nil || len(list) == 0 {
|
||||
return nil, fmt.Errorf("unexpected metadata shape")
|
||||
}
|
||||
return list[0], nil
|
||||
}
|
||||
|
||||
func readZipEntry(zr *zip.Reader, name string) ([]byte, error) {
|
||||
rc, err := zr.Open(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
return io.ReadAll(io.LimitReader(rc, maxTikaResponse))
|
||||
}
|
||||
|
||||
// metadataString reads a key; values are strings or lists.
|
||||
func metadataString(meta map[string]any, key string) string {
|
||||
switch v := meta[key].(type) {
|
||||
case string:
|
||||
return v
|
||||
case []any:
|
||||
if len(v) > 0 {
|
||||
if s, ok := v[0].(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
package preprocessor
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
. "github.com/onsi/ginkgo"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail"
|
||||
)
|
||||
|
||||
// unpackZip builds what /unpack/all returns: the entries and their metadata
|
||||
// side by side, "1.png" next to "1.png.metadata.json".
|
||||
func unpackZip(entries map[string][]byte, meta map[string]map[string]any) []byte {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
for name, data := range entries {
|
||||
w, err := zw.Create(name)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
_, err = w.Write(data)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
w, err = zw.Create(name + ".metadata.json")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(json.NewEncoder(w).Encode(meta[name])).To(Succeed())
|
||||
}
|
||||
Expect(zw.Close()).To(Succeed())
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func pngBytes() []byte {
|
||||
img := image.NewRGBA(image.Rect(0, 0, 4, 3))
|
||||
img.Set(1, 1, color.RGBA{R: 255, A: 255})
|
||||
var buf bytes.Buffer
|
||||
Expect(png.Encode(&buf, img)).To(Succeed())
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
var _ = Describe("TikaThumbnail", func() {
|
||||
var (
|
||||
server *httptest.Server
|
||||
requests []*http.Request
|
||||
thumbnail func(w http.ResponseWriter, r *http.Request)
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
requests = nil
|
||||
thumbnail = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests = append(requests, r)
|
||||
if r.URL.Path != "/unpack/all" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
thumbnail(w, r)
|
||||
}))
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
server.Close()
|
||||
})
|
||||
|
||||
It("takes the entry Tika marked as the thumbnail, in one request", func() {
|
||||
thumbnail = func(w http.ResponseWriter, r *http.Request) {
|
||||
Expect(r.Method).To(Equal(http.MethodPut))
|
||||
Expect(r.Header.Get("Content-Disposition")).To(ContainSubstring(`filename="shot.nef"`))
|
||||
Expect(r.Header.Get("Content-Type")).To(Equal("image/x-nikon-nef"), "the file's type travels as the detection hint")
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
_, _ = w.Write(unpackZip(
|
||||
map[string][]byte{"0.nef": []byte("the raw itself"), "1.png": pngBytes()},
|
||||
map[string]map[string]any{
|
||||
"0.nef": {"Content-Type": "image/x-nikon-nef"},
|
||||
"1.png": {"Content-Type": "image/png", "tk:embedded-resource-type": "THUMBNAIL"},
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
img, err := TikaThumbnail{tikaURL: server.URL, filename: "shot.nef", contentType: "image/x-nikon-nef"}.Convert(bytes.NewReader([]byte("raw")))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img).ToNot(BeNil())
|
||||
Expect(requests).To(HaveLen(1))
|
||||
})
|
||||
|
||||
It("ignores embedded images that are not the thumbnail", func() {
|
||||
thumbnail = func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
_, _ = w.Write(unpackZip(
|
||||
map[string][]byte{"1.png": pngBytes()},
|
||||
map[string]map[string]any{"1.png": {"Content-Type": "image/png", "tk:embedded-resource-type": "INLINE"}},
|
||||
))
|
||||
}
|
||||
|
||||
_, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("raw")))
|
||||
Expect(err).To(MatchError(ErrNoThumbnail), "an inline image is not a thumbnail, tika 4.0 marks cover art that way")
|
||||
})
|
||||
|
||||
It("takes the rendering when the thumbnail is a metafile", func() {
|
||||
thumbnail = func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
_, _ = w.Write(unpackZip(
|
||||
map[string][]byte{"1.wmf": []byte("vector"), "2.png": pngBytes()},
|
||||
map[string]map[string]any{
|
||||
"1.wmf": {"Content-Type": "image/wmf", "tk:embedded-resource-type": "THUMBNAIL", "tk:embedded-id-path": "/1"},
|
||||
"2.png": {"Content-Type": "image/png", "tk:embedded-resource-type": "RENDERING", "tk:embedded-id-path": "/1/2"},
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
img, err := TikaThumbnail{tikaURL: server.URL, filename: "report.doc"}.Convert(bytes.NewReader([]byte("ole2")))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img).ToNot(BeNil(), "office documents carry a metafile preview, the raster is its rendering")
|
||||
})
|
||||
|
||||
It("reports no thumbnail when the metafile was not rendered", func() {
|
||||
thumbnail = func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
_, _ = w.Write(unpackZip(
|
||||
map[string][]byte{"1.wmf": []byte("vector")},
|
||||
map[string]map[string]any{
|
||||
"1.wmf": {"Content-Type": "image/wmf", "tk:embedded-resource-type": "THUMBNAIL", "tk:embedded-id-path": "/1"},
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
_, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("ole2")))
|
||||
Expect(err).To(MatchError(ErrNoThumbnail), "rendering is off in tika by default")
|
||||
})
|
||||
|
||||
It("does not take a rendering that belongs to another image", func() {
|
||||
thumbnail = func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
_, _ = w.Write(unpackZip(
|
||||
map[string][]byte{"1.wmf": []byte("vector"), "3.png": pngBytes()},
|
||||
map[string]map[string]any{
|
||||
"1.wmf": {"Content-Type": "image/wmf", "tk:embedded-resource-type": "THUMBNAIL", "tk:embedded-id-path": "/1"},
|
||||
"3.png": {"Content-Type": "image/png", "tk:embedded-resource-type": "RENDERING", "tk:embedded-id-path": "/2/3"},
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
_, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("ole2")))
|
||||
Expect(err).To(MatchError(ErrNoThumbnail))
|
||||
})
|
||||
|
||||
It("takes a rendering of the document itself", func() {
|
||||
thumbnail = func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
_, _ = w.Write(unpackZip(
|
||||
map[string][]byte{"1.png": pngBytes()},
|
||||
map[string]map[string]any{
|
||||
"1.png": {"Content-Type": "image/png", "tk:embedded-resource-type": "RENDERING", "tk:embedded-id-path": "/1"},
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
img, err := TikaThumbnail{tikaURL: server.URL, filename: "report.pdf"}.Convert(bytes.NewReader([]byte("pdf")))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(img).ToNot(BeNil(), "a pdf has no preview of its own, a rendered page is one")
|
||||
})
|
||||
|
||||
It("reports a document without a thumbnail", func() {
|
||||
thumbnail = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }
|
||||
|
||||
_, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("zip")))
|
||||
Expect(err).To(MatchError(ErrNoThumbnail))
|
||||
})
|
||||
|
||||
It("reports a failing server", func() {
|
||||
thumbnail = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) }
|
||||
|
||||
_, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("raw")))
|
||||
Expect(err).To(MatchError(ContainSubstring("500")))
|
||||
})
|
||||
|
||||
It("reports a Tika that does not know the route", func() {
|
||||
thumbnail = func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) }
|
||||
|
||||
_, err := TikaThumbnail{tikaURL: server.URL}.Convert(bytes.NewReader([]byte("raw")))
|
||||
Expect(err).To(MatchError(ContainSubstring("404")))
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("ForType with a Tika server", func() {
|
||||
It("routes everything Tika is asked for to Tika, text and gif stay native", func() {
|
||||
opts := map[string]any{"tika": thumbnail.NewTika("http://tika:9998", nil), "filename": "song.mp3"}
|
||||
Expect(ForType("audio/mpeg", opts)).To(BeAssignableToTypeOf(TikaThumbnail{}))
|
||||
Expect(ForType("image/x-nikon-nef", opts)).To(BeAssignableToTypeOf(TikaThumbnail{}))
|
||||
Expect(ForType("application/vnd.geogebra.slides", opts)).To(BeAssignableToTypeOf(TikaThumbnail{}))
|
||||
Expect(ForType("application/pdf", opts)).To(BeAssignableToTypeOf(TikaThumbnail{}))
|
||||
Expect(ForType("text/plain", opts)).To(BeAssignableToTypeOf(TxtToImageConverter{}))
|
||||
Expect(ForType("image/gif", opts)).To(BeAssignableToTypeOf(GifDecoder{}))
|
||||
})
|
||||
|
||||
It("keeps the in-process converters without a Tika server", func() {
|
||||
Expect(ForType("audio/mpeg", nil)).To(BeAssignableToTypeOf(AudioDecoder{}))
|
||||
Expect(ForType("application/vnd.geogebra.slides", nil)).To(BeAssignableToTypeOf(GgsDecoder{}))
|
||||
Expect(ForType("image/x-nikon-nef", nil)).To(BeAssignableToTypeOf(ImageDecoder{}))
|
||||
})
|
||||
|
||||
It("follows a configured list and hands the mapped type to Tika", func() {
|
||||
opts := map[string]any{"tika": thumbnail.NewTika("http://tika:9998", []string{"application/pdf", "image/x-raw-samsung:image/x-samsung-srw"})}
|
||||
Expect(ForType("application/pdf", opts)).To(BeAssignableToTypeOf(TikaThumbnail{}))
|
||||
Expect(ForType("audio/mpeg", opts)).To(BeAssignableToTypeOf(AudioDecoder{}), "not in the configured list")
|
||||
Expect(ForType("image/x-raw-samsung", opts).(TikaThumbnail).contentType).To(Equal("image/x-samsung-srw"))
|
||||
})
|
||||
})
|
||||
@@ -119,7 +119,6 @@ func NewService(opts ...Option) (Service, error) {
|
||||
workflow.WithResolutions(resolutions),
|
||||
workflow.WithWebdavNamespace(conf.WebdavNamespace),
|
||||
workflow.WithFontMapFile(conf.FontMapFile),
|
||||
workflow.WithTika(thumbnail.NewTika(conf.TikaURL, conf.TikaThumbnailMimeTypes)),
|
||||
workflow.WithLogger(options.Logger),
|
||||
workflow.WithStater(workflow.NewGatewayStater(gatewaySelector)),
|
||||
workflow.WithFileDownloader(workflow.NewGatewayFileDownloader(gatewaySelector, httpClient)),
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
package thumbnail
|
||||
|
||||
import (
|
||||
"mime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Tika is the server that thumbnails what the built-in converters cannot.
|
||||
// By default every non-image file is sent and Tika decides.
|
||||
type Tika struct {
|
||||
URL string
|
||||
// our mime type -> Tika's, "" when equal; "*" means every type
|
||||
mimeTypes map[string]string
|
||||
}
|
||||
|
||||
// NewTika parses "mime[:tika-mime]" entries; empty means every type.
|
||||
func NewTika(url string, mimeTypes []string) Tika {
|
||||
t := Tika{URL: url, mimeTypes: map[string]string{}}
|
||||
for _, entry := range mimeTypes {
|
||||
ours, theirs, _ := strings.Cut(strings.ToLower(strings.TrimSpace(entry)), ":")
|
||||
if ours = strings.TrimSpace(ours); ours != "" {
|
||||
t.mimeTypes[ours] = strings.TrimSpace(theirs)
|
||||
}
|
||||
}
|
||||
if len(t.mimeTypes) == 0 {
|
||||
t.mimeTypes["*"] = ""
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// Supports reports whether the type is sent to Tika for its thumbnail.
|
||||
func (t Tika) Supports(mimeType string) bool {
|
||||
if t.URL == "" {
|
||||
return false
|
||||
}
|
||||
m, _, err := mime.ParseMediaType(mimeType)
|
||||
if err != nil || m == "httpd/unix-directory" {
|
||||
return false
|
||||
}
|
||||
if _, ok := t.mimeTypes[m]; ok {
|
||||
return true
|
||||
}
|
||||
_, all := t.mimeTypes["*"]
|
||||
return all
|
||||
}
|
||||
|
||||
// ContentType is the type Tika is told, mapped where configured.
|
||||
func (t Tika) ContentType(mimeType string) string {
|
||||
m, _, err := mime.ParseMediaType(mimeType)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if theirs := t.mimeTypes[m]; theirs != "" {
|
||||
return theirs
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package thumbnail_test
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/opencloud-eu/opencloud/services/webdav/pkg/thumbnail"
|
||||
)
|
||||
|
||||
var _ = Describe("Tika", func() {
|
||||
none := thumbnail.NewTika("", nil)
|
||||
all := thumbnail.NewTika("http://tika:9998", nil)
|
||||
some := thumbnail.NewTika("http://tika:9998", []string{" Application/PDF ", "audio/mpeg", "image/x-raw-samsung:image/x-samsung-srw"})
|
||||
|
||||
DescribeTable("Supports",
|
||||
func(tika thumbnail.Tika, mimeType string, want bool) {
|
||||
Expect(tika.Supports(mimeType)).To(Equal(want))
|
||||
},
|
||||
Entry("nothing without a server", none, "application/pdf", false),
|
||||
Entry("everything by default", all, "application/x-anything; charset=binary", true),
|
||||
Entry("but no directories", all, "httpd/unix-directory", false),
|
||||
Entry("nor an unparsable type", all, "not a mime", false),
|
||||
Entry("a listed type", some, "application/pdf", true),
|
||||
Entry("a listed type with parameters", some, "audio/mpeg; charset=binary", true),
|
||||
Entry("a mapped type", some, "image/x-raw-samsung", true),
|
||||
Entry("an unlisted type", some, "application/zip", false),
|
||||
)
|
||||
|
||||
DescribeTable("ContentType",
|
||||
func(tika thumbnail.Tika, mimeType, want string) {
|
||||
Expect(tika.ContentType(mimeType)).To(Equal(want))
|
||||
},
|
||||
Entry("maps a type to the one Tika knows", some, "image/x-raw-samsung", "image/x-samsung-srw"),
|
||||
Entry("passes an unmapped type through, without parameters", some, "application/pdf; charset=binary", "application/pdf"),
|
||||
Entry("passes everything through by default", all, "audio/mpeg", "audio/mpeg"),
|
||||
)
|
||||
})
|
||||
@@ -69,9 +69,6 @@ type UserResolver interface {
|
||||
// surface this as HTTP 425 Too Early with a Retry-After header.
|
||||
var ErrFileProcessing = fmt.Errorf("file is processing")
|
||||
|
||||
// ErrNoThumbnail is returned when the file has no thumbnail; cached.
|
||||
var ErrNoThumbnail = errors.New("thumbnails: the file has no thumbnail")
|
||||
|
||||
// ErrImageTooLarge is returned when the input image exceeds the configured
|
||||
// maximum width/height. Callers should surface this as HTTP 403 Forbidden,
|
||||
// matching the legacy thumbnail service error message.
|
||||
@@ -92,6 +89,11 @@ var ErrPermissionDenied = errors.New("thumbnails: no download permission")
|
||||
// "File could not be located" message, matching the legacy thumbnail service.
|
||||
var ErrNotFound = errors.New("thumbnails: file could not be located")
|
||||
|
||||
// ErrNoStorageSpace is returned when no storage space owns the requested path,
|
||||
// e.g. a path in another user's home or a non-existent path. Callers should
|
||||
// surface this as HTTP 404 Not Found, matching the legacy thumbnail service.
|
||||
var ErrNoStorageSpace = errors.New("thumbnails: no storage space found for path")
|
||||
|
||||
// ErrUnsupportedFileType is returned when the file's mime type is not in the
|
||||
// supported set (e.g. PDF, ODT, ZIP). Callers should surface this as HTTP 404
|
||||
// Not Found with an "Unsupported file type" message, matching the legacy
|
||||
@@ -136,7 +138,6 @@ type ThumbnailWorkflow struct {
|
||||
maxInputSize uint64
|
||||
resolutions *thumbnail.Resolutions
|
||||
webdavNS string
|
||||
tika thumbnail.Tika
|
||||
fontMapFile string
|
||||
log log.Logger
|
||||
stater Stater
|
||||
@@ -206,11 +207,6 @@ func WithWebdavNamespace(ns string) Option {
|
||||
}
|
||||
|
||||
// WithFontMapFile sets the font map file used for text thumbnail rendering.
|
||||
// WithTika sets the Tika server the preprocessing falls back to.
|
||||
func WithTika(t thumbnail.Tika) Option {
|
||||
return func(w *ThumbnailWorkflow) { w.tika = t }
|
||||
}
|
||||
|
||||
func WithFontMapFile(file string) Option {
|
||||
return func(w *ThumbnailWorkflow) { w.fontMapFile = file }
|
||||
}
|
||||
@@ -289,7 +285,7 @@ func (w *ThumbnailWorkflow) Head(ctx context.Context, tr *requests.ThumbnailRequ
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
|
||||
if !w.supportsMimeType(info.GetMimeType()) {
|
||||
if !thumbnail.IsMimeTypeSupported(info.GetMimeType()) {
|
||||
return fmt.Errorf("%w: %s", ErrUnsupportedFileType, info.GetMimeType())
|
||||
}
|
||||
|
||||
@@ -333,6 +329,12 @@ func (w *ThumbnailWorkflow) resolveReference(ctx context.Context, tr *requests.T
|
||||
|
||||
resolved, err := w.spaceLookup.Resolve(ctx, &providerv1beta1.Reference{Path: absPath}, auth)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNoStorageSpace) {
|
||||
// No storage space owns the path (e.g. a file in another user's home
|
||||
// or a non-existent path). Surface it as 404, matching the legacy
|
||||
// thumbnail service.
|
||||
return nil, fmt.Errorf("%w: %v", ErrNotFound, err)
|
||||
}
|
||||
return nil, fmt.Errorf("resolve space for path %q: %w", absPath, err)
|
||||
}
|
||||
return resolved, nil
|
||||
@@ -391,7 +393,7 @@ func (w *ThumbnailWorkflow) generate(ctx context.Context, ref *providerv1beta1.R
|
||||
return nil, "", false, ErrPermissionDenied
|
||||
}
|
||||
|
||||
if !w.supportsMimeType(info.GetMimeType()) {
|
||||
if !thumbnail.IsMimeTypeSupported(info.GetMimeType()) {
|
||||
return nil, "", false, fmt.Errorf("%w: %s", ErrUnsupportedFileType, info.GetMimeType())
|
||||
}
|
||||
|
||||
@@ -419,9 +421,11 @@ func (w *ThumbnailWorkflow) generate(ctx context.Context, ref *providerv1beta1.R
|
||||
|
||||
// The operation is resolved up front so it can be part of the cache key: a
|
||||
// fill and a fit-in request with the same dimensions produce different images
|
||||
// and must not share a cache entry. aIgnored reports whether the legacy "a"
|
||||
// flag was overridden by an explicit processor, so the client can be told.
|
||||
operation, aIgnored := w.matchOperation(tr, mimeType)
|
||||
// and must not share a cache entry. noUpscale reports whether the generator
|
||||
// must not enlarge the source (fit-in only); aIgnored reports whether the
|
||||
// legacy "a" flag was overridden by an explicit processor, so the client can
|
||||
// be told.
|
||||
operation, noUpscale, aIgnored := w.matchOperation(tr, mimeType)
|
||||
|
||||
// The cache key uses the requested resolution (like main's PrepareRequest),
|
||||
// so it can be computed before downloading. Checking the cache first means a
|
||||
@@ -430,10 +434,6 @@ func (w *ThumbnailWorkflow) generate(ctx context.Context, ref *providerv1beta1.R
|
||||
|
||||
if w.cache != nil {
|
||||
if cached, err := w.cache.Get(cacheKey); err == nil {
|
||||
// empty entry: known to have no thumbnail
|
||||
if len(cached) == 0 {
|
||||
return nil, "", false, ErrNoThumbnail
|
||||
}
|
||||
return cached, outputExt, aIgnored, nil
|
||||
}
|
||||
}
|
||||
@@ -447,11 +447,11 @@ func (w *ThumbnailWorkflow) generate(ctx context.Context, ref *providerv1beta1.R
|
||||
box = w.resolutions.Match(reqBox)
|
||||
}
|
||||
|
||||
// webdav never asks the generator to upscale: it snaps onto a resolution at
|
||||
// least as large as requested, so the box is always >= the source intent.
|
||||
// The no_upscale() filter makes the generator behave like real imagor for
|
||||
// any other client that posts smaller boxes.
|
||||
genURL := generator.BuildURL(w.generatorURL, int32(box.Dx()), int32(box.Dy()), operation, outputExt, true)
|
||||
// webdav snaps the requested size onto a configured resolution and lets the
|
||||
// generator fill that box. Only fit-in suppresses upscaling (noUpscale); for
|
||||
// fill/thumbnail/resize the generator may enlarge a small source (e.g. a text
|
||||
// file rendered at a fixed canvas) to fill the snapped box, matching main.
|
||||
genURL := generator.BuildURL(w.generatorURL, int32(box.Dx()), int32(box.Dy()), operation, outputExt, noUpscale)
|
||||
|
||||
// Produce the source image to send to the generator, never decoding an image
|
||||
// in webdav: real images (incl. gif) are streamed straight through undecoded;
|
||||
@@ -508,15 +508,8 @@ func (w *ThumbnailWorkflow) sourceImage(ctx context.Context, ref *providerv1beta
|
||||
return nil, nil, fmt.Errorf("read source: %w", err)
|
||||
}
|
||||
|
||||
ppOpts := map[string]any{
|
||||
"fontFileMap": w.fontMapFile,
|
||||
"tika": w.tika,
|
||||
"filename": filename,
|
||||
}
|
||||
ppOpts := map[string]any{"fontFileMap": w.fontMapFile}
|
||||
img, err := preprocessor.ForType(mimeType, ppOpts).Convert(bytes.NewReader(fileBytes))
|
||||
if errors.Is(err, preprocessor.ErrNoThumbnail) {
|
||||
return nil, nil, ErrNoThumbnail
|
||||
}
|
||||
if img == nil || err != nil {
|
||||
logger.Debug().Err(err).Msg("could not convert file to image")
|
||||
return nil, nil, fmt.Errorf("%w: could not get image", ErrNotFound)
|
||||
@@ -544,10 +537,14 @@ func (w *ThumbnailWorkflow) sourceImage(ctx context.Context, ref *providerv1beta
|
||||
// "thumbnail" and "fit" are legacy aliases. They map onto the imagor operations
|
||||
// above but IGNORE the legacy "a" and "scalingup" query parameters — callers that
|
||||
// need aspect/upscaling control should use the imagor names directly ("fill",
|
||||
// "fit-in", "stretch"). The second return value reports whether the legacy "a"
|
||||
// flag was ignored because an explicit processor overrode it with a different
|
||||
// behavior, so the caller can tell the client via a response header.
|
||||
func (w *ThumbnailWorkflow) matchOperation(tr *requests.ThumbnailRequest, mimeType string) (string, bool) {
|
||||
// "fit-in", "stretch"). The second return value reports whether upscaling must be
|
||||
// suppressed (no_upscale): only fit-in never enlarges its source, so it is true
|
||||
// exactly for the fit/fit-in processors; fill, thumbnail and resize all allow the
|
||||
// generator to enlarge a small source to fill the snapped box. The third return
|
||||
// value reports whether the legacy "a" flag was ignored because an explicit
|
||||
// processor overrode it with a different behavior, so the caller can tell the
|
||||
// client via a response header.
|
||||
func (w *ThumbnailWorkflow) matchOperation(tr *requests.ThumbnailRequest, mimeType string) (string, bool, bool) {
|
||||
proc := strings.ToLower(strings.TrimSpace(tr.Processor))
|
||||
|
||||
var operation string
|
||||
@@ -566,6 +563,8 @@ func (w *ThumbnailWorkflow) matchOperation(tr *requests.ThumbnailRequest, mimeTy
|
||||
}
|
||||
}
|
||||
|
||||
noUpscale := operation == generator.OpFitIn
|
||||
|
||||
// The legacy "a" flag is a hint (a=1/absent -> preserve aspect / fit-in,
|
||||
// a=0 -> fill). An explicit processor always wins over it; report when the
|
||||
// processor's behavior contradicts what "a" asked so the client can be told
|
||||
@@ -581,7 +580,7 @@ func (w *ThumbnailWorkflow) matchOperation(tr *requests.ThumbnailRequest, mimeTy
|
||||
}
|
||||
}
|
||||
|
||||
return operation, aIgnored
|
||||
return operation, noUpscale, aIgnored
|
||||
}
|
||||
|
||||
// postToGenerator streams the source image to the generator as a multipart
|
||||
@@ -658,11 +657,6 @@ func (w *ThumbnailWorkflow) postToGenerator(ctx context.Context, url string, img
|
||||
return rspData, nil
|
||||
}
|
||||
|
||||
// supportsMimeType is the built-in list plus what Tika is asked for.
|
||||
func (w *ThumbnailWorkflow) supportsMimeType(mimeType string) bool {
|
||||
return thumbnail.IsMimeTypeSupported(mimeType) || w.tika.Supports(mimeType)
|
||||
}
|
||||
|
||||
// gatewaySelector is the interface for selecting a gateway client.
|
||||
type gatewaySelector interface {
|
||||
Next(...pool.Option) (gatewayv1beta1.GatewayAPIClient, error)
|
||||
@@ -854,7 +848,7 @@ func (g *gatewaySpaceLookup) Resolve(ctx context.Context, ref *providerv1beta1.R
|
||||
|
||||
switch len(lSSRes.GetStorageSpaces()) {
|
||||
case 0:
|
||||
return nil, fmt.Errorf("no storage space found for path %q", ref.GetPath())
|
||||
return nil, fmt.Errorf("%w: %q", ErrNoStorageSpace, ref.GetPath())
|
||||
case 1:
|
||||
space := lSSRes.GetStorageSpaces()[0]
|
||||
if space.GetRoot() == nil {
|
||||
|
||||
@@ -72,56 +72,68 @@ var _ = Describe("processorID", func() {
|
||||
var _ = Describe("matchOperation", func() {
|
||||
w := &ThumbnailWorkflow{}
|
||||
|
||||
It("stretches for the resize processor", func() {
|
||||
op, aIgnored := w.matchOperation(&requests.ThumbnailRequest{Processor: "resize"}, "image/jpeg")
|
||||
It("stretches for the resize processor and allows upscaling", func() {
|
||||
op, noUpscale, aIgnored := w.matchOperation(&requests.ThumbnailRequest{Processor: "resize"}, "image/jpeg")
|
||||
Expect(op).To(Equal(generator.OpStretch))
|
||||
Expect(noUpscale).To(BeFalse())
|
||||
Expect(aIgnored).To(BeFalse())
|
||||
})
|
||||
|
||||
It("fills for the fill processor", func() {
|
||||
op, _ := w.matchOperation(&requests.ThumbnailRequest{Processor: "fill"}, "image/jpeg")
|
||||
It("fills for the fill processor and allows upscaling", func() {
|
||||
op, noUpscale, _ := w.matchOperation(&requests.ThumbnailRequest{Processor: "fill"}, "image/jpeg")
|
||||
Expect(op).To(Equal(generator.OpFill))
|
||||
Expect(noUpscale).To(BeFalse())
|
||||
})
|
||||
|
||||
It("treats the thumbnail processor as fill (square tiles)", func() {
|
||||
op, _ := w.matchOperation(&requests.ThumbnailRequest{Processor: "thumbnail"}, "image/jpeg")
|
||||
It("treats the thumbnail processor as fill (square tiles) and allows upscaling", func() {
|
||||
op, noUpscale, _ := w.matchOperation(&requests.ThumbnailRequest{Processor: "thumbnail"}, "image/jpeg")
|
||||
Expect(op).To(Equal(generator.OpFill))
|
||||
Expect(noUpscale).To(BeFalse())
|
||||
})
|
||||
|
||||
It("fits in for the fit processor", func() {
|
||||
op, _ := w.matchOperation(&requests.ThumbnailRequest{Processor: "fit"}, "image/jpeg")
|
||||
It("fits in for the fit processor and suppresses upscaling", func() {
|
||||
op, noUpscale, _ := w.matchOperation(&requests.ThumbnailRequest{Processor: "fit"}, "image/jpeg")
|
||||
Expect(op).To(Equal(generator.OpFitIn))
|
||||
Expect(noUpscale).To(BeTrue())
|
||||
})
|
||||
|
||||
It("defaults to fill for non-gif sources without a processor", func() {
|
||||
op, aIgnored := w.matchOperation(&requests.ThumbnailRequest{}, "image/jpeg")
|
||||
It("fits in for the fit-in processor and suppresses upscaling", func() {
|
||||
op, noUpscale, _ := w.matchOperation(&requests.ThumbnailRequest{Processor: "fit-in"}, "image/jpeg")
|
||||
Expect(op).To(Equal(generator.OpFitIn))
|
||||
Expect(noUpscale).To(BeTrue())
|
||||
})
|
||||
|
||||
It("defaults to fill for non-gif sources without a processor and allows upscaling", func() {
|
||||
op, noUpscale, aIgnored := w.matchOperation(&requests.ThumbnailRequest{}, "image/jpeg")
|
||||
Expect(op).To(Equal(generator.OpFill))
|
||||
Expect(noUpscale).To(BeFalse())
|
||||
Expect(aIgnored).To(BeFalse())
|
||||
})
|
||||
|
||||
It("stretches gifs by default (resize for gifs)", func() {
|
||||
op, _ := w.matchOperation(&requests.ThumbnailRequest{}, "image/gif")
|
||||
It("stretches gifs by default (resize for gifs) and allows upscaling", func() {
|
||||
op, noUpscale, _ := w.matchOperation(&requests.ThumbnailRequest{}, "image/gif")
|
||||
Expect(op).To(Equal(generator.OpStretch))
|
||||
Expect(noUpscale).To(BeFalse())
|
||||
})
|
||||
|
||||
It("reports aIgnored when an explicit processor overrides the legacy a flag", func() {
|
||||
// a=0 (Aspect=false) wants fill; fit processor overrides it with fit-in.
|
||||
op, aIgnored := w.matchOperation(&requests.ThumbnailRequest{Processor: "fit"}, "image/jpeg")
|
||||
op, _, aIgnored := w.matchOperation(&requests.ThumbnailRequest{Processor: "fit"}, "image/jpeg")
|
||||
Expect(op).To(Equal(generator.OpFitIn))
|
||||
Expect(aIgnored).To(BeTrue())
|
||||
|
||||
// a=1 (Aspect=true) wants fit-in; fill processor overrides it with fill.
|
||||
op, aIgnored = w.matchOperation(&requests.ThumbnailRequest{Processor: "fill", Aspect: true}, "image/jpeg")
|
||||
op, _, aIgnored = w.matchOperation(&requests.ThumbnailRequest{Processor: "fill", Aspect: true}, "image/jpeg")
|
||||
Expect(op).To(Equal(generator.OpFill))
|
||||
Expect(aIgnored).To(BeTrue())
|
||||
|
||||
// a=0 wants fill; thumbnail processor implies fill -> no contradiction.
|
||||
op, aIgnored = w.matchOperation(&requests.ThumbnailRequest{Processor: "thumbnail", Aspect: false}, "image/jpeg")
|
||||
op, _, aIgnored = w.matchOperation(&requests.ThumbnailRequest{Processor: "thumbnail", Aspect: false}, "image/jpeg")
|
||||
Expect(op).To(Equal(generator.OpFill))
|
||||
Expect(aIgnored).To(BeFalse())
|
||||
|
||||
// resize is neutral: never reports a contradiction.
|
||||
_, aIgnored = w.matchOperation(&requests.ThumbnailRequest{Processor: "resize", Aspect: false}, "image/jpeg")
|
||||
_, _, aIgnored = w.matchOperation(&requests.ThumbnailRequest{Processor: "resize", Aspect: false}, "image/jpeg")
|
||||
Expect(aIgnored).To(BeFalse())
|
||||
})
|
||||
})
|
||||
@@ -478,6 +490,67 @@ var _ = Describe("ThumbnailWorkflow", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("path-only reference for another user's file", func() {
|
||||
It("returns ErrNotFound when no storage space owns the path", func() {
|
||||
// Mirrors the acceptance scenario where Brian requests a preview of
|
||||
// Alice's file: the URL carries Alice's username (identifier) but the
|
||||
// request is authenticated as Brian. The path absolutizes to
|
||||
// /users/Alice/parent.txt, which no storage space visible to Brian
|
||||
// owns, so the space lookup returns none and the workflow must
|
||||
// surface 404 rather than a server error.
|
||||
|
||||
// Drop the BeforeEach ListStorageSpaces mock (which resolves any path
|
||||
// to a space) so this test's "no space" expectation is the one that
|
||||
// matches. The username-scoped tests still rely on it.
|
||||
for i := len(gatewayClient.ExpectedCalls) - 1; i >= 0; i-- {
|
||||
if gatewayClient.ExpectedCalls[i].Method == "ListStorageSpaces" {
|
||||
gatewayClient.ExpectedCalls = append(
|
||||
gatewayClient.ExpectedCalls[:i],
|
||||
gatewayClient.ExpectedCalls[i+1:]...,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
gatewayClient.On("GetUserByClaim", mock.Anything, mock.MatchedBy(func(req *userv1beta1.GetUserByClaimRequest) bool {
|
||||
return req.GetClaim() == "username" && req.GetValue() == "Alice"
|
||||
})).Return(&userv1beta1.GetUserByClaimResponse{
|
||||
Status: status.NewOK(context.Background()),
|
||||
User: &userv1beta1.User{
|
||||
Id: &userv1beta1.UserId{
|
||||
Idp: "https://opencloud-server:9200",
|
||||
OpaqueId: "alice-opaque",
|
||||
Type: userv1beta1.UserType_USER_TYPE_PRIMARY,
|
||||
},
|
||||
Username: "Alice",
|
||||
},
|
||||
}, nil)
|
||||
|
||||
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.MatchedBy(func(req *providerv1beta1.ListStorageSpacesRequest) bool {
|
||||
m := req.GetOpaque().GetMap()
|
||||
return m["path"] != nil &&
|
||||
string(m["path"].GetValue()) == "/users/alice-opaque/parent.txt" &&
|
||||
m["unique"] != nil &&
|
||||
string(m["unique"].GetValue()) == "true"
|
||||
})).Return(&providerv1beta1.ListStorageSpacesResponse{
|
||||
Status: status.NewOK(context.Background()),
|
||||
StorageSpaces: []*providerv1beta1.StorageSpace{},
|
||||
}, nil)
|
||||
|
||||
tr := &requests.ThumbnailRequest{
|
||||
Ref: &providerv1beta1.Reference{Path: "parent.txt"},
|
||||
Filename: "parent.txt",
|
||||
Extension: ".txt",
|
||||
Width: 32,
|
||||
Height: 32,
|
||||
Identifier: "Alice",
|
||||
}
|
||||
|
||||
_, _, _, err := wf.Execute(context.Background(), tr, testToken, logger)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(errors.Is(err, ErrNotFound)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("space-scoped reference", func() {
|
||||
It("anchors stat and download at the space ResourceId, not a path mount", func() {
|
||||
const (
|
||||
@@ -1512,3 +1585,4 @@ var _ = Describe("ThumbnailWorkflow", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,6 +25,7 @@ use GuzzleHttp\Exception\GuzzleException;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use TestHelpers\WebDavHelper;
|
||||
use TestHelpers\WaitHelper;
|
||||
use TestHelpers\HttpRequestHelper;
|
||||
use TestHelpers\BehatHelper;
|
||||
use Behat\Step\Then;
|
||||
@@ -37,6 +38,7 @@ require_once 'bootstrap.php';
|
||||
*/
|
||||
class SearchContext implements Context {
|
||||
private FeatureContext $featureContext;
|
||||
private array $lastSearchQuery = [];
|
||||
|
||||
/**
|
||||
* @param string $user
|
||||
@@ -149,6 +151,13 @@ class SearchContext implements Context {
|
||||
// NOTE: because indexing of newly uploaded files or directories with OpenCloud is decoupled and occurs asynchronously
|
||||
// short wait is necessary before searching
|
||||
sleep(10);
|
||||
// remember the query so "should eventually contain" steps can re-search
|
||||
$this->lastSearchQuery = [
|
||||
"user" => $user,
|
||||
"pattern" => $pattern,
|
||||
"limit" => $limit,
|
||||
"properties" => $properties,
|
||||
];
|
||||
$response = $this->searchFiles($user, $pattern, $limit, null, null, null, $properties);
|
||||
$this->featureContext->setResponse($response);
|
||||
}
|
||||
@@ -279,4 +288,83 @@ class SearchContext implements Context {
|
||||
$response = $this-> searchFiles($user, $pattern, null, $scopeType, $scope, $spaceName);
|
||||
$this->featureContext->setResponse($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* re-run the last WebDAV search until the assertion passes or the WaitHelper
|
||||
* timeout elapses, leaving the last response set for a final assertion by the
|
||||
* caller. Indexing of newly uploaded resources is asynchronous, so a wanted
|
||||
* file can be missing from an early search; OpenSearch never returns a partial
|
||||
* document, so once the expected entries are present the result is complete.
|
||||
*
|
||||
* @param callable $assert
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function retrySearchUntilSatisfied(callable $assert): void {
|
||||
Assert::assertNotEmpty(
|
||||
$this->lastSearchQuery,
|
||||
'No search to retry. Use a "searches for ... using the WebDAV API" step first.'
|
||||
);
|
||||
$query = $this->lastSearchQuery;
|
||||
$response = WaitHelper::waitUntil(
|
||||
fn () => $this->searchFiles(
|
||||
$query["user"],
|
||||
$query["pattern"],
|
||||
$query["limit"],
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
$query["properties"]
|
||||
),
|
||||
function ($response) use ($assert) {
|
||||
$this->featureContext->setResponse($response);
|
||||
try {
|
||||
$assert();
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
);
|
||||
$this->featureContext->setResponse($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $user
|
||||
* @param TableNode $expectedFiles
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
#[Then('/^the search result of user "([^"]*)" should eventually contain only these (?:files|entries):$/')]
|
||||
public function theSearchResultShouldEventuallyContainOnlyEntries(string $user, TableNode $expectedFiles): void {
|
||||
$assert = fn () => $this->featureContext->thePropfindResultShouldContainOnlyEntries($user, $expectedFiles);
|
||||
$this->retrySearchUntilSatisfied($assert);
|
||||
$assert();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $user
|
||||
* @param TableNode $expectedFiles
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
#[Then('/^the search result of user "([^"]*)" should eventually contain these (?:files|entries):$/')]
|
||||
public function theSearchResultShouldEventuallyContainEntries(string $user, TableNode $expectedFiles): void {
|
||||
$assert = fn () => $this->featureContext->thePropfindResultShouldContainEntries($user, '', $expectedFiles);
|
||||
$this->retrySearchUntilSatisfied($assert);
|
||||
$assert();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param TableNode $expectedFiles
|
||||
* @param string $expectedContent
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
#[Then('/^the search result should eventually contain these (?:files|entries) with highlight on keyword "([^"]*)"$/')]
|
||||
public function theSearchResultShouldEventuallyContainEntriesWithHighlight(TableNode $expectedFiles, string $expectedContent): void {
|
||||
$assert = fn () => $this->theSearchResultShouldContainEntriesWithHighlight($expectedFiles, $expectedContent);
|
||||
$this->retrySearchUntilSatisfied($assert);
|
||||
$assert();
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,9 @@ class SpacesContext implements Context {
|
||||
* key is space name and value is the username that created the space
|
||||
*/
|
||||
private array $createdSpaces = [];
|
||||
// the request of the last driveItem GET, so a later "should eventually
|
||||
// match" step can re-run it (the counterpart of the stored response)
|
||||
private array $lastDriveItemRequest = [];
|
||||
private string $ocsApiUrl = '/ocs/v2.php/apps/files_sharing/api/v1/shares';
|
||||
|
||||
/**
|
||||
@@ -4533,17 +4536,16 @@ class SpacesContext implements Context {
|
||||
}
|
||||
|
||||
/**
|
||||
* resolve the Graph URL of a file in a space
|
||||
*
|
||||
* @param string $user
|
||||
* @param string $file
|
||||
* @param string $space
|
||||
*
|
||||
* @return void
|
||||
* @return string
|
||||
*/
|
||||
#[When('user :user gets the file :file from space :space using the Graph API')]
|
||||
public function userGetsTheDriveItemInSpace(string $user, string $file, string $space): void {
|
||||
private function getDriveItemUrl(string $user, string $file, string $space): string {
|
||||
$spaceId = ($this->getSpaceByName($user, $space))["id"];
|
||||
$itemId = '';
|
||||
if ($space === "Shares") {
|
||||
$itemId = GraphHelper::getShareMountId(
|
||||
$this->featureContext->getBaseUrl(),
|
||||
@@ -4555,10 +4557,51 @@ class SpacesContext implements Context {
|
||||
} else {
|
||||
$itemId = $this->getFileId($user, $space, $file);
|
||||
}
|
||||
$url = $this->featureContext->getBaseUrl() . "/graph/v1.0/drives/$spaceId/items/$itemId";
|
||||
return $this->featureContext->getBaseUrl() . "/graph/v1.0/drives/$spaceId/items/$itemId";
|
||||
}
|
||||
|
||||
// NOTE: extracting properties occurs asynchronously after upload, so we need to wait until the properties are available
|
||||
$extractionFacets = ["image", "photo", "location", "audio", "video"];
|
||||
/**
|
||||
*
|
||||
* @param string $user
|
||||
* @param string $file
|
||||
* @param string $space
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
#[When('user :user gets the file :file from space :space using the Graph API')]
|
||||
public function userGetsTheDriveItemInSpace(string $user, string $file, string $space): void {
|
||||
$this->lastDriveItemRequest = [
|
||||
"url" => $this->getDriveItemUrl($user, $file, $space),
|
||||
"user" => $user,
|
||||
];
|
||||
$response = HttpRequestHelper::get(
|
||||
$this->lastDriveItemRequest["url"],
|
||||
$this->featureContext->getStepLineRef(),
|
||||
$user,
|
||||
$this->featureContext->getPasswordForUser($user),
|
||||
);
|
||||
$this->featureContext->setResponse($response);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param PyStringNode $schemaString
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
#[Then('the JSON data of the response should eventually match')]
|
||||
public function theJsonDataOfTheResponseShouldEventuallyMatch(PyStringNode $schemaString): void {
|
||||
Assert::assertNotEmpty(
|
||||
$this->lastDriveItemRequest,
|
||||
'no driveItem request to repeat, get the file using the Graph API first'
|
||||
);
|
||||
$url = $this->lastDriveItemRequest["url"];
|
||||
$user = $this->lastDriveItemRequest["user"];
|
||||
$schema = $this->featureContext->getJSONSchema($schemaString);
|
||||
|
||||
// Extraction is asynchronous, so re-fetch until the response satisfies the
|
||||
// expected schema (a partial payload never matches) or the WaitHelper
|
||||
// timeout elapses.
|
||||
$response = WaitHelper::waitUntil(
|
||||
fn () => HttpRequestHelper::get(
|
||||
$url,
|
||||
@@ -4566,16 +4609,24 @@ class SpacesContext implements Context {
|
||||
$user,
|
||||
$this->featureContext->getPasswordForUser($user),
|
||||
),
|
||||
function ($response) use ($extractionFacets) {
|
||||
function ($response) use ($schema) {
|
||||
if ($response->getStatusCode() !== 200) {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
$body = $this->featureContext->getJsonDecodedResponseBodyContent($response);
|
||||
$this->featureContext->assertJsonDocumentMatchesSchema($body, $schema);
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
$body = $this->featureContext->getJsonDecodedResponseBodyContent($response);
|
||||
return \is_object($body)
|
||||
&& !empty(\array_intersect($extractionFacets, \array_keys((array) $body)));
|
||||
}
|
||||
);
|
||||
|
||||
$this->featureContext->setResponse($response);
|
||||
$this->featureContext->assertJsonDocumentMatchesSchema(
|
||||
$this->featureContext->getJsonDecodedResponseBodyContent($response),
|
||||
$schema
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ Feature: content search
|
||||
And user "Alice" has uploaded file with content "namaste from nepal" to "hello.txt"
|
||||
When user "Alice" searches for "Content:hello" using the WebDAV API
|
||||
Then the HTTP status code should be "207"
|
||||
And the search result of user "Alice" should contain only these files:
|
||||
And the search result of user "Alice" should eventually contain only these files:
|
||||
| keywordAtStart.txt |
|
||||
| keywordAtMiddle.txt |
|
||||
| keywordAtLast.txt |
|
||||
@@ -34,15 +34,15 @@ Feature: content search
|
||||
And user "Alice" has uploaded file with content "alan@example.org want to say hello" to "findByEmail.docs"
|
||||
When user "Alice" searches for "Content:k6" using the WebDAV API
|
||||
Then the HTTP status code should be "207"
|
||||
And the search result of user "Alice" should contain only these files:
|
||||
And the search result of user "Alice" should eventually contain only these files:
|
||||
| wordWithNumber.md |
|
||||
When user "Alice" searches for "Content:https://opencloud.eu/" using the WebDAV API
|
||||
Then the HTTP status code should be "207"
|
||||
And the search result of user "Alice" should contain only these files:
|
||||
And the search result of user "Alice" should eventually contain only these files:
|
||||
| findByWebSite.txt |
|
||||
When user "Alice" searches for "Content:alan@" using the WebDAV API
|
||||
Then the HTTP status code should be "207"
|
||||
And the search result of user "Alice" should contain only these files:
|
||||
And the search result of user "Alice" should eventually contain only these files:
|
||||
| findByEmail.docs |
|
||||
Examples:
|
||||
| dav-path-version |
|
||||
@@ -71,11 +71,11 @@ Feature: content search
|
||||
And user "Alice" has uploaded file with content "He has expirience, we must to have, I have to find ...." to "fileWithStopWords.txt"
|
||||
When user "Alice" searches for 'Content:"he has"' using the WebDAV API
|
||||
Then the HTTP status code should be "207"
|
||||
And the search result of user "Alice" should contain only these files:
|
||||
And the search result of user "Alice" should eventually contain only these files:
|
||||
| fileWithStopWords.txt |
|
||||
When user "Alice" searches for 'Content:"I have"' using the WebDAV API
|
||||
Then the HTTP status code should be "207"
|
||||
And the search result of user "Alice" should contain only these files:
|
||||
And the search result of user "Alice" should eventually contain only these files:
|
||||
| fileWithStopWords.txt |
|
||||
Examples:
|
||||
| dav-path-version |
|
||||
@@ -101,7 +101,7 @@ Feature: content search
|
||||
And user "Brian" has a share "uploadFolder" synced
|
||||
When user "Brian" searches for "Content:hello" using the WebDAV API
|
||||
Then the HTTP status code should be "207"
|
||||
And the search result of user "Brian" should contain only these files:
|
||||
And the search result of user "Brian" should eventually contain only these files:
|
||||
| keywordAtStart.txt |
|
||||
| keywordAtMiddle.txt |
|
||||
| keywordAtLast.txt |
|
||||
@@ -121,7 +121,7 @@ Feature: content search
|
||||
And user "Alice" has deleted file "keywordAtLast.txt"
|
||||
When user "Alice" searches for "Content:hello" using the WebDAV API
|
||||
Then the HTTP status code should be "207"
|
||||
And the search result of user "Alice" should contain only these files:
|
||||
And the search result of user "Alice" should eventually contain only these files:
|
||||
| keywordAtStart.txt |
|
||||
| keywordAtMiddle.txt |
|
||||
Examples:
|
||||
@@ -139,7 +139,7 @@ Feature: content search
|
||||
And user "Alice" has restored the file with original path "keywordAtStart.txt"
|
||||
When user "Alice" searches for "Content:hello" using the WebDAV API
|
||||
Then the HTTP status code should be "207"
|
||||
And the search result of user "Alice" should contain only these files:
|
||||
And the search result of user "Alice" should eventually contain only these files:
|
||||
| keywordAtStart.txt |
|
||||
Examples:
|
||||
| dav-path-version |
|
||||
@@ -154,7 +154,7 @@ Feature: content search
|
||||
And user "Alice" has restored version index "1" of file "test.txt"
|
||||
When user "Alice" searches for "Content:hello" using the WebDAV API
|
||||
Then the HTTP status code should be "207"
|
||||
And the search result of user "Alice" should contain only these files:
|
||||
And the search result of user "Alice" should eventually contain only these files:
|
||||
| test.txt |
|
||||
Examples:
|
||||
| dav-path-version |
|
||||
@@ -175,7 +175,7 @@ Feature: content search
|
||||
And using <dav-path-version> DAV path
|
||||
When user "Alice" searches for "Content:hello" using the WebDAV API
|
||||
Then the HTTP status code should be "207"
|
||||
And the search result of user "Alice" should contain only these files:
|
||||
And the search result of user "Alice" should eventually contain only these files:
|
||||
| keywordAtStart.txt |
|
||||
| keywordAtMiddle.txt |
|
||||
| keywordAtLast.txt |
|
||||
@@ -204,7 +204,7 @@ Feature: content search
|
||||
And using <dav-path-version> DAV path
|
||||
When user "Brian" searches for "Content:hello" using the WebDAV API
|
||||
Then the HTTP status code should be "207"
|
||||
And the search result of user "Alice" should contain only these files:
|
||||
And the search result of user "Alice" should eventually contain only these files:
|
||||
| keywordAtStart.txt |
|
||||
| keywordAtMiddle.txt |
|
||||
| keywordAtLast.txt |
|
||||
@@ -224,10 +224,10 @@ Feature: content search
|
||||
| technical task.txt | test |
|
||||
When user "Alice" searches for '<pattern>' using the WebDAV API
|
||||
Then the HTTP status code should be "207"
|
||||
And the search result should contain "<result-count>" entries
|
||||
And the search result of user "Alice" should contain these entries:
|
||||
And the search result of user "Alice" should eventually contain these entries:
|
||||
| <search-result-1> |
|
||||
| <search-result-2> |
|
||||
And the search result should contain "<result-count>" entries
|
||||
Examples:
|
||||
| pattern | result-count | search-result-1 | search-result-2 |
|
||||
| Content:hello | 1 | technical task.txt | |
|
||||
@@ -251,7 +251,7 @@ Feature: content search
|
||||
And user "Alice" has uploaded a file inside space "project-space" with content "this is a simple odt file" to "test-odt-file.odt"
|
||||
When user "Alice" searches for "Content:simple" using the WebDAV API
|
||||
Then the HTTP status code should be "207"
|
||||
And the search result should contain these entries with highlight on keyword "simple"
|
||||
And the search result should eventually contain these entries with highlight on keyword "simple"
|
||||
| test-text-file.txt |
|
||||
| test-pdf-file.pdf |
|
||||
| test-cpp-file.cpp |
|
||||
|
||||
@@ -131,7 +131,7 @@ Feature: propfind extracted props
|
||||
Given user "Alice" has uploaded a file "filesForUpload/testaudio.mp3" to "testaudio.mp3" in space "Personal"
|
||||
When user "Alice" gets the file "testaudio.mp3" from space "Personal" using the Graph API
|
||||
Then the HTTP status code should be "200"
|
||||
And the JSON data of the response should match
|
||||
And the JSON data of the response should eventually match
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
@@ -179,7 +179,7 @@ Feature: propfind extracted props
|
||||
Given user "Alice" has uploaded a file "filesForUpload/testavatar.jpg" to "testavatar.jpg" in space "Personal"
|
||||
When user "Alice" gets the file "testavatar.jpg" from space "Personal" using the Graph API
|
||||
Then the HTTP status code should be "200"
|
||||
And the JSON data of the response should match
|
||||
And the JSON data of the response should eventually match
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
@@ -251,7 +251,7 @@ Feature: propfind extracted props
|
||||
And user "Alice" has uploaded a file "filesForUpload/testaudio.mp3" to "testaudio.mp3" in space "new-space"
|
||||
When user "Alice" gets the file "testaudio.mp3" from space "new-space" using the Graph API
|
||||
Then the HTTP status code should be "200"
|
||||
And the JSON data of the response should match
|
||||
And the JSON data of the response should eventually match
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
@@ -301,7 +301,7 @@ Feature: propfind extracted props
|
||||
And user "Alice" has uploaded a file "filesForUpload/testavatar.jpg" to "testavatar.jpg" in space "new-space"
|
||||
When user "Alice" gets the file "testavatar.jpg" from space "new-space" using the Graph API
|
||||
Then the HTTP status code should be "200"
|
||||
And the JSON data of the response should match
|
||||
And the JSON data of the response should eventually match
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
@@ -379,7 +379,7 @@ Feature: propfind extracted props
|
||||
And user "Brian" has a share "testaudio.mp3" synced
|
||||
When user "Brian" gets the file "testaudio.mp3" from space "Shares" using the Graph API
|
||||
Then the HTTP status code should be "200"
|
||||
And the JSON data of the response should match
|
||||
And the JSON data of the response should eventually match
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
@@ -435,7 +435,7 @@ Feature: propfind extracted props
|
||||
And user "Brian" has a share "testavatar.jpg" synced
|
||||
When user "Brian" gets the file "testavatar.jpg" from space "Shares" using the Graph API
|
||||
Then the HTTP status code should be "200"
|
||||
And the JSON data of the response should match
|
||||
And the JSON data of the response should eventually match
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
|
||||
@@ -20,26 +20,26 @@ Feature: sizing of previews of files downloaded through the webdav API
|
||||
# No processor is given, so the default behavior applies: fill. The source
|
||||
# preview of a text file is always 640x480 (landscape). The requested size is
|
||||
# snapped onto a configured resolution (the box) and the image is center-cropped
|
||||
# to fill that box, but webdav sends no_upscale() so the source is never
|
||||
# enlarged: when the 640x480 source already fits inside the box it is returned
|
||||
# at its native size, and only boxes smaller than the source are downscaled.
|
||||
# to fill that exact box. fill may upscale, so when the box is larger than the
|
||||
# source the 640x480 source is enlarged to cover it; only boxes smaller than
|
||||
# the source are downscaled.
|
||||
Examples:
|
||||
| request-width | request-height | return-width | return-height | dav-path-version |
|
||||
| 1 | 1 | 16 | 16 | old |
|
||||
| 32 | 32 | 32 | 32 | old |
|
||||
| 1024 | 1024 | 640 | 480 | old |
|
||||
| 1 | 1024 | 640 | 480 | old |
|
||||
| 1024 | 1 | 640 | 480 | old |
|
||||
| 1024 | 1024 | 1024 | 1024 | old |
|
||||
| 1 | 1024 | 1080 | 1920 | old |
|
||||
| 1024 | 1 | 1024 | 1024 | old |
|
||||
| 1 | 1 | 16 | 16 | new |
|
||||
| 32 | 32 | 32 | 32 | new |
|
||||
| 1024 | 1024 | 640 | 480 | new |
|
||||
| 1 | 1024 | 640 | 480 | new |
|
||||
| 1024 | 1 | 640 | 480 | new |
|
||||
| 1024 | 1024 | 1024 | 1024 | new |
|
||||
| 1 | 1024 | 1080 | 1920 | new |
|
||||
| 1024 | 1 | 1024 | 1024 | new |
|
||||
| 1 | 1 | 16 | 16 | spaces |
|
||||
| 32 | 32 | 32 | 32 | spaces |
|
||||
| 1024 | 1024 | 640 | 480 | spaces |
|
||||
| 1 | 1024 | 640 | 480 | spaces |
|
||||
| 1024 | 1 | 640 | 480 | spaces |
|
||||
| 1024 | 1024 | 1024 | 1024 | spaces |
|
||||
| 1 | 1024 | 1080 | 1920 | spaces |
|
||||
| 1024 | 1 | 1024 | 1024 | spaces |
|
||||
|
||||
|
||||
Scenario Outline: download a preview with an explicit processor
|
||||
|
||||
-1
@@ -34,7 +34,6 @@ import (
|
||||
_ "github.com/opencloud-eu/reva/v2/internal/http/services/prometheus"
|
||||
_ "github.com/opencloud-eu/reva/v2/internal/http/services/reverseproxy"
|
||||
_ "github.com/opencloud-eu/reva/v2/internal/http/services/sciencemesh"
|
||||
_ "github.com/opencloud-eu/reva/v2/internal/http/services/siteacc"
|
||||
_ "github.com/opencloud-eu/reva/v2/internal/http/services/sysinfo"
|
||||
_ "github.com/opencloud-eu/reva/v2/internal/http/services/wellknown"
|
||||
// Add your own service here
|
||||
|
||||
Generated
Vendored
+46
-1
@@ -96,6 +96,30 @@ var (
|
||||
"width",
|
||||
"height",
|
||||
}
|
||||
videoKeys = []string{
|
||||
"audioBitsPerSample",
|
||||
"audioChannels",
|
||||
"audioFormat",
|
||||
"audioSamplesPerSecond",
|
||||
"bitrate",
|
||||
"duration",
|
||||
"fourCC",
|
||||
"frameRate",
|
||||
"height",
|
||||
"width",
|
||||
}
|
||||
motionPhotoKeys = []string{
|
||||
"presentationTimestampUs",
|
||||
"version",
|
||||
"videoSize",
|
||||
}
|
||||
livePhotoKeys = []string{
|
||||
"auto",
|
||||
"contentId",
|
||||
"stillImageTimeUs",
|
||||
"vitalityScore",
|
||||
"vitalityScoringVersion",
|
||||
}
|
||||
photoKeys = []string{
|
||||
"cameraMake",
|
||||
"cameraModel",
|
||||
@@ -887,6 +911,12 @@ func metadataKeys(pf XML) ([]string, []string) {
|
||||
metadataKeys = append(metadataKeys, metadataKeysWithPrefix("libre.graph.image", imageKeys)...)
|
||||
case "http://owncloud.org/ns/photo":
|
||||
metadataKeys = append(metadataKeys, metadataKeysWithPrefix("libre.graph.photo", photoKeys)...)
|
||||
case "http://owncloud.org/ns/video":
|
||||
metadataKeys = append(metadataKeys, metadataKeysWithPrefix("libre.graph.video", videoKeys)...)
|
||||
case "http://owncloud.org/ns/motion-photo":
|
||||
metadataKeys = append(metadataKeys, metadataKeysWithPrefix("libre.graph.motionPhoto", motionPhotoKeys)...)
|
||||
case "http://owncloud.org/ns/live-photo":
|
||||
metadataKeys = append(metadataKeys, metadataKeysWithPrefix("libre.graph.livePhoto", livePhotoKeys)...)
|
||||
default:
|
||||
metadataKeys = append(metadataKeys, key)
|
||||
}
|
||||
@@ -944,7 +974,7 @@ func requiresExplicitFetching(n *xml.Name) bool {
|
||||
}
|
||||
case net.NsOwncloud:
|
||||
switch n.Local {
|
||||
case "favorite", "share-types", "checksums", "size", "tags", "audio", "location", "image", "photo":
|
||||
case "favorite", "share-types", "checksums", "size", "tags", "audio", "location", "image", "photo", "video", "motion-photo", "live-photo":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -1292,6 +1322,9 @@ func mdToPropResponse(ctx context.Context, pf *XML, md *provider.ResourceInfo, p
|
||||
appendMetadataProp(k, "oc", "location", "libre.graph.location", locationKeys)
|
||||
appendMetadataProp(k, "oc", "image", "libre.graph.image", imageKeys)
|
||||
appendMetadataProp(k, "oc", "photo", "libre.graph.photo", photoKeys)
|
||||
appendMetadataProp(k, "oc", "video", "libre.graph.video", videoKeys)
|
||||
appendMetadataProp(k, "oc", "motion-photo", "libre.graph.motionPhoto", motionPhotoKeys)
|
||||
appendMetadataProp(k, "oc", "live-photo", "libre.graph.livePhoto", livePhotoKeys)
|
||||
}
|
||||
|
||||
if md.Type == provider.ResourceType_RESOURCE_TYPE_CONTAINER {
|
||||
@@ -1579,6 +1612,18 @@ func mdToPropResponse(ctx context.Context, pf *XML, md *provider.ResourceInfo, p
|
||||
if k := md.GetArbitraryMetadata().GetMetadata(); k != nil {
|
||||
appendMetadataProp(k, "oc", "photo", "libre.graph.photo", photoKeys)
|
||||
}
|
||||
case "video":
|
||||
if k := md.GetArbitraryMetadata().GetMetadata(); k != nil {
|
||||
appendMetadataProp(k, "oc", "video", "libre.graph.video", videoKeys)
|
||||
}
|
||||
case "motion-photo":
|
||||
if k := md.GetArbitraryMetadata().GetMetadata(); k != nil {
|
||||
appendMetadataProp(k, "oc", "motion-photo", "libre.graph.motionPhoto", motionPhotoKeys)
|
||||
}
|
||||
case "live-photo":
|
||||
if k := md.GetArbitraryMetadata().GetMetadata(); k != nil {
|
||||
appendMetadataProp(k, "oc", "live-photo", "libre.graph.livePhoto", livePhotoKeys)
|
||||
}
|
||||
case "name":
|
||||
appendToOK(prop.Escaped("oc:name", md.Name))
|
||||
case "shareid":
|
||||
|
||||
-127
@@ -1,127 +0,0 @@
|
||||
// Copyright 2018-2021 CERN
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// In applying this license, CERN does not waive the privileges and immunities
|
||||
// granted to it by virtue of its status as an Intergovernmental Organization
|
||||
// or submit itself to any jurisdiction.
|
||||
|
||||
package siteacc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/siteacc"
|
||||
"github.com/opencloud-eu/reva/v2/pkg/siteacc/config"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/opencloud-eu/reva/v2/pkg/rhttp/global"
|
||||
)
|
||||
|
||||
func init() {
|
||||
global.Register(serviceName, New)
|
||||
}
|
||||
|
||||
type svc struct {
|
||||
conf *config.Configuration
|
||||
log *zerolog.Logger
|
||||
|
||||
siteacc *siteacc.SiteAccounts
|
||||
}
|
||||
|
||||
const (
|
||||
serviceName = "siteacc"
|
||||
)
|
||||
|
||||
// Close is called when this service is being stopped.
|
||||
func (s *svc) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Prefix returns the main endpoint of this service.
|
||||
func (s *svc) Prefix() string {
|
||||
return s.conf.Prefix
|
||||
}
|
||||
|
||||
// Unprotected returns all endpoints that can be queried without prior authorization.
|
||||
func (s *svc) Unprotected() []string {
|
||||
return s.siteacc.GetPublicEndpoints()
|
||||
}
|
||||
|
||||
// Handler serves all HTTP requests.
|
||||
func (s *svc) Handler() http.Handler {
|
||||
return s.siteacc.RequestHandler()
|
||||
}
|
||||
|
||||
func parseConfig(m map[string]interface{}) (*config.Configuration, error) {
|
||||
conf := &config.Configuration{}
|
||||
if err := mapstructure.Decode(m, &conf); err != nil {
|
||||
return nil, errors.Wrap(err, "error decoding configuration")
|
||||
}
|
||||
applyDefaultConfig(conf)
|
||||
conf.Cleanup()
|
||||
|
||||
if conf.Webserver.URL == "" {
|
||||
return nil, errors.Errorf("no webserver URL specified")
|
||||
}
|
||||
|
||||
return conf, nil
|
||||
}
|
||||
|
||||
func applyDefaultConfig(conf *config.Configuration) {
|
||||
if conf.Prefix == "" {
|
||||
conf.Prefix = serviceName
|
||||
}
|
||||
|
||||
if conf.Storage.Driver == "" {
|
||||
conf.Storage.Driver = "file"
|
||||
}
|
||||
|
||||
if conf.Mentix.DataEndpoint == "" {
|
||||
conf.Mentix.DataEndpoint = "/sites"
|
||||
}
|
||||
|
||||
if conf.Mentix.SiteRegistrationEndpoint == "" {
|
||||
conf.Mentix.SiteRegistrationEndpoint = "/sitereg"
|
||||
}
|
||||
|
||||
// Enforce a minimum session timeout of 1 minute (and default to 5 minutes)
|
||||
if conf.Webserver.SessionTimeout < 60 {
|
||||
conf.Webserver.SessionTimeout = 5 * 60
|
||||
}
|
||||
}
|
||||
|
||||
// New returns a new Site Accounts service.
|
||||
func New(m map[string]interface{}, log *zerolog.Logger) (global.Service, error) {
|
||||
// Prepare the configuration
|
||||
conf, err := parseConfig(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create the site accounts instance
|
||||
siteacc, err := siteacc.New(conf, log)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error creating the site accounts service")
|
||||
}
|
||||
|
||||
// Create the service
|
||||
s := &svc{
|
||||
conf: conf,
|
||||
log: log,
|
||||
siteacc: siteacc,
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
+1
@@ -602,6 +602,7 @@ var mimeTypes = map[string]string{
|
||||
"nc": "application/x-netcdf",
|
||||
"ncx": "application/x-dtbncx+xml",
|
||||
"nef": "image/x-nikon-nef",
|
||||
"nrw": "image/x-nikon-nrw",
|
||||
"nfo": "text/x-nfo",
|
||||
"ngdat": "application/vnd.nokia.n-gage.data",
|
||||
"nitf": "application/vnd.nitf",
|
||||
|
||||
+33
-11
@@ -289,6 +289,34 @@ func (m *Manager) initialize(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.storage.MakeDirIfNotExist(ctx, "migrations")
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
// A missing "storages" directory means nobody has ever used this metadata
|
||||
// tree, so there is nothing to migrate. Record all migrations as applied
|
||||
// before creating "storages": any instance that observes "storages" is then
|
||||
// guaranteed to also observe the state file, which keeps instances starting
|
||||
// in parallel from disagreeing about whether migrations have to run.
|
||||
_, err = m.storage.Stat(ctx, "storages")
|
||||
switch err.(type) {
|
||||
case nil:
|
||||
// existing deployment, leave the migration state untouched
|
||||
case errtypes.IsNotFound:
|
||||
if err = migration.MarkAllApplied(ctx, m.storage); err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
default:
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.storage.MakeDirIfNotExist(ctx, "storages")
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
@@ -307,12 +335,6 @@ func (m *Manager) initialize(ctx context.Context) error {
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
err = m.storage.MakeDirIfNotExist(ctx, "migrations")
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
span.SetStatus(codes.Ok, "initialized")
|
||||
return nil
|
||||
@@ -566,7 +588,7 @@ func (m *Manager) GetShare(ctx context.Context, ref *collaboration.ShareReferenc
|
||||
if s.ResourceId.SpaceId == s.ResourceId.OpaqueId {
|
||||
if err := events.Publish(ctx, m.eventStream, events.SpaceMembershipExpired{
|
||||
SpaceOwner: s.GetOwner(),
|
||||
SpaceID: &provider.StorageSpaceId{OpaqueId: s.ResourceId.StorageId + "$" + s.ResourceId.SpaceId},
|
||||
SpaceID: &provider.StorageSpaceId{OpaqueId: storagespace.FormatStorageID(s.ResourceId.StorageId, s.ResourceId.SpaceId)},
|
||||
ExpiredAt: time.Unix(int64(s.GetExpiration().GetSeconds()), int64(s.GetExpiration().GetNanos())),
|
||||
GranteeUserID: s.GetGrantee().GetUserId(),
|
||||
GranteeGroupID: s.GetGrantee().GetGroupId(),
|
||||
@@ -780,7 +802,7 @@ func (m *Manager) listSharesByIDs(ctx context.Context, user *userv1beta1.User, f
|
||||
if s.ResourceId.SpaceId == s.ResourceId.OpaqueId {
|
||||
if err := events.Publish(ctx, m.eventStream, events.SpaceMembershipExpired{
|
||||
SpaceOwner: s.GetOwner(),
|
||||
SpaceID: &provider.StorageSpaceId{OpaqueId: s.ResourceId.StorageId + "$" + s.ResourceId.SpaceId},
|
||||
SpaceID: &provider.StorageSpaceId{OpaqueId: storagespace.FormatStorageID(s.ResourceId.StorageId, s.ResourceId.SpaceId)},
|
||||
ExpiredAt: time.Unix(int64(s.GetExpiration().GetSeconds()), int64(s.GetExpiration().GetNanos())),
|
||||
GranteeUserID: s.GetGrantee().GetUserId(),
|
||||
GranteeGroupID: s.GetGrantee().GetGroupId(),
|
||||
@@ -911,7 +933,7 @@ func (m *Manager) listCreatedShares(ctx context.Context, user *userv1beta1.User,
|
||||
if s.ResourceId.SpaceId == s.ResourceId.OpaqueId {
|
||||
if err := events.Publish(ctx, m.eventStream, events.SpaceMembershipExpired{
|
||||
SpaceOwner: s.GetOwner(),
|
||||
SpaceID: &provider.StorageSpaceId{OpaqueId: s.ResourceId.StorageId + "$" + s.ResourceId.SpaceId},
|
||||
SpaceID: &provider.StorageSpaceId{OpaqueId: storagespace.FormatStorageID(s.ResourceId.StorageId, s.ResourceId.SpaceId)},
|
||||
ExpiredAt: time.Unix(int64(s.GetExpiration().GetSeconds()), int64(s.GetExpiration().GetNanos())),
|
||||
GranteeUserID: s.GetGrantee().GetUserId(),
|
||||
GranteeGroupID: s.GetGrantee().GetGroupId(),
|
||||
@@ -1096,7 +1118,7 @@ func (m *Manager) ListReceivedShares(ctx context.Context, filters []*collaborati
|
||||
if s.ResourceId.SpaceId == s.ResourceId.OpaqueId {
|
||||
if err := events.Publish(ctx, m.eventStream, events.SpaceMembershipExpired{
|
||||
SpaceOwner: s.GetOwner(),
|
||||
SpaceID: &provider.StorageSpaceId{OpaqueId: s.ResourceId.StorageId + "$" + s.ResourceId.SpaceId},
|
||||
SpaceID: &provider.StorageSpaceId{OpaqueId: storagespace.FormatStorageID(s.ResourceId.StorageId, s.ResourceId.SpaceId)},
|
||||
ExpiredAt: time.Unix(int64(s.GetExpiration().GetSeconds()), int64(s.GetExpiration().GetNanos())),
|
||||
GranteeUserID: s.GetGrantee().GetUserId(),
|
||||
GranteeGroupID: s.GetGrantee().GetGroupId(),
|
||||
@@ -1212,7 +1234,7 @@ func (m *Manager) getReceived(ctx context.Context, ref *collaboration.ShareRefer
|
||||
if s.ResourceId.SpaceId == s.ResourceId.OpaqueId {
|
||||
if err := events.Publish(ctx, m.eventStream, events.SpaceMembershipExpired{
|
||||
SpaceOwner: s.GetOwner(),
|
||||
SpaceID: &provider.StorageSpaceId{OpaqueId: s.ResourceId.StorageId + "$" + s.ResourceId.SpaceId},
|
||||
SpaceID: &provider.StorageSpaceId{OpaqueId: storagespace.FormatStorageID(s.ResourceId.StorageId, s.ResourceId.SpaceId)},
|
||||
ExpiredAt: time.Unix(int64(s.GetExpiration().GetSeconds()), int64(s.GetExpiration().GetNanos())),
|
||||
GranteeUserID: s.GetGrantee().GetUserId(),
|
||||
GranteeGroupID: s.GetGrantee().GetGroupId(),
|
||||
|
||||
Generated
Vendored
+34
@@ -139,6 +139,40 @@ func New(logger zerolog.Logger,
|
||||
}
|
||||
}
|
||||
|
||||
// LatestVersion returns the version of the most recent registered migration.
|
||||
func LatestVersion() int {
|
||||
version := 0
|
||||
for _, mig := range migrations {
|
||||
if mig.Version() > version {
|
||||
version = mig.Version()
|
||||
}
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
// MarkAllApplied records every registered migration as applied without running
|
||||
// any of them. It is meant for fresh deployments, where there is no data to
|
||||
// migrate and running the migrations would needlessly block write operations
|
||||
// while depending on services that may not be up yet.
|
||||
//
|
||||
// The state file is created atomically and an existing state file is never
|
||||
// overwritten, so instances starting in parallel converge on the same state.
|
||||
func MarkAllApplied(ctx context.Context, storage metadata.Storage) error {
|
||||
data, err := json.Marshal(persistedState{Version: LatestVersion()})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = storage.Upload(ctx, metadata.UploadRequest{
|
||||
Path: stateFile,
|
||||
Content: data,
|
||||
IfNoneMatch: []string{"*"},
|
||||
})
|
||||
if err != nil && !isConflict(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// acquireLock tries to atomically create the lock file, blocking until the lock
|
||||
// is obtained. It returns the etag of the lock file on success. It retries
|
||||
// indefinitely until ctx is cancelled. A lock whose timestamp is older than
|
||||
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
// Copyright 2018-2020 CERN
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// In applying this license, CERN does not waive the privileges and immunities
|
||||
// granted to it by virtue of its status as an Intergovernmental Organization
|
||||
// or submit itself to any jurisdiction.
|
||||
|
||||
package contact
|
||||
|
||||
import "github.com/opencloud-eu/reva/v2/pkg/siteacc/html"
|
||||
|
||||
// PanelTemplate is the content provider for the contact form.
|
||||
type PanelTemplate struct {
|
||||
html.ContentProvider
|
||||
}
|
||||
|
||||
// GetTitle returns the title of the panel.
|
||||
func (template *PanelTemplate) GetTitle() string {
|
||||
return "ScienceMesh Contact Form"
|
||||
}
|
||||
|
||||
// GetCaption returns the caption which is displayed on the panel.
|
||||
func (template *PanelTemplate) GetCaption() string {
|
||||
return "Contact the ScienceMesh administration!"
|
||||
}
|
||||
|
||||
// GetContentJavaScript delivers additional JavaScript code.
|
||||
func (template *PanelTemplate) GetContentJavaScript() string {
|
||||
return tplJavaScript
|
||||
}
|
||||
|
||||
// GetContentStyleSheet delivers additional stylesheet code.
|
||||
func (template *PanelTemplate) GetContentStyleSheet() string {
|
||||
return tplStyleSheet
|
||||
}
|
||||
|
||||
// GetContentBody delivers the actual body content.
|
||||
func (template *PanelTemplate) GetContentBody() string {
|
||||
return tplBody
|
||||
}
|
||||
-111
@@ -1,111 +0,0 @@
|
||||
// Copyright 2018-2020 CERN
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// In applying this license, CERN does not waive the privileges and immunities
|
||||
// granted to it by virtue of its status as an Intergovernmental Organization
|
||||
// or submit itself to any jurisdiction.
|
||||
|
||||
package contact
|
||||
|
||||
const tplJavaScript = `
|
||||
function verifyForm(formData) {
|
||||
if (formData.getTrimmed("subject") == "") {
|
||||
setState(STATE_ERROR, "Please enter a subject.", "form", "subject", true);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (formData.getTrimmed("message") == "") {
|
||||
setState(STATE_ERROR, "Please enter a message.", "form", "message", true);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleAction(action) {
|
||||
const formData = new FormData(document.querySelector("form"));
|
||||
if (!verifyForm(formData)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(STATE_STATUS, "Sending message... this should only take a moment.", "form", null, false);
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "{{getServerAddress}}/" + action);
|
||||
xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
|
||||
|
||||
xhr.onload = function() {
|
||||
if (this.status == 200) {
|
||||
setState(STATE_SUCCESS, "Your message was successfully sent! A copy of the message has been sent to your email address.");
|
||||
} else {
|
||||
var resp = JSON.parse(this.responseText);
|
||||
setState(STATE_ERROR, "An error occurred while trying to send your message:<br><em>" + resp.error + "</em>", "form", null, true);
|
||||
}
|
||||
}
|
||||
|
||||
var postData = {
|
||||
"subject": formData.getTrimmed("subject"),
|
||||
"message": formData.getTrimmed("message")
|
||||
};
|
||||
|
||||
xhr.send(JSON.stringify(postData));
|
||||
}
|
||||
`
|
||||
|
||||
const tplStyleSheet = `
|
||||
html * {
|
||||
font-family: arial !important;
|
||||
}
|
||||
|
||||
.mandatory {
|
||||
color: red;
|
||||
font-weight: bold;
|
||||
}
|
||||
`
|
||||
|
||||
const tplBody = `
|
||||
<div>
|
||||
<p>Contact the ScienceMesh administration using the form below.</p>
|
||||
<p style="margin-bottom: 0em;">Please include as much information as possible in your request, especially:</p>
|
||||
<ul style="margin-top: 0em;">
|
||||
<li>The site your request refers to (if not obvious from your account information)</li>
|
||||
<li>Your role within the ScienceMesh site (e.g., administrator, operational team member, etc.)</li>
|
||||
<li>Any specific reasons for your request</li>
|
||||
<li>Anything else that might help to process your request</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div> </div>
|
||||
<div>
|
||||
<form id="form" method="POST" class="box container-inline" style="width: 100%;" onSubmit="handleAction('contact'); return false;">
|
||||
<div style="grid-row: 1;"><label for="subject">Subject: <span class="mandatory">*</span></label></div>
|
||||
<div style="grid-row: 2; grid-column: 1 / span 2;"><input type="text" id="subject" name="subject" {{if .Params.Subject}}value="{{.Params.Subject}}" readonly{{end}}/></div>
|
||||
|
||||
<div style="grid-row: 3;"><label for="message">Message: <span class="mandatory">*</span></label></div>
|
||||
<div style="grid-row: 4; grid-column: 1 / span 2;">
|
||||
<textarea rows="10" id="message" name="message" style="box-sizing: border-box; width: 100%;" {{if .Params.Info}}placeholder="{{.Params.Info}}"{{end}}>{{if .Params.Message}}{{.Params.Message}}{{end}}</textarea>
|
||||
</div>
|
||||
|
||||
<div style="grid-row: 5; align-self: center;">
|
||||
Fields marked with <span class="mandatory">*</span> are mandatory.
|
||||
</div>
|
||||
<div style="grid-row: 5; grid-column: 2; text-align: right;">
|
||||
<button type="reset">Reset</button>
|
||||
<button type="submit" style="font-weight: bold;">Send</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
<p>Go <a href="{{getServerAddress}}/account/?path=manage">back</a> to the main account page.</p>
|
||||
</div>
|
||||
`
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
// Copyright 2018-2020 CERN
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// In applying this license, CERN does not waive the privileges and immunities
|
||||
// granted to it by virtue of its status as an Intergovernmental Organization
|
||||
// or submit itself to any jurisdiction.
|
||||
|
||||
package edit
|
||||
|
||||
import "github.com/opencloud-eu/reva/v2/pkg/siteacc/html"
|
||||
|
||||
// PanelTemplate is the content provider for the edit form.
|
||||
type PanelTemplate struct {
|
||||
html.ContentProvider
|
||||
}
|
||||
|
||||
// GetTitle returns the title of the panel.
|
||||
func (template *PanelTemplate) GetTitle() string {
|
||||
return "ScienceMesh Site Administrator Account"
|
||||
}
|
||||
|
||||
// GetCaption returns the caption which is displayed on the panel.
|
||||
func (template *PanelTemplate) GetCaption() string {
|
||||
return "Edit your ScienceMesh Site Administrator Account!"
|
||||
}
|
||||
|
||||
// GetContentJavaScript delivers additional JavaScript code.
|
||||
func (template *PanelTemplate) GetContentJavaScript() string {
|
||||
return tplJavaScript
|
||||
}
|
||||
|
||||
// GetContentStyleSheet delivers additional stylesheet code.
|
||||
func (template *PanelTemplate) GetContentStyleSheet() string {
|
||||
return tplStyleSheet
|
||||
}
|
||||
|
||||
// GetContentBody delivers the actual body content.
|
||||
func (template *PanelTemplate) GetContentBody() string {
|
||||
return tplBody
|
||||
}
|
||||
-158
@@ -1,158 +0,0 @@
|
||||
// Copyright 2018-2020 CERN
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// In applying this license, CERN does not waive the privileges and immunities
|
||||
// granted to it by virtue of its status as an Intergovernmental Organization
|
||||
// or submit itself to any jurisdiction.
|
||||
|
||||
package edit
|
||||
|
||||
const tplJavaScript = `
|
||||
function verifyForm(formData) {
|
||||
if (formData.getTrimmed("fname") == "") {
|
||||
setState(STATE_ERROR, "Please specify your first name.", "form", "fname", true);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (formData.getTrimmed("lname") == "") {
|
||||
setState(STATE_ERROR, "Please specify your last name.", "form", "lname", true);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (formData.getTrimmed("role") == "") {
|
||||
setState(STATE_ERROR, "Please specify your role within your site.", "form", "role", true);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (formData.get("password") != "") {
|
||||
if (formData.get("password2") == "") {
|
||||
setState(STATE_ERROR, "Please confirm your new password.", "form", "password2", true);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (formData.get("password") != formData.get("password2")) {
|
||||
setState(STATE_ERROR, "The entered passwords do not match.", "form", "password2", true);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleAction(action) {
|
||||
const formData = new FormData(document.querySelector("form"));
|
||||
if (!verifyForm(formData)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(STATE_STATUS, "Updating account... this should only take a moment.", "form", null, false);
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "{{getServerAddress}}/" + action);
|
||||
xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
|
||||
|
||||
xhr.onload = function() {
|
||||
if (this.status == 200) {
|
||||
setState(STATE_SUCCESS, "Your account was successfully updated!", "form", null, true);
|
||||
} else {
|
||||
var resp = JSON.parse(this.responseText);
|
||||
setState(STATE_ERROR, "An error occurred while trying to update your account:<br><em>" + resp.error + "</em>", "form", null, true);
|
||||
}
|
||||
}
|
||||
|
||||
var postData = {
|
||||
"title": formData.getTrimmed("title"),
|
||||
"firstName": formData.getTrimmed("fname"),
|
||||
"lastName": formData.getTrimmed("lname"),
|
||||
"role": formData.getTrimmed("role"),
|
||||
"phoneNumber": formData.getTrimmed("phone"),
|
||||
"password": {
|
||||
"value": formData.get("password")
|
||||
}
|
||||
};
|
||||
|
||||
xhr.send(JSON.stringify(postData));
|
||||
}
|
||||
`
|
||||
|
||||
const tplStyleSheet = `
|
||||
html * {
|
||||
font-family: arial !important;
|
||||
}
|
||||
|
||||
.mandatory {
|
||||
color: red;
|
||||
font-weight: bold;
|
||||
}
|
||||
`
|
||||
|
||||
const tplBody = `
|
||||
<div>
|
||||
<p>Edit your ScienceMesh Site Administrator Account information below.</p>
|
||||
<p>Please note that you cannot modify your email address using this form.</p>
|
||||
</div>
|
||||
<div> </div>
|
||||
<div>
|
||||
<form id="form" method="POST" class="box container-inline" style="width: 100%;" onSubmit="handleAction('update?invoker=user'); return false;">
|
||||
<div style="grid-row: 1;"><label for="title">Title: <span class="mandatory">*</span></label></div>
|
||||
<div style="grid-row: 2;">
|
||||
<select id="title" name="title">
|
||||
{{$title := .Account.Title}}
|
||||
{{range .Titles}}
|
||||
<option value="{{.}}" {{if eq . $title}}selected{{end}}>{{.}}.</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="grid-row: 3;"><label for="fname">First name: <span class="mandatory">*</span></label></div>
|
||||
<div style="grid-row: 4;"><input type="text" id="fname" name="fname" value="{{.Account.FirstName}}"/></div>
|
||||
<div style="grid-row: 3;"><label for="lname">Last name: <span class="mandatory">*</span></label></div>
|
||||
<div style="grid-row: 4;"><input type="text" id="lname" name="lname" value="{{.Account.LastName}}"/></div>
|
||||
|
||||
<div style="grid-row: 5;"><label for="role">Role: <span class="mandatory">*</span></label></div>
|
||||
<div style="grid-row: 6;"><input type="text" id="role" name="role" placeholder="Site administrator" value="{{.Account.Role}}"/></div>
|
||||
<div style="grid-row: 5;"><label for="phone">Phone number:</label></div>
|
||||
<div style="grid-row: 6;"><input type="text" id="phone" name="phone" placeholder="+49 030 123456" value="{{.Account.PhoneNumber}}"/></div>
|
||||
|
||||
<div style="grid-row: 7;"> </div>
|
||||
|
||||
<div style="grid-row: 8; grid-column: 1 / span 2;">If you want to change your password, fill out the fields below. Otherwise, leave them empty to keep your current one.</div>
|
||||
<div style="grid-row: 9;"><label for="password">New password:</label></div>
|
||||
<div style="grid-row: 10;"><input type="password" id="password" name="password" autocomplete="new-password"/></div>
|
||||
<div style="grid-row: 9"><label for="password2">Confirm new password:</label></div>
|
||||
<div style="grid-row: 10;"><input type="password" id="password2" name="password2" autocomplete="new-password"/></div>
|
||||
|
||||
<div style="grid-row: 11; font-style: italic; font-size: 0.8em;">
|
||||
The password must fulfil the following criteria:
|
||||
<ul style="margin-top: 0em;">
|
||||
<li>Must be at least 8 characters long</li>
|
||||
<li>Must contain at least 1 lowercase letter</li>
|
||||
<li>Must contain at least 1 uppercase letter</li>
|
||||
<li>Must contain at least 1 digit</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="grid-row: 12; align-self: center;">
|
||||
Fields marked with <span class="mandatory">*</span> are mandatory.
|
||||
</div>
|
||||
<div style="grid-row: 12; grid-column: 2; text-align: right;">
|
||||
<button type="reset">Reset</button>
|
||||
<button type="submit" style="font-weight: bold;">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
<p>Go <a href="{{getServerAddress}}/account/?path=manage">back</a> to the main account page.</p>
|
||||
</div>
|
||||
`
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
// Copyright 2018-2020 CERN
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// In applying this license, CERN does not waive the privileges and immunities
|
||||
// granted to it by virtue of its status as an Intergovernmental Organization
|
||||
// or submit itself to any jurisdiction.
|
||||
|
||||
package login
|
||||
|
||||
import "github.com/opencloud-eu/reva/v2/pkg/siteacc/html"
|
||||
|
||||
// PanelTemplate is the content provider for the login form.
|
||||
type PanelTemplate struct {
|
||||
html.ContentProvider
|
||||
}
|
||||
|
||||
// GetTitle returns the title of the panel.
|
||||
func (template *PanelTemplate) GetTitle() string {
|
||||
return "ScienceMesh Site Administrator Account Login"
|
||||
}
|
||||
|
||||
// GetCaption returns the caption which is displayed on the panel.
|
||||
func (template *PanelTemplate) GetCaption() string {
|
||||
return "Login to your ScienceMesh Site Administrator Account!"
|
||||
}
|
||||
|
||||
// GetContentJavaScript delivers additional JavaScript code.
|
||||
func (template *PanelTemplate) GetContentJavaScript() string {
|
||||
return tplJavaScript
|
||||
}
|
||||
|
||||
// GetContentStyleSheet delivers additional stylesheet code.
|
||||
func (template *PanelTemplate) GetContentStyleSheet() string {
|
||||
return tplStyleSheet
|
||||
}
|
||||
|
||||
// GetContentBody delivers the actual body content.
|
||||
func (template *PanelTemplate) GetContentBody() string {
|
||||
return tplBody
|
||||
}
|
||||
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user