From 3e904023501a0db87f08faa2ab6067ec5f1b5539 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Tue, 6 Feb 2024 10:35:55 +0100 Subject: [PATCH 01/29] feat: Add new collaboration service (WOPI) --- Makefile | 1 + go.mod | 2 +- go.sum | 2 + ocis-pkg/config/config.go | 2 + ocis-pkg/config/defaultconfig.go | 2 + ocis/pkg/command/services.go | 6 + services/collaboration/Makefile | 37 +++ services/collaboration/pkg/command/root.go | 29 ++ services/collaboration/pkg/command/server.go | 109 +++++++ services/collaboration/pkg/config/app.go | 9 + services/collaboration/pkg/config/config.go | 47 +++ services/collaboration/pkg/config/cs3api.go | 15 + services/collaboration/pkg/config/debug.go | 9 + .../pkg/config/defaults/defaultconfig.go | 83 ++++++ services/collaboration/pkg/config/grpc.go | 7 + services/collaboration/pkg/config/http.go | 14 + services/collaboration/pkg/config/log.go | 9 + .../collaboration/pkg/config/parser/parse.go | 38 +++ services/collaboration/pkg/config/service.go | 6 + services/collaboration/pkg/config/wopiapp.go | 7 + .../collaboration/pkg/cs3wopiserver/start.go | 42 +++ .../collaboration/pkg/internal/app/app.go | 109 +++++++ .../collaboration/pkg/internal/app/claims.go | 8 + .../collaboration/pkg/internal/app/crypto.go | 74 +++++ .../pkg/internal/app/fileinfo.go | 141 +++++++++ .../collaboration/pkg/internal/app/headers.go | 5 + .../pkg/internal/app/wopicontext.go | 86 ++++++ .../pkg/internal/app/wopidiscovery.go | 123 ++++++++ .../pkg/internal/app/wopifilecontents.go | 97 ++++++ .../pkg/internal/app/wopiinfo.go | 136 +++++++++ .../pkg/internal/app/wopilocking.go | 280 ++++++++++++++++++ .../pkg/internal/helpers/download.go | 106 +++++++ .../pkg/internal/helpers/upload.go | 127 ++++++++ .../pkg/internal/logging/logging.go | 17 ++ .../pkg/internal/middleware/accesslog.go | 32 ++ .../collaboration/pkg/server/grpc/option.go | 76 +++++ .../collaboration/pkg/server/grpc/server.go | 32 ++ .../collaboration/pkg/server/http/option.go | 68 +++++ .../collaboration/pkg/server/http/server.go | 157 ++++++++++ .../pkg/service/grpc/v0/option.go | 47 +++ .../pkg/service/grpc/v0/service.go | 227 ++++++++++++++ vendor/github.com/dchest/uniuri/COPYING | 121 ++++++++ vendor/github.com/dchest/uniuri/README.md | 95 ++++++ vendor/github.com/dchest/uniuri/uniuri.go | 120 ++++++++ vendor/modules.txt | 3 + 45 files changed, 2762 insertions(+), 1 deletion(-) create mode 100644 services/collaboration/Makefile create mode 100644 services/collaboration/pkg/command/root.go create mode 100644 services/collaboration/pkg/command/server.go create mode 100644 services/collaboration/pkg/config/app.go create mode 100644 services/collaboration/pkg/config/config.go create mode 100644 services/collaboration/pkg/config/cs3api.go create mode 100644 services/collaboration/pkg/config/debug.go create mode 100644 services/collaboration/pkg/config/defaults/defaultconfig.go create mode 100644 services/collaboration/pkg/config/grpc.go create mode 100644 services/collaboration/pkg/config/http.go create mode 100644 services/collaboration/pkg/config/log.go create mode 100644 services/collaboration/pkg/config/parser/parse.go create mode 100644 services/collaboration/pkg/config/service.go create mode 100644 services/collaboration/pkg/config/wopiapp.go create mode 100644 services/collaboration/pkg/cs3wopiserver/start.go create mode 100644 services/collaboration/pkg/internal/app/app.go create mode 100644 services/collaboration/pkg/internal/app/claims.go create mode 100644 services/collaboration/pkg/internal/app/crypto.go create mode 100644 services/collaboration/pkg/internal/app/fileinfo.go create mode 100644 services/collaboration/pkg/internal/app/headers.go create mode 100644 services/collaboration/pkg/internal/app/wopicontext.go create mode 100644 services/collaboration/pkg/internal/app/wopidiscovery.go create mode 100644 services/collaboration/pkg/internal/app/wopifilecontents.go create mode 100644 services/collaboration/pkg/internal/app/wopiinfo.go create mode 100644 services/collaboration/pkg/internal/app/wopilocking.go create mode 100644 services/collaboration/pkg/internal/helpers/download.go create mode 100644 services/collaboration/pkg/internal/helpers/upload.go create mode 100644 services/collaboration/pkg/internal/logging/logging.go create mode 100644 services/collaboration/pkg/internal/middleware/accesslog.go create mode 100644 services/collaboration/pkg/server/grpc/option.go create mode 100644 services/collaboration/pkg/server/grpc/server.go create mode 100644 services/collaboration/pkg/server/http/option.go create mode 100644 services/collaboration/pkg/server/http/server.go create mode 100644 services/collaboration/pkg/service/grpc/v0/option.go create mode 100644 services/collaboration/pkg/service/grpc/v0/service.go create mode 100644 vendor/github.com/dchest/uniuri/COPYING create mode 100644 vendor/github.com/dchest/uniuri/README.md create mode 100644 vendor/github.com/dchest/uniuri/uniuri.go diff --git a/Makefile b/Makefile index 5cbfb56888..39d6c7d949 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,7 @@ OCIS_MODULES = \ services/auth-machine \ services/auth-service \ services/clientlog \ + services/collaboration \ services/eventhistory \ services/frontend \ services/gateway \ diff --git a/go.mod b/go.mod index 9ac700805e..885eeadbcc 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/MicahParks/keyfunc v1.9.0 github.com/Nerzal/gocloak/v13 v13.9.0 github.com/bbalet/stopwords v1.0.0 + github.com/beevik/etree v1.3.0 github.com/blevesearch/bleve/v2 v2.4.0 github.com/cenkalti/backoff v2.2.1+incompatible github.com/coreos/go-oidc/v3 v3.10.0 @@ -133,7 +134,6 @@ require ( github.com/armon/go-radix v1.0.0 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aws/aws-sdk-go v1.45.1 // indirect - github.com/beevik/etree v1.3.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bitly/go-simplejson v0.5.0 // indirect github.com/bits-and-blooms/bitset v1.2.1 // indirect diff --git a/go.sum b/go.sum index 7bf4922cf0..919a250a01 100644 --- a/go.sum +++ b/go.sum @@ -1031,6 +1031,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g= +github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY= github.com/deckarep/golang-set v1.8.0 h1:sk9/l/KqpunDwP7pSjUg0keiOOLEnOBHzykLrsPppp4= github.com/deckarep/golang-set v1.8.0/go.mod h1:5nI87KwE7wgsBU1F4GKAw2Qod7p5kyS383rP6+o6qqo= github.com/deepmap/oapi-codegen v1.3.11/go.mod h1:suMvK7+rKlx3+tpa8ByptmvoXbAV70wERKTOGH3hLp0= diff --git a/ocis-pkg/config/config.go b/ocis-pkg/config/config.go index c6ea6f671c..efd9527a05 100644 --- a/ocis-pkg/config/config.go +++ b/ocis-pkg/config/config.go @@ -11,6 +11,7 @@ import ( authmachine "github.com/owncloud/ocis/v2/services/auth-machine/pkg/config" authservice "github.com/owncloud/ocis/v2/services/auth-service/pkg/config" clientlog "github.com/owncloud/ocis/v2/services/clientlog/pkg/config" + collaboration "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" eventhistory "github.com/owncloud/ocis/v2/services/eventhistory/pkg/config" frontend "github.com/owncloud/ocis/v2/services/frontend/pkg/config" gateway "github.com/owncloud/ocis/v2/services/gateway/pkg/config" @@ -88,6 +89,7 @@ type Config struct { AuthMachine *authmachine.Config `yaml:"auth_machine"` AuthService *authservice.Config `yaml:"auth_service"` Clientlog *clientlog.Config `yaml:"clientlog"` + Collaboration *collaboration.Config `yaml:"collaboration"` EventHistory *eventhistory.Config `yaml:"eventhistory"` Frontend *frontend.Config `yaml:"frontend"` Gateway *gateway.Config `yaml:"gateway"` diff --git a/ocis-pkg/config/defaultconfig.go b/ocis-pkg/config/defaultconfig.go index 0cd441aec3..6a13d306ee 100644 --- a/ocis-pkg/config/defaultconfig.go +++ b/ocis-pkg/config/defaultconfig.go @@ -10,6 +10,7 @@ import ( authmachine "github.com/owncloud/ocis/v2/services/auth-machine/pkg/config/defaults" authservice "github.com/owncloud/ocis/v2/services/auth-service/pkg/config/defaults" clientlog "github.com/owncloud/ocis/v2/services/clientlog/pkg/config/defaults" + collaboration "github.com/owncloud/ocis/v2/services/collaboration/pkg/config/defaults" eventhistory "github.com/owncloud/ocis/v2/services/eventhistory/pkg/config/defaults" frontend "github.com/owncloud/ocis/v2/services/frontend/pkg/config/defaults" gateway "github.com/owncloud/ocis/v2/services/gateway/pkg/config/defaults" @@ -60,6 +61,7 @@ func DefaultConfig() *Config { AuthMachine: authmachine.DefaultConfig(), AuthService: authservice.DefaultConfig(), Clientlog: clientlog.DefaultConfig(), + Collaboration: collaboration.DefaultConfig(), EventHistory: eventhistory.DefaultConfig(), Frontend: frontend.DefaultConfig(), Gateway: gateway.DefaultConfig(), diff --git a/ocis/pkg/command/services.go b/ocis/pkg/command/services.go index 448a0ff937..1ddfafdaa3 100644 --- a/ocis/pkg/command/services.go +++ b/ocis/pkg/command/services.go @@ -17,6 +17,7 @@ import ( authmachine "github.com/owncloud/ocis/v2/services/auth-machine/pkg/command" authservice "github.com/owncloud/ocis/v2/services/auth-service/pkg/command" clientlog "github.com/owncloud/ocis/v2/services/clientlog/pkg/command" + collaboration "github.com/owncloud/ocis/v2/services/collaboration/pkg/command" eventhistory "github.com/owncloud/ocis/v2/services/eventhistory/pkg/command" frontend "github.com/owncloud/ocis/v2/services/frontend/pkg/command" gateway "github.com/owncloud/ocis/v2/services/gateway/pkg/command" @@ -96,6 +97,11 @@ var svccmds = []register.Command{ cfg.Clientlog.Commons = cfg.Commons }) }, + func(cfg *config.Config) *cli.Command { + return ServiceCommand(cfg, cfg.Collaboration.Service.Name, collaboration.GetCommands(cfg.Collaboration), func(c *config.Config) { + cfg.Collaboration.Commons = cfg.Commons + }) + }, func(cfg *config.Config) *cli.Command { return ServiceCommand(cfg, cfg.EventHistory.Service.Name, eventhistory.GetCommands(cfg.EventHistory), func(c *config.Config) { cfg.EventHistory.Commons = cfg.Commons diff --git a/services/collaboration/Makefile b/services/collaboration/Makefile new file mode 100644 index 0000000000..2b960a7f14 --- /dev/null +++ b/services/collaboration/Makefile @@ -0,0 +1,37 @@ +SHELL := bash +NAME := collaboration + +include ../../.make/recursion.mk + +############ tooling ############ +ifneq (, $(shell command -v go 2> /dev/null)) # suppress `command not found warnings` for non go targets in CI +include ../../.bingo/Variables.mk +endif + +############ go tooling ############ +include ../../.make/go.mk + +############ release ############ +include ../../.make/release.mk + +############ docs generate ############ +include ../../.make/docs.mk + +.PHONY: docs-generate +docs-generate: config-docs-generate + +############ generate ############ +include ../../.make/generate.mk + +.PHONY: ci-go-generate +ci-go-generate: # CI runs ci-node-generate automatically before this target + +.PHONY: ci-node-generate +ci-node-generate: + +############ licenses ############ +.PHONY: ci-node-check-licenses +ci-node-check-licenses: + +.PHONY: ci-node-save-licenses +ci-node-save-licenses: diff --git a/services/collaboration/pkg/command/root.go b/services/collaboration/pkg/command/root.go new file mode 100644 index 0000000000..1e73df302f --- /dev/null +++ b/services/collaboration/pkg/command/root.go @@ -0,0 +1,29 @@ +package command + +import ( + "os" + + "github.com/owncloud/ocis/v2/ocis-pkg/clihelper" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" + "github.com/urfave/cli/v2" +) + +// GetCommands provides all commands for this service +func GetCommands(cfg *config.Config) cli.Commands { + return []*cli.Command{ + Server(cfg), + //Health(cfg), + //Version(cfg), + } +} + +// Execute is the entry point for the antivirus command. +func Execute(cfg *config.Config) error { + app := clihelper.DefaultApp(&cli.App{ + Name: "collaboration", + Usage: "Serve WOPI for oCIS", + Commands: GetCommands(cfg), + }) + + return app.Run(os.Args) +} diff --git a/services/collaboration/pkg/command/server.go b/services/collaboration/pkg/command/server.go new file mode 100644 index 0000000000..400fca879c --- /dev/null +++ b/services/collaboration/pkg/command/server.go @@ -0,0 +1,109 @@ +package command + +import ( + "context" + "fmt" + "net" + + "github.com/oklog/run" + "github.com/owncloud/ocis/v2/ocis-pkg/config/configlog" + "github.com/owncloud/ocis/v2/ocis-pkg/tracing" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config/parser" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/cs3wopiserver" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/logging" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/server/grpc" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/server/http" + "github.com/urfave/cli/v2" +) + +// Server is the entrypoint for the server command. +func Server(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "server", + Usage: fmt.Sprintf("start the %s service without runtime (unsupervised mode)", cfg.Service.Name), + Category: "server", + Before: func(c *cli.Context) error { + return configlog.ReturnFatal(parser.ParseConfig(cfg)) + }, + Action: func(c *cli.Context) error { + logger := logging.Configure(cfg.Service.Name, cfg.Log) + traceProvider, err := tracing.GetServiceTraceProvider(cfg.Tracing, cfg.Service.Name) + if err != nil { + return err + } + + gr := run.Group{} + ctx, cancel := func() (context.Context, context.CancelFunc) { + if cfg.Context == nil { + return context.WithCancel(context.Background()) + } + return context.WithCancel(cfg.Context) + }() + defer cancel() + + app, err := cs3wopiserver.Start(cfg) // grpc server needs decoupling + if err != nil { + return err + } + grpcServer, teardown, err := grpc.Server( + grpc.App(app), + grpc.Config(cfg), + grpc.Logger(logger), + ) + defer teardown() + if err != nil { + logger.Info(). + Err(err). + Str("transport", "grpc"). + Msg("Failed to initialize server") + return err + } + + gr.Add(func() error { + l, err := net.Listen("tcp", cfg.GRPC.Addr) + if err != nil { + return err + } + grpcServer.Serve(l) + return nil + }, + func(_ error) { + logger.Error(). + Err(err). + Str("server", "grpc"). + Msg("shutting down server") + cancel() + }) + + /* + server, err := debug.Server( + debug.Logger(logger), + debug.Context(ctx), + debug.Config(cfg), + ) + if err != nil { + logger.Info().Err(err).Str("transport", "debug").Msg("Failed to initialize server") + return err + } + + gr.Add(server.ListenAndServe, func(_ error) { + _ = server.Shutdown(ctx) + cancel() + }) + */ + server, err := http.Server( + http.App(app), + http.Logger(logger), + http.Config(cfg), + http.Context(ctx), + http.TracerProvider(traceProvider), + ) + gr.Add(server.Run, func(_ error) { + cancel() + }) + + return gr.Run() + }, + } +} diff --git a/services/collaboration/pkg/config/app.go b/services/collaboration/pkg/config/app.go new file mode 100644 index 0000000000..075ecc0641 --- /dev/null +++ b/services/collaboration/pkg/config/app.go @@ -0,0 +1,9 @@ +package config + +// App defines the available app configuration. +type App struct { + Name string `yaml:"name" env:"COLLABORATION_APP_NAME" desc:"The name of the app"` + Description string `yaml:"description" env:"COLLABORATION_APP_DESCRIPTION" desc:"App description"` + Icon string `yaml:"icon" env:"COLLABORATION_APP_ICON" desc:"Icon for the app"` + LockName string `yaml:"lockname" env:"COLLABORATION_APP_LOCKNAME" desc:"Name for the app lock"` +} diff --git a/services/collaboration/pkg/config/config.go b/services/collaboration/pkg/config/config.go new file mode 100644 index 0000000000..117dd9fe14 --- /dev/null +++ b/services/collaboration/pkg/config/config.go @@ -0,0 +1,47 @@ +package config + +import ( + "context" + + "github.com/owncloud/ocis/v2/ocis-pkg/shared" + "github.com/owncloud/ocis/v2/ocis-pkg/tracing" +) + +// Config combines all available configuration parts. +type Config struct { + Commons *shared.Commons `yaml:"-"` // don't use this directly as configuration for a service + + Service Service `yaml:"-"` + App App `yaml:"app"` + + Secret string `yaml:"secret" env:"COLLABORATION_SECRET" desc:"Used as JWT token and to encrypt access token."` + + GRPC GRPC `yaml:"grpc"` + HTTP HTTP `yaml:"http"` + WopiApp WopiApp `yaml:"wopiapp"` + CS3Api CS3Api `yaml:"cs3api"` + + Tracing *Tracing `yaml:"tracing"` + Log *Log `yaml:"log"` + Debug Debug `yaml:"debug"` + + Context context.Context `yaml:"-"` +} + +// Tracing defines the available tracing configuration. Not used at the moment +type Tracing struct { + Enabled bool `yaml:"enabled" env:"OCIS_TRACING_ENABLED;COLLABORATION_TRACING_ENABLED" desc:"Activates tracing."` + Type string `yaml:"type" env:"OCIS_TRACING_TYPE;COLLABORATION_TRACING_TYPE" desc:"The type of tracing. Defaults to '', which is the same as 'jaeger'. Allowed tracing types are 'jaeger' and '' as of now."` + Endpoint string `yaml:"endpoint" env:"OCIS_TRACING_ENDPOINT;COLLABORATION_TRACING_ENDPOINT" desc:"The endpoint of the tracing agent."` + Collector string `yaml:"collector" env:"OCIS_TRACING_COLLECTOR;COLLABORATION_TRACING_COLLECTOR" desc:"The HTTP endpoint for sending spans directly to a collector, i.e. http://jaeger-collector:14268/api/traces. Only used if the tracing endpoint is unset."` +} + +// Convert Tracing to the tracing package's Config struct. +func (t Tracing) Convert() tracing.Config { + return tracing.Config{ + Enabled: t.Enabled, + Type: t.Type, + Endpoint: t.Endpoint, + Collector: t.Collector, + } +} diff --git a/services/collaboration/pkg/config/cs3api.go b/services/collaboration/pkg/config/cs3api.go new file mode 100644 index 0000000000..d810e8ca2e --- /dev/null +++ b/services/collaboration/pkg/config/cs3api.go @@ -0,0 +1,15 @@ +package config + +// WopiApp defines the available configuration in order to connect to a WOPI app. +type CS3Api struct { + Gateway Gateway `yaml:"gateway"` + DataGateway DataGateway `yaml:"datagateway"` +} + +type Gateway struct { + Name string `yaml: "name" env:"COLLABORATION_CS3API_GATEWAY_NAME" desc:"service name of the CS3API gateway"` +} + +type DataGateway struct { + Insecure bool `yaml:"insecure" env:"COLLABORATION_CS3API_DATAGATEWAY_INSECURE" desc:"connect to the CS3API data gateway insecurely"` +} diff --git a/services/collaboration/pkg/config/debug.go b/services/collaboration/pkg/config/debug.go new file mode 100644 index 0000000000..ad1afb576f --- /dev/null +++ b/services/collaboration/pkg/config/debug.go @@ -0,0 +1,9 @@ +package config + +// Debug defines the available debug configuration. Not used at the moment +type Debug struct { + Addr string `yaml:"addr" env:"COLLABORATION_DEBUG_ADDR" desc:"Bind address of the debug server, where metrics, health, config and debug endpoints will be exposed."` + Token string `yaml:"token" env:"COLLABORATION_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint."` + Pprof bool `yaml:"pprof" env:"COLLABORATION_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling."` + Zpages bool `yaml:"zpages" env:"COLLABORATION_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces."` +} diff --git a/services/collaboration/pkg/config/defaults/defaultconfig.go b/services/collaboration/pkg/config/defaults/defaultconfig.go new file mode 100644 index 0000000000..7025860fce --- /dev/null +++ b/services/collaboration/pkg/config/defaults/defaultconfig.go @@ -0,0 +1,83 @@ +package defaults + +import ( + "github.com/dchest/uniuri" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" +) + +// FullDefaultConfig returns a fully initialized default configuration +func FullDefaultConfig() *config.Config { + cfg := DefaultConfig() + EnsureDefaults(cfg) + Sanitize(cfg) + return cfg +} + +// DefaultConfig returns a basic default configuration +func DefaultConfig() *config.Config { + return &config.Config{ + Service: config.Service{ + Name: "collaboration", + }, + App: config.App{ + Name: "WOPI app", + Description: "Open office documents with a WOPI app", + Icon: "image-edit", + LockName: "com.github.owncloud.collaboration", + }, + Secret: uniuri.NewLen(32), + GRPC: config.GRPC{ + Addr: "127.0.0.1:56778", + Namespace: "com.owncloud.collaboration", + }, + HTTP: config.HTTP{ + Addr: "127.0.0.1:6789", + Namespace: "com.owncloud.collaboration", + //Scheme: "http", + }, + WopiApp: config.WopiApp{ + Addr: "https://127.0.0.1:8080", + Insecure: true, // TODO: this should have a secure default + }, + CS3Api: config.CS3Api{ + Gateway: config.Gateway{ + Name: "com.owncloud.api.gateway", + }, + DataGateway: config.DataGateway{ + Insecure: true, // TODO: this should have a secure default + }, + }, + } +} + +// EnsureDefaults adds default values to the configuration if they are not set yet +func EnsureDefaults(cfg *config.Config) { + // provide with defaults for shared logging, since we need a valid destination address for "envdecode". + if cfg.Log == nil && cfg.Commons != nil && cfg.Commons.Log != nil { + cfg.Log = &config.Log{ + Level: cfg.Commons.Log.Level, + Pretty: cfg.Commons.Log.Pretty, + Color: cfg.Commons.Log.Color, + File: cfg.Commons.Log.File, + } + } else if cfg.Log == nil { + cfg.Log = &config.Log{} + } + + // provide with defaults for shared tracing, since we need a valid destination address for "envdecode". + if cfg.Tracing == nil && cfg.Commons != nil && cfg.Commons.Tracing != nil { + cfg.Tracing = &config.Tracing{ + Enabled: cfg.Commons.Tracing.Enabled, + Type: cfg.Commons.Tracing.Type, + Endpoint: cfg.Commons.Tracing.Endpoint, + Collector: cfg.Commons.Tracing.Collector, + } + } else if cfg.Tracing == nil { + cfg.Tracing = &config.Tracing{} + } +} + +// Sanitize sanitized the configuration +func Sanitize(cfg *config.Config) { + // sanitize config +} diff --git a/services/collaboration/pkg/config/grpc.go b/services/collaboration/pkg/config/grpc.go new file mode 100644 index 0000000000..795f4970f7 --- /dev/null +++ b/services/collaboration/pkg/config/grpc.go @@ -0,0 +1,7 @@ +package config + +// Service defines the available grpc configuration. +type GRPC struct { + Addr string `yaml:"addr" env:"COLLABORATION_GRPC_ADDR" desc:"The bind address of the GRPC service"` + Namespace string `yaml:"-"` +} diff --git a/services/collaboration/pkg/config/http.go b/services/collaboration/pkg/config/http.go new file mode 100644 index 0000000000..e2b472a9c8 --- /dev/null +++ b/services/collaboration/pkg/config/http.go @@ -0,0 +1,14 @@ +package config + +import ( + "github.com/owncloud/ocis/v2/ocis-pkg/shared" +) + +// HTTP defines the available http configuration. +type HTTP struct { + Addr string `yaml:"addr" env:"COLLABORATION_HTTP_ADDR" desc:"The address of the HTTP service."` + BindAddr string `yaml:"bindaddr" env:"COLLABORATION_HTTP_BINDADDR" desc:"The bind address of the HTTP service."` + Namespace string `yaml:"-"` + Scheme string `yaml:"scheme" env:"COLLABORATION_HTTP_SCHEME" desc:"Either http or https"` + TLS shared.HTTPServiceTLS `yaml:"tls"` +} diff --git a/services/collaboration/pkg/config/log.go b/services/collaboration/pkg/config/log.go new file mode 100644 index 0000000000..0c3cc0360a --- /dev/null +++ b/services/collaboration/pkg/config/log.go @@ -0,0 +1,9 @@ +package config + +// Log defines the available log configuration. +type Log struct { + Level string `yaml:"level" env:"OCIS_LOG_LEVEL;COLLABORATION_LOG_LEVEL" desc:"The log level. Valid values are: 'panic', 'fatal', 'error', 'warn', 'info', 'debug', 'trace'."` + Pretty bool `yaml:"pretty" env:"OCIS_LOG_PRETTY;COLLABORATION_LOG_PRETTY" desc:"Activates pretty log output."` + Color bool `yaml:"color" env:"OCIS_LOG_COLOR;COLLABORATION_LOG_COLOR" desc:"Activates colorized log output."` + File string `yaml:"file" env:"OCIS_LOG_FILE;COLLABORATION_LOG_FILE" desc:"The path to the log file. Activates logging to this file if set."` +} diff --git a/services/collaboration/pkg/config/parser/parse.go b/services/collaboration/pkg/config/parser/parse.go new file mode 100644 index 0000000000..7fc029fc42 --- /dev/null +++ b/services/collaboration/pkg/config/parser/parse.go @@ -0,0 +1,38 @@ +package parser + +import ( + "errors" + + ociscfg "github.com/owncloud/ocis/v2/ocis-pkg/config" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config/defaults" + + "github.com/owncloud/ocis/v2/ocis-pkg/config/envdecode" +) + +// ParseConfig loads configuration from known paths. +func ParseConfig(cfg *config.Config) error { + _, err := ociscfg.BindSourcesToStructs(cfg.Service.Name, cfg) + if err != nil { + return err + } + + defaults.EnsureDefaults(cfg) + + // load all env variables relevant to the config in the current context. + if err := envdecode.Decode(cfg); err != nil { + // no environment variable set for this config is an expected "error" + if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) { + return err + } + } + + defaults.Sanitize(cfg) + + return Validate(cfg) +} + +// Validate validates the configuration +func Validate(cfg *config.Config) error { + return nil +} diff --git a/services/collaboration/pkg/config/service.go b/services/collaboration/pkg/config/service.go new file mode 100644 index 0000000000..d1eac383f0 --- /dev/null +++ b/services/collaboration/pkg/config/service.go @@ -0,0 +1,6 @@ +package config + +// Service defines the available service configuration. +type Service struct { + Name string `yaml:"-"` +} diff --git a/services/collaboration/pkg/config/wopiapp.go b/services/collaboration/pkg/config/wopiapp.go new file mode 100644 index 0000000000..fcf24ca6e1 --- /dev/null +++ b/services/collaboration/pkg/config/wopiapp.go @@ -0,0 +1,7 @@ +package config + +// WopiApp defines the available configuration in order to connect to a WOPI app. +type WopiApp struct { + Addr string `yaml:"addr" env:"COLLABORATION_WOPIAPP_ADDR" desc:"The URL where the WOPI app is located, such as https://127.0.0.1:8080."` + Insecure bool `yaml:"insecure" env:"COLLABORATION_WOPIAPP_INSECURE" desc:"Connect insecurely"` +} diff --git a/services/collaboration/pkg/cs3wopiserver/start.go b/services/collaboration/pkg/cs3wopiserver/start.go new file mode 100644 index 0000000000..425f36911c --- /dev/null +++ b/services/collaboration/pkg/cs3wopiserver/start.go @@ -0,0 +1,42 @@ +package cs3wopiserver + +import ( + "context" + + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/app" +) + +func Start(cfg *config.Config) (*app.DemoApp, error) { + ctx := context.Background() + + app, err := app.New(cfg) + if err != nil { + return nil, err + } + + if err := app.RegisterOcisService(ctx); err != nil { + return nil, err + } + + if err := app.WopiDiscovery(ctx); err != nil { + return nil, err + } + + if err := app.GetCS3apiClient(); err != nil { + return nil, err + } + + if err := app.RegisterDemoApp(ctx); err != nil { + return nil, err + } + + // NOTE: + // GRPC and HTTP server are started using the standard + // `ocis collaboration server` command through the usual means + + // TODO: + // "app" initialization needs to be moved + + return app, nil +} diff --git a/services/collaboration/pkg/internal/app/app.go b/services/collaboration/pkg/internal/app/app.go new file mode 100644 index 0000000000..7281aa3ca6 --- /dev/null +++ b/services/collaboration/pkg/internal/app/app.go @@ -0,0 +1,109 @@ +package app + +import ( + "context" + "errors" + + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config/defaults" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/logging" + + registryv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/registry/v1beta1" + gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + "github.com/cs3org/reva/v2/pkg/mime" + "github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool" + "github.com/gofrs/uuid" + "github.com/owncloud/ocis/v2/ocis-pkg/config/envdecode" + "github.com/owncloud/ocis/v2/ocis-pkg/registry" + "google.golang.org/grpc" +) + +type DemoApp struct { + gwc gatewayv1beta1.GatewayAPIClient + grpcServer *grpc.Server + + AppURLs map[string]map[string]string + + Config *config.Config + + Logger log.Logger +} + +func New(cfg *config.Config) (*DemoApp, error) { + app := &DemoApp{ + Config: cfg, + } + + err := envdecode.Decode(app) + if err != nil { + if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) { + return nil, err + } + } + + app.Logger = logging.Configure("wopiserver", defaults.FullDefaultConfig().Log) + + return app, nil +} + +func (app *DemoApp) GetCS3apiClient() error { + // establish a connection to the cs3 api endpoint + // in this case a REVA gateway, started by oCIS + gwc, err := pool.GetGatewayServiceClient(app.Config.CS3Api.Gateway.Name) + if err != nil { + return err + } + app.gwc = gwc + + return nil +} + +func (app *DemoApp) RegisterOcisService(ctx context.Context) error { + svc := registry.BuildGRPCService(app.Config.Service.Name, uuid.Must(uuid.NewV4()).String(), app.Config.GRPC.Addr, "0.0.0") + return registry.RegisterService(ctx, svc, app.Logger) +} + +func (app *DemoApp) RegisterDemoApp(ctx context.Context) error { + mimeTypesMap := make(map[string]bool) + for _, extensions := range app.AppURLs { + for ext := range extensions { + m := mime.Detect(false, ext) + mimeTypesMap[m] = true + } + } + + mimeTypes := make([]string, 0, len(mimeTypesMap)) + for m := range mimeTypesMap { + mimeTypes = append(mimeTypes, m) + } + + // TODO: REVA has way to filter supported mimetypes (do we need to implement it here or is it in the registry?) + + // TODO: an added app provider shouldn't last forever. Instead the registry should use a TTL + // and delete providers that didn't register again. If an app provider dies or get's disconnected, + // the users will be no longer available to choose to open a file with it (currently, opening a file just fails) + req := ®istryv1beta1.AddAppProviderRequest{ + Provider: ®istryv1beta1.ProviderInfo{ + Name: app.Config.App.Name, + Description: app.Config.App.Description, + Icon: app.Config.App.Icon, + Address: app.Config.Service.Name, + MimeTypes: mimeTypes, + }, + } + + resp, err := app.gwc.AddAppProvider(ctx, req) + if err != nil { + app.Logger.Error().Err(err).Msg("AddAppProvider failed") + return err + } + + if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + app.Logger.Error().Str("status_code", resp.Status.Code.String()).Msg("AddAppProvider failed") + return errors.New("status code != CODE_OK") + } + + return nil +} diff --git a/services/collaboration/pkg/internal/app/claims.go b/services/collaboration/pkg/internal/app/claims.go new file mode 100644 index 0000000000..6715996261 --- /dev/null +++ b/services/collaboration/pkg/internal/app/claims.go @@ -0,0 +1,8 @@ +package app + +import "github.com/golang-jwt/jwt/v4" + +type Claims struct { + WopiContext WopiContext `json:"WopiContext"` + jwt.StandardClaims +} diff --git a/services/collaboration/pkg/internal/app/crypto.go b/services/collaboration/pkg/internal/app/crypto.go new file mode 100644 index 0000000000..f30aac26fa --- /dev/null +++ b/services/collaboration/pkg/internal/app/crypto.go @@ -0,0 +1,74 @@ +package app + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "io" +) + +func keyPadding(key []byte) []byte { + switch length := len(key); { + case length < 16: + return append(key, make([]byte, 16-length)...) + case length == 16: + return key + case length < 24: + return append(key, make([]byte, 24-length)...) + case length == 24: + return key + case length < 32: + return append(key, make([]byte, 32-length)...) + case length == 32: + return key + case length > 32: + return key[:32] + } + return []byte{} +} + +func EncryptAES(key []byte, plainText string) (string, error) { + src := []byte(plainText) + + block, err := aes.NewCipher(keyPadding(key)) + if err != nil { + return "", err + } + + cipherText := make([]byte, aes.BlockSize+len(src)) + iv := cipherText[:aes.BlockSize] + if _, err = io.ReadFull(rand.Reader, iv); err != nil { + return "", err + } + + stream := cipher.NewCFBEncrypter(block, iv) + stream.XORKeyStream(cipherText[aes.BlockSize:], src) + + return base64.URLEncoding.EncodeToString(cipherText), nil +} + +func DecryptAES(key []byte, securemess string) (string, error) { + cipherText, err := base64.URLEncoding.DecodeString(securemess) + if err != nil { + return "", err + } + + block, err := aes.NewCipher(keyPadding(key)) + if err != nil { + return "", err + } + + if len(cipherText) < aes.BlockSize { + return "", errors.New("ciphertext block size is too short") + } + + iv := cipherText[:aes.BlockSize] + cipherText = cipherText[aes.BlockSize:] + + stream := cipher.NewCFBDecrypter(block, iv) + stream.XORKeyStream(cipherText, cipherText) + + return string(cipherText), nil +} diff --git a/services/collaboration/pkg/internal/app/fileinfo.go b/services/collaboration/pkg/internal/app/fileinfo.go new file mode 100644 index 0000000000..3de3c9937d --- /dev/null +++ b/services/collaboration/pkg/internal/app/fileinfo.go @@ -0,0 +1,141 @@ +package app + +type FileInfo struct { + // ------------ + // Microsoft WOPI check file info specification: + // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo + // ------------ + + // The string name of the file, including extension, without a path. Used for display in user interface (UI), and determining the extension of the file. + BaseFileName string `json:"BaseFileName,omitempty"` + //A string that uniquely identifies the owner of the file. In most cases, the user who uploaded or created the file should be considered the owner. + OwnerID string `json:"OwnerId,omitempty"` + // A string value uniquely identifying the user currently accessing the file. + UserID string `json:"UserId,omitempty"` + // The size of the file in bytes, expressed as a long, a 64-bit signed integer. + Size int64 `json:"Size,omitempty"` + // The current version of the file based on the server’s file version schema, as a string. This value must change when the file changes, and version values must never repeat for a given file. + Version string `json:"Version,omitempty"` + // A string that is the name of the user, suitable for displaying in UI. + UserFriendlyName string `json:"UserFriendlyName,omitempty"` + // A 256 bit SHA-2-encoded [FIPS 180-2] hash of the file contents, as a Base64-encoded string. Used for caching purposes in WOPI clients. + SHA256 string `json:"SHA256,omitempty"` + // A string value representing the file extension for the file. This value must begin with a .. If provided, WOPI clients will use this value as the file extension. Otherwise the extension will be parsed from the BaseFileName. + FileExtension string `json:"FileExtension,omitempty"` + // An integer value that indicates the maximum length for file names that the WOPI host supports, excluding the file extension. The default value is 250. Note that WOPI clients will use this default value if the property is omitted or if it is explicitly set to 0. + FileNameMaxLength int `json:"FileNameMaxLength,omitempty"` + // A string that represents the last time that the file was modified. This time must always be a must be a UTC time, and must be formatted in ISO 8601 round-trip format. For example, "2009-06-15T13:45:30.0000000Z". + LastModifiedTime string `json:"LastModifiedTime,omitempty"` + // A string value containing information about the user. This string can be passed from a WOPI client to the host by means of a PutUserInfo operation. If the host has a UserInfo string for the user, they must include it in this property. See the PutUserInfo documentation for more details. + UserInfo string `json:"UserInfo,omitempty"` + // A Boolean value that indicates that, for this user, the file cannot be changed. + ReadOnly bool `json:"ReadOnly"` + // A Boolean value that indicates that if host is temporarily unable to process writes on a file + TemporarilyNotWritable bool `json:"TemporarilyNotWritable,omitempty"` + // A string value indicating whether the current document is shared with other users. The value can change upon adding or removing permissions to other users. Clients should use this value to help decide when to enable collaboration features as a document must be Shared in order to multi-user collaboration on the document. + SharingStatus string `json:"SharingStatus,omitempty"` + // A Boolean value that indicates that the WOPI client should restrict what actions the user can perform on the file. The behavior of this property is dependent on the WOPI client. + RestrictedWebViewOnly bool `json:"RestrictedWebViewOnly"` + // A Boolean value that indicates that the user has permission to view a broadcast of this file. + UserCanAttend bool `json:"UserCanAttend"` + // A Boolean value that indicates that the user has permission to broadcast this file to a set of users who have permission to broadcast or view a broadcast of the current file. + UserCanPresent bool `json:"UserCanPresent"` + // A Boolean value that indicates the user does not have sufficient permission to create new files on the WOPI server. Setting this to true tells the WOPI client that calls to PutRelativeFile will fail for this user on the current file. + UserCanNotWriteRelative bool `json:"UserCanNotWriteRelative"` + // A Boolean value that indicates the user has permission to rename the current file. + UserCanRename bool `json:"UserCanRename"` + // A Boolean value that indicates that the user has permission to alter the file. Setting this to true tells the WOPI client that it can call PutFile on behalf of the user. + UserCanWrite bool `json:"UserCanWrite"` + // A Boolean value that indicates the WOPI client should close the window or tab when the user activates any Close UI in the WOPI client. + CloseButtonClosesWindow bool `json:"CloseButtonClosesWindow"` + // A string value indicating whether the WOPI client should disable Copy and Paste functionality within the application. The default is to permit all Copy and Paste functionality, i.e. the setting has no effect. + CopyPasteRestrictions string `json:"CopyPasteRestrictions,omitempty"` + // A Boolean value that indicates the WOPI client should disable all print functionality. + DisablePrint bool `json:"DisablePrint"` + // A Boolean value that indicates the WOPI client should disable all machine translation functionality. + DisableTranslation bool `json:"DisableTranslation"` + // A URI to a web page that the WOPI client should navigate to when the application closes, or in the event of an unrecoverable error. + CloseUrl string `json:"CloseUrl,omitempty"` + // A user-accessible URI to the file intended to allow the user to download a copy of the file. + DownloadUrl string `json:"DownloadUrl,omitempty"` + // A URI to a location that allows the user to create an embeddable URI to the file. + FileEmbedCommandUrl string `json:"FileEmbedCommandUrl,omitempty"` + // A URI to a location that allows the user to share the file. + FileSharingUrl string `json:"FileSharingUrl,omitempty"` + // A URI to the file location that the WOPI client uses to get the file. If this is provided, the WOPI client may use this URI to get the file instead of a GetFile request. A host might set this property if it is easier or provides better performance to serve files from a different domain than the one handling standard WOPI requests. WOPI clients must not add or remove parameters from the URL; no other parameters, including the access token, should be appended to the FileUrl before it is used. + FileUrl string `json:"FileUrl,omitempty"` + // A URI to a location that allows the user to view the version history for the file. + FileVersionUrl string `json:"FileVersionUrl,omitempty"` + // A URI to a host page that loads the edit WOPI action. + HostEditUrl string `json:"HostEditUrl,omitempty"` + // A URI to a web page that provides access to a viewing experience for the file that can be embedded in another HTML page. This is typically a URI to a host page that loads the embedview WOPI action. + HostEmbeddedViewUrl string `json:"HostEmbeddedViewUrl,omitempty"` + // A URI to a host page that loads the view WOPI action. This URL is used by Office Online to navigate between view and edit mode. + HostViewUrl string `json:"HostViewUrl,omitempty"` + // A URI that will sign the current user out of the host’s authentication system. + SignoutUrl string `json:"SignoutUrl,omitempty"` + // A string that indicates the brand name of the host. + BreadcrumbBrandName string `json:"BreadcrumbBrandName,omitempty"` + // A URI to a web page that the WOPI client should navigate to when the user clicks on UI that displays BreadcrumbBrandName. + BreadcrumbBrandUrl string `json:"BreadcrumbBrandUrl,omitempty"` + // A string that indicates the name of the file. If this is not provided, WOPI clients may use the BaseFileName value. + BreadcrumbDocName string `json:"BreadcrumbDocName,omitempty"` + // A string that indicates the name of the container that contains the file. + BreadcrumbFolderName string `json:"BreadcrumbFolderName,omitempty"` + // A URI to a web page that the WOPI client should navigate to when the user clicks on UI that displays BreadcrumbFolderName. + BreadcrumbFolderUrl string `json:"BreadcrumbFolderUrl,omitempty"` + // A Boolean value that indicates a WOPI client may connect to Microsoft services to provide end-user functionality. + AllowAdditionalMicrosoftServices bool `json:"AllowAdditionalMicrosoftServices"` + // A Boolean value that indicates that in the event of an error, the WOPI client is permitted to prompt the user for permission to collect a detailed report about their specific error. The information gathered could include the user’s file and other session-specific state. + AllowErrorReportPrompt bool `json:"AllowErrorReportPrompt"` + // A Boolean value that indicates a WOPI client may allow connections to external services referenced in the file (for example, a marketplace of embeddable JavaScript apps). + AllowExternalMarketplace bool `json:"AllowExternalMarketplace"` + // A string value offering guidance to the WOPI client as to how to differentiate client throttling behaviors between the user and documents combinations from the WOPI host. + ClientThrottlingProtection string `json:"ClientThrottlingProtection,omitempty"` + // A string value indicating whether the WOPI host is experiencing capacity problems and would like to reduce the frequency at which the WOPI clients make calls to the host + RequestedCallThrottling string `json:"RequestedCallThrottling,omitempty"` + // An array of strings containing the Share URL types supported by the host. + SupportedShareUrlTypes []string `json:"SupportedShareUrlTypes,omitempty"` + // A Boolean value that indicates that the host supports the following WOPI operations: ExecuteCellStorageRequest, ExecuteCellStorageRelativeRequest + SupportsCobalt bool `json:"SupportsCobalt"` + // A Boolean value that indicates that the host supports the following WOPI operations: CheckContainerInfo, CreateChildContainer, CreateChildFile, DeleteContainer, DeleteFile, EnumerateAncestors (containers), EnumerateAncestors (files), EnumerateChildren (containers), GetEcosystem (containers), RenameContainer + SupportsContainers bool `json:"SupportsContainers"` + // A Boolean value that indicates that the host supports the following WOPI operations: CheckEcosystem, GetEcosystem (containers), GetEcosystem (files), GetRootContainer (ecosystem) + SupportsEcosystem bool `json:"SupportsEcosystem"` + // A Boolean value that indicates that the host supports lock IDs up to 1024 ASCII characters long. If not provided, WOPI clients will assume that lock IDs are limited to 256 ASCII characters. + SupportsExtendedLockLength bool `json:"SupportsExtendedLockLength"` + // A Boolean value that indicates that the host supports the following WOPI operations: CheckFolderInfo, EnumerateChildren (folders), DeleteFile + SupportsFolders bool `json:"SupportsFolders"` + // A Boolean value that indicates that the host supports the GetFileWopiSrc (ecosystem) operation. + SupportsGetFileWopiSrc bool `json:"SupportsGetFileWopiSrc"` + // A Boolean value that indicates that the host supports the GetLock operation. + SupportsGetLock bool `json:"SupportsGetLock"` + // A Boolean value that indicates that the host supports the following WOPI operations: Lock, Unlock, RefreshLock, UnlockAndRelock operations for this file. + SupportsLocks bool `json:"SupportsLocks"` + // A Boolean value that indicates that the host supports the RenameFile operation. + SupportsRename bool `json:"SupportsRename"` + // A Boolean value that indicates that the host supports the following WOPI operations: PutFile, PutRelativeFile + SupportsUpdate bool `json:"SupportsUpdate"` // whether "Putfile" and "PutRelativeFile" work + // A Boolean value that indicates that the host supports the DeleteFile operation. + SupportsDeleteFile bool `json:"SupportsDeleteFile"` + // A Boolean value that indicates that the host supports the PutUserInfo operation. + SupportsUserInfo bool `json:"SupportsUserInfo"` + // A Boolean value indicating whether the user is authenticated with the host or not. Hosts should always set this to true for unauthenticated users, so that clients are aware that the user is anonymous. When setting this to true, hosts can choose to omit the UserId property, but must still set the OwnerId property. + IsAnonymousUser bool `json:"IsAnonymousUser,omitempty"` + // A Boolean value indicating whether the user is an education user or not. + IsEduUser bool `json:"IsEduUser,omitempty"` + // A Boolean value indicating whether the user is a business user or not. + LicenseCheckForEditIsEnabled bool `json:"LicenseCheckForEditIsEnabled,omitempty"` + + // ------------ + // Collabora WOPI check file info specification: + // https://sdk.collaboraonline.com/docs/advanced_integration.html + // ------------ + + // If set to true, it allows the document owner (the one with OwnerId =UserId) to send a closedocument message (see protocol.txt) + EnableOwnerTermination bool `json:"EnableOwnerTermination,omitempty"` + // Disables export functionality in backend. If set to true, HideExportOption is assumed to be true + DisableExport bool `json:"DisableExport,omitempty"` + // Disables copying from the document in libreoffice online backend. Pasting into the document would still be possible. However, it is still possible to do an “internal” cut/copy/paste. + DisableCopy bool `json:"DisableCopy,omitempty"` +} diff --git a/services/collaboration/pkg/internal/app/headers.go b/services/collaboration/pkg/internal/app/headers.go new file mode 100644 index 0000000000..9b73836b32 --- /dev/null +++ b/services/collaboration/pkg/internal/app/headers.go @@ -0,0 +1,5 @@ +package app + +const ( + HeaderWopiLock string = "X-WOPI-Lock" +) diff --git a/services/collaboration/pkg/internal/app/wopicontext.go b/services/collaboration/pkg/internal/app/wopicontext.go new file mode 100644 index 0000000000..1bd2a317eb --- /dev/null +++ b/services/collaboration/pkg/internal/app/wopicontext.go @@ -0,0 +1,86 @@ +package app + +import ( + "context" + "errors" + "fmt" + "net/http" + + appproviderv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1" + userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + ctxpkg "github.com/cs3org/reva/v2/pkg/ctx" + "github.com/golang-jwt/jwt/v4" + "google.golang.org/grpc/metadata" +) + +type key int + +const ( + wopiContextKey key = iota +) + +type WopiContext struct { + AccessToken string + FileReference providerv1beta1.Reference + User *userv1beta1.User + ViewMode appproviderv1beta1.ViewMode + EditAppUrl string + ViewAppUrl string +} + +func WopiContextAuthMiddleware(app *DemoApp, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + accessToken := r.URL.Query().Get("access_token") + if accessToken == "" { + fmt.Println("wopicontext", "accesstoken empty") + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + + claims := &Claims{} + _, err := jwt.ParseWithClaims(accessToken, claims, func(token *jwt.Token) (interface{}, error) { + + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + + return []byte(app.Config.Secret), nil + }) + + if err != nil { + fmt.Println("wopicontext", err) + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + + if err := claims.Valid(); err != nil { + fmt.Println("wopicontext", err) + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + + ctx := r.Context() + + wopiContextAccessToken, err := DecryptAES([]byte(app.Config.Secret), claims.WopiContext.AccessToken) + if err != nil { + fmt.Println("wopicontext", err) + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + claims.WopiContext.AccessToken = wopiContextAccessToken + + ctx = context.WithValue(ctx, wopiContextKey, claims.WopiContext) + // authentication for the CS3 api + ctx = metadata.AppendToOutgoingContext(ctx, ctxpkg.TokenHeader, claims.WopiContext.AccessToken) + + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func WopiContextFromCtx(ctx context.Context) (WopiContext, error) { + if wopiContext, ok := ctx.Value(wopiContextKey).(WopiContext); ok { + return wopiContext, nil + } + return WopiContext{}, errors.New("no wopi context found") +} diff --git a/services/collaboration/pkg/internal/app/wopidiscovery.go b/services/collaboration/pkg/internal/app/wopidiscovery.go new file mode 100644 index 0000000000..29a2e6cd7a --- /dev/null +++ b/services/collaboration/pkg/internal/app/wopidiscovery.go @@ -0,0 +1,123 @@ +package app + +import ( + "context" + "crypto/tls" + "io" + "net/http" + "net/url" + "strings" + + "github.com/beevik/etree" + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/pkg/errors" +) + +func (app *DemoApp) WopiDiscovery(ctx context.Context) error { + res, err := getAppURLs(app.Config.WopiApp.Addr, app.Config.WopiApp.Insecure, app.Logger) + if err != nil { + // logging is already covered inside the `getAppURLs` function + return err + } + + app.AppURLs = res + return nil +} + +func getAppURLs(wopiAppUrl string, insecure bool, logger log.Logger) (map[string]map[string]string, error) { + + wopiAppUrl = wopiAppUrl + "/hosting/discovery" + + httpClient := http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: insecure, + }, + }, + } + + httpResp, err := httpClient.Get(wopiAppUrl) + if err != nil { + logger.Error(). + Err(err). + Str("WopiAppUrl", wopiAppUrl). + Msg("WopiDiscovery: failed to access wopi app url") + return nil, err + } + + if httpResp.StatusCode != http.StatusOK { + logger.Error(). + Str("WopiAppUrl", wopiAppUrl). + Int("HttpCode", httpResp.StatusCode). + Msg("WopiDiscovery: wopi app url failed with unexpected code") + return nil, errors.New("status code was not 200") + } + + defer httpResp.Body.Close() + + var appURLs map[string]map[string]string + + appURLs, err = parseWopiDiscovery(httpResp.Body) + if err != nil { + logger.Error(). + Err(err). + Str("WopiAppUrl", wopiAppUrl). + Msg("WopiDiscovery: failed to parse wopi discovery response") + return nil, errors.Wrap(err, "error parsing wopi discovery response") + } + + // TODO: Log appUrls? not easy with the format + // It's also a one-time call during service setup, so it's pointless + // to use an "all-is-good" debug log + return appURLs, nil +} + +func parseWopiDiscovery(body io.Reader) (map[string]map[string]string, error) { + appURLs := make(map[string]map[string]string) + + doc := etree.NewDocument() + if _, err := doc.ReadFrom(body); err != nil { + return nil, err + } + root := doc.SelectElement("wopi-discovery") + + for _, netzone := range root.SelectElements("net-zone") { + + if strings.Contains(netzone.SelectAttrValue("name", ""), "external") { + for _, app := range netzone.SelectElements("app") { + for _, action := range app.SelectElements("action") { + access := action.SelectAttrValue("name", "") + if access == "view" || access == "edit" { + ext := action.SelectAttrValue("ext", "") + urlString := action.SelectAttrValue("urlsrc", "") + + if ext == "" || urlString == "" { + continue + } + + u, err := url.Parse(urlString) + if err != nil { + continue + } + + // remove any malformed query parameter from discovery urls + q := u.Query() + for k := range q { + if strings.Contains(k, "<") || strings.Contains(k, ">") { + q.Del(k) + } + } + + u.RawQuery = q.Encode() + + if _, ok := appURLs[access]; !ok { + appURLs[access] = make(map[string]string) + } + appURLs[access]["."+ext] = u.String() + } + } + } + } + } + return appURLs, nil +} diff --git a/services/collaboration/pkg/internal/app/wopifilecontents.go b/services/collaboration/pkg/internal/app/wopifilecontents.go new file mode 100644 index 0000000000..aa6e1717d2 --- /dev/null +++ b/services/collaboration/pkg/internal/app/wopifilecontents.go @@ -0,0 +1,97 @@ +package app + +import ( + "io" + "net/http" + + "github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/helpers" +) + +// GetFile downloads the file from the storage +// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/getfile +func GetFile(app *DemoApp, w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + wopiContext, _ := WopiContextFromCtx(ctx) + + // download the file + resp, err := helpers.DownloadFile( + ctx, + &wopiContext.FileReference, + app.gwc, + wopiContext.AccessToken, + app.Config.CS3Api.DataGateway.Insecure, + app.Logger, + ) + + if err != nil || resp.StatusCode != http.StatusOK { + app.Logger.Error(). + Err(err). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Int("HttpCode", resp.StatusCode). + Msg("GetFile: downloading the file failed") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + // read the file from the body + defer resp.Body.Close() + _, err = io.Copy(w, resp.Body) + if err != nil { + app.Logger.Error(). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Msg("GetFile: copying the file content to the response body failed") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + app.Logger.Debug(). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Msg("GetFile: success") + http.Error(w, "", http.StatusOK) +} + +// PutFile uploads the file to the storage +// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/putfile +func PutFile(app *DemoApp, w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + wopiContext, _ := WopiContextFromCtx(ctx) + + // read the file from the body + defer r.Body.Close() + + // upload the file + err := helpers.UploadFile( + ctx, + r.Body, + &wopiContext.FileReference, + app.gwc, + wopiContext.AccessToken, + r.Header.Get(HeaderWopiLock), + app.Config.CS3Api.DataGateway.Insecure, + app.Logger, + ) + + if err != nil { + app.Logger.Error(). + Err(err). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Msg("PutFile: uploading the file failed") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + app.Logger.Debug(). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Msg("PutFile: success") + http.Error(w, "", http.StatusOK) +} diff --git a/services/collaboration/pkg/internal/app/wopiinfo.go b/services/collaboration/pkg/internal/app/wopiinfo.go new file mode 100644 index 0000000000..f06015bc52 --- /dev/null +++ b/services/collaboration/pkg/internal/app/wopiinfo.go @@ -0,0 +1,136 @@ +package app + +import ( + "encoding/json" + "net/http" + "path" + + appproviderv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1" + userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/google/uuid" +) + +func WopiInfoHandler(app *DemoApp, w http.ResponseWriter, r *http.Request) { + // Logs for this endpoint will be covered by the access log. We can't extract + // more info + http.Error(w, http.StatusText(http.StatusTeapot), http.StatusTeapot) +} + +// CheckFileInfo returns information about the requested file and capabilities of the wopi server +// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo +func CheckFileInfo(app *DemoApp, w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + wopiContext, _ := WopiContextFromCtx(ctx) + + statRes, err := app.gwc.Stat(ctx, &providerv1beta1.StatRequest{ + Ref: &wopiContext.FileReference, + }) + if err != nil { + app.Logger.Error(). + Err(err). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Msg("CheckFileInfo: stat failed") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + if statRes.Status.Code != rpcv1beta1.Code_CODE_OK { + app.Logger.Error(). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("StatusCode", statRes.Status.Code.String()). + Str("StatusMsg", statRes.Status.Message). + Msg("CheckFileInfo: stat failed with unexpected status") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + fileInfo := FileInfo{ + OwnerID: statRes.Info.Owner.OpaqueId + "@" + statRes.Info.Owner.Idp, + Size: int64(statRes.Info.Size), + Version: statRes.Info.Mtime.String(), + BaseFileName: path.Base(statRes.Info.Path), + BreadcrumbDocName: path.Base(statRes.Info.Path), + // to get the folder we actually need to do a GetPath() request + //BreadcrumbFolderName: path.Dir(statRes.Info.Path), + + UserCanNotWriteRelative: true, + + HostViewUrl: wopiContext.ViewAppUrl, + HostEditUrl: wopiContext.EditAppUrl, + + EnableOwnerTermination: true, + + SupportsExtendedLockLength: true, + + SupportsGetLock: true, + SupportsLocks: true, + } + + switch wopiContext.ViewMode { + case appproviderv1beta1.ViewMode_VIEW_MODE_READ_WRITE: + fileInfo.SupportsUpdate = true + fileInfo.UserCanWrite = true + + case appproviderv1beta1.ViewMode_VIEW_MODE_READ_ONLY: + // nothing special to do here for now + + case appproviderv1beta1.ViewMode_VIEW_MODE_VIEW_ONLY: + fileInfo.DisableExport = true + fileInfo.DisableCopy = true + fileInfo.DisablePrint = true + } + + // user logic from reva wopi driver #TODO: refactor + var isPublicShare bool = false + if wopiContext.User != nil { + if wopiContext.User.Id.Type == userv1beta1.UserType_USER_TYPE_LIGHTWEIGHT { + fileInfo.UserID = statRes.Info.Owner.OpaqueId + "@" + statRes.Info.Owner.Idp + } else { + fileInfo.UserID = wopiContext.User.Id.OpaqueId + "@" + wopiContext.User.Id.Idp + } + + if wopiContext.User.Opaque != nil { + if _, ok := wopiContext.User.Opaque.Map["public-share-role"]; ok { + isPublicShare = true + } + } + if !isPublicShare { + fileInfo.UserFriendlyName = wopiContext.User.Username + fileInfo.UserID = wopiContext.User.Id.OpaqueId + "@" + wopiContext.User.Id.Idp + } + } + if wopiContext.User == nil || isPublicShare { + randomID, _ := uuid.NewUUID() + fileInfo.UserID = "guest-" + randomID.String() + fileInfo.UserFriendlyName = "Guest " + randomID.String() + fileInfo.IsAnonymousUser = true + } + + jsonFileInfo, err := json.Marshal(fileInfo) + if err != nil { + app.Logger.Error(). + Err(err). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Msg("CheckFileInfo: failed to marshal fileinfo") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + app.Logger.Debug(). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Msg("CheckFileInfo: success") + + w.Header().Set("Content-Type", "application/json") + w.Write(jsonFileInfo) + w.WriteHeader(http.StatusOK) +} diff --git a/services/collaboration/pkg/internal/app/wopilocking.go b/services/collaboration/pkg/internal/app/wopilocking.go new file mode 100644 index 0000000000..fd52980efa --- /dev/null +++ b/services/collaboration/pkg/internal/app/wopilocking.go @@ -0,0 +1,280 @@ +package app + +import ( + "net/http" + "time" + + rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" +) + +const ( + // WOPI Locks generally have a lock duration of 30 minutes and will be refreshed before expiration if needed + // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/concepts#lock + lockDuration time.Duration = 30 * time.Minute +) + +// GetLock returns a lock or an empty string if no lock exists +// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/getlock +func GetLock(app *DemoApp, w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + wopiContext, _ := WopiContextFromCtx(ctx) + + req := &providerv1beta1.GetLockRequest{ + Ref: &wopiContext.FileReference, + } + + resp, err := app.gwc.GetLock(ctx, req) + if err != nil { + app.Logger.Error(). + Err(err). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Msg("GetLock failed") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + app.Logger.Error(). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("GetLock failed with unexpected status") + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + + lockID := "" + if resp.Lock != nil { + lockID = resp.Lock.LockId + } + + // log the success at debug level + app.Logger.Debug(). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("StatusCode", resp.Status.Code.String()). + Str("LockID", lockID). + Msg("GetLock success") + + w.Header().Set(HeaderWopiLock, lockID) + http.Error(w, http.StatusText(http.StatusOK), http.StatusOK) +} + +// Lock returns a WOPI lock or performs an unlock and relock +// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/lock +// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/unlockandrelock +func Lock(app *DemoApp, w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + wopiContext, _ := WopiContextFromCtx(ctx) + + // TODO: handle un- and relock + + lockID := r.Header.Get(HeaderWopiLock) + if lockID == "" { + app.Logger.Error(). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Msg("Lock failed due to empty lockID") + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + req := &providerv1beta1.SetLockRequest{ + Ref: &wopiContext.FileReference, + Lock: &providerv1beta1.Lock{ + LockId: lockID, + AppName: app.Config.App.LockName, + Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, + Expiration: &typesv1beta1.Timestamp{ + Seconds: uint64(time.Now().Add(lockDuration).Unix()), + }, + }, + } + + app.Logger.Debug(). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("RequestedLockID", lockID). + Msg("Performing SetLock") + + resp, err := app.gwc.SetLock(ctx, req) + if err != nil { + app.Logger.Error(). + Err(err). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("RequestedLockID", lockID). + Msg("SetLock failed") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + switch resp.Status.Code { + case rpcv1beta1.Code_CODE_OK: + app.Logger.Debug(). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("RequestedLockID", lockID). + Msg("SetLock successful") + http.Error(w, http.StatusText(http.StatusOK), http.StatusOK) + return + + case rpcv1beta1.Code_CODE_FAILED_PRECONDITION: + // already locked + req := &providerv1beta1.GetLockRequest{ + Ref: &wopiContext.FileReference, + } + + resp, err := app.gwc.GetLock(ctx, req) + if err != nil { + app.Logger.Error(). + Err(err). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("RequestedLockID", lockID). + Msg("SetLock failed, fallback to GetLock failed too") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + app.Logger.Error(). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("RequestedLockID", lockID). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("SetLock failed, fallback to GetLock failed with unexpected status") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + if resp.Lock != nil { + if resp.Lock.LockId != lockID { + app.Logger.Warn(). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("RequestedLockID", lockID). + Str("LockID", resp.Lock.LockId). + Msg("SetLock conflict") + w.Header().Set(HeaderWopiLock, resp.Lock.LockId) + http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) + return + } + + // TODO: according to the spec we need to treat this as a RefreshLock + + app.Logger.Warn(). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("RequestedLockID", lockID). + Str("LockID", resp.Lock.LockId). + Msg("SetLock lock refreshed instead") + http.Error(w, http.StatusText(http.StatusOK), http.StatusOK) + return + } + + app.Logger.Error(). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("RequestedLockID", lockID). + Msg("SetLock failed and could not refresh") + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + + default: + app.Logger.Error(). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("RequestedLockID", lockID). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("SetLock failed with unexpected status") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + +} + +// RefreshLock refreshes a provided lock for 30 minutes +// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/refreshlock +func RefreshLock(app *DemoApp, w http.ResponseWriter, r *http.Request) { + // TODO: implement + http.Error(w, http.StatusText(http.StatusNotImplemented), http.StatusNotImplemented) +} + +// UnLock removes a given lock from a file +// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/unlock +func UnLock(app *DemoApp, w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + wopiContext, _ := WopiContextFromCtx(ctx) + + lockID := r.Header.Get(HeaderWopiLock) + if lockID == "" { + app.Logger.Error(). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Msg("Unlock failed due to empty lockID") + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + req := &providerv1beta1.UnlockRequest{ + Ref: &wopiContext.FileReference, + Lock: &providerv1beta1.Lock{ + LockId: lockID, + AppName: app.Config.App.LockName, + }, + } + + resp, err := app.gwc.Unlock(ctx, req) + if err != nil { + app.Logger.Error(). + Err(err). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("RequestedLockID", lockID). + Msg("Unlock failed") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + app.Logger.Error(). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("RequestedLockID", lockID). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("Unlock failed with unexpected status") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + app.Logger.Debug(). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("RequestedLockID", lockID). + Msg("Unlock successful") + http.Error(w, http.StatusText(http.StatusOK), http.StatusOK) +} diff --git a/services/collaboration/pkg/internal/helpers/download.go b/services/collaboration/pkg/internal/helpers/download.go new file mode 100644 index 0000000000..981e46beec --- /dev/null +++ b/services/collaboration/pkg/internal/helpers/download.go @@ -0,0 +1,106 @@ +package helpers + +import ( + "bytes" + "context" + "crypto/tls" + "errors" + "net/http" + + "github.com/owncloud/ocis/v2/ocis-pkg/log" + + gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" +) + +func DownloadFile( + ctx context.Context, + ref *providerv1beta1.Reference, + gwc gatewayv1beta1.GatewayAPIClient, + token string, + insecure bool, + logger log.Logger, +) (http.Response, error) { + + req := &providerv1beta1.InitiateFileDownloadRequest{ + Ref: ref, + } + + resp, err := gwc.InitiateFileDownload(ctx, req) + if err != nil { + logger.Error(). + Err(err). + Str("FileReference", ref.String()). + Msg("DownloadHelper: InitiateFileDownload failed") + return http.Response{}, err + } + + if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("FileReference", ref.String()). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("DownloadHelper: InitiateFileDownload failed with wrong status") + return http.Response{}, errors.New("InitiateFileDownload failed with status " + resp.Status.Code.String()) + } + + downloadEndpoint := "" + downloadToken := "" + hasDownloadToken := false + + for _, proto := range resp.Protocols { + if proto.Protocol == "simple" || proto.Protocol == "spaces" { + downloadEndpoint = proto.DownloadEndpoint + downloadToken = proto.Token + hasDownloadToken = proto.Token != "" + } + } + + if downloadEndpoint == "" { + logger.Error(). + Str("FileReference", ref.String()). + Str("Endpoint", downloadEndpoint). + Bool("HasDownloadToken", hasDownloadToken). + Msg("DownloadHelper: Download endpoint or token is missing") + return http.Response{}, errors.New("download endpoint is missing") + } + + httpClient := http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: insecure, + }, + }, + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadEndpoint, bytes.NewReader([]byte(""))) + if err != nil { + logger.Error(). + Err(err). + Str("FileReference", ref.String()). + Str("Endpoint", downloadEndpoint). + Bool("HasDownloadToken", hasDownloadToken). + Msg("DownloadHelper: Could not create the request to the endpoint") + return http.Response{}, err + } + if downloadToken != "" { + // public link downloads have the token in the download endpoint + httpReq.Header.Add("X-Reva-Transfer", downloadToken) + } + // TODO: the access token shouldn't be needed + httpReq.Header.Add("x-access-token", token) + + httpResp, err := httpClient.Do(httpReq) + if err != nil { + logger.Error(). + Err(err). + Str("FileReference", ref.String()). + Str("Endpoint", downloadEndpoint). + Bool("HasDownloadToken", hasDownloadToken). + Msg("DownloadHelper: Get request to the download endpoint failed") + return http.Response{}, err + } + + return *httpResp, nil +} diff --git a/services/collaboration/pkg/internal/helpers/upload.go b/services/collaboration/pkg/internal/helpers/upload.go new file mode 100644 index 0000000000..ed01d21af8 --- /dev/null +++ b/services/collaboration/pkg/internal/helpers/upload.go @@ -0,0 +1,127 @@ +package helpers + +import ( + "context" + "crypto/tls" + "errors" + "io" + "net/http" + "strconv" + + gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/owncloud/ocis/v2/ocis-pkg/log" +) + +func UploadFile(ctx context.Context, content io.ReadCloser, ref *providerv1beta1.Reference, gwc gatewayv1beta1.GatewayAPIClient, token string, lockID string, insecure bool, logger log.Logger) error { + + req := &providerv1beta1.InitiateFileUploadRequest{ + Ref: ref, + LockId: lockID, + // TODO: if-match + //Options: &providerv1beta1.InitiateFileUploadRequest_IfMatch{ + // IfMatch: "", + //}, + } + + resp, err := gwc.InitiateFileUpload(ctx, req) + if err != nil { + logger.Error(). + Err(err). + Str("FileReference", ref.String()). + Str("RequestedLockID", lockID). + Msg("UploadHelper: InitiateFileUpload failed") + return err + } + + if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("FileReference", ref.String()). + Str("RequestedLockID", lockID). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("UploadHelper: InitiateFileUpload failed with wrong status") + return errors.New("InitiateFileUpload failed with status " + resp.Status.Code.String()) + } + + uploadEndpoint := "" + uploadToken := "" + hasUploadToken := false + + for _, proto := range resp.Protocols { + if proto.Protocol == "simple" || proto.Protocol == "spaces" { + uploadEndpoint = proto.UploadEndpoint + uploadToken = proto.Token + hasUploadToken = proto.Token != "" + break + } + } + + if uploadEndpoint == "" { + logger.Error(). + Str("FileReference", ref.String()). + Str("RequestedLockID", lockID). + Str("Endpoint", uploadEndpoint). + Bool("HasUploadToken", hasUploadToken). + Msg("UploadHelper: Upload endpoint or token is missing") + return errors.New("upload endpoint or token is missing") + } + + httpClient := http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: insecure, + }, + }, + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadEndpoint, content) + if err != nil { + logger.Error(). + Err(err). + Str("FileReference", ref.String()). + Str("RequestedLockID", lockID). + Str("Endpoint", uploadEndpoint). + Bool("HasUploadToken", hasUploadToken). + Msg("UploadHelper: Could not create the request to the endpoint") + return err + } + + if uploadToken != "" { + // public link uploads have the token in the upload endpoint + httpReq.Header.Add("X-Reva-Transfer", uploadToken) + } + // TODO: the access token shouldn't be needed + httpReq.Header.Add("x-access-token", token) + + // TODO: better mechanism for the upload while locked, relies on patch in REVA + //if lockID, ok := ctxpkg.ContextGetLockID(ctx); ok { + // httpReq.Header.Add("X-Lock-Id", lockID) + //} + + httpResp, err := httpClient.Do(httpReq) + if err != nil { + logger.Error(). + Err(err). + Str("FileReference", ref.String()). + Str("RequestedLockID", lockID). + Str("Endpoint", uploadEndpoint). + Bool("HasUploadToken", hasUploadToken). + Msg("UploadHelper: Put request to the upload endpoint failed") + return err + } + + if httpResp.StatusCode != http.StatusOK { + logger.Error(). + Str("FileReference", ref.String()). + Str("RequestedLockID", lockID). + Str("Endpoint", uploadEndpoint). + Bool("HasUploadToken", hasUploadToken). + Int("HttpCode", httpResp.StatusCode). + Msg("UploadHelper: Put request to the upload endpoint failed with unexpected status") + return errors.New("Put request failed with status " + strconv.Itoa(httpResp.StatusCode)) + } + + return nil +} diff --git a/services/collaboration/pkg/internal/logging/logging.go b/services/collaboration/pkg/internal/logging/logging.go new file mode 100644 index 0000000000..91d896e349 --- /dev/null +++ b/services/collaboration/pkg/internal/logging/logging.go @@ -0,0 +1,17 @@ +package logging + +import ( + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" +) + +// LoggerFromConfig initializes a service-specific logger instance. +func Configure(name string, cfg *config.Log) log.Logger { + return log.NewLogger( + log.Name(name), + log.Level(cfg.Level), + log.Pretty(cfg.Pretty), + log.Color(cfg.Color), + log.File(cfg.File), + ) +} diff --git a/services/collaboration/pkg/internal/middleware/accesslog.go b/services/collaboration/pkg/internal/middleware/accesslog.go new file mode 100644 index 0000000000..43d96e41bf --- /dev/null +++ b/services/collaboration/pkg/internal/middleware/accesslog.go @@ -0,0 +1,32 @@ +package middleware + +import ( + "net/http" + "time" + + "github.com/go-chi/chi/v5/middleware" + chimiddleware "github.com/go-chi/chi/v5/middleware" + "github.com/owncloud/ocis/v2/ocis-pkg/log" +) + +// AccessLog is a middleware to log http requests at info level logging. +func AccessLog(logger log.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + wrap := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + next.ServeHTTP(wrap, r) + + logger.Info(). + Str("proto", r.Proto). + Str("request", chimiddleware.GetReqID(r.Context())). + Str("remote-addr", r.RemoteAddr). + Str("method", r.Method). + Int("status", wrap.Status()). + Str("path", r.URL.Path). + Dur("duration", time.Since(start)). + Int("bytes", wrap.BytesWritten()). + Msg("access-log") + }) + } +} diff --git a/services/collaboration/pkg/server/grpc/option.go b/services/collaboration/pkg/server/grpc/option.go new file mode 100644 index 0000000000..281caa8bc5 --- /dev/null +++ b/services/collaboration/pkg/server/grpc/option.go @@ -0,0 +1,76 @@ +package grpc + +import ( + "context" + + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/app" + "go.opentelemetry.io/otel/trace" +) + +// Option defines a single option function. +type Option func(o *Options) + +// Options defines the available options for this package. +type Options struct { + App *app.DemoApp + Name string + Logger log.Logger + Context context.Context + Config *config.Config + TraceProvider trace.TracerProvider +} + +// newOptions initializes the available default options. +func newOptions(opts ...Option) Options { + opt := Options{} + + for _, o := range opts { + o(&opt) + } + + return opt +} + +// App provides a function to set the logger option. +func App(val *app.DemoApp) Option { + return func(o *Options) { + o.App = val + } +} + +// Name provides a name for the service. +func Name(val string) Option { + return func(o *Options) { + o.Name = val + } +} + +// Logger provides a function to set the logger option. +func Logger(val log.Logger) Option { + return func(o *Options) { + o.Logger = val + } +} + +// Context provides a function to set the context option. +func Context(val context.Context) Option { + return func(o *Options) { + o.Context = val + } +} + +// Config provides a function to set the config option. +func Config(val *config.Config) Option { + return func(o *Options) { + o.Config = val + } +} + +// TraceProvider provides a function to set the trace provider option. +func TraceProvider(val trace.TracerProvider) Option { + return func(o *Options) { + o.TraceProvider = val + } +} diff --git a/services/collaboration/pkg/server/grpc/server.go b/services/collaboration/pkg/server/grpc/server.go new file mode 100644 index 0000000000..7b5a3f7e04 --- /dev/null +++ b/services/collaboration/pkg/server/grpc/server.go @@ -0,0 +1,32 @@ +package grpc + +import ( + appproviderv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1" + svc "github.com/owncloud/ocis/v2/services/collaboration/pkg/service/grpc/v0" + "google.golang.org/grpc" +) + +// Server initializes a new grpc service ready to run +// THIS SERVICE IS REGISTERED AGAINST REVA, NOT GO-MICRO +func Server(opts ...Option) (*grpc.Server, func(), error) { + grpcOpts := []grpc.ServerOption{} + options := newOptions(opts...) + grpcServer := grpc.NewServer(grpcOpts...) + + handle, teardown, err := svc.NewHandler( + svc.Config(options.Config), + svc.Logger(options.Logger), + svc.AppURLs(options.App.AppURLs), + ) + if err != nil { + options.Logger.Error(). + Err(err). + Msg("Error initializing collaboration service") + return grpcServer, teardown, err + } + + // register the app provider interface / OpenInApp call + appproviderv1beta1.RegisterProviderAPIServer(grpcServer, handle) + + return grpcServer, teardown, nil +} diff --git a/services/collaboration/pkg/server/http/option.go b/services/collaboration/pkg/server/http/option.go new file mode 100644 index 0000000000..acffb503bb --- /dev/null +++ b/services/collaboration/pkg/server/http/option.go @@ -0,0 +1,68 @@ +package http + +import ( + "context" + + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/app" + "go.opentelemetry.io/otel/trace" +) + +// Option defines a single option function. +type Option func(o *Options) + +// Options defines the available options for this package. +type Options struct { + App *app.DemoApp + Logger log.Logger + Context context.Context + Config *config.Config + TracerProvider trace.TracerProvider +} + +// newOptions initializes the available default options. +func newOptions(opts ...Option) Options { + opt := Options{} + + for _, o := range opts { + o(&opt) + } + + return opt +} + +// App provides a function to set the logger option. +func App(val *app.DemoApp) Option { + return func(o *Options) { + o.App = val + } +} + +// Logger provides a function to set the logger option. +func Logger(val log.Logger) Option { + return func(o *Options) { + o.Logger = val + } +} + +// Context provides a function to set the context option. +func Context(val context.Context) Option { + return func(o *Options) { + o.Context = val + } +} + +// Config provides a function to set the config option. +func Config(val *config.Config) Option { + return func(o *Options) { + o.Config = val + } +} + +// TracerProvider provides a function to set the TracerProvider option +func TracerProvider(val trace.TracerProvider) Option { + return func(o *Options) { + o.TracerProvider = val + } +} diff --git a/services/collaboration/pkg/server/http/server.go b/services/collaboration/pkg/server/http/server.go new file mode 100644 index 0000000000..5b392d06d7 --- /dev/null +++ b/services/collaboration/pkg/server/http/server.go @@ -0,0 +1,157 @@ +package http + +import ( + "fmt" + + stdhttp "net/http" + + "github.com/go-chi/chi/v5" + chimiddleware "github.com/go-chi/chi/v5/middleware" + "github.com/owncloud/ocis/v2/ocis-pkg/account" + "github.com/owncloud/ocis/v2/ocis-pkg/middleware" + "github.com/owncloud/ocis/v2/ocis-pkg/service/http" + "github.com/owncloud/ocis/v2/ocis-pkg/tracing" + "github.com/owncloud/ocis/v2/ocis-pkg/version" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/app" + "github.com/riandyrn/otelchi" + "go-micro.dev/v4" +) + +// Server initializes the http service and server. +func Server(opts ...Option) (http.Service, error) { + options := newOptions(opts...) + + service, err := http.NewService( + http.TLSConfig(options.Config.HTTP.TLS), + http.Logger(options.Logger), + http.Namespace(options.Config.HTTP.Namespace), + http.Name(options.Config.Service.Name), + http.Version(version.GetString()), + http.Address(options.Config.HTTP.BindAddr), + http.Context(options.Context), + ) + if err != nil { + options.Logger.Error(). + Err(err). + Msg("Error initializing http service") + return http.Service{}, fmt.Errorf("could not initialize http service: %w", err) + } + + middlewares := []func(stdhttp.Handler) stdhttp.Handler{ + chimiddleware.RequestID, + middleware.Version( + "userlog", + version.GetString(), + ), + middleware.Logger( + options.Logger, + ), + middleware.ExtractAccountUUID( + account.Logger(options.Logger), + account.JWTSecret(options.Config.Secret), // previously, secret came from Config.TokenManager.JWTSecret + ), + /* + // Need CORS? not in the original server + // Also, CORS isn't part of the config right now + middleware.Cors( + cors.Logger(options.Logger), + cors.AllowedOrigins(options.Config.HTTP.CORS.AllowedOrigins), + cors.AllowedMethods(options.Config.HTTP.CORS.AllowedMethods), + cors.AllowedHeaders(options.Config.HTTP.CORS.AllowedHeaders), + cors.AllowCredentials(options.Config.HTTP.CORS.AllowCredentials), + ), + */ + middleware.Secure, + } + + mux := chi.NewMux() + mux.Use(middlewares...) + + mux.Use( + otelchi.Middleware( + "collaboration", + otelchi.WithChiRoutes(mux), + otelchi.WithTracerProvider(options.TracerProvider), + otelchi.WithPropagators(tracing.GetPropagator()), + ), + ) + + prepareRoutes(mux, options.App) + + if err := micro.RegisterHandler(service.Server(), mux); err != nil { + return http.Service{}, err + } + + return service, nil +} + +func prepareRoutes(r *chi.Mux, demoapp *app.DemoApp) { + r.Route("/wopi", func(r chi.Router) { + + r.Get("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + app.WopiInfoHandler(demoapp, w, r) + }) + + r.Route("/files/{fileid}", func(r chi.Router) { + + r.Use(func(h stdhttp.Handler) stdhttp.Handler { + // authentication and wopi context + return app.WopiContextAuthMiddleware(demoapp, h) + }, + ) + + r.Get("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + app.CheckFileInfo(demoapp, w, r) + }) + + r.Post("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + action := r.Header.Get("X-WOPI-Override") + switch action { + + case "LOCK": + app.Lock(demoapp, w, r) + case "GET_LOCK": + app.GetLock(demoapp, w, r) + case "REFRESH_LOCK": + app.RefreshLock(demoapp, w, r) + case "UNLOCK": + app.UnLock(demoapp, w, r) + + case "PUT_USER_INFO": + // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/putuserinfo + stdhttp.Error(w, stdhttp.StatusText(stdhttp.StatusNotImplemented), stdhttp.StatusNotImplemented) + case "PUT_RELATIVE": + // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/putrelativefile + stdhttp.Error(w, stdhttp.StatusText(stdhttp.StatusNotImplemented), stdhttp.StatusNotImplemented) + case "RENAME_FILE": + // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/renamefile + stdhttp.Error(w, stdhttp.StatusText(stdhttp.StatusNotImplemented), stdhttp.StatusNotImplemented) + case "DELETE": + // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/deletefile + stdhttp.Error(w, stdhttp.StatusText(stdhttp.StatusNotImplemented), stdhttp.StatusNotImplemented) + + default: + stdhttp.Error(w, stdhttp.StatusText(stdhttp.StatusInternalServerError), stdhttp.StatusInternalServerError) + } + }) + + r.Route("/contents", func(r chi.Router) { + r.Get("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + app.GetFile(demoapp, w, r) + }) + + r.Post("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + action := r.Header.Get("X-WOPI-Override") + switch action { + + case "PUT": + app.PutFile(demoapp, w, r) + + default: + stdhttp.Error(w, stdhttp.StatusText(stdhttp.StatusInternalServerError), stdhttp.StatusInternalServerError) + } + }) + }) + }) + }) +} diff --git a/services/collaboration/pkg/service/grpc/v0/option.go b/services/collaboration/pkg/service/grpc/v0/option.go new file mode 100644 index 0000000000..a107fd66e2 --- /dev/null +++ b/services/collaboration/pkg/service/grpc/v0/option.go @@ -0,0 +1,47 @@ +package service + +import ( + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" +) + +// Option defines a single option function. +type Option func(o *Options) + +// Options defines the available options for this package. +type Options struct { + Logger log.Logger + Config *config.Config + AppURLs map[string]map[string]string +} + +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 + } +} + +// Config provides a function to set the Config option. +func Config(val *config.Config) Option { + return func(o *Options) { + o.Config = val + } +} + +// ViewUrl provides a function to set the ViewUrl option. +func AppURLs(val map[string]map[string]string) Option { + return func(o *Options) { + o.AppURLs = val + } +} diff --git a/services/collaboration/pkg/service/grpc/v0/service.go b/services/collaboration/pkg/service/grpc/v0/service.go new file mode 100644 index 0000000000..22289103c0 --- /dev/null +++ b/services/collaboration/pkg/service/grpc/v0/service.go @@ -0,0 +1,227 @@ +package service + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "net/url" + "path" + "strconv" + + appproviderv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1" + gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool" + "github.com/golang-jwt/jwt/v4" + + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/app" +) + +func NewHandler(opts ...Option) (*Service, func(), error) { + teardown := func() {} + options := newOptions(opts...) + + gwc, err := pool.GetGatewayServiceClient(options.Config.CS3Api.Gateway.Name) + if err != nil { + return nil, teardown, err + } + + return &Service{ + id: options.Config.GRPC.Namespace + "." + options.Config.Service.Name, + appURLs: options.AppURLs, + logger: options.Logger, + config: options.Config, + gwc: gwc, + }, teardown, nil +} + +// Service implements the searchServiceHandler interface +type Service struct { + id string + appURLs map[string]map[string]string + logger log.Logger + config *config.Config + gwc gatewayv1beta1.GatewayAPIClient +} + +func (s *Service) OpenInApp( + ctx context.Context, + req *appproviderv1beta1.OpenInAppRequest, +) (*appproviderv1beta1.OpenInAppResponse, error) { + + // get the current user + var user *userv1beta1.User = nil + meReq := &gatewayv1beta1.WhoAmIRequest{ + Token: req.AccessToken, + } + meResp, err := s.gwc.WhoAmI(ctx, meReq) + if err == nil { + if meResp.Status.Code == rpcv1beta1.Code_CODE_OK { + user = meResp.User + } + } + + // required for the response, it will be used also for logs + providerFileRef := providerv1beta1.Reference{ + ResourceId: req.GetResourceInfo().GetId(), + Path: ".", + } + + // build a urlsafe and stable file reference that can be used for proxy routing, + // so that all sessions on one file end on the same office server + + c := sha256.New() + c.Write([]byte(req.ResourceInfo.Id.StorageId + "$" + req.ResourceInfo.Id.SpaceId + "!" + req.ResourceInfo.Id.OpaqueId)) + fileRef := hex.EncodeToString(c.Sum(nil)) + + // get the file extension to use the right wopi app url + fileExt := path.Ext(req.GetResourceInfo().Path) + + var viewAppURL string + var editAppURL string + if viewAppURLs, ok := s.appURLs["view"]; ok { + if url := viewAppURLs[fileExt]; ok { + viewAppURL = url + } + } + if editAppURLs, ok := s.appURLs["edit"]; ok { + if url, ok := editAppURLs[fileExt]; ok { + editAppURL = url + } + } + + if editAppURL == "" { + // assuming that an view action is always available in the /hosting/discovery manifest + // eg. Collabora does support viewing jpgs but no editing + // eg. OnlyOffice does support viewing pdfs but no editing + // there is no known case of supporting edit only without view + editAppURL = viewAppURL + } + + wopiSrcURL := url.URL{ + Scheme: s.config.HTTP.Scheme, + Host: s.config.HTTP.Addr, + Path: path.Join("wopi", "files", fileRef), + } + + addWopiSrcQueryParam := func(baseURL string) (string, error) { + u, err := url.Parse(baseURL) + if err != nil { + return "", err + } + + q := u.Query() + q.Add("WOPISrc", wopiSrcURL.String()) + qs := q.Encode() + u.RawQuery = qs + + return u.String(), nil + } + + viewAppURL, err = addWopiSrcQueryParam(viewAppURL) + if err != nil { + s.logger.Error(). + Err(err). + Str("FileReference", providerFileRef.String()). + Str("ViewMode", req.ViewMode.String()). + Str("Requester", user.GetId().String()). + Msg("OpenInApp: error parsing viewAppUrl") + return nil, err + } + editAppURL, err = addWopiSrcQueryParam(editAppURL) + if err != nil { + s.logger.Error(). + Err(err). + Str("FileReference", providerFileRef.String()). + Str("ViewMode", req.ViewMode.String()). + Str("Requester", user.GetId().String()). + Msg("OpenInApp: error parsing editAppUrl") + return nil, err + } + + appURL := viewAppURL + if req.ViewMode == appproviderv1beta1.ViewMode_VIEW_MODE_READ_WRITE { + appURL = editAppURL + } + + cryptedReqAccessToken, err := app.EncryptAES([]byte(s.config.Secret), req.AccessToken) + if err != nil { + s.logger.Error(). + Err(err). + Str("FileReference", providerFileRef.String()). + Str("ViewMode", req.ViewMode.String()). + Str("Requester", user.GetId().String()). + Msg("OpenInApp: error encrypting access token") + return &appproviderv1beta1.OpenInAppResponse{ + Status: &rpcv1beta1.Status{Code: rpcv1beta1.Code_CODE_INTERNAL}, + }, err + } + + wopiContext := app.WopiContext{ + AccessToken: cryptedReqAccessToken, + FileReference: providerFileRef, + User: user, + ViewMode: req.ViewMode, + EditAppUrl: editAppURL, + ViewAppUrl: viewAppURL, + } + + cs3Claims := &jwt.StandardClaims{} + cs3JWTparser := jwt.Parser{} + _, _, err = cs3JWTparser.ParseUnverified(req.AccessToken, cs3Claims) + if err != nil { + s.logger.Error(). + Err(err). + Str("FileReference", providerFileRef.String()). + Str("ViewMode", req.ViewMode.String()). + Str("Requester", user.GetId().String()). + Msg("OpenInApp: error parsing JWT token") + return nil, err + } + + claims := &app.Claims{ + WopiContext: wopiContext, + StandardClaims: jwt.StandardClaims{ + ExpiresAt: cs3Claims.ExpiresAt, + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + accessToken, err := token.SignedString([]byte(s.config.Secret)) + + if err != nil { + s.logger.Error(). + Err(err). + Str("FileReference", providerFileRef.String()). + Str("ViewMode", req.ViewMode.String()). + Str("Requester", user.GetId().String()). + Msg("OpenInApp: error signing access token") + return &appproviderv1beta1.OpenInAppResponse{ + Status: &rpcv1beta1.Status{Code: rpcv1beta1.Code_CODE_INTERNAL}, + }, err + } + + s.logger.Debug(). + Str("FileReference", providerFileRef.String()). + Str("ViewMode", req.ViewMode.String()). + Str("Requester", user.GetId().String()). + Msg("OpenInApp: success") + + return &appproviderv1beta1.OpenInAppResponse{ + Status: &rpcv1beta1.Status{Code: rpcv1beta1.Code_CODE_OK}, + AppUrl: &appproviderv1beta1.OpenInAppURL{ + AppUrl: appURL, + Method: "POST", + FormParameters: map[string]string{ + // these parameters will be passed to the web server by the app provider application + "access_token": accessToken, + // milliseconds since Jan 1, 1970 UTC as required in https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/concepts#access_token_ttl + "access_token_ttl": strconv.FormatInt(claims.ExpiresAt*1000, 10), + }, + }, + }, nil +} diff --git a/vendor/github.com/dchest/uniuri/COPYING b/vendor/github.com/dchest/uniuri/COPYING new file mode 100644 index 0000000000..0e259d42c9 --- /dev/null +++ b/vendor/github.com/dchest/uniuri/COPYING @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/vendor/github.com/dchest/uniuri/README.md b/vendor/github.com/dchest/uniuri/README.md new file mode 100644 index 0000000000..6240bc9bae --- /dev/null +++ b/vendor/github.com/dchest/uniuri/README.md @@ -0,0 +1,95 @@ +Package uniuri +===================== + +```go +import "github.com/dchest/uniuri" +``` + +Package uniuri generates random strings good for use in URIs to identify +unique objects. + +Example usage: + +```go +s := uniuri.New() // s is now "apHCJBl7L1OmC57n" +``` + +A standard string created by New() is 16 bytes in length and consists of +Latin upper and lowercase letters, and numbers (from the set of 62 allowed +characters), which means that it has ~95 bits of entropy. To get more +entropy, you can use NewLen(UUIDLen), which returns 20-byte string, giving +~119 bits of entropy, or any other desired length. + +Functions read from crypto/rand random source, and panic if they fail to +read from it. + + +Constants +--------- + +```go +const ( + // StdLen is a standard length of uniuri string to achive ~95 bits of entropy. + StdLen = 16 + // UUIDLen is a length of uniuri string to achive ~119 bits of entropy, closest + // to what can be losslessly converted to UUIDv4 (122 bits). + UUIDLen = 20 +) + +``` + + + +Variables +--------- + +```go +var StdChars = []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") +``` + + +StdChars is a set of standard characters allowed in uniuri string. + + +Functions +--------- + +### func New + +```go +func New() string +``` + +New returns a new random string of the standard length, consisting of +standard characters. + +### func NewLen + +```go +func NewLen(length int) string +``` + +NewLen returns a new random string of the provided length, consisting of +standard characters. + +### func NewLenChars + +```go +func NewLenChars(length int, chars []byte) string +``` + +NewLenChars returns a new random string of the provided length, consisting +of the provided byte slice of allowed characters (maximum 256). + + + +Public domain dedication +------------------------ + +Written in 2011-2014 by Dmitry Chestnykh + +The author(s) have dedicated all copyright and related and +neighboring rights to this software to the public domain +worldwide. Distributed without any warranty. +http://creativecommons.org/publicdomain/zero/1.0/ + diff --git a/vendor/github.com/dchest/uniuri/uniuri.go b/vendor/github.com/dchest/uniuri/uniuri.go new file mode 100644 index 0000000000..dd96592120 --- /dev/null +++ b/vendor/github.com/dchest/uniuri/uniuri.go @@ -0,0 +1,120 @@ +// Written in 2011-2014 by Dmitry Chestnykh +// +// The author(s) have dedicated all copyright and related and +// neighboring rights to this software to the public domain +// worldwide. Distributed without any warranty. +// http://creativecommons.org/publicdomain/zero/1.0/ + +// Package uniuri generates random strings good for use in URIs to identify +// unique objects. +// +// Example usage: +// +// s := uniuri.New() // s is now "apHCJBl7L1OmC57n" +// +// A standard string created by New() is 16 bytes in length and consists of +// Latin upper and lowercase letters, and numbers (from the set of 62 allowed +// characters), which means that it has ~95 bits of entropy. To get more +// entropy, you can use NewLen(UUIDLen), which returns 20-byte string, giving +// ~119 bits of entropy, or any other desired length. +// +// Functions read from crypto/rand random source, and panic if they fail to +// read from it. +package uniuri + +import ( + "crypto/rand" + "math" +) + +const ( + // StdLen is a standard length of uniuri string to achive ~95 bits of entropy. + StdLen = 16 + // UUIDLen is a length of uniuri string to achive ~119 bits of entropy, closest + // to what can be losslessly converted to UUIDv4 (122 bits). + UUIDLen = 20 +) + +// StdChars is a set of standard characters allowed in uniuri string. +var StdChars = []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") + +// New returns a new random string of the standard length, consisting of +// standard characters. +func New() string { + return NewLenChars(StdLen, StdChars) +} + +// NewLen returns a new random string of the provided length, consisting of +// standard characters. +func NewLen(length int) string { + return NewLenChars(length, StdChars) +} + +// maxBufLen is the maximum length of a temporary buffer for random bytes. +const maxBufLen = 2048 + +// minRegenBufLen is the minimum length of temporary buffer for random bytes +// to fill after the first rand.Read request didn't produce the full result. +// If the initial buffer is smaller, this value is ignored. +// Rationale: for performance, assume it's pointless to request fewer bytes from rand.Read. +const minRegenBufLen = 16 + +// estimatedBufLen returns the estimated number of random bytes to request +// given that byte values greater than maxByte will be rejected. +func estimatedBufLen(need, maxByte int) int { + return int(math.Ceil(float64(need) * (255 / float64(maxByte)))) +} + +// NewLenCharsBytes returns a new random byte slice of the provided length, consisting +// of the provided byte slice of allowed characters (maximum 256). +func NewLenCharsBytes(length int, chars []byte) []byte { + if length == 0 { + return nil + } + clen := len(chars) + if clen < 2 || clen > 256 { + panic("uniuri: wrong charset length for NewLenChars") + } + maxrb := 255 - (256 % clen) + buflen := estimatedBufLen(length, maxrb) + if buflen < length { + buflen = length + } + if buflen > maxBufLen { + buflen = maxBufLen + } + buf := make([]byte, buflen) // storage for random bytes + out := make([]byte, length) // storage for result + i := 0 + for { + if _, err := rand.Read(buf[:buflen]); err != nil { + panic("uniuri: error reading random bytes: " + err.Error()) + } + for _, rb := range buf[:buflen] { + c := int(rb) + if c > maxrb { + // Skip this number to avoid modulo bias. + continue + } + out[i] = chars[c%clen] + i++ + if i == length { + return out + } + } + // Adjust new requested length, but no smaller than minRegenBufLen. + buflen = estimatedBufLen(length-i, maxrb) + if buflen < minRegenBufLen && minRegenBufLen < cap(buf) { + buflen = minRegenBufLen + } + if buflen > maxBufLen { + buflen = maxBufLen + } + } +} + +// NewLenChars returns a new random string of the provided length, consisting +// of the provided byte slice of allowed characters (maximum 256). +func NewLenChars(length int, chars []byte) string { + return string(NewLenCharsBytes(length, chars)) +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 06e891fc77..235ef5a61a 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -737,6 +737,9 @@ github.com/cyphar/filepath-securejoin # github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc ## explicit github.com/davecgh/go-spew/spew +# github.com/dchest/uniuri v1.2.0 +## explicit; go 1.19 +github.com/dchest/uniuri # github.com/deckarep/golang-set v1.8.0 ## explicit; go 1.17 github.com/deckarep/golang-set From ecc235bbecc0b64c782e9fd05d25f148895fce6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Wed, 14 Feb 2024 15:04:05 +0100 Subject: [PATCH 02/29] chore: Add readme file and adjust configuration for better defaults --- docs/services/general-info/port-ranges.md | 2 +- services/collaboration/README.md | 22 +++++++++++++++++++ services/collaboration/pkg/config/config.go | 4 ++-- services/collaboration/pkg/config/cs3api.go | 4 ++-- .../pkg/config/defaults/defaultconfig.go | 13 ++++++----- services/collaboration/pkg/config/http.go | 2 +- services/collaboration/pkg/config/wopiapp.go | 2 +- .../pkg/internal/app/wopicontext.go | 4 ++-- .../collaboration/pkg/server/http/server.go | 2 +- .../pkg/service/grpc/v0/service.go | 4 ++-- 10 files changed, 41 insertions(+), 18 deletions(-) create mode 100644 services/collaboration/README.md diff --git a/docs/services/general-info/port-ranges.md b/docs/services/general-info/port-ranges.md index 317fa8b873..c51fadb680 100644 --- a/docs/services/general-info/port-ranges.md +++ b/docs/services/general-info/port-ranges.md @@ -71,7 +71,7 @@ We also suggest to use the last port in your extensions' range as a debug/metric | 9285-9289 | FREE | | 9290-9294 | FREE | | 9295-9299 | FREE | -| 9300-9304 | FREE | +| 9300-9304 | [collaboration]({{< ref "../collaboration/_index.md" >}}) | | 9305-9309 | FREE | | 9310-9314 | FREE | | 9315-9319 | FREE | diff --git a/services/collaboration/README.md b/services/collaboration/README.md new file mode 100644 index 0000000000..d7dce5a994 --- /dev/null +++ b/services/collaboration/README.md @@ -0,0 +1,22 @@ +# Collaboration + +The collaboration service connects ocis with document servers such as collabora and onlyoffice using the WOPI protocol. + +Since this service requires an external service (onlyoffice, for example), it won't run by default with the general `ocis server` command. You need to run it manually with the `ocis collaboration server` command. + +## Requirements + +The collaboration service requires the target document server (onlyoffice, collabora, etc) to be up and running. +We also need reva's gateway and app provider services to be running in order to register the GRPC service for the "open in app" action. + +If any of those services are down, the collaboration service won't start. + +## Configuration + +There are a few variables that you need to set: + +* `COLLABORATION_WOPIAPP_ADDR`: The URL of the WOPI app (onlyoffice, collabora, etc). For example: "https://office.mycloud.prv". +* `COLLABORATION_HTTP_ADDR`: The external address of the collaboration service. The target app (onlyoffice, collabora) will use this address to read and write files from ocis. For example: "wopiserver.mycloud.prv" +* `COLLABORATION_HTTP_SCHEME`: The scheme to be used when accessing the collaboration service. Either "http" or "https". This will be used to build the URL that the WOPI app needs in order to contact this service. + +The rest of the configuration options available can be left with the default values. diff --git a/services/collaboration/pkg/config/config.go b/services/collaboration/pkg/config/config.go index 117dd9fe14..f0afb149f4 100644 --- a/services/collaboration/pkg/config/config.go +++ b/services/collaboration/pkg/config/config.go @@ -14,7 +14,7 @@ type Config struct { Service Service `yaml:"-"` App App `yaml:"app"` - Secret string `yaml:"secret" env:"COLLABORATION_SECRET" desc:"Used as JWT token and to encrypt access token."` + JWTSecret string `yaml:"jwt_secret" env:"OCIS_JWT_SECRET;COLLABORATION_JWT_SECRET" desc:"Used as JWT token and to encrypt access token."` GRPC GRPC `yaml:"grpc"` HTTP HTTP `yaml:"http"` @@ -23,7 +23,7 @@ type Config struct { Tracing *Tracing `yaml:"tracing"` Log *Log `yaml:"log"` - Debug Debug `yaml:"debug"` + //Debug Debug `yaml:"debug"` Context context.Context `yaml:"-"` } diff --git a/services/collaboration/pkg/config/cs3api.go b/services/collaboration/pkg/config/cs3api.go index d810e8ca2e..3ea45b3c3e 100644 --- a/services/collaboration/pkg/config/cs3api.go +++ b/services/collaboration/pkg/config/cs3api.go @@ -7,9 +7,9 @@ type CS3Api struct { } type Gateway struct { - Name string `yaml: "name" env:"COLLABORATION_CS3API_GATEWAY_NAME" desc:"service name of the CS3API gateway"` + Name string `yaml: "name" env:"COLLABORATION_CS3API_GATEWAY_NAME" desc:"The service name of the CS3API gateway."` } type DataGateway struct { - Insecure bool `yaml:"insecure" env:"COLLABORATION_CS3API_DATAGATEWAY_INSECURE" desc:"connect to the CS3API data gateway insecurely"` + Insecure bool `yaml:"insecure" env:"COLLABORATION_CS3API_DATAGATEWAY_INSECURE" desc:"Connect to the CS3API data gateway insecurely."` } diff --git a/services/collaboration/pkg/config/defaults/defaultconfig.go b/services/collaboration/pkg/config/defaults/defaultconfig.go index 7025860fce..05cfe38933 100644 --- a/services/collaboration/pkg/config/defaults/defaultconfig.go +++ b/services/collaboration/pkg/config/defaults/defaultconfig.go @@ -25,26 +25,27 @@ func DefaultConfig() *config.Config { Icon: "image-edit", LockName: "com.github.owncloud.collaboration", }, - Secret: uniuri.NewLen(32), + JWTSecret: uniuri.NewLen(32), GRPC: config.GRPC{ - Addr: "127.0.0.1:56778", + Addr: "0.0.0.0:9301", Namespace: "com.owncloud.collaboration", }, HTTP: config.HTTP{ - Addr: "127.0.0.1:6789", + Addr: "127.0.0.1:9300", + BindAddr: "0.0.0.0:9300", Namespace: "com.owncloud.collaboration", - //Scheme: "http", + Scheme: "https", }, WopiApp: config.WopiApp{ Addr: "https://127.0.0.1:8080", - Insecure: true, // TODO: this should have a secure default + Insecure: false, }, CS3Api: config.CS3Api{ Gateway: config.Gateway{ Name: "com.owncloud.api.gateway", }, DataGateway: config.DataGateway{ - Insecure: true, // TODO: this should have a secure default + Insecure: false, }, }, } diff --git a/services/collaboration/pkg/config/http.go b/services/collaboration/pkg/config/http.go index e2b472a9c8..5b95cd017a 100644 --- a/services/collaboration/pkg/config/http.go +++ b/services/collaboration/pkg/config/http.go @@ -6,7 +6,7 @@ import ( // HTTP defines the available http configuration. type HTTP struct { - Addr string `yaml:"addr" env:"COLLABORATION_HTTP_ADDR" desc:"The address of the HTTP service."` + Addr string `yaml:"addr" env:"COLLABORATION_HTTP_ADDR" desc:"The external address of the HTTP service. Either IP address or host (127.0.0.1:9301 or wopi.private.prv). The configured "Scheme" will be used to build public URLs along with this address."` BindAddr string `yaml:"bindaddr" env:"COLLABORATION_HTTP_BINDADDR" desc:"The bind address of the HTTP service."` Namespace string `yaml:"-"` Scheme string `yaml:"scheme" env:"COLLABORATION_HTTP_SCHEME" desc:"Either http or https"` diff --git a/services/collaboration/pkg/config/wopiapp.go b/services/collaboration/pkg/config/wopiapp.go index fcf24ca6e1..33784ce958 100644 --- a/services/collaboration/pkg/config/wopiapp.go +++ b/services/collaboration/pkg/config/wopiapp.go @@ -3,5 +3,5 @@ package config // WopiApp defines the available configuration in order to connect to a WOPI app. type WopiApp struct { Addr string `yaml:"addr" env:"COLLABORATION_WOPIAPP_ADDR" desc:"The URL where the WOPI app is located, such as https://127.0.0.1:8080."` - Insecure bool `yaml:"insecure" env:"COLLABORATION_WOPIAPP_INSECURE" desc:"Connect insecurely"` + Insecure bool `yaml:"insecure" env:"COLLABORATION_WOPIAPP_INSECURE" desc:"Connect to the WOPI app insecurely."` } diff --git a/services/collaboration/pkg/internal/app/wopicontext.go b/services/collaboration/pkg/internal/app/wopicontext.go index 1bd2a317eb..b2f9074d1e 100644 --- a/services/collaboration/pkg/internal/app/wopicontext.go +++ b/services/collaboration/pkg/internal/app/wopicontext.go @@ -45,7 +45,7 @@ func WopiContextAuthMiddleware(app *DemoApp, next http.Handler) http.Handler { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } - return []byte(app.Config.Secret), nil + return []byte(app.Config.JWTSecret), nil }) if err != nil { @@ -62,7 +62,7 @@ func WopiContextAuthMiddleware(app *DemoApp, next http.Handler) http.Handler { ctx := r.Context() - wopiContextAccessToken, err := DecryptAES([]byte(app.Config.Secret), claims.WopiContext.AccessToken) + wopiContextAccessToken, err := DecryptAES([]byte(app.Config.JWTSecret), claims.WopiContext.AccessToken) if err != nil { fmt.Println("wopicontext", err) http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) diff --git a/services/collaboration/pkg/server/http/server.go b/services/collaboration/pkg/server/http/server.go index 5b392d06d7..d32db58859 100644 --- a/services/collaboration/pkg/server/http/server.go +++ b/services/collaboration/pkg/server/http/server.go @@ -48,7 +48,7 @@ func Server(opts ...Option) (http.Service, error) { ), middleware.ExtractAccountUUID( account.Logger(options.Logger), - account.JWTSecret(options.Config.Secret), // previously, secret came from Config.TokenManager.JWTSecret + account.JWTSecret(options.Config.JWTSecret), // previously, secret came from Config.TokenManager.JWTSecret ), /* // Need CORS? not in the original server diff --git a/services/collaboration/pkg/service/grpc/v0/service.go b/services/collaboration/pkg/service/grpc/v0/service.go index 22289103c0..5c1fe9ad23 100644 --- a/services/collaboration/pkg/service/grpc/v0/service.go +++ b/services/collaboration/pkg/service/grpc/v0/service.go @@ -148,7 +148,7 @@ func (s *Service) OpenInApp( appURL = editAppURL } - cryptedReqAccessToken, err := app.EncryptAES([]byte(s.config.Secret), req.AccessToken) + cryptedReqAccessToken, err := app.EncryptAES([]byte(s.config.JWTSecret), req.AccessToken) if err != nil { s.logger.Error(). Err(err). @@ -191,7 +191,7 @@ func (s *Service) OpenInApp( } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - accessToken, err := token.SignedString([]byte(s.config.Secret)) + accessToken, err := token.SignedString([]byte(s.config.JWTSecret)) if err != nil { s.logger.Error(). From eb77b4c276d0aa41081227dd6fb8e6e05f324c3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Wed, 21 Feb 2024 10:46:53 +0100 Subject: [PATCH 03/29] fix: use same logger everywhere and include log for registered mimetypes --- services/collaboration/pkg/command/server.go | 2 +- services/collaboration/pkg/cs3wopiserver/start.go | 5 +++-- services/collaboration/pkg/internal/app/app.go | 10 ++++++---- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/services/collaboration/pkg/command/server.go b/services/collaboration/pkg/command/server.go index 400fca879c..0a28002d57 100644 --- a/services/collaboration/pkg/command/server.go +++ b/services/collaboration/pkg/command/server.go @@ -42,7 +42,7 @@ func Server(cfg *config.Config) *cli.Command { }() defer cancel() - app, err := cs3wopiserver.Start(cfg) // grpc server needs decoupling + app, err := cs3wopiserver.Start(cfg, logger) // grpc server needs decoupling if err != nil { return err } diff --git a/services/collaboration/pkg/cs3wopiserver/start.go b/services/collaboration/pkg/cs3wopiserver/start.go index 425f36911c..9bc28ca1a7 100644 --- a/services/collaboration/pkg/cs3wopiserver/start.go +++ b/services/collaboration/pkg/cs3wopiserver/start.go @@ -3,14 +3,15 @@ package cs3wopiserver import ( "context" + "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" "github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/app" ) -func Start(cfg *config.Config) (*app.DemoApp, error) { +func Start(cfg *config.Config, logger log.Logger) (*app.DemoApp, error) { ctx := context.Background() - app, err := app.New(cfg) + app, err := app.New(cfg, logger) if err != nil { return nil, err } diff --git a/services/collaboration/pkg/internal/app/app.go b/services/collaboration/pkg/internal/app/app.go index 7281aa3ca6..5390b71286 100644 --- a/services/collaboration/pkg/internal/app/app.go +++ b/services/collaboration/pkg/internal/app/app.go @@ -6,8 +6,6 @@ import ( "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" - "github.com/owncloud/ocis/v2/services/collaboration/pkg/config/defaults" - "github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/logging" registryv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/registry/v1beta1" gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" @@ -31,7 +29,7 @@ type DemoApp struct { Logger log.Logger } -func New(cfg *config.Config) (*DemoApp, error) { +func New(cfg *config.Config, logger log.Logger) (*DemoApp, error) { app := &DemoApp{ Config: cfg, } @@ -43,7 +41,7 @@ func New(cfg *config.Config) (*DemoApp, error) { } } - app.Logger = logging.Configure("wopiserver", defaults.FullDefaultConfig().Log) + app.Logger = logger return app, nil } @@ -79,6 +77,10 @@ func (app *DemoApp) RegisterDemoApp(ctx context.Context) error { mimeTypes = append(mimeTypes, m) } + app.Logger.Debug(). + Str("AppName", app.Config.App.Name). + Strs("Mimetypes", mimeTypes). + Msg("Registering mimetypes in the app provider") // TODO: REVA has way to filter supported mimetypes (do we need to implement it here or is it in the registry?) // TODO: an added app provider shouldn't last forever. Instead the registry should use a TTL From a904af0b503d1bce1fa3fbc914db580541a5cfff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Tue, 20 Feb 2024 12:49:58 +0100 Subject: [PATCH 04/29] ci: adjust CI pipeline to use builtin wopiserver for the wopi validator --- .drone.star | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/.drone.star b/.drone.star index 8a1b363b50..640e92c05d 100644 --- a/.drone.star +++ b/.drone.star @@ -908,6 +908,7 @@ def wopiValidatorTests(ctx, storage, accounts_hash_difficulty = 4): "Features", ] + ocis_bin = "ocis/bin/ocis" validatorTests = [] for testgroup in testgroups: @@ -958,19 +959,28 @@ def wopiValidatorTests(ctx, storage, accounts_hash_difficulty = 4): [ { "name": "wopiserver", - "image": "cs3org/wopiserver:v10.3.0", + "image": OC_CI_GOLANG, "detach": True, + "environment": { + "MICRO_REGISTRY": "nats-js-kv", + "MICRO_REGISTRY_ADDRESS": "ocis-server:9233", + "COLLABORATION_LOG_LEVEL": "debug", + "COLLABORATION_APP_NAME": "FakeOffice", + "COLLABORATION_HTTP_ADDR": "wopiserver:9300", + "COLLABORATION_HTTP_SCHEME": "http", + "COLLABORATION_WOPIAPP_ADDR": "http://fakeoffice:8080", + "COLLABORATION_WOPIAPP_INSECURE": "true", + "COLLABORATION_CS3API_DATAGATEWAY_INSECURE": "true", + }, "commands": [ - "cp %s/tests/config/drone/wopiserver.conf /etc/wopi/wopiserver.conf" % (dirs["base"]), - "echo 123 > /etc/wopi/wopisecret", - "/app/wopiserver.py", + "%s collaboration server" % ocis_bin, ], }, { "name": "wait-for-wopi-server", "image": OC_CI_WAIT_FOR, "commands": [ - "wait-for -it wopiserver:8880 -t 300", + "wait-for -it wopiserver:9300 -t 300", ], }, { @@ -987,7 +997,7 @@ def wopiValidatorTests(ctx, storage, accounts_hash_difficulty = 4): "cat open.json", "cat open.json | jq .form_parameters.access_token | tr -d '\"' > accesstoken", "cat open.json | jq .form_parameters.access_token_ttl | tr -d '\"' > accesstokenttl", - "echo -n 'http://wopiserver:8880/wopi/files/' > wopisrc", + "echo -n 'http://wopiserver:9300/wopi/files/' > wopisrc", "cat open.json | jq .app_url | sed -n -e 's/^.*files%2F//p' | tr -d '\"' >> wopisrc", ], }, @@ -2011,6 +2021,8 @@ def ocisServer(storage, accounts_hash_difficulty = 4, volumes = [], depends_on = "OCIS_EVENTS_ENABLE_TLS": False, "MICRO_REGISTRY": "nats-js-kv", "MICRO_REGISTRY_ADDRESS": "127.0.0.1:9233", + "NATS_NATS_HOST": "0.0.0.0", + "NATS_NATS_PORT": 9233, } if deploy_type == "": @@ -2025,12 +2037,13 @@ def ocisServer(storage, accounts_hash_difficulty = 4, volumes = [], depends_on = if deploy_type == "wopi_validator": environment["GATEWAY_GRPC_ADDR"] = "0.0.0.0:9142" # make gateway available to wopi server + environment["OCIS_ASYNC_UPLOADS"] = False # disable async uploads because it could interfere with the wopi validator environment["APP_PROVIDER_EXTERNAL_ADDR"] = "com.owncloud.api.app-provider" environment["APP_PROVIDER_DRIVER"] = "wopi" environment["APP_PROVIDER_WOPI_APP_NAME"] = "FakeOffice" environment["APP_PROVIDER_WOPI_APP_URL"] = "http://fakeoffice:8080" environment["APP_PROVIDER_WOPI_INSECURE"] = "true" - environment["APP_PROVIDER_WOPI_WOPI_SERVER_EXTERNAL_URL"] = "http://wopiserver:8880" + environment["APP_PROVIDER_WOPI_WOPI_SERVER_EXTERNAL_URL"] = "http://wopiserver:9300" environment["APP_PROVIDER_WOPI_FOLDER_URL_BASE_URL"] = "https://ocis-server:9200" if tika_enabled: From 26cc6fc7a60ce9ff89282366ed7ca83fad01de9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Wed, 21 Feb 2024 15:31:27 +0100 Subject: [PATCH 05/29] fix: adjust code to pass the wopivalidator tests --- .../pkg/internal/app/fileinfo.go | 8 +- .../collaboration/pkg/internal/app/headers.go | 3 +- .../pkg/internal/app/wopifilecontents.go | 78 +++- .../pkg/internal/app/wopiinfo.go | 3 +- .../pkg/internal/app/wopilocking.go | 402 ++++++++++++++---- .../pkg/internal/helpers/download.go | 3 +- .../pkg/internal/helpers/upload.go | 46 +- .../collaboration/pkg/server/http/server.go | 1 + 8 files changed, 453 insertions(+), 91 deletions(-) diff --git a/services/collaboration/pkg/internal/app/fileinfo.go b/services/collaboration/pkg/internal/app/fileinfo.go index 3de3c9937d..b204ac478c 100644 --- a/services/collaboration/pkg/internal/app/fileinfo.go +++ b/services/collaboration/pkg/internal/app/fileinfo.go @@ -13,7 +13,7 @@ type FileInfo struct { // A string value uniquely identifying the user currently accessing the file. UserID string `json:"UserId,omitempty"` // The size of the file in bytes, expressed as a long, a 64-bit signed integer. - Size int64 `json:"Size,omitempty"` + Size int64 `json:"Size"` // The current version of the file based on the server’s file version schema, as a string. This value must change when the file changes, and version values must never repeat for a given file. Version string `json:"Version,omitempty"` // A string that is the name of the user, suitable for displaying in UI. @@ -47,7 +47,7 @@ type FileInfo struct { // A Boolean value that indicates that the user has permission to alter the file. Setting this to true tells the WOPI client that it can call PutFile on behalf of the user. UserCanWrite bool `json:"UserCanWrite"` // A Boolean value that indicates the WOPI client should close the window or tab when the user activates any Close UI in the WOPI client. - CloseButtonClosesWindow bool `json:"CloseButtonClosesWindow"` + CloseButtonClosesWindow bool `json:"CloseButtonClosesWindow,omitempty"` // A string value indicating whether the WOPI client should disable Copy and Paste functionality within the application. The default is to permit all Copy and Paste functionality, i.e. the setting has no effect. CopyPasteRestrictions string `json:"CopyPasteRestrictions,omitempty"` // A Boolean value that indicates the WOPI client should disable all print functionality. @@ -87,7 +87,7 @@ type FileInfo struct { // A Boolean value that indicates a WOPI client may connect to Microsoft services to provide end-user functionality. AllowAdditionalMicrosoftServices bool `json:"AllowAdditionalMicrosoftServices"` // A Boolean value that indicates that in the event of an error, the WOPI client is permitted to prompt the user for permission to collect a detailed report about their specific error. The information gathered could include the user’s file and other session-specific state. - AllowErrorReportPrompt bool `json:"AllowErrorReportPrompt"` + AllowErrorReportPrompt bool `json:"AllowErrorReportPrompt,omitempty"` // A Boolean value that indicates a WOPI client may allow connections to external services referenced in the file (for example, a marketplace of embeddable JavaScript apps). AllowExternalMarketplace bool `json:"AllowExternalMarketplace"` // A string value offering guidance to the WOPI client as to how to differentiate client throttling behaviors between the user and documents combinations from the WOPI host. @@ -107,7 +107,7 @@ type FileInfo struct { // A Boolean value that indicates that the host supports the following WOPI operations: CheckFolderInfo, EnumerateChildren (folders), DeleteFile SupportsFolders bool `json:"SupportsFolders"` // A Boolean value that indicates that the host supports the GetFileWopiSrc (ecosystem) operation. - SupportsGetFileWopiSrc bool `json:"SupportsGetFileWopiSrc"` + //SupportsGetFileWopiSrc bool `json:"SupportsGetFileWopiSrc"` // wopivalidator is complaining and the property isn't used for now -> commented // A Boolean value that indicates that the host supports the GetLock operation. SupportsGetLock bool `json:"SupportsGetLock"` // A Boolean value that indicates that the host supports the following WOPI operations: Lock, Unlock, RefreshLock, UnlockAndRelock operations for this file. diff --git a/services/collaboration/pkg/internal/app/headers.go b/services/collaboration/pkg/internal/app/headers.go index 9b73836b32..698ad3a637 100644 --- a/services/collaboration/pkg/internal/app/headers.go +++ b/services/collaboration/pkg/internal/app/headers.go @@ -1,5 +1,6 @@ package app const ( - HeaderWopiLock string = "X-WOPI-Lock" + HeaderWopiLock string = "X-WOPI-Lock" + HeaderWopiOldLock string = "X-WOPI-OldLock" ) diff --git a/services/collaboration/pkg/internal/app/wopifilecontents.go b/services/collaboration/pkg/internal/app/wopifilecontents.go index aa6e1717d2..d45a975609 100644 --- a/services/collaboration/pkg/internal/app/wopifilecontents.go +++ b/services/collaboration/pkg/internal/app/wopifilecontents.go @@ -3,7 +3,10 @@ package app import ( "io" "net/http" + "strconv" + rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" "github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/helpers" ) @@ -53,7 +56,6 @@ func GetFile(app *DemoApp, w http.ResponseWriter, r *http.Request) { Str("ViewMode", wopiContext.ViewMode.String()). Str("Requester", wopiContext.User.GetId().String()). Msg("GetFile: success") - http.Error(w, "", http.StatusOK) } // PutFile uploads the file to the storage @@ -65,10 +67,77 @@ func PutFile(app *DemoApp, w http.ResponseWriter, r *http.Request) { // read the file from the body defer r.Body.Close() + // We need a stat call on the target file in order to get both the lock + // (if any) and the current size of the file + statRes, err := app.gwc.Stat(ctx, &providerv1beta1.StatRequest{ + Ref: &wopiContext.FileReference, + }) + if err != nil { + app.Logger.Error(). + Err(err). + Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", r.Header.Get(HeaderWopiLock)). + Str("UploadLength", strconv.FormatInt(r.ContentLength, 10)). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Msg("PutFile: stat failed") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + if statRes.Status.Code != rpcv1beta1.Code_CODE_OK { + app.Logger.Error(). + Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", r.Header.Get(HeaderWopiLock)). + Str("UploadLength", strconv.FormatInt(r.ContentLength, 10)). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("StatusCode", statRes.Status.Code.String()). + Str("StatusMsg", statRes.Status.Message). + Msg("PutFile: stat failed with unexpected status") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + // If there is a lock and it mismatches, return 409 + if statRes.Info.Lock != nil && statRes.Info.Lock.LockId != r.Header.Get(HeaderWopiLock) { + app.Logger.Error(). + Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", r.Header.Get(HeaderWopiLock)). + Str("UploadLength", strconv.FormatInt(r.ContentLength, 10)). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("LockID", statRes.Info.Lock.LockId). + Msg("PutFile: wrong lock") + // onlyoffice says it's required to send the current lockId, MS doesn't say anything + w.Header().Add(HeaderWopiLock, statRes.Info.Lock.LockId) + http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) + return + } + + // only unlocked uploads can go through if the target file is empty, + // otherwise the X-WOPI-Lock header is required even if there is no lock on the file + // This is part of the onlyoffice documentation (https://api.onlyoffice.com/editors/wopi/restapi/putfile) + // Wopivalidator fails some tests if we don't also check for the X-WOPI-Lock header. + if r.Header.Get(HeaderWopiLock) == "" && statRes.Info.Lock == nil && statRes.Info.Size > 0 { + app.Logger.Error(). + Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", r.Header.Get(HeaderWopiLock)). + Str("UploadLength", strconv.FormatInt(r.ContentLength, 10)). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Msg("PutFile: file must be locked first") + // onlyoffice says to send an empty string if the file is unlocked, MS doesn't say anything + w.Header().Add(HeaderWopiLock, "") + http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) + return + } + // upload the file - err := helpers.UploadFile( + err = helpers.UploadFile( ctx, r.Body, + r.ContentLength, &wopiContext.FileReference, app.gwc, wopiContext.AccessToken, @@ -81,6 +150,8 @@ func PutFile(app *DemoApp, w http.ResponseWriter, r *http.Request) { app.Logger.Error(). Err(err). Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", r.Header.Get(HeaderWopiLock)). + Str("UploadLength", strconv.FormatInt(r.ContentLength, 10)). Str("ViewMode", wopiContext.ViewMode.String()). Str("Requester", wopiContext.User.GetId().String()). Msg("PutFile: uploading the file failed") @@ -90,8 +161,9 @@ func PutFile(app *DemoApp, w http.ResponseWriter, r *http.Request) { app.Logger.Debug(). Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", r.Header.Get(HeaderWopiLock)). + Str("UploadLength", strconv.FormatInt(r.ContentLength, 10)). Str("ViewMode", wopiContext.ViewMode.String()). Str("Requester", wopiContext.User.GetId().String()). Msg("PutFile: success") - http.Error(w, "", http.StatusOK) } diff --git a/services/collaboration/pkg/internal/app/wopiinfo.go b/services/collaboration/pkg/internal/app/wopiinfo.go index f06015bc52..17edf259f4 100644 --- a/services/collaboration/pkg/internal/app/wopiinfo.go +++ b/services/collaboration/pkg/internal/app/wopiinfo.go @@ -64,7 +64,8 @@ func CheckFileInfo(app *DemoApp, w http.ResponseWriter, r *http.Request) { HostViewUrl: wopiContext.ViewAppUrl, HostEditUrl: wopiContext.EditAppUrl, - EnableOwnerTermination: true, + //EnableOwnerTermination: true, // enable only for collabora? wopivalidator is complaining + EnableOwnerTermination: false, SupportsExtendedLockLength: true, diff --git a/services/collaboration/pkg/internal/app/wopilocking.go b/services/collaboration/pkg/internal/app/wopilocking.go index fd52980efa..4e8bf17a59 100644 --- a/services/collaboration/pkg/internal/app/wopilocking.go +++ b/services/collaboration/pkg/internal/app/wopilocking.go @@ -64,7 +64,6 @@ func GetLock(app *DemoApp, w http.ResponseWriter, r *http.Request) { Msg("GetLock success") w.Header().Set(HeaderWopiLock, lockID) - http.Error(w, http.StatusText(http.StatusOK), http.StatusOK) } // Lock returns a WOPI lock or performs an unlock and relock @@ -74,8 +73,7 @@ func Lock(app *DemoApp, w http.ResponseWriter, r *http.Request) { ctx := r.Context() wopiContext, _ := WopiContextFromCtx(ctx) - // TODO: handle un- and relock - + oldLockID := r.Header.Get(HeaderWopiOldLock) lockID := r.Header.Get(HeaderWopiLock) if lockID == "" { app.Logger.Error(). @@ -87,7 +85,184 @@ func Lock(app *DemoApp, w http.ResponseWriter, r *http.Request) { return } - req := &providerv1beta1.SetLockRequest{ + var setOrRefreshStatus *rpcv1beta1.Status + if oldLockID == "" { + // If the oldLockID is empty, this is a "LOCK" request + req := &providerv1beta1.SetLockRequest{ + Ref: &wopiContext.FileReference, + Lock: &providerv1beta1.Lock{ + LockId: lockID, + AppName: app.Config.App.LockName, + Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, + Expiration: &typesv1beta1.Timestamp{ + Seconds: uint64(time.Now().Add(lockDuration).Unix()), + }, + }, + } + + resp, err := app.gwc.SetLock(ctx, req) + if err != nil { + app.Logger.Error(). + Err(err). + Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", lockID). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Msg("SetLock failed") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + setOrRefreshStatus = resp.Status + } else { + // If the oldLockID isn't empty, this is a "UnlockAndRelock" request. We'll + // do a "RefreshLock" in reva and provide the old lock + req := &providerv1beta1.RefreshLockRequest{ + Ref: &wopiContext.FileReference, + Lock: &providerv1beta1.Lock{ + LockId: lockID, + AppName: app.Config.App.LockName, + Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, + Expiration: &typesv1beta1.Timestamp{ + Seconds: uint64(time.Now().Add(lockDuration).Unix()), + }, + }, + ExistingLockId: oldLockID, + } + + resp, err := app.gwc.RefreshLock(ctx, req) + if err != nil { + app.Logger.Error(). + Err(err). + Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", lockID). + Str("RequestedOldLockID", oldLockID). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Msg("UnlockAndRefresh failed") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + setOrRefreshStatus = resp.Status + } + + // we're checking the status of either the "SetLock" or "RefreshLock" operations + switch setOrRefreshStatus.Code { + case rpcv1beta1.Code_CODE_OK: + app.Logger.Debug(). + Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", lockID). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Msg("SetLock successful") + return + + case rpcv1beta1.Code_CODE_FAILED_PRECONDITION, rpcv1beta1.Code_CODE_ABORTED: + // Code_CODE_FAILED_PRECONDITION -> Lock operation mismatched lock + // Code_CODE_ABORTED -> UnlockAndRelock operation mismatched lock + // In both cases, we need to get the current lock to return it in a + // 409 response if needed + req := &providerv1beta1.GetLockRequest{ + Ref: &wopiContext.FileReference, + } + + resp, err := app.gwc.GetLock(ctx, req) + if err != nil { + app.Logger.Error(). + Err(err). + Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", lockID). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Msg("SetLock failed, fallback to GetLock failed too") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + app.Logger.Error(). + Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", lockID). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("SetLock failed, fallback to GetLock failed with unexpected status") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + + if resp.Lock != nil { + if resp.Lock.LockId != lockID { + // lockId is different -> return 409 with the current lockId + app.Logger.Warn(). + Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", lockID). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("LockID", resp.Lock.LockId). + Msg("SetLock conflict") + w.Header().Set(HeaderWopiLock, resp.Lock.LockId) + http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) + return + } + + // TODO: according to the spec we need to treat this as a RefreshLock + // There was a problem with the lock, but the file has the same lockId now. + // This should never happen unless there are race conditions. + // Since the lockId matches now, we'll assume success for now. + // As said in the todo, we probably should send a "RefreshLock" request here. + app.Logger.Warn(). + Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", lockID). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("LockID", resp.Lock.LockId). + Msg("SetLock lock refreshed instead") + return + } + + // TODO: Is this the right error code? + app.Logger.Error(). + Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", lockID). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Msg("SetLock failed and could not refresh") + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + + default: + app.Logger.Error(). + Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", lockID). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("StatusCode", setOrRefreshStatus.Code.String()). + Str("StatusMsg", setOrRefreshStatus.Message). + Msg("SetLock failed with unexpected status") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + +} + +// RefreshLock refreshes a provided lock for 30 minutes +// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/refreshlock +func RefreshLock(app *DemoApp, w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + wopiContext, _ := WopiContextFromCtx(ctx) + + lockID := r.Header.Get(HeaderWopiLock) + if lockID == "" { + app.Logger.Error(). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Msg("RefreshLock failed due to empty lockID") + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + req := &providerv1beta1.RefreshLockRequest{ Ref: &wopiContext.FileReference, Lock: &providerv1beta1.Lock{ LockId: lockID, @@ -99,22 +274,15 @@ func Lock(app *DemoApp, w http.ResponseWriter, r *http.Request) { }, } - app.Logger.Debug(). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("RequestedLockID", lockID). - Msg("Performing SetLock") - - resp, err := app.gwc.SetLock(ctx, req) + resp, err := app.gwc.RefreshLock(ctx, req) if err != nil { app.Logger.Error(). Err(err). Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", lockID). Str("ViewMode", wopiContext.ViewMode.String()). Str("Requester", wopiContext.User.GetId().String()). - Str("RequestedLockID", lockID). - Msg("SetLock failed") + Msg("RefreshLock failed") http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -123,15 +291,36 @@ func Lock(app *DemoApp, w http.ResponseWriter, r *http.Request) { case rpcv1beta1.Code_CODE_OK: app.Logger.Debug(). Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", lockID). Str("ViewMode", wopiContext.ViewMode.String()). Str("Requester", wopiContext.User.GetId().String()). - Str("RequestedLockID", lockID). - Msg("SetLock successful") - http.Error(w, http.StatusText(http.StatusOK), http.StatusOK) + Msg("RefreshLock successful") return - case rpcv1beta1.Code_CODE_FAILED_PRECONDITION: - // already locked + case rpcv1beta1.Code_CODE_NOT_FOUND: + app.Logger.Error(). + Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", lockID). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("RefreshLock failed, file reference not found") + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + + case rpcv1beta1.Code_CODE_ABORTED: + app.Logger.Error(). + Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", lockID). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("RefreshLock failed, lock mismatch") + + // Either the file is unlocked or there is no lock + // We need to return 409 with the current lock req := &providerv1beta1.GetLockRequest{ Ref: &wopiContext.FileReference, } @@ -141,10 +330,10 @@ func Lock(app *DemoApp, w http.ResponseWriter, r *http.Request) { app.Logger.Error(). Err(err). Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", lockID). Str("ViewMode", wopiContext.ViewMode.String()). Str("Requester", wopiContext.User.GetId().String()). - Str("RequestedLockID", lockID). - Msg("SetLock failed, fallback to GetLock failed too") + Msg("RefreshLock failed trying to get the current lock") http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } @@ -152,71 +341,55 @@ func Lock(app *DemoApp, w http.ResponseWriter, r *http.Request) { if resp.Status.Code != rpcv1beta1.Code_CODE_OK { app.Logger.Error(). Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", lockID). Str("ViewMode", wopiContext.ViewMode.String()). Str("Requester", wopiContext.User.GetId().String()). - Str("RequestedLockID", lockID). Str("StatusCode", resp.Status.Code.String()). Str("StatusMsg", resp.Status.Message). - Msg("SetLock failed, fallback to GetLock failed with unexpected status") + Msg("RefreshLock failed, tried to get the current lock failed with unexpected status") http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } - - if resp.Lock != nil { - if resp.Lock.LockId != lockID { - app.Logger.Warn(). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("RequestedLockID", lockID). - Str("LockID", resp.Lock.LockId). - Msg("SetLock conflict") - w.Header().Set(HeaderWopiLock, resp.Lock.LockId) - http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) - return - } - - // TODO: according to the spec we need to treat this as a RefreshLock - - app.Logger.Warn(). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("RequestedLockID", lockID). - Str("LockID", resp.Lock.LockId). - Msg("SetLock lock refreshed instead") - http.Error(w, http.StatusText(http.StatusOK), http.StatusOK) return } - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("RequestedLockID", lockID). - Msg("SetLock failed and could not refresh") - http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) - return - + if resp.Lock == nil { + app.Logger.Error(). + Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", lockID). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("RefreshLock failed, no lock on file") + w.Header().Set(HeaderWopiLock, "") + http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) + return + } else { + // lock is different than the one requested, otherwise we wouldn't reached this point + app.Logger.Error(). + Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", lockID). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("LockID", resp.Lock.LockId). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("RefreshLock failed, lock mismatch") + w.Header().Set(HeaderWopiLock, resp.Lock.LockId) + http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) + return + } default: app.Logger.Error(). Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", lockID). Str("ViewMode", wopiContext.ViewMode.String()). Str("Requester", wopiContext.User.GetId().String()). - Str("RequestedLockID", lockID). Str("StatusCode", resp.Status.Code.String()). Str("StatusMsg", resp.Status.Message). - Msg("SetLock failed with unexpected status") + Msg("RefreshLock failed with unexpected status") http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - -} - -// RefreshLock refreshes a provided lock for 30 minutes -// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/refreshlock -func RefreshLock(app *DemoApp, w http.ResponseWriter, r *http.Request) { - // TODO: implement - http.Error(w, http.StatusText(http.StatusNotImplemented), http.StatusNotImplemented) } // UnLock removes a given lock from a file @@ -249,32 +422,103 @@ func UnLock(app *DemoApp, w http.ResponseWriter, r *http.Request) { app.Logger.Error(). Err(err). Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", lockID). Str("ViewMode", wopiContext.ViewMode.String()). Str("Requester", wopiContext.User.GetId().String()). - Str("RequestedLockID", lockID). Msg("Unlock failed") http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - if resp.Status.Code != rpcv1beta1.Code_CODE_OK { - app.Logger.Error(). + switch resp.Status.Code { + case rpcv1beta1.Code_CODE_OK: + app.Logger.Debug(). Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", lockID). Str("ViewMode", wopiContext.ViewMode.String()). Str("Requester", wopiContext.User.GetId().String()). + Msg("Unlock successful") + return + case rpcv1beta1.Code_CODE_ABORTED: + // File isn't locked. Need to return 409 with empty lock + app.Logger.Error(). + Err(err). + Str("FileReference", wopiContext.FileReference.String()). Str("RequestedLockID", lockID). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Msg("Unlock failed, file isn't locked") + w.Header().Set(HeaderWopiLock, "") + http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) + return + case rpcv1beta1.Code_CODE_LOCKED: + // We need to return 409 with the current lock + req := &providerv1beta1.GetLockRequest{ + Ref: &wopiContext.FileReference, + } + + resp, err := app.gwc.GetLock(ctx, req) + if err != nil { + app.Logger.Error(). + Err(err). + Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", lockID). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Msg("Unlock failed trying to get the current lock") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + app.Logger.Error(). + Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", lockID). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("Unlock failed, tried to get the current lock failed with unexpected status") + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + if resp.Lock == nil { + app.Logger.Error(). + Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", lockID). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("Unlock failed, no lock on file") + w.Header().Set(HeaderWopiLock, "") + http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) + } else { + // lock is different than the one requested, otherwise we wouldn't reached this point + app.Logger.Error(). + Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", lockID). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Str("LockID", resp.Lock.LockId). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("Unlock failed, lock mismatch") + w.Header().Set(HeaderWopiLock, resp.Lock.LockId) + http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) + } + return + default: + app.Logger.Error(). + Str("FileReference", wopiContext.FileReference.String()). + Str("RequestedLockID", lockID). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). Str("StatusCode", resp.Status.Code.String()). Str("StatusMsg", resp.Status.Message). Msg("Unlock failed with unexpected status") http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - - app.Logger.Debug(). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("RequestedLockID", lockID). - Msg("Unlock successful") - http.Error(w, http.StatusText(http.StatusOK), http.StatusOK) } diff --git a/services/collaboration/pkg/internal/helpers/download.go b/services/collaboration/pkg/internal/helpers/download.go index 981e46beec..ee0568f598 100644 --- a/services/collaboration/pkg/internal/helpers/download.go +++ b/services/collaboration/pkg/internal/helpers/download.go @@ -54,6 +54,7 @@ func DownloadFile( downloadEndpoint = proto.DownloadEndpoint downloadToken = proto.Token hasDownloadToken = proto.Token != "" + break } } @@ -89,7 +90,7 @@ func DownloadFile( httpReq.Header.Add("X-Reva-Transfer", downloadToken) } // TODO: the access token shouldn't be needed - httpReq.Header.Add("x-access-token", token) + httpReq.Header.Add("X-Access-Token", token) httpResp, err := httpClient.Do(httpReq) if err != nil { diff --git a/services/collaboration/pkg/internal/helpers/upload.go b/services/collaboration/pkg/internal/helpers/upload.go index ed01d21af8..95e0da2214 100644 --- a/services/collaboration/pkg/internal/helpers/upload.go +++ b/services/collaboration/pkg/internal/helpers/upload.go @@ -7,16 +7,40 @@ import ( "io" "net/http" "strconv" + "time" gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" "github.com/owncloud/ocis/v2/ocis-pkg/log" ) -func UploadFile(ctx context.Context, content io.ReadCloser, ref *providerv1beta1.Reference, gwc gatewayv1beta1.GatewayAPIClient, token string, lockID string, insecure bool, logger log.Logger) error { +func UploadFile( + ctx context.Context, + content io.ReadCloser, + contentLength int64, + ref *providerv1beta1.Reference, + gwc gatewayv1beta1.GatewayAPIClient, + token string, + lockID string, + insecure bool, + logger log.Logger, +) error { + opaque := &types.Opaque{ + Map: make(map[string]*types.OpaqueEntry), + } + + strContentLength := strconv.FormatInt(contentLength, 10) + if contentLength >= 0 { + opaque.Map["Upload-Length"] = &types.OpaqueEntry{ + Decoder: "plain", + Value: []byte(strContentLength), + } + } req := &providerv1beta1.InitiateFileUploadRequest{ + Opaque: opaque, Ref: ref, LockId: lockID, // TODO: if-match @@ -31,6 +55,7 @@ func UploadFile(ctx context.Context, content io.ReadCloser, ref *providerv1beta1 Err(err). Str("FileReference", ref.String()). Str("RequestedLockID", lockID). + Str("UploadLength", strContentLength). Msg("UploadHelper: InitiateFileUpload failed") return err } @@ -39,12 +64,18 @@ func UploadFile(ctx context.Context, content io.ReadCloser, ref *providerv1beta1 logger.Error(). Str("FileReference", ref.String()). Str("RequestedLockID", lockID). + Str("UploadLength", strContentLength). Str("StatusCode", resp.Status.Code.String()). Str("StatusMsg", resp.Status.Message). Msg("UploadHelper: InitiateFileUpload failed with wrong status") return errors.New("InitiateFileUpload failed with status " + resp.Status.Code.String()) } + // if content length is 0, we're done. We don't upload anything to the target endpoint + if contentLength == 0 { + return nil + } + uploadEndpoint := "" uploadToken := "" hasUploadToken := false @@ -62,6 +93,7 @@ func UploadFile(ctx context.Context, content io.ReadCloser, ref *providerv1beta1 logger.Error(). Str("FileReference", ref.String()). Str("RequestedLockID", lockID). + Str("UploadLength", strContentLength). Str("Endpoint", uploadEndpoint). Bool("HasUploadToken", hasUploadToken). Msg("UploadHelper: Upload endpoint or token is missing") @@ -74,6 +106,7 @@ func UploadFile(ctx context.Context, content io.ReadCloser, ref *providerv1beta1 InsecureSkipVerify: insecure, }, }, + Timeout: 10 * time.Second, } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadEndpoint, content) @@ -82,19 +115,25 @@ func UploadFile(ctx context.Context, content io.ReadCloser, ref *providerv1beta1 Err(err). Str("FileReference", ref.String()). Str("RequestedLockID", lockID). + Str("UploadLength", strContentLength). Str("Endpoint", uploadEndpoint). Bool("HasUploadToken", hasUploadToken). Msg("UploadHelper: Could not create the request to the endpoint") return err } + // "content" is an *http.body and doesn't fill the httpReq.ContentLength automatically + // we need to fill the ContentLength ourselves, and must match the stream length in order + // to prevent issues + httpReq.ContentLength = contentLength if uploadToken != "" { // public link uploads have the token in the upload endpoint httpReq.Header.Add("X-Reva-Transfer", uploadToken) } // TODO: the access token shouldn't be needed - httpReq.Header.Add("x-access-token", token) + httpReq.Header.Add("X-Access-Token", token) + httpReq.Header.Add("X-Lock-Id", lockID) // TODO: better mechanism for the upload while locked, relies on patch in REVA //if lockID, ok := ctxpkg.ContextGetLockID(ctx); ok { // httpReq.Header.Add("X-Lock-Id", lockID) @@ -106,16 +145,19 @@ func UploadFile(ctx context.Context, content io.ReadCloser, ref *providerv1beta1 Err(err). Str("FileReference", ref.String()). Str("RequestedLockID", lockID). + Str("UploadLength", strContentLength). Str("Endpoint", uploadEndpoint). Bool("HasUploadToken", hasUploadToken). Msg("UploadHelper: Put request to the upload endpoint failed") return err } + defer httpResp.Body.Close() if httpResp.StatusCode != http.StatusOK { logger.Error(). Str("FileReference", ref.String()). Str("RequestedLockID", lockID). + Str("UploadLength", strContentLength). Str("Endpoint", uploadEndpoint). Bool("HasUploadToken", hasUploadToken). Int("HttpCode", httpResp.StatusCode). diff --git a/services/collaboration/pkg/server/http/server.go b/services/collaboration/pkg/server/http/server.go index d32db58859..c93f3ac1ae 100644 --- a/services/collaboration/pkg/server/http/server.go +++ b/services/collaboration/pkg/server/http/server.go @@ -109,6 +109,7 @@ func prepareRoutes(r *chi.Mux, demoapp *app.DemoApp) { switch action { case "LOCK": + // "UnlockAndRelock" operation goes through here app.Lock(demoapp, w, r) case "GET_LOCK": app.GetLock(demoapp, w, r) From 7fda20602b3d17e68019812a4a3cc2c995838636 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Mon, 4 Mar 2024 14:21:22 +0100 Subject: [PATCH 06/29] docs: improve documentation of configuration options --- services/collaboration/README.md | 2 +- services/collaboration/pkg/config/cs3api.go | 2 +- services/collaboration/pkg/config/grpc.go | 2 +- services/collaboration/pkg/config/http.go | 6 +++--- services/collaboration/pkg/config/wopiapp.go | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/services/collaboration/README.md b/services/collaboration/README.md index d7dce5a994..ec7fea2991 100644 --- a/services/collaboration/README.md +++ b/services/collaboration/README.md @@ -17,6 +17,6 @@ There are a few variables that you need to set: * `COLLABORATION_WOPIAPP_ADDR`: The URL of the WOPI app (onlyoffice, collabora, etc). For example: "https://office.mycloud.prv". * `COLLABORATION_HTTP_ADDR`: The external address of the collaboration service. The target app (onlyoffice, collabora) will use this address to read and write files from ocis. For example: "wopiserver.mycloud.prv" -* `COLLABORATION_HTTP_SCHEME`: The scheme to be used when accessing the collaboration service. Either "http" or "https". This will be used to build the URL that the WOPI app needs in order to contact this service. +* `COLLABORATION_HTTP_SCHEME`: The scheme to be used when accessing the collaboration service. Either "http" or "https". This will be used to finally build the URL that the WOPI app needs in order to contact this service. The rest of the configuration options available can be left with the default values. diff --git a/services/collaboration/pkg/config/cs3api.go b/services/collaboration/pkg/config/cs3api.go index 3ea45b3c3e..45c972ff39 100644 --- a/services/collaboration/pkg/config/cs3api.go +++ b/services/collaboration/pkg/config/cs3api.go @@ -7,7 +7,7 @@ type CS3Api struct { } type Gateway struct { - Name string `yaml: "name" env:"COLLABORATION_CS3API_GATEWAY_NAME" desc:"The service name of the CS3API gateway."` + Name string `yaml: "name" env:"OCIS_REVA_GATEWAY;COLLABORATION_CS3API_GATEWAY_NAME" desc:"The service name of the CS3API gateway."` } type DataGateway struct { diff --git a/services/collaboration/pkg/config/grpc.go b/services/collaboration/pkg/config/grpc.go index 795f4970f7..44f51604c6 100644 --- a/services/collaboration/pkg/config/grpc.go +++ b/services/collaboration/pkg/config/grpc.go @@ -2,6 +2,6 @@ package config // Service defines the available grpc configuration. type GRPC struct { - Addr string `yaml:"addr" env:"COLLABORATION_GRPC_ADDR" desc:"The bind address of the GRPC service"` + Addr string `yaml:"addr" env:"COLLABORATION_GRPC_ADDR" desc:"The bind address of the GRPC service."` Namespace string `yaml:"-"` } diff --git a/services/collaboration/pkg/config/http.go b/services/collaboration/pkg/config/http.go index 5b95cd017a..9ec6a07ef0 100644 --- a/services/collaboration/pkg/config/http.go +++ b/services/collaboration/pkg/config/http.go @@ -6,9 +6,9 @@ import ( // HTTP defines the available http configuration. type HTTP struct { - Addr string `yaml:"addr" env:"COLLABORATION_HTTP_ADDR" desc:"The external address of the HTTP service. Either IP address or host (127.0.0.1:9301 or wopi.private.prv). The configured "Scheme" will be used to build public URLs along with this address."` - BindAddr string `yaml:"bindaddr" env:"COLLABORATION_HTTP_BINDADDR" desc:"The bind address of the HTTP service."` + Addr string `yaml:"addr" env:"COLLABORATION_HTTP_ADDR" desc:"The external address of the collaboration service wihout a leading scheme. Either use an IP address or a hostname (127.0.0.1:9301 or wopi.private.prv). The configured 'Scheme' in another envvar will be used to finally build the public URL along with this address."` + BindAddr string `yaml:"bindaddr" env:"COLLABORATION_HTTP_BINDADDR" desc:"The bind address of the HTTP service. Use ':', for example, '127.0.0.1:9301' or '0.0.0.0:9301'."` Namespace string `yaml:"-"` - Scheme string `yaml:"scheme" env:"COLLABORATION_HTTP_SCHEME" desc:"Either http or https"` + Scheme string `yaml:"scheme" env:"COLLABORATION_HTTP_SCHEME" desc:"The scheme to use for the HTTP address, which is either 'http' or 'https'."` TLS shared.HTTPServiceTLS `yaml:"tls"` } diff --git a/services/collaboration/pkg/config/wopiapp.go b/services/collaboration/pkg/config/wopiapp.go index 33784ce958..1fa8ae8eb4 100644 --- a/services/collaboration/pkg/config/wopiapp.go +++ b/services/collaboration/pkg/config/wopiapp.go @@ -3,5 +3,5 @@ package config // WopiApp defines the available configuration in order to connect to a WOPI app. type WopiApp struct { Addr string `yaml:"addr" env:"COLLABORATION_WOPIAPP_ADDR" desc:"The URL where the WOPI app is located, such as https://127.0.0.1:8080."` - Insecure bool `yaml:"insecure" env:"COLLABORATION_WOPIAPP_INSECURE" desc:"Connect to the WOPI app insecurely."` + Insecure bool `yaml:"insecure" env:"COLLABORATION_WOPIAPP_INSECURE" desc:"Skip TLS certificate verification when connecting to the WOPI app"` } From 1714eacbf0d6ac93f66cf1eb384df692fbf444cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Tue, 5 Mar 2024 11:39:03 +0100 Subject: [PATCH 07/29] docs: add the "introductionVersion" keyword in all env vars --- services/collaboration/pkg/config/app.go | 8 ++++---- services/collaboration/pkg/config/config.go | 10 +++++----- services/collaboration/pkg/config/cs3api.go | 4 ++-- services/collaboration/pkg/config/debug.go | 8 ++++---- services/collaboration/pkg/config/grpc.go | 2 +- services/collaboration/pkg/config/http.go | 6 +++--- services/collaboration/pkg/config/log.go | 8 ++++---- services/collaboration/pkg/config/wopiapp.go | 4 ++-- 8 files changed, 25 insertions(+), 25 deletions(-) diff --git a/services/collaboration/pkg/config/app.go b/services/collaboration/pkg/config/app.go index 075ecc0641..69c5447f0a 100644 --- a/services/collaboration/pkg/config/app.go +++ b/services/collaboration/pkg/config/app.go @@ -2,8 +2,8 @@ package config // App defines the available app configuration. type App struct { - Name string `yaml:"name" env:"COLLABORATION_APP_NAME" desc:"The name of the app"` - Description string `yaml:"description" env:"COLLABORATION_APP_DESCRIPTION" desc:"App description"` - Icon string `yaml:"icon" env:"COLLABORATION_APP_ICON" desc:"Icon for the app"` - LockName string `yaml:"lockname" env:"COLLABORATION_APP_LOCKNAME" desc:"Name for the app lock"` + Name string `yaml:"name" env:"COLLABORATION_APP_NAME" desc:"The name of the app" introductionVersion:"5.1"` + Description string `yaml:"description" env:"COLLABORATION_APP_DESCRIPTION" desc:"App description" introductionVersion:"5.1"` + Icon string `yaml:"icon" env:"COLLABORATION_APP_ICON" desc:"Icon for the app" introductionVersion:"5.1"` + LockName string `yaml:"lockname" env:"COLLABORATION_APP_LOCKNAME" desc:"Name for the app lock" introductionVersion:"5.1"` } diff --git a/services/collaboration/pkg/config/config.go b/services/collaboration/pkg/config/config.go index f0afb149f4..5c3ca9dc57 100644 --- a/services/collaboration/pkg/config/config.go +++ b/services/collaboration/pkg/config/config.go @@ -14,7 +14,7 @@ type Config struct { Service Service `yaml:"-"` App App `yaml:"app"` - JWTSecret string `yaml:"jwt_secret" env:"OCIS_JWT_SECRET;COLLABORATION_JWT_SECRET" desc:"Used as JWT token and to encrypt access token."` + JWTSecret string `yaml:"jwt_secret" env:"OCIS_JWT_SECRET;COLLABORATION_JWT_SECRET" desc:"Used as JWT token and to encrypt access token." introductionVersion:"5.1"` GRPC GRPC `yaml:"grpc"` HTTP HTTP `yaml:"http"` @@ -30,10 +30,10 @@ type Config struct { // Tracing defines the available tracing configuration. Not used at the moment type Tracing struct { - Enabled bool `yaml:"enabled" env:"OCIS_TRACING_ENABLED;COLLABORATION_TRACING_ENABLED" desc:"Activates tracing."` - Type string `yaml:"type" env:"OCIS_TRACING_TYPE;COLLABORATION_TRACING_TYPE" desc:"The type of tracing. Defaults to '', which is the same as 'jaeger'. Allowed tracing types are 'jaeger' and '' as of now."` - Endpoint string `yaml:"endpoint" env:"OCIS_TRACING_ENDPOINT;COLLABORATION_TRACING_ENDPOINT" desc:"The endpoint of the tracing agent."` - Collector string `yaml:"collector" env:"OCIS_TRACING_COLLECTOR;COLLABORATION_TRACING_COLLECTOR" desc:"The HTTP endpoint for sending spans directly to a collector, i.e. http://jaeger-collector:14268/api/traces. Only used if the tracing endpoint is unset."` + Enabled bool `yaml:"enabled" env:"OCIS_TRACING_ENABLED;COLLABORATION_TRACING_ENABLED" desc:"Activates tracing." introductionVersion:"5.1"` + Type string `yaml:"type" env:"OCIS_TRACING_TYPE;COLLABORATION_TRACING_TYPE" desc:"The type of tracing. Defaults to '', which is the same as 'jaeger'. Allowed tracing types are 'jaeger' and '' as of now." introductionVersion:"5.1"` + Endpoint string `yaml:"endpoint" env:"OCIS_TRACING_ENDPOINT;COLLABORATION_TRACING_ENDPOINT" desc:"The endpoint of the tracing agent." introductionVersion:"5.1"` + Collector string `yaml:"collector" env:"OCIS_TRACING_COLLECTOR;COLLABORATION_TRACING_COLLECTOR" desc:"The HTTP endpoint for sending spans directly to a collector, i.e. http://jaeger-collector:14268/api/traces. Only used if the tracing endpoint is unset." introductionVersion:"5.1"` } // Convert Tracing to the tracing package's Config struct. diff --git a/services/collaboration/pkg/config/cs3api.go b/services/collaboration/pkg/config/cs3api.go index 45c972ff39..555999718b 100644 --- a/services/collaboration/pkg/config/cs3api.go +++ b/services/collaboration/pkg/config/cs3api.go @@ -7,9 +7,9 @@ type CS3Api struct { } type Gateway struct { - Name string `yaml: "name" env:"OCIS_REVA_GATEWAY;COLLABORATION_CS3API_GATEWAY_NAME" desc:"The service name of the CS3API gateway."` + Name string `yaml: "name" env:"OCIS_REVA_GATEWAY;COLLABORATION_CS3API_GATEWAY_NAME" desc:"The service name of the CS3API gateway." introductionVersion:"5.1"` } type DataGateway struct { - Insecure bool `yaml:"insecure" env:"COLLABORATION_CS3API_DATAGATEWAY_INSECURE" desc:"Connect to the CS3API data gateway insecurely."` + Insecure bool `yaml:"insecure" env:"COLLABORATION_CS3API_DATAGATEWAY_INSECURE" desc:"Connect to the CS3API data gateway insecurely." introductionVersion:"5.1"` } diff --git a/services/collaboration/pkg/config/debug.go b/services/collaboration/pkg/config/debug.go index ad1afb576f..0961402e31 100644 --- a/services/collaboration/pkg/config/debug.go +++ b/services/collaboration/pkg/config/debug.go @@ -2,8 +2,8 @@ package config // Debug defines the available debug configuration. Not used at the moment type Debug struct { - Addr string `yaml:"addr" env:"COLLABORATION_DEBUG_ADDR" desc:"Bind address of the debug server, where metrics, health, config and debug endpoints will be exposed."` - Token string `yaml:"token" env:"COLLABORATION_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint."` - Pprof bool `yaml:"pprof" env:"COLLABORATION_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling."` - Zpages bool `yaml:"zpages" env:"COLLABORATION_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces."` + Addr string `yaml:"addr" env:"COLLABORATION_DEBUG_ADDR" desc:"Bind address of the debug server, where metrics, health, config and debug endpoints will be exposed." introductionVersion:"5.1"` + Token string `yaml:"token" env:"COLLABORATION_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint." introductionVersion:"5.1"` + Pprof bool `yaml:"pprof" env:"COLLABORATION_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling." introductionVersion:"5.1"` + Zpages bool `yaml:"zpages" env:"COLLABORATION_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces." introductionVersion:"5.1"` } diff --git a/services/collaboration/pkg/config/grpc.go b/services/collaboration/pkg/config/grpc.go index 44f51604c6..06da141ba4 100644 --- a/services/collaboration/pkg/config/grpc.go +++ b/services/collaboration/pkg/config/grpc.go @@ -2,6 +2,6 @@ package config // Service defines the available grpc configuration. type GRPC struct { - Addr string `yaml:"addr" env:"COLLABORATION_GRPC_ADDR" desc:"The bind address of the GRPC service."` + Addr string `yaml:"addr" env:"COLLABORATION_GRPC_ADDR" desc:"The bind address of the GRPC service." introductionVersion:"5.1"` Namespace string `yaml:"-"` } diff --git a/services/collaboration/pkg/config/http.go b/services/collaboration/pkg/config/http.go index 9ec6a07ef0..31a0ee090d 100644 --- a/services/collaboration/pkg/config/http.go +++ b/services/collaboration/pkg/config/http.go @@ -6,9 +6,9 @@ import ( // HTTP defines the available http configuration. type HTTP struct { - Addr string `yaml:"addr" env:"COLLABORATION_HTTP_ADDR" desc:"The external address of the collaboration service wihout a leading scheme. Either use an IP address or a hostname (127.0.0.1:9301 or wopi.private.prv). The configured 'Scheme' in another envvar will be used to finally build the public URL along with this address."` - BindAddr string `yaml:"bindaddr" env:"COLLABORATION_HTTP_BINDADDR" desc:"The bind address of the HTTP service. Use ':', for example, '127.0.0.1:9301' or '0.0.0.0:9301'."` + Addr string `yaml:"addr" env:"COLLABORATION_HTTP_ADDR" desc:"The external address of the collaboration service wihout a leading scheme. Either use an IP address or a hostname (127.0.0.1:9301 or wopi.private.prv). The configured 'Scheme' in another envvar will be used to finally build the public URL along with this address." introductionVersion:"5.1"` + BindAddr string `yaml:"bindaddr" env:"COLLABORATION_HTTP_BINDADDR" desc:"The bind address of the HTTP service. Use ':', for example, '127.0.0.1:9301' or '0.0.0.0:9301'." introductionVersion:"5.1"` Namespace string `yaml:"-"` - Scheme string `yaml:"scheme" env:"COLLABORATION_HTTP_SCHEME" desc:"The scheme to use for the HTTP address, which is either 'http' or 'https'."` + Scheme string `yaml:"scheme" env:"COLLABORATION_HTTP_SCHEME" desc:"The scheme to use for the HTTP address, which is either 'http' or 'https'." introductionVersion:"5.1"` TLS shared.HTTPServiceTLS `yaml:"tls"` } diff --git a/services/collaboration/pkg/config/log.go b/services/collaboration/pkg/config/log.go index 0c3cc0360a..2f6c7dbd9d 100644 --- a/services/collaboration/pkg/config/log.go +++ b/services/collaboration/pkg/config/log.go @@ -2,8 +2,8 @@ package config // Log defines the available log configuration. type Log struct { - Level string `yaml:"level" env:"OCIS_LOG_LEVEL;COLLABORATION_LOG_LEVEL" desc:"The log level. Valid values are: 'panic', 'fatal', 'error', 'warn', 'info', 'debug', 'trace'."` - Pretty bool `yaml:"pretty" env:"OCIS_LOG_PRETTY;COLLABORATION_LOG_PRETTY" desc:"Activates pretty log output."` - Color bool `yaml:"color" env:"OCIS_LOG_COLOR;COLLABORATION_LOG_COLOR" desc:"Activates colorized log output."` - File string `yaml:"file" env:"OCIS_LOG_FILE;COLLABORATION_LOG_FILE" desc:"The path to the log file. Activates logging to this file if set."` + Level string `yaml:"level" env:"OCIS_LOG_LEVEL;COLLABORATION_LOG_LEVEL" desc:"The log level. Valid values are: 'panic', 'fatal', 'error', 'warn', 'info', 'debug', 'trace'." introductionVersion:"5.1"` + Pretty bool `yaml:"pretty" env:"OCIS_LOG_PRETTY;COLLABORATION_LOG_PRETTY" desc:"Activates pretty log output." introductionVersion:"5.1"` + Color bool `yaml:"color" env:"OCIS_LOG_COLOR;COLLABORATION_LOG_COLOR" desc:"Activates colorized log output." introductionVersion:"5.1"` + File string `yaml:"file" env:"OCIS_LOG_FILE;COLLABORATION_LOG_FILE" desc:"The path to the log file. Activates logging to this file if set." introductionVersion:"5.1"` } diff --git a/services/collaboration/pkg/config/wopiapp.go b/services/collaboration/pkg/config/wopiapp.go index 1fa8ae8eb4..8f4427e2e1 100644 --- a/services/collaboration/pkg/config/wopiapp.go +++ b/services/collaboration/pkg/config/wopiapp.go @@ -2,6 +2,6 @@ package config // WopiApp defines the available configuration in order to connect to a WOPI app. type WopiApp struct { - Addr string `yaml:"addr" env:"COLLABORATION_WOPIAPP_ADDR" desc:"The URL where the WOPI app is located, such as https://127.0.0.1:8080."` - Insecure bool `yaml:"insecure" env:"COLLABORATION_WOPIAPP_INSECURE" desc:"Skip TLS certificate verification when connecting to the WOPI app"` + Addr string `yaml:"addr" env:"COLLABORATION_WOPIAPP_ADDR" desc:"The URL where the WOPI app is located, such as https://127.0.0.1:8080." introductionVersion:"5.1"` + Insecure bool `yaml:"insecure" env:"COLLABORATION_WOPIAPP_INSECURE" desc:"Skip TLS certificate verification when connecting to the WOPI app" introductionVersion:"5.1"` } From 14f76519b029d42be41706e0e8f458066d047eb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Tue, 12 Mar 2024 18:08:29 +0100 Subject: [PATCH 08/29] fix: check for more errors, remove obsolete code and encode user ids --- services/collaboration/pkg/command/server.go | 3 +-- .../collaboration/pkg/internal/app/app.go | 4 +-- .../pkg/internal/app/wopicontext.go | 4 --- .../pkg/internal/app/wopiinfo.go | 25 ++++++++++++++----- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/services/collaboration/pkg/command/server.go b/services/collaboration/pkg/command/server.go index 0a28002d57..d49183b5bb 100644 --- a/services/collaboration/pkg/command/server.go +++ b/services/collaboration/pkg/command/server.go @@ -65,8 +65,7 @@ func Server(cfg *config.Config) *cli.Command { if err != nil { return err } - grpcServer.Serve(l) - return nil + return grpcServer.Serve(l) }, func(_ error) { logger.Error(). diff --git a/services/collaboration/pkg/internal/app/app.go b/services/collaboration/pkg/internal/app/app.go index 5390b71286..53e4b288d9 100644 --- a/services/collaboration/pkg/internal/app/app.go +++ b/services/collaboration/pkg/internal/app/app.go @@ -15,12 +15,10 @@ import ( "github.com/gofrs/uuid" "github.com/owncloud/ocis/v2/ocis-pkg/config/envdecode" "github.com/owncloud/ocis/v2/ocis-pkg/registry" - "google.golang.org/grpc" ) type DemoApp struct { - gwc gatewayv1beta1.GatewayAPIClient - grpcServer *grpc.Server + gwc gatewayv1beta1.GatewayAPIClient AppURLs map[string]map[string]string diff --git a/services/collaboration/pkg/internal/app/wopicontext.go b/services/collaboration/pkg/internal/app/wopicontext.go index b2f9074d1e..fea0b60421 100644 --- a/services/collaboration/pkg/internal/app/wopicontext.go +++ b/services/collaboration/pkg/internal/app/wopicontext.go @@ -33,7 +33,6 @@ func WopiContextAuthMiddleware(app *DemoApp, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { accessToken := r.URL.Query().Get("access_token") if accessToken == "" { - fmt.Println("wopicontext", "accesstoken empty") http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) return } @@ -49,13 +48,11 @@ func WopiContextAuthMiddleware(app *DemoApp, next http.Handler) http.Handler { }) if err != nil { - fmt.Println("wopicontext", err) http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) return } if err := claims.Valid(); err != nil { - fmt.Println("wopicontext", err) http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) return } @@ -64,7 +61,6 @@ func WopiContextAuthMiddleware(app *DemoApp, next http.Handler) http.Handler { wopiContextAccessToken, err := DecryptAES([]byte(app.Config.JWTSecret), claims.WopiContext.AccessToken) if err != nil { - fmt.Println("wopicontext", err) http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) return } diff --git a/services/collaboration/pkg/internal/app/wopiinfo.go b/services/collaboration/pkg/internal/app/wopiinfo.go index 17edf259f4..0a5bc44ad3 100644 --- a/services/collaboration/pkg/internal/app/wopiinfo.go +++ b/services/collaboration/pkg/internal/app/wopiinfo.go @@ -1,6 +1,7 @@ package app import ( + "encoding/hex" "encoding/json" "net/http" "path" @@ -51,7 +52,8 @@ func CheckFileInfo(app *DemoApp, w http.ResponseWriter, r *http.Request) { } fileInfo := FileInfo{ - OwnerID: statRes.Info.Owner.OpaqueId + "@" + statRes.Info.Owner.Idp, + // OwnerID must use only alphanumeric chars (https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo/checkfileinfo-response#requirements-for-user-identity-properties) + OwnerID: hex.EncodeToString([]byte(statRes.Info.Owner.OpaqueId + "@" + statRes.Info.Owner.Idp)), Size: int64(statRes.Info.Size), Version: statRes.Info.Mtime.String(), BaseFileName: path.Base(statRes.Info.Path), @@ -90,10 +92,11 @@ func CheckFileInfo(app *DemoApp, w http.ResponseWriter, r *http.Request) { // user logic from reva wopi driver #TODO: refactor var isPublicShare bool = false if wopiContext.User != nil { + // UserID must use only alphanumeric chars (https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo/checkfileinfo-response#requirements-for-user-identity-properties) if wopiContext.User.Id.Type == userv1beta1.UserType_USER_TYPE_LIGHTWEIGHT { - fileInfo.UserID = statRes.Info.Owner.OpaqueId + "@" + statRes.Info.Owner.Idp + fileInfo.UserID = hex.EncodeToString([]byte(statRes.Info.Owner.OpaqueId + "@" + statRes.Info.Owner.Idp)) } else { - fileInfo.UserID = wopiContext.User.Id.OpaqueId + "@" + wopiContext.User.Id.Idp + fileInfo.UserID = hex.EncodeToString([]byte(wopiContext.User.Id.OpaqueId + "@" + wopiContext.User.Id.Idp)) } if wopiContext.User.Opaque != nil { @@ -103,12 +106,12 @@ func CheckFileInfo(app *DemoApp, w http.ResponseWriter, r *http.Request) { } if !isPublicShare { fileInfo.UserFriendlyName = wopiContext.User.Username - fileInfo.UserID = wopiContext.User.Id.OpaqueId + "@" + wopiContext.User.Id.Idp + fileInfo.UserID = hex.EncodeToString([]byte(wopiContext.User.Id.OpaqueId + "@" + wopiContext.User.Id.Idp)) } } if wopiContext.User == nil || isPublicShare { randomID, _ := uuid.NewUUID() - fileInfo.UserID = "guest-" + randomID.String() + fileInfo.UserID = hex.EncodeToString([]byte("guest-" + randomID.String())) fileInfo.UserFriendlyName = "Guest " + randomID.String() fileInfo.IsAnonymousUser = true } @@ -132,6 +135,16 @@ func CheckFileInfo(app *DemoApp, w http.ResponseWriter, r *http.Request) { Msg("CheckFileInfo: success") w.Header().Set("Content-Type", "application/json") - w.Write(jsonFileInfo) w.WriteHeader(http.StatusOK) + bytes, err := w.Write(jsonFileInfo) + if err != nil { + app.Logger.Error(). + Err(err). + Str("FileReference", wopiContext.FileReference.String()). + Str("ViewMode", wopiContext.ViewMode.String()). + Str("Requester", wopiContext.User.GetId().String()). + Int("TotalBytes", len(jsonFileInfo)). + Int("WrittenBytes", bytes). + Msg("CheckFileInfo: failed to write contents in the HTTP response") + } } From beaf5abc5b8653c0c49344dd86cd68d418889b43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Tue, 12 Mar 2024 18:22:14 +0100 Subject: [PATCH 09/29] ci: remove env variable --- .drone.star | 1 - 1 file changed, 1 deletion(-) diff --git a/.drone.star b/.drone.star index 640e92c05d..f5512c0aac 100644 --- a/.drone.star +++ b/.drone.star @@ -2037,7 +2037,6 @@ def ocisServer(storage, accounts_hash_difficulty = 4, volumes = [], depends_on = if deploy_type == "wopi_validator": environment["GATEWAY_GRPC_ADDR"] = "0.0.0.0:9142" # make gateway available to wopi server - environment["OCIS_ASYNC_UPLOADS"] = False # disable async uploads because it could interfere with the wopi validator environment["APP_PROVIDER_EXTERNAL_ADDR"] = "com.owncloud.api.app-provider" environment["APP_PROVIDER_DRIVER"] = "wopi" environment["APP_PROVIDER_WOPI_APP_NAME"] = "FakeOffice" From 8fcc626d7bba508415f5ba3e43082f1f20eff689 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Wed, 13 Mar 2024 14:52:33 +0100 Subject: [PATCH 10/29] ci: adjust code for sonarcloud issues --- services/collaboration/pkg/internal/app/claims.go | 2 +- services/collaboration/pkg/internal/app/wopidiscovery.go | 4 ++-- services/collaboration/pkg/internal/helpers/download.go | 4 +++- services/collaboration/pkg/internal/helpers/upload.go | 2 +- services/collaboration/pkg/service/grpc/v0/service.go | 6 +++--- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/services/collaboration/pkg/internal/app/claims.go b/services/collaboration/pkg/internal/app/claims.go index 6715996261..e310c7e3b7 100644 --- a/services/collaboration/pkg/internal/app/claims.go +++ b/services/collaboration/pkg/internal/app/claims.go @@ -4,5 +4,5 @@ import "github.com/golang-jwt/jwt/v4" type Claims struct { WopiContext WopiContext `json:"WopiContext"` - jwt.StandardClaims + jwt.RegisteredClaims } diff --git a/services/collaboration/pkg/internal/app/wopidiscovery.go b/services/collaboration/pkg/internal/app/wopidiscovery.go index 29a2e6cd7a..1e8ae29bef 100644 --- a/services/collaboration/pkg/internal/app/wopidiscovery.go +++ b/services/collaboration/pkg/internal/app/wopidiscovery.go @@ -45,6 +45,8 @@ func getAppURLs(wopiAppUrl string, insecure bool, logger log.Logger) (map[string return nil, err } + defer httpResp.Body.Close() + if httpResp.StatusCode != http.StatusOK { logger.Error(). Str("WopiAppUrl", wopiAppUrl). @@ -53,8 +55,6 @@ func getAppURLs(wopiAppUrl string, insecure bool, logger log.Logger) (map[string return nil, errors.New("status code was not 200") } - defer httpResp.Body.Close() - var appURLs map[string]map[string]string appURLs, err = parseWopiDiscovery(httpResp.Body) diff --git a/services/collaboration/pkg/internal/helpers/download.go b/services/collaboration/pkg/internal/helpers/download.go index ee0568f598..acba7a3753 100644 --- a/services/collaboration/pkg/internal/helpers/download.go +++ b/services/collaboration/pkg/internal/helpers/download.go @@ -92,7 +92,9 @@ func DownloadFile( // TODO: the access token shouldn't be needed httpReq.Header.Add("X-Access-Token", token) - httpResp, err := httpClient.Do(httpReq) + // TODO: this needs a refactor to comply with the "bodyclose" linter + // response body is closed in the caller method for now + httpResp, err := httpClient.Do(httpReq) //nolint:bodyclose if err != nil { logger.Error(). Err(err). diff --git a/services/collaboration/pkg/internal/helpers/upload.go b/services/collaboration/pkg/internal/helpers/upload.go index 95e0da2214..0a40220a71 100644 --- a/services/collaboration/pkg/internal/helpers/upload.go +++ b/services/collaboration/pkg/internal/helpers/upload.go @@ -18,7 +18,7 @@ import ( func UploadFile( ctx context.Context, - content io.ReadCloser, + content io.Reader, // content won't be closed inside the method contentLength int64, ref *providerv1beta1.Reference, gwc gatewayv1beta1.GatewayAPIClient, diff --git a/services/collaboration/pkg/service/grpc/v0/service.go b/services/collaboration/pkg/service/grpc/v0/service.go index 5c1fe9ad23..90b2da7375 100644 --- a/services/collaboration/pkg/service/grpc/v0/service.go +++ b/services/collaboration/pkg/service/grpc/v0/service.go @@ -170,7 +170,7 @@ func (s *Service) OpenInApp( ViewAppUrl: viewAppURL, } - cs3Claims := &jwt.StandardClaims{} + cs3Claims := &jwt.RegisteredClaims{} cs3JWTparser := jwt.Parser{} _, _, err = cs3JWTparser.ParseUnverified(req.AccessToken, cs3Claims) if err != nil { @@ -185,7 +185,7 @@ func (s *Service) OpenInApp( claims := &app.Claims{ WopiContext: wopiContext, - StandardClaims: jwt.StandardClaims{ + RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: cs3Claims.ExpiresAt, }, } @@ -220,7 +220,7 @@ func (s *Service) OpenInApp( // these parameters will be passed to the web server by the app provider application "access_token": accessToken, // milliseconds since Jan 1, 1970 UTC as required in https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/concepts#access_token_ttl - "access_token_ttl": strconv.FormatInt(claims.ExpiresAt*1000, 10), + "access_token_ttl": strconv.FormatInt(claims.ExpiresAt.UnixMilli(), 10), }, }, }, nil From 7ebccea19cec127f382ba1fcbaba974e9694f112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Wed, 13 Mar 2024 15:33:28 +0100 Subject: [PATCH 11/29] ci: run wopi validator against both cs3 and builtin servers --- .drone.star | 64 ++++++++++++++++++++---------- tests/config/drone/wopiserver.conf | 2 +- 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/.drone.star b/.drone.star index f5512c0aac..832ff8414d 100644 --- a/.drone.star +++ b/.drone.star @@ -344,7 +344,8 @@ def testPipelines(ctx): if "skip" not in config["cs3ApiTests"] or not config["cs3ApiTests"]["skip"]: pipelines.append(cs3ApiTests(ctx, "ocis", "default")) if "skip" not in config["wopiValidatorTests"] or not config["wopiValidatorTests"]["skip"]: - pipelines.append(wopiValidatorTests(ctx, "ocis", "default")) + pipelines.append(wopiValidatorTests(ctx, "ocis", "builtin", "default")) + pipelines.append(wopiValidatorTests(ctx, "ocis", "cs3", "default")) pipelines += localApiTestPipeline(ctx) @@ -895,7 +896,7 @@ def cs3ApiTests(ctx, storage, accounts_hash_difficulty = 4): }, } -def wopiValidatorTests(ctx, storage, accounts_hash_difficulty = 4): +def wopiValidatorTests(ctx, storage, wopiServerType, accounts_hash_difficulty = 4): testgroups = [ "BaseWopiViewing", "CheckFileInfoSchema", @@ -911,6 +912,43 @@ def wopiValidatorTests(ctx, storage, accounts_hash_difficulty = 4): ocis_bin = "ocis/bin/ocis" validatorTests = [] + wopiServer = [] + if wopiServerType == "cs3": + wopiServer = [ + { + "name": "wopiserver", + "image": "cs3org/wopiserver:v10.3.0", + "detach": True, + "commands": [ + "cp %s/tests/config/drone/wopiserver.conf /etc/wopi/wopiserver.conf" % (dirs["base"]), + "echo 123 > /etc/wopi/wopisecret", + "/app/wopiserver.py", + ], + }, + ] + else: + wopiServer = [ + { + "name": "wopiserver", + "image": OC_CI_GOLANG, + "detach": True, + "environment": { + "MICRO_REGISTRY": "nats-js-kv", + "MICRO_REGISTRY_ADDRESS": "ocis-server:9233", + "COLLABORATION_LOG_LEVEL": "debug", + "COLLABORATION_APP_NAME": "FakeOffice", + "COLLABORATION_HTTP_ADDR": "wopiserver:9300", + "COLLABORATION_HTTP_SCHEME": "http", + "COLLABORATION_WOPIAPP_ADDR": "http://fakeoffice:8080", + "COLLABORATION_WOPIAPP_INSECURE": "true", + "COLLABORATION_CS3API_DATAGATEWAY_INSECURE": "true", + }, + "commands": [ + "%s collaboration server" % ocis_bin, + ], + }, + ] + for testgroup in testgroups: validatorTests.append({ "name": "wopiValidatorTests-%s-%s" % (storage, testgroup), @@ -930,7 +968,7 @@ def wopiValidatorTests(ctx, storage, accounts_hash_difficulty = 4): return { "kind": "pipeline", "type": "docker", - "name": "wopiValidatorTests-%s" % (storage), + "name": "wopiValidatorTests-%s-%s" % (wopiServerType, storage), "platform": { "os": "linux", "arch": "amd64", @@ -956,26 +994,8 @@ def wopiValidatorTests(ctx, storage, accounts_hash_difficulty = 4): }, ] + ocisServer(storage, accounts_hash_difficulty, [], [], "wopi_validator") + + wopiServer + [ - { - "name": "wopiserver", - "image": OC_CI_GOLANG, - "detach": True, - "environment": { - "MICRO_REGISTRY": "nats-js-kv", - "MICRO_REGISTRY_ADDRESS": "ocis-server:9233", - "COLLABORATION_LOG_LEVEL": "debug", - "COLLABORATION_APP_NAME": "FakeOffice", - "COLLABORATION_HTTP_ADDR": "wopiserver:9300", - "COLLABORATION_HTTP_SCHEME": "http", - "COLLABORATION_WOPIAPP_ADDR": "http://fakeoffice:8080", - "COLLABORATION_WOPIAPP_INSECURE": "true", - "COLLABORATION_CS3API_DATAGATEWAY_INSECURE": "true", - }, - "commands": [ - "%s collaboration server" % ocis_bin, - ], - }, { "name": "wait-for-wopi-server", "image": OC_CI_WAIT_FOR, diff --git a/tests/config/drone/wopiserver.conf b/tests/config/drone/wopiserver.conf index af62204157..ce53eb0573 100644 --- a/tests/config/drone/wopiserver.conf +++ b/tests/config/drone/wopiserver.conf @@ -13,7 +13,7 @@ storagetype = cs3 # Port where to listen for WOPI requests -port = 8880 +port = 9300 # Logging level. Debug enables the Flask debug mode as well. # Valid values are: Debug, Info, Warning, Error. From 05f8b7a9dad3bf90f4c13175916ac5967496b095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Thu, 14 Mar 2024 11:07:54 +0100 Subject: [PATCH 12/29] ci: disable depguard linter for now --- .golangci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.golangci.yml b/.golangci.yml index 1479d22c23..7279fa5472 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -41,6 +41,7 @@ linters: - stylecheck - gocognit - nestif # each 10-50 issues in codebase + - depguard # disabled for now. Needs configuration linters-settings: gocyclo: From 8f4806f1d46acd52a1d5ab925c5511cf779d7bf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Thu, 14 Mar 2024 12:06:31 +0100 Subject: [PATCH 13/29] refactor: replace uniuri with custom code --- go.sum | 2 - ocis-pkg/generators/password.go | 30 ++++- .../pkg/config/defaults/defaultconfig.go | 5 +- vendor/github.com/dchest/uniuri/COPYING | 121 ------------------ vendor/github.com/dchest/uniuri/README.md | 95 -------------- vendor/github.com/dchest/uniuri/uniuri.go | 120 ----------------- vendor/modules.txt | 3 - 7 files changed, 32 insertions(+), 344 deletions(-) delete mode 100644 vendor/github.com/dchest/uniuri/COPYING delete mode 100644 vendor/github.com/dchest/uniuri/README.md delete mode 100644 vendor/github.com/dchest/uniuri/uniuri.go diff --git a/go.sum b/go.sum index 919a250a01..7bf4922cf0 100644 --- a/go.sum +++ b/go.sum @@ -1031,8 +1031,6 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g= -github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY= github.com/deckarep/golang-set v1.8.0 h1:sk9/l/KqpunDwP7pSjUg0keiOOLEnOBHzykLrsPppp4= github.com/deckarep/golang-set v1.8.0/go.mod h1:5nI87KwE7wgsBU1F4GKAw2Qod7p5kyS383rP6+o6qqo= github.com/deepmap/oapi-codegen v1.3.11/go.mod h1:suMvK7+rKlx3+tpa8ByptmvoXbAV70wERKTOGH3hLp0= diff --git a/ocis-pkg/generators/password.go b/ocis-pkg/generators/password.go index 3c2d571fa5..7083cc1805 100644 --- a/ocis-pkg/generators/password.go +++ b/ocis-pkg/generators/password.go @@ -5,8 +5,36 @@ import ( "math/big" ) +const ( + // PasswordChars contains alphanumeric chars (0-9, A-Z, a-z), plus "-=+!@#$%^&*." + PasswordChars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-=+!@#$%^&*." + // AlphaNumChars contains alphanumeric chars (0-9, A-Z, a-z) + AlphaNumChars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +) + +// GenerateRandomPassword generates a random password with the given length. +// The password will contain chars picked from the `PasswordChars` constant. +// If an error happens, the string will be empty and the error will be non-nil. +// +// This is equivalent to `GenerateRandomString(PasswordChars, length)` func GenerateRandomPassword(length int) (string, error) { - const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-=+!@#$%^&*." + return generateString(PasswordChars, length) +} + +// GenerateRandomString generates a random string with the given length +// based on the chars provided. You can use `PasswordChars` or `AlphaNumChars` +// constants, or even any other string. +// +// Chars from the provided string will be picked uniformly. The provided +// constants have unique chars, which means that all the chars will have the +// same probability of being picked. +// You can use your own strings to change that probability. For example, using +// "AAAB" you'll have a 75% of probability of getting "A" and 25% of "B" +func GenerateRandomString(chars string, length int) (string, error) { + return generateString(chars, length) +} + +func generateString(chars string, length int) (string, error) { ret := make([]byte, length) for i := 0; i < length; i++ { num, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars)))) diff --git a/services/collaboration/pkg/config/defaults/defaultconfig.go b/services/collaboration/pkg/config/defaults/defaultconfig.go index 05cfe38933..872b05e5af 100644 --- a/services/collaboration/pkg/config/defaults/defaultconfig.go +++ b/services/collaboration/pkg/config/defaults/defaultconfig.go @@ -1,7 +1,7 @@ package defaults import ( - "github.com/dchest/uniuri" + "github.com/owncloud/ocis/v2/ocis-pkg/generators" "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" ) @@ -15,6 +15,7 @@ func FullDefaultConfig() *config.Config { // DefaultConfig returns a basic default configuration func DefaultConfig() *config.Config { + secret, _ := generators.GenerateRandomString(generators.AlphaNumChars, 32) // anything to do with the error? return &config.Config{ Service: config.Service{ Name: "collaboration", @@ -25,7 +26,7 @@ func DefaultConfig() *config.Config { Icon: "image-edit", LockName: "com.github.owncloud.collaboration", }, - JWTSecret: uniuri.NewLen(32), + JWTSecret: secret, GRPC: config.GRPC{ Addr: "0.0.0.0:9301", Namespace: "com.owncloud.collaboration", diff --git a/vendor/github.com/dchest/uniuri/COPYING b/vendor/github.com/dchest/uniuri/COPYING deleted file mode 100644 index 0e259d42c9..0000000000 --- a/vendor/github.com/dchest/uniuri/COPYING +++ /dev/null @@ -1,121 +0,0 @@ -Creative Commons Legal Code - -CC0 1.0 Universal - - CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE - LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN - ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS - INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES - REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS - PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM - THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED - HEREUNDER. - -Statement of Purpose - -The laws of most jurisdictions throughout the world automatically confer -exclusive Copyright and Related Rights (defined below) upon the creator -and subsequent owner(s) (each and all, an "owner") of an original work of -authorship and/or a database (each, a "Work"). - -Certain owners wish to permanently relinquish those rights to a Work for -the purpose of contributing to a commons of creative, cultural and -scientific works ("Commons") that the public can reliably and without fear -of later claims of infringement build upon, modify, incorporate in other -works, reuse and redistribute as freely as possible in any form whatsoever -and for any purposes, including without limitation commercial purposes. -These owners may contribute to the Commons to promote the ideal of a free -culture and the further production of creative, cultural and scientific -works, or to gain reputation or greater distribution for their Work in -part through the use and efforts of others. - -For these and/or other purposes and motivations, and without any -expectation of additional consideration or compensation, the person -associating CC0 with a Work (the "Affirmer"), to the extent that he or she -is an owner of Copyright and Related Rights in the Work, voluntarily -elects to apply CC0 to the Work and publicly distribute the Work under its -terms, with knowledge of his or her Copyright and Related Rights in the -Work and the meaning and intended legal effect of CC0 on those rights. - -1. Copyright and Related Rights. A Work made available under CC0 may be -protected by copyright and related or neighboring rights ("Copyright and -Related Rights"). Copyright and Related Rights include, but are not -limited to, the following: - - i. the right to reproduce, adapt, distribute, perform, display, - communicate, and translate a Work; - ii. moral rights retained by the original author(s) and/or performer(s); -iii. publicity and privacy rights pertaining to a person's image or - likeness depicted in a Work; - iv. rights protecting against unfair competition in regards to a Work, - subject to the limitations in paragraph 4(a), below; - v. rights protecting the extraction, dissemination, use and reuse of data - in a Work; - vi. database rights (such as those arising under Directive 96/9/EC of the - European Parliament and of the Council of 11 March 1996 on the legal - protection of databases, and under any national implementation - thereof, including any amended or successor version of such - directive); and -vii. other similar, equivalent or corresponding rights throughout the - world based on applicable law or treaty, and any national - implementations thereof. - -2. Waiver. To the greatest extent permitted by, but not in contravention -of, applicable law, Affirmer hereby overtly, fully, permanently, -irrevocably and unconditionally waives, abandons, and surrenders all of -Affirmer's Copyright and Related Rights and associated claims and causes -of action, whether now known or unknown (including existing as well as -future claims and causes of action), in the Work (i) in all territories -worldwide, (ii) for the maximum duration provided by applicable law or -treaty (including future time extensions), (iii) in any current or future -medium and for any number of copies, and (iv) for any purpose whatsoever, -including without limitation commercial, advertising or promotional -purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each -member of the public at large and to the detriment of Affirmer's heirs and -successors, fully intending that such Waiver shall not be subject to -revocation, rescission, cancellation, termination, or any other legal or -equitable action to disrupt the quiet enjoyment of the Work by the public -as contemplated by Affirmer's express Statement of Purpose. - -3. Public License Fallback. Should any part of the Waiver for any reason -be judged legally invalid or ineffective under applicable law, then the -Waiver shall be preserved to the maximum extent permitted taking into -account Affirmer's express Statement of Purpose. In addition, to the -extent the Waiver is so judged Affirmer hereby grants to each affected -person a royalty-free, non transferable, non sublicensable, non exclusive, -irrevocable and unconditional license to exercise Affirmer's Copyright and -Related Rights in the Work (i) in all territories worldwide, (ii) for the -maximum duration provided by applicable law or treaty (including future -time extensions), (iii) in any current or future medium and for any number -of copies, and (iv) for any purpose whatsoever, including without -limitation commercial, advertising or promotional purposes (the -"License"). The License shall be deemed effective as of the date CC0 was -applied by Affirmer to the Work. Should any part of the License for any -reason be judged legally invalid or ineffective under applicable law, such -partial invalidity or ineffectiveness shall not invalidate the remainder -of the License, and in such case Affirmer hereby affirms that he or she -will not (i) exercise any of his or her remaining Copyright and Related -Rights in the Work or (ii) assert any associated claims and causes of -action with respect to the Work, in either case contrary to Affirmer's -express Statement of Purpose. - -4. Limitations and Disclaimers. - - a. No trademark or patent rights held by Affirmer are waived, abandoned, - surrendered, licensed or otherwise affected by this document. - b. Affirmer offers the Work as-is and makes no representations or - warranties of any kind concerning the Work, express, implied, - statutory or otherwise, including without limitation warranties of - title, merchantability, fitness for a particular purpose, non - infringement, or the absence of latent or other defects, accuracy, or - the present or absence of errors, whether or not discoverable, all to - the greatest extent permissible under applicable law. - c. Affirmer disclaims responsibility for clearing rights of other persons - that may apply to the Work or any use thereof, including without - limitation any person's Copyright and Related Rights in the Work. - Further, Affirmer disclaims responsibility for obtaining any necessary - consents, permissions or other rights required for any use of the - Work. - d. Affirmer understands and acknowledges that Creative Commons is not a - party to this document and has no duty or obligation with respect to - this CC0 or use of the Work. diff --git a/vendor/github.com/dchest/uniuri/README.md b/vendor/github.com/dchest/uniuri/README.md deleted file mode 100644 index 6240bc9bae..0000000000 --- a/vendor/github.com/dchest/uniuri/README.md +++ /dev/null @@ -1,95 +0,0 @@ -Package uniuri -===================== - -```go -import "github.com/dchest/uniuri" -``` - -Package uniuri generates random strings good for use in URIs to identify -unique objects. - -Example usage: - -```go -s := uniuri.New() // s is now "apHCJBl7L1OmC57n" -``` - -A standard string created by New() is 16 bytes in length and consists of -Latin upper and lowercase letters, and numbers (from the set of 62 allowed -characters), which means that it has ~95 bits of entropy. To get more -entropy, you can use NewLen(UUIDLen), which returns 20-byte string, giving -~119 bits of entropy, or any other desired length. - -Functions read from crypto/rand random source, and panic if they fail to -read from it. - - -Constants ---------- - -```go -const ( - // StdLen is a standard length of uniuri string to achive ~95 bits of entropy. - StdLen = 16 - // UUIDLen is a length of uniuri string to achive ~119 bits of entropy, closest - // to what can be losslessly converted to UUIDv4 (122 bits). - UUIDLen = 20 -) - -``` - - - -Variables ---------- - -```go -var StdChars = []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") -``` - - -StdChars is a set of standard characters allowed in uniuri string. - - -Functions ---------- - -### func New - -```go -func New() string -``` - -New returns a new random string of the standard length, consisting of -standard characters. - -### func NewLen - -```go -func NewLen(length int) string -``` - -NewLen returns a new random string of the provided length, consisting of -standard characters. - -### func NewLenChars - -```go -func NewLenChars(length int, chars []byte) string -``` - -NewLenChars returns a new random string of the provided length, consisting -of the provided byte slice of allowed characters (maximum 256). - - - -Public domain dedication ------------------------- - -Written in 2011-2014 by Dmitry Chestnykh - -The author(s) have dedicated all copyright and related and -neighboring rights to this software to the public domain -worldwide. Distributed without any warranty. -http://creativecommons.org/publicdomain/zero/1.0/ - diff --git a/vendor/github.com/dchest/uniuri/uniuri.go b/vendor/github.com/dchest/uniuri/uniuri.go deleted file mode 100644 index dd96592120..0000000000 --- a/vendor/github.com/dchest/uniuri/uniuri.go +++ /dev/null @@ -1,120 +0,0 @@ -// Written in 2011-2014 by Dmitry Chestnykh -// -// The author(s) have dedicated all copyright and related and -// neighboring rights to this software to the public domain -// worldwide. Distributed without any warranty. -// http://creativecommons.org/publicdomain/zero/1.0/ - -// Package uniuri generates random strings good for use in URIs to identify -// unique objects. -// -// Example usage: -// -// s := uniuri.New() // s is now "apHCJBl7L1OmC57n" -// -// A standard string created by New() is 16 bytes in length and consists of -// Latin upper and lowercase letters, and numbers (from the set of 62 allowed -// characters), which means that it has ~95 bits of entropy. To get more -// entropy, you can use NewLen(UUIDLen), which returns 20-byte string, giving -// ~119 bits of entropy, or any other desired length. -// -// Functions read from crypto/rand random source, and panic if they fail to -// read from it. -package uniuri - -import ( - "crypto/rand" - "math" -) - -const ( - // StdLen is a standard length of uniuri string to achive ~95 bits of entropy. - StdLen = 16 - // UUIDLen is a length of uniuri string to achive ~119 bits of entropy, closest - // to what can be losslessly converted to UUIDv4 (122 bits). - UUIDLen = 20 -) - -// StdChars is a set of standard characters allowed in uniuri string. -var StdChars = []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") - -// New returns a new random string of the standard length, consisting of -// standard characters. -func New() string { - return NewLenChars(StdLen, StdChars) -} - -// NewLen returns a new random string of the provided length, consisting of -// standard characters. -func NewLen(length int) string { - return NewLenChars(length, StdChars) -} - -// maxBufLen is the maximum length of a temporary buffer for random bytes. -const maxBufLen = 2048 - -// minRegenBufLen is the minimum length of temporary buffer for random bytes -// to fill after the first rand.Read request didn't produce the full result. -// If the initial buffer is smaller, this value is ignored. -// Rationale: for performance, assume it's pointless to request fewer bytes from rand.Read. -const minRegenBufLen = 16 - -// estimatedBufLen returns the estimated number of random bytes to request -// given that byte values greater than maxByte will be rejected. -func estimatedBufLen(need, maxByte int) int { - return int(math.Ceil(float64(need) * (255 / float64(maxByte)))) -} - -// NewLenCharsBytes returns a new random byte slice of the provided length, consisting -// of the provided byte slice of allowed characters (maximum 256). -func NewLenCharsBytes(length int, chars []byte) []byte { - if length == 0 { - return nil - } - clen := len(chars) - if clen < 2 || clen > 256 { - panic("uniuri: wrong charset length for NewLenChars") - } - maxrb := 255 - (256 % clen) - buflen := estimatedBufLen(length, maxrb) - if buflen < length { - buflen = length - } - if buflen > maxBufLen { - buflen = maxBufLen - } - buf := make([]byte, buflen) // storage for random bytes - out := make([]byte, length) // storage for result - i := 0 - for { - if _, err := rand.Read(buf[:buflen]); err != nil { - panic("uniuri: error reading random bytes: " + err.Error()) - } - for _, rb := range buf[:buflen] { - c := int(rb) - if c > maxrb { - // Skip this number to avoid modulo bias. - continue - } - out[i] = chars[c%clen] - i++ - if i == length { - return out - } - } - // Adjust new requested length, but no smaller than minRegenBufLen. - buflen = estimatedBufLen(length-i, maxrb) - if buflen < minRegenBufLen && minRegenBufLen < cap(buf) { - buflen = minRegenBufLen - } - if buflen > maxBufLen { - buflen = maxBufLen - } - } -} - -// NewLenChars returns a new random string of the provided length, consisting -// of the provided byte slice of allowed characters (maximum 256). -func NewLenChars(length int, chars []byte) string { - return string(NewLenCharsBytes(length, chars)) -} diff --git a/vendor/modules.txt b/vendor/modules.txt index 235ef5a61a..06e891fc77 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -737,9 +737,6 @@ github.com/cyphar/filepath-securejoin # github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc ## explicit github.com/davecgh/go-spew/spew -# github.com/dchest/uniuri v1.2.0 -## explicit; go 1.19 -github.com/dchest/uniuri # github.com/deckarep/golang-set v1.8.0 ## explicit; go 1.17 github.com/deckarep/golang-set From aa58caef63d0953c5d87b6dd5a2f1505e0564d79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Wed, 20 Mar 2024 10:14:32 +0100 Subject: [PATCH 14/29] refactor: move wopi operation into connector and change logging --- services/collaboration/pkg/command/server.go | 13 +- .../collaboration/pkg/connector/connector.go | 37 ++ .../pkg/connector/contentconnector.go | 312 +++++++++++ .../pkg/connector/fileconnector.go | 480 ++++++++++++++++ .../pkg/connector/httpadapter.go | 178 ++++++ .../collaboration/pkg/internal/app/app.go | 4 + .../collaboration/pkg/internal/app/headers.go | 6 - .../pkg/internal/app/wopicontext.go | 18 +- .../pkg/internal/app/wopifilecontents.go | 169 ------ .../pkg/internal/app/wopiinfo.go | 150 ----- .../pkg/internal/app/wopilocking.go | 524 ------------------ .../pkg/internal/helpers/download.go | 109 ---- .../pkg/internal/helpers/upload.go | 169 ------ .../pkg/internal/logging/logging.go | 17 - .../collaboration/pkg/server/http/option.go | 8 +- .../collaboration/pkg/server/http/server.go | 37 +- 16 files changed, 1066 insertions(+), 1165 deletions(-) create mode 100644 services/collaboration/pkg/connector/connector.go create mode 100644 services/collaboration/pkg/connector/contentconnector.go create mode 100644 services/collaboration/pkg/connector/fileconnector.go create mode 100644 services/collaboration/pkg/connector/httpadapter.go delete mode 100644 services/collaboration/pkg/internal/app/headers.go delete mode 100644 services/collaboration/pkg/internal/app/wopifilecontents.go delete mode 100644 services/collaboration/pkg/internal/app/wopiinfo.go delete mode 100644 services/collaboration/pkg/internal/app/wopilocking.go delete mode 100644 services/collaboration/pkg/internal/helpers/download.go delete mode 100644 services/collaboration/pkg/internal/helpers/upload.go delete mode 100644 services/collaboration/pkg/internal/logging/logging.go diff --git a/services/collaboration/pkg/command/server.go b/services/collaboration/pkg/command/server.go index d49183b5bb..a7eba82730 100644 --- a/services/collaboration/pkg/command/server.go +++ b/services/collaboration/pkg/command/server.go @@ -7,11 +7,12 @@ import ( "github.com/oklog/run" "github.com/owncloud/ocis/v2/ocis-pkg/config/configlog" + "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/ocis-pkg/tracing" "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" "github.com/owncloud/ocis/v2/services/collaboration/pkg/config/parser" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/connector" "github.com/owncloud/ocis/v2/services/collaboration/pkg/cs3wopiserver" - "github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/logging" "github.com/owncloud/ocis/v2/services/collaboration/pkg/server/grpc" "github.com/owncloud/ocis/v2/services/collaboration/pkg/server/http" "github.com/urfave/cli/v2" @@ -27,7 +28,13 @@ func Server(cfg *config.Config) *cli.Command { return configlog.ReturnFatal(parser.ParseConfig(cfg)) }, Action: func(c *cli.Context) error { - logger := logging.Configure(cfg.Service.Name, cfg.Log) + logger := log.NewLogger( + log.Name(cfg.Service.Name), + log.Level(cfg.Log.Level), + log.Pretty(cfg.Log.Pretty), + log.Color(cfg.Log.Color), + log.File(cfg.Log.File), + ) traceProvider, err := tracing.GetServiceTraceProvider(cfg.Tracing, cfg.Service.Name) if err != nil { return err @@ -92,7 +99,7 @@ func Server(cfg *config.Config) *cli.Command { }) */ server, err := http.Server( - http.App(app), + http.Adapter(connector.NewHttpAdapter(app.GetGwc(), cfg)), http.Logger(logger), http.Config(cfg), http.Context(ctx), diff --git a/services/collaboration/pkg/connector/connector.go b/services/collaboration/pkg/connector/connector.go new file mode 100644 index 0000000000..c23c2a4f77 --- /dev/null +++ b/services/collaboration/pkg/connector/connector.go @@ -0,0 +1,37 @@ +package connector + +type ConnectorError struct { + HttpCodeOut int + Msg string +} + +func (e *ConnectorError) Error() string { + return e.Msg +} + +func NewConnectorError(code int, msg string) *ConnectorError { + return &ConnectorError{ + HttpCodeOut: code, + Msg: msg, + } +} + +type Connector struct { + fileConnector *FileConnector + contentConnector *ContentConnector +} + +func NewConnector(fc *FileConnector, cc *ContentConnector) *Connector { + return &Connector{ + fileConnector: fc, + contentConnector: cc, + } +} + +func (c *Connector) GetFileConnector() *FileConnector { + return c.fileConnector +} + +func (c *Connector) GetContentConnector() *ContentConnector { + return c.contentConnector +} diff --git a/services/collaboration/pkg/connector/contentconnector.go b/services/collaboration/pkg/connector/contentconnector.go new file mode 100644 index 0000000000..014cdac46a --- /dev/null +++ b/services/collaboration/pkg/connector/contentconnector.go @@ -0,0 +1,312 @@ +package connector + +import ( + "bytes" + "context" + "crypto/tls" + "io" + "net/http" + "strconv" + "time" + + gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/app" + "github.com/rs/zerolog" +) + +type ContentConnector struct { + gwc gatewayv1beta1.GatewayAPIClient + cfg *config.Config +} + +func NewContentConnector(gwc gatewayv1beta1.GatewayAPIClient, cfg *config.Config) *ContentConnector { + return &ContentConnector{ + gwc: gwc, + cfg: cfg, + } +} + +// GetFile downloads the file from the storage +// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/getfile +func (c *ContentConnector) GetFile(ctx context.Context, writer io.Writer) error { + wopiContext, err := app.WopiContextFromCtx(ctx) + if err != nil { + return err + } + + logger := zerolog.Ctx(ctx) + + // Initiate download request + req := &providerv1beta1.InitiateFileDownloadRequest{ + Ref: &wopiContext.FileReference, + } + + resp, err := c.gwc.InitiateFileDownload(ctx, req) + if err != nil { + logger.Error().Err(err).Msg("GetFile: InitiateFileDownload failed") + return err + } + + if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("GetFile: InitiateFileDownload failed with wrong status") + return NewConnectorError(500, resp.Status.GetCode().String()+" "+resp.Status.GetMessage()) + } + + // Figure out the download endpoint and download token + downloadEndpoint := "" + downloadToken := "" + hasDownloadToken := false + + for _, proto := range resp.Protocols { + if proto.Protocol == "simple" || proto.Protocol == "spaces" { + downloadEndpoint = proto.DownloadEndpoint + downloadToken = proto.Token + hasDownloadToken = proto.Token != "" + break + } + } + + if downloadEndpoint == "" { + logger.Error(). + Str("Endpoint", downloadEndpoint). + Bool("HasDownloadToken", hasDownloadToken). + Msg("GetFile: Download endpoint or token is missing") + return NewConnectorError(500, "GetFile: Download endpoint is missing") + } + + httpClient := http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: c.cfg.CS3Api.DataGateway.Insecure, + }, + }, + } + + // Prepare the request to download the file + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadEndpoint, bytes.NewReader([]byte(""))) + if err != nil { + logger.Error(). + Err(err). + Str("Endpoint", downloadEndpoint). + Bool("HasDownloadToken", hasDownloadToken). + Msg("GetFile: Could not create the request to the endpoint") + return err + } + if downloadToken != "" { + // public link downloads have the token in the download endpoint + httpReq.Header.Add("X-Reva-Transfer", downloadToken) + } + // TODO: the access token shouldn't be needed + httpReq.Header.Add("X-Access-Token", wopiContext.AccessToken) + + httpResp, err := httpClient.Do(httpReq) + if err != nil { + logger.Error(). + Err(err). + Str("Endpoint", downloadEndpoint). + Bool("HasDownloadToken", hasDownloadToken). + Msg("GetFile: Get request to the download endpoint failed") + return err + } + + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusOK { + logger.Error(). + Err(err). + Int("HttpCode", httpResp.StatusCode). + Msg("GetFile: downloading the file failed") + return NewConnectorError(500, "GetFile: Downloading the file failed") + } + + // Copy the download into the writer + _, err = io.Copy(writer, httpResp.Body) + if err != nil { + logger.Error().Msg("GetFile: copying the file content to the response body failed") + return err + } + + logger.Debug().Msg("GetFile: success") + return nil +} + +// PutFile uploads the file to the storage +// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/putfile +func (c *ContentConnector) PutFile(ctx context.Context, stream io.Reader, streamLength int64, lockID string) (string, error) { + wopiContext, err := app.WopiContextFromCtx(ctx) + if err != nil { + return "", err + } + + logger := zerolog.Ctx(ctx).With(). + Str("RequestedLockID", lockID). + Int64("UploadLength", streamLength). + Logger() + + // We need a stat call on the target file in order to get both the lock + // (if any) and the current size of the file + statRes, err := c.gwc.Stat(ctx, &providerv1beta1.StatRequest{ + Ref: &wopiContext.FileReference, + }) + if err != nil { + logger.Error().Err(err).Msg("PutFile: stat failed") + return "", err + } + + if statRes.Status.Code != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", statRes.Status.Code.String()). + Str("StatusMsg", statRes.Status.Message). + Msg("PutFile: stat failed with unexpected status") + return "", NewConnectorError(500, statRes.Status.GetCode().String()+" "+statRes.Status.GetMessage()) + } + + // If there is a lock and it mismatches, return 409 + if statRes.Info.Lock != nil && statRes.Info.Lock.LockId != lockID { + logger.Error(). + Str("LockID", statRes.Info.Lock.LockId). + Msg("PutFile: wrong lock") + // onlyoffice says it's required to send the current lockId, MS doesn't say anything + return statRes.Info.Lock.LockId, NewConnectorError(409, "Wrong lock") + } + + // only unlocked uploads can go through if the target file is empty, + // otherwise the X-WOPI-Lock header is required even if there is no lock on the file + // This is part of the onlyoffice documentation (https://api.onlyoffice.com/editors/wopi/restapi/putfile) + // Wopivalidator fails some tests if we don't also check for the X-WOPI-Lock header. + if lockID == "" && statRes.Info.Lock == nil && statRes.Info.Size > 0 { + logger.Error().Msg("PutFile: file must be locked first") + // onlyoffice says to send an empty string if the file is unlocked, MS doesn't say anything + return "", NewConnectorError(409, "File must be locked first") + } + + // Prepare the data to initiate the upload + opaque := &types.Opaque{ + Map: make(map[string]*types.OpaqueEntry), + } + + if streamLength >= 0 { + opaque.Map["Upload-Length"] = &types.OpaqueEntry{ + Decoder: "plain", + Value: []byte(strconv.FormatInt(streamLength, 10)), + } + } + + req := &providerv1beta1.InitiateFileUploadRequest{ + Opaque: opaque, + Ref: &wopiContext.FileReference, + LockId: lockID, + // TODO: if-match + //Options: &providerv1beta1.InitiateFileUploadRequest_IfMatch{ + // IfMatch: "", + //}, + } + + // Initiate the upload request + resp, err := c.gwc.InitiateFileUpload(ctx, req) + if err != nil { + logger.Error().Err(err).Msg("UploadHelper: InitiateFileUpload failed") + return "", err + } + + if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("UploadHelper: InitiateFileUpload failed with wrong status") + return "", NewConnectorError(500, resp.Status.GetCode().String()+" "+resp.Status.GetMessage()) + } + + // if the content length is greater than 0, we need to upload the content to the + // target endpoint, otherwise we're done + if streamLength > 0 { + + uploadEndpoint := "" + uploadToken := "" + hasUploadToken := false + + for _, proto := range resp.Protocols { + if proto.Protocol == "simple" || proto.Protocol == "spaces" { + uploadEndpoint = proto.UploadEndpoint + uploadToken = proto.Token + hasUploadToken = proto.Token != "" + break + } + } + + if uploadEndpoint == "" { + logger.Error(). + Str("Endpoint", uploadEndpoint). + Bool("HasUploadToken", hasUploadToken). + Msg("UploadHelper: Upload endpoint or token is missing") + return "", NewConnectorError(500, "upload endpoint or token is missing") + } + + httpClient := http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: c.cfg.CS3Api.DataGateway.Insecure, + }, + }, + Timeout: 10 * time.Second, + } + + // prepare the request to upload the contents to the upload endpoint + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadEndpoint, stream) + if err != nil { + logger.Error(). + Err(err). + Str("Endpoint", uploadEndpoint). + Bool("HasUploadToken", hasUploadToken). + Msg("UploadHelper: Could not create the request to the endpoint") + return "", err + } + // "stream" is an *http.body and doesn't fill the httpReq.ContentLength automatically + // we need to fill the ContentLength ourselves, and must match the stream length in order + // to prevent issues + httpReq.ContentLength = streamLength + + if uploadToken != "" { + // public link uploads have the token in the upload endpoint + httpReq.Header.Add("X-Reva-Transfer", uploadToken) + } + // TODO: the access token shouldn't be needed + httpReq.Header.Add("X-Access-Token", wopiContext.AccessToken) + + httpReq.Header.Add("X-Lock-Id", lockID) + // TODO: better mechanism for the upload while locked, relies on patch in REVA + //if lockID, ok := ctxpkg.ContextGetLockID(ctx); ok { + // httpReq.Header.Add("X-Lock-Id", lockID) + //} + + httpResp, err := httpClient.Do(httpReq) + if err != nil { + logger.Error(). + Err(err). + Str("Endpoint", uploadEndpoint). + Bool("HasUploadToken", hasUploadToken). + Msg("UploadHelper: Put request to the upload endpoint failed") + return "", err + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusOK { + logger.Error(). + Str("Endpoint", uploadEndpoint). + Bool("HasUploadToken", hasUploadToken). + Int("HttpCode", httpResp.StatusCode). + Msg("UploadHelper: Put request to the upload endpoint failed with unexpected status") + return "", NewConnectorError(500, "PutFile: Uploading the file failed") + } + } + + logger.Debug().Msg("PutFile: success") + return "", nil +} diff --git a/services/collaboration/pkg/connector/fileconnector.go b/services/collaboration/pkg/connector/fileconnector.go new file mode 100644 index 0000000000..94fea49175 --- /dev/null +++ b/services/collaboration/pkg/connector/fileconnector.go @@ -0,0 +1,480 @@ +package connector + +import ( + "context" + "encoding/hex" + "path" + "time" + + appproviderv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1" + gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" + "github.com/google/uuid" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/app" + "github.com/rs/zerolog" +) + +const ( + // WOPI Locks generally have a lock duration of 30 minutes and will be refreshed before expiration if needed + // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/concepts#lock + lockDuration time.Duration = 30 * time.Minute +) + +type FileConnector struct { + gwc gatewayv1beta1.GatewayAPIClient + cfg *config.Config +} + +func NewFileConnector(gwc gatewayv1beta1.GatewayAPIClient, cfg *config.Config) *FileConnector { + return &FileConnector{ + gwc: gwc, + cfg: cfg, + } +} + +// GetLock returns a lock or an empty string if no lock exists +// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/getlock +func (f *FileConnector) GetLock(ctx context.Context) (string, error) { + wopiContext, err := app.WopiContextFromCtx(ctx) + if err != nil { + return "", err + } + + logger := zerolog.Ctx(ctx) + + req := &providerv1beta1.GetLockRequest{ + Ref: &wopiContext.FileReference, + } + + resp, err := f.gwc.GetLock(ctx, req) + if err != nil { + logger.Error().Err(err).Msg("GetLock failed") + return "", err + } + + if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", resp.Status.GetCode().String()). + Str("StatusMsg", resp.Status.GetMessage()). + Msg("GetLock failed with unexpected status") + return "", NewConnectorError(404, resp.Status.GetCode().String()+" "+resp.Status.GetMessage()) + } + + lockID := "" + if resp.Lock != nil { + lockID = resp.Lock.LockId + } + + // log the success at debug level + logger.Debug(). + Str("LockID", lockID). + Msg("GetLock success") + + return lockID, nil +} + +// Lock returns a WOPI lock or performs an unlock and relock +// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/lock +// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/unlockandrelock +func (f *FileConnector) Lock(ctx context.Context, lockID, oldLockID string) (string, error) { + wopiContext, err := app.WopiContextFromCtx(ctx) + if err != nil { + return "", err + } + + logger := zerolog.Ctx(ctx).With(). + Str("RequestedLockID", lockID). + Str("RequestedOldLockID", oldLockID). + Logger() + + if lockID == "" { + logger.Error().Msg("Lock failed due to empty lockID") + return "", NewConnectorError(400, "Requested lockID is empty") + } + + var setOrRefreshStatus *rpcv1beta1.Status + if oldLockID == "" { + // If the oldLockID is empty, this is a "LOCK" request + req := &providerv1beta1.SetLockRequest{ + Ref: &wopiContext.FileReference, + Lock: &providerv1beta1.Lock{ + LockId: lockID, + AppName: f.cfg.App.LockName, + Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, + Expiration: &typesv1beta1.Timestamp{ + Seconds: uint64(time.Now().Add(lockDuration).Unix()), + }, + }, + } + + resp, err := f.gwc.SetLock(ctx, req) + if err != nil { + logger.Error().Err(err).Msg("SetLock failed") + return "", err + } + setOrRefreshStatus = resp.Status + } else { + // If the oldLockID isn't empty, this is a "UnlockAndRelock" request. We'll + // do a "RefreshLock" in reva and provide the old lock + req := &providerv1beta1.RefreshLockRequest{ + Ref: &wopiContext.FileReference, + Lock: &providerv1beta1.Lock{ + LockId: lockID, + AppName: f.cfg.App.LockName, + Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, + Expiration: &typesv1beta1.Timestamp{ + Seconds: uint64(time.Now().Add(lockDuration).Unix()), + }, + }, + ExistingLockId: oldLockID, + } + + resp, err := f.gwc.RefreshLock(ctx, req) + if err != nil { + logger.Error().Err(err).Msg("UnlockAndRefresh failed") + return "", err + } + setOrRefreshStatus = resp.Status + } + + // we're checking the status of either the "SetLock" or "RefreshLock" operations + switch setOrRefreshStatus.Code { + case rpcv1beta1.Code_CODE_OK: + logger.Debug().Msg("SetLock successful") + return "", nil + + case rpcv1beta1.Code_CODE_FAILED_PRECONDITION, rpcv1beta1.Code_CODE_ABORTED: + // Code_CODE_FAILED_PRECONDITION -> Lock operation mismatched lock + // Code_CODE_ABORTED -> UnlockAndRelock operation mismatched lock + // In both cases, we need to get the current lock to return it in a + // 409 response if needed + req := &providerv1beta1.GetLockRequest{ + Ref: &wopiContext.FileReference, + } + + resp, err := f.gwc.GetLock(ctx, req) + if err != nil { + logger.Error().Err(err).Msg("SetLock failed, fallback to GetLock failed too") + return "", err + } + + if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("SetLock failed, fallback to GetLock failed with unexpected status") + } + + if resp.Lock != nil { + if resp.Lock.LockId != lockID { + // lockId is different -> return 409 with the current lockId + logger.Warn(). + Str("LockID", resp.Lock.LockId). + Msg("SetLock conflict") + return resp.Lock.LockId, NewConnectorError(409, "Lock conflict") + } + + // TODO: according to the spec we need to treat this as a RefreshLock + // There was a problem with the lock, but the file has the same lockId now. + // This should never happen unless there are race conditions. + // Since the lockId matches now, we'll assume success for now. + // As said in the todo, we probably should send a "RefreshLock" request here. + logger.Warn(). + Str("LockID", resp.Lock.LockId). + Msg("SetLock lock refreshed instead") + return resp.Lock.LockId, nil + } + + // TODO: Is this the right error code? + logger.Error().Msg("SetLock failed and could not refresh") + return "", NewConnectorError(404, "Could not refresh the lock") + + default: + logger.Error(). + Str("StatusCode", setOrRefreshStatus.Code.String()). + Str("StatusMsg", setOrRefreshStatus.Message). + Msg("SetLock failed with unexpected status") + return "", NewConnectorError(500, setOrRefreshStatus.GetCode().String()+" "+setOrRefreshStatus.GetMessage()) + } +} + +// RefreshLock refreshes a provided lock for 30 minutes +// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/refreshlock +func (f *FileConnector) RefreshLock(ctx context.Context, lockID string) (string, error) { + wopiContext, err := app.WopiContextFromCtx(ctx) + if err != nil { + return "", err + } + + logger := zerolog.Ctx(ctx).With(). + Str("RequestedLockID", lockID). + Logger() + + if lockID == "" { + logger.Error().Msg("RefreshLock failed due to empty lockID") + return "", NewConnectorError(400, "Requested lockID is empty") + } + + req := &providerv1beta1.RefreshLockRequest{ + Ref: &wopiContext.FileReference, + Lock: &providerv1beta1.Lock{ + LockId: lockID, + AppName: f.cfg.App.LockName, + Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, + Expiration: &typesv1beta1.Timestamp{ + Seconds: uint64(time.Now().Add(lockDuration).Unix()), + }, + }, + } + + resp, err := f.gwc.RefreshLock(ctx, req) + if err != nil { + logger.Error().Err(err).Msg("RefreshLock failed") + return "", err + } + + switch resp.Status.Code { + case rpcv1beta1.Code_CODE_OK: + logger.Debug().Msg("RefreshLock successful") + return "", nil + + case rpcv1beta1.Code_CODE_NOT_FOUND: + logger.Error(). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("RefreshLock failed, file reference not found") + return "", NewConnectorError(404, "File reference not found") + + case rpcv1beta1.Code_CODE_ABORTED: + logger.Error(). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("RefreshLock failed, lock mismatch") + + // Either the file is unlocked or there is no lock + // We need to return 409 with the current lock + req := &providerv1beta1.GetLockRequest{ + Ref: &wopiContext.FileReference, + } + + resp, err := f.gwc.GetLock(ctx, req) + if err != nil { + logger.Error().Err(err).Msg("RefreshLock failed trying to get the current lock") + return "", err + } + + if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("RefreshLock failed, tried to get the current lock failed with unexpected status") + return "", NewConnectorError(500, resp.Status.GetCode().String()+" "+resp.Status.GetMessage()) + } + + if resp.Lock == nil { + logger.Error(). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("RefreshLock failed, no lock on file") + return "", NewConnectorError(409, "No lock on file") + } else { + // lock is different than the one requested, otherwise we wouldn't reached this point + logger.Error(). + Str("LockID", resp.Lock.LockId). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("RefreshLock failed, lock mismatch") + return resp.Lock.LockId, NewConnectorError(409, "Lock mismatch") + } + default: + logger.Error(). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("RefreshLock failed with unexpected status") + return "", NewConnectorError(500, resp.Status.GetCode().String()+" "+resp.Status.GetMessage()) + } +} + +// UnLock removes a given lock from a file +// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/unlock +func (f *FileConnector) UnLock(ctx context.Context, lockID string) (string, error) { + wopiContext, err := app.WopiContextFromCtx(ctx) + if err != nil { + return "", err + } + + logger := zerolog.Ctx(ctx).With(). + Str("RequestedLockID", lockID). + Logger() + + if lockID == "" { + logger.Error().Msg("Unlock failed due to empty lockID") + return "", NewConnectorError(400, "Requested lockID is empty") + } + + req := &providerv1beta1.UnlockRequest{ + Ref: &wopiContext.FileReference, + Lock: &providerv1beta1.Lock{ + LockId: lockID, + AppName: f.cfg.App.LockName, + }, + } + + resp, err := f.gwc.Unlock(ctx, req) + if err != nil { + logger.Error().Err(err).Msg("Unlock failed") + return "", err + } + + switch resp.Status.Code { + case rpcv1beta1.Code_CODE_OK: + logger.Debug().Msg("Unlock successful") + return "", nil + case rpcv1beta1.Code_CODE_ABORTED: + // File isn't locked. Need to return 409 with empty lock + logger.Error().Err(err).Msg("Unlock failed, file isn't locked") + return "", NewConnectorError(409, "File is not locked") + case rpcv1beta1.Code_CODE_LOCKED: + // We need to return 409 with the current lock + req := &providerv1beta1.GetLockRequest{ + Ref: &wopiContext.FileReference, + } + + resp, err := f.gwc.GetLock(ctx, req) + if err != nil { + logger.Error().Err(err).Msg("Unlock failed trying to get the current lock") + return "", err + } + + if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("Unlock failed, tried to get the current lock failed with unexpected status") + return "", NewConnectorError(500, resp.Status.GetCode().String()+" "+resp.Status.GetMessage()) + } + + var outLockId string + if resp.Lock == nil { + logger.Error(). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("Unlock failed, no lock on file") + outLockId = "" + } else { + // lock is different than the one requested, otherwise we wouldn't reached this point + logger.Error(). + Str("LockID", resp.Lock.LockId). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("Unlock failed, lock mismatch") + outLockId = resp.Lock.LockId + } + return outLockId, NewConnectorError(409, "Lock mismatch") + default: + logger.Error(). + Str("StatusCode", resp.Status.Code.String()). + Str("StatusMsg", resp.Status.Message). + Msg("Unlock failed with unexpected status") + return "", NewConnectorError(500, resp.Status.GetCode().String()+" "+resp.Status.GetMessage()) + } +} + +// CheckFileInfo returns information about the requested file and capabilities of the wopi server +// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo +func (f *FileConnector) CheckFileInfo(ctx context.Context) (app.FileInfo, error) { + wopiContext, err := app.WopiContextFromCtx(ctx) + if err != nil { + return app.FileInfo{}, err + } + + logger := zerolog.Ctx(ctx) + + statRes, err := f.gwc.Stat(ctx, &providerv1beta1.StatRequest{ + Ref: &wopiContext.FileReference, + }) + if err != nil { + logger.Error().Err(err).Msg("CheckFileInfo: stat failed") + return app.FileInfo{}, err + } + + if statRes.Status.Code != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", statRes.Status.Code.String()). + Str("StatusMsg", statRes.Status.Message). + Msg("CheckFileInfo: stat failed with unexpected status") + return app.FileInfo{}, NewConnectorError(500, statRes.Status.GetCode().String()+" "+statRes.Status.GetMessage()) + } + + fileInfo := app.FileInfo{ + // OwnerID must use only alphanumeric chars (https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo/checkfileinfo-response#requirements-for-user-identity-properties) + OwnerID: hex.EncodeToString([]byte(statRes.Info.Owner.OpaqueId + "@" + statRes.Info.Owner.Idp)), + Size: int64(statRes.Info.Size), + Version: statRes.Info.Mtime.String(), + BaseFileName: path.Base(statRes.Info.Path), + BreadcrumbDocName: path.Base(statRes.Info.Path), + // to get the folder we actually need to do a GetPath() request + //BreadcrumbFolderName: path.Dir(statRes.Info.Path), + + UserCanNotWriteRelative: true, + + HostViewUrl: wopiContext.ViewAppUrl, + HostEditUrl: wopiContext.EditAppUrl, + + //EnableOwnerTermination: true, // enable only for collabora? wopivalidator is complaining + EnableOwnerTermination: false, + + SupportsExtendedLockLength: true, + + SupportsGetLock: true, + SupportsLocks: true, + } + + switch wopiContext.ViewMode { + case appproviderv1beta1.ViewMode_VIEW_MODE_READ_WRITE: + fileInfo.SupportsUpdate = true + fileInfo.UserCanWrite = true + + case appproviderv1beta1.ViewMode_VIEW_MODE_READ_ONLY: + // nothing special to do here for now + + case appproviderv1beta1.ViewMode_VIEW_MODE_VIEW_ONLY: + fileInfo.DisableExport = true + fileInfo.DisableCopy = true + fileInfo.DisablePrint = true + } + + // user logic from reva wopi driver #TODO: refactor + var isPublicShare bool = false + if wopiContext.User != nil { + // UserID must use only alphanumeric chars (https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo/checkfileinfo-response#requirements-for-user-identity-properties) + if wopiContext.User.Id.Type == userv1beta1.UserType_USER_TYPE_LIGHTWEIGHT { + fileInfo.UserID = hex.EncodeToString([]byte(statRes.Info.Owner.OpaqueId + "@" + statRes.Info.Owner.Idp)) + } else { + fileInfo.UserID = hex.EncodeToString([]byte(wopiContext.User.Id.OpaqueId + "@" + wopiContext.User.Id.Idp)) + } + + if wopiContext.User.Opaque != nil { + if _, ok := wopiContext.User.Opaque.Map["public-share-role"]; ok { + isPublicShare = true + } + } + if !isPublicShare { + fileInfo.UserFriendlyName = wopiContext.User.Username + fileInfo.UserID = hex.EncodeToString([]byte(wopiContext.User.Id.OpaqueId + "@" + wopiContext.User.Id.Idp)) + } + } + if wopiContext.User == nil || isPublicShare { + randomID, _ := uuid.NewUUID() + fileInfo.UserID = hex.EncodeToString([]byte("guest-" + randomID.String())) + fileInfo.UserFriendlyName = "Guest " + randomID.String() + fileInfo.IsAnonymousUser = true + } + + logger.Debug().Msg("CheckFileInfo: success") + return fileInfo, nil +} diff --git a/services/collaboration/pkg/connector/httpadapter.go b/services/collaboration/pkg/connector/httpadapter.go new file mode 100644 index 0000000000..ff94a6d479 --- /dev/null +++ b/services/collaboration/pkg/connector/httpadapter.go @@ -0,0 +1,178 @@ +package connector + +import ( + "encoding/json" + "errors" + "net/http" + + gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" + "github.com/rs/zerolog" +) + +const ( + HeaderWopiLock string = "X-WOPI-Lock" + HeaderWopiOldLock string = "X-WOPI-OldLock" +) + +type HttpAdapter struct { + con *Connector +} + +func NewHttpAdapter(gwc gatewayv1beta1.GatewayAPIClient, cfg *config.Config) *HttpAdapter { + return &HttpAdapter{ + con: NewConnector( + NewFileConnector(gwc, cfg), + NewContentConnector(gwc, cfg), + ), + } +} + +func (h *HttpAdapter) GetLock(w http.ResponseWriter, r *http.Request) { + fileCon := h.con.GetFileConnector() + + lockID, err := fileCon.GetLock(r.Context()) + if err != nil { + var conError *ConnectorError + if errors.As(err, &conError) { + http.Error(w, http.StatusText(conError.HttpCodeOut), conError.HttpCodeOut) + } else { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + return + } + w.Header().Set(HeaderWopiLock, lockID) +} + +func (h *HttpAdapter) Lock(w http.ResponseWriter, r *http.Request) { + oldLockID := r.Header.Get(HeaderWopiOldLock) + lockID := r.Header.Get(HeaderWopiLock) + + fileCon := h.con.GetFileConnector() + newLockID, err := fileCon.Lock(r.Context(), lockID, oldLockID) + if err != nil { + var conError *ConnectorError + if errors.As(err, &conError) { + if conError.HttpCodeOut == 409 { + w.Header().Set(HeaderWopiLock, newLockID) + } + http.Error(w, http.StatusText(conError.HttpCodeOut), conError.HttpCodeOut) + } else { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + return + } + // If no error, a HTTP 200 should be sent automatically. + // X-WOPI-Lock header isn't needed on HTTP 200 +} + +func (h *HttpAdapter) RefreshLock(w http.ResponseWriter, r *http.Request) { + lockID := r.Header.Get(HeaderWopiLock) + + fileCon := h.con.GetFileConnector() + newLockID, err := fileCon.RefreshLock(r.Context(), lockID) + if err != nil { + var conError *ConnectorError + if errors.As(err, &conError) { + if conError.HttpCodeOut == 409 { + w.Header().Set(HeaderWopiLock, newLockID) + } + http.Error(w, http.StatusText(conError.HttpCodeOut), conError.HttpCodeOut) + } else { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + return + } + // If no error, a HTTP 200 should be sent automatically. + // X-WOPI-Lock header isn't needed on HTTP 200 +} + +func (h *HttpAdapter) UnLock(w http.ResponseWriter, r *http.Request) { + lockID := r.Header.Get(HeaderWopiLock) + + fileCon := h.con.GetFileConnector() + newLockID, err := fileCon.UnLock(r.Context(), lockID) + if err != nil { + var conError *ConnectorError + if errors.As(err, &conError) { + if conError.HttpCodeOut == 409 { + w.Header().Set(HeaderWopiLock, newLockID) + } + http.Error(w, http.StatusText(conError.HttpCodeOut), conError.HttpCodeOut) + } else { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + return + } + // If no error, a HTTP 200 should be sent automatically. + // X-WOPI-Lock header isn't needed on HTTP 200 +} + +func (h *HttpAdapter) CheckFileInfo(w http.ResponseWriter, r *http.Request) { + fileCon := h.con.GetFileConnector() + + fileInfo, err := fileCon.CheckFileInfo(r.Context()) + if err != nil { + var conError *ConnectorError + if errors.As(err, &conError) { + http.Error(w, http.StatusText(conError.HttpCodeOut), conError.HttpCodeOut) + } else { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + return + } + + logger := zerolog.Ctx(r.Context()) + + jsonFileInfo, err := json.Marshal(fileInfo) + if err != nil { + logger.Error().Err(err).Msg("CheckFileInfo: failed to marshal fileinfo") + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + bytes, err := w.Write(jsonFileInfo) + + if err != nil { + logger.Error(). + Err(err). + Int("TotalBytes", len(jsonFileInfo)). + Int("WrittenBytes", bytes). + Msg("CheckFileInfo: failed to write contents in the HTTP response") + } +} + +func (h *HttpAdapter) GetFile(w http.ResponseWriter, r *http.Request) { + contentCon := h.con.GetContentConnector() + err := contentCon.GetFile(r.Context(), w) + if err != nil { + var conError *ConnectorError + if errors.As(err, &conError) { + http.Error(w, http.StatusText(conError.HttpCodeOut), conError.HttpCodeOut) + } else { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + } +} + +func (h *HttpAdapter) PutFile(w http.ResponseWriter, r *http.Request) { + lockID := r.Header.Get(HeaderWopiLock) + + contentCon := h.con.GetContentConnector() + newLockID, err := contentCon.PutFile(r.Context(), r.Body, r.ContentLength, lockID) + if err != nil { + var conError *ConnectorError + if errors.As(err, &conError) { + if conError.HttpCodeOut == 409 { + w.Header().Set(HeaderWopiLock, newLockID) + } + http.Error(w, http.StatusText(conError.HttpCodeOut), conError.HttpCodeOut) + } else { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + return + } + // If no error, a HTTP 200 should be sent automatically. + // X-WOPI-Lock header isn't needed on HTTP 200 +} diff --git a/services/collaboration/pkg/internal/app/app.go b/services/collaboration/pkg/internal/app/app.go index 53e4b288d9..ab85a6d67e 100644 --- a/services/collaboration/pkg/internal/app/app.go +++ b/services/collaboration/pkg/internal/app/app.go @@ -44,6 +44,10 @@ func New(cfg *config.Config, logger log.Logger) (*DemoApp, error) { return app, nil } +func (app *DemoApp) GetGwc() gatewayv1beta1.GatewayAPIClient { + return app.gwc +} + func (app *DemoApp) GetCS3apiClient() error { // establish a connection to the cs3 api endpoint // in this case a REVA gateway, started by oCIS diff --git a/services/collaboration/pkg/internal/app/headers.go b/services/collaboration/pkg/internal/app/headers.go deleted file mode 100644 index 698ad3a637..0000000000 --- a/services/collaboration/pkg/internal/app/headers.go +++ /dev/null @@ -1,6 +0,0 @@ -package app - -const ( - HeaderWopiLock string = "X-WOPI-Lock" - HeaderWopiOldLock string = "X-WOPI-OldLock" -) diff --git a/services/collaboration/pkg/internal/app/wopicontext.go b/services/collaboration/pkg/internal/app/wopicontext.go index fea0b60421..806e3cee99 100644 --- a/services/collaboration/pkg/internal/app/wopicontext.go +++ b/services/collaboration/pkg/internal/app/wopicontext.go @@ -11,6 +11,7 @@ import ( providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" ctxpkg "github.com/cs3org/reva/v2/pkg/ctx" "github.com/golang-jwt/jwt/v4" + "github.com/rs/zerolog" "google.golang.org/grpc/metadata" ) @@ -29,7 +30,7 @@ type WopiContext struct { ViewAppUrl string } -func WopiContextAuthMiddleware(app *DemoApp, next http.Handler) http.Handler { +func WopiContextAuthMiddleware(jwtSecret string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { accessToken := r.URL.Query().Get("access_token") if accessToken == "" { @@ -44,7 +45,7 @@ func WopiContextAuthMiddleware(app *DemoApp, next http.Handler) http.Handler { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } - return []byte(app.Config.JWTSecret), nil + return []byte(jwtSecret), nil }) if err != nil { @@ -59,7 +60,7 @@ func WopiContextAuthMiddleware(app *DemoApp, next http.Handler) http.Handler { ctx := r.Context() - wopiContextAccessToken, err := DecryptAES([]byte(app.Config.JWTSecret), claims.WopiContext.AccessToken) + wopiContextAccessToken, err := DecryptAES([]byte(jwtSecret), claims.WopiContext.AccessToken) if err != nil { http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) return @@ -70,6 +71,17 @@ func WopiContextAuthMiddleware(app *DemoApp, next http.Handler) http.Handler { // authentication for the CS3 api ctx = metadata.AppendToOutgoingContext(ctx, ctxpkg.TokenHeader, claims.WopiContext.AccessToken) + // include additional info in the context's logger + // we might need to check https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/common-headers + // although some headers might not be sent depending on the client. + logger := zerolog.Ctx(ctx) + ctx = logger.With(). + Str("WopiOverride", r.Header.Get("X-WOPI-Override")). + Str("FileReference", claims.WopiContext.FileReference.String()). + Str("ViewMode", claims.WopiContext.ViewMode.String()). + Str("Requester", claims.WopiContext.User.GetId().String()). + Logger().WithContext(ctx) + next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/services/collaboration/pkg/internal/app/wopifilecontents.go b/services/collaboration/pkg/internal/app/wopifilecontents.go deleted file mode 100644 index d45a975609..0000000000 --- a/services/collaboration/pkg/internal/app/wopifilecontents.go +++ /dev/null @@ -1,169 +0,0 @@ -package app - -import ( - "io" - "net/http" - "strconv" - - rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" - providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" - "github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/helpers" -) - -// GetFile downloads the file from the storage -// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/getfile -func GetFile(app *DemoApp, w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - wopiContext, _ := WopiContextFromCtx(ctx) - - // download the file - resp, err := helpers.DownloadFile( - ctx, - &wopiContext.FileReference, - app.gwc, - wopiContext.AccessToken, - app.Config.CS3Api.DataGateway.Insecure, - app.Logger, - ) - - if err != nil || resp.StatusCode != http.StatusOK { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Int("HttpCode", resp.StatusCode). - Msg("GetFile: downloading the file failed") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - // read the file from the body - defer resp.Body.Close() - _, err = io.Copy(w, resp.Body) - if err != nil { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("GetFile: copying the file content to the response body failed") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - app.Logger.Debug(). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("GetFile: success") -} - -// PutFile uploads the file to the storage -// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/putfile -func PutFile(app *DemoApp, w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - wopiContext, _ := WopiContextFromCtx(ctx) - - // read the file from the body - defer r.Body.Close() - - // We need a stat call on the target file in order to get both the lock - // (if any) and the current size of the file - statRes, err := app.gwc.Stat(ctx, &providerv1beta1.StatRequest{ - Ref: &wopiContext.FileReference, - }) - if err != nil { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", r.Header.Get(HeaderWopiLock)). - Str("UploadLength", strconv.FormatInt(r.ContentLength, 10)). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("PutFile: stat failed") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - if statRes.Status.Code != rpcv1beta1.Code_CODE_OK { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", r.Header.Get(HeaderWopiLock)). - Str("UploadLength", strconv.FormatInt(r.ContentLength, 10)). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", statRes.Status.Code.String()). - Str("StatusMsg", statRes.Status.Message). - Msg("PutFile: stat failed with unexpected status") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - // If there is a lock and it mismatches, return 409 - if statRes.Info.Lock != nil && statRes.Info.Lock.LockId != r.Header.Get(HeaderWopiLock) { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", r.Header.Get(HeaderWopiLock)). - Str("UploadLength", strconv.FormatInt(r.ContentLength, 10)). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("LockID", statRes.Info.Lock.LockId). - Msg("PutFile: wrong lock") - // onlyoffice says it's required to send the current lockId, MS doesn't say anything - w.Header().Add(HeaderWopiLock, statRes.Info.Lock.LockId) - http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) - return - } - - // only unlocked uploads can go through if the target file is empty, - // otherwise the X-WOPI-Lock header is required even if there is no lock on the file - // This is part of the onlyoffice documentation (https://api.onlyoffice.com/editors/wopi/restapi/putfile) - // Wopivalidator fails some tests if we don't also check for the X-WOPI-Lock header. - if r.Header.Get(HeaderWopiLock) == "" && statRes.Info.Lock == nil && statRes.Info.Size > 0 { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", r.Header.Get(HeaderWopiLock)). - Str("UploadLength", strconv.FormatInt(r.ContentLength, 10)). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("PutFile: file must be locked first") - // onlyoffice says to send an empty string if the file is unlocked, MS doesn't say anything - w.Header().Add(HeaderWopiLock, "") - http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) - return - } - - // upload the file - err = helpers.UploadFile( - ctx, - r.Body, - r.ContentLength, - &wopiContext.FileReference, - app.gwc, - wopiContext.AccessToken, - r.Header.Get(HeaderWopiLock), - app.Config.CS3Api.DataGateway.Insecure, - app.Logger, - ) - - if err != nil { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", r.Header.Get(HeaderWopiLock)). - Str("UploadLength", strconv.FormatInt(r.ContentLength, 10)). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("PutFile: uploading the file failed") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - app.Logger.Debug(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", r.Header.Get(HeaderWopiLock)). - Str("UploadLength", strconv.FormatInt(r.ContentLength, 10)). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("PutFile: success") -} diff --git a/services/collaboration/pkg/internal/app/wopiinfo.go b/services/collaboration/pkg/internal/app/wopiinfo.go deleted file mode 100644 index 0a5bc44ad3..0000000000 --- a/services/collaboration/pkg/internal/app/wopiinfo.go +++ /dev/null @@ -1,150 +0,0 @@ -package app - -import ( - "encoding/hex" - "encoding/json" - "net/http" - "path" - - appproviderv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1" - userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" - rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" - providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" - "github.com/google/uuid" -) - -func WopiInfoHandler(app *DemoApp, w http.ResponseWriter, r *http.Request) { - // Logs for this endpoint will be covered by the access log. We can't extract - // more info - http.Error(w, http.StatusText(http.StatusTeapot), http.StatusTeapot) -} - -// CheckFileInfo returns information about the requested file and capabilities of the wopi server -// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo -func CheckFileInfo(app *DemoApp, w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - wopiContext, _ := WopiContextFromCtx(ctx) - - statRes, err := app.gwc.Stat(ctx, &providerv1beta1.StatRequest{ - Ref: &wopiContext.FileReference, - }) - if err != nil { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("CheckFileInfo: stat failed") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - if statRes.Status.Code != rpcv1beta1.Code_CODE_OK { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", statRes.Status.Code.String()). - Str("StatusMsg", statRes.Status.Message). - Msg("CheckFileInfo: stat failed with unexpected status") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - fileInfo := FileInfo{ - // OwnerID must use only alphanumeric chars (https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo/checkfileinfo-response#requirements-for-user-identity-properties) - OwnerID: hex.EncodeToString([]byte(statRes.Info.Owner.OpaqueId + "@" + statRes.Info.Owner.Idp)), - Size: int64(statRes.Info.Size), - Version: statRes.Info.Mtime.String(), - BaseFileName: path.Base(statRes.Info.Path), - BreadcrumbDocName: path.Base(statRes.Info.Path), - // to get the folder we actually need to do a GetPath() request - //BreadcrumbFolderName: path.Dir(statRes.Info.Path), - - UserCanNotWriteRelative: true, - - HostViewUrl: wopiContext.ViewAppUrl, - HostEditUrl: wopiContext.EditAppUrl, - - //EnableOwnerTermination: true, // enable only for collabora? wopivalidator is complaining - EnableOwnerTermination: false, - - SupportsExtendedLockLength: true, - - SupportsGetLock: true, - SupportsLocks: true, - } - - switch wopiContext.ViewMode { - case appproviderv1beta1.ViewMode_VIEW_MODE_READ_WRITE: - fileInfo.SupportsUpdate = true - fileInfo.UserCanWrite = true - - case appproviderv1beta1.ViewMode_VIEW_MODE_READ_ONLY: - // nothing special to do here for now - - case appproviderv1beta1.ViewMode_VIEW_MODE_VIEW_ONLY: - fileInfo.DisableExport = true - fileInfo.DisableCopy = true - fileInfo.DisablePrint = true - } - - // user logic from reva wopi driver #TODO: refactor - var isPublicShare bool = false - if wopiContext.User != nil { - // UserID must use only alphanumeric chars (https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo/checkfileinfo-response#requirements-for-user-identity-properties) - if wopiContext.User.Id.Type == userv1beta1.UserType_USER_TYPE_LIGHTWEIGHT { - fileInfo.UserID = hex.EncodeToString([]byte(statRes.Info.Owner.OpaqueId + "@" + statRes.Info.Owner.Idp)) - } else { - fileInfo.UserID = hex.EncodeToString([]byte(wopiContext.User.Id.OpaqueId + "@" + wopiContext.User.Id.Idp)) - } - - if wopiContext.User.Opaque != nil { - if _, ok := wopiContext.User.Opaque.Map["public-share-role"]; ok { - isPublicShare = true - } - } - if !isPublicShare { - fileInfo.UserFriendlyName = wopiContext.User.Username - fileInfo.UserID = hex.EncodeToString([]byte(wopiContext.User.Id.OpaqueId + "@" + wopiContext.User.Id.Idp)) - } - } - if wopiContext.User == nil || isPublicShare { - randomID, _ := uuid.NewUUID() - fileInfo.UserID = hex.EncodeToString([]byte("guest-" + randomID.String())) - fileInfo.UserFriendlyName = "Guest " + randomID.String() - fileInfo.IsAnonymousUser = true - } - - jsonFileInfo, err := json.Marshal(fileInfo) - if err != nil { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("CheckFileInfo: failed to marshal fileinfo") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - app.Logger.Debug(). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("CheckFileInfo: success") - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - bytes, err := w.Write(jsonFileInfo) - if err != nil { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Int("TotalBytes", len(jsonFileInfo)). - Int("WrittenBytes", bytes). - Msg("CheckFileInfo: failed to write contents in the HTTP response") - } -} diff --git a/services/collaboration/pkg/internal/app/wopilocking.go b/services/collaboration/pkg/internal/app/wopilocking.go deleted file mode 100644 index 4e8bf17a59..0000000000 --- a/services/collaboration/pkg/internal/app/wopilocking.go +++ /dev/null @@ -1,524 +0,0 @@ -package app - -import ( - "net/http" - "time" - - rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" - providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" - typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" -) - -const ( - // WOPI Locks generally have a lock duration of 30 minutes and will be refreshed before expiration if needed - // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/concepts#lock - lockDuration time.Duration = 30 * time.Minute -) - -// GetLock returns a lock or an empty string if no lock exists -// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/getlock -func GetLock(app *DemoApp, w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - wopiContext, _ := WopiContextFromCtx(ctx) - - req := &providerv1beta1.GetLockRequest{ - Ref: &wopiContext.FileReference, - } - - resp, err := app.gwc.GetLock(ctx, req) - if err != nil { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("GetLock failed") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - if resp.Status.Code != rpcv1beta1.Code_CODE_OK { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("GetLock failed with unexpected status") - http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) - return - } - - lockID := "" - if resp.Lock != nil { - lockID = resp.Lock.LockId - } - - // log the success at debug level - app.Logger.Debug(). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", resp.Status.Code.String()). - Str("LockID", lockID). - Msg("GetLock success") - - w.Header().Set(HeaderWopiLock, lockID) -} - -// Lock returns a WOPI lock or performs an unlock and relock -// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/lock -// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/unlockandrelock -func Lock(app *DemoApp, w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - wopiContext, _ := WopiContextFromCtx(ctx) - - oldLockID := r.Header.Get(HeaderWopiOldLock) - lockID := r.Header.Get(HeaderWopiLock) - if lockID == "" { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("Lock failed due to empty lockID") - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - var setOrRefreshStatus *rpcv1beta1.Status - if oldLockID == "" { - // If the oldLockID is empty, this is a "LOCK" request - req := &providerv1beta1.SetLockRequest{ - Ref: &wopiContext.FileReference, - Lock: &providerv1beta1.Lock{ - LockId: lockID, - AppName: app.Config.App.LockName, - Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, - Expiration: &typesv1beta1.Timestamp{ - Seconds: uint64(time.Now().Add(lockDuration).Unix()), - }, - }, - } - - resp, err := app.gwc.SetLock(ctx, req) - if err != nil { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("SetLock failed") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - setOrRefreshStatus = resp.Status - } else { - // If the oldLockID isn't empty, this is a "UnlockAndRelock" request. We'll - // do a "RefreshLock" in reva and provide the old lock - req := &providerv1beta1.RefreshLockRequest{ - Ref: &wopiContext.FileReference, - Lock: &providerv1beta1.Lock{ - LockId: lockID, - AppName: app.Config.App.LockName, - Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, - Expiration: &typesv1beta1.Timestamp{ - Seconds: uint64(time.Now().Add(lockDuration).Unix()), - }, - }, - ExistingLockId: oldLockID, - } - - resp, err := app.gwc.RefreshLock(ctx, req) - if err != nil { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("RequestedOldLockID", oldLockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("UnlockAndRefresh failed") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - setOrRefreshStatus = resp.Status - } - - // we're checking the status of either the "SetLock" or "RefreshLock" operations - switch setOrRefreshStatus.Code { - case rpcv1beta1.Code_CODE_OK: - app.Logger.Debug(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("SetLock successful") - return - - case rpcv1beta1.Code_CODE_FAILED_PRECONDITION, rpcv1beta1.Code_CODE_ABORTED: - // Code_CODE_FAILED_PRECONDITION -> Lock operation mismatched lock - // Code_CODE_ABORTED -> UnlockAndRelock operation mismatched lock - // In both cases, we need to get the current lock to return it in a - // 409 response if needed - req := &providerv1beta1.GetLockRequest{ - Ref: &wopiContext.FileReference, - } - - resp, err := app.gwc.GetLock(ctx, req) - if err != nil { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("SetLock failed, fallback to GetLock failed too") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - if resp.Status.Code != rpcv1beta1.Code_CODE_OK { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("SetLock failed, fallback to GetLock failed with unexpected status") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } - - if resp.Lock != nil { - if resp.Lock.LockId != lockID { - // lockId is different -> return 409 with the current lockId - app.Logger.Warn(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("LockID", resp.Lock.LockId). - Msg("SetLock conflict") - w.Header().Set(HeaderWopiLock, resp.Lock.LockId) - http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) - return - } - - // TODO: according to the spec we need to treat this as a RefreshLock - // There was a problem with the lock, but the file has the same lockId now. - // This should never happen unless there are race conditions. - // Since the lockId matches now, we'll assume success for now. - // As said in the todo, we probably should send a "RefreshLock" request here. - app.Logger.Warn(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("LockID", resp.Lock.LockId). - Msg("SetLock lock refreshed instead") - return - } - - // TODO: Is this the right error code? - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("SetLock failed and could not refresh") - http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) - return - - default: - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", setOrRefreshStatus.Code.String()). - Str("StatusMsg", setOrRefreshStatus.Message). - Msg("SetLock failed with unexpected status") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - -} - -// RefreshLock refreshes a provided lock for 30 minutes -// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/refreshlock -func RefreshLock(app *DemoApp, w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - wopiContext, _ := WopiContextFromCtx(ctx) - - lockID := r.Header.Get(HeaderWopiLock) - if lockID == "" { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("RefreshLock failed due to empty lockID") - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - req := &providerv1beta1.RefreshLockRequest{ - Ref: &wopiContext.FileReference, - Lock: &providerv1beta1.Lock{ - LockId: lockID, - AppName: app.Config.App.LockName, - Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, - Expiration: &typesv1beta1.Timestamp{ - Seconds: uint64(time.Now().Add(lockDuration).Unix()), - }, - }, - } - - resp, err := app.gwc.RefreshLock(ctx, req) - if err != nil { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("RefreshLock failed") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - switch resp.Status.Code { - case rpcv1beta1.Code_CODE_OK: - app.Logger.Debug(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("RefreshLock successful") - return - - case rpcv1beta1.Code_CODE_NOT_FOUND: - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("RefreshLock failed, file reference not found") - http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) - return - - case rpcv1beta1.Code_CODE_ABORTED: - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("RefreshLock failed, lock mismatch") - - // Either the file is unlocked or there is no lock - // We need to return 409 with the current lock - req := &providerv1beta1.GetLockRequest{ - Ref: &wopiContext.FileReference, - } - - resp, err := app.gwc.GetLock(ctx, req) - if err != nil { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("RefreshLock failed trying to get the current lock") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - if resp.Status.Code != rpcv1beta1.Code_CODE_OK { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("RefreshLock failed, tried to get the current lock failed with unexpected status") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - if resp.Lock == nil { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("RefreshLock failed, no lock on file") - w.Header().Set(HeaderWopiLock, "") - http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) - return - } else { - // lock is different than the one requested, otherwise we wouldn't reached this point - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("LockID", resp.Lock.LockId). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("RefreshLock failed, lock mismatch") - w.Header().Set(HeaderWopiLock, resp.Lock.LockId) - http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) - return - } - default: - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("RefreshLock failed with unexpected status") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } -} - -// UnLock removes a given lock from a file -// https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/unlock -func UnLock(app *DemoApp, w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - wopiContext, _ := WopiContextFromCtx(ctx) - - lockID := r.Header.Get(HeaderWopiLock) - if lockID == "" { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("Unlock failed due to empty lockID") - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - req := &providerv1beta1.UnlockRequest{ - Ref: &wopiContext.FileReference, - Lock: &providerv1beta1.Lock{ - LockId: lockID, - AppName: app.Config.App.LockName, - }, - } - - resp, err := app.gwc.Unlock(ctx, req) - if err != nil { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("Unlock failed") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - switch resp.Status.Code { - case rpcv1beta1.Code_CODE_OK: - app.Logger.Debug(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("Unlock successful") - return - case rpcv1beta1.Code_CODE_ABORTED: - // File isn't locked. Need to return 409 with empty lock - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("Unlock failed, file isn't locked") - w.Header().Set(HeaderWopiLock, "") - http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) - return - case rpcv1beta1.Code_CODE_LOCKED: - // We need to return 409 with the current lock - req := &providerv1beta1.GetLockRequest{ - Ref: &wopiContext.FileReference, - } - - resp, err := app.gwc.GetLock(ctx, req) - if err != nil { - app.Logger.Error(). - Err(err). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Msg("Unlock failed trying to get the current lock") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - if resp.Status.Code != rpcv1beta1.Code_CODE_OK { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("Unlock failed, tried to get the current lock failed with unexpected status") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - if resp.Lock == nil { - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("Unlock failed, no lock on file") - w.Header().Set(HeaderWopiLock, "") - http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) - } else { - // lock is different than the one requested, otherwise we wouldn't reached this point - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("LockID", resp.Lock.LockId). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("Unlock failed, lock mismatch") - w.Header().Set(HeaderWopiLock, resp.Lock.LockId) - http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict) - } - return - default: - app.Logger.Error(). - Str("FileReference", wopiContext.FileReference.String()). - Str("RequestedLockID", lockID). - Str("ViewMode", wopiContext.ViewMode.String()). - Str("Requester", wopiContext.User.GetId().String()). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("Unlock failed with unexpected status") - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } -} diff --git a/services/collaboration/pkg/internal/helpers/download.go b/services/collaboration/pkg/internal/helpers/download.go deleted file mode 100644 index acba7a3753..0000000000 --- a/services/collaboration/pkg/internal/helpers/download.go +++ /dev/null @@ -1,109 +0,0 @@ -package helpers - -import ( - "bytes" - "context" - "crypto/tls" - "errors" - "net/http" - - "github.com/owncloud/ocis/v2/ocis-pkg/log" - - gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" - rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" - providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" -) - -func DownloadFile( - ctx context.Context, - ref *providerv1beta1.Reference, - gwc gatewayv1beta1.GatewayAPIClient, - token string, - insecure bool, - logger log.Logger, -) (http.Response, error) { - - req := &providerv1beta1.InitiateFileDownloadRequest{ - Ref: ref, - } - - resp, err := gwc.InitiateFileDownload(ctx, req) - if err != nil { - logger.Error(). - Err(err). - Str("FileReference", ref.String()). - Msg("DownloadHelper: InitiateFileDownload failed") - return http.Response{}, err - } - - if resp.Status.Code != rpcv1beta1.Code_CODE_OK { - logger.Error(). - Str("FileReference", ref.String()). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("DownloadHelper: InitiateFileDownload failed with wrong status") - return http.Response{}, errors.New("InitiateFileDownload failed with status " + resp.Status.Code.String()) - } - - downloadEndpoint := "" - downloadToken := "" - hasDownloadToken := false - - for _, proto := range resp.Protocols { - if proto.Protocol == "simple" || proto.Protocol == "spaces" { - downloadEndpoint = proto.DownloadEndpoint - downloadToken = proto.Token - hasDownloadToken = proto.Token != "" - break - } - } - - if downloadEndpoint == "" { - logger.Error(). - Str("FileReference", ref.String()). - Str("Endpoint", downloadEndpoint). - Bool("HasDownloadToken", hasDownloadToken). - Msg("DownloadHelper: Download endpoint or token is missing") - return http.Response{}, errors.New("download endpoint is missing") - } - - httpClient := http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: insecure, - }, - }, - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadEndpoint, bytes.NewReader([]byte(""))) - if err != nil { - logger.Error(). - Err(err). - Str("FileReference", ref.String()). - Str("Endpoint", downloadEndpoint). - Bool("HasDownloadToken", hasDownloadToken). - Msg("DownloadHelper: Could not create the request to the endpoint") - return http.Response{}, err - } - if downloadToken != "" { - // public link downloads have the token in the download endpoint - httpReq.Header.Add("X-Reva-Transfer", downloadToken) - } - // TODO: the access token shouldn't be needed - httpReq.Header.Add("X-Access-Token", token) - - // TODO: this needs a refactor to comply with the "bodyclose" linter - // response body is closed in the caller method for now - httpResp, err := httpClient.Do(httpReq) //nolint:bodyclose - if err != nil { - logger.Error(). - Err(err). - Str("FileReference", ref.String()). - Str("Endpoint", downloadEndpoint). - Bool("HasDownloadToken", hasDownloadToken). - Msg("DownloadHelper: Get request to the download endpoint failed") - return http.Response{}, err - } - - return *httpResp, nil -} diff --git a/services/collaboration/pkg/internal/helpers/upload.go b/services/collaboration/pkg/internal/helpers/upload.go deleted file mode 100644 index 0a40220a71..0000000000 --- a/services/collaboration/pkg/internal/helpers/upload.go +++ /dev/null @@ -1,169 +0,0 @@ -package helpers - -import ( - "context" - "crypto/tls" - "errors" - "io" - "net/http" - "strconv" - "time" - - gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" - rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" - providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" - types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" - "github.com/owncloud/ocis/v2/ocis-pkg/log" -) - -func UploadFile( - ctx context.Context, - content io.Reader, // content won't be closed inside the method - contentLength int64, - ref *providerv1beta1.Reference, - gwc gatewayv1beta1.GatewayAPIClient, - token string, - lockID string, - insecure bool, - logger log.Logger, -) error { - opaque := &types.Opaque{ - Map: make(map[string]*types.OpaqueEntry), - } - - strContentLength := strconv.FormatInt(contentLength, 10) - if contentLength >= 0 { - opaque.Map["Upload-Length"] = &types.OpaqueEntry{ - Decoder: "plain", - Value: []byte(strContentLength), - } - } - - req := &providerv1beta1.InitiateFileUploadRequest{ - Opaque: opaque, - Ref: ref, - LockId: lockID, - // TODO: if-match - //Options: &providerv1beta1.InitiateFileUploadRequest_IfMatch{ - // IfMatch: "", - //}, - } - - resp, err := gwc.InitiateFileUpload(ctx, req) - if err != nil { - logger.Error(). - Err(err). - Str("FileReference", ref.String()). - Str("RequestedLockID", lockID). - Str("UploadLength", strContentLength). - Msg("UploadHelper: InitiateFileUpload failed") - return err - } - - if resp.Status.Code != rpcv1beta1.Code_CODE_OK { - logger.Error(). - Str("FileReference", ref.String()). - Str("RequestedLockID", lockID). - Str("UploadLength", strContentLength). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). - Msg("UploadHelper: InitiateFileUpload failed with wrong status") - return errors.New("InitiateFileUpload failed with status " + resp.Status.Code.String()) - } - - // if content length is 0, we're done. We don't upload anything to the target endpoint - if contentLength == 0 { - return nil - } - - uploadEndpoint := "" - uploadToken := "" - hasUploadToken := false - - for _, proto := range resp.Protocols { - if proto.Protocol == "simple" || proto.Protocol == "spaces" { - uploadEndpoint = proto.UploadEndpoint - uploadToken = proto.Token - hasUploadToken = proto.Token != "" - break - } - } - - if uploadEndpoint == "" { - logger.Error(). - Str("FileReference", ref.String()). - Str("RequestedLockID", lockID). - Str("UploadLength", strContentLength). - Str("Endpoint", uploadEndpoint). - Bool("HasUploadToken", hasUploadToken). - Msg("UploadHelper: Upload endpoint or token is missing") - return errors.New("upload endpoint or token is missing") - } - - httpClient := http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: insecure, - }, - }, - Timeout: 10 * time.Second, - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadEndpoint, content) - if err != nil { - logger.Error(). - Err(err). - Str("FileReference", ref.String()). - Str("RequestedLockID", lockID). - Str("UploadLength", strContentLength). - Str("Endpoint", uploadEndpoint). - Bool("HasUploadToken", hasUploadToken). - Msg("UploadHelper: Could not create the request to the endpoint") - return err - } - // "content" is an *http.body and doesn't fill the httpReq.ContentLength automatically - // we need to fill the ContentLength ourselves, and must match the stream length in order - // to prevent issues - httpReq.ContentLength = contentLength - - if uploadToken != "" { - // public link uploads have the token in the upload endpoint - httpReq.Header.Add("X-Reva-Transfer", uploadToken) - } - // TODO: the access token shouldn't be needed - httpReq.Header.Add("X-Access-Token", token) - - httpReq.Header.Add("X-Lock-Id", lockID) - // TODO: better mechanism for the upload while locked, relies on patch in REVA - //if lockID, ok := ctxpkg.ContextGetLockID(ctx); ok { - // httpReq.Header.Add("X-Lock-Id", lockID) - //} - - httpResp, err := httpClient.Do(httpReq) - if err != nil { - logger.Error(). - Err(err). - Str("FileReference", ref.String()). - Str("RequestedLockID", lockID). - Str("UploadLength", strContentLength). - Str("Endpoint", uploadEndpoint). - Bool("HasUploadToken", hasUploadToken). - Msg("UploadHelper: Put request to the upload endpoint failed") - return err - } - defer httpResp.Body.Close() - - if httpResp.StatusCode != http.StatusOK { - logger.Error(). - Str("FileReference", ref.String()). - Str("RequestedLockID", lockID). - Str("UploadLength", strContentLength). - Str("Endpoint", uploadEndpoint). - Bool("HasUploadToken", hasUploadToken). - Int("HttpCode", httpResp.StatusCode). - Msg("UploadHelper: Put request to the upload endpoint failed with unexpected status") - return errors.New("Put request failed with status " + strconv.Itoa(httpResp.StatusCode)) - } - - return nil -} diff --git a/services/collaboration/pkg/internal/logging/logging.go b/services/collaboration/pkg/internal/logging/logging.go deleted file mode 100644 index 91d896e349..0000000000 --- a/services/collaboration/pkg/internal/logging/logging.go +++ /dev/null @@ -1,17 +0,0 @@ -package logging - -import ( - "github.com/owncloud/ocis/v2/ocis-pkg/log" - "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" -) - -// LoggerFromConfig initializes a service-specific logger instance. -func Configure(name string, cfg *config.Log) log.Logger { - return log.NewLogger( - log.Name(name), - log.Level(cfg.Level), - log.Pretty(cfg.Pretty), - log.Color(cfg.Color), - log.File(cfg.File), - ) -} diff --git a/services/collaboration/pkg/server/http/option.go b/services/collaboration/pkg/server/http/option.go index acffb503bb..10628a0fc2 100644 --- a/services/collaboration/pkg/server/http/option.go +++ b/services/collaboration/pkg/server/http/option.go @@ -5,7 +5,7 @@ import ( "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" - "github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/app" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/connector" "go.opentelemetry.io/otel/trace" ) @@ -14,7 +14,7 @@ type Option func(o *Options) // Options defines the available options for this package. type Options struct { - App *app.DemoApp + Adapter *connector.HttpAdapter Logger log.Logger Context context.Context Config *config.Config @@ -33,9 +33,9 @@ func newOptions(opts ...Option) Options { } // App provides a function to set the logger option. -func App(val *app.DemoApp) Option { +func Adapter(val *connector.HttpAdapter) Option { return func(o *Options) { - o.App = val + o.Adapter = val } } diff --git a/services/collaboration/pkg/server/http/server.go b/services/collaboration/pkg/server/http/server.go index c93f3ac1ae..bdd2f2c2b8 100644 --- a/services/collaboration/pkg/server/http/server.go +++ b/services/collaboration/pkg/server/http/server.go @@ -8,6 +8,7 @@ import ( "github.com/go-chi/chi/v5" chimiddleware "github.com/go-chi/chi/v5/middleware" "github.com/owncloud/ocis/v2/ocis-pkg/account" + "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/ocis-pkg/middleware" "github.com/owncloud/ocis/v2/ocis-pkg/service/http" "github.com/owncloud/ocis/v2/ocis-pkg/tracing" @@ -76,7 +77,7 @@ func Server(opts ...Option) (http.Service, error) { ), ) - prepareRoutes(mux, options.App) + prepareRoutes(mux, options) if err := micro.RegisterHandler(service.Server(), mux); err != nil { return http.Service{}, err @@ -85,23 +86,37 @@ func Server(opts ...Option) (http.Service, error) { return service, nil } -func prepareRoutes(r *chi.Mux, demoapp *app.DemoApp) { +func prepareRoutes(r *chi.Mux, options Options) { + adapter := options.Adapter + logger := options.Logger + // prepare basic logger for the request + r.Use(func(h stdhttp.Handler) stdhttp.Handler { + return stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + ctx := logger.With(). + Str(log.RequestIDString, r.Header.Get("X-Request-ID")). + Str("proto", r.Proto). + Str("method", r.Method). + Str("path", r.URL.Path). + Logger().WithContext(r.Context()) + h.ServeHTTP(w, r.WithContext(ctx)) + }) + }) r.Route("/wopi", func(r chi.Router) { r.Get("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) { - app.WopiInfoHandler(demoapp, w, r) + stdhttp.Error(w, stdhttp.StatusText(stdhttp.StatusTeapot), stdhttp.StatusTeapot) }) r.Route("/files/{fileid}", func(r chi.Router) { r.Use(func(h stdhttp.Handler) stdhttp.Handler { // authentication and wopi context - return app.WopiContextAuthMiddleware(demoapp, h) + return app.WopiContextAuthMiddleware(options.Config.JWTSecret, h) }, ) r.Get("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) { - app.CheckFileInfo(demoapp, w, r) + adapter.CheckFileInfo(w, r) }) r.Post("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) { @@ -110,13 +125,13 @@ func prepareRoutes(r *chi.Mux, demoapp *app.DemoApp) { case "LOCK": // "UnlockAndRelock" operation goes through here - app.Lock(demoapp, w, r) + adapter.Lock(w, r) case "GET_LOCK": - app.GetLock(demoapp, w, r) + adapter.GetLock(w, r) case "REFRESH_LOCK": - app.RefreshLock(demoapp, w, r) + adapter.RefreshLock(w, r) case "UNLOCK": - app.UnLock(demoapp, w, r) + adapter.UnLock(w, r) case "PUT_USER_INFO": // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/putuserinfo @@ -138,7 +153,7 @@ func prepareRoutes(r *chi.Mux, demoapp *app.DemoApp) { r.Route("/contents", func(r chi.Router) { r.Get("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) { - app.GetFile(demoapp, w, r) + adapter.GetFile(w, r) }) r.Post("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) { @@ -146,7 +161,7 @@ func prepareRoutes(r *chi.Mux, demoapp *app.DemoApp) { switch action { case "PUT": - app.PutFile(demoapp, w, r) + adapter.PutFile(w, r) default: stdhttp.Error(w, stdhttp.StatusText(stdhttp.StatusInternalServerError), stdhttp.StatusInternalServerError) From 0a413223b90eff7fe66d423382c54c3352591bcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Wed, 20 Mar 2024 11:05:43 +0100 Subject: [PATCH 15/29] refactor: move wopi context and related to middleware package --- .../pkg/connector/contentconnector.go | 6 ++-- .../pkg/connector/fileconnector.go | 22 ++++++------- .../{internal/app => connector}/fileinfo.go | 2 +- .../pkg/internal/middleware/accesslog.go | 32 ------------------- .../{internal/app => middleware}/claims.go | 2 +- .../{internal/app => middleware}/crypto.go | 2 +- .../app => middleware}/wopicontext.go | 2 +- .../collaboration/pkg/server/http/server.go | 4 +-- .../pkg/service/grpc/v0/service.go | 8 ++--- 9 files changed, 24 insertions(+), 56 deletions(-) rename services/collaboration/pkg/{internal/app => connector}/fileinfo.go (99%) delete mode 100644 services/collaboration/pkg/internal/middleware/accesslog.go rename services/collaboration/pkg/{internal/app => middleware}/claims.go (87%) rename services/collaboration/pkg/{internal/app => middleware}/crypto.go (98%) rename services/collaboration/pkg/{internal/app => middleware}/wopicontext.go (99%) diff --git a/services/collaboration/pkg/connector/contentconnector.go b/services/collaboration/pkg/connector/contentconnector.go index 014cdac46a..042edc3e19 100644 --- a/services/collaboration/pkg/connector/contentconnector.go +++ b/services/collaboration/pkg/connector/contentconnector.go @@ -14,7 +14,7 @@ import ( providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" - "github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/app" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/middleware" "github.com/rs/zerolog" ) @@ -33,7 +33,7 @@ func NewContentConnector(gwc gatewayv1beta1.GatewayAPIClient, cfg *config.Config // GetFile downloads the file from the storage // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/getfile func (c *ContentConnector) GetFile(ctx context.Context, writer io.Writer) error { - wopiContext, err := app.WopiContextFromCtx(ctx) + wopiContext, err := middleware.WopiContextFromCtx(ctx) if err != nil { return err } @@ -140,7 +140,7 @@ func (c *ContentConnector) GetFile(ctx context.Context, writer io.Writer) error // PutFile uploads the file to the storage // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/putfile func (c *ContentConnector) PutFile(ctx context.Context, stream io.Reader, streamLength int64, lockID string) (string, error) { - wopiContext, err := app.WopiContextFromCtx(ctx) + wopiContext, err := middleware.WopiContextFromCtx(ctx) if err != nil { return "", err } diff --git a/services/collaboration/pkg/connector/fileconnector.go b/services/collaboration/pkg/connector/fileconnector.go index 94fea49175..7972ba3497 100644 --- a/services/collaboration/pkg/connector/fileconnector.go +++ b/services/collaboration/pkg/connector/fileconnector.go @@ -14,7 +14,7 @@ import ( typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" "github.com/google/uuid" "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" - "github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/app" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/middleware" "github.com/rs/zerolog" ) @@ -39,7 +39,7 @@ func NewFileConnector(gwc gatewayv1beta1.GatewayAPIClient, cfg *config.Config) * // GetLock returns a lock or an empty string if no lock exists // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/getlock func (f *FileConnector) GetLock(ctx context.Context) (string, error) { - wopiContext, err := app.WopiContextFromCtx(ctx) + wopiContext, err := middleware.WopiContextFromCtx(ctx) if err != nil { return "", err } @@ -81,7 +81,7 @@ func (f *FileConnector) GetLock(ctx context.Context) (string, error) { // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/lock // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/unlockandrelock func (f *FileConnector) Lock(ctx context.Context, lockID, oldLockID string) (string, error) { - wopiContext, err := app.WopiContextFromCtx(ctx) + wopiContext, err := middleware.WopiContextFromCtx(ctx) if err != nil { return "", err } @@ -205,7 +205,7 @@ func (f *FileConnector) Lock(ctx context.Context, lockID, oldLockID string) (str // RefreshLock refreshes a provided lock for 30 minutes // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/refreshlock func (f *FileConnector) RefreshLock(ctx context.Context, lockID string) (string, error) { - wopiContext, err := app.WopiContextFromCtx(ctx) + wopiContext, err := middleware.WopiContextFromCtx(ctx) if err != nil { return "", err } @@ -302,7 +302,7 @@ func (f *FileConnector) RefreshLock(ctx context.Context, lockID string) (string, // UnLock removes a given lock from a file // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/unlock func (f *FileConnector) UnLock(ctx context.Context, lockID string) (string, error) { - wopiContext, err := app.WopiContextFromCtx(ctx) + wopiContext, err := middleware.WopiContextFromCtx(ctx) if err != nil { return "", err } @@ -386,10 +386,10 @@ func (f *FileConnector) UnLock(ctx context.Context, lockID string) (string, erro // CheckFileInfo returns information about the requested file and capabilities of the wopi server // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo -func (f *FileConnector) CheckFileInfo(ctx context.Context) (app.FileInfo, error) { - wopiContext, err := app.WopiContextFromCtx(ctx) +func (f *FileConnector) CheckFileInfo(ctx context.Context) (FileInfo, error) { + wopiContext, err := middleware.WopiContextFromCtx(ctx) if err != nil { - return app.FileInfo{}, err + return FileInfo{}, err } logger := zerolog.Ctx(ctx) @@ -399,7 +399,7 @@ func (f *FileConnector) CheckFileInfo(ctx context.Context) (app.FileInfo, error) }) if err != nil { logger.Error().Err(err).Msg("CheckFileInfo: stat failed") - return app.FileInfo{}, err + return FileInfo{}, err } if statRes.Status.Code != rpcv1beta1.Code_CODE_OK { @@ -407,10 +407,10 @@ func (f *FileConnector) CheckFileInfo(ctx context.Context) (app.FileInfo, error) Str("StatusCode", statRes.Status.Code.String()). Str("StatusMsg", statRes.Status.Message). Msg("CheckFileInfo: stat failed with unexpected status") - return app.FileInfo{}, NewConnectorError(500, statRes.Status.GetCode().String()+" "+statRes.Status.GetMessage()) + return FileInfo{}, NewConnectorError(500, statRes.Status.GetCode().String()+" "+statRes.Status.GetMessage()) } - fileInfo := app.FileInfo{ + fileInfo := FileInfo{ // OwnerID must use only alphanumeric chars (https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo/checkfileinfo-response#requirements-for-user-identity-properties) OwnerID: hex.EncodeToString([]byte(statRes.Info.Owner.OpaqueId + "@" + statRes.Info.Owner.Idp)), Size: int64(statRes.Info.Size), diff --git a/services/collaboration/pkg/internal/app/fileinfo.go b/services/collaboration/pkg/connector/fileinfo.go similarity index 99% rename from services/collaboration/pkg/internal/app/fileinfo.go rename to services/collaboration/pkg/connector/fileinfo.go index b204ac478c..96418b1f80 100644 --- a/services/collaboration/pkg/internal/app/fileinfo.go +++ b/services/collaboration/pkg/connector/fileinfo.go @@ -1,4 +1,4 @@ -package app +package connector type FileInfo struct { // ------------ diff --git a/services/collaboration/pkg/internal/middleware/accesslog.go b/services/collaboration/pkg/internal/middleware/accesslog.go deleted file mode 100644 index 43d96e41bf..0000000000 --- a/services/collaboration/pkg/internal/middleware/accesslog.go +++ /dev/null @@ -1,32 +0,0 @@ -package middleware - -import ( - "net/http" - "time" - - "github.com/go-chi/chi/v5/middleware" - chimiddleware "github.com/go-chi/chi/v5/middleware" - "github.com/owncloud/ocis/v2/ocis-pkg/log" -) - -// AccessLog is a middleware to log http requests at info level logging. -func AccessLog(logger log.Logger) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - wrap := middleware.NewWrapResponseWriter(w, r.ProtoMajor) - next.ServeHTTP(wrap, r) - - logger.Info(). - Str("proto", r.Proto). - Str("request", chimiddleware.GetReqID(r.Context())). - Str("remote-addr", r.RemoteAddr). - Str("method", r.Method). - Int("status", wrap.Status()). - Str("path", r.URL.Path). - Dur("duration", time.Since(start)). - Int("bytes", wrap.BytesWritten()). - Msg("access-log") - }) - } -} diff --git a/services/collaboration/pkg/internal/app/claims.go b/services/collaboration/pkg/middleware/claims.go similarity index 87% rename from services/collaboration/pkg/internal/app/claims.go rename to services/collaboration/pkg/middleware/claims.go index e310c7e3b7..1e2b9adf62 100644 --- a/services/collaboration/pkg/internal/app/claims.go +++ b/services/collaboration/pkg/middleware/claims.go @@ -1,4 +1,4 @@ -package app +package middleware import "github.com/golang-jwt/jwt/v4" diff --git a/services/collaboration/pkg/internal/app/crypto.go b/services/collaboration/pkg/middleware/crypto.go similarity index 98% rename from services/collaboration/pkg/internal/app/crypto.go rename to services/collaboration/pkg/middleware/crypto.go index f30aac26fa..8ab61a2730 100644 --- a/services/collaboration/pkg/internal/app/crypto.go +++ b/services/collaboration/pkg/middleware/crypto.go @@ -1,4 +1,4 @@ -package app +package middleware import ( "crypto/aes" diff --git a/services/collaboration/pkg/internal/app/wopicontext.go b/services/collaboration/pkg/middleware/wopicontext.go similarity index 99% rename from services/collaboration/pkg/internal/app/wopicontext.go rename to services/collaboration/pkg/middleware/wopicontext.go index 806e3cee99..3e3c2e9009 100644 --- a/services/collaboration/pkg/internal/app/wopicontext.go +++ b/services/collaboration/pkg/middleware/wopicontext.go @@ -1,4 +1,4 @@ -package app +package middleware import ( "context" diff --git a/services/collaboration/pkg/server/http/server.go b/services/collaboration/pkg/server/http/server.go index bdd2f2c2b8..e7ce3db96d 100644 --- a/services/collaboration/pkg/server/http/server.go +++ b/services/collaboration/pkg/server/http/server.go @@ -13,7 +13,7 @@ import ( "github.com/owncloud/ocis/v2/ocis-pkg/service/http" "github.com/owncloud/ocis/v2/ocis-pkg/tracing" "github.com/owncloud/ocis/v2/ocis-pkg/version" - "github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/app" + colabmiddleware "github.com/owncloud/ocis/v2/services/collaboration/pkg/middleware" "github.com/riandyrn/otelchi" "go-micro.dev/v4" ) @@ -111,7 +111,7 @@ func prepareRoutes(r *chi.Mux, options Options) { r.Use(func(h stdhttp.Handler) stdhttp.Handler { // authentication and wopi context - return app.WopiContextAuthMiddleware(options.Config.JWTSecret, h) + return colabmiddleware.WopiContextAuthMiddleware(options.Config.JWTSecret, h) }, ) diff --git a/services/collaboration/pkg/service/grpc/v0/service.go b/services/collaboration/pkg/service/grpc/v0/service.go index 90b2da7375..038a44d142 100644 --- a/services/collaboration/pkg/service/grpc/v0/service.go +++ b/services/collaboration/pkg/service/grpc/v0/service.go @@ -18,7 +18,7 @@ import ( "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" - "github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/app" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/middleware" ) func NewHandler(opts ...Option) (*Service, func(), error) { @@ -148,7 +148,7 @@ func (s *Service) OpenInApp( appURL = editAppURL } - cryptedReqAccessToken, err := app.EncryptAES([]byte(s.config.JWTSecret), req.AccessToken) + cryptedReqAccessToken, err := middleware.EncryptAES([]byte(s.config.JWTSecret), req.AccessToken) if err != nil { s.logger.Error(). Err(err). @@ -161,7 +161,7 @@ func (s *Service) OpenInApp( }, err } - wopiContext := app.WopiContext{ + wopiContext := middleware.WopiContext{ AccessToken: cryptedReqAccessToken, FileReference: providerFileRef, User: user, @@ -183,7 +183,7 @@ func (s *Service) OpenInApp( return nil, err } - claims := &app.Claims{ + claims := &middleware.Claims{ WopiContext: wopiContext, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: cs3Claims.ExpiresAt, From ce6ed399a94fd8b1d78ec4bd225c376ecc4138ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Wed, 20 Mar 2024 17:30:07 +0100 Subject: [PATCH 16/29] refactor: move components to setup the service into a helpers package --- services/collaboration/pkg/command/server.go | 25 +++- .../collaboration/pkg/cs3wopiserver/start.go | 43 ------- services/collaboration/pkg/helpers/cs3.go | 23 ++++ .../wopidiscovery.go => helpers/discovery.go} | 22 +--- .../collaboration/pkg/helpers/registration.go | 73 +++++++++++ .../collaboration/pkg/internal/app/app.go | 113 ------------------ .../collaboration/pkg/server/grpc/option.go | 9 +- .../collaboration/pkg/server/grpc/server.go | 2 +- 8 files changed, 127 insertions(+), 183 deletions(-) delete mode 100644 services/collaboration/pkg/cs3wopiserver/start.go create mode 100644 services/collaboration/pkg/helpers/cs3.go rename services/collaboration/pkg/{internal/app/wopidiscovery.go => helpers/discovery.go} (83%) create mode 100644 services/collaboration/pkg/helpers/registration.go delete mode 100644 services/collaboration/pkg/internal/app/app.go diff --git a/services/collaboration/pkg/command/server.go b/services/collaboration/pkg/command/server.go index a7eba82730..b118d64d70 100644 --- a/services/collaboration/pkg/command/server.go +++ b/services/collaboration/pkg/command/server.go @@ -12,7 +12,7 @@ import ( "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" "github.com/owncloud/ocis/v2/services/collaboration/pkg/config/parser" "github.com/owncloud/ocis/v2/services/collaboration/pkg/connector" - "github.com/owncloud/ocis/v2/services/collaboration/pkg/cs3wopiserver" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/helpers" "github.com/owncloud/ocis/v2/services/collaboration/pkg/server/grpc" "github.com/owncloud/ocis/v2/services/collaboration/pkg/server/http" "github.com/urfave/cli/v2" @@ -49,12 +49,28 @@ func Server(cfg *config.Config) *cli.Command { }() defer cancel() - app, err := cs3wopiserver.Start(cfg, logger) // grpc server needs decoupling + // prepare components + if err := helpers.RegisterOcisService(ctx, cfg, logger); err != nil { + return err + } + + gwc, err := helpers.GetCS3apiClient(cfg, false) if err != nil { return err } + + appUrls, err := helpers.GetAppURLs(cfg, logger) + if err != nil { + return err + } + + if err := helpers.RegisterAppProvider(ctx, cfg, logger, gwc, appUrls); err != nil { + return err + } + + // start GRPC server grpcServer, teardown, err := grpc.Server( - grpc.App(app), + grpc.AppURLs(appUrls), grpc.Config(cfg), grpc.Logger(logger), ) @@ -98,8 +114,9 @@ func Server(cfg *config.Config) *cli.Command { cancel() }) */ + // start HTTP server server, err := http.Server( - http.Adapter(connector.NewHttpAdapter(app.GetGwc(), cfg)), + http.Adapter(connector.NewHttpAdapter(gwc, cfg)), http.Logger(logger), http.Config(cfg), http.Context(ctx), diff --git a/services/collaboration/pkg/cs3wopiserver/start.go b/services/collaboration/pkg/cs3wopiserver/start.go deleted file mode 100644 index 9bc28ca1a7..0000000000 --- a/services/collaboration/pkg/cs3wopiserver/start.go +++ /dev/null @@ -1,43 +0,0 @@ -package cs3wopiserver - -import ( - "context" - - "github.com/owncloud/ocis/v2/ocis-pkg/log" - "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" - "github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/app" -) - -func Start(cfg *config.Config, logger log.Logger) (*app.DemoApp, error) { - ctx := context.Background() - - app, err := app.New(cfg, logger) - if err != nil { - return nil, err - } - - if err := app.RegisterOcisService(ctx); err != nil { - return nil, err - } - - if err := app.WopiDiscovery(ctx); err != nil { - return nil, err - } - - if err := app.GetCS3apiClient(); err != nil { - return nil, err - } - - if err := app.RegisterDemoApp(ctx); err != nil { - return nil, err - } - - // NOTE: - // GRPC and HTTP server are started using the standard - // `ocis collaboration server` command through the usual means - - // TODO: - // "app" initialization needs to be moved - - return app, nil -} diff --git a/services/collaboration/pkg/helpers/cs3.go b/services/collaboration/pkg/helpers/cs3.go new file mode 100644 index 0000000000..a1cb1cfac3 --- /dev/null +++ b/services/collaboration/pkg/helpers/cs3.go @@ -0,0 +1,23 @@ +package helpers + +import ( + gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + "github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" +) + +var commonCS3ApiClient gatewayv1beta1.GatewayAPIClient + +func GetCS3apiClient(cfg *config.Config, forceNew bool) (gatewayv1beta1.GatewayAPIClient, error) { + // establish a connection to the cs3 api endpoint + // in this case a REVA gateway, started by oCIS + if commonCS3ApiClient != nil && !forceNew { + return commonCS3ApiClient, nil + } + + client, err := pool.GetGatewayServiceClient(cfg.CS3Api.Gateway.Name) + if err == nil { + commonCS3ApiClient = client + } + return client, err +} diff --git a/services/collaboration/pkg/internal/app/wopidiscovery.go b/services/collaboration/pkg/helpers/discovery.go similarity index 83% rename from services/collaboration/pkg/internal/app/wopidiscovery.go rename to services/collaboration/pkg/helpers/discovery.go index 1e8ae29bef..bbc388c932 100644 --- a/services/collaboration/pkg/internal/app/wopidiscovery.go +++ b/services/collaboration/pkg/helpers/discovery.go @@ -1,7 +1,6 @@ -package app +package helpers import ( - "context" "crypto/tls" "io" "net/http" @@ -10,28 +9,17 @@ import ( "github.com/beevik/etree" "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" "github.com/pkg/errors" ) -func (app *DemoApp) WopiDiscovery(ctx context.Context) error { - res, err := getAppURLs(app.Config.WopiApp.Addr, app.Config.WopiApp.Insecure, app.Logger) - if err != nil { - // logging is already covered inside the `getAppURLs` function - return err - } - - app.AppURLs = res - return nil -} - -func getAppURLs(wopiAppUrl string, insecure bool, logger log.Logger) (map[string]map[string]string, error) { - - wopiAppUrl = wopiAppUrl + "/hosting/discovery" +func GetAppURLs(cfg *config.Config, logger log.Logger) (map[string]map[string]string, error) { + wopiAppUrl := cfg.WopiApp.Addr + "/hosting/discovery" httpClient := http.Client{ Transport: &http.Transport{ TLSClientConfig: &tls.Config{ - InsecureSkipVerify: insecure, + InsecureSkipVerify: cfg.WopiApp.Insecure, }, }, } diff --git a/services/collaboration/pkg/helpers/registration.go b/services/collaboration/pkg/helpers/registration.go new file mode 100644 index 0000000000..06de9cd51c --- /dev/null +++ b/services/collaboration/pkg/helpers/registration.go @@ -0,0 +1,73 @@ +package helpers + +import ( + "context" + "errors" + + registryv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/registry/v1beta1" + gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + "github.com/cs3org/reva/v2/pkg/mime" + "github.com/gofrs/uuid" + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/ocis-pkg/registry" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" +) + +func RegisterOcisService(ctx context.Context, cfg *config.Config, logger log.Logger) error { + svc := registry.BuildGRPCService(cfg.Service.Name, uuid.Must(uuid.NewV4()).String(), cfg.GRPC.Addr, "0.0.0") + return registry.RegisterService(ctx, svc, logger) +} + +func RegisterAppProvider( + ctx context.Context, + cfg *config.Config, + logger log.Logger, + gwc gatewayv1beta1.GatewayAPIClient, + appUrls map[string]map[string]string, +) error { + mimeTypesMap := make(map[string]bool) + for _, extensions := range appUrls { + for ext := range extensions { + m := mime.Detect(false, ext) + mimeTypesMap[m] = true + } + } + + mimeTypes := make([]string, 0, len(mimeTypesMap)) + for m := range mimeTypesMap { + mimeTypes = append(mimeTypes, m) + } + + logger.Debug(). + Str("AppName", cfg.App.Name). + Strs("Mimetypes", mimeTypes). + Msg("Registering mimetypes in the app provider") + // TODO: REVA has way to filter supported mimetypes (do we need to implement it here or is it in the registry?) + + // TODO: an added app provider shouldn't last forever. Instead the registry should use a TTL + // and delete providers that didn't register again. If an app provider dies or get's disconnected, + // the users will be no longer available to choose to open a file with it (currently, opening a file just fails) + req := ®istryv1beta1.AddAppProviderRequest{ + Provider: ®istryv1beta1.ProviderInfo{ + Name: cfg.App.Name, + Description: cfg.App.Description, + Icon: cfg.App.Icon, + Address: cfg.Service.Name, + MimeTypes: mimeTypes, + }, + } + + resp, err := gwc.AddAppProvider(ctx, req) + if err != nil { + logger.Error().Err(err).Msg("AddAppProvider failed") + return err + } + + if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + logger.Error().Str("status_code", resp.Status.Code.String()).Msg("AddAppProvider failed") + return errors.New("status code != CODE_OK") + } + + return nil +} diff --git a/services/collaboration/pkg/internal/app/app.go b/services/collaboration/pkg/internal/app/app.go deleted file mode 100644 index ab85a6d67e..0000000000 --- a/services/collaboration/pkg/internal/app/app.go +++ /dev/null @@ -1,113 +0,0 @@ -package app - -import ( - "context" - "errors" - - "github.com/owncloud/ocis/v2/ocis-pkg/log" - "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" - - registryv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/registry/v1beta1" - gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" - rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" - "github.com/cs3org/reva/v2/pkg/mime" - "github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool" - "github.com/gofrs/uuid" - "github.com/owncloud/ocis/v2/ocis-pkg/config/envdecode" - "github.com/owncloud/ocis/v2/ocis-pkg/registry" -) - -type DemoApp struct { - gwc gatewayv1beta1.GatewayAPIClient - - AppURLs map[string]map[string]string - - Config *config.Config - - Logger log.Logger -} - -func New(cfg *config.Config, logger log.Logger) (*DemoApp, error) { - app := &DemoApp{ - Config: cfg, - } - - err := envdecode.Decode(app) - if err != nil { - if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) { - return nil, err - } - } - - app.Logger = logger - - return app, nil -} - -func (app *DemoApp) GetGwc() gatewayv1beta1.GatewayAPIClient { - return app.gwc -} - -func (app *DemoApp) GetCS3apiClient() error { - // establish a connection to the cs3 api endpoint - // in this case a REVA gateway, started by oCIS - gwc, err := pool.GetGatewayServiceClient(app.Config.CS3Api.Gateway.Name) - if err != nil { - return err - } - app.gwc = gwc - - return nil -} - -func (app *DemoApp) RegisterOcisService(ctx context.Context) error { - svc := registry.BuildGRPCService(app.Config.Service.Name, uuid.Must(uuid.NewV4()).String(), app.Config.GRPC.Addr, "0.0.0") - return registry.RegisterService(ctx, svc, app.Logger) -} - -func (app *DemoApp) RegisterDemoApp(ctx context.Context) error { - mimeTypesMap := make(map[string]bool) - for _, extensions := range app.AppURLs { - for ext := range extensions { - m := mime.Detect(false, ext) - mimeTypesMap[m] = true - } - } - - mimeTypes := make([]string, 0, len(mimeTypesMap)) - for m := range mimeTypesMap { - mimeTypes = append(mimeTypes, m) - } - - app.Logger.Debug(). - Str("AppName", app.Config.App.Name). - Strs("Mimetypes", mimeTypes). - Msg("Registering mimetypes in the app provider") - // TODO: REVA has way to filter supported mimetypes (do we need to implement it here or is it in the registry?) - - // TODO: an added app provider shouldn't last forever. Instead the registry should use a TTL - // and delete providers that didn't register again. If an app provider dies or get's disconnected, - // the users will be no longer available to choose to open a file with it (currently, opening a file just fails) - req := ®istryv1beta1.AddAppProviderRequest{ - Provider: ®istryv1beta1.ProviderInfo{ - Name: app.Config.App.Name, - Description: app.Config.App.Description, - Icon: app.Config.App.Icon, - Address: app.Config.Service.Name, - MimeTypes: mimeTypes, - }, - } - - resp, err := app.gwc.AddAppProvider(ctx, req) - if err != nil { - app.Logger.Error().Err(err).Msg("AddAppProvider failed") - return err - } - - if resp.Status.Code != rpcv1beta1.Code_CODE_OK { - app.Logger.Error().Str("status_code", resp.Status.Code.String()).Msg("AddAppProvider failed") - return errors.New("status code != CODE_OK") - } - - return nil -} diff --git a/services/collaboration/pkg/server/grpc/option.go b/services/collaboration/pkg/server/grpc/option.go index 281caa8bc5..df6d23d7d1 100644 --- a/services/collaboration/pkg/server/grpc/option.go +++ b/services/collaboration/pkg/server/grpc/option.go @@ -5,7 +5,6 @@ import ( "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" - "github.com/owncloud/ocis/v2/services/collaboration/pkg/internal/app" "go.opentelemetry.io/otel/trace" ) @@ -14,7 +13,7 @@ type Option func(o *Options) // Options defines the available options for this package. type Options struct { - App *app.DemoApp + AppURLs map[string]map[string]string Name string Logger log.Logger Context context.Context @@ -33,10 +32,10 @@ func newOptions(opts ...Option) Options { return opt } -// App provides a function to set the logger option. -func App(val *app.DemoApp) Option { +// AppURLs provides app urls based on mimetypes. +func AppURLs(val map[string]map[string]string) Option { return func(o *Options) { - o.App = val + o.AppURLs = val } } diff --git a/services/collaboration/pkg/server/grpc/server.go b/services/collaboration/pkg/server/grpc/server.go index 7b5a3f7e04..a0be59dcca 100644 --- a/services/collaboration/pkg/server/grpc/server.go +++ b/services/collaboration/pkg/server/grpc/server.go @@ -16,7 +16,7 @@ func Server(opts ...Option) (*grpc.Server, func(), error) { handle, teardown, err := svc.NewHandler( svc.Config(options.Config), svc.Logger(options.Logger), - svc.AppURLs(options.App.AppURLs), + svc.AppURLs(options.AppURLs), ) if err != nil { options.Logger.Error(). From f240d4e7cb70ac8536ee393f01a6fc92a2c4e419 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Wed, 20 Mar 2024 18:45:03 +0100 Subject: [PATCH 17/29] feat: include debug server in collaboration --- services/collaboration/pkg/command/server.go | 35 ++++++----- services/collaboration/pkg/config/config.go | 2 +- .../pkg/config/defaults/defaultconfig.go | 6 ++ .../collaboration/pkg/server/debug/option.go | 50 +++++++++++++++ .../collaboration/pkg/server/debug/server.go | 63 +++++++++++++++++++ 5 files changed, 138 insertions(+), 18 deletions(-) create mode 100644 services/collaboration/pkg/server/debug/option.go create mode 100644 services/collaboration/pkg/server/debug/server.go diff --git a/services/collaboration/pkg/command/server.go b/services/collaboration/pkg/command/server.go index b118d64d70..0ccc2bfd09 100644 --- a/services/collaboration/pkg/command/server.go +++ b/services/collaboration/pkg/command/server.go @@ -13,6 +13,7 @@ import ( "github.com/owncloud/ocis/v2/services/collaboration/pkg/config/parser" "github.com/owncloud/ocis/v2/services/collaboration/pkg/connector" "github.com/owncloud/ocis/v2/services/collaboration/pkg/helpers" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/server/debug" "github.com/owncloud/ocis/v2/services/collaboration/pkg/server/grpc" "github.com/owncloud/ocis/v2/services/collaboration/pkg/server/http" "github.com/urfave/cli/v2" @@ -98,31 +99,31 @@ func Server(cfg *config.Config) *cli.Command { cancel() }) - /* - server, err := debug.Server( - debug.Logger(logger), - debug.Context(ctx), - debug.Config(cfg), - ) - if err != nil { - logger.Info().Err(err).Str("transport", "debug").Msg("Failed to initialize server") - return err - } + // start debug server + debugServer, err := debug.Server( + debug.Logger(logger), + debug.Context(ctx), + debug.Config(cfg), + ) + if err != nil { + logger.Info().Err(err).Str("transport", "debug").Msg("Failed to initialize server") + return err + } + + gr.Add(debugServer.ListenAndServe, func(_ error) { + _ = debugServer.Shutdown(ctx) + cancel() + }) - gr.Add(server.ListenAndServe, func(_ error) { - _ = server.Shutdown(ctx) - cancel() - }) - */ // start HTTP server - server, err := http.Server( + httpServer, err := http.Server( http.Adapter(connector.NewHttpAdapter(gwc, cfg)), http.Logger(logger), http.Config(cfg), http.Context(ctx), http.TracerProvider(traceProvider), ) - gr.Add(server.Run, func(_ error) { + gr.Add(httpServer.Run, func(_ error) { cancel() }) diff --git a/services/collaboration/pkg/config/config.go b/services/collaboration/pkg/config/config.go index 5c3ca9dc57..7654a5e34e 100644 --- a/services/collaboration/pkg/config/config.go +++ b/services/collaboration/pkg/config/config.go @@ -23,7 +23,7 @@ type Config struct { Tracing *Tracing `yaml:"tracing"` Log *Log `yaml:"log"` - //Debug Debug `yaml:"debug"` + Debug Debug `yaml:"debug"` Context context.Context `yaml:"-"` } diff --git a/services/collaboration/pkg/config/defaults/defaultconfig.go b/services/collaboration/pkg/config/defaults/defaultconfig.go index 872b05e5af..ef4edc91ee 100644 --- a/services/collaboration/pkg/config/defaults/defaultconfig.go +++ b/services/collaboration/pkg/config/defaults/defaultconfig.go @@ -37,6 +37,12 @@ func DefaultConfig() *config.Config { Namespace: "com.owncloud.collaboration", Scheme: "https", }, + Debug: config.Debug{ + Addr: "127.0.0.1:9304", + Token: "", + Pprof: false, + Zpages: false, + }, WopiApp: config.WopiApp{ Addr: "https://127.0.0.1:8080", Insecure: false, diff --git a/services/collaboration/pkg/server/debug/option.go b/services/collaboration/pkg/server/debug/option.go new file mode 100644 index 0000000000..283d90d4a3 --- /dev/null +++ b/services/collaboration/pkg/server/debug/option.go @@ -0,0 +1,50 @@ +package debug + +import ( + "context" + + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" +) + +// Option defines a single option function. +type Option func(o *Options) + +// Options defines the available options for this package. +type Options struct { + Logger log.Logger + Context context.Context + Config *config.Config +} + +// newOptions initializes the available default options. +func newOptions(opts ...Option) Options { + opt := Options{} + + for _, o := range opts { + o(&opt) + } + + return opt +} + +// Logger provides a function to set the logger option. +func Logger(val log.Logger) Option { + return func(o *Options) { + o.Logger = val + } +} + +// Context provides a function to set the context option. +func Context(val context.Context) Option { + return func(o *Options) { + o.Context = val + } +} + +// Config provides a function to set the config option. +func Config(val *config.Config) Option { + return func(o *Options) { + o.Config = val + } +} diff --git a/services/collaboration/pkg/server/debug/server.go b/services/collaboration/pkg/server/debug/server.go new file mode 100644 index 0000000000..abcbe9e8a7 --- /dev/null +++ b/services/collaboration/pkg/server/debug/server.go @@ -0,0 +1,63 @@ +package debug + +import ( + "io" + "net/http" + + "github.com/owncloud/ocis/v2/ocis-pkg/service/debug" + "github.com/owncloud/ocis/v2/ocis-pkg/version" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" +) + +// Server initializes the debug service and server. +func Server(opts ...Option) (*http.Server, error) { + options := newOptions(opts...) + + return debug.NewService( + debug.Logger(options.Logger), + debug.Name(options.Config.Service.Name), + debug.Version(version.GetString()), + debug.Address(options.Config.Debug.Addr), + debug.Token(options.Config.Debug.Token), + debug.Pprof(options.Config.Debug.Pprof), + debug.Zpages(options.Config.Debug.Zpages), + debug.Health(health(options.Config)), + debug.Ready(ready(options.Config)), + //debug.CorsAllowedOrigins(options.Config.HTTP.CORS.AllowedOrigins), + //debug.CorsAllowedMethods(options.Config.HTTP.CORS.AllowedMethods), + //debug.CorsAllowedHeaders(options.Config.HTTP.CORS.AllowedHeaders), + //debug.CorsAllowCredentials(options.Config.HTTP.CORS.AllowCredentials), + ), nil +} + +// health implements the health check. +func health(cfg *config.Config) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + + // TODO: check if services are up and running + + _, err := io.WriteString(w, http.StatusText(http.StatusOK)) + // io.WriteString should not fail but if it does we want to know. + if err != nil { + panic(err) + } + } +} + +// ready implements the ready check. +func ready(cfg *config.Config) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + + // TODO: check if services are up and running + + _, err := io.WriteString(w, http.StatusText(http.StatusOK)) + // io.WriteString should not fail but if it does we want to know. + if err != nil { + panic(err) + } + } +} From 4f3ebb245a2ab5615aa4dedc277a87b4d8dc959f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Thu, 21 Mar 2024 10:13:22 +0100 Subject: [PATCH 18/29] feat: add health and version commands --- services/collaboration/pkg/command/health.go | 54 +++++++++++++++++++ services/collaboration/pkg/command/root.go | 4 +- services/collaboration/pkg/command/server.go | 10 +--- services/collaboration/pkg/command/version.go | 50 +++++++++++++++++ services/collaboration/pkg/logging/logging.go | 17 ++++++ 5 files changed, 125 insertions(+), 10 deletions(-) create mode 100644 services/collaboration/pkg/command/health.go create mode 100644 services/collaboration/pkg/command/version.go create mode 100644 services/collaboration/pkg/logging/logging.go diff --git a/services/collaboration/pkg/command/health.go b/services/collaboration/pkg/command/health.go new file mode 100644 index 0000000000..a0fc40bfc7 --- /dev/null +++ b/services/collaboration/pkg/command/health.go @@ -0,0 +1,54 @@ +package command + +import ( + "fmt" + "net/http" + + "github.com/owncloud/ocis/v2/ocis-pkg/config/configlog" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config/parser" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/logging" + "github.com/urfave/cli/v2" +) + +// Health is the entrypoint for the health command. +func Health(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "health", + Usage: "check health status", + Category: "info", + Before: func(c *cli.Context) error { + return configlog.ReturnError(parser.ParseConfig(cfg)) + }, + Action: func(c *cli.Context) error { + logger := logging.Configure(cfg.Service.Name, cfg.Log) + + resp, err := http.Get( + fmt.Sprintf( + "http://%s/healthz", + cfg.Debug.Addr, + ), + ) + + if err != nil { + logger.Fatal(). + Err(err). + Msg("Failed to request health check") + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + logger.Fatal(). + Int("code", resp.StatusCode). + Msg("Health seems to be in bad state") + } + + logger.Debug(). + Int("code", resp.StatusCode). + Msg("Health got a good state") + + return nil + }, + } +} diff --git a/services/collaboration/pkg/command/root.go b/services/collaboration/pkg/command/root.go index 1e73df302f..31fe6db63f 100644 --- a/services/collaboration/pkg/command/root.go +++ b/services/collaboration/pkg/command/root.go @@ -12,8 +12,8 @@ import ( func GetCommands(cfg *config.Config) cli.Commands { return []*cli.Command{ Server(cfg), - //Health(cfg), - //Version(cfg), + Health(cfg), + Version(cfg), } } diff --git a/services/collaboration/pkg/command/server.go b/services/collaboration/pkg/command/server.go index 0ccc2bfd09..c18b641486 100644 --- a/services/collaboration/pkg/command/server.go +++ b/services/collaboration/pkg/command/server.go @@ -7,12 +7,12 @@ import ( "github.com/oklog/run" "github.com/owncloud/ocis/v2/ocis-pkg/config/configlog" - "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/ocis-pkg/tracing" "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" "github.com/owncloud/ocis/v2/services/collaboration/pkg/config/parser" "github.com/owncloud/ocis/v2/services/collaboration/pkg/connector" "github.com/owncloud/ocis/v2/services/collaboration/pkg/helpers" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/logging" "github.com/owncloud/ocis/v2/services/collaboration/pkg/server/debug" "github.com/owncloud/ocis/v2/services/collaboration/pkg/server/grpc" "github.com/owncloud/ocis/v2/services/collaboration/pkg/server/http" @@ -29,13 +29,7 @@ func Server(cfg *config.Config) *cli.Command { return configlog.ReturnFatal(parser.ParseConfig(cfg)) }, Action: func(c *cli.Context) error { - logger := log.NewLogger( - log.Name(cfg.Service.Name), - log.Level(cfg.Log.Level), - log.Pretty(cfg.Log.Pretty), - log.Color(cfg.Log.Color), - log.File(cfg.Log.File), - ) + logger := logging.Configure(cfg.Service.Name, cfg.Log) traceProvider, err := tracing.GetServiceTraceProvider(cfg.Tracing, cfg.Service.Name) if err != nil { return err diff --git a/services/collaboration/pkg/command/version.go b/services/collaboration/pkg/command/version.go new file mode 100644 index 0000000000..57aa0f6ece --- /dev/null +++ b/services/collaboration/pkg/command/version.go @@ -0,0 +1,50 @@ +package command + +import ( + "fmt" + "os" + + "github.com/owncloud/ocis/v2/ocis-pkg/registry" + "github.com/owncloud/ocis/v2/ocis-pkg/version" + + tw "github.com/olekukonko/tablewriter" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" + "github.com/urfave/cli/v2" +) + +// Version prints the service versions of all running instances. +func Version(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "version", + Usage: "print the version of this binary and the running service instances", + Category: "info", + Action: func(c *cli.Context) error { + fmt.Println("Version: " + version.GetString()) + fmt.Printf("Compiled: %s\n", version.Compiled()) + fmt.Println("") + + reg := registry.GetRegistry() + services, err := reg.GetService(cfg.HTTP.Namespace + "." + cfg.Service.Name) + if err != nil { + fmt.Println(fmt.Errorf("could not get %s services from the registry: %v", cfg.Service.Name, err)) + return err + } + + if len(services) == 0 { + fmt.Println("No running " + cfg.Service.Name + " service found.") + return nil + } + + table := tw.NewWriter(os.Stdout) + table.SetHeader([]string{"Version", "Address", "Id"}) + table.SetAutoFormatHeaders(false) + for _, s := range services { + for _, n := range s.Nodes { + table.Append([]string{s.Version, n.Address, n.Id}) + } + } + table.Render() + return nil + }, + } +} diff --git a/services/collaboration/pkg/logging/logging.go b/services/collaboration/pkg/logging/logging.go new file mode 100644 index 0000000000..4bbd15e279 --- /dev/null +++ b/services/collaboration/pkg/logging/logging.go @@ -0,0 +1,17 @@ +package logging + +import ( + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" +) + +// Configure initializes a service-specific logger instance. +func Configure(name string, cfg *config.Log) log.Logger { + return log.NewLogger( + log.Name(name), + log.Level(cfg.Level), + log.Pretty(cfg.Pretty), + log.Color(cfg.Color), + log.File(cfg.File), + ) +} From a9b963019c9323dea4694272b15cb15950262d63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Thu, 21 Mar 2024 13:36:05 +0100 Subject: [PATCH 19/29] refactor: reorganize fileinfo properties and add new ones --- .../pkg/connector/fileconnector.go | 14 +- .../collaboration/pkg/connector/fileinfo.go | 324 ++++++++++++++---- 2 files changed, 262 insertions(+), 76 deletions(-) diff --git a/services/collaboration/pkg/connector/fileconnector.go b/services/collaboration/pkg/connector/fileconnector.go index 7972ba3497..fbe6e83e88 100644 --- a/services/collaboration/pkg/connector/fileconnector.go +++ b/services/collaboration/pkg/connector/fileconnector.go @@ -411,8 +411,8 @@ func (f *FileConnector) CheckFileInfo(ctx context.Context) (FileInfo, error) { } fileInfo := FileInfo{ - // OwnerID must use only alphanumeric chars (https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo/checkfileinfo-response#requirements-for-user-identity-properties) - OwnerID: hex.EncodeToString([]byte(statRes.Info.Owner.OpaqueId + "@" + statRes.Info.Owner.Idp)), + // OwnerId must use only alphanumeric chars (https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo/checkfileinfo-response#requirements-for-user-identity-properties) + OwnerId: hex.EncodeToString([]byte(statRes.Info.Owner.OpaqueId + "@" + statRes.Info.Owner.Idp)), Size: int64(statRes.Info.Size), Version: statRes.Info.Mtime.String(), BaseFileName: path.Base(statRes.Info.Path), @@ -451,11 +451,11 @@ func (f *FileConnector) CheckFileInfo(ctx context.Context) (FileInfo, error) { // user logic from reva wopi driver #TODO: refactor var isPublicShare bool = false if wopiContext.User != nil { - // UserID must use only alphanumeric chars (https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo/checkfileinfo-response#requirements-for-user-identity-properties) + // UserId must use only alphanumeric chars (https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo/checkfileinfo-response#requirements-for-user-identity-properties) if wopiContext.User.Id.Type == userv1beta1.UserType_USER_TYPE_LIGHTWEIGHT { - fileInfo.UserID = hex.EncodeToString([]byte(statRes.Info.Owner.OpaqueId + "@" + statRes.Info.Owner.Idp)) + fileInfo.UserId = hex.EncodeToString([]byte(statRes.Info.Owner.OpaqueId + "@" + statRes.Info.Owner.Idp)) } else { - fileInfo.UserID = hex.EncodeToString([]byte(wopiContext.User.Id.OpaqueId + "@" + wopiContext.User.Id.Idp)) + fileInfo.UserId = hex.EncodeToString([]byte(wopiContext.User.Id.OpaqueId + "@" + wopiContext.User.Id.Idp)) } if wopiContext.User.Opaque != nil { @@ -465,12 +465,12 @@ func (f *FileConnector) CheckFileInfo(ctx context.Context) (FileInfo, error) { } if !isPublicShare { fileInfo.UserFriendlyName = wopiContext.User.Username - fileInfo.UserID = hex.EncodeToString([]byte(wopiContext.User.Id.OpaqueId + "@" + wopiContext.User.Id.Idp)) + fileInfo.UserId = hex.EncodeToString([]byte(wopiContext.User.Id.OpaqueId + "@" + wopiContext.User.Id.Idp)) } } if wopiContext.User == nil || isPublicShare { randomID, _ := uuid.NewUUID() - fileInfo.UserID = hex.EncodeToString([]byte("guest-" + randomID.String())) + fileInfo.UserId = hex.EncodeToString([]byte("guest-" + randomID.String())) fileInfo.UserFriendlyName = "Guest " + randomID.String() fileInfo.IsAnonymousUser = true } diff --git a/services/collaboration/pkg/connector/fileinfo.go b/services/collaboration/pkg/connector/fileinfo.go index 96418b1f80..e9f715ca91 100644 --- a/services/collaboration/pkg/connector/fileinfo.go +++ b/services/collaboration/pkg/connector/fileinfo.go @@ -6,54 +6,90 @@ type FileInfo struct { // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo // ------------ + // + // Required response properties + // + // The string name of the file, including extension, without a path. Used for display in user interface (UI), and determining the extension of the file. BaseFileName string `json:"BaseFileName,omitempty"` //A string that uniquely identifies the owner of the file. In most cases, the user who uploaded or created the file should be considered the owner. - OwnerID string `json:"OwnerId,omitempty"` - // A string value uniquely identifying the user currently accessing the file. - UserID string `json:"UserId,omitempty"` + OwnerId string `json:"OwnerId,omitempty"` // The size of the file in bytes, expressed as a long, a 64-bit signed integer. Size int64 `json:"Size"` + // A string value uniquely identifying the user currently accessing the file. + UserId string `json:"UserId,omitempty"` // The current version of the file based on the server’s file version schema, as a string. This value must change when the file changes, and version values must never repeat for a given file. Version string `json:"Version,omitempty"` + + // + // WOPI host capabilities properties + // + + // An array of strings containing the Share URL types supported by the host. + SupportedShareUrlTypes []string `json:"SupportedShareUrlTypes,omitempty"` + // A Boolean value that indicates that the host supports the following WOPI operations: ExecuteCellStorageRequest, ExecuteCellStorageRelativeRequest + SupportsCobalt bool `json:"SupportsCobalt"` + // A Boolean value that indicates that the host supports the following WOPI operations: CheckContainerInfo, CreateChildContainer, CreateChildFile, DeleteContainer, DeleteFile, EnumerateAncestors (containers), EnumerateAncestors (files), EnumerateChildren (containers), GetEcosystem (containers), RenameContainer + SupportsContainers bool `json:"SupportsContainers"` + // A Boolean value that indicates that the host supports the DeleteFile operation. + SupportsDeleteFile bool `json:"SupportsDeleteFile"` + // A Boolean value that indicates that the host supports the following WOPI operations: CheckEcosystem, GetEcosystem (containers), GetEcosystem (files), GetRootContainer (ecosystem) + SupportsEcosystem bool `json:"SupportsEcosystem"` + // A Boolean value that indicates that the host supports lock IDs up to 1024 ASCII characters long. If not provided, WOPI clients will assume that lock IDs are limited to 256 ASCII characters. + SupportsExtendedLockLength bool `json:"SupportsExtendedLockLength"` + // A Boolean value that indicates that the host supports the following WOPI operations: CheckFolderInfo, EnumerateChildren (folders), DeleteFile + SupportsFolders bool `json:"SupportsFolders"` + // A Boolean value that indicates that the host supports the GetFileWopiSrc (ecosystem) operation. + //SupportsGetFileWopiSrc bool `json:"SupportsGetFileWopiSrc"` // wopivalidator is complaining and the property isn't used for now -> commented + // A Boolean value that indicates that the host supports the GetLock operation. + SupportsGetLock bool `json:"SupportsGetLock"` + // A Boolean value that indicates that the host supports the following WOPI operations: Lock, Unlock, RefreshLock, UnlockAndRelock operations for this file. + SupportsLocks bool `json:"SupportsLocks"` + // A Boolean value that indicates that the host supports the RenameFile operation. + SupportsRename bool `json:"SupportsRename"` + // A Boolean value that indicates that the host supports the following WOPI operations: PutFile, PutRelativeFile + SupportsUpdate bool `json:"SupportsUpdate"` // whether "Putfile" and "PutRelativeFile" work + // A Boolean value that indicates that the host supports the PutUserInfo operation. + SupportsUserInfo bool `json:"SupportsUserInfo"` + + // + // User metadata properties + // + + // A Boolean value indicating whether the user is authenticated with the host or not. Hosts should always set this to true for unauthenticated users, so that clients are aware that the user is anonymous. When setting this to true, hosts can choose to omit the UserId property, but must still set the OwnerId property. + IsAnonymousUser bool `json:"IsAnonymousUser,omitempty"` + // A Boolean value indicating whether the user is an education user or not. + IsEduUser bool `json:"IsEduUser,omitempty"` + // A Boolean value indicating whether the user is a business user or not. + LicenseCheckForEditIsEnabled bool `json:"LicenseCheckForEditIsEnabled,omitempty"` // A string that is the name of the user, suitable for displaying in UI. UserFriendlyName string `json:"UserFriendlyName,omitempty"` - // A 256 bit SHA-2-encoded [FIPS 180-2] hash of the file contents, as a Base64-encoded string. Used for caching purposes in WOPI clients. - SHA256 string `json:"SHA256,omitempty"` - // A string value representing the file extension for the file. This value must begin with a .. If provided, WOPI clients will use this value as the file extension. Otherwise the extension will be parsed from the BaseFileName. - FileExtension string `json:"FileExtension,omitempty"` - // An integer value that indicates the maximum length for file names that the WOPI host supports, excluding the file extension. The default value is 250. Note that WOPI clients will use this default value if the property is omitted or if it is explicitly set to 0. - FileNameMaxLength int `json:"FileNameMaxLength,omitempty"` - // A string that represents the last time that the file was modified. This time must always be a must be a UTC time, and must be formatted in ISO 8601 round-trip format. For example, "2009-06-15T13:45:30.0000000Z". - LastModifiedTime string `json:"LastModifiedTime,omitempty"` // A string value containing information about the user. This string can be passed from a WOPI client to the host by means of a PutUserInfo operation. If the host has a UserInfo string for the user, they must include it in this property. See the PutUserInfo documentation for more details. UserInfo string `json:"UserInfo,omitempty"` + + // + // User permission properties + // + // A Boolean value that indicates that, for this user, the file cannot be changed. ReadOnly bool `json:"ReadOnly"` - // A Boolean value that indicates that if host is temporarily unable to process writes on a file - TemporarilyNotWritable bool `json:"TemporarilyNotWritable,omitempty"` - // A string value indicating whether the current document is shared with other users. The value can change upon adding or removing permissions to other users. Clients should use this value to help decide when to enable collaboration features as a document must be Shared in order to multi-user collaboration on the document. - SharingStatus string `json:"SharingStatus,omitempty"` // A Boolean value that indicates that the WOPI client should restrict what actions the user can perform on the file. The behavior of this property is dependent on the WOPI client. RestrictedWebViewOnly bool `json:"RestrictedWebViewOnly"` // A Boolean value that indicates that the user has permission to view a broadcast of this file. UserCanAttend bool `json:"UserCanAttend"` - // A Boolean value that indicates that the user has permission to broadcast this file to a set of users who have permission to broadcast or view a broadcast of the current file. - UserCanPresent bool `json:"UserCanPresent"` // A Boolean value that indicates the user does not have sufficient permission to create new files on the WOPI server. Setting this to true tells the WOPI client that calls to PutRelativeFile will fail for this user on the current file. UserCanNotWriteRelative bool `json:"UserCanNotWriteRelative"` + // A Boolean value that indicates that the user has permission to broadcast this file to a set of users who have permission to broadcast or view a broadcast of the current file. + UserCanPresent bool `json:"UserCanPresent"` // A Boolean value that indicates the user has permission to rename the current file. UserCanRename bool `json:"UserCanRename"` // A Boolean value that indicates that the user has permission to alter the file. Setting this to true tells the WOPI client that it can call PutFile on behalf of the user. UserCanWrite bool `json:"UserCanWrite"` - // A Boolean value that indicates the WOPI client should close the window or tab when the user activates any Close UI in the WOPI client. - CloseButtonClosesWindow bool `json:"CloseButtonClosesWindow,omitempty"` - // A string value indicating whether the WOPI client should disable Copy and Paste functionality within the application. The default is to permit all Copy and Paste functionality, i.e. the setting has no effect. - CopyPasteRestrictions string `json:"CopyPasteRestrictions,omitempty"` - // A Boolean value that indicates the WOPI client should disable all print functionality. - DisablePrint bool `json:"DisablePrint"` - // A Boolean value that indicates the WOPI client should disable all machine translation functionality. - DisableTranslation bool `json:"DisableTranslation"` + + // + // File URL properties + // + // A URI to a web page that the WOPI client should navigate to when the application closes, or in the event of an unrecoverable error. CloseUrl string `json:"CloseUrl,omitempty"` // A user-accessible URI to the file intended to allow the user to download a copy of the file. @@ -74,6 +110,48 @@ type FileInfo struct { HostViewUrl string `json:"HostViewUrl,omitempty"` // A URI that will sign the current user out of the host’s authentication system. SignoutUrl string `json:"SignoutUrl,omitempty"` + + // + // Miscellaneous properties + // + + // A Boolean value that indicates a WOPI client may connect to Microsoft services to provide end-user functionality. + AllowAdditionalMicrosoftServices bool `json:"AllowAdditionalMicrosoftServices"` + // A Boolean value that indicates that in the event of an error, the WOPI client is permitted to prompt the user for permission to collect a detailed report about their specific error. The information gathered could include the user’s file and other session-specific state. + AllowErrorReportPrompt bool `json:"AllowErrorReportPrompt,omitempty"` + // A Boolean value that indicates a WOPI client may allow connections to external services referenced in the file (for example, a marketplace of embeddable JavaScript apps). + AllowExternalMarketplace bool `json:"AllowExternalMarketplace"` + // A string value offering guidance to the WOPI client as to how to differentiate client throttling behaviors between the user and documents combinations from the WOPI host. + ClientThrottlingProtection string `json:"ClientThrottlingProtection,omitempty"` + // A Boolean value that indicates the WOPI client should close the window or tab when the user activates any Close UI in the WOPI client. + CloseButtonClosesWindow bool `json:"CloseButtonClosesWindow,omitempty"` + // A string value indicating whether the WOPI client should disable Copy and Paste functionality within the application. The default is to permit all Copy and Paste functionality, i.e. the setting has no effect. + CopyPasteRestrictions string `json:"CopyPasteRestrictions,omitempty"` + // A Boolean value that indicates the WOPI client should disable all print functionality. + DisablePrint bool `json:"DisablePrint"` + // A Boolean value that indicates the WOPI client should disable all machine translation functionality. + DisableTranslation bool `json:"DisableTranslation"` + // A string value representing the file extension for the file. This value must begin with a .. If provided, WOPI clients will use this value as the file extension. Otherwise the extension will be parsed from the BaseFileName. + FileExtension string `json:"FileExtension,omitempty"` + // An integer value that indicates the maximum length for file names that the WOPI host supports, excluding the file extension. The default value is 250. Note that WOPI clients will use this default value if the property is omitted or if it is explicitly set to 0. + FileNameMaxLength int `json:"FileNameMaxLength,omitempty"` + // A string that represents the last time that the file was modified. This time must always be a must be a UTC time, and must be formatted in ISO 8601 round-trip format. For example, "2009-06-15T13:45:30.0000000Z". + LastModifiedTime string `json:"LastModifiedTime,omitempty"` + // A string value indicating whether the WOPI host is experiencing capacity problems and would like to reduce the frequency at which the WOPI clients make calls to the host + RequestedCallThrottling string `json:"RequestedCallThrottling,omitempty"` + // A 256 bit SHA-2-encoded [FIPS 180-2] hash of the file contents, as a Base64-encoded string. Used for caching purposes in WOPI clients. + SHA256 string `json:"SHA256,omitempty"` + // A string value indicating whether the current document is shared with other users. The value can change upon adding or removing permissions to other users. Clients should use this value to help decide when to enable collaboration features as a document must be Shared in order to multi-user collaboration on the document. + SharingStatus string `json:"SharingStatus,omitempty"` + // A Boolean value that indicates that if host is temporarily unable to process writes on a file + TemporarilyNotWritable bool `json:"TemporarilyNotWritable,omitempty"` + // In special cases, a host may choose to not provide a SHA256, but still have some mechanism for identifying that two different files contain the same content in the same manner as the SHA256 is used. This string value can be provided rather than a SHA256 value if and only if the host can guarantee that two different files with the same content will have the same UniqueContentId value. + //UniqueContentId string `json:"UniqueContentId,omitempty"` // From microsoft docs: Not supported in CSPP -> commented + + // + // Breadcrumb properties + // + // A string that indicates the brand name of the host. BreadcrumbBrandName string `json:"BreadcrumbBrandName,omitempty"` // A URI to a web page that the WOPI client should navigate to when the user clicks on UI that displays BreadcrumbBrandName. @@ -84,58 +162,166 @@ type FileInfo struct { BreadcrumbFolderName string `json:"BreadcrumbFolderName,omitempty"` // A URI to a web page that the WOPI client should navigate to when the user clicks on UI that displays BreadcrumbFolderName. BreadcrumbFolderUrl string `json:"BreadcrumbFolderUrl,omitempty"` - // A Boolean value that indicates a WOPI client may connect to Microsoft services to provide end-user functionality. - AllowAdditionalMicrosoftServices bool `json:"AllowAdditionalMicrosoftServices"` - // A Boolean value that indicates that in the event of an error, the WOPI client is permitted to prompt the user for permission to collect a detailed report about their specific error. The information gathered could include the user’s file and other session-specific state. - AllowErrorReportPrompt bool `json:"AllowErrorReportPrompt,omitempty"` - // A Boolean value that indicates a WOPI client may allow connections to external services referenced in the file (for example, a marketplace of embeddable JavaScript apps). - AllowExternalMarketplace bool `json:"AllowExternalMarketplace"` - // A string value offering guidance to the WOPI client as to how to differentiate client throttling behaviors between the user and documents combinations from the WOPI host. - ClientThrottlingProtection string `json:"ClientThrottlingProtection,omitempty"` - // A string value indicating whether the WOPI host is experiencing capacity problems and would like to reduce the frequency at which the WOPI clients make calls to the host - RequestedCallThrottling string `json:"RequestedCallThrottling,omitempty"` - // An array of strings containing the Share URL types supported by the host. - SupportedShareUrlTypes []string `json:"SupportedShareUrlTypes,omitempty"` - // A Boolean value that indicates that the host supports the following WOPI operations: ExecuteCellStorageRequest, ExecuteCellStorageRelativeRequest - SupportsCobalt bool `json:"SupportsCobalt"` - // A Boolean value that indicates that the host supports the following WOPI operations: CheckContainerInfo, CreateChildContainer, CreateChildFile, DeleteContainer, DeleteFile, EnumerateAncestors (containers), EnumerateAncestors (files), EnumerateChildren (containers), GetEcosystem (containers), RenameContainer - SupportsContainers bool `json:"SupportsContainers"` - // A Boolean value that indicates that the host supports the following WOPI operations: CheckEcosystem, GetEcosystem (containers), GetEcosystem (files), GetRootContainer (ecosystem) - SupportsEcosystem bool `json:"SupportsEcosystem"` - // A Boolean value that indicates that the host supports lock IDs up to 1024 ASCII characters long. If not provided, WOPI clients will assume that lock IDs are limited to 256 ASCII characters. - SupportsExtendedLockLength bool `json:"SupportsExtendedLockLength"` - // A Boolean value that indicates that the host supports the following WOPI operations: CheckFolderInfo, EnumerateChildren (folders), DeleteFile - SupportsFolders bool `json:"SupportsFolders"` - // A Boolean value that indicates that the host supports the GetFileWopiSrc (ecosystem) operation. - //SupportsGetFileWopiSrc bool `json:"SupportsGetFileWopiSrc"` // wopivalidator is complaining and the property isn't used for now -> commented - // A Boolean value that indicates that the host supports the GetLock operation. - SupportsGetLock bool `json:"SupportsGetLock"` - // A Boolean value that indicates that the host supports the following WOPI operations: Lock, Unlock, RefreshLock, UnlockAndRelock operations for this file. - SupportsLocks bool `json:"SupportsLocks"` - // A Boolean value that indicates that the host supports the RenameFile operation. - SupportsRename bool `json:"SupportsRename"` - // A Boolean value that indicates that the host supports the following WOPI operations: PutFile, PutRelativeFile - SupportsUpdate bool `json:"SupportsUpdate"` // whether "Putfile" and "PutRelativeFile" work - // A Boolean value that indicates that the host supports the DeleteFile operation. - SupportsDeleteFile bool `json:"SupportsDeleteFile"` - // A Boolean value that indicates that the host supports the PutUserInfo operation. - SupportsUserInfo bool `json:"SupportsUserInfo"` - // A Boolean value indicating whether the user is authenticated with the host or not. Hosts should always set this to true for unauthenticated users, so that clients are aware that the user is anonymous. When setting this to true, hosts can choose to omit the UserId property, but must still set the OwnerId property. - IsAnonymousUser bool `json:"IsAnonymousUser,omitempty"` - // A Boolean value indicating whether the user is an education user or not. - IsEduUser bool `json:"IsEduUser,omitempty"` - // A Boolean value indicating whether the user is a business user or not. - LicenseCheckForEditIsEnabled bool `json:"LicenseCheckForEditIsEnabled,omitempty"` // ------------ // Collabora WOPI check file info specification: // https://sdk.collaboraonline.com/docs/advanced_integration.html // ------------ - // If set to true, it allows the document owner (the one with OwnerId =UserId) to send a closedocument message (see protocol.txt) - EnableOwnerTermination bool `json:"EnableOwnerTermination,omitempty"` + // + // Response properties + // + + //BaseFileName -> already in MS WOPI + //DisablePrint -> already in MS WOPI + //OwnerID -> already in MS WOPI + + // A string for the domain the host page sends/receives PostMessages from, we only listen to messages from this domain. + PostMessageOrigin string `json:"PostMessageOrigin,omitempty"` + + //Size -> already in MS WOPI + + // The ID of file (like the wopi/files/ID) can be a non-existing file. In that case, the file will be created from a template when the template (eg. an OTT file) is specified as TemplateSource in the CheckFileInfo response. The TemplateSource is supposed to be an URL like https://somewhere/accessible/file.ott that is accessible by the Online. For the actual saving of the content, normal PutFile mechanism will be used. + TemplateSource string `json:"TemplateSource,omitempty"` + + //UserCanWrite -> already in MS WOPI + //UserCanNotWriteRelative -> already in MS WOPI + //UserId -> already in MS WOPI + //UserFriendlyName -> already in MS WOPI + + // + // Extended response properties + // + + // If set to true, this will enable the insertion of images chosen from the WOPI storage. A UI_InsertGraphic postMessage will be send to the WOPI host to request the UI to select the file. + EnableInsertRemoteImage bool `json:"EnableInsertRemoteImage,omitempty"` + // If set to true, this will disable the insertion of image chosen from the local device. If EnableInsertRemoteImage is not set to true, then inserting images files is not possible. + DisableInsertLocalImage bool `json:"DisableInsertLocalImage,omitempty"` + // If set to true, hides the print option from the file menu bar in the UI. + HidePrintOption bool `json:"HidePrintOption,omitempty"` + // If set to true, hides the save button from the toolbar and file menubar in the UI. + HideSaveOption bool `json:"HideSaveOption,omitempty"` + // Hides Download as option in the file menubar. + HideExportOption bool `json:"HideExportOption,omitempty"` // Disables export functionality in backend. If set to true, HideExportOption is assumed to be true DisableExport bool `json:"DisableExport,omitempty"` // Disables copying from the document in libreoffice online backend. Pasting into the document would still be possible. However, it is still possible to do an “internal” cut/copy/paste. DisableCopy bool `json:"DisableCopy,omitempty"` + // Disables displaying of the explanation text on the overlay when the document becomes inactive or killed. With this, the JS integration must provide the user with appropriate message when it gets Session_Closed or User_Idle postMessages. + DisableInactiveMessages bool `json:"DisableInactiveMessages,omitempty"` + // Indicate that the integration wants to handle the downloading of pdf for printing or svg for slideshows or exported document, because it cannot rely on browser’s support for downloading. + DownloadAsPostMessage bool `json:"DownloadAsPostMessage,omitempty"` + // Similar to download as, doctype extensions can be provided for save-as. In this case the new file is loaded in the integration instead of downloaded. + SaveAsPostmessage bool `json:"SaveAsPostmessage,omitempty"` + // If set to true, it allows the document owner (the one with OwnerId =UserId) to send a closedocument message (see protocol.txt) + EnableOwnerTermination bool `json:"EnableOwnerTermination,omitempty"` + + // JSON object that contains additional info about the user, namely the avatar image. + //UserExtraInfo -> requires definition, currently not used + // JSON object that contains additional info about the user, but unlike the UserExtraInfo it is not shared among the views in collaborative editing sessions. + //UserPrivateInfo -> requires definition, currently not used + + // If set to a non-empty string, is used for rendering a watermark-like text on each tile of the document. + WatermarkText string `json:"WatermarkText,omitempty"` + + // ------------ + // OnlyOffice WOPI check file info specification: + // https://api.onlyoffice.com/editors/wopi/restapi/checkfileinfo + // ------------ + + // + // Required response properties + // + + //BaseFileName -> already in MS WOPI + //Version -> already in MS WOPI + + // + // Breadcrumb properties + // + + //BreadcrumbBrandName -> already in MS WOPI + //BreadcrumbBrandUrl -> already in MS WOPI + //BreadcrumbDocName -> already in MS WOPI + //BreadcrumbFolderName -> already in MS WOPI + //BreadcrumbFolderUrl -> already in MS WOPI + + // + // PostMessage properties + // + + // Specifies if the WOPI client should notify the WOPI server in case the user closes the rendering or editing client currently using this file. The host expects to receive the UI_Close PostMessage when the Close UI in the online office is activated. + ClosePostMessage bool `json:"ClosePostMessage,omitempty"` + // Specifies if the WOPI client should notify the WOPI server in case the user tries to edit a file. The host expects to receive the UI_Edit PostMessage when the Edit UI in the online office is activated. + EditModePostMessage bool `json:"EditModePostMessage,omitempty"` + // Specifies if the WOPI client should notify the WOPI server in case the user tries to edit a file. The host expects to receive the Edit_Notification PostMessage. + EditNotificationPostMessage bool `json:"EditNotificationPostMessage,omitempty"` + // Specifies if the WOPI client should notify the WOPI server in case the user tries to share a file. The host expects to receive the UI_Sharing PostMessage when the Share UI in the online office is activated. + FileSharingPostMessage bool `json:"FileSharingPostMessage,omitempty"` + // Specifies if the WOPI client will notify the WOPI server in case the user tries to navigate to the previous file version. The host expects to receive the UI_FileVersions PostMessage when the Previous Versions UI in the online office is activated. + FileVersionPostMessage bool `json:"FileVersionPostMessage,omitempty"` + // A domain that the WOPI client must use as the targetOrigin parameter when sending messages as described in [W3C-HTML5WEBMSG]. + //PostMessageOrigin -> already in collabora WOPI + + // + // File URL properties + // + + //CloseUrl -> already in MS WOPI + //FileSharingUrl -> already in MS WOPI + //FileVersionUrl -> already in MS WOPI + //HostEditUrl -> already in MS WOPI + + // + // Miscellaneous properties + // + + // Specifies if the WOPI client must disable the Copy and Paste functionality within the application. By default, all Copy and Paste functionality is enabled, i.e. the setting has no effect. Possible property values: + // BlockAll - the Copy and Paste functionality is completely disabled within the application; + // CurrentDocumentOnly - the Copy and Paste functionality is enabled but content can only be copied and pasted within the file currently open in the application. + //CopyPasteRestrictions -> already in MS WOPI + //DisablePrint -> already in MS WOPI + //FileExtension -> already in MS WOPI + //FileNameMaxLength -> already in MS WOPI + //LastModifiedTime -> already in MS WOPI + + // + // User metadata properties + // + + //IsAnonymousUser -> already in MS WOPI + //UserFriendlyName -> already in MS WOPI + //UserId -> already in MS WOPI + + // + // User permissions properties + // + + //ReadOnly -> already in MS WOPI + //UserCanNotWriteRelative -> already in MS WOPI + //UserCanRename -> already in MS WOPI + + // Specifies if the user has permissions to review a file. + UserCanReview bool `json:"UserCanReview,omitempty"` + + //UserCanWrite -> already in MS WOPI + + // + // Host capabilities properties + // + + //SupportsLocks -> already in MS WOPI + //SupportsRename -> already in MS WOPI + + // Specifies if the WOPI server supports the review permission. + SupportsReviewing bool `json:"SupportsReviewing,omitempty"` + + //SupportsUpdate -> already in MS WOPI + + // + // Other properties + // + + //EnableInsertRemoteImage -> already in collabora WOPI + //HidePrintOption -> already in collabora WOPI } From 01d3e84d79433264703830915ea4bddfeacb3b48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Thu, 21 Mar 2024 15:04:15 +0100 Subject: [PATCH 20/29] fix: ensure uploads have the IfMatch option --- services/collaboration/pkg/connector/contentconnector.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/services/collaboration/pkg/connector/contentconnector.go b/services/collaboration/pkg/connector/contentconnector.go index 042edc3e19..4d23c0f328 100644 --- a/services/collaboration/pkg/connector/contentconnector.go +++ b/services/collaboration/pkg/connector/contentconnector.go @@ -203,10 +203,9 @@ func (c *ContentConnector) PutFile(ctx context.Context, stream io.Reader, stream Opaque: opaque, Ref: &wopiContext.FileReference, LockId: lockID, - // TODO: if-match - //Options: &providerv1beta1.InitiateFileUploadRequest_IfMatch{ - // IfMatch: "", - //}, + Options: &providerv1beta1.InitiateFileUploadRequest_IfMatch{ + IfMatch: statRes.Info.Etag, + }, } // Initiate the upload request From c163e668c9f80257fd7079f86b0ea6965ff675b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Fri, 22 Mar 2024 11:01:40 +0100 Subject: [PATCH 21/29] fix: avoid direct access to proto fields --- .../pkg/connector/contentconnector.go | 54 +++---- .../pkg/connector/fileconnector.go | 140 +++++++++--------- .../collaboration/pkg/helpers/registration.go | 4 +- .../pkg/service/grpc/v0/service.go | 30 ++-- 4 files changed, 114 insertions(+), 114 deletions(-) diff --git a/services/collaboration/pkg/connector/contentconnector.go b/services/collaboration/pkg/connector/contentconnector.go index 4d23c0f328..2bd16eaba1 100644 --- a/services/collaboration/pkg/connector/contentconnector.go +++ b/services/collaboration/pkg/connector/contentconnector.go @@ -51,12 +51,12 @@ func (c *ContentConnector) GetFile(ctx context.Context, writer io.Writer) error return err } - if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + if resp.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { logger.Error(). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). + Str("StatusCode", resp.GetStatus().GetCode().String()). + Str("StatusMsg", resp.GetStatus().GetMessage()). Msg("GetFile: InitiateFileDownload failed with wrong status") - return NewConnectorError(500, resp.Status.GetCode().String()+" "+resp.Status.GetMessage()) + return NewConnectorError(500, resp.GetStatus().GetCode().String()+" "+resp.GetStatus().GetMessage()) } // Figure out the download endpoint and download token @@ -64,11 +64,11 @@ func (c *ContentConnector) GetFile(ctx context.Context, writer io.Writer) error downloadToken := "" hasDownloadToken := false - for _, proto := range resp.Protocols { - if proto.Protocol == "simple" || proto.Protocol == "spaces" { - downloadEndpoint = proto.DownloadEndpoint - downloadToken = proto.Token - hasDownloadToken = proto.Token != "" + for _, proto := range resp.GetProtocols() { + if proto.GetProtocol() == "simple" || proto.GetProtocol() == "spaces" { + downloadEndpoint = proto.GetDownloadEndpoint() + downloadToken = proto.GetToken() + hasDownloadToken = proto.GetToken() != "" break } } @@ -160,28 +160,28 @@ func (c *ContentConnector) PutFile(ctx context.Context, stream io.Reader, stream return "", err } - if statRes.Status.Code != rpcv1beta1.Code_CODE_OK { + if statRes.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { logger.Error(). - Str("StatusCode", statRes.Status.Code.String()). - Str("StatusMsg", statRes.Status.Message). + Str("StatusCode", statRes.GetStatus().GetCode().String()). + Str("StatusMsg", statRes.GetStatus().GetMessage()). Msg("PutFile: stat failed with unexpected status") - return "", NewConnectorError(500, statRes.Status.GetCode().String()+" "+statRes.Status.GetMessage()) + return "", NewConnectorError(500, statRes.GetStatus().GetCode().String()+" "+statRes.GetStatus().GetMessage()) } // If there is a lock and it mismatches, return 409 - if statRes.Info.Lock != nil && statRes.Info.Lock.LockId != lockID { + if statRes.GetInfo().GetLock() != nil && statRes.GetInfo().GetLock().GetLockId() != lockID { logger.Error(). - Str("LockID", statRes.Info.Lock.LockId). + Str("LockID", statRes.GetInfo().GetLock().GetLockId()). Msg("PutFile: wrong lock") // onlyoffice says it's required to send the current lockId, MS doesn't say anything - return statRes.Info.Lock.LockId, NewConnectorError(409, "Wrong lock") + return statRes.GetInfo().GetLock().GetLockId(), NewConnectorError(409, "Wrong lock") } // only unlocked uploads can go through if the target file is empty, // otherwise the X-WOPI-Lock header is required even if there is no lock on the file // This is part of the onlyoffice documentation (https://api.onlyoffice.com/editors/wopi/restapi/putfile) // Wopivalidator fails some tests if we don't also check for the X-WOPI-Lock header. - if lockID == "" && statRes.Info.Lock == nil && statRes.Info.Size > 0 { + if lockID == "" && statRes.GetInfo().GetLock() == nil && statRes.GetInfo().GetSize() > 0 { logger.Error().Msg("PutFile: file must be locked first") // onlyoffice says to send an empty string if the file is unlocked, MS doesn't say anything return "", NewConnectorError(409, "File must be locked first") @@ -204,7 +204,7 @@ func (c *ContentConnector) PutFile(ctx context.Context, stream io.Reader, stream Ref: &wopiContext.FileReference, LockId: lockID, Options: &providerv1beta1.InitiateFileUploadRequest_IfMatch{ - IfMatch: statRes.Info.Etag, + IfMatch: statRes.GetInfo().GetEtag(), }, } @@ -215,12 +215,12 @@ func (c *ContentConnector) PutFile(ctx context.Context, stream io.Reader, stream return "", err } - if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + if resp.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { logger.Error(). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). + Str("StatusCode", resp.GetStatus().GetCode().String()). + Str("StatusMsg", resp.GetStatus().GetMessage()). Msg("UploadHelper: InitiateFileUpload failed with wrong status") - return "", NewConnectorError(500, resp.Status.GetCode().String()+" "+resp.Status.GetMessage()) + return "", NewConnectorError(500, resp.GetStatus().GetCode().String()+" "+resp.GetStatus().GetMessage()) } // if the content length is greater than 0, we need to upload the content to the @@ -231,11 +231,11 @@ func (c *ContentConnector) PutFile(ctx context.Context, stream io.Reader, stream uploadToken := "" hasUploadToken := false - for _, proto := range resp.Protocols { - if proto.Protocol == "simple" || proto.Protocol == "spaces" { - uploadEndpoint = proto.UploadEndpoint - uploadToken = proto.Token - hasUploadToken = proto.Token != "" + for _, proto := range resp.GetProtocols() { + if proto.GetProtocol() == "simple" || proto.GetProtocol() == "spaces" { + uploadEndpoint = proto.GetUploadEndpoint() + uploadToken = proto.GetToken() + hasUploadToken = proto.GetToken() != "" break } } diff --git a/services/collaboration/pkg/connector/fileconnector.go b/services/collaboration/pkg/connector/fileconnector.go index fbe6e83e88..bc6f07f370 100644 --- a/services/collaboration/pkg/connector/fileconnector.go +++ b/services/collaboration/pkg/connector/fileconnector.go @@ -56,17 +56,17 @@ func (f *FileConnector) GetLock(ctx context.Context) (string, error) { return "", err } - if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + if resp.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { logger.Error(). - Str("StatusCode", resp.Status.GetCode().String()). - Str("StatusMsg", resp.Status.GetMessage()). + Str("StatusCode", resp.GetStatus().GetCode().String()). + Str("StatusMsg", resp.GetStatus().GetMessage()). Msg("GetLock failed with unexpected status") - return "", NewConnectorError(404, resp.Status.GetCode().String()+" "+resp.Status.GetMessage()) + return "", NewConnectorError(404, resp.GetStatus().GetCode().String()+" "+resp.GetStatus().GetMessage()) } lockID := "" - if resp.Lock != nil { - lockID = resp.Lock.LockId + if resp.GetLock() != nil { + lockID = resp.GetLock().GetLockId() } // log the success at debug level @@ -116,7 +116,7 @@ func (f *FileConnector) Lock(ctx context.Context, lockID, oldLockID string) (str logger.Error().Err(err).Msg("SetLock failed") return "", err } - setOrRefreshStatus = resp.Status + setOrRefreshStatus = resp.GetStatus() } else { // If the oldLockID isn't empty, this is a "UnlockAndRelock" request. We'll // do a "RefreshLock" in reva and provide the old lock @@ -138,11 +138,11 @@ func (f *FileConnector) Lock(ctx context.Context, lockID, oldLockID string) (str logger.Error().Err(err).Msg("UnlockAndRefresh failed") return "", err } - setOrRefreshStatus = resp.Status + setOrRefreshStatus = resp.GetStatus() } // we're checking the status of either the "SetLock" or "RefreshLock" operations - switch setOrRefreshStatus.Code { + switch setOrRefreshStatus.GetCode() { case rpcv1beta1.Code_CODE_OK: logger.Debug().Msg("SetLock successful") return "", nil @@ -162,20 +162,20 @@ func (f *FileConnector) Lock(ctx context.Context, lockID, oldLockID string) (str return "", err } - if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + if resp.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { logger.Error(). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). + Str("StatusCode", resp.GetStatus().GetCode().String()). + Str("StatusMsg", resp.GetStatus().GetMessage()). Msg("SetLock failed, fallback to GetLock failed with unexpected status") } - if resp.Lock != nil { - if resp.Lock.LockId != lockID { + if resp.GetLock() != nil { + if resp.GetLock().GetLockId() != lockID { // lockId is different -> return 409 with the current lockId logger.Warn(). - Str("LockID", resp.Lock.LockId). + Str("LockID", resp.GetLock().GetLockId()). Msg("SetLock conflict") - return resp.Lock.LockId, NewConnectorError(409, "Lock conflict") + return resp.GetLock().GetLockId(), NewConnectorError(409, "Lock conflict") } // TODO: according to the spec we need to treat this as a RefreshLock @@ -184,9 +184,9 @@ func (f *FileConnector) Lock(ctx context.Context, lockID, oldLockID string) (str // Since the lockId matches now, we'll assume success for now. // As said in the todo, we probably should send a "RefreshLock" request here. logger.Warn(). - Str("LockID", resp.Lock.LockId). + Str("LockID", resp.GetLock().GetLockId()). Msg("SetLock lock refreshed instead") - return resp.Lock.LockId, nil + return resp.GetLock().GetLockId(), nil } // TODO: Is this the right error code? @@ -195,8 +195,8 @@ func (f *FileConnector) Lock(ctx context.Context, lockID, oldLockID string) (str default: logger.Error(). - Str("StatusCode", setOrRefreshStatus.Code.String()). - Str("StatusMsg", setOrRefreshStatus.Message). + Str("StatusCode", setOrRefreshStatus.GetCode().String()). + Str("StatusMsg", setOrRefreshStatus.GetMessage()). Msg("SetLock failed with unexpected status") return "", NewConnectorError(500, setOrRefreshStatus.GetCode().String()+" "+setOrRefreshStatus.GetMessage()) } @@ -237,22 +237,22 @@ func (f *FileConnector) RefreshLock(ctx context.Context, lockID string) (string, return "", err } - switch resp.Status.Code { + switch resp.GetStatus().GetCode() { case rpcv1beta1.Code_CODE_OK: logger.Debug().Msg("RefreshLock successful") return "", nil case rpcv1beta1.Code_CODE_NOT_FOUND: logger.Error(). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). + Str("StatusCode", resp.GetStatus().GetCode().String()). + Str("StatusMsg", resp.GetStatus().GetMessage()). Msg("RefreshLock failed, file reference not found") return "", NewConnectorError(404, "File reference not found") case rpcv1beta1.Code_CODE_ABORTED: logger.Error(). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). + Str("StatusCode", resp.GetStatus().GetCode().String()). + Str("StatusMsg", resp.GetStatus().GetMessage()). Msg("RefreshLock failed, lock mismatch") // Either the file is unlocked or there is no lock @@ -267,35 +267,35 @@ func (f *FileConnector) RefreshLock(ctx context.Context, lockID string) (string, return "", err } - if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + if resp.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { logger.Error(). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). + Str("StatusCode", resp.GetStatus().GetCode().String()). + Str("StatusMsg", resp.GetStatus().GetMessage()). Msg("RefreshLock failed, tried to get the current lock failed with unexpected status") - return "", NewConnectorError(500, resp.Status.GetCode().String()+" "+resp.Status.GetMessage()) + return "", NewConnectorError(500, resp.GetStatus().GetCode().String()+" "+resp.GetStatus().GetMessage()) } - if resp.Lock == nil { + if resp.GetLock() == nil { logger.Error(). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). + Str("StatusCode", resp.GetStatus().GetCode().String()). + Str("StatusMsg", resp.GetStatus().GetMessage()). Msg("RefreshLock failed, no lock on file") return "", NewConnectorError(409, "No lock on file") } else { // lock is different than the one requested, otherwise we wouldn't reached this point logger.Error(). - Str("LockID", resp.Lock.LockId). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). + Str("LockID", resp.GetLock().GetLockId()). + Str("StatusCode", resp.GetStatus().GetCode().String()). + Str("StatusMsg", resp.GetStatus().GetMessage()). Msg("RefreshLock failed, lock mismatch") - return resp.Lock.LockId, NewConnectorError(409, "Lock mismatch") + return resp.GetLock().GetLockId(), NewConnectorError(409, "Lock mismatch") } default: logger.Error(). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). + Str("StatusCode", resp.GetStatus().GetCode().String()). + Str("StatusMsg", resp.GetStatus().GetMessage()). Msg("RefreshLock failed with unexpected status") - return "", NewConnectorError(500, resp.Status.GetCode().String()+" "+resp.Status.GetMessage()) + return "", NewConnectorError(500, resp.GetStatus().GetCode().String()+" "+resp.GetStatus().GetMessage()) } } @@ -330,7 +330,7 @@ func (f *FileConnector) UnLock(ctx context.Context, lockID string) (string, erro return "", err } - switch resp.Status.Code { + switch resp.GetStatus().GetCode() { case rpcv1beta1.Code_CODE_OK: logger.Debug().Msg("Unlock successful") return "", nil @@ -350,37 +350,37 @@ func (f *FileConnector) UnLock(ctx context.Context, lockID string) (string, erro return "", err } - if resp.Status.Code != rpcv1beta1.Code_CODE_OK { + if resp.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { logger.Error(). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). + Str("StatusCode", resp.GetStatus().GetCode().String()). + Str("StatusMsg", resp.GetStatus().GetMessage()). Msg("Unlock failed, tried to get the current lock failed with unexpected status") - return "", NewConnectorError(500, resp.Status.GetCode().String()+" "+resp.Status.GetMessage()) + return "", NewConnectorError(500, resp.GetStatus().GetCode().String()+" "+resp.GetStatus().GetMessage()) } var outLockId string - if resp.Lock == nil { + if resp.GetLock() == nil { logger.Error(). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). + Str("StatusCode", resp.GetStatus().GetCode().String()). + Str("StatusMsg", resp.GetStatus().GetMessage()). Msg("Unlock failed, no lock on file") outLockId = "" } else { // lock is different than the one requested, otherwise we wouldn't reached this point logger.Error(). - Str("LockID", resp.Lock.LockId). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). + Str("LockID", resp.GetLock().GetLockId()). + Str("StatusCode", resp.GetStatus().GetCode().String()). + Str("StatusMsg", resp.GetStatus().GetMessage()). Msg("Unlock failed, lock mismatch") - outLockId = resp.Lock.LockId + outLockId = resp.GetLock().GetLockId() } return outLockId, NewConnectorError(409, "Lock mismatch") default: logger.Error(). - Str("StatusCode", resp.Status.Code.String()). - Str("StatusMsg", resp.Status.Message). + Str("StatusCode", resp.GetStatus().GetCode().String()). + Str("StatusMsg", resp.GetStatus().GetMessage()). Msg("Unlock failed with unexpected status") - return "", NewConnectorError(500, resp.Status.GetCode().String()+" "+resp.Status.GetMessage()) + return "", NewConnectorError(500, resp.GetStatus().GetCode().String()+" "+resp.GetStatus().GetMessage()) } } @@ -402,21 +402,21 @@ func (f *FileConnector) CheckFileInfo(ctx context.Context) (FileInfo, error) { return FileInfo{}, err } - if statRes.Status.Code != rpcv1beta1.Code_CODE_OK { + if statRes.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { logger.Error(). - Str("StatusCode", statRes.Status.Code.String()). - Str("StatusMsg", statRes.Status.Message). + Str("StatusCode", statRes.GetStatus().GetCode().String()). + Str("StatusMsg", statRes.GetStatus().GetMessage()). Msg("CheckFileInfo: stat failed with unexpected status") - return FileInfo{}, NewConnectorError(500, statRes.Status.GetCode().String()+" "+statRes.Status.GetMessage()) + return FileInfo{}, NewConnectorError(500, statRes.GetStatus().GetCode().String()+" "+statRes.GetStatus().GetMessage()) } fileInfo := FileInfo{ // OwnerId must use only alphanumeric chars (https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo/checkfileinfo-response#requirements-for-user-identity-properties) - OwnerId: hex.EncodeToString([]byte(statRes.Info.Owner.OpaqueId + "@" + statRes.Info.Owner.Idp)), - Size: int64(statRes.Info.Size), - Version: statRes.Info.Mtime.String(), - BaseFileName: path.Base(statRes.Info.Path), - BreadcrumbDocName: path.Base(statRes.Info.Path), + OwnerId: hex.EncodeToString([]byte(statRes.GetInfo().GetOwner().GetOpaqueId() + "@" + statRes.GetInfo().GetOwner().GetIdp())), + Size: int64(statRes.GetInfo().GetSize()), + Version: statRes.GetInfo().GetMtime().String(), + BaseFileName: path.Base(statRes.GetInfo().GetPath()), + BreadcrumbDocName: path.Base(statRes.GetInfo().GetPath()), // to get the folder we actually need to do a GetPath() request //BreadcrumbFolderName: path.Dir(statRes.Info.Path), @@ -452,20 +452,20 @@ func (f *FileConnector) CheckFileInfo(ctx context.Context) (FileInfo, error) { var isPublicShare bool = false if wopiContext.User != nil { // UserId must use only alphanumeric chars (https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo/checkfileinfo-response#requirements-for-user-identity-properties) - if wopiContext.User.Id.Type == userv1beta1.UserType_USER_TYPE_LIGHTWEIGHT { - fileInfo.UserId = hex.EncodeToString([]byte(statRes.Info.Owner.OpaqueId + "@" + statRes.Info.Owner.Idp)) + if wopiContext.User.GetId().GetType() == userv1beta1.UserType_USER_TYPE_LIGHTWEIGHT { + fileInfo.UserId = hex.EncodeToString([]byte(statRes.GetInfo().GetOwner().GetOpaqueId() + "@" + statRes.GetInfo().GetOwner().GetIdp())) } else { - fileInfo.UserId = hex.EncodeToString([]byte(wopiContext.User.Id.OpaqueId + "@" + wopiContext.User.Id.Idp)) + fileInfo.UserId = hex.EncodeToString([]byte(wopiContext.User.GetId().GetOpaqueId() + "@" + wopiContext.User.GetId().GetIdp())) } - if wopiContext.User.Opaque != nil { - if _, ok := wopiContext.User.Opaque.Map["public-share-role"]; ok { + if wopiContext.User.GetOpaque() != nil { + if _, ok := wopiContext.User.GetOpaque().GetMap()["public-share-role"]; ok { isPublicShare = true } } if !isPublicShare { - fileInfo.UserFriendlyName = wopiContext.User.Username - fileInfo.UserId = hex.EncodeToString([]byte(wopiContext.User.Id.OpaqueId + "@" + wopiContext.User.Id.Idp)) + fileInfo.UserFriendlyName = wopiContext.User.GetUsername() + fileInfo.UserId = hex.EncodeToString([]byte(wopiContext.User.GetId().GetOpaqueId() + "@" + wopiContext.User.GetId().GetIdp())) } } if wopiContext.User == nil || isPublicShare { diff --git a/services/collaboration/pkg/helpers/registration.go b/services/collaboration/pkg/helpers/registration.go index 06de9cd51c..eea4708375 100644 --- a/services/collaboration/pkg/helpers/registration.go +++ b/services/collaboration/pkg/helpers/registration.go @@ -64,8 +64,8 @@ func RegisterAppProvider( return err } - if resp.Status.Code != rpcv1beta1.Code_CODE_OK { - logger.Error().Str("status_code", resp.Status.Code.String()).Msg("AddAppProvider failed") + if resp.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { + logger.Error().Str("status_code", resp.GetStatus().GetCode().String()).Msg("AddAppProvider failed") return errors.New("status code != CODE_OK") } diff --git a/services/collaboration/pkg/service/grpc/v0/service.go b/services/collaboration/pkg/service/grpc/v0/service.go index 038a44d142..6edee2d8ec 100644 --- a/services/collaboration/pkg/service/grpc/v0/service.go +++ b/services/collaboration/pkg/service/grpc/v0/service.go @@ -56,12 +56,12 @@ func (s *Service) OpenInApp( // get the current user var user *userv1beta1.User = nil meReq := &gatewayv1beta1.WhoAmIRequest{ - Token: req.AccessToken, + Token: req.GetAccessToken(), } meResp, err := s.gwc.WhoAmI(ctx, meReq) if err == nil { - if meResp.Status.Code == rpcv1beta1.Code_CODE_OK { - user = meResp.User + if meResp.GetStatus().GetCode() == rpcv1beta1.Code_CODE_OK { + user = meResp.GetUser() } } @@ -75,11 +75,11 @@ func (s *Service) OpenInApp( // so that all sessions on one file end on the same office server c := sha256.New() - c.Write([]byte(req.ResourceInfo.Id.StorageId + "$" + req.ResourceInfo.Id.SpaceId + "!" + req.ResourceInfo.Id.OpaqueId)) + c.Write([]byte(req.GetResourceInfo().GetId().GetStorageId() + "$" + req.GetResourceInfo().GetId().GetSpaceId() + "!" + req.GetResourceInfo().GetId().GetOpaqueId())) fileRef := hex.EncodeToString(c.Sum(nil)) // get the file extension to use the right wopi app url - fileExt := path.Ext(req.GetResourceInfo().Path) + fileExt := path.Ext(req.GetResourceInfo().GetPath()) var viewAppURL string var editAppURL string @@ -127,7 +127,7 @@ func (s *Service) OpenInApp( s.logger.Error(). Err(err). Str("FileReference", providerFileRef.String()). - Str("ViewMode", req.ViewMode.String()). + Str("ViewMode", req.GetViewMode().String()). Str("Requester", user.GetId().String()). Msg("OpenInApp: error parsing viewAppUrl") return nil, err @@ -137,23 +137,23 @@ func (s *Service) OpenInApp( s.logger.Error(). Err(err). Str("FileReference", providerFileRef.String()). - Str("ViewMode", req.ViewMode.String()). + Str("ViewMode", req.GetViewMode().String()). Str("Requester", user.GetId().String()). Msg("OpenInApp: error parsing editAppUrl") return nil, err } appURL := viewAppURL - if req.ViewMode == appproviderv1beta1.ViewMode_VIEW_MODE_READ_WRITE { + if req.GetViewMode() == appproviderv1beta1.ViewMode_VIEW_MODE_READ_WRITE { appURL = editAppURL } - cryptedReqAccessToken, err := middleware.EncryptAES([]byte(s.config.JWTSecret), req.AccessToken) + cryptedReqAccessToken, err := middleware.EncryptAES([]byte(s.config.JWTSecret), req.GetAccessToken()) if err != nil { s.logger.Error(). Err(err). Str("FileReference", providerFileRef.String()). - Str("ViewMode", req.ViewMode.String()). + Str("ViewMode", req.GetViewMode().String()). Str("Requester", user.GetId().String()). Msg("OpenInApp: error encrypting access token") return &appproviderv1beta1.OpenInAppResponse{ @@ -165,19 +165,19 @@ func (s *Service) OpenInApp( AccessToken: cryptedReqAccessToken, FileReference: providerFileRef, User: user, - ViewMode: req.ViewMode, + ViewMode: req.GetViewMode(), EditAppUrl: editAppURL, ViewAppUrl: viewAppURL, } cs3Claims := &jwt.RegisteredClaims{} cs3JWTparser := jwt.Parser{} - _, _, err = cs3JWTparser.ParseUnverified(req.AccessToken, cs3Claims) + _, _, err = cs3JWTparser.ParseUnverified(req.GetAccessToken(), cs3Claims) if err != nil { s.logger.Error(). Err(err). Str("FileReference", providerFileRef.String()). - Str("ViewMode", req.ViewMode.String()). + Str("ViewMode", req.GetViewMode().String()). Str("Requester", user.GetId().String()). Msg("OpenInApp: error parsing JWT token") return nil, err @@ -197,7 +197,7 @@ func (s *Service) OpenInApp( s.logger.Error(). Err(err). Str("FileReference", providerFileRef.String()). - Str("ViewMode", req.ViewMode.String()). + Str("ViewMode", req.GetViewMode().String()). Str("Requester", user.GetId().String()). Msg("OpenInApp: error signing access token") return &appproviderv1beta1.OpenInAppResponse{ @@ -207,7 +207,7 @@ func (s *Service) OpenInApp( s.logger.Debug(). Str("FileReference", providerFileRef.String()). - Str("ViewMode", req.ViewMode.String()). + Str("ViewMode", req.GetViewMode().String()). Str("Requester", user.GetId().String()). Msg("OpenInApp: success") From 5c85daa06c1c67641ee7cc42731890c1e4dfa6b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Fri, 22 Mar 2024 14:39:51 +0100 Subject: [PATCH 22/29] fix: remove some TODO messages --- services/collaboration/pkg/connector/contentconnector.go | 2 -- services/collaboration/pkg/connector/fileconnector.go | 7 +++++-- services/collaboration/pkg/helpers/discovery.go | 4 +--- services/collaboration/pkg/helpers/registration.go | 1 - 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/services/collaboration/pkg/connector/contentconnector.go b/services/collaboration/pkg/connector/contentconnector.go index 2bd16eaba1..f639c1f725 100644 --- a/services/collaboration/pkg/connector/contentconnector.go +++ b/services/collaboration/pkg/connector/contentconnector.go @@ -103,7 +103,6 @@ func (c *ContentConnector) GetFile(ctx context.Context, writer io.Writer) error // public link downloads have the token in the download endpoint httpReq.Header.Add("X-Reva-Transfer", downloadToken) } - // TODO: the access token shouldn't be needed httpReq.Header.Add("X-Access-Token", wopiContext.AccessToken) httpResp, err := httpClient.Do(httpReq) @@ -276,7 +275,6 @@ func (c *ContentConnector) PutFile(ctx context.Context, stream io.Reader, stream // public link uploads have the token in the upload endpoint httpReq.Header.Add("X-Reva-Transfer", uploadToken) } - // TODO: the access token shouldn't be needed httpReq.Header.Add("X-Access-Token", wopiContext.AccessToken) httpReq.Header.Add("X-Lock-Id", lockID) diff --git a/services/collaboration/pkg/connector/fileconnector.go b/services/collaboration/pkg/connector/fileconnector.go index bc6f07f370..534fb2fa8b 100644 --- a/services/collaboration/pkg/connector/fileconnector.go +++ b/services/collaboration/pkg/connector/fileconnector.go @@ -189,9 +189,12 @@ func (f *FileConnector) Lock(ctx context.Context, lockID, oldLockID string) (str return resp.GetLock().GetLockId(), nil } - // TODO: Is this the right error code? logger.Error().Msg("SetLock failed and could not refresh") - return "", NewConnectorError(404, "Could not refresh the lock") + return "", NewConnectorError(500, "Could not refresh the lock") + + case rpcv1beta1.Code_CODE_NOT_FOUND: + logger.Error().Msg("SetLock failed, file not found") + return "", NewConnectorError(404, "File not found") default: logger.Error(). diff --git a/services/collaboration/pkg/helpers/discovery.go b/services/collaboration/pkg/helpers/discovery.go index bbc388c932..1af71c0854 100644 --- a/services/collaboration/pkg/helpers/discovery.go +++ b/services/collaboration/pkg/helpers/discovery.go @@ -54,9 +54,7 @@ func GetAppURLs(cfg *config.Config, logger log.Logger) (map[string]map[string]st return nil, errors.Wrap(err, "error parsing wopi discovery response") } - // TODO: Log appUrls? not easy with the format - // It's also a one-time call during service setup, so it's pointless - // to use an "all-is-good" debug log + // We won't log anything if successful return appURLs, nil } diff --git a/services/collaboration/pkg/helpers/registration.go b/services/collaboration/pkg/helpers/registration.go index eea4708375..1908c76e64 100644 --- a/services/collaboration/pkg/helpers/registration.go +++ b/services/collaboration/pkg/helpers/registration.go @@ -43,7 +43,6 @@ func RegisterAppProvider( Str("AppName", cfg.App.Name). Strs("Mimetypes", mimeTypes). Msg("Registering mimetypes in the app provider") - // TODO: REVA has way to filter supported mimetypes (do we need to implement it here or is it in the registry?) // TODO: an added app provider shouldn't last forever. Instead the registry should use a TTL // and delete providers that didn't register again. If an app provider dies or get's disconnected, From d85d24b5c5e2faa7a7d8ea6203c6ba339e704797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Mon, 25 Mar 2024 11:22:19 +0100 Subject: [PATCH 23/29] docs: add code documentation --- .../collaboration/pkg/connector/connector.go | 13 +++++ .../pkg/connector/contentconnector.go | 29 +++++++++++ .../pkg/connector/fileconnector.go | 52 +++++++++++++++++++ .../collaboration/pkg/connector/fileinfo.go | 6 +++ .../pkg/connector/httpadapter.go | 8 +++ services/collaboration/pkg/helpers/cs3.go | 4 ++ .../collaboration/pkg/helpers/registration.go | 9 ++++ .../pkg/service/grpc/v0/option.go | 2 +- .../pkg/service/grpc/v0/service.go | 3 +- 9 files changed, 124 insertions(+), 2 deletions(-) diff --git a/services/collaboration/pkg/connector/connector.go b/services/collaboration/pkg/connector/connector.go index c23c2a4f77..f87a09f5fc 100644 --- a/services/collaboration/pkg/connector/connector.go +++ b/services/collaboration/pkg/connector/connector.go @@ -1,5 +1,9 @@ package connector +// ConnectorError defines an error in the connector. It contains an error code +// and a message. +// For convenience, the error code can be used as HTTP error code, although +// the connector shouldn't know anything about HTTP. type ConnectorError struct { HttpCodeOut int Msg string @@ -16,6 +20,15 @@ func NewConnectorError(code int, msg string) *ConnectorError { } } +// Connector will implement the WOPI operations. +// For convenience, the connector splits the operations based on the +// WOPI endpoints, so you'll need to get the specific connector first. +// +// Available endpoints: +// * "Files" -> GetFileConnector() +// * "File contents" -> GetContentConnector() +// +// Other endpoints aren't available for now. type Connector struct { fileConnector *FileConnector contentConnector *ContentConnector diff --git a/services/collaboration/pkg/connector/contentconnector.go b/services/collaboration/pkg/connector/contentconnector.go index f639c1f725..1559e9d314 100644 --- a/services/collaboration/pkg/connector/contentconnector.go +++ b/services/collaboration/pkg/connector/contentconnector.go @@ -18,6 +18,10 @@ import ( "github.com/rs/zerolog" ) +// ContentConnector implements the "File contents" endpoint. +// Basically, the ContentConnector handles downloads (GetFile) and +// uploads (PutFile) +// Note that operations might return any kind of error, not just ConnectorError type ContentConnector struct { gwc gatewayv1beta1.GatewayAPIClient cfg *config.Config @@ -32,6 +36,13 @@ func NewContentConnector(gwc gatewayv1beta1.GatewayAPIClient, cfg *config.Config // GetFile downloads the file from the storage // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/getfile +// +// The context MUST have a WOPI context, otherwise an error will be returned. +// You can pass a pre-configured zerologger instance through the context that +// will be used to log messages. +// +// The contents of the file will be written directly into the writer passed as +// parameter. func (c *ContentConnector) GetFile(ctx context.Context, writer io.Writer) error { wopiContext, err := middleware.WopiContextFromCtx(ctx) if err != nil { @@ -138,6 +149,24 @@ func (c *ContentConnector) GetFile(ctx context.Context, writer io.Writer) error // PutFile uploads the file to the storage // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/putfile +// +// The context MUST have a WOPI context, otherwise an error will be returned. +// You can pass a pre-configured zerologger instance through the context that +// will be used to log messages. +// +// The contents of the file will be read from the stream. The full stream +// length must be provided in order to upload the file. +// +// A lock ID must be provided for the upload (which must match the lock in the +// file). The only case where an empty lock ID can be used is if the target +// file has 0 size. +// +// This method will return the lock ID that should be returned in case of a +// conflict, otherwise it will return an empty string. This means that if the +// method returns a ConnectorError with code 409, the returned string is the +// lock ID that should be used in the X-WOPI-Lock header. In other error +// cases or if the method is successful, an empty string will be returned +// (check for err != nil to know if something went wrong) func (c *ContentConnector) PutFile(ctx context.Context, stream io.Reader, streamLength int64, lockID string) (string, error) { wopiContext, err := middleware.WopiContextFromCtx(ctx) if err != nil { diff --git a/services/collaboration/pkg/connector/fileconnector.go b/services/collaboration/pkg/connector/fileconnector.go index 534fb2fa8b..2366c4a43d 100644 --- a/services/collaboration/pkg/connector/fileconnector.go +++ b/services/collaboration/pkg/connector/fileconnector.go @@ -38,6 +38,14 @@ func NewFileConnector(gwc gatewayv1beta1.GatewayAPIClient, cfg *config.Config) * // GetLock returns a lock or an empty string if no lock exists // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/getlock +// +// The context MUST have a WOPI context, otherwise an error will be returned. +// You can pass a pre-configured zerologger instance through the context that +// will be used to log messages. +// +// The lock ID applied to the file reference in the context will be returned +// (if any). An error will be returned if something goes wrong. The error +// could be a ConnectorError func (f *FileConnector) GetLock(ctx context.Context) (string, error) { wopiContext, err := middleware.WopiContextFromCtx(ctx) if err != nil { @@ -80,6 +88,21 @@ func (f *FileConnector) GetLock(ctx context.Context) (string, error) { // Lock returns a WOPI lock or performs an unlock and relock // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/lock // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/unlockandrelock +// +// The context MUST have a WOPI context, otherwise an error will be returned. +// You can pass a pre-configured zerologger instance through the context that +// will be used to log messages. +// +// Lock the file reference contained in the context with the provided lockID. +// The oldLockID is only used for the "unlock and relock" operation. The "lock" +// operation doesn't use the oldLockID and needs to be empty in this case. +// +// For the "lock" operation, if the operation is successful, an empty lock id +// will be returned without any error. In case of conflict, the current lock +// id will be returned along with a 409 ConnectorError. For any other error, +// the method will return an empty lock id. +// +// For the "unlock and relock" operation, the behavior will be the same. func (f *FileConnector) Lock(ctx context.Context, lockID, oldLockID string) (string, error) { wopiContext, err := middleware.WopiContextFromCtx(ctx) if err != nil { @@ -207,6 +230,17 @@ func (f *FileConnector) Lock(ctx context.Context, lockID, oldLockID string) (str // RefreshLock refreshes a provided lock for 30 minutes // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/refreshlock +// +// The context MUST have a WOPI context, otherwise an error will be returned. +// You can pass a pre-configured zerologger instance through the context that +// will be used to log messages. +// +// If the operation is successful, an empty lock id will be returned without +// any error. In case of conflict, the current lock id will be returned +// along with a 409 ConnectorError. For any other error, the method will +// return an empty lock id. +// The conflict happens if the provided lockID doesn't match the one actually +// applied in the target file. func (f *FileConnector) RefreshLock(ctx context.Context, lockID string) (string, error) { wopiContext, err := middleware.WopiContextFromCtx(ctx) if err != nil { @@ -304,6 +338,17 @@ func (f *FileConnector) RefreshLock(ctx context.Context, lockID string) (string, // UnLock removes a given lock from a file // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/unlock +// +// The context MUST have a WOPI context, otherwise an error will be returned. +// You can pass a pre-configured zerologger instance through the context that +// will be used to log messages. +// +// If the operation is successful, an empty lock id will be returned without +// any error. In case of conflict, the current lock id will be returned +// along with a 409 ConnectorError. For any other error, the method will +// return an empty lock id. +// The conflict happens if the provided lockID doesn't match the one actually +// applied in the target file. func (f *FileConnector) UnLock(ctx context.Context, lockID string) (string, error) { wopiContext, err := middleware.WopiContextFromCtx(ctx) if err != nil { @@ -389,6 +434,13 @@ func (f *FileConnector) UnLock(ctx context.Context, lockID string) (string, erro // CheckFileInfo returns information about the requested file and capabilities of the wopi server // https://docs.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo +// +// The context MUST have a WOPI context, otherwise an error will be returned. +// You can pass a pre-configured zerologger instance through the context that +// will be used to log messages. +// +// If the operation is successful, a "FileInfo" instance will be returned, +// otherwise the "FileInfo" will be empty and an error will be returned. func (f *FileConnector) CheckFileInfo(ctx context.Context) (FileInfo, error) { wopiContext, err := middleware.WopiContextFromCtx(ctx) if err != nil { diff --git a/services/collaboration/pkg/connector/fileinfo.go b/services/collaboration/pkg/connector/fileinfo.go index e9f715ca91..9466568406 100644 --- a/services/collaboration/pkg/connector/fileinfo.go +++ b/services/collaboration/pkg/connector/fileinfo.go @@ -1,5 +1,11 @@ package connector +// FileInfo contains the properties of the file. +// Some properties refer to capabilities in the WOPI client, and capabilities +// that the WOPI server has. +// +// For now, the FileInfo contains data for Microsoft, Collabora and OnlyOffice. +// Not all the properties are supported by every system. type FileInfo struct { // ------------ // Microsoft WOPI check file info specification: diff --git a/services/collaboration/pkg/connector/httpadapter.go b/services/collaboration/pkg/connector/httpadapter.go index ff94a6d479..8eda845cfd 100644 --- a/services/collaboration/pkg/connector/httpadapter.go +++ b/services/collaboration/pkg/connector/httpadapter.go @@ -15,6 +15,14 @@ const ( HeaderWopiOldLock string = "X-WOPI-OldLock" ) +// HttpAdapter will adapt the responses from the connector to HTTP. +// +// The adapter will use the request's context for the connector operations, +// this means that the request MUST have a valid WOPI context and a +// pre-configured logger. This should have been prepared in the routing. +// +// All operations are expected to follow the definitions found in +// https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/endpoints type HttpAdapter struct { con *Connector } diff --git a/services/collaboration/pkg/helpers/cs3.go b/services/collaboration/pkg/helpers/cs3.go index a1cb1cfac3..8b3f21f469 100644 --- a/services/collaboration/pkg/helpers/cs3.go +++ b/services/collaboration/pkg/helpers/cs3.go @@ -8,6 +8,10 @@ import ( var commonCS3ApiClient gatewayv1beta1.GatewayAPIClient +// GatewayAPIClient gets an instance based on the provided configuration. +// The instance will be cached and returned if possible, unless the "forceNew" +// parameter is set to true. In this case, the old instance will be replaced +// with the new one if there is no error. func GetCS3apiClient(cfg *config.Config, forceNew bool) (gatewayv1beta1.GatewayAPIClient, error) { // establish a connection to the cs3 api endpoint // in this case a REVA gateway, started by oCIS diff --git a/services/collaboration/pkg/helpers/registration.go b/services/collaboration/pkg/helpers/registration.go index 1908c76e64..49fbfab28b 100644 --- a/services/collaboration/pkg/helpers/registration.go +++ b/services/collaboration/pkg/helpers/registration.go @@ -14,11 +14,20 @@ import ( "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" ) +// RegisterOcisService will register this service. +// There are no explicit requirements for the context, and it will be passed +// without changes to the underlying RegisterService method. func RegisterOcisService(ctx context.Context, cfg *config.Config, logger log.Logger) error { svc := registry.BuildGRPCService(cfg.Service.Name, uuid.Must(uuid.NewV4()).String(), cfg.GRPC.Addr, "0.0.0") return registry.RegisterService(ctx, svc, logger) } +// RegisterAppProvider will register this service as app provider in REVA. +// The GatewayAPIClient is expected to be provided via `helpers.GetCS3apiClient`. +// The appUrls are expected to be provided via `helpers.GetAppURLs` +// +// Note that this method doesn't provide a re-registration mechanism, so it +// will register the service once func RegisterAppProvider( ctx context.Context, cfg *config.Config, diff --git a/services/collaboration/pkg/service/grpc/v0/option.go b/services/collaboration/pkg/service/grpc/v0/option.go index a107fd66e2..0ad88e8826 100644 --- a/services/collaboration/pkg/service/grpc/v0/option.go +++ b/services/collaboration/pkg/service/grpc/v0/option.go @@ -39,7 +39,7 @@ func Config(val *config.Config) Option { } } -// ViewUrl provides a function to set the ViewUrl option. +// AppURLs provides a function to set the AppURLs option. func AppURLs(val map[string]map[string]string) Option { return func(o *Options) { o.AppURLs = val diff --git a/services/collaboration/pkg/service/grpc/v0/service.go b/services/collaboration/pkg/service/grpc/v0/service.go index 6edee2d8ec..78de1bd56e 100644 --- a/services/collaboration/pkg/service/grpc/v0/service.go +++ b/services/collaboration/pkg/service/grpc/v0/service.go @@ -21,6 +21,7 @@ import ( "github.com/owncloud/ocis/v2/services/collaboration/pkg/middleware" ) +// NewHandler creates a new grpc service implementing the OpenInApp interface func NewHandler(opts ...Option) (*Service, func(), error) { teardown := func() {} options := newOptions(opts...) @@ -39,7 +40,7 @@ func NewHandler(opts ...Option) (*Service, func(), error) { }, teardown, nil } -// Service implements the searchServiceHandler interface +// Service implements the OpenInApp interface type Service struct { id string appURLs map[string]map[string]string From 710a0b4561b90aa09e7e35cba9b645dcebe6cca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Wed, 27 Mar 2024 17:35:30 +0100 Subject: [PATCH 24/29] test: add unit tests, adjust fileinfo --- .../pkg/connector/connector_suite_test.go | 13 + .../pkg/connector/contentconnector_test.go | 418 +++++++++ .../pkg/connector/fileconnector.go | 4 +- .../pkg/connector/fileconnector_test.go | 875 ++++++++++++++++++ .../pkg/helpers/discovery_test.go | 124 +++ .../pkg/helpers/helpers_suite_test.go | 13 + .../pkg/middleware/wopicontext.go | 11 + 7 files changed, 1457 insertions(+), 1 deletion(-) create mode 100644 services/collaboration/pkg/connector/connector_suite_test.go create mode 100644 services/collaboration/pkg/connector/contentconnector_test.go create mode 100644 services/collaboration/pkg/connector/fileconnector_test.go create mode 100644 services/collaboration/pkg/helpers/discovery_test.go create mode 100644 services/collaboration/pkg/helpers/helpers_suite_test.go diff --git a/services/collaboration/pkg/connector/connector_suite_test.go b/services/collaboration/pkg/connector/connector_suite_test.go new file mode 100644 index 0000000000..0781160de9 --- /dev/null +++ b/services/collaboration/pkg/connector/connector_suite_test.go @@ -0,0 +1,13 @@ +package connector_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestGraph(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Connector Suite") +} diff --git a/services/collaboration/pkg/connector/contentconnector_test.go b/services/collaboration/pkg/connector/contentconnector_test.go new file mode 100644 index 0000000000..a39a29d586 --- /dev/null +++ b/services/collaboration/pkg/connector/contentconnector_test.go @@ -0,0 +1,418 @@ +package connector_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" + + appproviderv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1" + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + "github.com/cs3org/reva/v2/pkg/rgrpc/status" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/connector" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/middleware" + + cs3mocks "github.com/cs3org/reva/v2/tests/cs3mocks/mocks" +) + +var _ = Describe("ContentConnector", func() { + var ( + cc *connector.ContentConnector + gatewayClient *cs3mocks.GatewayAPIClient + cfg *config.Config + wopiCtx middleware.WopiContext + + srv *httptest.Server + srvReqHeader http.Header + randomContent string + ) + + BeforeEach(func() { + // contentConnector only uses "cfg.CS3Api.DataGateway.Insecure", which is irrelevant for the tests + cfg = &config.Config{} + gatewayClient = &cs3mocks.GatewayAPIClient{} + cc = connector.NewContentConnector(gatewayClient, cfg) + + wopiCtx = middleware.WopiContext{ + AccessToken: "abcdef123456", + FileReference: providerv1beta1.Reference{ + ResourceId: &providerv1beta1.ResourceId{ + StorageId: "abc", + OpaqueId: "12345", + SpaceId: "zzz", + }, + Path: ".", + }, + User: &userv1beta1.User{}, // Not used for now + ViewMode: appproviderv1beta1.ViewMode_VIEW_MODE_READ_WRITE, + EditAppUrl: "http://test.ex.prv/edit", + ViewAppUrl: "http://test.ex.prv/view", + } + + randomContent = "This is the content of the test.txt file" + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + srvReqHeader = req.Header // save the request header to check later + switch req.URL.Path { + case "/download/failed.png": + w.WriteHeader(404) + case "/download/test.txt": + w.Write([]byte(randomContent)) + case "/upload/failed.png": + w.WriteHeader(404) + case "/upload/test.txt": + w.WriteHeader(200) + } + })) + }) + + AfterEach(func() { + srv.Close() + }) + + Describe("GetFile", func() { + It("No valid context", func() { + sb := &strings.Builder{} + ctx := context.Background() + err := cc.GetFile(ctx, sb) + Expect(err).To(HaveOccurred()) + }) + + It("Initiate download failed", func() { + sb := &strings.Builder{} + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + targetErr := errors.New("Something went wrong") + gatewayClient.On("InitiateFileDownload", mock.Anything, mock.Anything).Times(1).Return(&gateway.InitiateFileDownloadResponse{ + Status: status.NewInternal(ctx, "Something failed"), + }, targetErr) + + err := cc.GetFile(ctx, sb) + Expect(err).To(Equal(targetErr)) + }) + + It("Initiate download status not ok", func() { + sb := &strings.Builder{} + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("InitiateFileDownload", mock.Anything, mock.Anything).Times(1).Return(&gateway.InitiateFileDownloadResponse{ + Status: status.NewInternal(ctx, "Something failed"), + }, nil) + + err := cc.GetFile(ctx, sb) + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(500)) + }) + + It("Missing download endpoint", func() { + sb := &strings.Builder{} + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("InitiateFileDownload", mock.Anything, mock.Anything).Times(1).Return(&gateway.InitiateFileDownloadResponse{ + Status: status.NewOK(ctx), + }, nil) + + err := cc.GetFile(ctx, sb) + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(500)) + }) + + It("Download request failed", func() { + sb := &strings.Builder{} + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("InitiateFileDownload", mock.Anything, mock.Anything).Times(1).Return(&gateway.InitiateFileDownloadResponse{ + Status: status.NewOK(ctx), + Protocols: []*gateway.FileDownloadProtocol{ + &gateway.FileDownloadProtocol{ + Protocol: "simple", + DownloadEndpoint: srv.URL + "/download/failed.png", + Token: "MyDownloadToken", + }, + }, + }, nil) + + err := cc.GetFile(ctx, sb) + Expect(srvReqHeader.Get("X-Access-Token")).To(Equal(wopiCtx.AccessToken)) + Expect(srvReqHeader.Get("X-Reva-Transfer")).To(Equal("MyDownloadToken")) + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(500)) + }) + + It("Download request success", func() { + sb := &strings.Builder{} + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("InitiateFileDownload", mock.Anything, mock.Anything).Times(1).Return(&gateway.InitiateFileDownloadResponse{ + Status: status.NewOK(ctx), + Protocols: []*gateway.FileDownloadProtocol{ + &gateway.FileDownloadProtocol{ + Protocol: "simple", + DownloadEndpoint: srv.URL + "/download/test.txt", + Token: "MyDownloadToken", + }, + }, + }, nil) + + err := cc.GetFile(ctx, sb) + Expect(srvReqHeader.Get("X-Access-Token")).To(Equal(wopiCtx.AccessToken)) + Expect(srvReqHeader.Get("X-Reva-Transfer")).To(Equal("MyDownloadToken")) + Expect(err).To(Succeed()) + Expect(sb.String()).To(Equal(randomContent)) + }) + }) + + Describe("PutFile", func() { + It("No valid context", func() { + reader := strings.NewReader("Content to upload is here!") + ctx := context.Background() + newLockId, err := cc.PutFile(ctx, reader, reader.Size(), "notARandomLockId") + Expect(err).To(HaveOccurred()) + Expect(newLockId).To(Equal("")) + }) + + It("Stat call failed", func() { + reader := strings.NewReader("Content to upload is here!") + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + targetErr := errors.New("Something went wrong") + gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{ + Status: status.NewInternal(ctx, "Something failed"), + }, targetErr) + + newLockId, err := cc.PutFile(ctx, reader, reader.Size(), "notARandomLockId") + Expect(err).To(Equal(targetErr)) + Expect(newLockId).To(Equal("")) + }) + + It("Stat call status not ok", func() { + reader := strings.NewReader("Content to upload is here!") + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{ + Status: status.NewInternal(ctx, "Something failed"), + }, nil) + + newLockId, err := cc.PutFile(ctx, reader, reader.Size(), "notARandomLockId") + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(500)) + Expect(newLockId).To(Equal("")) + }) + + It("Mismatched lockId", func() { + reader := strings.NewReader("Content to upload is here!") + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{ + Status: status.NewOK(ctx), + Info: &providerv1beta1.ResourceInfo{ + Lock: &providerv1beta1.Lock{ + LockId: "goodAndValidLock", + Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, + }, + }, + }, nil) + + newLockId, err := cc.PutFile(ctx, reader, reader.Size(), "notARandomLockId") + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(409)) + Expect(newLockId).To(Equal("goodAndValidLock")) + }) + + It("Upload without lockId but on a non empty file", func() { + reader := strings.NewReader("Content to upload is here!") + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{ + Status: status.NewOK(ctx), + Info: &providerv1beta1.ResourceInfo{ + Lock: nil, + Size: uint64(123456789), + }, + }, nil) + + newLockId, err := cc.PutFile(ctx, reader, reader.Size(), "") + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(409)) + Expect(newLockId).To(Equal("")) + }) + + It("Initiate upload fails", func() { + reader := strings.NewReader("Content to upload is here!") + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{ + Status: status.NewOK(ctx), + Info: &providerv1beta1.ResourceInfo{ + Lock: &providerv1beta1.Lock{ + LockId: "goodAndValidLock", + Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, + }, + Size: uint64(123456789), + }, + }, nil) + + targetErr := errors.New("Something went wrong") + gatewayClient.On("InitiateFileUpload", mock.Anything, mock.Anything).Times(1).Return(&gateway.InitiateFileUploadResponse{ + Status: status.NewInternal(ctx, "Something failed"), + }, targetErr) + + newLockId, err := cc.PutFile(ctx, reader, reader.Size(), "goodAndValidLock") + Expect(err).To(HaveOccurred()) + Expect(newLockId).To(Equal("")) + }) + + It("Initiate upload status not ok", func() { + reader := strings.NewReader("Content to upload is here!") + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{ + Status: status.NewOK(ctx), + Info: &providerv1beta1.ResourceInfo{ + Lock: &providerv1beta1.Lock{ + LockId: "goodAndValidLock", + Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, + }, + Size: uint64(123456789), + }, + }, nil) + + gatewayClient.On("InitiateFileUpload", mock.Anything, mock.Anything).Times(1).Return(&gateway.InitiateFileUploadResponse{ + Status: status.NewInternal(ctx, "Something failed"), + }, nil) + + newLockId, err := cc.PutFile(ctx, reader, reader.Size(), "goodAndValidLock") + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(500)) + Expect(newLockId).To(Equal("")) + }) + + It("Empty upload successful", func() { + reader := strings.NewReader("") + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{ + Status: status.NewOK(ctx), + Info: &providerv1beta1.ResourceInfo{ + Lock: &providerv1beta1.Lock{ + LockId: "goodAndValidLock", + Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, + }, + Size: uint64(123456789), + }, + }, nil) + + gatewayClient.On("InitiateFileUpload", mock.Anything, mock.Anything).Times(1).Return(&gateway.InitiateFileUploadResponse{ + Status: status.NewOK(ctx), + }, nil) + + newLockId, err := cc.PutFile(ctx, reader, reader.Size(), "goodAndValidLock") + Expect(err).To(Succeed()) + Expect(newLockId).To(Equal("")) + }) + + It("Missing upload endpoint", func() { + reader := strings.NewReader("Content to upload is here!") + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{ + Status: status.NewOK(ctx), + Info: &providerv1beta1.ResourceInfo{ + Lock: &providerv1beta1.Lock{ + LockId: "goodAndValidLock", + Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, + }, + Size: uint64(123456789), + }, + }, nil) + + gatewayClient.On("InitiateFileUpload", mock.Anything, mock.Anything).Times(1).Return(&gateway.InitiateFileUploadResponse{ + Status: status.NewOK(ctx), + }, nil) + + newLockId, err := cc.PutFile(ctx, reader, reader.Size(), "goodAndValidLock") + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(500)) + Expect(newLockId).To(Equal("")) + }) + + It("upload request failed", func() { + reader := strings.NewReader("Content to upload is here!") + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{ + Status: status.NewOK(ctx), + Info: &providerv1beta1.ResourceInfo{ + Lock: &providerv1beta1.Lock{ + LockId: "goodAndValidLock", + Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, + }, + Size: uint64(123456789), + }, + }, nil) + + gatewayClient.On("InitiateFileUpload", mock.Anything, mock.Anything).Times(1).Return(&gateway.InitiateFileUploadResponse{ + Status: status.NewOK(ctx), + Protocols: []*gateway.FileUploadProtocol{ + &gateway.FileUploadProtocol{ + Protocol: "simple", + UploadEndpoint: srv.URL + "/upload/failed.png", + }, + }, + }, nil) + + newLockId, err := cc.PutFile(ctx, reader, reader.Size(), "goodAndValidLock") + Expect(srvReqHeader.Get("X-Access-Token")).To(Equal(wopiCtx.AccessToken)) + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(500)) + Expect(newLockId).To(Equal("")) + }) + + It("upload request success", func() { + reader := strings.NewReader("Content to upload is here!") + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{ + Status: status.NewOK(ctx), + Info: &providerv1beta1.ResourceInfo{ + Lock: &providerv1beta1.Lock{ + LockId: "goodAndValidLock", + Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, + }, + Size: uint64(123456789), + }, + }, nil) + + gatewayClient.On("InitiateFileUpload", mock.Anything, mock.Anything).Times(1).Return(&gateway.InitiateFileUploadResponse{ + Status: status.NewOK(ctx), + Protocols: []*gateway.FileUploadProtocol{ + &gateway.FileUploadProtocol{ + Protocol: "simple", + UploadEndpoint: srv.URL + "/upload/test.txt", + }, + }, + }, nil) + + newLockId, err := cc.PutFile(ctx, reader, reader.Size(), "goodAndValidLock") + Expect(srvReqHeader.Get("X-Access-Token")).To(Equal(wopiCtx.AccessToken)) + Expect(err).To(Succeed()) + Expect(newLockId).To(Equal("")) + }) + }) +}) diff --git a/services/collaboration/pkg/connector/fileconnector.go b/services/collaboration/pkg/connector/fileconnector.go index 2366c4a43d..6945f3b917 100644 --- a/services/collaboration/pkg/connector/fileconnector.go +++ b/services/collaboration/pkg/connector/fileconnector.go @@ -4,6 +4,7 @@ import ( "context" "encoding/hex" "path" + "strconv" "time" appproviderv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1" @@ -69,6 +70,7 @@ func (f *FileConnector) GetLock(ctx context.Context) (string, error) { Str("StatusCode", resp.GetStatus().GetCode().String()). Str("StatusMsg", resp.GetStatus().GetMessage()). Msg("GetLock failed with unexpected status") + // TODO: Should we be more strict? There could be more causes for the failure return "", NewConnectorError(404, resp.GetStatus().GetCode().String()+" "+resp.GetStatus().GetMessage()) } @@ -469,7 +471,7 @@ func (f *FileConnector) CheckFileInfo(ctx context.Context) (FileInfo, error) { // OwnerId must use only alphanumeric chars (https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/checkfileinfo/checkfileinfo-response#requirements-for-user-identity-properties) OwnerId: hex.EncodeToString([]byte(statRes.GetInfo().GetOwner().GetOpaqueId() + "@" + statRes.GetInfo().GetOwner().GetIdp())), Size: int64(statRes.GetInfo().GetSize()), - Version: statRes.GetInfo().GetMtime().String(), + Version: strconv.FormatUint(statRes.GetInfo().GetMtime().GetSeconds(), 10) + "." + strconv.FormatUint(uint64(statRes.GetInfo().GetMtime().GetNanos()), 10), BaseFileName: path.Base(statRes.GetInfo().GetPath()), BreadcrumbDocName: path.Base(statRes.GetInfo().GetPath()), // to get the folder we actually need to do a GetPath() request diff --git a/services/collaboration/pkg/connector/fileconnector_test.go b/services/collaboration/pkg/connector/fileconnector_test.go new file mode 100644 index 0000000000..4ed54e2da1 --- /dev/null +++ b/services/collaboration/pkg/connector/fileconnector_test.go @@ -0,0 +1,875 @@ +package connector_test + +import ( + "context" + "encoding/hex" + "errors" + + appproviderv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1" + userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" + "github.com/cs3org/reva/v2/pkg/rgrpc/status" + cs3mocks "github.com/cs3org/reva/v2/tests/cs3mocks/mocks" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/connector" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/middleware" + "github.com/stretchr/testify/mock" +) + +var _ = Describe("FileConnector", func() { + var ( + fc *connector.FileConnector + gatewayClient *cs3mocks.GatewayAPIClient + cfg *config.Config + wopiCtx middleware.WopiContext + ) + + BeforeEach(func() { + cfg = &config.Config{ + App: config.App{ + LockName: "testName_for_unittests", // Only the LockName is used + }, + } + gatewayClient = &cs3mocks.GatewayAPIClient{} + fc = connector.NewFileConnector(gatewayClient, cfg) + + wopiCtx = middleware.WopiContext{ + AccessToken: "abcdef123456", + FileReference: providerv1beta1.Reference{ + ResourceId: &providerv1beta1.ResourceId{ + StorageId: "abc", + OpaqueId: "12345", + SpaceId: "zzz", + }, + Path: ".", + }, + User: &userv1beta1.User{ + Id: &userv1beta1.UserId{ + Idp: "inmemory", + OpaqueId: "opaqueId", + Type: userv1beta1.UserType_USER_TYPE_PRIMARY, + }, + Username: "Pet Shaft", + // Opaque is here for reference, not used by default but might be needed for some tests + //Opaque: &typesv1beta1.Opaque{ + // Map: map[string]*typesv1beta1.OpaqueEntry{ + // "public-share-role": &typesv1beta1.OpaqueEntry{ + // Decoder: "plain", + // Value: []byte("viewer"), + // }, + // }, + //}, + }, + ViewMode: appproviderv1beta1.ViewMode_VIEW_MODE_READ_WRITE, + EditAppUrl: "http://test.ex.prv/edit", + ViewAppUrl: "http://test.ex.prv/view", + } + }) + + Describe("GetLock", func() { + It("No valid context", func() { + ctx := context.Background() + newLockId, err := fc.GetLock(ctx) + Expect(err).To(HaveOccurred()) + Expect(newLockId).To(Equal("")) + }) + + It("Get lock failed", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + targetErr := errors.New("Something went wrong") + gatewayClient.On("GetLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.GetLockResponse{ + Status: status.NewInternal(ctx, "Something failed"), + }, targetErr) + + newLockId, err := fc.GetLock(ctx) + Expect(err).To(Equal(targetErr)) + Expect(newLockId).To(Equal("")) + }) + + It("Get lock failed status not ok", func() { + // assume failure happens because the target file doesn't exists + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("GetLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.GetLockResponse{ + Status: status.NewNotFound(ctx, "File is missing"), + }, nil) + + newLockId, err := fc.GetLock(ctx) + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(404)) + Expect(newLockId).To(Equal("")) + }) + + It("Get lock success", func() { + // assume failure happens because the target file doesn't exists + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("GetLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.GetLockResponse{ + Status: status.NewOK(ctx), + Lock: &providerv1beta1.Lock{ + LockId: "zzz999", + Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, + }, + }, nil) + + newLockId, err := fc.GetLock(ctx) + Expect(err).To(Succeed()) + Expect(newLockId).To(Equal("zzz999")) + }) + }) + + Describe("Lock", func() { + Describe("Lock", func() { + It("No valid context", func() { + ctx := context.Background() + newLockId, err := fc.Lock(ctx, "newLock", "") + Expect(err).To(HaveOccurred()) + Expect(newLockId).To(Equal("")) + }) + + It("Empty lockId", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + newLockId, err := fc.Lock(ctx, "", "") + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(400)) + Expect(newLockId).To(Equal("")) + }) + + It("Set lock failed", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + targetErr := errors.New("Something went wrong") + gatewayClient.On("SetLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.SetLockResponse{ + Status: status.NewInternal(ctx, "Something failed"), + }, targetErr) + + newLockId, err := fc.Lock(ctx, "abcdef123", "") + Expect(err).To(HaveOccurred()) + Expect(err).To(Equal(targetErr)) + Expect(newLockId).To(Equal("")) + }) + + It("Set lock success", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("SetLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.SetLockResponse{ + Status: status.NewOK(ctx), + }, nil) + + newLockId, err := fc.Lock(ctx, "abcdef123", "") + Expect(err).To(Succeed()) + Expect(newLockId).To(Equal("")) + }) + + It("Set lock mismatches error getting lock", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("SetLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.SetLockResponse{ + Status: status.NewFailedPrecondition(ctx, nil, "lock mismatch"), + }, nil) + + targetErr := errors.New("Something went wrong getting the lock") + gatewayClient.On("GetLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.GetLockResponse{ + Status: status.NewInternal(ctx, "lock mismatch"), + }, targetErr) + + newLockId, err := fc.Lock(ctx, "abcdef123", "") + Expect(err).To(HaveOccurred()) + Expect(err).To(Equal(targetErr)) + Expect(newLockId).To(Equal("")) + }) + + It("Set lock mismatches", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("SetLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.SetLockResponse{ + Status: status.NewFailedPrecondition(ctx, nil, "lock mismatch"), + }, nil) + + gatewayClient.On("GetLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.GetLockResponse{ + Status: status.NewOK(ctx), + Lock: &providerv1beta1.Lock{ + LockId: "zzz999", + Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, + }, + }, nil) + + newLockId, err := fc.Lock(ctx, "abcdef123", "") + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(409)) + Expect(newLockId).To(Equal("zzz999")) + }) + + It("Set lock mismatches but get lock matches", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("SetLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.SetLockResponse{ + Status: status.NewFailedPrecondition(ctx, nil, "lock mismatch"), + }, nil) + + gatewayClient.On("GetLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.GetLockResponse{ + Status: status.NewOK(ctx), + Lock: &providerv1beta1.Lock{ + LockId: "abcdef123", + Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, + }, + }, nil) + + newLockId, err := fc.Lock(ctx, "abcdef123", "") + Expect(err).To(Succeed()) + Expect(newLockId).To(Equal("abcdef123")) + }) + + It("Set lock mismatches but get lock doesn't return lockId", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("SetLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.SetLockResponse{ + Status: status.NewFailedPrecondition(ctx, nil, "lock mismatch"), + }, nil) + + gatewayClient.On("GetLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.GetLockResponse{ + Status: status.NewOK(ctx), + }, nil) + + newLockId, err := fc.Lock(ctx, "abcdef123", "") + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(500)) + Expect(newLockId).To(Equal("")) + }) + + It("File not found", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("SetLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.SetLockResponse{ + Status: status.NewNotFound(ctx, "file not found"), + }, nil) + + newLockId, err := fc.Lock(ctx, "abcdef123", "") + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(404)) + Expect(newLockId).To(Equal("")) + }) + + It("Default error handling (insufficient storage)", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("SetLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.SetLockResponse{ + Status: status.NewInsufficientStorage(ctx, nil, "file too big"), + }, nil) + + newLockId, err := fc.Lock(ctx, "abcdef123", "") + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(500)) + Expect(newLockId).To(Equal("")) + }) + }) + + Describe("Unlock and relock", func() { + It("No valid context", func() { + ctx := context.Background() + newLockId, err := fc.Lock(ctx, "newLock", "oldLock") + Expect(err).To(HaveOccurred()) + Expect(newLockId).To(Equal("")) + }) + + It("Empty lockId", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + newLockId, err := fc.Lock(ctx, "", "oldLock") + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(400)) + Expect(newLockId).To(Equal("")) + }) + + It("Refresh lock failed", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + targetErr := errors.New("Something went wrong") + gatewayClient.On("RefreshLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.RefreshLockResponse{ + Status: status.NewInternal(ctx, "Something failed"), + }, targetErr) + + newLockId, err := fc.Lock(ctx, "abcdef123", "oldLock") + Expect(err).To(HaveOccurred()) + Expect(err).To(Equal(targetErr)) + Expect(newLockId).To(Equal("")) + }) + + It("Refresh lock success", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("RefreshLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.RefreshLockResponse{ + Status: status.NewOK(ctx), + }, nil) + + newLockId, err := fc.Lock(ctx, "abcdef123", "oldLock") + Expect(err).To(Succeed()) + Expect(newLockId).To(Equal("")) + }) + + It("Refresh lock mismatches error getting lock", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("RefreshLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.RefreshLockResponse{ + Status: status.NewConflict(ctx, nil, "lock mismatch"), + }, nil) + + targetErr := errors.New("Something went wrong getting the lock") + gatewayClient.On("GetLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.GetLockResponse{ + Status: status.NewInternal(ctx, "lock mismatch"), + }, targetErr) + + newLockId, err := fc.Lock(ctx, "abcdef123", "112233") + Expect(err).To(HaveOccurred()) + Expect(err).To(Equal(targetErr)) + Expect(newLockId).To(Equal("")) + }) + + It("Refresh lock mismatches", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("RefreshLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.RefreshLockResponse{ + Status: status.NewConflict(ctx, nil, "lock mismatch"), + }, nil) + + gatewayClient.On("GetLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.GetLockResponse{ + Status: status.NewOK(ctx), + Lock: &providerv1beta1.Lock{ + LockId: "zzz999", + Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, + }, + }, nil) + + newLockId, err := fc.Lock(ctx, "abcdef123", "112233") + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(409)) + Expect(newLockId).To(Equal("zzz999")) + }) + + It("Refresh lock mismatches but get lock matches", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("RefreshLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.RefreshLockResponse{ + Status: status.NewConflict(ctx, nil, "lock mismatch"), + }, nil) + + gatewayClient.On("GetLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.GetLockResponse{ + Status: status.NewOK(ctx), + Lock: &providerv1beta1.Lock{ + LockId: "abcdef123", + Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, + }, + }, nil) + + newLockId, err := fc.Lock(ctx, "abcdef123", "112233") + Expect(err).To(Succeed()) + Expect(newLockId).To(Equal("abcdef123")) + }) + + It("Refresh lock mismatches but get lock doesn't return lockId", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("RefreshLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.RefreshLockResponse{ + Status: status.NewConflict(ctx, nil, "lock mismatch"), + }, nil) + + gatewayClient.On("GetLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.GetLockResponse{ + Status: status.NewOK(ctx), + }, nil) + + newLockId, err := fc.Lock(ctx, "abcdef123", "112233") + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(500)) + Expect(newLockId).To(Equal("")) + }) + + It("File not found", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("RefreshLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.RefreshLockResponse{ + Status: status.NewNotFound(ctx, "file not found"), + }, nil) + + newLockId, err := fc.Lock(ctx, "abcdef123", "112233") + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(404)) + Expect(newLockId).To(Equal("")) + }) + + It("Default error handling (insufficient storage)", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("RefreshLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.RefreshLockResponse{ + Status: status.NewInsufficientStorage(ctx, nil, "file too big"), + }, nil) + + newLockId, err := fc.Lock(ctx, "abcdef123", "112233") + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(500)) + Expect(newLockId).To(Equal("")) + }) + }) + }) + + Describe("RefreshLock", func() { + It("No valid context", func() { + ctx := context.Background() + newLockId, err := fc.RefreshLock(ctx, "newLock") + Expect(err).To(HaveOccurred()) + Expect(newLockId).To(Equal("")) + }) + + It("Empty lockId", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + newLockId, err := fc.RefreshLock(ctx, "") + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(400)) + Expect(newLockId).To(Equal("")) + }) + + It("Refresh lock fails", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + targetErr := errors.New("Something went wrong") + gatewayClient.On("RefreshLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.RefreshLockResponse{ + Status: status.NewConflict(ctx, nil, "lock mismatch"), + }, targetErr) + + newLockId, err := fc.RefreshLock(ctx, "abcdef123") + Expect(err).To(HaveOccurred()) + Expect(err).To(Equal(targetErr)) + Expect(newLockId).To(Equal("")) + }) + + It("Refresh lock success", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("RefreshLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.RefreshLockResponse{ + Status: status.NewOK(ctx), + }, nil) + + newLockId, err := fc.RefreshLock(ctx, "abcdef123") + Expect(err).To(Succeed()) + Expect(newLockId).To(Equal("")) + }) + + It("Refresh lock file not found", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("RefreshLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.RefreshLockResponse{ + Status: status.NewNotFound(ctx, "file not found"), + }, nil) + + newLockId, err := fc.RefreshLock(ctx, "abcdef123") + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(404)) + Expect(newLockId).To(Equal("")) + }) + + It("Refresh lock mismatch and get lock fails", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("RefreshLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.RefreshLockResponse{ + Status: status.NewConflict(ctx, nil, "lock mismatch"), + }, nil) + + targetErr := errors.New("Something went wrong") + gatewayClient.On("GetLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.GetLockResponse{ + Status: status.NewConflict(ctx, nil, "lock mismatch"), + }, targetErr) + + newLockId, err := fc.RefreshLock(ctx, "abcdef123") + Expect(err).To(HaveOccurred()) + Expect(err).To(Equal(targetErr)) + Expect(newLockId).To(Equal("")) + }) + + It("Refresh lock mismatch and get lock status not ok", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("RefreshLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.RefreshLockResponse{ + Status: status.NewConflict(ctx, nil, "lock mismatch"), + }, nil) + + gatewayClient.On("GetLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.GetLockResponse{ + Status: status.NewInternal(ctx, "lock mismatch"), + }, nil) + + newLockId, err := fc.RefreshLock(ctx, "abcdef123") + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(500)) + Expect(newLockId).To(Equal("")) + }) + + It("Refresh lock mismatch and no lock", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("RefreshLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.RefreshLockResponse{ + Status: status.NewConflict(ctx, nil, "lock mismatch"), + }, nil) + + gatewayClient.On("GetLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.GetLockResponse{ + Status: status.NewOK(ctx), + }, nil) + + newLockId, err := fc.RefreshLock(ctx, "abcdef123") + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(409)) + Expect(newLockId).To(Equal("")) + }) + + It("Refresh lock mismatch", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("RefreshLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.RefreshLockResponse{ + Status: status.NewConflict(ctx, nil, "lock mismatch"), + }, nil) + + gatewayClient.On("GetLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.GetLockResponse{ + Status: status.NewOK(ctx), + Lock: &providerv1beta1.Lock{ + LockId: "zzz999", + Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, + }, + }, nil) + + newLockId, err := fc.RefreshLock(ctx, "abcdef123") + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(409)) + Expect(newLockId).To(Equal("zzz999")) + }) + + It("Default error handling (insufficient storage)", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("RefreshLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.RefreshLockResponse{ + Status: status.NewInsufficientStorage(ctx, nil, "file too big"), + }, nil) + + newLockId, err := fc.RefreshLock(ctx, "abcdef123") + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(500)) + Expect(newLockId).To(Equal("")) + }) + }) + + Describe("Unlock", func() { + It("No valid context", func() { + ctx := context.Background() + newLockId, err := fc.UnLock(ctx, "newLock") + Expect(err).To(HaveOccurred()) + Expect(newLockId).To(Equal("")) + }) + + It("Empty lockId", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + newLockId, err := fc.UnLock(ctx, "") + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(400)) + Expect(newLockId).To(Equal("")) + }) + + It("Unlock fails", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + targetErr := errors.New("Something went wrong") + gatewayClient.On("Unlock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.UnlockResponse{ + Status: status.NewInternal(ctx, "something failed"), + }, targetErr) + + newLockId, err := fc.UnLock(ctx, "abcdef123") + Expect(err).To(HaveOccurred()) + Expect(err).To(Equal(targetErr)) + Expect(newLockId).To(Equal("")) + }) + + It("Unlock success", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("Unlock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.UnlockResponse{ + Status: status.NewOK(ctx), + }, nil) + + newLockId, err := fc.UnLock(ctx, "abcdef123") + Expect(err).To(Succeed()) + Expect(newLockId).To(Equal("")) + }) + + It("Unlock file isn't locked", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("Unlock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.UnlockResponse{ + Status: status.NewConflict(ctx, nil, "lock mismatch"), + }, nil) + + newLockId, err := fc.UnLock(ctx, "abcdef123") + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(409)) + Expect(newLockId).To(Equal("")) + }) + + It("Unlock mismatch get lock fails", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("Unlock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.UnlockResponse{ + Status: status.NewLocked(ctx, "lock mismatch"), + }, nil) + + targetErr := errors.New("Something went wrong") + gatewayClient.On("GetLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.GetLockResponse{ + Status: status.NewInternal(ctx, "something failed"), + }, targetErr) + + newLockId, err := fc.UnLock(ctx, "abcdef123") + Expect(err).To(HaveOccurred()) + Expect(err).To(Equal(targetErr)) + Expect(newLockId).To(Equal("")) + }) + + It("Unlock mismatch get lock status not ok", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("Unlock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.UnlockResponse{ + Status: status.NewLocked(ctx, "lock mismatch"), + }, nil) + + gatewayClient.On("GetLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.GetLockResponse{ + Status: status.NewInternal(ctx, "something failed"), + }, nil) + + newLockId, err := fc.UnLock(ctx, "abcdef123") + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(500)) + Expect(newLockId).To(Equal("")) + }) + + It("Unlock mismatch get lock doesn't return lock", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("Unlock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.UnlockResponse{ + Status: status.NewLocked(ctx, "lock mismatch"), + }, nil) + + gatewayClient.On("GetLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.GetLockResponse{ + Status: status.NewOK(ctx), + }, nil) + + newLockId, err := fc.UnLock(ctx, "abcdef123") + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(409)) + Expect(newLockId).To(Equal("")) + }) + + It("Unlock mismatch", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("Unlock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.UnlockResponse{ + Status: status.NewLocked(ctx, "lock mismatch"), + }, nil) + + gatewayClient.On("GetLock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.GetLockResponse{ + Status: status.NewOK(ctx), + Lock: &providerv1beta1.Lock{ + LockId: "zzz999", + Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, + }, + }, nil) + + newLockId, err := fc.UnLock(ctx, "abcdef123") + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(409)) + Expect(newLockId).To(Equal("zzz999")) + }) + + It("Default error handling (insufficient storage)", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("Unlock", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.UnlockResponse{ + Status: status.NewInsufficientStorage(ctx, nil, "file too big"), + }, nil) + + newLockId, err := fc.UnLock(ctx, "abcdef123") + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(500)) + Expect(newLockId).To(Equal("")) + }) + }) + + Describe("CheckFileInfo", func() { + It("No valid context", func() { + ctx := context.Background() + newFileInfo, err := fc.CheckFileInfo(ctx) + Expect(err).To(HaveOccurred()) + Expect(newFileInfo).To(Equal(connector.FileInfo{})) + }) + + It("Stat fails", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + targetErr := errors.New("Something went wrong") + gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{ + Status: status.NewInternal(ctx, "something failed"), + }, targetErr) + + newFileInfo, err := fc.CheckFileInfo(ctx) + Expect(err).To(HaveOccurred()) + Expect(err).To(Equal(targetErr)) + Expect(newFileInfo).To(Equal(connector.FileInfo{})) + }) + + It("Stat fails status not ok", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{ + Status: status.NewInternal(ctx, "something failed"), + }, nil) + + newFileInfo, err := fc.CheckFileInfo(ctx) + Expect(err).To(HaveOccurred()) + conErr := err.(*connector.ConnectorError) + Expect(conErr.HttpCodeOut).To(Equal(500)) + Expect(newFileInfo).To(Equal(connector.FileInfo{})) + }) + + It("Stat success", func() { + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{ + Status: status.NewOK(ctx), + Info: &providerv1beta1.ResourceInfo{ + Owner: &userv1beta1.UserId{ + Idp: "customIdp", + OpaqueId: "aabbcc", + Type: userv1beta1.UserType_USER_TYPE_PRIMARY, + }, + Size: uint64(998877), + Mtime: &typesv1beta1.Timestamp{ + Seconds: uint64(16273849), + }, + Path: "/path/to/test.txt", + // Other properties aren't used for now. + }, + }, nil) + + expectedFileInfo := connector.FileInfo{ + OwnerId: "61616262636340637573746f6d496470", // hex of aabbcc@customIdp + Size: int64(998877), + Version: "16273849.0", + BaseFileName: "test.txt", + BreadcrumbDocName: "test.txt", + UserCanNotWriteRelative: true, + HostViewUrl: "http://test.ex.prv/view", + HostEditUrl: "http://test.ex.prv/edit", + EnableOwnerTermination: false, + SupportsExtendedLockLength: true, + SupportsGetLock: true, + SupportsLocks: true, + SupportsUpdate: true, + UserCanWrite: true, + UserId: "6f7061717565496440696e6d656d6f7279", // hex of opaqueId@inmemory + UserFriendlyName: "Pet Shaft", + } + + newFileInfo, err := fc.CheckFileInfo(ctx) + Expect(err).To(Succeed()) + Expect(newFileInfo).To(Equal(expectedFileInfo)) + }) + + It("Stat success guests", func() { + // add user's opaque to include public-share-role + wopiCtx.User.Opaque = &typesv1beta1.Opaque{ + Map: map[string]*typesv1beta1.OpaqueEntry{ + "public-share-role": &typesv1beta1.OpaqueEntry{ + Decoder: "plain", + Value: []byte("viewer"), + }, + }, + } + // change view mode to view only + wopiCtx.ViewMode = appproviderv1beta1.ViewMode_VIEW_MODE_VIEW_ONLY + + ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + + gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{ + Status: status.NewOK(ctx), + Info: &providerv1beta1.ResourceInfo{ + Owner: &userv1beta1.UserId{ + Idp: "customIdp", + OpaqueId: "aabbcc", + Type: userv1beta1.UserType_USER_TYPE_PRIMARY, + }, + Size: uint64(998877), + Mtime: &typesv1beta1.Timestamp{ + Seconds: uint64(16273849), + }, + Path: "/path/to/test.txt", + // Other properties aren't used for now. + }, + }, nil) + + expectedFileInfo := connector.FileInfo{ + OwnerId: "61616262636340637573746f6d496470", // hex of aabbcc@customIdp + Size: int64(998877), + Version: "16273849.0", + BaseFileName: "test.txt", + BreadcrumbDocName: "test.txt", + UserCanNotWriteRelative: true, + HostViewUrl: "http://test.ex.prv/view", + HostEditUrl: "http://test.ex.prv/edit", + EnableOwnerTermination: false, + SupportsExtendedLockLength: true, + SupportsGetLock: true, + SupportsLocks: true, + DisableExport: true, + DisableCopy: true, + DisablePrint: true, + IsAnonymousUser: true, + UserId: "guest-zzz000", + UserFriendlyName: "guest zzz000", + } + + newFileInfo, err := fc.CheckFileInfo(ctx) + + // UserId and UserFriendlyName have random Ids generated which are impossible to guess + // Check both separately + Expect(newFileInfo.UserId).To(HavePrefix(hex.EncodeToString([]byte("guest-")))) + Expect(newFileInfo.UserFriendlyName).To(HavePrefix("Guest ")) + // overwrite UserId and UserFriendlyName here for easier matching + newFileInfo.UserId = "guest-zzz000" + newFileInfo.UserFriendlyName = "guest zzz000" + + Expect(err).To(Succeed()) + Expect(newFileInfo).To(Equal(expectedFileInfo)) + }) + }) +}) diff --git a/services/collaboration/pkg/helpers/discovery_test.go b/services/collaboration/pkg/helpers/discovery_test.go new file mode 100644 index 0000000000..cb1679045e --- /dev/null +++ b/services/collaboration/pkg/helpers/discovery_test.go @@ -0,0 +1,124 @@ +package helpers_test + +import ( + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/helpers" +) + +var _ = Describe("Discovery", func() { + var ( + discoveryContent1 string + srv *httptest.Server + ) + + BeforeEach(func() { + discoveryContent1 = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + +` + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case "/bad/hosting/discovery": + w.WriteHeader(500) + case "/good/hosting/discovery": + w.Write([]byte(discoveryContent1)) + case "/wrongformat/hosting/discovery": + w.Write([]byte("Text that be XML /form Date: Tue, 2 Apr 2024 14:42:50 +0200 Subject: [PATCH 25/29] test: include interfaces, add mocks and add unit tests in adapter --- services/collaboration/.mockery.yaml | 12 + .../collaboration/mocks/connector_service.go | 129 +++++ .../mocks/content_connector_service.go | 143 ++++++ .../mocks/file_connector_service.go | 322 ++++++++++++ .../collaboration/pkg/connector/connector.go | 19 +- .../pkg/connector/contentconnector.go | 15 + .../pkg/connector/fileconnector.go | 26 + .../pkg/connector/httpadapter.go | 18 +- .../pkg/connector/httpadapter_test.go | 476 ++++++++++++++++++ 9 files changed, 1153 insertions(+), 7 deletions(-) create mode 100644 services/collaboration/.mockery.yaml create mode 100644 services/collaboration/mocks/connector_service.go create mode 100644 services/collaboration/mocks/content_connector_service.go create mode 100644 services/collaboration/mocks/file_connector_service.go create mode 100644 services/collaboration/pkg/connector/httpadapter_test.go diff --git a/services/collaboration/.mockery.yaml b/services/collaboration/.mockery.yaml new file mode 100644 index 0000000000..87425509bc --- /dev/null +++ b/services/collaboration/.mockery.yaml @@ -0,0 +1,12 @@ +with-expecter: true +filename: "{{.InterfaceName | snakecase }}.go" +mockname: "{{.InterfaceName}}" +outpkg: "mocks" +packages: + github.com/owncloud/ocis/v2/services/collaboration/pkg/connector: + config: + dir: "mocks" + interfaces: + ConnectorService: + ContentConnectorService: + FileConnectorService: diff --git a/services/collaboration/mocks/connector_service.go b/services/collaboration/mocks/connector_service.go new file mode 100644 index 0000000000..ca93a6f562 --- /dev/null +++ b/services/collaboration/mocks/connector_service.go @@ -0,0 +1,129 @@ +// Code generated by mockery v2.40.2. DO NOT EDIT. + +package mocks + +import ( + connector "github.com/owncloud/ocis/v2/services/collaboration/pkg/connector" + mock "github.com/stretchr/testify/mock" +) + +// ConnectorService is an autogenerated mock type for the ConnectorService type +type ConnectorService struct { + mock.Mock +} + +type ConnectorService_Expecter struct { + mock *mock.Mock +} + +func (_m *ConnectorService) EXPECT() *ConnectorService_Expecter { + return &ConnectorService_Expecter{mock: &_m.Mock} +} + +// GetContentConnector provides a mock function with given fields: +func (_m *ConnectorService) GetContentConnector() connector.ContentConnectorService { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetContentConnector") + } + + var r0 connector.ContentConnectorService + if rf, ok := ret.Get(0).(func() connector.ContentConnectorService); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(connector.ContentConnectorService) + } + } + + return r0 +} + +// ConnectorService_GetContentConnector_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetContentConnector' +type ConnectorService_GetContentConnector_Call struct { + *mock.Call +} + +// GetContentConnector is a helper method to define mock.On call +func (_e *ConnectorService_Expecter) GetContentConnector() *ConnectorService_GetContentConnector_Call { + return &ConnectorService_GetContentConnector_Call{Call: _e.mock.On("GetContentConnector")} +} + +func (_c *ConnectorService_GetContentConnector_Call) Run(run func()) *ConnectorService_GetContentConnector_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ConnectorService_GetContentConnector_Call) Return(_a0 connector.ContentConnectorService) *ConnectorService_GetContentConnector_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ConnectorService_GetContentConnector_Call) RunAndReturn(run func() connector.ContentConnectorService) *ConnectorService_GetContentConnector_Call { + _c.Call.Return(run) + return _c +} + +// GetFileConnector provides a mock function with given fields: +func (_m *ConnectorService) GetFileConnector() connector.FileConnectorService { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetFileConnector") + } + + var r0 connector.FileConnectorService + if rf, ok := ret.Get(0).(func() connector.FileConnectorService); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(connector.FileConnectorService) + } + } + + return r0 +} + +// ConnectorService_GetFileConnector_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetFileConnector' +type ConnectorService_GetFileConnector_Call struct { + *mock.Call +} + +// GetFileConnector is a helper method to define mock.On call +func (_e *ConnectorService_Expecter) GetFileConnector() *ConnectorService_GetFileConnector_Call { + return &ConnectorService_GetFileConnector_Call{Call: _e.mock.On("GetFileConnector")} +} + +func (_c *ConnectorService_GetFileConnector_Call) Run(run func()) *ConnectorService_GetFileConnector_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ConnectorService_GetFileConnector_Call) Return(_a0 connector.FileConnectorService) *ConnectorService_GetFileConnector_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ConnectorService_GetFileConnector_Call) RunAndReturn(run func() connector.FileConnectorService) *ConnectorService_GetFileConnector_Call { + _c.Call.Return(run) + return _c +} + +// NewConnectorService creates a new instance of ConnectorService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewConnectorService(t interface { + mock.TestingT + Cleanup(func()) +}) *ConnectorService { + mock := &ConnectorService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/services/collaboration/mocks/content_connector_service.go b/services/collaboration/mocks/content_connector_service.go new file mode 100644 index 0000000000..25cd2a2ec7 --- /dev/null +++ b/services/collaboration/mocks/content_connector_service.go @@ -0,0 +1,143 @@ +// Code generated by mockery v2.40.2. DO NOT EDIT. + +package mocks + +import ( + context "context" + io "io" + + mock "github.com/stretchr/testify/mock" +) + +// ContentConnectorService is an autogenerated mock type for the ContentConnectorService type +type ContentConnectorService struct { + mock.Mock +} + +type ContentConnectorService_Expecter struct { + mock *mock.Mock +} + +func (_m *ContentConnectorService) EXPECT() *ContentConnectorService_Expecter { + return &ContentConnectorService_Expecter{mock: &_m.Mock} +} + +// GetFile provides a mock function with given fields: ctx, writer +func (_m *ContentConnectorService) GetFile(ctx context.Context, writer io.Writer) error { + ret := _m.Called(ctx, writer) + + if len(ret) == 0 { + panic("no return value specified for GetFile") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, io.Writer) error); ok { + r0 = rf(ctx, writer) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// ContentConnectorService_GetFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetFile' +type ContentConnectorService_GetFile_Call struct { + *mock.Call +} + +// GetFile is a helper method to define mock.On call +// - ctx context.Context +// - writer io.Writer +func (_e *ContentConnectorService_Expecter) GetFile(ctx interface{}, writer interface{}) *ContentConnectorService_GetFile_Call { + return &ContentConnectorService_GetFile_Call{Call: _e.mock.On("GetFile", ctx, writer)} +} + +func (_c *ContentConnectorService_GetFile_Call) Run(run func(ctx context.Context, writer io.Writer)) *ContentConnectorService_GetFile_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(io.Writer)) + }) + return _c +} + +func (_c *ContentConnectorService_GetFile_Call) Return(_a0 error) *ContentConnectorService_GetFile_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *ContentConnectorService_GetFile_Call) RunAndReturn(run func(context.Context, io.Writer) error) *ContentConnectorService_GetFile_Call { + _c.Call.Return(run) + return _c +} + +// PutFile provides a mock function with given fields: ctx, stream, streamLength, lockID +func (_m *ContentConnectorService) PutFile(ctx context.Context, stream io.Reader, streamLength int64, lockID string) (string, error) { + ret := _m.Called(ctx, stream, streamLength, lockID) + + if len(ret) == 0 { + panic("no return value specified for PutFile") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, io.Reader, int64, string) (string, error)); ok { + return rf(ctx, stream, streamLength, lockID) + } + if rf, ok := ret.Get(0).(func(context.Context, io.Reader, int64, string) string); ok { + r0 = rf(ctx, stream, streamLength, lockID) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(context.Context, io.Reader, int64, string) error); ok { + r1 = rf(ctx, stream, streamLength, lockID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ContentConnectorService_PutFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutFile' +type ContentConnectorService_PutFile_Call struct { + *mock.Call +} + +// PutFile is a helper method to define mock.On call +// - ctx context.Context +// - stream io.Reader +// - streamLength int64 +// - lockID string +func (_e *ContentConnectorService_Expecter) PutFile(ctx interface{}, stream interface{}, streamLength interface{}, lockID interface{}) *ContentConnectorService_PutFile_Call { + return &ContentConnectorService_PutFile_Call{Call: _e.mock.On("PutFile", ctx, stream, streamLength, lockID)} +} + +func (_c *ContentConnectorService_PutFile_Call) Run(run func(ctx context.Context, stream io.Reader, streamLength int64, lockID string)) *ContentConnectorService_PutFile_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(io.Reader), args[2].(int64), args[3].(string)) + }) + return _c +} + +func (_c *ContentConnectorService_PutFile_Call) Return(_a0 string, _a1 error) *ContentConnectorService_PutFile_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *ContentConnectorService_PutFile_Call) RunAndReturn(run func(context.Context, io.Reader, int64, string) (string, error)) *ContentConnectorService_PutFile_Call { + _c.Call.Return(run) + return _c +} + +// NewContentConnectorService creates a new instance of ContentConnectorService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewContentConnectorService(t interface { + mock.TestingT + Cleanup(func()) +}) *ContentConnectorService { + mock := &ContentConnectorService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/services/collaboration/mocks/file_connector_service.go b/services/collaboration/mocks/file_connector_service.go new file mode 100644 index 0000000000..7671d8f35c --- /dev/null +++ b/services/collaboration/mocks/file_connector_service.go @@ -0,0 +1,322 @@ +// Code generated by mockery v2.40.2. DO NOT EDIT. + +package mocks + +import ( + context "context" + + connector "github.com/owncloud/ocis/v2/services/collaboration/pkg/connector" + + mock "github.com/stretchr/testify/mock" +) + +// FileConnectorService is an autogenerated mock type for the FileConnectorService type +type FileConnectorService struct { + mock.Mock +} + +type FileConnectorService_Expecter struct { + mock *mock.Mock +} + +func (_m *FileConnectorService) EXPECT() *FileConnectorService_Expecter { + return &FileConnectorService_Expecter{mock: &_m.Mock} +} + +// CheckFileInfo provides a mock function with given fields: ctx +func (_m *FileConnectorService) CheckFileInfo(ctx context.Context) (connector.FileInfo, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for CheckFileInfo") + } + + var r0 connector.FileInfo + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (connector.FileInfo, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) connector.FileInfo); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(connector.FileInfo) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// FileConnectorService_CheckFileInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CheckFileInfo' +type FileConnectorService_CheckFileInfo_Call struct { + *mock.Call +} + +// CheckFileInfo is a helper method to define mock.On call +// - ctx context.Context +func (_e *FileConnectorService_Expecter) CheckFileInfo(ctx interface{}) *FileConnectorService_CheckFileInfo_Call { + return &FileConnectorService_CheckFileInfo_Call{Call: _e.mock.On("CheckFileInfo", ctx)} +} + +func (_c *FileConnectorService_CheckFileInfo_Call) Run(run func(ctx context.Context)) *FileConnectorService_CheckFileInfo_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *FileConnectorService_CheckFileInfo_Call) Return(_a0 connector.FileInfo, _a1 error) *FileConnectorService_CheckFileInfo_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *FileConnectorService_CheckFileInfo_Call) RunAndReturn(run func(context.Context) (connector.FileInfo, error)) *FileConnectorService_CheckFileInfo_Call { + _c.Call.Return(run) + return _c +} + +// GetLock provides a mock function with given fields: ctx +func (_m *FileConnectorService) GetLock(ctx context.Context) (string, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetLock") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (string, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) string); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// FileConnectorService_GetLock_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetLock' +type FileConnectorService_GetLock_Call struct { + *mock.Call +} + +// GetLock is a helper method to define mock.On call +// - ctx context.Context +func (_e *FileConnectorService_Expecter) GetLock(ctx interface{}) *FileConnectorService_GetLock_Call { + return &FileConnectorService_GetLock_Call{Call: _e.mock.On("GetLock", ctx)} +} + +func (_c *FileConnectorService_GetLock_Call) Run(run func(ctx context.Context)) *FileConnectorService_GetLock_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *FileConnectorService_GetLock_Call) Return(_a0 string, _a1 error) *FileConnectorService_GetLock_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *FileConnectorService_GetLock_Call) RunAndReturn(run func(context.Context) (string, error)) *FileConnectorService_GetLock_Call { + _c.Call.Return(run) + return _c +} + +// Lock provides a mock function with given fields: ctx, lockID, oldLockID +func (_m *FileConnectorService) Lock(ctx context.Context, lockID string, oldLockID string) (string, error) { + ret := _m.Called(ctx, lockID, oldLockID) + + if len(ret) == 0 { + panic("no return value specified for Lock") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) (string, error)); ok { + return rf(ctx, lockID, oldLockID) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) string); ok { + r0 = rf(ctx, lockID, oldLockID) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, lockID, oldLockID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// FileConnectorService_Lock_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Lock' +type FileConnectorService_Lock_Call struct { + *mock.Call +} + +// Lock is a helper method to define mock.On call +// - ctx context.Context +// - lockID string +// - oldLockID string +func (_e *FileConnectorService_Expecter) Lock(ctx interface{}, lockID interface{}, oldLockID interface{}) *FileConnectorService_Lock_Call { + return &FileConnectorService_Lock_Call{Call: _e.mock.On("Lock", ctx, lockID, oldLockID)} +} + +func (_c *FileConnectorService_Lock_Call) Run(run func(ctx context.Context, lockID string, oldLockID string)) *FileConnectorService_Lock_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *FileConnectorService_Lock_Call) Return(_a0 string, _a1 error) *FileConnectorService_Lock_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *FileConnectorService_Lock_Call) RunAndReturn(run func(context.Context, string, string) (string, error)) *FileConnectorService_Lock_Call { + _c.Call.Return(run) + return _c +} + +// RefreshLock provides a mock function with given fields: ctx, lockID +func (_m *FileConnectorService) RefreshLock(ctx context.Context, lockID string) (string, error) { + ret := _m.Called(ctx, lockID) + + if len(ret) == 0 { + panic("no return value specified for RefreshLock") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (string, error)); ok { + return rf(ctx, lockID) + } + if rf, ok := ret.Get(0).(func(context.Context, string) string); ok { + r0 = rf(ctx, lockID) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, lockID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// FileConnectorService_RefreshLock_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RefreshLock' +type FileConnectorService_RefreshLock_Call struct { + *mock.Call +} + +// RefreshLock is a helper method to define mock.On call +// - ctx context.Context +// - lockID string +func (_e *FileConnectorService_Expecter) RefreshLock(ctx interface{}, lockID interface{}) *FileConnectorService_RefreshLock_Call { + return &FileConnectorService_RefreshLock_Call{Call: _e.mock.On("RefreshLock", ctx, lockID)} +} + +func (_c *FileConnectorService_RefreshLock_Call) Run(run func(ctx context.Context, lockID string)) *FileConnectorService_RefreshLock_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *FileConnectorService_RefreshLock_Call) Return(_a0 string, _a1 error) *FileConnectorService_RefreshLock_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *FileConnectorService_RefreshLock_Call) RunAndReturn(run func(context.Context, string) (string, error)) *FileConnectorService_RefreshLock_Call { + _c.Call.Return(run) + return _c +} + +// UnLock provides a mock function with given fields: ctx, lockID +func (_m *FileConnectorService) UnLock(ctx context.Context, lockID string) (string, error) { + ret := _m.Called(ctx, lockID) + + if len(ret) == 0 { + panic("no return value specified for UnLock") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (string, error)); ok { + return rf(ctx, lockID) + } + if rf, ok := ret.Get(0).(func(context.Context, string) string); ok { + r0 = rf(ctx, lockID) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, lockID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// FileConnectorService_UnLock_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UnLock' +type FileConnectorService_UnLock_Call struct { + *mock.Call +} + +// UnLock is a helper method to define mock.On call +// - ctx context.Context +// - lockID string +func (_e *FileConnectorService_Expecter) UnLock(ctx interface{}, lockID interface{}) *FileConnectorService_UnLock_Call { + return &FileConnectorService_UnLock_Call{Call: _e.mock.On("UnLock", ctx, lockID)} +} + +func (_c *FileConnectorService_UnLock_Call) Run(run func(ctx context.Context, lockID string)) *FileConnectorService_UnLock_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *FileConnectorService_UnLock_Call) Return(_a0 string, _a1 error) *FileConnectorService_UnLock_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *FileConnectorService_UnLock_Call) RunAndReturn(run func(context.Context, string) (string, error)) *FileConnectorService_UnLock_Call { + _c.Call.Return(run) + return _c +} + +// NewFileConnectorService creates a new instance of FileConnectorService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewFileConnectorService(t interface { + mock.TestingT + Cleanup(func()) +}) *FileConnectorService { + mock := &FileConnectorService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/services/collaboration/pkg/connector/connector.go b/services/collaboration/pkg/connector/connector.go index f87a09f5fc..b38eb73370 100644 --- a/services/collaboration/pkg/connector/connector.go +++ b/services/collaboration/pkg/connector/connector.go @@ -20,6 +20,15 @@ func NewConnectorError(code int, msg string) *ConnectorError { } } +// ConnectorService is the interface to implement the WOPI operations. They're +// divided into multiple endpoints. +// The IFileConnector will implement the "File" endpoint +// The IContentConnector will implement the "File content" endpoint +type ConnectorService interface { + GetFileConnector() FileConnectorService + GetContentConnector() ContentConnectorService +} + // Connector will implement the WOPI operations. // For convenience, the connector splits the operations based on the // WOPI endpoints, so you'll need to get the specific connector first. @@ -30,21 +39,21 @@ func NewConnectorError(code int, msg string) *ConnectorError { // // Other endpoints aren't available for now. type Connector struct { - fileConnector *FileConnector - contentConnector *ContentConnector + fileConnector FileConnectorService + contentConnector ContentConnectorService } -func NewConnector(fc *FileConnector, cc *ContentConnector) *Connector { +func NewConnector(fc FileConnectorService, cc ContentConnectorService) *Connector { return &Connector{ fileConnector: fc, contentConnector: cc, } } -func (c *Connector) GetFileConnector() *FileConnector { +func (c *Connector) GetFileConnector() FileConnectorService { return c.fileConnector } -func (c *Connector) GetContentConnector() *ContentConnector { +func (c *Connector) GetContentConnector() ContentConnectorService { return c.contentConnector } diff --git a/services/collaboration/pkg/connector/contentconnector.go b/services/collaboration/pkg/connector/contentconnector.go index 1559e9d314..5153e821d4 100644 --- a/services/collaboration/pkg/connector/contentconnector.go +++ b/services/collaboration/pkg/connector/contentconnector.go @@ -18,6 +18,21 @@ import ( "github.com/rs/zerolog" ) +// ContentConnectorService is the interface to implement the "File contents" +// endpoint. Basically upload and download contents. +// All operations need a context containing a WOPI context and, optionally, +// a zerolog logger. +// Target file is within the WOPI context +type ContentConnectorService interface { + // GetFile downloads the file and write its contents in the provider writer + GetFile(ctx context.Context, writer io.Writer) error + // PutFile uploads the stream up to the stream length. The file should be + // locked beforehand, so the lockID needs to be provided. + // The current lockID will be returned ONLY if a conflict happens (the file is + // locked with a different lockID) + PutFile(ctx context.Context, stream io.Reader, streamLength int64, lockID string) (string, error) +} + // ContentConnector implements the "File contents" endpoint. // Basically, the ContentConnector handles downloads (GetFile) and // uploads (PutFile) diff --git a/services/collaboration/pkg/connector/fileconnector.go b/services/collaboration/pkg/connector/fileconnector.go index 6945f3b917..c16b117fb1 100644 --- a/services/collaboration/pkg/connector/fileconnector.go +++ b/services/collaboration/pkg/connector/fileconnector.go @@ -25,6 +25,32 @@ const ( lockDuration time.Duration = 30 * time.Minute ) +// FileConnectorService is the interface to implement the "Files" +// endpoint. Basically lock operations on the file plus the CheckFileInfo. +// All operations need a context containing a WOPI context and, optionally, +// a zerolog logger. +// Target file is within the WOPI context +type FileConnectorService interface { + // GetLock will return the lockID present in the target file. + GetLock(ctx context.Context) (string, error) + // Lock will lock the target file with the provided lockID. If the oldLockID + // is provided (not empty), the method will perform an unlockAndRelock + // operation (unlock the file with the oldLockID and immediately relock + // the file with the new lockID). + // The current lockID will be returned if a conflict happens + Lock(ctx context.Context, lockID, oldLockID string) (string, error) + // RefreshLock will extend the lock time 30 minutes. The current lockID + // needs to be provided. + // The current lockID will be returned if a conflict happens + RefreshLock(ctx context.Context, lockID string) (string, error) + // Unlock will unlock the target file. The current lockID needs to be + // provided. + // The current lockID will be returned if a conflict happens + UnLock(ctx context.Context, lockID string) (string, error) + // CheckFileInfo will return the file information of the target file + CheckFileInfo(ctx context.Context) (FileInfo, error) +} + type FileConnector struct { gwc gatewayv1beta1.GatewayAPIClient cfg *config.Config diff --git a/services/collaboration/pkg/connector/httpadapter.go b/services/collaboration/pkg/connector/httpadapter.go index 8eda845cfd..075c3d3d8c 100644 --- a/services/collaboration/pkg/connector/httpadapter.go +++ b/services/collaboration/pkg/connector/httpadapter.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "net/http" + "strconv" gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" @@ -24,9 +25,11 @@ const ( // All operations are expected to follow the definitions found in // https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/endpoints type HttpAdapter struct { - con *Connector + con ConnectorService } +// NewHttpAdapter will create a new HTTP adapter. A new connector using the +// provided gateway API client and configuration will be used in the adapter func NewHttpAdapter(gwc gatewayv1beta1.GatewayAPIClient, cfg *config.Config) *HttpAdapter { return &HttpAdapter{ con: NewConnector( @@ -36,6 +39,14 @@ func NewHttpAdapter(gwc gatewayv1beta1.GatewayAPIClient, cfg *config.Config) *Ht } } +// NewHttpAdapterWithConnector will create a new HTTP adapter that will use +// the provided connector service +func NewHttpAdapterWithConnector(con ConnectorService) *HttpAdapter { + return &HttpAdapter{ + con: con, + } +} + func (h *HttpAdapter) GetLock(w http.ResponseWriter, r *http.Request) { fileCon := h.con.GetFileConnector() @@ -119,6 +130,9 @@ func (h *HttpAdapter) UnLock(w http.ResponseWriter, r *http.Request) { func (h *HttpAdapter) CheckFileInfo(w http.ResponseWriter, r *http.Request) { fileCon := h.con.GetFileConnector() + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", "0") + fileInfo, err := fileCon.CheckFileInfo(r.Context()) if err != nil { var conError *ConnectorError @@ -138,7 +152,7 @@ func (h *HttpAdapter) CheckFileInfo(w http.ResponseWriter, r *http.Request) { return } - w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", strconv.Itoa(len(jsonFileInfo))) w.WriteHeader(http.StatusOK) bytes, err := w.Write(jsonFileInfo) diff --git a/services/collaboration/pkg/connector/httpadapter_test.go b/services/collaboration/pkg/connector/httpadapter_test.go new file mode 100644 index 0000000000..3a277f5fc4 --- /dev/null +++ b/services/collaboration/pkg/connector/httpadapter_test.go @@ -0,0 +1,476 @@ +package connector_test + +import ( + "encoding/json" + "errors" + "io" + "net/http/httptest" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/owncloud/ocis/v2/services/collaboration/mocks" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/connector" + "github.com/stretchr/testify/mock" +) + +var _ = Describe("HttpAdapter", func() { + var ( + fc *mocks.FileConnectorService + cc *mocks.ContentConnectorService + con *mocks.ConnectorService + httpAdapter *connector.HttpAdapter + ) + + BeforeEach(func() { + fc = &mocks.FileConnectorService{} + cc = &mocks.ContentConnectorService{} + + con = &mocks.ConnectorService{} + con.On("GetContentConnector").Return(cc) + con.On("GetFileConnector").Return(fc) + + httpAdapter = connector.NewHttpAdapterWithConnector(con) + }) + + Describe("GetLock", func() { + It("General error", func() { + req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil) + req.Header.Set("X-WOPI-Override", "POST_LOCK") + + w := httptest.NewRecorder() + + fc.On("GetLock", mock.Anything).Times(1).Return("", errors.New("Something happened")) + + httpAdapter.GetLock(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(500)) + }) + + It("File not found", func() { + req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil) + req.Header.Set("X-WOPI-Override", "POST_LOCK") + + w := httptest.NewRecorder() + + fc.On("GetLock", mock.Anything).Times(1).Return("", connector.NewConnectorError(404, "Couldn't get the file")) + + httpAdapter.GetLock(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(404)) + }) + + It("LockId", func() { + req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil) + req.Header.Set("X-WOPI-Override", "POST_LOCK") + + w := httptest.NewRecorder() + + fc.On("GetLock", mock.Anything).Times(1).Return("zzz111", nil) + + httpAdapter.GetLock(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(200)) + Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("zzz111")) + }) + + It("Empty LockId", func() { + req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil) + req.Header.Set("X-WOPI-Override", "POST_LOCK") + + w := httptest.NewRecorder() + + fc.On("GetLock", mock.Anything).Times(1).Return("", nil) + + httpAdapter.GetLock(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(200)) + Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("")) + }) + }) + + Describe("Lock", func() { + Describe("Just lock", func() { + It("General error", func() { + req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil) + req.Header.Set("X-WOPI-Override", "LOCK") + req.Header.Set(connector.HeaderWopiLock, "abc123") + + w := httptest.NewRecorder() + + fc.On("Lock", mock.Anything, "abc123", "").Times(1).Return("", errors.New("Something happened")) + + httpAdapter.Lock(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(500)) + }) + + It("No LockId provided", func() { + req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil) + req.Header.Set("X-WOPI-Override", "LOCK") + req.Header.Set(connector.HeaderWopiLock, "") + + w := httptest.NewRecorder() + + fc.On("Lock", mock.Anything, "", "").Times(1).Return("", connector.NewConnectorError(400, "No lockId")) + + httpAdapter.Lock(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(400)) + }) + + It("Conflict", func() { + req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil) + req.Header.Set("X-WOPI-Override", "LOCK") + req.Header.Set(connector.HeaderWopiLock, "abc123") + + w := httptest.NewRecorder() + + fc.On("Lock", mock.Anything, "abc123", "").Times(1).Return("zzz111", connector.NewConnectorError(409, "Lock conflict")) + + httpAdapter.Lock(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(409)) + Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("zzz111")) + }) + + It("Success", func() { + req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil) + req.Header.Set("X-WOPI-Override", "LOCK") + req.Header.Set(connector.HeaderWopiLock, "abc123") + + w := httptest.NewRecorder() + + fc.On("Lock", mock.Anything, "abc123", "").Times(1).Return("", nil) + + httpAdapter.Lock(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(200)) + }) + }) + + Describe("Unlock and relock", func() { + It("General error", func() { + req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil) + req.Header.Set("X-WOPI-Override", "LOCK") + req.Header.Set(connector.HeaderWopiLock, "abc123") + req.Header.Set(connector.HeaderWopiOldLock, "qwerty") + + w := httptest.NewRecorder() + + fc.On("Lock", mock.Anything, "abc123", "qwerty").Times(1).Return("", errors.New("Something happened")) + + httpAdapter.Lock(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(500)) + }) + + It("No LockId provided", func() { + req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil) + req.Header.Set("X-WOPI-Override", "LOCK") + req.Header.Set(connector.HeaderWopiLock, "") + req.Header.Set(connector.HeaderWopiOldLock, "") + + w := httptest.NewRecorder() + + fc.On("Lock", mock.Anything, "", "").Times(1).Return("", connector.NewConnectorError(400, "No lockId")) + + httpAdapter.Lock(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(400)) + }) + + It("Conflict", func() { + req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil) + req.Header.Set("X-WOPI-Override", "LOCK") + req.Header.Set(connector.HeaderWopiLock, "abc123") + req.Header.Set(connector.HeaderWopiOldLock, "qwerty") + + w := httptest.NewRecorder() + + fc.On("Lock", mock.Anything, "abc123", "qwerty").Times(1).Return("zzz111", connector.NewConnectorError(409, "Lock conflict")) + + httpAdapter.Lock(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(409)) + Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("zzz111")) + }) + + It("Success", func() { + req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil) + req.Header.Set("X-WOPI-Override", "LOCK") + req.Header.Set(connector.HeaderWopiLock, "abc123") + req.Header.Set(connector.HeaderWopiOldLock, "qwerty") + + w := httptest.NewRecorder() + + fc.On("Lock", mock.Anything, "abc123", "qwerty").Times(1).Return("", nil) + + httpAdapter.Lock(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(200)) + }) + }) + }) + + Describe("RefreshLock", func() { + It("General error", func() { + req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil) + req.Header.Set("X-WOPI-Override", "REFRESH_LOCK") + req.Header.Set(connector.HeaderWopiLock, "abc123") + + w := httptest.NewRecorder() + + fc.On("RefreshLock", mock.Anything, "abc123").Times(1).Return("", errors.New("Something happened")) + + httpAdapter.RefreshLock(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(500)) + }) + + It("No LockId provided", func() { + req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil) + req.Header.Set("X-WOPI-Override", "REFRESH_LOCK") + req.Header.Set(connector.HeaderWopiLock, "") + + w := httptest.NewRecorder() + + fc.On("RefreshLock", mock.Anything, "").Times(1).Return("", connector.NewConnectorError(400, "No lockId")) + + httpAdapter.RefreshLock(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(400)) + }) + + It("Conflict", func() { + req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil) + req.Header.Set("X-WOPI-Override", "REFRESH_LOCK") + req.Header.Set(connector.HeaderWopiLock, "abc123") + + w := httptest.NewRecorder() + + fc.On("RefreshLock", mock.Anything, "abc123").Times(1).Return("zzz111", connector.NewConnectorError(409, "Lock conflict")) + + httpAdapter.RefreshLock(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(409)) + Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("zzz111")) + }) + + It("Success", func() { + req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil) + req.Header.Set("X-WOPI-Override", "REFRESH_LOCK") + req.Header.Set(connector.HeaderWopiLock, "abc123") + + w := httptest.NewRecorder() + + fc.On("RefreshLock", mock.Anything, "abc123").Times(1).Return("", nil) + + httpAdapter.RefreshLock(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(200)) + }) + }) + + Describe("Unlock", func() { + It("General error", func() { + req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil) + req.Header.Set("X-WOPI-Override", "UNLOCK") + req.Header.Set(connector.HeaderWopiLock, "abc123") + + w := httptest.NewRecorder() + + fc.On("UnLock", mock.Anything, "abc123").Times(1).Return("", errors.New("Something happened")) + + httpAdapter.UnLock(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(500)) + }) + + It("No LockId provided", func() { + req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil) + req.Header.Set("X-WOPI-Override", "UNLOCK") + req.Header.Set(connector.HeaderWopiLock, "") + + w := httptest.NewRecorder() + + fc.On("UnLock", mock.Anything, "").Times(1).Return("", connector.NewConnectorError(400, "No lockId")) + + httpAdapter.UnLock(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(400)) + }) + + It("Conflict", func() { + req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil) + req.Header.Set("X-WOPI-Override", "UNLOCK") + req.Header.Set(connector.HeaderWopiLock, "abc123") + + w := httptest.NewRecorder() + + fc.On("UnLock", mock.Anything, "abc123").Times(1).Return("zzz111", connector.NewConnectorError(409, "Lock conflict")) + + httpAdapter.UnLock(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(409)) + Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("zzz111")) + }) + + It("Success", func() { + req := httptest.NewRequest("POST", "/wopi/files/abcdef", nil) + req.Header.Set("X-WOPI-Override", "UNLOCK") + req.Header.Set(connector.HeaderWopiLock, "abc123") + + w := httptest.NewRecorder() + + fc.On("UnLock", mock.Anything, "abc123").Times(1).Return("", nil) + + httpAdapter.UnLock(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(200)) + }) + }) + + Describe("CheckFileInfo", func() { + It("General error", func() { + req := httptest.NewRequest("GET", "/wopi/files/abcdef", nil) + + w := httptest.NewRecorder() + + fc.On("CheckFileInfo", mock.Anything).Times(1).Return(connector.FileInfo{}, errors.New("Something happened")) + + httpAdapter.CheckFileInfo(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(500)) + }) + + It("Not found", func() { + // 404 isn't thrown at the moment. Test is here to prove it's possible to + // throw any error code + req := httptest.NewRequest("GET", "/wopi/files/abcdef", nil) + + w := httptest.NewRecorder() + + fc.On("CheckFileInfo", mock.Anything).Times(1).Return(connector.FileInfo{}, connector.NewConnectorError(404, "Not found")) + + httpAdapter.CheckFileInfo(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(404)) + }) + + It("Success", func() { + req := httptest.NewRequest("GET", "/wopi/files/abcdef", nil) + + w := httptest.NewRecorder() + + // might need more info, but should be enough for the test + fileinfo := connector.FileInfo{ + Size: 123456789, + BreadcrumbDocName: "testy.docx", + } + fc.On("CheckFileInfo", mock.Anything).Times(1).Return(fileinfo, nil) + + httpAdapter.CheckFileInfo(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(200)) + + jsonInfo, _ := io.ReadAll(resp.Body) + + var responseInfo connector.FileInfo + json.Unmarshal(jsonInfo, &responseInfo) + Expect(responseInfo).To(Equal(fileinfo)) + }) + }) + + Describe("GetFile", func() { + It("General error", func() { + req := httptest.NewRequest("GET", "/wopi/files/abcdef/contents", nil) + + w := httptest.NewRecorder() + + cc.On("GetFile", mock.Anything, mock.Anything).Times(1).Return(errors.New("Something happened")) + + httpAdapter.GetFile(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(500)) + }) + + It("Not found", func() { + // 404 isn't thrown at the moment. Test is here to prove it's possible to + // throw any error code + req := httptest.NewRequest("GET", "/wopi/files/abcdef/contents", nil) + + w := httptest.NewRecorder() + + cc.On("GetFile", mock.Anything, mock.Anything).Times(1).Return(connector.NewConnectorError(404, "Not found")) + + httpAdapter.GetFile(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(404)) + }) + + It("Success", func() { + req := httptest.NewRequest("GET", "/wopi/files/abcdef/contents", nil) + + w := httptest.NewRecorder() + + expectedContent := []byte("This is a fake content for a test file") + cc.On("GetFile", mock.Anything, mock.Anything).Times(1).Run(func(args mock.Arguments) { + w := args.Get(1).(io.Writer) + w.Write(expectedContent) + }).Return(nil) + + httpAdapter.GetFile(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(200)) + + content, _ := io.ReadAll(resp.Body) + Expect(content).To(Equal(expectedContent)) + }) + }) + + Describe("PutFile", func() { + It("General error", func() { + contentBody := "this is the new fake content" + req := httptest.NewRequest("GET", "/wopi/files/abcdef/contents", strings.NewReader(contentBody)) + req.Header.Set(connector.HeaderWopiLock, "abc123") + + w := httptest.NewRecorder() + + cc.On("PutFile", mock.Anything, mock.Anything, int64(len(contentBody)), "abc123").Times(1).Return("", errors.New("Something happened")) + + httpAdapter.PutFile(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(500)) + }) + + It("Conflict", func() { + contentBody := "this is the new fake content" + req := httptest.NewRequest("GET", "/wopi/files/abcdef/contents", strings.NewReader(contentBody)) + req.Header.Set(connector.HeaderWopiLock, "abc123") + + w := httptest.NewRecorder() + + cc.On("PutFile", mock.Anything, mock.Anything, int64(len(contentBody)), "abc123").Times(1).Return("zzz111", connector.NewConnectorError(409, "Lock conflict")) + + httpAdapter.PutFile(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(409)) + Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("zzz111")) + }) + + It("Success", func() { + contentBody := "this is the new fake content" + req := httptest.NewRequest("GET", "/wopi/files/abcdef/contents", strings.NewReader(contentBody)) + req.Header.Set(connector.HeaderWopiLock, "abc123") + + w := httptest.NewRecorder() + + cc.On("PutFile", mock.Anything, mock.Anything, int64(len(contentBody)), "abc123").Times(1).Return("", nil) + + httpAdapter.PutFile(w, req) + resp := w.Result() + Expect(resp.StatusCode).To(Equal(200)) + }) + }) +}) From f248d7bba388d258e24a1d6420e593ba443a4187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Wed, 3 Apr 2024 08:51:15 +0200 Subject: [PATCH 26/29] test: allow injection of gateway client in grpc service and add tests --- .../pkg/service/grpc/v0/option.go | 9 + .../pkg/service/grpc/v0/service.go | 10 +- .../pkg/service/grpc/v0/service_suite_test.go | 13 ++ .../pkg/service/grpc/v0/service_test.go | 182 ++++++++++++++++++ 4 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 services/collaboration/pkg/service/grpc/v0/service_suite_test.go create mode 100644 services/collaboration/pkg/service/grpc/v0/service_test.go diff --git a/services/collaboration/pkg/service/grpc/v0/option.go b/services/collaboration/pkg/service/grpc/v0/option.go index 0ad88e8826..977ca05a94 100644 --- a/services/collaboration/pkg/service/grpc/v0/option.go +++ b/services/collaboration/pkg/service/grpc/v0/option.go @@ -1,6 +1,7 @@ package service import ( + gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" ) @@ -13,6 +14,7 @@ type Options struct { Logger log.Logger Config *config.Config AppURLs map[string]map[string]string + Gwc gatewayv1beta1.GatewayAPIClient } func newOptions(opts ...Option) Options { @@ -45,3 +47,10 @@ func AppURLs(val map[string]map[string]string) Option { o.AppURLs = val } } + +// GatewayAPIClient provides a function to set the GatewayAPIClient option. +func GatewayAPIClient(val gatewayv1beta1.GatewayAPIClient) Option { + return func(o *Options) { + o.Gwc = val + } +} diff --git a/services/collaboration/pkg/service/grpc/v0/service.go b/services/collaboration/pkg/service/grpc/v0/service.go index 78de1bd56e..0083dad501 100644 --- a/services/collaboration/pkg/service/grpc/v0/service.go +++ b/services/collaboration/pkg/service/grpc/v0/service.go @@ -26,9 +26,13 @@ func NewHandler(opts ...Option) (*Service, func(), error) { teardown := func() {} options := newOptions(opts...) - gwc, err := pool.GetGatewayServiceClient(options.Config.CS3Api.Gateway.Name) - if err != nil { - return nil, teardown, err + gwc := options.Gwc + var err error + if gwc == nil { + gwc, err = pool.GetGatewayServiceClient(options.Config.CS3Api.Gateway.Name) + if err != nil { + return nil, teardown, err + } } return &Service{ diff --git a/services/collaboration/pkg/service/grpc/v0/service_suite_test.go b/services/collaboration/pkg/service/grpc/v0/service_suite_test.go new file mode 100644 index 0000000000..d135fa2cc2 --- /dev/null +++ b/services/collaboration/pkg/service/grpc/v0/service_suite_test.go @@ -0,0 +1,13 @@ +package service_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestGraph(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Service Suite") +} diff --git a/services/collaboration/pkg/service/grpc/v0/service_test.go b/services/collaboration/pkg/service/grpc/v0/service_test.go new file mode 100644 index 0000000000..cc5bbcd452 --- /dev/null +++ b/services/collaboration/pkg/service/grpc/v0/service_test.go @@ -0,0 +1,182 @@ +package service_test + +import ( + "context" + "strconv" + "time" + + "github.com/golang-jwt/jwt/v4" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" + + appproviderv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1" + authpb "github.com/cs3org/go-cs3apis/cs3/auth/provider/v1beta1" + gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" + + "github.com/cs3org/reva/v2/pkg/rgrpc/status" + cs3mocks "github.com/cs3org/reva/v2/tests/cs3mocks/mocks" + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" + service "github.com/owncloud/ocis/v2/services/collaboration/pkg/service/grpc/v0" +) + +// Based on https://github.com/cs3org/reva/blob/b99ad4865401144a981d4cfd1ae28b5a018ea51d/pkg/token/manager/jwt/jwt.go#L82 +func MintToken(u *userv1beta1.User, secret string, nowTime time.Time) string { + scopes := make(map[string]*authpb.Scope) + scopes["user"] = &authpb.Scope{ + Resource: &types.OpaqueEntry{ + Decoder: "json", + Value: []byte("{\"Path\":\"/\"}"), + }, + Role: authpb.Role_ROLE_OWNER, + } + + claims := jwt.MapClaims{ + "exp": nowTime.Add(5 * time.Hour).Unix(), + "iss": "myself", + "aud": "reva", + "iat": nowTime.Unix(), + "user": u, + "scope": scopes, + } + /* + claims := claims{ + StandardClaims: jwt.RegisteredClaims{ + ExpiresAt: time.Now().Add(5 * time.Hour), + Issuer: "myself", + Audience: "reva", + IssuedAt: time.Now(), + }, + User: u, + Scope: scopes, + } + */ + + t := jwt.NewWithClaims(jwt.GetSigningMethod("HS256"), claims) + + tkn, _ := t.SignedString([]byte(secret)) + + return tkn +} + +var _ = Describe("Discovery", func() { + var ( + cfg *config.Config + gatewayClient *cs3mocks.GatewayAPIClient + srv *service.Service + srvTear func() + ) + + BeforeEach(func() { + cfg = &config.Config{} + gatewayClient = &cs3mocks.GatewayAPIClient{} + + srv, srvTear, _ = service.NewHandler( + service.Logger(log.NopLogger()), + service.Config(cfg), + service.AppURLs(map[string]map[string]string{ + "view": map[string]string{ + ".pdf": "https://test.server.prv/hosting/wopi/word/view", + ".djvu": "https://test.server.prv/hosting/wopi/word/view", + ".docx": "https://test.server.prv/hosting/wopi/word/view", + ".xls": "https://test.server.prv/hosting/wopi/cell/view", + ".xlsb": "https://test.server.prv/hosting/wopi/cell/view", + }, + "edit": map[string]string{ + ".docx": "https://test.server.prv/hosting/wopi/word/edit", + }, + }), + service.GatewayAPIClient(gatewayClient), + ) + }) + + AfterEach(func() { + srvTear() + }) + + Describe("OpenInApp", func() { + It("Invalid access token", func() { + ctx := context.Background() + + cfg.HTTP.Addr = "wopiserver.test.prv" + cfg.HTTP.Scheme = "https" + + req := &appproviderv1beta1.OpenInAppRequest{ + ResourceInfo: &providerv1beta1.ResourceInfo{ + Id: &providerv1beta1.ResourceId{ + StorageId: "myStorage", + OpaqueId: "storageOpaque001", + SpaceId: "SpaceA", + }, + Path: "/path/to/file", + }, + ViewMode: appproviderv1beta1.ViewMode_VIEW_MODE_READ_WRITE, + AccessToken: "goodAccessToken", + } + + gatewayClient.On("WhoAmI", mock.Anything, mock.Anything).Times(1).Return(&gatewayv1beta1.WhoAmIResponse{ + Status: status.NewOK(ctx), + User: &userv1beta1.User{ + Id: &userv1beta1.UserId{ + Idp: "myIdp", + OpaqueId: "opaque001", + Type: userv1beta1.UserType_USER_TYPE_PRIMARY, + }, + Username: "username", + }, + }, nil) + + resp, err := srv.OpenInApp(ctx, req) + Expect(err).To(HaveOccurred()) + Expect(resp).To(BeNil()) + }) + + It("Success", func() { + ctx := context.Background() + nowTime := time.Now() + + cfg.HTTP.Addr = "wopiserver.test.prv" + cfg.HTTP.Scheme = "https" + cfg.JWTSecret = "my_supa_secret" + + myself := &userv1beta1.User{ + Id: &userv1beta1.UserId{ + Idp: "myIdp", + OpaqueId: "opaque001", + Type: userv1beta1.UserType_USER_TYPE_PRIMARY, + }, + Username: "username", + } + + req := &appproviderv1beta1.OpenInAppRequest{ + ResourceInfo: &providerv1beta1.ResourceInfo{ + Id: &providerv1beta1.ResourceId{ + StorageId: "myStorage", + OpaqueId: "storageOpaque001", + SpaceId: "SpaceA", + }, + Path: "/path/to/file.docx", + }, + ViewMode: appproviderv1beta1.ViewMode_VIEW_MODE_READ_WRITE, + AccessToken: MintToken(myself, cfg.JWTSecret, nowTime), + } + + gatewayClient.On("WhoAmI", mock.Anything, mock.Anything).Times(1).Return(&gatewayv1beta1.WhoAmIResponse{ + Status: status.NewOK(ctx), + User: myself, + }, nil) + + resp, err := srv.OpenInApp(ctx, req) + Expect(err).To(Succeed()) + Expect(resp.GetStatus().GetCode()).To(Equal(rpcv1beta1.Code_CODE_OK)) + Expect(resp.GetAppUrl().GetMethod()).To(Equal("POST")) + Expect(resp.GetAppUrl().GetAppUrl()).To(Equal("https://test.server.prv/hosting/wopi/word/edit?WOPISrc=https%3A%2F%2Fwopiserver.test.prv%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e")) + Expect(resp.GetAppUrl().GetFormParameters()["access_token_ttl"]).To(Equal(strconv.FormatInt(nowTime.Add(5*time.Hour).Unix()*1000, 10))) + }) + }) +}) From 54d7a0ddb55b4bdc76da59b18aab79b622f34aeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Thu, 4 Apr 2024 13:28:38 +0200 Subject: [PATCH 27/29] docs: fix some wrong comments --- services/collaboration/pkg/config/cs3api.go | 4 +++- services/collaboration/pkg/config/grpc.go | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/services/collaboration/pkg/config/cs3api.go b/services/collaboration/pkg/config/cs3api.go index 555999718b..776cf86177 100644 --- a/services/collaboration/pkg/config/cs3api.go +++ b/services/collaboration/pkg/config/cs3api.go @@ -1,15 +1,17 @@ package config -// WopiApp defines the available configuration in order to connect to a WOPI app. +// CS3Api defines the available configuration in order to access to the CS3 gateway. type CS3Api struct { Gateway Gateway `yaml:"gateway"` DataGateway DataGateway `yaml:"datagateway"` } +// Gateway defines the available configuration for the CS3 API gateway type Gateway struct { Name string `yaml: "name" env:"OCIS_REVA_GATEWAY;COLLABORATION_CS3API_GATEWAY_NAME" desc:"The service name of the CS3API gateway." introductionVersion:"5.1"` } +// DataGateway defines the available configuration for the CS3 API data gateway type DataGateway struct { Insecure bool `yaml:"insecure" env:"COLLABORATION_CS3API_DATAGATEWAY_INSECURE" desc:"Connect to the CS3API data gateway insecurely." introductionVersion:"5.1"` } diff --git a/services/collaboration/pkg/config/grpc.go b/services/collaboration/pkg/config/grpc.go index 06da141ba4..b7351bd37a 100644 --- a/services/collaboration/pkg/config/grpc.go +++ b/services/collaboration/pkg/config/grpc.go @@ -1,6 +1,6 @@ package config -// Service defines the available grpc configuration. +// GRPC defines the available grpc configuration. type GRPC struct { Addr string `yaml:"addr" env:"COLLABORATION_GRPC_ADDR" desc:"The bind address of the GRPC service." introductionVersion:"5.1"` Namespace string `yaml:"-"` From f7671e8d19b66237ba9d0b51b6c576a20262e992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Tue, 9 Apr 2024 17:14:56 +0200 Subject: [PATCH 28/29] docs: clarify service requirements in the README --- services/collaboration/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/collaboration/README.md b/services/collaboration/README.md index ec7fea2991..202694ae13 100644 --- a/services/collaboration/README.md +++ b/services/collaboration/README.md @@ -7,9 +7,12 @@ Since this service requires an external service (onlyoffice, for example), it wo ## Requirements The collaboration service requires the target document server (onlyoffice, collabora, etc) to be up and running. -We also need reva's gateway and app provider services to be running in order to register the GRPC service for the "open in app" action. +Some oCIS services are also required to be running in order to register the GRPC service for the "open in app" action: +* reva's gateway +* app provider If any of those services are down, the collaboration service won't start. +For convenience, you might want to start the whole oCIS stack before starting the collaboration service. ## Configuration From 57b50c858734f9af20a9e408beb0d56dc92c5e8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Pablo=20Villaf=C3=A1=C3=B1ez?= Date: Wed, 17 Apr 2024 15:23:37 +0200 Subject: [PATCH 29/29] docs: add missing documentation to functions --- .../collaboration/pkg/connector/connector.go | 5 +++ .../pkg/connector/contentconnector.go | 1 + .../pkg/connector/fileconnector.go | 4 +++ .../pkg/connector/httpadapter.go | 34 +++++++++++++++++++ .../collaboration/pkg/helpers/discovery.go | 4 +++ .../collaboration/pkg/middleware/claims.go | 1 + .../collaboration/pkg/middleware/crypto.go | 9 +++++ .../pkg/middleware/wopicontext.go | 12 +++++++ .../collaboration/pkg/server/http/server.go | 1 + .../pkg/service/grpc/v0/option.go | 1 + .../pkg/service/grpc/v0/service.go | 1 + 11 files changed, 73 insertions(+) diff --git a/services/collaboration/pkg/connector/connector.go b/services/collaboration/pkg/connector/connector.go index b38eb73370..c4224ba740 100644 --- a/services/collaboration/pkg/connector/connector.go +++ b/services/collaboration/pkg/connector/connector.go @@ -9,10 +9,12 @@ type ConnectorError struct { Msg string } +// Error gets the error message func (e *ConnectorError) Error() string { return e.Msg } +// NewConnectorError creates a new connector error using the provided parameters func NewConnectorError(code int, msg string) *ConnectorError { return &ConnectorError{ HttpCodeOut: code, @@ -43,6 +45,7 @@ type Connector struct { contentConnector ContentConnectorService } +// NewConnector creates a new connector func NewConnector(fc FileConnectorService, cc ContentConnectorService) *Connector { return &Connector{ fileConnector: fc, @@ -50,10 +53,12 @@ func NewConnector(fc FileConnectorService, cc ContentConnectorService) *Connecto } } +// GetFileConnector gets the file connector service associated to this connector func (c *Connector) GetFileConnector() FileConnectorService { return c.fileConnector } +// GetContentConnector gets the content connector service associated to this connector func (c *Connector) GetContentConnector() ContentConnectorService { return c.contentConnector } diff --git a/services/collaboration/pkg/connector/contentconnector.go b/services/collaboration/pkg/connector/contentconnector.go index 5153e821d4..7ce1a8977b 100644 --- a/services/collaboration/pkg/connector/contentconnector.go +++ b/services/collaboration/pkg/connector/contentconnector.go @@ -42,6 +42,7 @@ type ContentConnector struct { cfg *config.Config } +// NewContentConnector creates a new content connector func NewContentConnector(gwc gatewayv1beta1.GatewayAPIClient, cfg *config.Config) *ContentConnector { return &ContentConnector{ gwc: gwc, diff --git a/services/collaboration/pkg/connector/fileconnector.go b/services/collaboration/pkg/connector/fileconnector.go index c16b117fb1..f8fe28d2c1 100644 --- a/services/collaboration/pkg/connector/fileconnector.go +++ b/services/collaboration/pkg/connector/fileconnector.go @@ -51,11 +51,15 @@ type FileConnectorService interface { CheckFileInfo(ctx context.Context) (FileInfo, error) } +// FileConnector implements the "File" endpoint. +// Currently, it handles file locks and getting the file info. +// Note that operations might return any kind of error, not just ConnectorError type FileConnector struct { gwc gatewayv1beta1.GatewayAPIClient cfg *config.Config } +// NewFileConnector creates a new file connector func NewFileConnector(gwc gatewayv1beta1.GatewayAPIClient, cfg *config.Config) *FileConnector { return &FileConnector{ gwc: gwc, diff --git a/services/collaboration/pkg/connector/httpadapter.go b/services/collaboration/pkg/connector/httpadapter.go index 075c3d3d8c..201ee769ec 100644 --- a/services/collaboration/pkg/connector/httpadapter.go +++ b/services/collaboration/pkg/connector/httpadapter.go @@ -47,6 +47,10 @@ func NewHttpAdapterWithConnector(con ConnectorService) *HttpAdapter { } } +// GetLock adapts the "GetLock" operation for WOPI. +// Only the request's context is needed in order to extract the WOPI context. +// The operation's response will be sent through the response writer and +// the headers according to the spec func (h *HttpAdapter) GetLock(w http.ResponseWriter, r *http.Request) { fileCon := h.con.GetFileConnector() @@ -63,6 +67,12 @@ func (h *HttpAdapter) GetLock(w http.ResponseWriter, r *http.Request) { w.Header().Set(HeaderWopiLock, lockID) } +// Lock adapts the "Lock" and "UnlockAndRelock" operations for WOPI. +// The request's context is needed in order to extract the WOPI context. In +// addition, the "X-WOPI-Lock" and "X-WOPI-OldLock" headers might be needed" +// (check spec) +// The operation's response will be sent through the response writer and +// the headers according to the spec func (h *HttpAdapter) Lock(w http.ResponseWriter, r *http.Request) { oldLockID := r.Header.Get(HeaderWopiOldLock) lockID := r.Header.Get(HeaderWopiLock) @@ -85,6 +95,13 @@ func (h *HttpAdapter) Lock(w http.ResponseWriter, r *http.Request) { // X-WOPI-Lock header isn't needed on HTTP 200 } +// RefreshLock adapts the "RefreshLock" operation for WOPI +// The request's context is needed in order to extract the WOPI context. In +// addition, the "X-WOPI-Lock" header is needed (check spec). +// The lock will be refreshed to last another 30 minutes. The value is +// hardcoded +// The operation's response will be sent through the response writer and +// the headers according to the spec func (h *HttpAdapter) RefreshLock(w http.ResponseWriter, r *http.Request) { lockID := r.Header.Get(HeaderWopiLock) @@ -106,6 +123,11 @@ func (h *HttpAdapter) RefreshLock(w http.ResponseWriter, r *http.Request) { // X-WOPI-Lock header isn't needed on HTTP 200 } +// UnLock adapts the "Unlock" operation for WOPI +// The request's context is needed in order to extract the WOPI context. In +// addition, the "X-WOPI-Lock" header is needed (check spec). +// The operation's response will be sent through the response writer and +// the headers according to the spec func (h *HttpAdapter) UnLock(w http.ResponseWriter, r *http.Request) { lockID := r.Header.Get(HeaderWopiLock) @@ -127,6 +149,10 @@ func (h *HttpAdapter) UnLock(w http.ResponseWriter, r *http.Request) { // X-WOPI-Lock header isn't needed on HTTP 200 } +// CheckFileInfo will retrieve the information of the file in json format +// Only the request's context is needed in order to extract the WOPI context. +// The operation's response will be sent through the response writer and +// the headers according to the spec func (h *HttpAdapter) CheckFileInfo(w http.ResponseWriter, r *http.Request) { fileCon := h.con.GetFileConnector() @@ -165,6 +191,9 @@ func (h *HttpAdapter) CheckFileInfo(w http.ResponseWriter, r *http.Request) { } } +// GetFile will download the file +// Only the request's context is needed in order to extract the WOPI context. +// The file's content will be written in the response writer func (h *HttpAdapter) GetFile(w http.ResponseWriter, r *http.Request) { contentCon := h.con.GetContentConnector() err := contentCon.GetFile(r.Context(), w) @@ -178,6 +207,11 @@ func (h *HttpAdapter) GetFile(w http.ResponseWriter, r *http.Request) { } } +// PutFile will upload the file +// The request's context and its body are needed (content length is also +// needed) +// The operation's response will be sent through the response writer and +// the headers according to the spec func (h *HttpAdapter) PutFile(w http.ResponseWriter, r *http.Request) { lockID := r.Header.Get(HeaderWopiLock) diff --git a/services/collaboration/pkg/helpers/discovery.go b/services/collaboration/pkg/helpers/discovery.go index 1af71c0854..893ca75685 100644 --- a/services/collaboration/pkg/helpers/discovery.go +++ b/services/collaboration/pkg/helpers/discovery.go @@ -13,6 +13,9 @@ import ( "github.com/pkg/errors" ) +// GetAppURLs gets the edit and view urls for different file types from the +// target WOPI app (onlyoffice, collabora, etc) via their "/hosting/discovery" +// endpoint. func GetAppURLs(cfg *config.Config, logger log.Logger) (map[string]map[string]string, error) { wopiAppUrl := cfg.WopiApp.Addr + "/hosting/discovery" @@ -58,6 +61,7 @@ func GetAppURLs(cfg *config.Config, logger log.Logger) (map[string]map[string]st return appURLs, nil } +// parseWopiDiscovery parses the response of the "/hosting/discovery" endpoint func parseWopiDiscovery(body io.Reader) (map[string]map[string]string, error) { appURLs := make(map[string]map[string]string) diff --git a/services/collaboration/pkg/middleware/claims.go b/services/collaboration/pkg/middleware/claims.go index 1e2b9adf62..893e19e940 100644 --- a/services/collaboration/pkg/middleware/claims.go +++ b/services/collaboration/pkg/middleware/claims.go @@ -2,6 +2,7 @@ package middleware import "github.com/golang-jwt/jwt/v4" +// Claims contains the jwt registered claims plus the used WOPI context type Claims struct { WopiContext WopiContext `json:"WopiContext"` jwt.RegisteredClaims diff --git a/services/collaboration/pkg/middleware/crypto.go b/services/collaboration/pkg/middleware/crypto.go index 8ab61a2730..c626aa677f 100644 --- a/services/collaboration/pkg/middleware/crypto.go +++ b/services/collaboration/pkg/middleware/crypto.go @@ -9,6 +9,9 @@ import ( "io" ) +// keyPadding will add the required zero padding to the provided key. +// The resulting key will have a length of either 16, 24 or 32 bytes. +// If the key has more than 32 bytes, only the first 32 bytes will be returned. func keyPadding(key []byte) []byte { switch length := len(key); { case length < 16: @@ -29,6 +32,9 @@ func keyPadding(key []byte) []byte { return []byte{} } +// EncryptAES encrypts the provided plainText using the provided key. +// AES CFB will be used as cryptographic method. +// Use DecryptAES to decrypt the resulting string func EncryptAES(key []byte, plainText string) (string, error) { src := []byte(plainText) @@ -49,6 +55,9 @@ func EncryptAES(key []byte, plainText string) (string, error) { return base64.URLEncoding.EncodeToString(cipherText), nil } +// DecryptAES decrypts the provided string using the provided key. +// The provided string must have been encrypted with AES CFB. +// This method will decrypt the result from the EncryptAES method func DecryptAES(key []byte, securemess string) (string, error) { cipherText, err := base64.URLEncoding.DecodeString(securemess) if err != nil { diff --git a/services/collaboration/pkg/middleware/wopicontext.go b/services/collaboration/pkg/middleware/wopicontext.go index 39134a0fa4..14d86c9303 100644 --- a/services/collaboration/pkg/middleware/wopicontext.go +++ b/services/collaboration/pkg/middleware/wopicontext.go @@ -21,6 +21,7 @@ const ( wopiContextKey key = iota ) +// WopiContext wraps all the information we need for WOPI type WopiContext struct { AccessToken string FileReference providerv1beta1.Reference @@ -30,6 +31,17 @@ type WopiContext struct { ViewAppUrl string } +// WopiContextAuthMiddleware will prepare an HTTP handler to be used as +// middleware. The handler will create a WopiContext by parsing the +// access_token (which must be provided as part of the URL query). +// The access_token is required. +// +// This middleware will add the following to the request's context: +// * The access token as metadata for outgoing requests (for the +// authentication against the CS3 API, the "x-access-token" header). +// * The created WopiContext for the request +// * A contextual zerologger containing information about the request +// and the WopiContext func WopiContextAuthMiddleware(jwtSecret string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { accessToken := r.URL.Query().Get("access_token") diff --git a/services/collaboration/pkg/server/http/server.go b/services/collaboration/pkg/server/http/server.go index e7ce3db96d..160c39bef4 100644 --- a/services/collaboration/pkg/server/http/server.go +++ b/services/collaboration/pkg/server/http/server.go @@ -86,6 +86,7 @@ func Server(opts ...Option) (http.Service, error) { return service, nil } +// prepareRoutes will prepare all the implemented routes func prepareRoutes(r *chi.Mux, options Options) { adapter := options.Adapter logger := options.Logger diff --git a/services/collaboration/pkg/service/grpc/v0/option.go b/services/collaboration/pkg/service/grpc/v0/option.go index 977ca05a94..3b89ec725b 100644 --- a/services/collaboration/pkg/service/grpc/v0/option.go +++ b/services/collaboration/pkg/service/grpc/v0/option.go @@ -17,6 +17,7 @@ type Options struct { Gwc gatewayv1beta1.GatewayAPIClient } +// newOptions initializes the available default options. func newOptions(opts ...Option) Options { opt := Options{} diff --git a/services/collaboration/pkg/service/grpc/v0/service.go b/services/collaboration/pkg/service/grpc/v0/service.go index 0083dad501..28d27b47fd 100644 --- a/services/collaboration/pkg/service/grpc/v0/service.go +++ b/services/collaboration/pkg/service/grpc/v0/service.go @@ -53,6 +53,7 @@ type Service struct { gwc gatewayv1beta1.GatewayAPIClient } +// OpenInApp will implement the OpenInApp interface of the app provider func (s *Service) OpenInApp( ctx context.Context, req *appproviderv1beta1.OpenInAppRequest,