diff --git a/.drone.star b/.drone.star index 136aa6b28a..1638cafb68 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", @@ -908,8 +909,46 @@ def wopiValidatorTests(ctx, storage, accounts_hash_difficulty = 4): "Features", ] + 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), @@ -929,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", @@ -955,22 +994,13 @@ def wopiValidatorTests(ctx, storage, accounts_hash_difficulty = 4): }, ] + ocisServer(storage, accounts_hash_difficulty, [], [], "wopi_validator") + + 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", - ], - }, { "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 +1017,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 +2041,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 == "": @@ -2030,7 +2062,7 @@ def ocisServer(storage, accounts_hash_difficulty = 4, volumes = [], depends_on = 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: diff --git a/.golangci.yml b/.golangci.yml index af560657a5..f9e9e1bd62 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: 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/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/go.mod b/go.mod index b7046a5425..1298753b24 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/Nerzal/gocloak/v13 v13.9.0 github.com/a8m/envsubst v1.4.2 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 @@ -135,7 +136,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/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/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/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/.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/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/README.md b/services/collaboration/README.md new file mode 100644 index 0000000000..202694ae13 --- /dev/null +++ b/services/collaboration/README.md @@ -0,0 +1,25 @@ +# 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. +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 + +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 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/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/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 new file mode 100644 index 0000000000..31fe6db63f --- /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..c18b641486 --- /dev/null +++ b/services/collaboration/pkg/command/server.go @@ -0,0 +1,127 @@ +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/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" + "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() + + // 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.AppURLs(appUrls), + 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 + } + return grpcServer.Serve(l) + }, + func(_ error) { + logger.Error(). + Err(err). + Str("server", "grpc"). + Msg("shutting down server") + cancel() + }) + + // 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() + }) + + // start 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(httpServer.Run, func(_ error) { + cancel() + }) + + return gr.Run() + }, + } +} 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/config/app.go b/services/collaboration/pkg/config/app.go new file mode 100644 index 0000000000..69c5447f0a --- /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" 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 new file mode 100644 index 0000000000..7654a5e34e --- /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"` + + 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"` + 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." 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. +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..776cf86177 --- /dev/null +++ b/services/collaboration/pkg/config/cs3api.go @@ -0,0 +1,17 @@ +package config + +// 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/debug.go b/services/collaboration/pkg/config/debug.go new file mode 100644 index 0000000000..0961402e31 --- /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." 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/defaults/defaultconfig.go b/services/collaboration/pkg/config/defaults/defaultconfig.go new file mode 100644 index 0000000000..ef4edc91ee --- /dev/null +++ b/services/collaboration/pkg/config/defaults/defaultconfig.go @@ -0,0 +1,91 @@ +package defaults + +import ( + "github.com/owncloud/ocis/v2/ocis-pkg/generators" + "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 { + secret, _ := generators.GenerateRandomString(generators.AlphaNumChars, 32) // anything to do with the error? + 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", + }, + JWTSecret: secret, + GRPC: config.GRPC{ + Addr: "0.0.0.0:9301", + Namespace: "com.owncloud.collaboration", + }, + HTTP: config.HTTP{ + Addr: "127.0.0.1:9300", + BindAddr: "0.0.0.0:9300", + 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, + }, + CS3Api: config.CS3Api{ + Gateway: config.Gateway{ + Name: "com.owncloud.api.gateway", + }, + DataGateway: config.DataGateway{ + Insecure: false, + }, + }, + } +} + +// 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..b7351bd37a --- /dev/null +++ b/services/collaboration/pkg/config/grpc.go @@ -0,0 +1,7 @@ +package config + +// 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:"-"` +} diff --git a/services/collaboration/pkg/config/http.go b/services/collaboration/pkg/config/http.go new file mode 100644 index 0000000000..31a0ee090d --- /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 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'." introductionVersion:"5.1"` + 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..2f6c7dbd9d --- /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'." 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/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..8f4427e2e1 --- /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." 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"` +} diff --git a/services/collaboration/pkg/connector/connector.go b/services/collaboration/pkg/connector/connector.go new file mode 100644 index 0000000000..c4224ba740 --- /dev/null +++ b/services/collaboration/pkg/connector/connector.go @@ -0,0 +1,64 @@ +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 +} + +// 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, + Msg: msg, + } +} + +// 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. +// +// Available endpoints: +// * "Files" -> GetFileConnector() +// * "File contents" -> GetContentConnector() +// +// Other endpoints aren't available for now. +type Connector struct { + fileConnector FileConnectorService + contentConnector ContentConnectorService +} + +// NewConnector creates a new connector +func NewConnector(fc FileConnectorService, cc ContentConnectorService) *Connector { + return &Connector{ + fileConnector: fc, + contentConnector: cc, + } +} + +// 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/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.go b/services/collaboration/pkg/connector/contentconnector.go new file mode 100644 index 0000000000..7ce1a8977b --- /dev/null +++ b/services/collaboration/pkg/connector/contentconnector.go @@ -0,0 +1,354 @@ +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/middleware" + "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) +// Note that operations might return any kind of error, not just ConnectorError +type ContentConnector struct { + gwc gatewayv1beta1.GatewayAPIClient + cfg *config.Config +} + +// NewContentConnector creates a new content connector +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 +// +// 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 { + 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.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", resp.GetStatus().GetCode().String()). + Str("StatusMsg", resp.GetStatus().GetMessage()). + Msg("GetFile: InitiateFileDownload failed with wrong status") + return NewConnectorError(500, resp.GetStatus().GetCode().String()+" "+resp.GetStatus().GetMessage()) + } + + // Figure out the download endpoint and download token + downloadEndpoint := "" + downloadToken := "" + hasDownloadToken := false + + for _, proto := range resp.GetProtocols() { + if proto.GetProtocol() == "simple" || proto.GetProtocol() == "spaces" { + downloadEndpoint = proto.GetDownloadEndpoint() + downloadToken = proto.GetToken() + hasDownloadToken = proto.GetToken() != "" + 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) + } + 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 +// +// 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 { + 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.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", statRes.GetStatus().GetCode().String()). + Str("StatusMsg", statRes.GetStatus().GetMessage()). + Msg("PutFile: stat failed with unexpected status") + return "", NewConnectorError(500, statRes.GetStatus().GetCode().String()+" "+statRes.GetStatus().GetMessage()) + } + + // If there is a lock and it mismatches, return 409 + if statRes.GetInfo().GetLock() != nil && statRes.GetInfo().GetLock().GetLockId() != lockID { + logger.Error(). + 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.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.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") + } + + // 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, + Options: &providerv1beta1.InitiateFileUploadRequest_IfMatch{ + IfMatch: statRes.GetInfo().GetEtag(), + }, + } + + // 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.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", resp.GetStatus().GetCode().String()). + Str("StatusMsg", resp.GetStatus().GetMessage()). + Msg("UploadHelper: InitiateFileUpload failed with wrong status") + 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 + // target endpoint, otherwise we're done + if streamLength > 0 { + + uploadEndpoint := "" + uploadToken := "" + hasUploadToken := false + + for _, proto := range resp.GetProtocols() { + if proto.GetProtocol() == "simple" || proto.GetProtocol() == "spaces" { + uploadEndpoint = proto.GetUploadEndpoint() + uploadToken = proto.GetToken() + hasUploadToken = proto.GetToken() != "" + 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) + } + 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/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 new file mode 100644 index 0000000000..f8fe28d2c1 --- /dev/null +++ b/services/collaboration/pkg/connector/fileconnector.go @@ -0,0 +1,567 @@ +package connector + +import ( + "context" + "encoding/hex" + "path" + "strconv" + "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/middleware" + "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 +) + +// 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) +} + +// 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, + 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 +// +// 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 { + 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.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { + logger.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()) + } + + lockID := "" + if resp.GetLock() != nil { + lockID = resp.GetLock().GetLockId() + } + + // 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 +// +// 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 { + 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.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 + 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.GetStatus() + } + + // we're checking the status of either the "SetLock" or "RefreshLock" operations + switch setOrRefreshStatus.GetCode() { + 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.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", resp.GetStatus().GetCode().String()). + Str("StatusMsg", resp.GetStatus().GetMessage()). + Msg("SetLock failed, fallback to GetLock failed with unexpected status") + } + + if resp.GetLock() != nil { + if resp.GetLock().GetLockId() != lockID { + // lockId is different -> return 409 with the current lockId + logger.Warn(). + Str("LockID", resp.GetLock().GetLockId()). + Msg("SetLock conflict") + return resp.GetLock().GetLockId(), 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.GetLock().GetLockId()). + Msg("SetLock lock refreshed instead") + return resp.GetLock().GetLockId(), nil + } + + logger.Error().Msg("SetLock failed and could not refresh") + 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(). + Str("StatusCode", setOrRefreshStatus.GetCode().String()). + Str("StatusMsg", setOrRefreshStatus.GetMessage()). + 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 +// +// 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 { + 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.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.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.GetStatus().GetCode().String()). + Str("StatusMsg", resp.GetStatus().GetMessage()). + 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.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { + logger.Error(). + 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.GetStatus().GetCode().String()+" "+resp.GetStatus().GetMessage()) + } + + if resp.GetLock() == nil { + logger.Error(). + 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.GetLock().GetLockId()). + Str("StatusCode", resp.GetStatus().GetCode().String()). + Str("StatusMsg", resp.GetStatus().GetMessage()). + Msg("RefreshLock failed, lock mismatch") + return resp.GetLock().GetLockId(), NewConnectorError(409, "Lock mismatch") + } + default: + logger.Error(). + Str("StatusCode", resp.GetStatus().GetCode().String()). + Str("StatusMsg", resp.GetStatus().GetMessage()). + Msg("RefreshLock failed with unexpected status") + return "", NewConnectorError(500, resp.GetStatus().GetCode().String()+" "+resp.GetStatus().GetMessage()) + } +} + +// 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 { + 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.GetStatus().GetCode() { + 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.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { + logger.Error(). + 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.GetStatus().GetCode().String()+" "+resp.GetStatus().GetMessage()) + } + + var outLockId string + if resp.GetLock() == nil { + logger.Error(). + 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.GetLock().GetLockId()). + Str("StatusCode", resp.GetStatus().GetCode().String()). + Str("StatusMsg", resp.GetStatus().GetMessage()). + Msg("Unlock failed, lock mismatch") + outLockId = resp.GetLock().GetLockId() + } + return outLockId, NewConnectorError(409, "Lock mismatch") + default: + logger.Error(). + Str("StatusCode", resp.GetStatus().GetCode().String()). + Str("StatusMsg", resp.GetStatus().GetMessage()). + Msg("Unlock failed with unexpected status") + return "", NewConnectorError(500, resp.GetStatus().GetCode().String()+" "+resp.GetStatus().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 +// +// 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 { + return 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 FileInfo{}, err + } + + if statRes.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", statRes.GetStatus().GetCode().String()). + Str("StatusMsg", statRes.GetStatus().GetMessage()). + Msg("CheckFileInfo: stat failed with unexpected status") + 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.GetInfo().GetOwner().GetOpaqueId() + "@" + statRes.GetInfo().GetOwner().GetIdp())), + Size: int64(statRes.GetInfo().GetSize()), + 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 + //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.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.GetId().GetOpaqueId() + "@" + wopiContext.User.GetId().GetIdp())) + } + + if wopiContext.User.GetOpaque() != nil { + if _, ok := wopiContext.User.GetOpaque().GetMap()["public-share-role"]; ok { + isPublicShare = true + } + } + if !isPublicShare { + fileInfo.UserFriendlyName = wopiContext.User.GetUsername() + fileInfo.UserId = hex.EncodeToString([]byte(wopiContext.User.GetId().GetOpaqueId() + "@" + wopiContext.User.GetId().GetIdp())) + } + } + 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/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/connector/fileinfo.go b/services/collaboration/pkg/connector/fileinfo.go new file mode 100644 index 0000000000..9466568406 --- /dev/null +++ b/services/collaboration/pkg/connector/fileinfo.go @@ -0,0 +1,333 @@ +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: + // 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"` + // 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 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 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 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"` + + // + // 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. + 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"` + + // + // 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. + 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"` + + // ------------ + // Collabora WOPI check file info specification: + // https://sdk.collaboraonline.com/docs/advanced_integration.html + // ------------ + + // + // 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 +} diff --git a/services/collaboration/pkg/connector/httpadapter.go b/services/collaboration/pkg/connector/httpadapter.go new file mode 100644 index 0000000000..201ee769ec --- /dev/null +++ b/services/collaboration/pkg/connector/httpadapter.go @@ -0,0 +1,234 @@ +package connector + +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" + "github.com/rs/zerolog" +) + +const ( + HeaderWopiLock string = "X-WOPI-Lock" + 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 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( + NewFileConnector(gwc, cfg), + NewContentConnector(gwc, cfg), + ), + } +} + +// NewHttpAdapterWithConnector will create a new HTTP adapter that will use +// the provided connector service +func NewHttpAdapterWithConnector(con ConnectorService) *HttpAdapter { + return &HttpAdapter{ + con: con, + } +} + +// 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() + + 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) +} + +// 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) + + 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 +} + +// 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) + + 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 +} + +// 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) + + 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 +} + +// 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() + + 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 + 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-Length", strconv.Itoa(len(jsonFileInfo))) + 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") + } +} + +// 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) + 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) + } + } +} + +// 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) + + 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/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)) + }) + }) +}) diff --git a/services/collaboration/pkg/helpers/cs3.go b/services/collaboration/pkg/helpers/cs3.go new file mode 100644 index 0000000000..8b3f21f469 --- /dev/null +++ b/services/collaboration/pkg/helpers/cs3.go @@ -0,0 +1,27 @@ +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 + +// 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 + 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/helpers/discovery.go b/services/collaboration/pkg/helpers/discovery.go new file mode 100644 index 0000000000..893ca75685 --- /dev/null +++ b/services/collaboration/pkg/helpers/discovery.go @@ -0,0 +1,113 @@ +package helpers + +import ( + "crypto/tls" + "io" + "net/http" + "net/url" + "strings" + + "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" +) + +// 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" + + httpClient := http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: cfg.WopiApp.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 + } + + defer httpResp.Body.Close() + + 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") + } + + 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") + } + + // We won't log anything if successful + 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) + + 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/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 32: + return key[:32] + } + 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) + + 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 +} + +// 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 { + 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/middleware/wopicontext.go b/services/collaboration/pkg/middleware/wopicontext.go new file mode 100644 index 0000000000..14d86c9303 --- /dev/null +++ b/services/collaboration/pkg/middleware/wopicontext.go @@ -0,0 +1,117 @@ +package middleware + +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" + "github.com/rs/zerolog" + "google.golang.org/grpc/metadata" +) + +type key int + +const ( + wopiContextKey key = iota +) + +// WopiContext wraps all the information we need for WOPI +type WopiContext struct { + AccessToken string + FileReference providerv1beta1.Reference + User *userv1beta1.User + ViewMode appproviderv1beta1.ViewMode + EditAppUrl string + 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") + if accessToken == "" { + 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(jwtSecret), nil + }) + + if err != nil { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + + if err := claims.Valid(); err != nil { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + + ctx := r.Context() + + wopiContextAccessToken, err := DecryptAES([]byte(jwtSecret), claims.WopiContext.AccessToken) + if err != nil { + 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) + + // 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)) + }) +} + +// Extract a WopiContext from the context if possible. An error will be +// returned if there is no WopiContext +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") +} + +// Set a WopiContext in the context. A new context will be returned with the +// add WopiContext inside. Note that the old one won't have the WopiContext set. +// +// This method is used for testing. The WopiContextAuthMiddleware should be +// used instead in order to provide a valid WopiContext +func WopiContextToCtx(ctx context.Context, wopiContext WopiContext) context.Context { + return context.WithValue(ctx, wopiContextKey, wopiContext) +} 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) + } + } +} diff --git a/services/collaboration/pkg/server/grpc/option.go b/services/collaboration/pkg/server/grpc/option.go new file mode 100644 index 0000000000..df6d23d7d1 --- /dev/null +++ b/services/collaboration/pkg/server/grpc/option.go @@ -0,0 +1,75 @@ +package grpc + +import ( + "context" + + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" + "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 { + AppURLs map[string]map[string]string + 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 +} + +// AppURLs provides app urls based on mimetypes. +func AppURLs(val map[string]map[string]string) Option { + return func(o *Options) { + o.AppURLs = 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..a0be59dcca --- /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.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..10628a0fc2 --- /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/connector" + "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 { + Adapter *connector.HttpAdapter + 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 Adapter(val *connector.HttpAdapter) Option { + return func(o *Options) { + o.Adapter = 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..160c39bef4 --- /dev/null +++ b/services/collaboration/pkg/server/http/server.go @@ -0,0 +1,174 @@ +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/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" + "github.com/owncloud/ocis/v2/ocis-pkg/version" + colabmiddleware "github.com/owncloud/ocis/v2/services/collaboration/pkg/middleware" + "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.JWTSecret), // 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) + + if err := micro.RegisterHandler(service.Server(), mux); err != nil { + return http.Service{}, err + } + + return service, nil +} + +// prepareRoutes will prepare all the implemented routes +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) { + 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 colabmiddleware.WopiContextAuthMiddleware(options.Config.JWTSecret, h) + }, + ) + + r.Get("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + adapter.CheckFileInfo(w, r) + }) + + r.Post("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + action := r.Header.Get("X-WOPI-Override") + switch action { + + case "LOCK": + // "UnlockAndRelock" operation goes through here + adapter.Lock(w, r) + case "GET_LOCK": + adapter.GetLock(w, r) + case "REFRESH_LOCK": + adapter.RefreshLock(w, r) + case "UNLOCK": + adapter.UnLock(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) { + adapter.GetFile(w, r) + }) + + r.Post("/", func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + action := r.Header.Get("X-WOPI-Override") + switch action { + + case "PUT": + adapter.PutFile(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..3b89ec725b --- /dev/null +++ b/services/collaboration/pkg/service/grpc/v0/option.go @@ -0,0 +1,57 @@ +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" +) + +// 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 + Gwc gatewayv1beta1.GatewayAPIClient +} + +// 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 + } +} + +// Config provides a function to set the Config option. +func Config(val *config.Config) Option { + return func(o *Options) { + o.Config = val + } +} + +// 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 + } +} + +// 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 new file mode 100644 index 0000000000..28d27b47fd --- /dev/null +++ b/services/collaboration/pkg/service/grpc/v0/service.go @@ -0,0 +1,233 @@ +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/middleware" +) + +// NewHandler creates a new grpc service implementing the OpenInApp interface +func NewHandler(opts ...Option) (*Service, func(), error) { + teardown := func() {} + options := newOptions(opts...) + + 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{ + 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 OpenInApp interface +type Service struct { + id string + appURLs map[string]map[string]string + logger log.Logger + config *config.Config + gwc gatewayv1beta1.GatewayAPIClient +} + +// OpenInApp will implement the OpenInApp interface of the app provider +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.GetAccessToken(), + } + meResp, err := s.gwc.WhoAmI(ctx, meReq) + if err == nil { + if meResp.GetStatus().GetCode() == rpcv1beta1.Code_CODE_OK { + user = meResp.GetUser() + } + } + + // 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.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().GetPath()) + + 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.GetViewMode().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.GetViewMode().String()). + Str("Requester", user.GetId().String()). + Msg("OpenInApp: error parsing editAppUrl") + return nil, err + } + + appURL := viewAppURL + if req.GetViewMode() == appproviderv1beta1.ViewMode_VIEW_MODE_READ_WRITE { + appURL = editAppURL + } + + 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.GetViewMode().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 := middleware.WopiContext{ + AccessToken: cryptedReqAccessToken, + FileReference: providerFileRef, + User: user, + ViewMode: req.GetViewMode(), + EditAppUrl: editAppURL, + ViewAppUrl: viewAppURL, + } + + cs3Claims := &jwt.RegisteredClaims{} + cs3JWTparser := jwt.Parser{} + _, _, err = cs3JWTparser.ParseUnverified(req.GetAccessToken(), cs3Claims) + if err != nil { + s.logger.Error(). + Err(err). + Str("FileReference", providerFileRef.String()). + Str("ViewMode", req.GetViewMode().String()). + Str("Requester", user.GetId().String()). + Msg("OpenInApp: error parsing JWT token") + return nil, err + } + + claims := &middleware.Claims{ + WopiContext: wopiContext, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: cs3Claims.ExpiresAt, + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + accessToken, err := token.SignedString([]byte(s.config.JWTSecret)) + + if err != nil { + s.logger.Error(). + Err(err). + Str("FileReference", providerFileRef.String()). + Str("ViewMode", req.GetViewMode().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.GetViewMode().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.UnixMilli(), 10), + }, + }, + }, nil +} 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))) + }) + }) +}) 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.