From bfa3dd07bc4694256bf162025751f2f737a57b28 Mon Sep 17 00:00:00 2001 From: Michael Barz Date: Wed, 24 Jul 2024 23:02:46 +0200 Subject: [PATCH] feat: office 365 proxy support --- .../mocks/content_connector_service.go | 24 +- services/collaboration/pkg/config/app.go | 3 +- services/collaboration/pkg/config/wopi.go | 2 + .../collaboration/pkg/connector/connector.go | 60 ++++ .../pkg/connector/contentconnector.go | 50 ++- .../pkg/connector/contentconnector_test.go | 80 +++-- .../pkg/connector/fileconnector.go | 141 ++++++-- .../pkg/connector/fileconnector_test.go | 338 +++++++++++++----- .../pkg/connector/fileinfo/microsoft.go | 2 +- .../pkg/connector/httpadapter.go | 26 +- .../pkg/connector/httpadapter_test.go | 59 ++- services/collaboration/pkg/helpers/path.go | 41 +++ services/collaboration/pkg/helpers/version.go | 20 ++ services/collaboration/pkg/locks/parser.go | 3 +- .../pkg/middleware/middleware_suite_test.go | 13 + .../collaboration/pkg/middleware/tracing.go | 13 +- .../pkg/middleware/wopicontext.go | 26 +- .../pkg/middleware/wopicontext_test.go | 226 ++++++++++++ .../collaboration/pkg/server/http/server.go | 1 + .../pkg/service/grpc/v0/service.go | 37 +- .../pkg/service/grpc/v0/service_test.go | 57 ++- services/collaboration/pkg/wopisrc/wopisrc.go | 72 ++++ .../pkg/wopisrc/wopisrc_suite_test.go | 13 + .../collaboration/pkg/wopisrc/wopisrc_test.go | 64 ++++ 24 files changed, 1153 insertions(+), 218 deletions(-) create mode 100644 services/collaboration/pkg/helpers/path.go create mode 100644 services/collaboration/pkg/helpers/version.go create mode 100644 services/collaboration/pkg/middleware/middleware_suite_test.go create mode 100644 services/collaboration/pkg/middleware/wopicontext_test.go create mode 100644 services/collaboration/pkg/wopisrc/wopisrc.go create mode 100644 services/collaboration/pkg/wopisrc/wopisrc_suite_test.go create mode 100644 services/collaboration/pkg/wopisrc/wopisrc_test.go diff --git a/services/collaboration/mocks/content_connector_service.go b/services/collaboration/mocks/content_connector_service.go index da6ff575cf..887c9de10c 100644 --- a/services/collaboration/mocks/content_connector_service.go +++ b/services/collaboration/mocks/content_connector_service.go @@ -7,6 +7,8 @@ import ( connector "github.com/owncloud/ocis/v2/services/collaboration/pkg/connector" + http "net/http" + io "io" mock "github.com/stretchr/testify/mock" @@ -25,17 +27,17 @@ 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) +// GetFile provides a mock function with given fields: ctx, w +func (_m *ContentConnectorService) GetFile(ctx context.Context, w http.ResponseWriter) error { + ret := _m.Called(ctx, w) 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) + if rf, ok := ret.Get(0).(func(context.Context, http.ResponseWriter) error); ok { + r0 = rf(ctx, w) } else { r0 = ret.Error(0) } @@ -50,14 +52,14 @@ type ContentConnectorService_GetFile_Call struct { // 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)} +// - w http.ResponseWriter +func (_e *ContentConnectorService_Expecter) GetFile(ctx interface{}, w interface{}) *ContentConnectorService_GetFile_Call { + return &ContentConnectorService_GetFile_Call{Call: _e.mock.On("GetFile", ctx, w)} } -func (_c *ContentConnectorService_GetFile_Call) Run(run func(ctx context.Context, writer io.Writer)) *ContentConnectorService_GetFile_Call { +func (_c *ContentConnectorService_GetFile_Call) Run(run func(ctx context.Context, w http.ResponseWriter)) *ContentConnectorService_GetFile_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(io.Writer)) + run(args[0].(context.Context), args[1].(http.ResponseWriter)) }) return _c } @@ -67,7 +69,7 @@ func (_c *ContentConnectorService_GetFile_Call) Return(_a0 error) *ContentConnec return _c } -func (_c *ContentConnectorService_GetFile_Call) RunAndReturn(run func(context.Context, io.Writer) error) *ContentConnectorService_GetFile_Call { +func (_c *ContentConnectorService_GetFile_Call) RunAndReturn(run func(context.Context, http.ResponseWriter) error) *ContentConnectorService_GetFile_Call { _c.Call.Return(run) return _c } diff --git a/services/collaboration/pkg/config/app.go b/services/collaboration/pkg/config/app.go index ee569189a6..28bf1de040 100644 --- a/services/collaboration/pkg/config/app.go +++ b/services/collaboration/pkg/config/app.go @@ -10,7 +10,8 @@ type App struct { Addr string `yaml:"addr" env:"COLLABORATION_APP_ADDR" desc:"The URL where the WOPI app is located, such as https://127.0.0.1:8080." introductionVersion:"6.0.0"` Insecure bool `yaml:"insecure" env:"COLLABORATION_APP_INSECURE" desc:"Skip TLS certificate verification when connecting to the WOPI app" introductionVersion:"6.0.0"` - ProofKeys ProofKeys `yaml:"proofkeys"` + ProofKeys ProofKeys `yaml:"proofkeys"` + LicenseCheckEnable bool `yaml:"licensecheckenable" env:"COLLABORATION_APP_LICENSE_CHECK_ENABLE" desc:"Enable license check for edit" introductionVersion:"%%NEXT%%"` } type ProofKeys struct { diff --git a/services/collaboration/pkg/config/wopi.go b/services/collaboration/pkg/config/wopi.go index 51e4caa3bd..fad016ecf3 100644 --- a/services/collaboration/pkg/config/wopi.go +++ b/services/collaboration/pkg/config/wopi.go @@ -5,4 +5,6 @@ type Wopi struct { WopiSrc string `yaml:"wopisrc" env:"COLLABORATION_WOPI_SRC" desc:"The WOPISrc base URL containing schema, host and port. Set this to the schema and domain where the collaboration service is reachable for the wopi app, such as https://office.owncloud.test." introductionVersion:"6.0.0"` Secret string `yaml:"secret" env:"COLLABORATION_WOPI_SECRET" desc:"Used to mint and verify WOPI JWT tokens and encrypt and decrypt the REVA JWT token embedded in the WOPI JWT token." introductionVersion:"6.0.0"` DisableChat bool `yaml:"disable_chat" env:"COLLABORATION_WOPI_DISABLE_CHAT;OCIS_WOPI_DISABLE_CHAT" desc:"Disable chat in the frontend." introductionVersion:"%%NEXT%%"` + ProxyURL string `yaml:"proxy_url" env:"COLLABORATION_WOPI_PROXY_URL" desc:"The URL to the ownCloud Office365 WOPI proxy." introductionVersion:"%%NEXT%%"` + ProxySecret string `yaml:"proxy_secret" env:"COLLABORATION_WOPI_PROXY_SECRET" desc:"The secret to authenticate against the ownCloud Office365 WOPI proxy." introductionVersion:"%%NEXT%%"` } diff --git a/services/collaboration/pkg/connector/connector.go b/services/collaboration/pkg/connector/connector.go index 8cccc7dfbe..96d196748a 100644 --- a/services/collaboration/pkg/connector/connector.go +++ b/services/collaboration/pkg/connector/connector.go @@ -1,5 +1,10 @@ package connector +import ( + types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/helpers" +) + // ConnectorResponse represent a response from the FileConnectorService. // The ConnectorResponse is oriented to HTTP, so it has the Status, Headers // and Body that the actual HTTP response should have. This includes HTTP @@ -33,6 +38,61 @@ func NewResponseWithLock(status int, lockID string) *ConnectorResponse { } } +// NewResponseLockConflict creates a new ConnectorResponse with the status 409 +// and the "X-WOPI-Lock" header having the value in the lockID parameter. +// +// This is used for conflict responses where the current lock id needs +// to be returned, although the `GetLock` method also uses this method for a +// successful response (with the lock id included) +// The lockFailureReason parameter will be included in the "X-WOPI-LockFailureReason". +func NewResponseLockConflict(lockID string, lockFailureReason string) *ConnectorResponse { + return &ConnectorResponse{ + Status: 409, + Headers: map[string]string{ + HeaderWopiLock: lockID, + HeaderWopiLockFailureReason: lockFailureReason, + }, + } +} + +// NewResponseWithVersion creates a new ConnectorResponse with the specified status +// and the "X-WOPI-ItemVersion" header having the value in the mtime parameter. +func NewResponseWithVersion(status int, mtime *types.Timestamp) *ConnectorResponse { + return &ConnectorResponse{ + Status: status, + Headers: map[string]string{ + HeaderWopiVersion: helpers.GetVersion(mtime), + }, + } +} + +// NewResponseConflictWithVersion creates a new ConnectorResponse with the status 409 +// and the "X-WOPI-ItemVersion" header having the value in the mtime parameter. +// The lockFailureReason parameter will be included in the "X-WOPI-LockFailureReason". +func NewResponseConflictWithVersion(mtime *types.Timestamp, lockFailureReason string) *ConnectorResponse { + return &ConnectorResponse{ + Status: 409, + Headers: map[string]string{ + HeaderWopiVersion: helpers.GetVersion(mtime), + HeaderWopiLockFailureReason: lockFailureReason, + }, + } +} + +// NewResponseWithVersionAndLock creates a new ConnectorResponse with the specified status +// and the "X-WOPI-ItemVersion" header and the "X-WOPI-Lock" header +// having the values in the mtime and lockID parameters. +func NewResponseWithVersionAndLock(status int, mtime *types.Timestamp, lockID string) *ConnectorResponse { + r := &ConnectorResponse{ + Status: status, + Headers: map[string]string{ + HeaderWopiVersion: helpers.GetVersion(mtime), + HeaderWopiLock: lockID, + }, + } + return r +} + // NewResponseSuccessBody creates a new ConnectorResponse with a fixed 200 // (success) status and the specified body. The headers will be nil. // diff --git a/services/collaboration/pkg/connector/contentconnector.go b/services/collaboration/pkg/connector/contentconnector.go index ead86d397c..fab2f1b339 100644 --- a/services/collaboration/pkg/connector/contentconnector.go +++ b/services/collaboration/pkg/connector/contentconnector.go @@ -17,6 +17,7 @@ import ( revactx "github.com/cs3org/reva/v2/pkg/ctx" "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/helpers" "github.com/owncloud/ocis/v2/services/collaboration/pkg/middleware" "github.com/rs/zerolog" "go.opentelemetry.io/otel/propagation" @@ -29,7 +30,7 @@ import ( // 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 + GetFile(ctx context.Context, w http.ResponseWriter) 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 @@ -61,9 +62,11 @@ func NewContentConnector(gwc gatewayv1beta1.GatewayAPIClient, cfg *config.Config // 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 +// The contents of the file will be written directly into the http Response writer passed as // parameter. -func (c *ContentConnector) GetFile(ctx context.Context, writer io.Writer) error { +// Be aware that the body of the response will be written during the execution of this method. +// Any further modifications to the response headers or body will be ignored. +func (c *ContentConnector) GetFile(ctx context.Context, w http.ResponseWriter) error { wopiContext, err := middleware.WopiContextFromCtx(ctx) if err != nil { return err @@ -74,6 +77,16 @@ func (c *ContentConnector) GetFile(ctx context.Context, writer io.Writer) error Logger() logger.Debug().Msg("GetFile: start") + sResp, err := c.gwc.Stat(ctx, &providerv1beta1.StatRequest{ + Ref: wopiContext.FileReference, + }) + if err != nil { + logger.Error().Err(err).Msg("GetFile: Stat Request failed") + return err + } + if sResp.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { + return NewConnectorError(500, sResp.GetStatus().GetCode().String()+" "+sResp.GetStatus().GetMessage()) + } // Initiate download request req := &providerv1beta1.InitiateFileDownloadRequest{ Ref: wopiContext.FileReference, @@ -168,13 +181,14 @@ func (c *ContentConnector) GetFile(ctx context.Context, writer io.Writer) error return NewConnectorError(500, "GetFile: Downloading the file failed") } + helpers.SetVersionHeader(w, sResp.GetInfo().GetMtime()) + // Copy the download into the writer - _, err = io.Copy(writer, httpResp.Body) + _, err = io.Copy(w, 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 } @@ -199,6 +213,8 @@ func (c *ContentConnector) GetFile(ctx context.Context, writer io.Writer) error // 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) +// +// On success, the method will return the new mtime of the file func (c *ContentConnector) PutFile(ctx context.Context, stream io.Reader, streamLength int64, lockID string) (*ConnectorResponse, error) { wopiContext, err := middleware.WopiContextFromCtx(ctx) if err != nil { @@ -230,13 +246,14 @@ func (c *ContentConnector) PutFile(ctx context.Context, stream io.Reader, stream return NewResponse(500), nil } + mtime := statRes.GetInfo().GetMtime() // 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 NewResponseWithLock(409, statRes.GetInfo().GetLock().GetLockId()), nil + return NewResponseLockConflict(statRes.GetInfo().GetLock().GetLockId(), "Lock Mismatch"), nil } // only unlocked uploads can go through if the target file is empty, @@ -246,7 +263,7 @@ func (c *ContentConnector) PutFile(ctx context.Context, stream io.Reader, stream 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 NewResponseWithLock(409, ""), nil + return NewResponseLockConflict("", "Cannot PutFile on unlocked file"), nil } // Prepare the data to initiate the upload @@ -367,8 +384,25 @@ func (c *ContentConnector) PutFile(ctx context.Context, stream io.Reader, stream Msg("UploadHelper: Put request to the upload endpoint failed with unexpected status") return NewResponse(500), nil } + // We need a stat call on the target file after the upload to get the + // new mtime + statResAfter, err := c.gwc.Stat(ctx, &providerv1beta1.StatRequest{ + Ref: wopiContext.FileReference, + }) + if err != nil { + logger.Error().Err(err).Msg("PutFile: stat after upload failed") + return nil, err + } + if statResAfter.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", statRes.GetStatus().GetCode().String()). + Str("StatusMsg", statRes.GetStatus().GetMessage()). + Msg("PutFile: stat after upload failed with unexpected status") + return NewResponse(500), nil + } + mtime = statResAfter.GetInfo().GetMtime() } logger.Debug().Msg("PutFile: success") - return NewResponse(200), nil + return NewResponseWithVersion(200, mtime), nil } diff --git a/services/collaboration/pkg/connector/contentconnector_test.go b/services/collaboration/pkg/connector/contentconnector_test.go index 82ba9e9c42..6081ca84ca 100644 --- a/services/collaboration/pkg/connector/contentconnector_test.go +++ b/services/collaboration/pkg/connector/contentconnector_test.go @@ -6,14 +6,15 @@ import ( "net/http" "net/http/httptest" "strings" + "time" + "github.com/cs3org/reva/v2/pkg/utils" . "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" revactx "github.com/cs3org/reva/v2/pkg/ctx" "github.com/cs3org/reva/v2/pkg/rgrpc/status" @@ -52,7 +53,6 @@ var _ = Describe("ContentConnector", func() { }, Path: ".", }, - User: &userv1beta1.User{}, // Not used for now ViewMode: appproviderv1beta1.ViewMode_VIEW_MODE_READ_WRITE, } @@ -77,15 +77,28 @@ var _ = Describe("ContentConnector", func() { }) Describe("GetFile", func() { + BeforeEach(func() { + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything).Return(&providerv1beta1.StatResponse{ + Status: status.NewOK(context.Background()), + Info: &providerv1beta1.ResourceInfo{ + Id: &providerv1beta1.ResourceId{ + StorageId: "abc", + OpaqueId: "12345", + SpaceId: "zzz", + }, + Path: ".", + }, + }, nil) + }) It("No valid context", func() { - sb := &strings.Builder{} + sb := httptest.NewRecorder() ctx := context.Background() err := cc.GetFile(ctx, sb) Expect(err).To(HaveOccurred()) }) It("Initiate download failed", func() { - sb := &strings.Builder{} + sb := httptest.NewRecorder() ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) targetErr := errors.New("Something went wrong") @@ -98,7 +111,7 @@ var _ = Describe("ContentConnector", func() { }) It("Initiate download status not ok", func() { - sb := &strings.Builder{} + sb := httptest.NewRecorder() ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) gatewayClient.On("InitiateFileDownload", mock.Anything, mock.Anything).Times(1).Return(&gateway.InitiateFileDownloadResponse{ @@ -112,7 +125,7 @@ var _ = Describe("ContentConnector", func() { }) It("Missing download endpoint", func() { - sb := &strings.Builder{} + sb := httptest.NewRecorder() ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) gatewayClient.On("InitiateFileDownload", mock.Anything, mock.Anything).Times(1).Return(&gateway.InitiateFileDownloadResponse{ @@ -126,7 +139,7 @@ var _ = Describe("ContentConnector", func() { }) It("Download request failed", func() { - sb := &strings.Builder{} + sb := httptest.NewRecorder() ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) gatewayClient.On("InitiateFileDownload", mock.Anything, mock.Anything).Times(1).Return(&gateway.InitiateFileDownloadResponse{ @@ -149,7 +162,7 @@ var _ = Describe("ContentConnector", func() { }) It("Download request success", func() { - sb := &strings.Builder{} + sb := httptest.NewRecorder() ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) gatewayClient.On("InitiateFileDownload", mock.Anything, mock.Anything).Times(1).Return(&gateway.InitiateFileDownloadResponse{ @@ -167,11 +180,11 @@ var _ = Describe("ContentConnector", func() { 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)) + Expect(sb.Body.String()).To(Equal(randomContent)) }) It("ViewOnlyMode Download request success", func() { - sb := &strings.Builder{} + sb := httptest.NewRecorder() wopiCtx = middleware.WopiContext{ AccessToken: "abcdef123456", @@ -184,7 +197,6 @@ var _ = Describe("ContentConnector", func() { }, Path: ".", }, - User: &userv1beta1.User{}, // Not used for now ViewMode: appproviderv1beta1.ViewMode_VIEW_MODE_VIEW_ONLY, } @@ -208,7 +220,7 @@ var _ = Describe("ContentConnector", func() { Expect(srvReqHeader.Get("X-Access-Token")).To(Equal(wopiCtx.ViewOnlyToken)) Expect(srvReqHeader.Get("X-Reva-Transfer")).To(Equal("MyDownloadToken")) Expect(err).To(Succeed()) - Expect(sb.String()).To(Equal(randomContent)) + Expect(sb.Body.String()).To(Equal(randomContent)) }) }) @@ -244,7 +256,7 @@ var _ = Describe("ContentConnector", func() { }, nil) response, err := cc.PutFile(ctx, reader, reader.Size(), "notARandomLockId") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(500)) Expect(response.Headers).To(BeNil()) }) @@ -264,9 +276,11 @@ var _ = Describe("ContentConnector", func() { }, nil) response, err := cc.PutFile(ctx, reader, reader.Size(), "notARandomLockId") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(409)) + Expect(response.Headers).To(HaveLen(2)) Expect(response.Headers[connector.HeaderWopiLock]).To(Equal("goodAndValidLock")) + Expect(response.Headers[connector.HeaderWopiLockFailureReason]).To(Equal("Lock Mismatch")) }) It("Upload without lockId but on a non empty file", func() { @@ -282,7 +296,7 @@ var _ = Describe("ContentConnector", func() { }, nil) response, err := cc.PutFile(ctx, reader, reader.Size(), "") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(409)) Expect(response.Headers[connector.HeaderWopiLock]).To(Equal("")) }) @@ -332,7 +346,7 @@ var _ = Describe("ContentConnector", func() { }, nil) response, err := cc.PutFile(ctx, reader, reader.Size(), "goodAndValidLock") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(500)) Expect(response.Headers).To(BeNil()) }) @@ -348,7 +362,8 @@ var _ = Describe("ContentConnector", func() { LockId: "goodAndValidLock", Type: providerv1beta1.LockType_LOCK_TYPE_WRITE, }, - Size: uint64(123456789), + Size: uint64(123456789), + Mtime: utils.TimeToTS(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)), }, }, nil) @@ -357,9 +372,10 @@ var _ = Describe("ContentConnector", func() { }, nil) response, err := cc.PutFile(ctx, reader, reader.Size(), "goodAndValidLock") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(200)) - Expect(response.Headers).To(BeNil()) + Expect(response.Headers).To(HaveLen(1)) + Expect(response.Headers[connector.HeaderWopiVersion]).To(Equal("v16094592000")) }) It("Missing upload endpoint", func() { @@ -382,7 +398,7 @@ var _ = Describe("ContentConnector", func() { }, nil) response, err := cc.PutFile(ctx, reader, reader.Size(), "goodAndValidLock") - Expect(err).To(BeNil()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(500)) Expect(response.Headers).To(BeNil()) }) @@ -414,7 +430,7 @@ var _ = Describe("ContentConnector", func() { response, err := cc.PutFile(ctx, reader, reader.Size(), "goodAndValidLock") Expect(srvReqHeader.Get("X-Access-Token")).To(Equal(wopiCtx.AccessToken)) - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(500)) Expect(response.Headers).To(BeNil()) }) @@ -434,6 +450,23 @@ var _ = Describe("ContentConnector", func() { }, }, nil) + gatewayClient.EXPECT().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), + Id: &providerv1beta1.ResourceId{ + StorageId: "storageID", + OpaqueId: "opaqueID", + SpaceId: "spaceID", + }, + Mtime: utils.TimeToTS(time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)), + }, + }, nil) + gatewayClient.On("InitiateFileUpload", mock.Anything, mock.Anything).Times(1).Return(&gateway.InitiateFileUploadResponse{ Status: status.NewOK(ctx), Protocols: []*gateway.FileUploadProtocol{ @@ -446,9 +479,10 @@ var _ = Describe("ContentConnector", func() { response, err := cc.PutFile(ctx, reader, reader.Size(), "goodAndValidLock") Expect(srvReqHeader.Get("X-Access-Token")).To(Equal(wopiCtx.AccessToken)) - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(200)) - Expect(response.Headers).To(BeNil()) + Expect(response.Headers).To(HaveLen(1)) + Expect(response.Headers[connector.HeaderWopiVersion]).To(Equal("v16094592000")) }) }) }) diff --git a/services/collaboration/pkg/connector/fileconnector.go b/services/collaboration/pkg/connector/fileconnector.go index d23e93c735..64712a901c 100644 --- a/services/collaboration/pkg/connector/fileconnector.go +++ b/services/collaboration/pkg/connector/fileconnector.go @@ -9,7 +9,6 @@ import ( "io" "net/url" "path" - "strconv" "strings" "time" @@ -19,12 +18,15 @@ import ( 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" + ctxpkg "github.com/cs3org/reva/v2/pkg/ctx" + "github.com/cs3org/reva/v2/pkg/storagespace" "github.com/cs3org/reva/v2/pkg/utils" "github.com/google/uuid" "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" "github.com/owncloud/ocis/v2/services/collaboration/pkg/connector/fileinfo" "github.com/owncloud/ocis/v2/services/collaboration/pkg/helpers" "github.com/owncloud/ocis/v2/services/collaboration/pkg/middleware" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/wopisrc" "github.com/rs/zerolog" ) @@ -52,7 +54,7 @@ type FileConnectorService interface { // needs to be provided. // The current lockID will be returned if a conflict happens RefreshLock(ctx context.Context, lockID string) (*ConnectorResponse, error) - // Unlock will unlock the target file. The current lockID needs to be + // 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) (*ConnectorResponse, error) @@ -172,6 +174,8 @@ func (f *FileConnector) GetLock(ctx context.Context) (*ConnectorResponse, error) // the method will return an empty lock id. // // For the "unlock and relock" operation, the behavior will be the same. +// +// On success, the mtime of the file will be returned in the X-Wopi-Version header. func (f *FileConnector) Lock(ctx context.Context, lockID, oldLockID string) (*ConnectorResponse, error) { wopiContext, err := middleware.WopiContextFromCtx(ctx) if err != nil { @@ -236,11 +240,26 @@ func (f *FileConnector) Lock(ctx context.Context, lockID, oldLockID string) (*Co setOrRefreshStatus = resp.GetStatus() } + statResp, err := f.gwc.Stat(ctx, &providerv1beta1.StatRequest{ + Ref: wopiContext.FileReference, + }) + if err != nil { + logger.Error().Err(err).Msg("Lock failed trying to get the file info") + return nil, err + } + if statResp.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", statResp.GetStatus().GetCode().String()). + Str("StatusMsg", statResp.GetStatus().GetMessage()). + Msg("Lock failed trying to get the file info with unexpected status") + return NewResponse(500), nil + } + // 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 NewResponse(200), nil + return NewResponseWithVersion(200, statResp.GetInfo().GetMtime()), nil case rpcv1beta1.Code_CODE_FAILED_PRECONDITION, rpcv1beta1.Code_CODE_ABORTED: // Code_CODE_FAILED_PRECONDITION -> Lock operation mismatched lock @@ -270,7 +289,7 @@ func (f *FileConnector) Lock(ctx context.Context, lockID, oldLockID string) (*Co logger.Warn(). Str("LockID", resp.GetLock().GetLockId()). Msg("SetLock conflict") - return NewResponseWithLock(409, resp.GetLock().GetLockId()), nil + return NewResponseLockConflict(resp.GetLock().GetLockId(), "Conflicting LockID"), nil } // TODO: according to the spec we need to treat this as a RefreshLock @@ -281,7 +300,7 @@ func (f *FileConnector) Lock(ctx context.Context, lockID, oldLockID string) (*Co logger.Warn(). Str("LockID", resp.GetLock().GetLockId()). Msg("SetLock lock refreshed instead") - return NewResponse(200), nil // no need to send the lockID for a 200 code + return NewResponseWithVersionAndLock(200, statResp.GetInfo().GetMtime(), resp.GetLock().GetLockId()), nil } logger.Error().Msg("SetLock failed and could not refresh") @@ -313,6 +332,8 @@ func (f *FileConnector) Lock(ctx context.Context, lockID, oldLockID string) (*Co // return an empty lock id. // The conflict happens if the provided lockID doesn't match the one actually // applied in the target file. +// +// On success, the mtime of the file will be returned in the X-Wopi-Version header. func (f *FileConnector) RefreshLock(ctx context.Context, lockID string) (*ConnectorResponse, error) { wopiContext, err := middleware.WopiContextFromCtx(ctx) if err != nil { @@ -347,10 +368,27 @@ func (f *FileConnector) RefreshLock(ctx context.Context, lockID string) (*Connec return nil, err } + statResp, err := f.gwc.Stat(ctx, &providerv1beta1.StatRequest{ + Ref: wopiContext.FileReference, + }) + if err != nil { + logger.Error().Err(err).Msg("RefreshLock failed trying to get the file info") + return nil, err + } + if statResp.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", statResp.GetStatus().GetCode().String()). + Str("StatusMsg", statResp.GetStatus().GetMessage()). + Msg("RefreshLock failed trying to get the file info with unexpected status") + return NewResponse(500), nil + } + switch resp.GetStatus().GetCode() { case rpcv1beta1.Code_CODE_OK: logger.Debug().Msg("RefreshLock successful") - return NewResponse(200), nil + // The current lock should not be returned in the headers on success + // https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/files/refreshlock#response-headers + return NewResponseWithVersion(200, statResp.GetInfo().GetMtime()), nil case rpcv1beta1.Code_CODE_NOT_FOUND: logger.Error(). @@ -390,7 +428,7 @@ func (f *FileConnector) RefreshLock(ctx context.Context, lockID string) (*Connec Str("StatusCode", resp.GetStatus().GetCode().String()). Str("StatusMsg", resp.GetStatus().GetMessage()). Msg("RefreshLock failed, no lock on file") - return NewResponseWithLock(409, ""), nil + return NewResponseConflictWithVersion(statResp.GetInfo().GetMtime(), "No lock on file"), nil } else { // lock is different than the one requested, otherwise we wouldn't reached this point logger.Error(). @@ -398,7 +436,7 @@ func (f *FileConnector) RefreshLock(ctx context.Context, lockID string) (*Connec Str("StatusCode", resp.GetStatus().GetCode().String()). Str("StatusMsg", resp.GetStatus().GetMessage()). Msg("RefreshLock failed, lock mismatch") - return NewResponseWithLock(409, resp.GetLock().GetLockId()), nil + return NewResponseLockConflict(resp.GetLock().GetLockId(), "Lock mismatch"), nil } default: logger.Error(). @@ -422,6 +460,8 @@ func (f *FileConnector) RefreshLock(ctx context.Context, lockID string) (*Connec // return an empty lock id. // The conflict happens if the provided lockID doesn't match the one actually // applied in the target file. +// +// On success, the mtime of the file will be returned in the X-Wopi-Version header. func (f *FileConnector) UnLock(ctx context.Context, lockID string) (*ConnectorResponse, error) { wopiContext, err := middleware.WopiContextFromCtx(ctx) if err != nil { @@ -452,14 +492,29 @@ func (f *FileConnector) UnLock(ctx context.Context, lockID string) (*ConnectorRe return nil, err } + statResp, err := f.gwc.Stat(ctx, &providerv1beta1.StatRequest{ + Ref: wopiContext.FileReference, + }) + if err != nil { + logger.Error().Err(err).Msg("Unlock failed trying to get the file info") + return nil, err + } + if statResp.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK { + logger.Error(). + Str("StatusCode", statResp.GetStatus().GetCode().String()). + Str("StatusMsg", statResp.GetStatus().GetMessage()). + Msg("Unlock failed trying to get the file info with unexpected status") + return NewResponse(500), nil + } + switch resp.GetStatus().GetCode() { case rpcv1beta1.Code_CODE_OK: logger.Debug().Msg("Unlock successful") - return NewResponse(200), nil + return NewResponseWithVersion(200, statResp.GetInfo().GetMtime()), 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 NewResponseWithLock(409, ""), nil + return NewResponseLockConflict("", "File isn't locked"), nil case rpcv1beta1.Code_CODE_LOCKED: // We need to return 409 with the current lock req := &providerv1beta1.GetLockRequest{ @@ -496,7 +551,7 @@ func (f *FileConnector) UnLock(ctx context.Context, lockID string) (*ConnectorRe Msg("Unlock failed, lock mismatch") outLockId = resp.GetLock().GetLockId() } - return NewResponseWithLock(409, outLockId), nil + return NewResponseLockConflict(outLockId, "Lock mismatch"), nil default: logger.Error(). Str("StatusCode", resp.GetStatus().GetCode().String()). @@ -612,7 +667,7 @@ func (f *FileConnector) PutRelativeFileSuggested(ctx context.Context, ccs Conten return nil, err } - wopiSrcURL, err := f.generateWOPISrc(ctx, wopiContext, newLogger) + wopiSrcURL, err := f.generateWOPISrc(wopiContext, newLogger) if err != nil { logger.Error().Err(err).Msg("PutRelativeFileSuggested: error generating the WOPISrc parameter") return nil, err @@ -708,7 +763,7 @@ func (f *FileConnector) PutRelativeFileRelative(ctx context.Context, ccs Content } // if conflict generate a different name and retry. // this should happen only once - wopiSrcURL, err2 := f.generateWOPISrc(ctx, wopiContext, newLogger) + wopiSrcURL, err2 := f.generateWOPISrc(wopiContext, newLogger) if err2 != nil { newLogger.Error(). Err(err2). @@ -724,12 +779,13 @@ func (f *FileConnector) PutRelativeFileRelative(ctx context.Context, ccs Content Str("LockID", lockID). Msg("PutRelativeFileRelative: error conflict") - // need to build the response ourselves + // need to build the response ourselves return &ConnectorResponse{ Status: 409, Headers: map[string]string{ - HeaderWopiValidRT: finalTarget, - HeaderWopiLock: lockID, + HeaderWopiValidRT: finalTarget, + HeaderWopiLock: lockID, + HeaderWopiLockFailureReason: "Lock Conflict", }, Body: map[string]interface{}{ "Name": target, @@ -747,7 +803,7 @@ func (f *FileConnector) PutRelativeFileRelative(ctx context.Context, ccs Content return nil, err } - wopiSrcURL, err := f.generateWOPISrc(ctx, wopiContext, newLogger) + wopiSrcURL, err := f.generateWOPISrc(wopiContext, newLogger) if err != nil { newLogger.Error().Err(err).Msg("PutRelativeFileRelative: error generating the WOPISrc parameter") return nil, err @@ -798,7 +854,8 @@ func (f *FileConnector) DeleteFile(ctx context.Context, lockID string) (*Connect if deleteRes.GetStatus().GetCode() == rpcv1beta1.Code_CODE_TOO_EARLY { // starting from 20ms, double the waiting time for each retry // capping at 5 secs - waitingTime := (20 * time.Millisecond) << retries + var waitingTime time.Duration + waitingTime = (20 * time.Millisecond) << retries if waitingTime.Seconds() > 5 { waitingTime = 5 * time.Second } @@ -849,7 +906,7 @@ func (f *FileConnector) DeleteFile(ctx context.Context, lockID string) (*Connect logger.Error(). Str("LockID", resp.GetLock().GetLockId()). Msg("DeleteFile: file is locked") - return NewResponseWithLock(409, resp.GetLock().GetLockId()), nil + return NewResponseLockConflict(resp.GetLock().GetLockId(), "File is locked"), nil } else { // return the original error since the file isn't locked logger.Error().Msg("DeleteFile: delete failed on unlocked file") @@ -942,7 +999,7 @@ func (f *FileConnector) RenameFile(ctx context.Context, lockID, target string) ( Str("StatusCode", moveRes.GetStatus().GetCode().String()). Str("StatusMsg", moveRes.GetStatus().GetMessage()). Msg("RenameFile: conflict") - return NewResponseWithLock(409, currentLockID), nil + return NewResponseLockConflict(currentLockID, "Lock Conflict"), nil } if moveRes.GetStatus().GetCode() == rpcv1beta1.Code_CODE_ALREADY_EXISTS { @@ -1018,7 +1075,6 @@ func (f *FileConnector) CheckFileInfo(ctx context.Context) (*ConnectorResponse, } hexEncodedOwnerId := hex.EncodeToString([]byte(statRes.GetInfo().GetOwner().GetOpaqueId() + "@" + statRes.GetInfo().GetOwner().GetIdp())) - version := strconv.FormatUint(statRes.GetInfo().GetMtime().GetSeconds(), 10) + "." + strconv.FormatUint(uint64(statRes.GetInfo().GetMtime().GetNanos()), 10) // 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) // assign userId, userFriendlyName and isAnonymousUser @@ -1029,30 +1085,44 @@ func (f *FileConnector) CheckFileInfo(ctx context.Context) (*ConnectorResponse, isAnonymousUser := true isPublicShare := false - if wopiContext.User != nil { + user := ctxpkg.ContextMustGetUser(ctx) + if user.String() != "" { // if we have a wopiContext.User - isPublicShare = utils.ExistsInOpaque(wopiContext.User.GetOpaque(), "public-share-role") + isPublicShare = utils.ExistsInOpaque(user.GetOpaque(), "public-share-role") if !isPublicShare { - hexEncodedWopiUserId := hex.EncodeToString([]byte(wopiContext.User.GetId().GetOpaqueId() + "@" + wopiContext.User.GetId().GetIdp())) + hexEncodedWopiUserId := hex.EncodeToString([]byte(user.GetId().GetOpaqueId() + "@" + user.GetId().GetIdp())) isAnonymousUser = false - userFriendlyName = wopiContext.User.GetDisplayName() + userFriendlyName = user.GetDisplayName() userId = hexEncodedWopiUserId } } + breadcrumbFolderName := path.Dir(statRes.Info.Path) + if breadcrumbFolderName == "." || breadcrumbFolderName == "" { + breadcrumbFolderName = statRes.GetInfo().GetSpace().GetName() + } + + ocisUrl, err := url.Parse(f.cfg.Commons.OcisURL) + if err != nil { + return nil, err + } + breadcrumbFolderURL, viewAppUrl, editAppUrl := *ocisUrl, *ocisUrl, *ocisUrl + breadcrumbFolderURL.Path = path.Join(breadcrumbFolderURL.Path, "f", storagespace.FormatResourceID(statRes.GetInfo().GetId())) + viewAppUrl.Path = path.Join(viewAppUrl.Path, "external"+strings.ToLower(f.cfg.App.Name)) + editAppUrl.Path = path.Join(editAppUrl.Path, "external"+strings.ToLower(f.cfg.App.Name)) // fileinfo map infoMap := map[string]interface{}{ fileinfo.KeyOwnerID: hexEncodedOwnerId, fileinfo.KeySize: int64(statRes.GetInfo().GetSize()), - fileinfo.KeyVersion: version, + fileinfo.KeyVersion: helpers.GetVersion(statRes.GetInfo().GetMtime()), fileinfo.KeyBaseFileName: path.Base(statRes.GetInfo().GetPath()), fileinfo.KeyBreadcrumbDocName: path.Base(statRes.GetInfo().GetPath()), // to get the folder we actually need to do a GetPath() request - //BreadcrumbFolderName: path.Dir(statRes.Info.Path), + fileinfo.KeyBreadcrumbFolderName: breadcrumbFolderName, + fileinfo.KeyBreadcrumbFolderURL: breadcrumbFolderURL.String(), - // TODO: these URLs must point to ocis, which is hosting the editor's iframe - //fileinfo.KeyHostViewURL: wopiContext.ViewAppUrl, - //fileinfo.KeyHostEditURL: wopiContext.EditAppUrl, + //fileinfo.KeyHostViewURL: viewAppUrl.String(), + //fileinfo.KeyHostEditURL: editAppUrl.String(), fileinfo.KeyEnableOwnerTermination: true, // only for collabora fileinfo.KeySupportsExtendedLockLength: true, @@ -1066,7 +1136,8 @@ func (f *FileConnector) CheckFileInfo(ctx context.Context) (*ConnectorResponse, fileinfo.KeyUserFriendlyName: userFriendlyName, fileinfo.KeyUserID: userId, - fileinfo.KeyPostMessageOrigin: f.cfg.Commons.OcisURL, + fileinfo.KeyPostMessageOrigin: f.cfg.Commons.OcisURL, + fileinfo.KeyLicenseCheckForEditIsEnabled: f.cfg.App.LicenseCheckEnable, } switch wopiContext.ViewMode { @@ -1082,7 +1153,7 @@ func (f *FileConnector) CheckFileInfo(ctx context.Context) (*ConnectorResponse, infoMap[fileinfo.KeyDisableCopy] = true // only for collabora infoMap[fileinfo.KeyDisablePrint] = true if !isPublicShare { - infoMap[fileinfo.KeyWatermarkText] = f.watermarkText(wopiContext.User) // only for collabora + infoMap[fileinfo.KeyWatermarkText] = f.watermarkText(user) // only for collabora } } @@ -1162,7 +1233,7 @@ func (f *FileConnector) generatePrefix() string { // contains the resource id of the target file without the path // (storage, opaque and space points directly to the file). The path component // will be ignored -func (f *FileConnector) generateWOPISrc(ctx context.Context, wopiContext middleware.WopiContext, logger zerolog.Logger) (*url.URL, error) { +func (f *FileConnector) generateWOPISrc(wopiContext middleware.WopiContext, logger zerolog.Logger) (*url.URL, error) { // get the WOPI token for the new file accessToken, _, err := middleware.GenerateWopiToken(wopiContext, f.cfg) if err != nil { @@ -1174,16 +1245,14 @@ func (f *FileConnector) generateWOPISrc(ctx context.Context, wopiContext middlew fileRef := helpers.HashResourceId(wopiContext.FileReference.GetResourceId()) // generate the URL for the WOPI app to access the new created file - wopiSrcURL, err := url.Parse(f.cfg.Wopi.WopiSrc) + wopiSrcURL, err := wopisrc.GenerateWopiSrc(fileRef, f.cfg) if err != nil { logger.Error().Err(err).Msg("generateWOPISrc: failed to generate WOPISrc URL for the new file") return nil, err } - wopiSrcURL.Path = path.Join("wopi", "files", fileRef) q := wopiSrcURL.Query() q.Add("access_token", accessToken) wopiSrcURL.RawQuery = q.Encode() - return wopiSrcURL, nil } diff --git a/services/collaboration/pkg/connector/fileconnector_test.go b/services/collaboration/pkg/connector/fileconnector_test.go index eb1ec5462c..9afd01482f 100644 --- a/services/collaboration/pkg/connector/fileconnector_test.go +++ b/services/collaboration/pkg/connector/fileconnector_test.go @@ -12,6 +12,7 @@ import ( 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" + ctxpkg "github.com/cs3org/reva/v2/pkg/ctx" "github.com/cs3org/reva/v2/pkg/rgrpc/status" cs3mocks "github.com/cs3org/reva/v2/tests/cs3mocks/mocks" . "github.com/onsi/ginkgo/v2" @@ -63,25 +64,6 @@ var _ = Describe("FileConnector", func() { }, Path: ".", }, - User: &userv1beta1.User{ - Id: &userv1beta1.UserId{ - Idp: "inmemory", - OpaqueId: "opaqueId", - Type: userv1beta1.UserType_USER_TYPE_PRIMARY, - }, - Username: "Shaft", - DisplayName: "Pet Shaft", - Mail: "shaft@example.com", - // 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, } }) @@ -116,7 +98,7 @@ var _ = Describe("FileConnector", func() { }, nil) response, err := fc.GetLock(ctx) - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(404)) Expect(response.Headers).To(BeNil()) }) @@ -134,7 +116,7 @@ var _ = Describe("FileConnector", func() { }, nil) response, err := fc.GetLock(ctx) - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(200)) Expect(response.Headers[connector.HeaderWopiLock]).To(Equal("zzz999")) }) @@ -153,7 +135,7 @@ var _ = Describe("FileConnector", func() { ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) response, err := fc.Lock(ctx, "", "") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(400)) Expect(response.Headers).To(BeNil()) }) @@ -179,10 +161,25 @@ var _ = Describe("FileConnector", func() { Status: status.NewOK(ctx), }, nil) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return( + &providerv1beta1.StatResponse{ + Status: status.NewOK(ctx), + Info: &providerv1beta1.ResourceInfo{ + Mtime: &typesv1beta1.Timestamp{ + Seconds: 12345, + Nanos: 6789, + }, + }, + }, + nil, + ) + response, err := fc.Lock(ctx, "abcdef123", "") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(200)) - Expect(response.Headers).To(BeNil()) + Expect(response.Headers).To(HaveLen(1)) + Expect(response.Headers[connector.HeaderWopiVersion]).To(Equal("v123456789")) }) It("Set lock mismatches error getting lock", func() { @@ -197,6 +194,9 @@ var _ = Describe("FileConnector", func() { Status: status.NewInternal(ctx, "lock mismatch"), }, targetErr) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return(&providerv1beta1.StatResponse{Status: status.NewOK(ctx)}, nil) + response, err := fc.Lock(ctx, "abcdef123", "") Expect(err).To(HaveOccurred()) Expect(err).To(Equal(targetErr)) @@ -218,10 +218,15 @@ var _ = Describe("FileConnector", func() { }, }, nil) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return(&providerv1beta1.StatResponse{Status: status.NewOK(ctx)}, nil) + response, err := fc.Lock(ctx, "abcdef123", "") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(409)) + Expect(response.Headers).To(HaveLen(2)) Expect(response.Headers[connector.HeaderWopiLock]).To(Equal("zzz999")) + Expect(response.Headers[connector.HeaderWopiLockFailureReason]).To(Equal("Conflicting LockID")) }) It("Set lock mismatches but get lock matches", func() { @@ -239,10 +244,20 @@ var _ = Describe("FileConnector", func() { }, }, nil) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return(&providerv1beta1.StatResponse{ + Status: status.NewOK(ctx), + Info: &providerv1beta1.ResourceInfo{ + Mtime: &typesv1beta1.Timestamp{Seconds: uint64(12345), Nanos: uint32(6789)}, + }, + }, nil) + response, err := fc.Lock(ctx, "abcdef123", "") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(200)) - Expect(response.Headers).To(BeNil()) + Expect(response.Headers).To(HaveLen(2)) + Expect(response.Headers[connector.HeaderWopiLock]).To(Equal("abcdef123")) + Expect(response.Headers[connector.HeaderWopiVersion]).To(Equal("v123456789")) }) It("Set lock mismatches but get lock doesn't return lockId", func() { @@ -256,8 +271,11 @@ var _ = Describe("FileConnector", func() { Status: status.NewOK(ctx), }, nil) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return(&providerv1beta1.StatResponse{Status: status.NewOK(ctx)}, nil) + response, err := fc.Lock(ctx, "abcdef123", "") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(500)) Expect(response.Headers).To(BeNil()) }) @@ -269,8 +287,11 @@ var _ = Describe("FileConnector", func() { Status: status.NewNotFound(ctx, "file not found"), }, nil) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return(&providerv1beta1.StatResponse{Status: status.NewOK(ctx)}, nil) + response, err := fc.Lock(ctx, "abcdef123", "") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(404)) Expect(response.Headers).To(BeNil()) }) @@ -282,8 +303,11 @@ var _ = Describe("FileConnector", func() { Status: status.NewInsufficientStorage(ctx, nil, "file too big"), }, nil) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return(&providerv1beta1.StatResponse{Status: status.NewOK(ctx)}, nil) + response, err := fc.Lock(ctx, "abcdef123", "") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(500)) Expect(response.Headers).To(BeNil()) }) @@ -301,7 +325,7 @@ var _ = Describe("FileConnector", func() { ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) response, err := fc.Lock(ctx, "", "oldLock") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(400)) Expect(response.Headers).To(BeNil()) }) @@ -327,10 +351,19 @@ var _ = Describe("FileConnector", func() { Status: status.NewOK(ctx), }, nil) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return(&providerv1beta1.StatResponse{ + Status: status.NewOK(ctx), + Info: &providerv1beta1.ResourceInfo{ + Mtime: &typesv1beta1.Timestamp{Seconds: uint64(12345), Nanos: uint32(6789)}, + }, + }, nil) + response, err := fc.Lock(ctx, "abcdef123", "oldLock") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(200)) - Expect(response.Headers).To(BeNil()) + Expect(response.Headers).To(HaveLen(1)) + Expect(response.Headers[connector.HeaderWopiVersion]).To(Equal("v123456789")) }) It("Refresh lock mismatches error getting lock", func() { @@ -345,6 +378,9 @@ var _ = Describe("FileConnector", func() { Status: status.NewInternal(ctx, "lock mismatch"), }, targetErr) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return(&providerv1beta1.StatResponse{Status: status.NewOK(ctx)}, nil) + response, err := fc.Lock(ctx, "abcdef123", "112233") Expect(err).To(HaveOccurred()) Expect(err).To(Equal(targetErr)) @@ -366,8 +402,11 @@ var _ = Describe("FileConnector", func() { }, }, nil) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return(&providerv1beta1.StatResponse{Status: status.NewOK(ctx)}, nil) + response, err := fc.Lock(ctx, "abcdef123", "112233") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(409)) Expect(response.Headers[connector.HeaderWopiLock]).To(Equal("zzz999")) }) @@ -387,10 +426,20 @@ var _ = Describe("FileConnector", func() { }, }, nil) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return(&providerv1beta1.StatResponse{ + Status: status.NewOK(ctx), + Info: &providerv1beta1.ResourceInfo{ + Mtime: &typesv1beta1.Timestamp{Seconds: uint64(12345), Nanos: uint32(6789)}, + }, + }, nil) + response, err := fc.Lock(ctx, "abcdef123", "112233") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(200)) - Expect(response.Headers).To(BeNil()) + Expect(response.Headers).To(HaveLen(2)) + Expect(response.Headers[connector.HeaderWopiLock]).To(Equal("abcdef123")) + Expect(response.Headers[connector.HeaderWopiVersion]).To(Equal("v123456789")) }) It("Refresh lock mismatches but get lock doesn't return lockId", func() { @@ -404,8 +453,11 @@ var _ = Describe("FileConnector", func() { Status: status.NewOK(ctx), }, nil) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return(&providerv1beta1.StatResponse{Status: status.NewOK(ctx)}, nil) + response, err := fc.Lock(ctx, "abcdef123", "112233") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(500)) Expect(response.Headers).To(BeNil()) }) @@ -417,8 +469,11 @@ var _ = Describe("FileConnector", func() { Status: status.NewNotFound(ctx, "file not found"), }, nil) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return(&providerv1beta1.StatResponse{Status: status.NewOK(ctx)}, nil) + response, err := fc.Lock(ctx, "abcdef123", "112233") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(404)) Expect(response.Headers).To(BeNil()) }) @@ -430,8 +485,11 @@ var _ = Describe("FileConnector", func() { Status: status.NewInsufficientStorage(ctx, nil, "file too big"), }, nil) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return(&providerv1beta1.StatResponse{Status: status.NewOK(ctx)}, nil) + response, err := fc.Lock(ctx, "abcdef123", "112233") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(500)) Expect(response.Headers).To(BeNil()) }) @@ -441,7 +499,8 @@ var _ = Describe("FileConnector", func() { Describe("RefreshLock", func() { It("No valid context", func() { ctx := context.Background() - response, err := fc.RefreshLock(ctx, "newLock") + + response, err := fc.RefreshLock(ctx, "") Expect(err).To(HaveOccurred()) Expect(response).To(BeNil()) }) @@ -450,7 +509,7 @@ var _ = Describe("FileConnector", func() { ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) response, err := fc.RefreshLock(ctx, "") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(400)) Expect(response.Headers).To(BeNil()) }) @@ -476,10 +535,19 @@ var _ = Describe("FileConnector", func() { Status: status.NewOK(ctx), }, nil) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return(&providerv1beta1.StatResponse{ + Status: status.NewOK(ctx), + Info: &providerv1beta1.ResourceInfo{ + Mtime: &typesv1beta1.Timestamp{Seconds: uint64(12345), Nanos: uint32(6789)}, + }, + }, nil) + response, err := fc.RefreshLock(ctx, "abcdef123") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(200)) - Expect(response.Headers).To(BeNil()) + Expect(response.Headers).To(HaveLen(1)) + Expect(response.Headers[connector.HeaderWopiVersion]).To(Equal("v123456789")) }) It("Refresh lock file not found", func() { @@ -489,8 +557,11 @@ var _ = Describe("FileConnector", func() { Status: status.NewNotFound(ctx, "file not found"), }, nil) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return(&providerv1beta1.StatResponse{Status: status.NewOK(ctx)}, nil) + response, err := fc.RefreshLock(ctx, "abcdef123") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(404)) Expect(response.Headers).To(BeNil()) }) @@ -507,6 +578,9 @@ var _ = Describe("FileConnector", func() { Status: status.NewConflict(ctx, nil, "lock mismatch"), }, targetErr) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return(&providerv1beta1.StatResponse{Status: status.NewOK(ctx)}, nil) + response, err := fc.RefreshLock(ctx, "abcdef123") Expect(err).To(HaveOccurred()) Expect(err).To(Equal(targetErr)) @@ -524,8 +598,11 @@ var _ = Describe("FileConnector", func() { Status: status.NewInternal(ctx, "lock mismatch"), }, nil) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return(&providerv1beta1.StatResponse{Status: status.NewOK(ctx)}, nil) + response, err := fc.RefreshLock(ctx, "abcdef123") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(500)) Expect(response.Headers).To(BeNil()) }) @@ -541,8 +618,11 @@ var _ = Describe("FileConnector", func() { Status: status.NewOK(ctx), }, nil) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return(&providerv1beta1.StatResponse{Status: status.NewOK(ctx)}, nil) + response, err := fc.RefreshLock(ctx, "abcdef123") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(409)) Expect(response.Headers[connector.HeaderWopiLock]).To(Equal("")) }) @@ -562,8 +642,11 @@ var _ = Describe("FileConnector", func() { }, }, nil) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return(&providerv1beta1.StatResponse{Status: status.NewOK(ctx)}, nil) + response, err := fc.RefreshLock(ctx, "abcdef123") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(409)) Expect(response.Headers[connector.HeaderWopiLock]).To(Equal("zzz999")) }) @@ -575,8 +658,11 @@ var _ = Describe("FileConnector", func() { Status: status.NewInsufficientStorage(ctx, nil, "file too big"), }, nil) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return(&providerv1beta1.StatResponse{Status: status.NewOK(ctx)}, nil) + response, err := fc.RefreshLock(ctx, "abcdef123") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(500)) Expect(response.Headers).To(BeNil()) }) @@ -585,7 +671,8 @@ var _ = Describe("FileConnector", func() { Describe("Unlock", func() { It("No valid context", func() { ctx := context.Background() - response, err := fc.UnLock(ctx, "newLock") + + response, err := fc.UnLock(ctx, "") Expect(err).To(HaveOccurred()) Expect(response).To(BeNil()) }) @@ -594,7 +681,7 @@ var _ = Describe("FileConnector", func() { ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) response, err := fc.UnLock(ctx, "") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(400)) Expect(response.Headers).To(BeNil()) }) @@ -620,10 +707,19 @@ var _ = Describe("FileConnector", func() { Status: status.NewOK(ctx), }, nil) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return(&providerv1beta1.StatResponse{ + Status: status.NewOK(ctx), + Info: &providerv1beta1.ResourceInfo{ + Mtime: &typesv1beta1.Timestamp{Seconds: uint64(12345), Nanos: uint32(6789)}, + }, + }, nil) + response, err := fc.UnLock(ctx, "abcdef123") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(200)) - Expect(response.Headers).To(BeNil()) + Expect(response.Headers).To(HaveLen(1)) + Expect(response.Headers[connector.HeaderWopiVersion]).To(Equal("v123456789")) }) It("Unlock file isn't locked", func() { @@ -633,8 +729,16 @@ var _ = Describe("FileConnector", func() { Status: status.NewConflict(ctx, nil, "lock mismatch"), }, nil) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return(&providerv1beta1.StatResponse{ + Status: status.NewOK(ctx), + Info: &providerv1beta1.ResourceInfo{ + Mtime: &typesv1beta1.Timestamp{Seconds: uint64(12345), Nanos: uint32(6789)}, + }, + }, nil) + response, err := fc.UnLock(ctx, "abcdef123") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(409)) Expect(response.Headers[connector.HeaderWopiLock]).To(Equal("")) }) @@ -651,6 +755,9 @@ var _ = Describe("FileConnector", func() { Status: status.NewInternal(ctx, "something failed"), }, targetErr) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return(&providerv1beta1.StatResponse{Status: status.NewOK(ctx)}, nil) + response, err := fc.UnLock(ctx, "abcdef123") Expect(err).To(HaveOccurred()) Expect(err).To(Equal(targetErr)) @@ -668,8 +775,11 @@ var _ = Describe("FileConnector", func() { Status: status.NewInternal(ctx, "something failed"), }, nil) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return(&providerv1beta1.StatResponse{Status: status.NewOK(ctx)}, nil) + response, err := fc.UnLock(ctx, "abcdef123") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(500)) Expect(response.Headers).To(BeNil()) }) @@ -685,8 +795,11 @@ var _ = Describe("FileConnector", func() { Status: status.NewOK(ctx), }, nil) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return(&providerv1beta1.StatResponse{Status: status.NewOK(ctx)}, nil) + response, err := fc.UnLock(ctx, "abcdef123") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(409)) Expect(response.Headers[connector.HeaderWopiLock]).To(Equal("")) }) @@ -706,8 +819,11 @@ var _ = Describe("FileConnector", func() { }, }, nil) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return(&providerv1beta1.StatResponse{Status: status.NewOK(ctx)}, nil) + response, err := fc.UnLock(ctx, "abcdef123") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(409)) Expect(response.Headers[connector.HeaderWopiLock]).To(Equal("zzz999")) }) @@ -719,8 +835,11 @@ var _ = Describe("FileConnector", func() { Status: status.NewInsufficientStorage(ctx, nil, "file too big"), }, nil) + gatewayClient.EXPECT().Stat(mock.Anything, mock.Anything). + Return(&providerv1beta1.StatResponse{Status: status.NewOK(ctx)}, nil) + response, err := fc.UnLock(ctx, "abcdef123") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(500)) Expect(response.Headers).To(BeNil()) }) @@ -759,7 +878,7 @@ var _ = Describe("FileConnector", func() { stream := strings.NewReader("This is the content of a file") response, err := fc.PutRelativeFileSuggested(ctx, ccs, stream, int64(stream.Len()), "newFile.txt") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(500)) Expect(response.Headers).To(BeNil()) Expect(response.Body).To(BeNil()) @@ -813,7 +932,7 @@ var _ = Describe("FileConnector", func() { }, nil) response, err := fc.PutRelativeFileSuggested(ctx, ccs, stream, int64(stream.Len()), "newDocument.docx") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(200)) Expect(response.Headers).To(BeNil()) rBody := response.Body.(map[string]interface{}) @@ -869,7 +988,7 @@ var _ = Describe("FileConnector", func() { }, nil) response, err := fc.PutRelativeFileSuggested(ctx, ccs, stream, int64(stream.Len()), ".pdf") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(200)) Expect(response.Headers).To(BeNil()) rBody := response.Body.(map[string]interface{}) @@ -938,7 +1057,7 @@ var _ = Describe("FileConnector", func() { }, nil) response, err := fc.PutRelativeFileSuggested(ctx, ccs, stream, int64(stream.Len()), ".pdf") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(200)) Expect(response.Headers).To(BeNil()) rBody := response.Body.(map[string]interface{}) @@ -972,7 +1091,7 @@ var _ = Describe("FileConnector", func() { ccs.On("PutFile", mock.Anything, stream, int64(stream.Len()), "").Times(1).Return(connector.NewResponse(500), nil) response, err := fc.PutRelativeFileSuggested(ctx, ccs, stream, int64(stream.Len()), ".pdf") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(500)) Expect(response.Headers).To(BeNil()) Expect(response.Body).To(BeNil()) @@ -1012,7 +1131,7 @@ var _ = Describe("FileConnector", func() { stream := strings.NewReader("This is the content of a file") response, err := fc.PutRelativeFileRelative(ctx, ccs, stream, int64(stream.Len()), "newFile.txt") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(500)) Expect(response.Headers).To(BeNil()) Expect(response.Body).To(BeNil()) @@ -1065,7 +1184,7 @@ var _ = Describe("FileConnector", func() { }, nil) response, err := fc.PutRelativeFileRelative(ctx, ccs, stream, int64(stream.Len()), "newDocument.docx") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(200)) Expect(response.Headers).To(BeNil()) rBody := response.Body.(map[string]interface{}) @@ -1124,7 +1243,7 @@ var _ = Describe("FileConnector", func() { }, nil) response, err := fc.PutRelativeFileRelative(ctx, ccs, stream, int64(stream.Len()), "convFile.pdf") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(409)) Expect(response.Headers[connector.HeaderWopiLock]).To(Equal("zzz999")) Expect(response.Headers[connector.HeaderWopiValidRT]).To(MatchRegexp(`[a-zA-Z0-9_-] convFile\.pdf`)) @@ -1159,7 +1278,7 @@ var _ = Describe("FileConnector", func() { ccs.On("PutFile", mock.Anything, stream, int64(stream.Len()), "").Times(1).Return(connector.NewResponse(500), nil) response, err := fc.PutRelativeFileRelative(ctx, ccs, stream, int64(stream.Len()), "convFile.pdf") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(500)) Expect(response.Headers).To(BeNil()) Expect(response.Body).To(BeNil()) @@ -1223,7 +1342,7 @@ var _ = Describe("FileConnector", func() { }, targetErr) response, err := fc.DeleteFile(ctx, "newlock") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(404)) Expect(response.Headers).To(BeNil()) }) @@ -1240,7 +1359,7 @@ var _ = Describe("FileConnector", func() { }, nil) response, err := fc.DeleteFile(ctx, "newlock") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(500)) Expect(response.Headers).To(BeNil()) }) @@ -1261,7 +1380,7 @@ var _ = Describe("FileConnector", func() { }, nil) response, err := fc.DeleteFile(ctx, "newlock") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(409)) Expect(response.Headers[connector.HeaderWopiLock]).To(Equal("zzz999")) }) @@ -1278,7 +1397,7 @@ var _ = Describe("FileConnector", func() { }, nil) response, err := fc.DeleteFile(ctx, "newlock") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(500)) Expect(response.Headers).To(BeNil()) }) @@ -1291,7 +1410,7 @@ var _ = Describe("FileConnector", func() { }, nil) response, err := fc.DeleteFile(ctx, "newlock") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(200)) Expect(response.Headers).To(BeNil()) }) @@ -1327,7 +1446,7 @@ var _ = Describe("FileConnector", func() { }, nil) response, err := fc.RenameFile(ctx, "lockid", "newFile.doc") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(500)) Expect(response.Headers).To(BeNil()) Expect(response.Body).To(BeNil()) @@ -1375,7 +1494,7 @@ var _ = Describe("FileConnector", func() { }, nil) response, err := fc.RenameFile(ctx, "lockid", "newFile.doc") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(500)) Expect(response.Headers).To(BeNil()) Expect(response.Body).To(BeNil()) @@ -1399,7 +1518,7 @@ var _ = Describe("FileConnector", func() { }, nil) response, err := fc.RenameFile(ctx, "lockid", "newFile.doc") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(409)) Expect(response.Headers[connector.HeaderWopiLock]).To(Equal("zzz999")) Expect(response.Body).To(BeNil()) @@ -1442,7 +1561,7 @@ var _ = Describe("FileConnector", func() { }, nil).Once() response, err := fc.RenameFile(ctx, "zzz999", "newFile.doc") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(200)) Expect(response.Headers).To(BeNil()) rBody := response.Body.(map[string]interface{}) @@ -1474,7 +1593,7 @@ var _ = Describe("FileConnector", func() { }, nil).Once() response, err := fc.RenameFile(ctx, "zzz999", "newFile.doc") - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(200)) Expect(response.Headers).To(BeNil()) rBody := response.Body.(map[string]interface{}) @@ -1512,13 +1631,21 @@ var _ = Describe("FileConnector", func() { }, nil) response, err := fc.CheckFileInfo(ctx) - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(500)) Expect(response.Body).To(BeNil()) }) It("Stat success", func() { ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + u := &userv1beta1.User{ + Id: &userv1beta1.UserId{ + Idp: "customIdp", + OpaqueId: "admin", + }, + DisplayName: "Pet Shaft", + } + ctx = ctxpkg.ContextSetUser(ctx, u) gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{ Status: status.NewOK(ctx), @@ -1533,17 +1660,25 @@ var _ = Describe("FileConnector", func() { Seconds: uint64(16273849), }, Path: "/path/to/test.txt", - // Other properties aren't used for now. + Id: &providerv1beta1.ResourceId{ + StorageId: "storageid", + OpaqueId: "opaqueid", + SpaceId: "spaceid", + }, }, }, nil) expectedFileInfo := &fileinfo.Microsoft{ - OwnerID: "61616262636340637573746f6d496470", // hex of aabbcc@customIdp - Size: int64(998877), - Version: "16273849.0", - BaseFileName: "test.txt", - BreadcrumbDocName: "test.txt", - UserCanNotWriteRelative: false, + OwnerID: "61616262636340637573746f6d496470", // hex of aabbcc@customIdp + Size: int64(998877), + Version: "v162738490", + BaseFileName: "test.txt", + BreadcrumbDocName: "test.txt", + BreadcrumbFolderName: "/path/to", + BreadcrumbFolderURL: "https://ocis.example.prv/f/storageid$spaceid%21opaqueid", + UserCanNotWriteRelative: false, + //HostViewURL: "http://test.ex.prv/view", + //HostEditURL: "http://test.ex.prv/edit", SupportsExtendedLockLength: true, SupportsGetLock: true, SupportsLocks: true, @@ -1552,19 +1687,20 @@ var _ = Describe("FileConnector", func() { SupportsRename: true, UserCanWrite: true, UserCanRename: true, - UserID: "6f7061717565496440696e6d656d6f7279", // hex of opaqueId@inmemory + UserID: "61646d696e40637573746f6d496470", // hex of admin@customIdp UserFriendlyName: "Pet Shaft", } response, err := fc.CheckFileInfo(ctx) - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(200)) Expect(response.Body.(*fileinfo.Microsoft)).To(Equal(expectedFileInfo)) }) It("Stat success guests", func() { // add user's opaque to include public-share-role - wopiCtx.User.Opaque = &typesv1beta1.Opaque{ + u := &userv1beta1.User{} + u.Opaque = &typesv1beta1.Opaque{ Map: map[string]*typesv1beta1.OpaqueEntry{ "public-share-role": &typesv1beta1.OpaqueEntry{ Decoder: "plain", @@ -1576,6 +1712,7 @@ var _ = Describe("FileConnector", func() { wopiCtx.ViewMode = appproviderv1beta1.ViewMode_VIEW_MODE_VIEW_ONLY ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + ctx = ctxpkg.ContextSetUser(ctx, u) gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{ Status: status.NewOK(ctx), @@ -1590,6 +1727,11 @@ var _ = Describe("FileConnector", func() { Seconds: uint64(16273849), }, Path: "/path/to/test.txt", + Id: &providerv1beta1.ResourceId{ + StorageId: "storageid", + OpaqueId: "opaqueid", + SpaceId: "spaceid", + }, // Other properties aren't used for now. }, }, nil) @@ -1625,7 +1767,7 @@ var _ = Describe("FileConnector", func() { response.Body.(*fileinfo.Collabora).UserID = "guest-zzz000" response.Body.(*fileinfo.Collabora).UserFriendlyName = "guest zzz000" - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(200)) Expect(response.Body.(*fileinfo.Collabora)).To(Equal(expectedFileInfo)) }) @@ -1635,6 +1777,16 @@ var _ = Describe("FileConnector", func() { wopiCtx.ViewMode = appproviderv1beta1.ViewMode_VIEW_MODE_VIEW_ONLY ctx := middleware.WopiContextToCtx(context.Background(), wopiCtx) + u := &userv1beta1.User{ + Id: &userv1beta1.UserId{ + Idp: "example.com", + OpaqueId: "aabbcc", + Type: userv1beta1.UserType_USER_TYPE_PRIMARY, + }, + DisplayName: "Pet Shaft", + Mail: "shaft@example.com", + } + ctx = ctxpkg.ContextSetUser(ctx, u) gatewayClient.On("Stat", mock.Anything, mock.Anything).Times(1).Return(&providerv1beta1.StatResponse{ Status: status.NewOK(ctx), @@ -1649,7 +1801,11 @@ var _ = Describe("FileConnector", func() { Seconds: uint64(16273849), }, Path: "/path/to/test.txt", - // Other properties aren't used for now. + Id: &providerv1beta1.ResourceId{ + StorageId: "storageid", + OpaqueId: "opaqueid", + SpaceId: "spaceid", + }, }, }, nil) @@ -1664,7 +1820,7 @@ var _ = Describe("FileConnector", func() { DisableExport: true, DisableCopy: true, DisablePrint: true, - UserID: hex.EncodeToString([]byte("opaqueId@inmemory")), + UserID: hex.EncodeToString([]byte("aabbcc@example.com")), UserFriendlyName: "Pet Shaft", EnableOwnerTermination: true, WatermarkText: "Pet Shaft shaft@example.com", @@ -1677,7 +1833,7 @@ var _ = Describe("FileConnector", func() { response, err := fc.CheckFileInfo(ctx) - Expect(err).To(Succeed()) + Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(200)) Expect(response.Body.(*fileinfo.Collabora)).To(Equal(expectedFileInfo)) }) diff --git a/services/collaboration/pkg/connector/fileinfo/microsoft.go b/services/collaboration/pkg/connector/fileinfo/microsoft.go index 66be0c0f09..6f30d2d8fc 100644 --- a/services/collaboration/pkg/connector/fileinfo/microsoft.go +++ b/services/collaboration/pkg/connector/fileinfo/microsoft.go @@ -60,7 +60,7 @@ type Microsoft struct { // 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"` + LicenseCheckForEditIsEnabled bool `json:"LicenseCheckForEditIsEnabled"` // 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. diff --git a/services/collaboration/pkg/connector/httpadapter.go b/services/collaboration/pkg/connector/httpadapter.go index 20c820dc0d..04046c5b63 100644 --- a/services/collaboration/pkg/connector/httpadapter.go +++ b/services/collaboration/pkg/connector/httpadapter.go @@ -5,7 +5,6 @@ import ( "errors" "net/http" "strconv" - "strings" gatewayv1beta1 "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" @@ -15,16 +14,18 @@ import ( ) const ( - HeaderWopiLock string = "X-WOPI-Lock" - HeaderWopiOldLock string = "X-WOPI-OldLock" - HeaderWopiST string = "X-WOPI-SuggestedTarget" - HeaderWopiRT string = "X-WOPI-RelativeTarget" - HeaderWopiOverwriteRT string = "X-WOPI-OverwriteRelativeTarget" - HeaderWopiSize string = "X-WOPI-Size" - HeaderWopiValidRT string = "X-WOPI-ValidRelativeTarget" - HeaderWopiRequestedName string = "X-WOPI-RequestedName" - HeaderContentLength string = "Content-Length" - HeaderContentType string = "Content-Type" + HeaderWopiLock string = "X-WOPI-Lock" + HeaderWopiOldLock string = "X-WOPI-OldLock" + HeaderWopiLockFailureReason string = "X-WOPI-LockFailureReason" + HeaderWopiST string = "X-WOPI-SuggestedTarget" + HeaderWopiRT string = "X-WOPI-RelativeTarget" + HeaderWopiOverwriteRT string = "X-WOPI-OverwriteRelativeTarget" + HeaderWopiSize string = "X-WOPI-Size" + HeaderWopiValidRT string = "X-WOPI-ValidRelativeTarget" + HeaderWopiRequestedName string = "X-WOPI-RequestedName" + HeaderContentLength string = "Content-Length" + HeaderContentType string = "Content-Type" + HeaderWopiVersion string = "X-WOPI-ItemVersion" ) // HttpAdapter will adapt the responses from the connector to HTTP. @@ -51,9 +52,6 @@ func NewHttpAdapter(gwc gatewayv1beta1.GatewayAPIClient, cfg *config.Config) *Ht } httpAdapter.locks = &locks.NoopLockParser{} - if strings.ToLower(cfg.App.Name) == "microsoftofficeonline" { - httpAdapter.locks = &locks.LegacyLockParser{} - } return httpAdapter } diff --git a/services/collaboration/pkg/connector/httpadapter_test.go b/services/collaboration/pkg/connector/httpadapter_test.go index 64de4409b1..4d4c9db05b 100644 --- a/services/collaboration/pkg/connector/httpadapter_test.go +++ b/services/collaboration/pkg/connector/httpadapter_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "strings" + typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/owncloud/ocis/v2/services/collaboration/mocks" @@ -133,12 +134,13 @@ var _ = Describe("HttpAdapter", func() { w := httptest.NewRecorder() - fc.On("Lock", mock.Anything, "abc123", "").Times(1).Return(connector.NewResponseWithLock(409, "zzz111"), nil) + fc.On("Lock", mock.Anything, "abc123", "").Times(1).Return(connector.NewResponseLockConflict("zzz111", "Lock Conflict"), nil) httpAdapter.Lock(w, req) resp := w.Result() Expect(resp.StatusCode).To(Equal(409)) Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("zzz111")) + Expect(resp.Header.Get(connector.HeaderWopiLockFailureReason)).To(Equal("Lock Conflict")) }) It("Success", func() { @@ -148,11 +150,18 @@ var _ = Describe("HttpAdapter", func() { w := httptest.NewRecorder() - fc.On("Lock", mock.Anything, "abc123", "").Times(1).Return(connector.NewResponse(200), nil) + fc.On("Lock", mock.Anything, "abc123", "").Times(1).Return( + connector.NewResponseWithVersionAndLock( + 200, + &typesv1beta1.Timestamp{Seconds: uint64(1234), Nanos: uint32(567)}, + "abc123", + ), nil) httpAdapter.Lock(w, req) resp := w.Result() Expect(resp.StatusCode).To(Equal(200)) + Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("abc123")) + Expect(resp.Header.Get(connector.HeaderWopiVersion)).To(Equal("v1234567")) }) }) @@ -195,12 +204,13 @@ var _ = Describe("HttpAdapter", func() { w := httptest.NewRecorder() - fc.On("Lock", mock.Anything, "abc123", "qwerty").Times(1).Return(connector.NewResponseWithLock(409, "zzz111"), nil) + fc.On("Lock", mock.Anything, "abc123", "qwerty").Times(1).Return(connector.NewResponseLockConflict("zzz111", "Lock Conflict"), nil) httpAdapter.Lock(w, req) resp := w.Result() Expect(resp.StatusCode).To(Equal(409)) Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("zzz111")) + Expect(resp.Header.Get(connector.HeaderWopiLockFailureReason)).To(Equal("Lock Conflict")) }) It("Success", func() { @@ -211,11 +221,18 @@ var _ = Describe("HttpAdapter", func() { w := httptest.NewRecorder() - fc.On("Lock", mock.Anything, "abc123", "qwerty").Times(1).Return(connector.NewResponse(200), nil) + fc.On("Lock", mock.Anything, "abc123", "qwerty").Times(1).Return( + connector.NewResponseWithVersionAndLock( + 200, + &typesv1beta1.Timestamp{Seconds: uint64(1234), Nanos: uint32(567)}, + "abc123", + ), nil) httpAdapter.Lock(w, req) resp := w.Result() Expect(resp.StatusCode).To(Equal(200)) + Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("abc123")) + Expect(resp.Header.Get(connector.HeaderWopiVersion)).To(Equal("v1234567")) }) }) }) @@ -256,12 +273,13 @@ var _ = Describe("HttpAdapter", func() { w := httptest.NewRecorder() - fc.On("RefreshLock", mock.Anything, "abc123").Times(1).Return(connector.NewResponseWithLock(409, "zzz111"), nil) + fc.On("RefreshLock", mock.Anything, "abc123").Times(1).Return(connector.NewResponseLockConflict("zzz111", "Lock Conflict"), nil) httpAdapter.RefreshLock(w, req) resp := w.Result() Expect(resp.StatusCode).To(Equal(409)) Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("zzz111")) + Expect(resp.Header.Get(connector.HeaderWopiLockFailureReason)).To(Equal("Lock Conflict")) }) It("Success", func() { @@ -271,11 +289,18 @@ var _ = Describe("HttpAdapter", func() { w := httptest.NewRecorder() - fc.On("RefreshLock", mock.Anything, "abc123").Times(1).Return(connector.NewResponse(200), nil) + fc.On("RefreshLock", mock.Anything, "abc123").Times(1).Return( + connector.NewResponseWithVersionAndLock( + 200, + &typesv1beta1.Timestamp{Seconds: uint64(1234), Nanos: uint32(5678)}, + "abc123", + ), nil) httpAdapter.RefreshLock(w, req) resp := w.Result() Expect(resp.StatusCode).To(Equal(200)) + Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("abc123")) + Expect(resp.Header.Get(connector.HeaderWopiVersion)).To(Equal("v12345678")) }) }) @@ -315,12 +340,13 @@ var _ = Describe("HttpAdapter", func() { w := httptest.NewRecorder() - fc.On("UnLock", mock.Anything, "abc123").Times(1).Return(connector.NewResponseWithLock(409, "zzz111"), nil) + fc.On("UnLock", mock.Anything, "abc123").Times(1).Return(connector.NewResponseLockConflict("zzz111", "Lock Conflict"), nil) httpAdapter.UnLock(w, req) resp := w.Result() Expect(resp.StatusCode).To(Equal(409)) Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("zzz111")) + Expect(resp.Header.Get(connector.HeaderWopiLockFailureReason)).To(Equal("Lock Conflict")) }) It("Success", func() { @@ -330,11 +356,15 @@ var _ = Describe("HttpAdapter", func() { w := httptest.NewRecorder() - fc.On("UnLock", mock.Anything, "abc123").Times(1).Return(connector.NewResponse(200), nil) + fc.On("UnLock", mock.Anything, "abc123").Times(1).Return( + connector.NewResponseWithVersion(200, + &typesv1beta1.Timestamp{Seconds: uint64(1234), Nanos: uint32(567)}, + ), nil) httpAdapter.UnLock(w, req) resp := w.Result() Expect(resp.StatusCode).To(Equal(200)) + Expect(resp.Header.Get(connector.HeaderWopiVersion)).To(Equal("v1234567")) }) }) @@ -458,12 +488,14 @@ var _ = Describe("HttpAdapter", func() { w := httptest.NewRecorder() - cc.On("PutFile", mock.Anything, mock.Anything, int64(len(contentBody)), "abc123").Times(1).Return(connector.NewResponseWithLock(409, "zzz111"), nil) + cc.On("PutFile", mock.Anything, mock.Anything, int64(len(contentBody)), "abc123").Times(1).Return( + connector.NewResponseLockConflict("zzz111", "Lock Conflict"), nil) httpAdapter.PutFile(w, req) resp := w.Result() Expect(resp.StatusCode).To(Equal(409)) Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("zzz111")) + Expect(resp.Header.Get(connector.HeaderWopiLockFailureReason)).To(Equal("Lock Conflict")) }) It("Success", func() { @@ -473,11 +505,18 @@ var _ = Describe("HttpAdapter", func() { w := httptest.NewRecorder() - cc.On("PutFile", mock.Anything, mock.Anything, int64(len(contentBody)), "abc123").Times(1).Return(connector.NewResponse(200), nil) + cc.On("PutFile", mock.Anything, mock.Anything, int64(len(contentBody)), "abc123").Times(1).Return( + connector.NewResponseWithVersionAndLock( + 200, + &typesv1beta1.Timestamp{Seconds: uint64(1234), Nanos: uint32(567)}, + "abc123", + ), nil) httpAdapter.PutFile(w, req) resp := w.Result() Expect(resp.StatusCode).To(Equal(200)) + Expect(resp.Header.Get(connector.HeaderWopiLock)).To(Equal("abc123")) + Expect(resp.Header.Get(connector.HeaderWopiVersion)).To(Equal("v1234567")) }) }) }) diff --git a/services/collaboration/pkg/helpers/path.go b/services/collaboration/pkg/helpers/path.go new file mode 100644 index 0000000000..290b85b3bc --- /dev/null +++ b/services/collaboration/pkg/helpers/path.go @@ -0,0 +1,41 @@ +package helpers + +import ( + "strings" + + "github.com/golang-jwt/jwt/v5" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" +) + +// ParseWopiFileID extracts the file id from a wopi path +// +// If the file id is a jwt, it will be decoded and the file id will be extracted from the jwt claims. +// If the file id is not a jwt, it will be returned as is. +func ParseWopiFileID(cfg *config.Config, path string) string { + s := strings.Split(path, "/") + if len(s) < 4 || (s[1] != "wopi" && s[2] != "files") { + return path + } + // check if the fileid is a jwt + if strings.Contains(s[3], ".") { + token, err := jwt.Parse(s[3], func(_ *jwt.Token) (interface{}, error) { + return []byte(cfg.Wopi.ProxySecret), nil + }) + if err != nil { + return s[3] + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return s[3] + } + + f, ok := claims["f"].(string) + if !ok { + return s[3] + } + return f + } + // fileid is not a jwt + return s[3] +} diff --git a/services/collaboration/pkg/helpers/version.go b/services/collaboration/pkg/helpers/version.go new file mode 100644 index 0000000000..a9196da061 --- /dev/null +++ b/services/collaboration/pkg/helpers/version.go @@ -0,0 +1,20 @@ +package helpers + +import ( + "net/http" + "strconv" + + typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" +) + +// SetVersionHeader sets a WOPI version header on the response writer +func SetVersionHeader(w http.ResponseWriter, t *typesv1beta1.Timestamp) { + // non-canonical headers can only be set directly on the header map + w.Header().Set("X-WOPI-ItemVersion", GetVersion(t)) +} + +// GetVersion returns a string representation of the timestamp +func GetVersion(timestamp *typesv1beta1.Timestamp) string { + return "v" + strconv.FormatUint(timestamp.GetSeconds(), 10) + + strconv.FormatUint(uint64(timestamp.GetNanos()), 10) +} diff --git a/services/collaboration/pkg/locks/parser.go b/services/collaboration/pkg/locks/parser.go index 022e59adff..e1606e2066 100644 --- a/services/collaboration/pkg/locks/parser.go +++ b/services/collaboration/pkg/locks/parser.go @@ -7,7 +7,6 @@ package locks import ( "encoding/json" - "strings" ) // LockParser is the interface that wraps the ParseLock method @@ -54,7 +53,7 @@ func (*NoopLockParser) ParseLock(id string) string { // If the JSON string is not in the expected format, the original lockID will be returned. func (*LegacyLockParser) ParseLock(id string) string { var decodedValues map[string]interface{} - err := json.NewDecoder(strings.NewReader(id)).Decode(&decodedValues) + err := json.Unmarshal([]byte(id), &decodedValues) if err != nil || len(decodedValues) == 0 { return id } diff --git a/services/collaboration/pkg/middleware/middleware_suite_test.go b/services/collaboration/pkg/middleware/middleware_suite_test.go new file mode 100644 index 0000000000..09e4e11360 --- /dev/null +++ b/services/collaboration/pkg/middleware/middleware_suite_test.go @@ -0,0 +1,13 @@ +package middleware_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestMiddleware(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Middleware Suite") +} diff --git a/services/collaboration/pkg/middleware/tracing.go b/services/collaboration/pkg/middleware/tracing.go index 816f81e129..4b78352370 100644 --- a/services/collaboration/pkg/middleware/tracing.go +++ b/services/collaboration/pkg/middleware/tracing.go @@ -3,6 +3,7 @@ package middleware import ( "net/http" + ctxpkg "github.com/cs3org/reva/v2/pkg/ctx" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" ) @@ -27,7 +28,6 @@ func CollaborationTracingMiddleware(next http.Handler) http.Handler { wopiMethod := r.Header.Get("X-WOPI-Override") wopiFile := wopiContext.FileReference - wopiUser := wopiContext.User.GetId() attrs := []attribute.KeyValue{ attribute.String("ocis.wopi.sessionid", r.Header.Get("X-WOPI-SessionId")), @@ -36,9 +36,14 @@ func CollaborationTracingMiddleware(next http.Handler) http.Handler { attribute.String("ocis.wopi.resource.id.opaque", wopiFile.GetResourceId().GetOpaqueId()), attribute.String("ocis.wopi.resource.id.space", wopiFile.GetResourceId().GetSpaceId()), attribute.String("ocis.wopi.resource.path", wopiFile.GetPath()), - attribute.String("ocis.wopi.user.idp", wopiUser.GetIdp()), - attribute.String("ocis.wopi.user.opaque", wopiUser.GetOpaqueId()), - attribute.String("ocis.wopi.user.type", wopiUser.GetType().String()), + } + + if wopiUser, ok := ctxpkg.ContextGetUser(r.Context()); ok { + attrs = append(attrs, []attribute.KeyValue{ + attribute.String("ocis.wopi.user.idp", wopiUser.GetId().GetIdp()), + attribute.String("ocis.wopi.user.opaque", wopiUser.GetId().GetOpaqueId()), + attribute.String("ocis.wopi.user.type", wopiUser.GetId().GetType().String()), + }...) } span.SetAttributes(attrs...) diff --git a/services/collaboration/pkg/middleware/wopicontext.go b/services/collaboration/pkg/middleware/wopicontext.go index 22dcdf0089..b02e1c7c35 100644 --- a/services/collaboration/pkg/middleware/wopicontext.go +++ b/services/collaboration/pkg/middleware/wopicontext.go @@ -5,12 +5,11 @@ import ( "errors" "fmt" "net/http" - "regexp" 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" + rjwt "github.com/cs3org/reva/v2/pkg/token/manager/jwt" "github.com/golang-jwt/jwt/v5" "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" "github.com/owncloud/ocis/v2/services/collaboration/pkg/helpers" @@ -29,7 +28,6 @@ type WopiContext struct { AccessToken string ViewOnlyToken string FileReference *providerv1beta1.Reference - User *userv1beta1.User ViewMode appproviderv1beta1.ViewMode } @@ -45,8 +43,6 @@ type WopiContext struct { // * A contextual zerologger containing information about the request // and the WopiContext func WopiContextAuthMiddleware(cfg *config.Config, next http.Handler) http.Handler { - // compile a regexp here to extract the fileid from the URL - fileIDregexp := regexp.MustCompile(`^/wopi/files/([0-9a-f]{64})(/.*)?$`) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { accessToken := r.URL.Query().Get("access_token") if accessToken == "" { @@ -76,11 +72,25 @@ func WopiContextAuthMiddleware(cfg *config.Config, next http.Handler) http.Handl http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) return } + tokenManager, err := rjwt.New(map[string]interface{}{ + "secret": cfg.TokenManager.JWTSecret, + "expires": int64(24 * 60 * 60), + }) + if err != nil { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + user, _, err := tokenManager.DismantleToken(ctx, wopiContextAccessToken) + 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) + ctx = ctxpkg.ContextSetUser(ctx, user) // 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 @@ -94,13 +104,13 @@ func WopiContextAuthMiddleware(cfg *config.Config, next http.Handler) http.Handl Str("WopiStamp", r.Header.Get("X-WOPI-TimeStamp")). Str("FileReference", claims.WopiContext.FileReference.String()). Str("ViewMode", claims.WopiContext.ViewMode.String()). - Str("Requester", claims.WopiContext.User.GetId().String()). + Str("Requester", user.GetId().String()). Logger() ctx = wopiLogger.WithContext(ctx) hashedRef := helpers.HashResourceId(claims.WopiContext.FileReference.GetResourceId()) - matches := fileIDregexp.FindStringSubmatch(r.URL.Path) - if len(matches) < 2 || matches[1] != hashedRef { + fileID := helpers.ParseWopiFileID(cfg, r.URL.Path) + if fileID != hashedRef { wopiLogger.Error().Msg("file reference in the URL doesn't match the one inside the access token") http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) return diff --git a/services/collaboration/pkg/middleware/wopicontext_test.go b/services/collaboration/pkg/middleware/wopicontext_test.go new file mode 100644 index 0000000000..900cf5c567 --- /dev/null +++ b/services/collaboration/pkg/middleware/wopicontext_test.go @@ -0,0 +1,226 @@ +package middleware_test + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "path" + "strconv" + + appprovider "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" + "github.com/cs3org/reva/v2/pkg/token" + rjwt "github.com/cs3org/reva/v2/pkg/token/manager/jwt" + . "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/helpers" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/middleware" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/wopisrc" +) + +var _ = Describe("Wopi Context Middleware", func() { + var ( + cfg *config.Config + ctx context.Context + mw http.Handler + rid *providerv1beta1.ResourceId + tknMngr token.Manager + user *userv1beta1.User + src *url.URL + ) + + BeforeEach(func() { + var err error + cfg = &config.Config{ + TokenManager: &config.TokenManager{JWTSecret: "jwtSecret"}, + Wopi: config.Wopi{ + Secret: "wopiSecret", + WopiSrc: "https://localhost:9300", + }, + } + + ctx = context.Background() + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mw = middleware.WopiContextAuthMiddleware(cfg, next) + + tknMngr, err = rjwt.New(map[string]interface{}{ + "secret": cfg.TokenManager.JWTSecret, + "expires": int64(24 * 60 * 60), + }) + Expect(err).ToNot(HaveOccurred()) + + user = &userv1beta1.User{ + Id: &userv1beta1.UserId{ + Idp: "example.com", + OpaqueId: "12345", + Type: userv1beta1.UserType_USER_TYPE_PRIMARY, + }, + Username: "admin", + Mail: "admin@example.com", + } + + rid = &providerv1beta1.ResourceId{ + StorageId: "storageID", + OpaqueId: "opaqueID", + SpaceId: "spaceID", + } + + src, err = url.Parse(cfg.Wopi.WopiSrc) + src.Path = path.Join("wopi", "files", helpers.HashResourceId(rid)) + Expect(err).ToNot(HaveOccurred()) + }) + It("Should not authorize with empty access token", func() { + req := httptest.NewRequest("GET", src.String(), nil).WithContext(ctx) + resp := httptest.NewRecorder() + + mw.ServeHTTP(resp, req) + Expect(resp.Code).To(Equal(http.StatusUnauthorized)) + }) + It("Should not authorize with malformed access token", func() { + req := httptest.NewRequest("GET", src.String(), nil).WithContext(ctx) + q := req.URL.Query() + q.Add("access_token", "token") + req.URL.RawQuery = q.Encode() + + resp := httptest.NewRecorder() + + mw.ServeHTTP(resp, req) + Expect(resp.Code).To(Equal(http.StatusUnauthorized)) + }) + It("Should not authorize when fileID mismatches", func() { + req := httptest.NewRequest("GET", src.String(), nil).WithContext(ctx) + // create request with different fileID in the wopi context + token, err := tknMngr.MintToken(ctx, user, nil) + Expect(err).ToNot(HaveOccurred()) + wopiContext := middleware.WopiContext{ + AccessToken: token, + ViewMode: appprovider.ViewMode_VIEW_MODE_READ_WRITE, + FileReference: &providerv1beta1.Reference{ + ResourceId: &providerv1beta1.ResourceId{ + StorageId: "storageID", + OpaqueId: "opaqueID2", + SpaceId: "spaceID", + }, + Path: ".", + }, + } + wopiToken, ttl, err := middleware.GenerateWopiToken(wopiContext, cfg) + q := req.URL.Query() + q.Add("access_token", wopiToken) + q.Add("access_token_ttl", strconv.FormatInt(ttl, 10)) + req.URL.RawQuery = q.Encode() + resp := httptest.NewRecorder() + + mw.ServeHTTP(resp, req) + Expect(resp.Code).To(Equal(http.StatusUnauthorized)) + }) + It("Should not authorize with wrong wopi secret", func() { + src.Path = path.Join("wopi", "files", helpers.HashResourceId(rid)) + req := httptest.NewRequest("GET", src.String(), nil).WithContext(ctx) + token, err := tknMngr.MintToken(ctx, user, nil) + Expect(err).ToNot(HaveOccurred()) + + wopiContext := middleware.WopiContext{ + AccessToken: token, + } + // use wrong wopi secret when generating the wopi token + wopiToken, ttl, err := middleware.GenerateWopiToken(wopiContext, &config.Config{Wopi: config.Wopi{ + Secret: "wrongSecret", + }}) + q := req.URL.Query() + q.Add("access_token", wopiToken) + q.Add("access_token_ttl", strconv.FormatInt(ttl, 10)) + req.URL.RawQuery = q.Encode() + resp := httptest.NewRecorder() + + mw.ServeHTTP(resp, req) + Expect(resp.Code).To(Equal(http.StatusUnauthorized)) + }) + It("Should authorize successful", func() { + req := httptest.NewRequest("GET", src.String(), nil).WithContext(ctx) + token, err := tknMngr.MintToken(ctx, user, nil) + Expect(err).ToNot(HaveOccurred()) + + wopiContext := middleware.WopiContext{ + AccessToken: token, + ViewMode: appprovider.ViewMode_VIEW_MODE_READ_WRITE, + FileReference: &providerv1beta1.Reference{ + ResourceId: rid, + Path: ".", + }, + } + wopiToken, ttl, err := middleware.GenerateWopiToken(wopiContext, cfg) + q := req.URL.Query() + q.Add("access_token", wopiToken) + q.Add("access_token_ttl", strconv.FormatInt(ttl, 10)) + req.URL.RawQuery = q.Encode() + resp := httptest.NewRecorder() + + mw.ServeHTTP(resp, req) + Expect(resp.Code).To(Equal(http.StatusOK)) + }) + It("Should not authorize with proxy when fileID mismatches", func() { + cfg.Wopi.ProxySecret = "proxySecret" + cfg.Wopi.ProxyURL = "https://proxy" + src, err := wopisrc.GenerateWopiSrc(helpers.HashResourceId(rid), cfg) + Expect(err).ToNot(HaveOccurred()) + + req := httptest.NewRequest("GET", src.String(), nil).WithContext(ctx) + token, err := tknMngr.MintToken(ctx, user, nil) + Expect(err).ToNot(HaveOccurred()) + wopiContext := middleware.WopiContext{ + AccessToken: token, + ViewMode: appprovider.ViewMode_VIEW_MODE_READ_WRITE, + FileReference: &providerv1beta1.Reference{ + ResourceId: &providerv1beta1.ResourceId{ + StorageId: "storageID", + OpaqueId: "opaqueID3", + SpaceId: "spaceID", + }, + Path: ".", + }, + } + wopiToken, ttl, err := middleware.GenerateWopiToken(wopiContext, cfg) + q := req.URL.Query() + q.Add("access_token", wopiToken) + q.Add("access_token_ttl", strconv.FormatInt(ttl, 10)) + req.URL.RawQuery = q.Encode() + + resp := httptest.NewRecorder() + mw.ServeHTTP(resp, req) + Expect(resp.Code).To(Equal(http.StatusUnauthorized)) + }) + It("Should authorize successful with proxy", func() { + cfg.Wopi.ProxySecret = "proxySecret" + cfg.Wopi.ProxyURL = "https://proxy" + src, err := wopisrc.GenerateWopiSrc(helpers.HashResourceId(rid), cfg) + Expect(err).ToNot(HaveOccurred()) + + req := httptest.NewRequest("GET", src.String(), nil).WithContext(ctx) + token, err := tknMngr.MintToken(ctx, user, nil) + Expect(err).ToNot(HaveOccurred()) + wopiContext := middleware.WopiContext{ + AccessToken: token, + ViewMode: appprovider.ViewMode_VIEW_MODE_READ_WRITE, + FileReference: &providerv1beta1.Reference{ + ResourceId: rid, + Path: ".", + }, + } + wopiToken, ttl, err := middleware.GenerateWopiToken(wopiContext, cfg) + q := req.URL.Query() + q.Add("access_token", wopiToken) + q.Add("access_token_ttl", strconv.FormatInt(ttl, 10)) + req.URL.RawQuery = q.Encode() + + resp := httptest.NewRecorder() + mw.ServeHTTP(resp, req) + Expect(resp.Code).To(Equal(http.StatusOK)) + }) +}) diff --git a/services/collaboration/pkg/server/http/server.go b/services/collaboration/pkg/server/http/server.go index e00cdb403c..e84fae856f 100644 --- a/services/collaboration/pkg/server/http/server.go +++ b/services/collaboration/pkg/server/http/server.go @@ -121,6 +121,7 @@ func prepareRoutes(r *chi.Mux, options Options) { // authentication and wopi context return colabmiddleware.WopiContextAuthMiddleware(options.Config, h) }, + colabmiddleware.CollaborationTracingMiddleware, ) diff --git a/services/collaboration/pkg/service/grpc/v0/service.go b/services/collaboration/pkg/service/grpc/v0/service.go index 1458a4e27e..c2cb630895 100644 --- a/services/collaboration/pkg/service/grpc/v0/service.go +++ b/services/collaboration/pkg/service/grpc/v0/service.go @@ -15,6 +15,7 @@ import ( providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" "github.com/cs3org/reva/v2/pkg/rgrpc/todo/pool" "github.com/cs3org/reva/v2/pkg/utils" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/wopisrc" "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" @@ -109,7 +110,6 @@ func (s *Service) OpenInApp( AccessToken: req.GetAccessToken(), // it will be encrypted ViewOnlyToken: utils.ReadPlainFromOpaque(req.GetOpaque(), "viewOnlyToken"), FileReference: &providerFileRef, - User: user, ViewMode: req.GetViewMode(), } @@ -201,11 +201,10 @@ func (s *Service) addQueryToURL(baseURL string, req *appproviderv1beta1.OpenInAp // so that all sessions on one file end on the same office server fileRef := helpers.HashResourceId(req.GetResourceInfo().GetId()) - wopiSrcURL, err := url.Parse(s.config.Wopi.WopiSrc) + wopiSrcURL, err := wopisrc.GenerateWopiSrc(fileRef, s.config) if err != nil { return "", err } - wopiSrcURL.Path = path.Join("wopi", "files", fileRef) q := u.Query() q.Add("WOPISrc", wopiSrcURL.String()) @@ -216,6 +215,38 @@ func (s *Service) addQueryToURL(baseURL string, req *appproviderv1beta1.OpenInAp lang := utils.ReadPlainFromOpaque(req.GetOpaque(), "lang") + // @TODO: this is a temporary solution until we figure out how to send these from oc web + switch lang { + case "bg": + lang = "bg-BG" + case "cs": + lang = "cs-CZ" + case "de": + lang = "de-DE" + case "en": + lang = "en-US" + case "es": + lang = "es-ES" + case "fr": + lang = "fr-FR" + case "gl": + lang = "gl-ES" + case "it": + lang = "it-IT" + case "nl": + lang = "nl-NL" + case "ko": + lang = "ko-KR" + case "sq": + lang = "sq-AL" + case "sv": + lang = "sv-SE" + case "tr": + lang = "tr-TR" + case "zh": + lang = "zh-CN" + } + if lang != "" { switch strings.ToLower(s.config.App.Name) { case "collabora": diff --git a/services/collaboration/pkg/service/grpc/v0/service_test.go b/services/collaboration/pkg/service/grpc/v0/service_test.go index 6fd77b1250..40655ac316 100644 --- a/services/collaboration/pkg/service/grpc/v0/service_test.go +++ b/services/collaboration/pkg/service/grpc/v0/service_test.go @@ -187,15 +187,60 @@ var _ = Describe("Discovery", func() { Entry("Microsoft chat no lang", "Microsoft", "", false, "https://test.server.prv/hosting/wopi/word/edit?WOPISrc=https%3A%2F%2Fwopiserver.test.prv%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e"), Entry("Collabora chat no lang", "Collabora", "", false, "https://test.server.prv/hosting/wopi/word/view?WOPISrc=https%3A%2F%2Fwopiserver.test.prv%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e"), Entry("OnlyOffice chat no lang", "OnlyOffice", "", false, "https://test.server.prv/hosting/wopi/word/edit?WOPISrc=https%3A%2F%2Fwopiserver.test.prv%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e"), - Entry("Microsoft chat lang", "Microsoft", "de", false, "https://test.server.prv/hosting/wopi/word/edit?UI_LLCC=de&WOPISrc=https%3A%2F%2Fwopiserver.test.prv%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e"), - Entry("Collabora chat lang", "Collabora", "de", false, "https://test.server.prv/hosting/wopi/word/view?WOPISrc=https%3A%2F%2Fwopiserver.test.prv%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e&lang=de"), - Entry("OnlyOffice chat lang", "OnlyOffice", "de", false, "https://test.server.prv/hosting/wopi/word/edit?WOPISrc=https%3A%2F%2Fwopiserver.test.prv%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e&ui=de"), + Entry("Microsoft chat lang", "Microsoft", "de", false, "https://test.server.prv/hosting/wopi/word/edit?UI_LLCC=de-DE&WOPISrc=https%3A%2F%2Fwopiserver.test.prv%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e"), + Entry("Collabora chat lang", "Collabora", "de", false, "https://test.server.prv/hosting/wopi/word/view?WOPISrc=https%3A%2F%2Fwopiserver.test.prv%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e&lang=de-DE"), + Entry("OnlyOffice chat lang", "OnlyOffice", "de", false, "https://test.server.prv/hosting/wopi/word/edit?WOPISrc=https%3A%2F%2Fwopiserver.test.prv%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e&ui=de-DE"), Entry("Microsoft no chat no lang", "Microsoft", "", true, "https://test.server.prv/hosting/wopi/word/edit?WOPISrc=https%3A%2F%2Fwopiserver.test.prv%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e&dchat=1"), Entry("Collabora no chat no lang", "Collabora", "", true, "https://test.server.prv/hosting/wopi/word/view?WOPISrc=https%3A%2F%2Fwopiserver.test.prv%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e&dchat=1"), Entry("OnlyOffice no chat no lang", "OnlyOffice", "", true, "https://test.server.prv/hosting/wopi/word/edit?WOPISrc=https%3A%2F%2Fwopiserver.test.prv%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e&dchat=1"), - Entry("Microsoft no chat lang", "Microsoft", "de", true, "https://test.server.prv/hosting/wopi/word/edit?UI_LLCC=de&WOPISrc=https%3A%2F%2Fwopiserver.test.prv%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e&dchat=1"), - Entry("Collabora no chat lang", "Collabora", "de", true, "https://test.server.prv/hosting/wopi/word/view?WOPISrc=https%3A%2F%2Fwopiserver.test.prv%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e&dchat=1&lang=de"), - Entry("OnlyOffice no chat lang", "OnlyOffice", "de", true, "https://test.server.prv/hosting/wopi/word/edit?WOPISrc=https%3A%2F%2Fwopiserver.test.prv%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e&dchat=1&ui=de"), + Entry("Microsoft no chat lang", "Microsoft", "de", true, "https://test.server.prv/hosting/wopi/word/edit?UI_LLCC=de-DE&WOPISrc=https%3A%2F%2Fwopiserver.test.prv%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e&dchat=1"), + Entry("Collabora no chat lang", "Collabora", "de", true, "https://test.server.prv/hosting/wopi/word/view?WOPISrc=https%3A%2F%2Fwopiserver.test.prv%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e&dchat=1&lang=de-DE"), + Entry("OnlyOffice no chat lang", "OnlyOffice", "de", true, "https://test.server.prv/hosting/wopi/word/edit?WOPISrc=https%3A%2F%2Fwopiserver.test.prv%2Fwopi%2Ffiles%2F2f6ec18696dd1008106749bd94106e5cfad5c09e15de7b77088d03843e71b43e&dchat=1&ui=de-DE"), ) + It("Success with Wopi Proxy", func() { + ctx := context.Background() + nowTime := time.Now() + + cfg.Wopi.WopiSrc = "https://wopiserver.test.prv" + cfg.Wopi.Secret = "my_supa_secret" + cfg.Wopi.ProxyURL = "https://office.proxy.test.prv" + cfg.Wopi.ProxySecret = "your_supa_secret" + cfg.App.Name = "Microsoft" + + 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.Wopi.Secret, nowTime), + } + req.Opaque = utils.AppendPlainToOpaque(req.Opaque, "lang", "en") + + 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?UI_LLCC=en-US&WOPISrc=https%3A%2F%2Foffice.proxy.test.prv%2Fwopi%2Ffiles%2FeyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1IjoiaHR0cHM6Ly93b3Bpc2VydmVyLnRlc3QucHJ2L3dvcGkvZmlsZXMvIiwiZiI6IjJmNmVjMTg2OTZkZDEwMDgxMDY3NDliZDk0MTA2ZTVjZmFkNWMwOWUxNWRlN2I3NzA4OGQwMzg0M2U3MWI0M2UifQ.yfyLHZ18Z1MFOa6u7AP0LqfIiQ9X5AMkYauEZGhbCNs")) + Expect(resp.GetAppUrl().GetFormParameters()["access_token_ttl"]).To(Equal(strconv.FormatInt(nowTime.Add(5*time.Hour).Unix()*1000, 10))) + }) }) }) diff --git a/services/collaboration/pkg/wopisrc/wopisrc.go b/services/collaboration/pkg/wopisrc/wopisrc.go new file mode 100644 index 0000000000..677f9f8b0c --- /dev/null +++ b/services/collaboration/pkg/wopisrc/wopisrc.go @@ -0,0 +1,72 @@ +package wopisrc + +import ( + "errors" + "net/url" + "path" + + "github.com/golang-jwt/jwt/v4" + "github.com/owncloud/ocis/v2/services/collaboration/pkg/config" +) + +// GenerateWopiSrc generates a WOPI src URL for the given file reference. +// If a proxy URL and proxy secret are configured, the URL will be generated +// as a jwt token that is signed with the proxy secret and contains the file reference +// and the WOPI src URL. +// Example: +// https://cloud.proxy.com/wopi/files/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1IjoiaHR0cHM6Ly9vY2lzLnRlYW0vd29waS9maWxlcy8iLCJmIjoiMTIzNDU2In0.6ol9PQXGKktKfAri8tsJ4X_a9rIeosJ7id6KTQW6Ui0 +// +// If no proxy URL and proxy secret are configured, the URL will be generated +// as a direct URL that contains the file reference. +// Example: +// https:/ocis.team/wopi/files/12312678470610632091729803710923 +func GenerateWopiSrc(fileRef string, cfg *config.Config) (*url.URL, error) { + wopiSrcURL, err := url.Parse(cfg.Wopi.WopiSrc) + if err != nil { + return nil, err + } + if wopiSrcURL.Host == "" { + return nil, errors.New("invalid WopiSrc URL") + } + + if cfg.Wopi.ProxyURL != "" && cfg.Wopi.ProxySecret != "" { + return generateProxySrc(fileRef, cfg.Wopi.ProxyURL, cfg.Wopi.ProxySecret, wopiSrcURL) + } + + return generateDirectSrc(fileRef, wopiSrcURL) +} + +func generateDirectSrc(fileRef string, wopiSrcURL *url.URL) (*url.URL, error) { + wopiSrcURL.Path = path.Join("wopi", "files", fileRef) + return wopiSrcURL, nil +} + +func generateProxySrc(fileRef string, proxyUrl string, proxySecret string, wopiSrcURL *url.URL) (*url.URL, error) { + proxyURL, err := url.Parse(proxyUrl) + if err != nil { + return nil, err + } + if proxyURL.Host == "" { + return nil, errors.New("invalid proxy URL") + } + + wopiSrcURL.Path = path.Join("wopi", "files") + + type tokenClaims struct { + URL string `json:"u"` + FileID string `json:"f"` + jwt.RegisteredClaims + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, tokenClaims{ + FileID: fileRef, + // the string value from the URL package always ends with a slash + // the office365 proxy assumes that we have a trailing slash + URL: wopiSrcURL.String() + "/", + }) + tokenString, err := token.SignedString([]byte(proxySecret)) + if err != nil { + return nil, err + } + proxyURL.Path = path.Join("wopi", "files", tokenString) + return proxyURL, nil +} diff --git a/services/collaboration/pkg/wopisrc/wopisrc_suite_test.go b/services/collaboration/pkg/wopisrc/wopisrc_suite_test.go new file mode 100644 index 0000000000..5740b99064 --- /dev/null +++ b/services/collaboration/pkg/wopisrc/wopisrc_suite_test.go @@ -0,0 +1,13 @@ +package wopisrc_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestWopisrc(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Wopisrc Suite") +} diff --git a/services/collaboration/pkg/wopisrc/wopisrc_test.go b/services/collaboration/pkg/wopisrc/wopisrc_test.go new file mode 100644 index 0000000000..1b4bab0d70 --- /dev/null +++ b/services/collaboration/pkg/wopisrc/wopisrc_test.go @@ -0,0 +1,64 @@ +package wopisrc_test + +import ( + . "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/wopisrc" +) + +var _ = Describe("Wopisrc Test", func() { + var ( + c *config.Config + ) + + Context("GenerateWopiSrc", func() { + BeforeEach(func() { + c = &config.Config{ + Wopi: config.Wopi{ + WopiSrc: "https://ocis.team/wopi/files", + ProxyURL: "https://cloud.proxy.com", + ProxySecret: "secret", + }, + } + }) + When("WopiSrc URL is incorrect", func() { + c = &config.Config{ + Wopi: config.Wopi{ + WopiSrc: "https:&//ocis.team/wopi/files", + }, + } + url, err := wopisrc.GenerateWopiSrc("123456", c) + Expect(err).To(HaveOccurred()) + Expect(url).To(BeNil()) + }) + When("proxy URL is incorrect", func() { + c = &config.Config{ + Wopi: config.Wopi{ + WopiSrc: "https://ocis.team/wopi/files", + ProxyURL: "cloud", + ProxySecret: "secret", + }, + } + url, err := wopisrc.GenerateWopiSrc("123456", c) + Expect(err).To(HaveOccurred()) + Expect(url).To(BeNil()) + }) + When("proxy URL and proxy secret are configured", func() { + It("should generate a WOPI src URL as a jwt token", func() { + url, err := wopisrc.GenerateWopiSrc("123456", c) + Expect(err).ToNot(HaveOccurred()) + Expect(url.String()).To(Equal("https://cloud.proxy.com/wopi/files/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1IjoiaHR0cHM6Ly9vY2lzLnRlYW0vd29waS9maWxlcy8iLCJmIjoiMTIzNDU2In0.6ol9PQXGKktKfAri8tsJ4X_a9rIeosJ7id6KTQW6Ui0")) + }) + }) + When("proxy URL and proxy secret are not configured", func() { + It("should generate a WOPI src URL as a direct URL", func() { + c.Wopi.ProxyURL = "" + c.Wopi.ProxySecret = "" + url, err := wopisrc.GenerateWopiSrc("123456", c) + Expect(err).ToNot(HaveOccurred()) + Expect(url.String()).To(Equal("https://ocis.team/wopi/files/123456")) + }) + }) + }) +})