Merge pull request #8374 from owncloud/new_collaboration_service

feat: add new collaboration service (WOPI)
This commit is contained in:
kobergj
2024-04-29 13:56:10 +02:00
committed by GitHub
59 changed files with 6139 additions and 20 deletions

View File

@@ -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:

View File

@@ -41,6 +41,7 @@ linters:
- stylecheck
- gocognit
- nestif # each 10-50 issues in codebase
- depguard # disabled for now. Needs configuration
linters-settings:
gocyclo:

View File

@@ -29,6 +29,7 @@ OCIS_MODULES = \
services/auth-machine \
services/auth-service \
services/clientlog \
services/collaboration \
services/eventhistory \
services/frontend \
services/gateway \

View File

@@ -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 |

2
go.mod
View File

@@ -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

View File

@@ -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"`

View File

@@ -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(),

View File

@@ -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))))

View File

@@ -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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
},
}
}

View File

@@ -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)
}

View File

@@ -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()
},
}
}

View File

@@ -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
},
}
}

View File

@@ -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"`
}

View File

@@ -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,
}
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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:"-"`
}

View File

@@ -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 '<ip-address>:<port>', 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"`
}

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -0,0 +1,6 @@
package config
// Service defines the available service configuration.
type Service struct {
Name string `yaml:"-"`
}

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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")
}

View File

@@ -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
}

View File

@@ -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(""))
})
})
})

View File

@@ -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
}

View File

@@ -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))
})
})
})

View File

@@ -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 servers 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 hosts 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 users 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 browsers 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
}

View File

@@ -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
}

View File

@@ -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))
})
})
})

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 = `
<?xml version="1.0" encoding="utf-8"?>
<wopi-discovery>
<net-zone name="external-http">
<app name="Word" favIconUrl="https://test.server.prv/web-apps/apps/documenteditor/main/resources/img/favicon.ico">
<action name="view" ext="pdf" urlsrc="https://test.server.prv/hosting/wopi/word/view?&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
<action name="embedview" ext="pdf" urlsrc="https://test.server.prv/hosting/wopi/word/view?embed=1&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
<action name="view" ext="djvu" urlsrc="https://test.server.prv/hosting/wopi/word/view?&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
<action name="embedview" ext="djvu" urlsrc="https://test.server.prv/hosting/wopi/word/view?embed=1&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
<action name="view" ext="docx" urlsrc="https://test.server.prv/hosting/wopi/word/view?&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
<action name="embedview" ext="docx" urlsrc="https://test.server.prv/hosting/wopi/word/view?embed=1&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
<action name="editnew" ext="docx" requires="locks,update" urlsrc="https://test.server.prv/hosting/wopi/word/edit?&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
<action name="edit" ext="docx" default="true" requires="locks,update" urlsrc="https://test.server.prv/hosting/wopi/word/edit?&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
</app>
<app name="Excel" favIconUrl="https://test.server.prv/web-apps/apps/spreadsheeteditor/main/resources/img/favicon.ico">
<action name="view" ext="xls" urlsrc="https://test.server.prv/hosting/wopi/cell/view?&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
<action name="embedview" ext="xls" urlsrc="https://test.server.prv/hosting/wopi/cell/view?embed=1&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
<action name="convert" ext="xls" targetext="xlsx" requires="update" urlsrc="https://test.server.prv/hosting/wopi/convert-and-edit/xls/xlsx?&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
<action name="view" ext="xlsb" urlsrc="https://test.server.prv/hosting/wopi/cell/view?&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
<action name="embedview" ext="xlsb" urlsrc="https://test.server.prv/hosting/wopi/cell/view?embed=1&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
<action name="convert" ext="xlsb" targetext="xlsx" requires="update" urlsrc="https://test.server.prv/hosting/wopi/convert-and-edit/xlsb/xlsx?&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
</app>
<app name="application/vnd.oasis.opendocument.presentation">
<action name="edit" ext="" default="true" requires="locks,update" urlsrc="https://test.server.prv/hosting/wopi/slide/edit?&amp;&lt;rs=DC_LLCC&amp;&gt;&lt;dchat=DISABLE_CHAT&amp;&gt;&lt;embed=EMBEDDED&amp;&gt;&lt;fs=FULLSCREEN&amp;&gt;&lt;hid=HOST_SESSION_ID&amp;&gt;&lt;rec=RECORDING&amp;&gt;&lt;sc=SESSION_CONTEXT&amp;&gt;&lt;thm=THEME_ID&amp;&gt;&lt;ui=UI_LLCC&amp;&gt;&lt;wopisrc=WOPI_SOURCE&amp;&gt;&amp;"/>
</app>
</net-zone>
<proof-key oldvalue="BgIAAACkAABSU0ExAAgAAAEAAQD/NVqekFNi8X3p6Bvdlaxm0GGuggW5kKfVEQzPGuOkGVrz6DrOMNR+k7Pq8tONY+1NHgS6Z+v3959em78qclVDuQX77Tkml0xMHAQHN4sAHF9iQJS8gOBUKSVKaHD7Z8YXch6F212YSUSc8QphpDSHWVShU7rcUeLQsd/0pkflh5+um4YKEZhm4Mou3vstp5p12NeffyK1WFZF7q4jB7jclAslYKQsP82YY3DcRwu5Tl/+W0ifVcXze0mI7v1reJ12pKn8ifRiq+0q5oJST3TRSrvmjLg9Gt3ozhVIt2HUi3La7Qh40YOAUXm0g/hUq2BepeOp1C7WSvaOFHXe6Hqq" oldmodulus="qnro3nUUjvZK1i7UqeOlXmCrVPiDtHlRgIPReAjt2nKL1GG3SBXO6N0aPbiM5rtK0XRPUoLmKu2rYvSJ/Kmkdp14a/3uiEl788VVn0hb/l9OuQtH3HBjmM0/LKRgJQuU3LgHI67uRVZYtSJ/n9fYdZqnLfveLsrgZpgRCoabrp+H5Uem9N+x0OJR3LpToVRZhzSkYQrxnERJmF3bhR5yF8Zn+3BoSiUpVOCAvJRAYl8cAIs3BwQcTEyXJjnt+wW5Q1VyKr+bXp/39+tnugQeTe1jjdPy6rOTftQwzjro81oZpOMazwwR1aeQuQWCrmHQZqyV3Rvo6X3xYlOQnlo1/w==" oldexponent="AQAB" value="BgIAAACkAABSU0ExAAgAAAEAAQD/NVqekFNi8X3p6Bvdlaxm0GGuggW5kKfVEQzPGuOkGVrz6DrOMNR+k7Pq8tONY+1NHgS6Z+v3959em78qclVDuQX77Tkml0xMHAQHN4sAHF9iQJS8gOBUKSVKaHD7Z8YXch6F212YSUSc8QphpDSHWVShU7rcUeLQsd/0pkflh5+um4YKEZhm4Mou3vstp5p12NeffyK1WFZF7q4jB7jclAslYKQsP82YY3DcRwu5Tl/+W0ifVcXze0mI7v1reJ12pKn8ifRiq+0q5oJST3TRSrvmjLg9Gt3ozhVIt2HUi3La7Qh40YOAUXm0g/hUq2BepeOp1C7WSvaOFHXe6Hqq" modulus="qnro3nUUjvZK1i7UqeOlXmCrVPiDtHlRgIPReAjt2nKL1GG3SBXO6N0aPbiM5rtK0XRPUoLmKu2rYvSJ/Kmkdp14a/3uiEl788VVn0hb/l9OuQtH3HBjmM0/LKRgJQuU3LgHI67uRVZYtSJ/n9fYdZqnLfveLsrgZpgRCoabrp+H5Uem9N+x0OJR3LpToVRZhzSkYQrxnERJmF3bhR5yF8Zn+3BoSiUpVOCAvJRAYl8cAIs3BwQcTEyXJjnt+wW5Q1VyKr+bXp/39+tnugQeTe1jjdPy6rOTftQwzjro81oZpOMazwwR1aeQuQWCrmHQZqyV3Rvo6X3xYlOQnlo1/w==" exponent="AQAB"/>
</wopi-discovery>
`
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 <can't> be XML /form<atted/"))
}
}))
})
AfterEach(func() {
srv.Close()
})
Describe("GetAppURLs", func() {
It("Good discovery URL", func() {
cfg := &config.Config{
WopiApp: config.WopiApp{
Addr: srv.URL + "/good",
Insecure: true,
},
}
logger := log.NopLogger()
appUrls, err := helpers.GetAppURLs(cfg, logger)
expectedAppUrls := 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",
},
}
Expect(err).To(Succeed())
Expect(appUrls).To(Equal(expectedAppUrls))
})
It("Wrong discovery URL", func() {
cfg := &config.Config{
WopiApp: config.WopiApp{
Addr: srv.URL + "/bad",
Insecure: true,
},
}
logger := log.NopLogger()
appUrls, err := helpers.GetAppURLs(cfg, logger)
Expect(err).To(HaveOccurred())
Expect(appUrls).To(BeNil())
})
It("Not XML formatted", func() {
cfg := &config.Config{
WopiApp: config.WopiApp{
Addr: srv.URL + "/wrongformat",
Insecure: true,
},
}
logger := log.NopLogger()
appUrls, err := helpers.GetAppURLs(cfg, logger)
Expect(err).To(HaveOccurred())
Expect(appUrls).To(BeNil())
})
})
})

View File

@@ -0,0 +1,13 @@
package helpers_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestGraph(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Helpers Suite")
}

View File

@@ -0,0 +1,81 @@
package helpers
import (
"context"
"errors"
registryv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/registry/v1beta1"
gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
"github.com/cs3org/reva/v2/pkg/mime"
"github.com/gofrs/uuid"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/ocis-pkg/registry"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/config"
)
// RegisterOcisService will register this service.
// There are no explicit requirements for the context, and it will be passed
// without changes to the underlying RegisterService method.
func RegisterOcisService(ctx context.Context, cfg *config.Config, logger log.Logger) error {
svc := registry.BuildGRPCService(cfg.Service.Name, uuid.Must(uuid.NewV4()).String(), cfg.GRPC.Addr, "0.0.0")
return registry.RegisterService(ctx, svc, logger)
}
// RegisterAppProvider will register this service as app provider in REVA.
// The GatewayAPIClient is expected to be provided via `helpers.GetCS3apiClient`.
// The appUrls are expected to be provided via `helpers.GetAppURLs`
//
// Note that this method doesn't provide a re-registration mechanism, so it
// will register the service once
func RegisterAppProvider(
ctx context.Context,
cfg *config.Config,
logger log.Logger,
gwc gatewayv1beta1.GatewayAPIClient,
appUrls map[string]map[string]string,
) error {
mimeTypesMap := make(map[string]bool)
for _, extensions := range appUrls {
for ext := range extensions {
m := mime.Detect(false, ext)
mimeTypesMap[m] = true
}
}
mimeTypes := make([]string, 0, len(mimeTypesMap))
for m := range mimeTypesMap {
mimeTypes = append(mimeTypes, m)
}
logger.Debug().
Str("AppName", cfg.App.Name).
Strs("Mimetypes", mimeTypes).
Msg("Registering mimetypes in the app provider")
// TODO: an added app provider shouldn't last forever. Instead the registry should use a TTL
// and delete providers that didn't register again. If an app provider dies or get's disconnected,
// the users will be no longer available to choose to open a file with it (currently, opening a file just fails)
req := &registryv1beta1.AddAppProviderRequest{
Provider: &registryv1beta1.ProviderInfo{
Name: cfg.App.Name,
Description: cfg.App.Description,
Icon: cfg.App.Icon,
Address: cfg.Service.Name,
MimeTypes: mimeTypes,
},
}
resp, err := gwc.AddAppProvider(ctx, req)
if err != nil {
logger.Error().Err(err).Msg("AddAppProvider failed")
return err
}
if resp.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK {
logger.Error().Str("status_code", resp.GetStatus().GetCode().String()).Msg("AddAppProvider failed")
return errors.New("status code != CODE_OK")
}
return nil
}

View File

@@ -0,0 +1,17 @@
package logging
import (
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/services/collaboration/pkg/config"
)
// Configure initializes a service-specific logger instance.
func Configure(name string, cfg *config.Log) log.Logger {
return log.NewLogger(
log.Name(name),
log.Level(cfg.Level),
log.Pretty(cfg.Pretty),
log.Color(cfg.Color),
log.File(cfg.File),
)
}

View File

@@ -0,0 +1,9 @@
package middleware
import "github.com/golang-jwt/jwt/v4"
// Claims contains the jwt registered claims plus the used WOPI context
type Claims struct {
WopiContext WopiContext `json:"WopiContext"`
jwt.RegisteredClaims
}

View File

@@ -0,0 +1,83 @@
package middleware
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"io"
)
// keyPadding will add the required zero padding to the provided key.
// The resulting key will have a length of either 16, 24 or 32 bytes.
// If the key has more than 32 bytes, only the first 32 bytes will be returned.
func keyPadding(key []byte) []byte {
switch length := len(key); {
case length < 16:
return append(key, make([]byte, 16-length)...)
case length == 16:
return key
case length < 24:
return append(key, make([]byte, 24-length)...)
case length == 24:
return key
case length < 32:
return append(key, make([]byte, 32-length)...)
case length == 32:
return key
case length > 32:
return key[:32]
}
return []byte{}
}
// 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
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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)
}
})
})
})
})
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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")
}

View File

@@ -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)))
})
})
})

View File

@@ -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.