From bfa3dd07bc4694256bf162025751f2f737a57b28 Mon Sep 17 00:00:00 2001 From: Michael Barz Date: Wed, 24 Jul 2024 23:02:46 +0200 Subject: [PATCH 1/3] 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 da6ff575c..887c9de10 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 ee569189a..28bf1de04 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 51e4caa3b..fad016ecf 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 8cccc7dfb..96d196748 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 ead86d397..fab2f1b33 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 82ba9e9c4..6081ca84c 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 d23e93c73..64712a901 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 eb1ec5462..9afd01482 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 66be0c0f0..6f30d2d8f 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 20c820dc0..04046c5b6 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 64de4409b..4d4c9db05 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 000000000..290b85b3b --- /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 000000000..a9196da06 --- /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 022e59adf..e1606e206 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 000000000..09e4e1136 --- /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 816f81e12..4b7835237 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 22dcdf008..b02e1c7c3 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 000000000..900cf5c56 --- /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 e00cdb403..e84fae856 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 1458a4e27..c2cb63089 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 6fd77b125..40655ac31 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 000000000..677f9f8b0 --- /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 000000000..5740b9906 --- /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 000000000..1b4bab0d7 --- /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")) + }) + }) + }) +}) From 90b36a4f64ef028876266bd69b0977728e9c8743 Mon Sep 17 00:00:00 2001 From: Michael Barz Date: Fri, 30 Aug 2024 10:51:47 +0200 Subject: [PATCH 2/3] docs: changelog, README, variable descriptions --- changelog/unreleased/microsoft-cloud-on-prem.md | 6 ++++++ services/collaboration/README.md | 5 +++++ services/collaboration/pkg/config/app.go | 2 +- services/collaboration/pkg/config/wopi.go | 6 +++--- services/collaboration/pkg/server/http/server.go | 1 - 5 files changed, 15 insertions(+), 5 deletions(-) create mode 100644 changelog/unreleased/microsoft-cloud-on-prem.md diff --git a/changelog/unreleased/microsoft-cloud-on-prem.md b/changelog/unreleased/microsoft-cloud-on-prem.md new file mode 100644 index 000000000..3013a325b --- /dev/null +++ b/changelog/unreleased/microsoft-cloud-on-prem.md @@ -0,0 +1,6 @@ +Enhancement: Microsoft Office365 and Office Online support + +Add support for Microsoft Office365 Cloud and Microsoft Office Online on premises. You can use the cloud feature either within a Microsoft [CSP](https://learn.microsoft.com/en-us/partner-center/enroll/csp-overview) partnership or via the ownCloud office365 proxy subscription. +Please contact sales@owncloud.com to get more information about the ownCloud office365 proxy subscription. + +https://github.com/owncloud/ocis/pull/9686 diff --git a/services/collaboration/README.md b/services/collaboration/README.md index 205c54e48..352d9ef9d 100644 --- a/services/collaboration/README.md +++ b/services/collaboration/README.md @@ -4,6 +4,8 @@ The collaboration service connects ocis with document servers such as Collabora Since this service requires an external document server, it won't start by default when using `ocis server`. You must start it manually with the `ocis collaboration server` command. +This service needs to be part of the ocis service mesh. It is not intended to be used as a standalone service. You must share the common config variables like OCIS_URL, OCIS_JWT_SECRET and OCIS_REVA_GATEWAY betweed this service and the other ocis services. In addition to that, MICRO_REGISTRY_ADDRESS should point to the main ocis service registry. + ## Requirements The collaboration service requires the target document server (ONLYOFFICE, Collabora, etc.) to be up and running. Additionally, some Infinite Scale services are also required to be running in order to register the GRPC service for the `open in app` action in the webUI. The following internal and external services need to be available: @@ -18,6 +20,9 @@ If any of the named services above have not been started or are not reachable, t There are a few variables that you need to set: +* `COLLABORATION_APP_NAME`:\ + The name of the connected WebOffice app, either `Collabora`, `OnlyOffice`, `Microsoft365` or `MicrosoftOfficeOnline`. + * `COLLABORATION_APP_ADDR`:\ The URL of the collaborative editing app (onlyoffice, collabora, etc).\ For example: `https://office.example.com`. diff --git a/services/collaboration/pkg/config/app.go b/services/collaboration/pkg/config/app.go index 28bf1de04..222f9b38f 100644 --- a/services/collaboration/pkg/config/app.go +++ b/services/collaboration/pkg/config/app.go @@ -11,7 +11,7 @@ type App struct { 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"` - LicenseCheckEnable bool `yaml:"licensecheckenable" env:"COLLABORATION_APP_LICENSE_CHECK_ENABLE" desc:"Enable license check for edit" introductionVersion:"%%NEXT%%"` + LicenseCheckEnable bool `yaml:"licensecheckenable" env:"COLLABORATION_APP_LICENSE_CHECK_ENABLE" desc:"Enable license check for edit. Needs to be enabled when using Microsoft365 with the business flow." introductionVersion:"%%NEXT%%"` } type ProofKeys struct { diff --git a/services/collaboration/pkg/config/wopi.go b/services/collaboration/pkg/config/wopi.go index fad016ecf..aa5bc3423 100644 --- a/services/collaboration/pkg/config/wopi.go +++ b/services/collaboration/pkg/config/wopi.go @@ -4,7 +4,7 @@ package config 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%%"` + DisableChat bool `yaml:"disable_chat" env:"COLLABORATION_WOPI_DISABLE_CHAT;OCIS_WOPI_DISABLE_CHAT" desc:"Disable chat in the frontend. This feature is available in OnlyOffice and Microsoft." introductionVersion:"%%NEXT%%"` + ProxyURL string `yaml:"proxy_url" env:"COLLABORATION_WOPI_PROXY_URL" desc:"The URL to the ownCloud Office365 WOPI proxy. Optional. To use this feature, you need an office365 proxy subscription. If you become part of the Microsoft CSP program (https://learn.microsoft.com/en-us/partner-center/enroll/csp-overview), you can use the WebOffice without a proxy." introductionVersion:"%%NEXT%%"` + ProxySecret string `yaml:"proxy_secret" env:"COLLABORATION_WOPI_PROXY_SECRET" desc:"The secret to authenticate against the ownCloud Office365 WOPI proxy. Optional. This secret can be obtained from ownCloud via the office365 proxy subscription." introductionVersion:"%%NEXT%%"` } diff --git a/services/collaboration/pkg/server/http/server.go b/services/collaboration/pkg/server/http/server.go index e84fae856..e00cdb403 100644 --- a/services/collaboration/pkg/server/http/server.go +++ b/services/collaboration/pkg/server/http/server.go @@ -121,7 +121,6 @@ func prepareRoutes(r *chi.Mux, options Options) { // authentication and wopi context return colabmiddleware.WopiContextAuthMiddleware(options.Config, h) }, - colabmiddleware.CollaborationTracingMiddleware, ) From 903b513d9cc9e8b5e4f8d52f07f285f5c4d54265 Mon Sep 17 00:00:00 2001 From: Michael Barz Date: Fri, 30 Aug 2024 16:19:42 +0200 Subject: [PATCH 3/3] fix: implement review --- .../collaboration/pkg/connector/connector.go | 30 ++++++-------- .../pkg/connector/contentconnector.go | 5 +-- .../pkg/connector/fileconnector.go | 12 +++--- .../pkg/connector/fileconnector_test.go | 6 +-- .../pkg/connector/httpadapter.go | 1 + .../pkg/connector/httpadapter_test.go | 4 +- services/collaboration/pkg/helpers/path.go | 41 ------------------- services/collaboration/pkg/helpers/version.go | 20 --------- .../pkg/middleware/wopicontext.go | 36 +++++++++++++++- 9 files changed, 59 insertions(+), 96 deletions(-) delete mode 100644 services/collaboration/pkg/helpers/path.go delete mode 100644 services/collaboration/pkg/helpers/version.go diff --git a/services/collaboration/pkg/connector/connector.go b/services/collaboration/pkg/connector/connector.go index 96d196748..0e1e821bf 100644 --- a/services/collaboration/pkg/connector/connector.go +++ b/services/collaboration/pkg/connector/connector.go @@ -1,8 +1,9 @@ package connector import ( + "strconv" + 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. @@ -57,24 +58,11 @@ func NewResponseLockConflict(lockID string, lockFailureReason string) *Connector // 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 { +func NewResponseWithVersion(mtime *types.Timestamp) *ConnectorResponse { return &ConnectorResponse{ - Status: status, + Status: 200, 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, + HeaderWopiVersion: getVersion(mtime), }, } } @@ -86,7 +74,7 @@ func NewResponseWithVersionAndLock(status int, mtime *types.Timestamp, lockID st r := &ConnectorResponse{ Status: status, Headers: map[string]string{ - HeaderWopiVersion: helpers.GetVersion(mtime), + HeaderWopiVersion: getVersion(mtime), HeaderWopiLock: lockID, }, } @@ -196,3 +184,9 @@ func (c *Connector) GetFileConnector() FileConnectorService { func (c *Connector) GetContentConnector() ContentConnectorService { return c.contentConnector } + +// getVersion returns a string representation of the timestamp +func getVersion(timestamp *types.Timestamp) string { + return "v" + strconv.FormatUint(timestamp.GetSeconds(), 10) + + strconv.FormatUint(uint64(timestamp.GetNanos()), 10) +} diff --git a/services/collaboration/pkg/connector/contentconnector.go b/services/collaboration/pkg/connector/contentconnector.go index fab2f1b33..3f4fbf026 100644 --- a/services/collaboration/pkg/connector/contentconnector.go +++ b/services/collaboration/pkg/connector/contentconnector.go @@ -17,7 +17,6 @@ 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" @@ -181,7 +180,7 @@ func (c *ContentConnector) GetFile(ctx context.Context, w http.ResponseWriter) e return NewConnectorError(500, "GetFile: Downloading the file failed") } - helpers.SetVersionHeader(w, sResp.GetInfo().GetMtime()) + w.Header().Set(HeaderWopiVersion, getVersion(sResp.GetInfo().GetMtime())) // Copy the download into the writer _, err = io.Copy(w, httpResp.Body) @@ -404,5 +403,5 @@ func (c *ContentConnector) PutFile(ctx context.Context, stream io.Reader, stream } logger.Debug().Msg("PutFile: success") - return NewResponseWithVersion(200, mtime), nil + return NewResponseWithVersion(mtime), nil } diff --git a/services/collaboration/pkg/connector/fileconnector.go b/services/collaboration/pkg/connector/fileconnector.go index 64712a901..a934fb5f2 100644 --- a/services/collaboration/pkg/connector/fileconnector.go +++ b/services/collaboration/pkg/connector/fileconnector.go @@ -259,7 +259,7 @@ func (f *FileConnector) Lock(ctx context.Context, lockID, oldLockID string) (*Co switch setOrRefreshStatus.GetCode() { case rpcv1beta1.Code_CODE_OK: logger.Debug().Msg("SetLock successful") - return NewResponseWithVersion(200, statResp.GetInfo().GetMtime()), nil + return NewResponseWithVersion(statResp.GetInfo().GetMtime()), nil case rpcv1beta1.Code_CODE_FAILED_PRECONDITION, rpcv1beta1.Code_CODE_ABORTED: // Code_CODE_FAILED_PRECONDITION -> Lock operation mismatched lock @@ -300,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 NewResponseWithVersionAndLock(200, statResp.GetInfo().GetMtime(), resp.GetLock().GetLockId()), nil + return NewResponseWithVersion(statResp.GetInfo().GetMtime()), nil } logger.Error().Msg("SetLock failed and could not refresh") @@ -388,7 +388,7 @@ func (f *FileConnector) RefreshLock(ctx context.Context, lockID string) (*Connec logger.Debug().Msg("RefreshLock successful") // 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 + return NewResponseWithVersion(statResp.GetInfo().GetMtime()), nil case rpcv1beta1.Code_CODE_NOT_FOUND: logger.Error(). @@ -428,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 NewResponseConflictWithVersion(statResp.GetInfo().GetMtime(), "No lock on file"), nil + return NewResponseLockConflict("", "No lock on file"), nil } else { // lock is different than the one requested, otherwise we wouldn't reached this point logger.Error(). @@ -510,7 +510,7 @@ func (f *FileConnector) UnLock(ctx context.Context, lockID string) (*ConnectorRe switch resp.GetStatus().GetCode() { case rpcv1beta1.Code_CODE_OK: logger.Debug().Msg("Unlock successful") - return NewResponseWithVersion(200, statResp.GetInfo().GetMtime()), nil + return NewResponseWithVersion(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") @@ -1114,7 +1114,7 @@ func (f *FileConnector) CheckFileInfo(ctx context.Context) (*ConnectorResponse, infoMap := map[string]interface{}{ fileinfo.KeyOwnerID: hexEncodedOwnerId, fileinfo.KeySize: int64(statRes.GetInfo().GetSize()), - fileinfo.KeyVersion: helpers.GetVersion(statRes.GetInfo().GetMtime()), + fileinfo.KeyVersion: 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 diff --git a/services/collaboration/pkg/connector/fileconnector_test.go b/services/collaboration/pkg/connector/fileconnector_test.go index 9afd01482..8dfa4961f 100644 --- a/services/collaboration/pkg/connector/fileconnector_test.go +++ b/services/collaboration/pkg/connector/fileconnector_test.go @@ -255,8 +255,7 @@ var _ = Describe("FileConnector", func() { response, err := fc.Lock(ctx, "abcdef123", "") Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(200)) - Expect(response.Headers).To(HaveLen(2)) - Expect(response.Headers[connector.HeaderWopiLock]).To(Equal("abcdef123")) + Expect(response.Headers).To(HaveLen(1)) Expect(response.Headers[connector.HeaderWopiVersion]).To(Equal("v123456789")) }) @@ -437,8 +436,7 @@ var _ = Describe("FileConnector", func() { response, err := fc.Lock(ctx, "abcdef123", "112233") Expect(err).ToNot(HaveOccurred()) Expect(response.Status).To(Equal(200)) - Expect(response.Headers).To(HaveLen(2)) - Expect(response.Headers[connector.HeaderWopiLock]).To(Equal("abcdef123")) + Expect(response.Headers).To(HaveLen(1)) Expect(response.Headers[connector.HeaderWopiVersion]).To(Equal("v123456789")) }) diff --git a/services/collaboration/pkg/connector/httpadapter.go b/services/collaboration/pkg/connector/httpadapter.go index 04046c5b6..b0a427945 100644 --- a/services/collaboration/pkg/connector/httpadapter.go +++ b/services/collaboration/pkg/connector/httpadapter.go @@ -51,6 +51,7 @@ func NewHttpAdapter(gwc gatewayv1beta1.GatewayAPIClient, cfg *config.Config) *Ht ), } + // TODO: check if we can get rid of custom log parsing completely httpAdapter.locks = &locks.NoopLockParser{} return httpAdapter } diff --git a/services/collaboration/pkg/connector/httpadapter_test.go b/services/collaboration/pkg/connector/httpadapter_test.go index 4d4c9db05..3d3d97c7a 100644 --- a/services/collaboration/pkg/connector/httpadapter_test.go +++ b/services/collaboration/pkg/connector/httpadapter_test.go @@ -357,9 +357,7 @@ var _ = Describe("HttpAdapter", func() { w := httptest.NewRecorder() fc.On("UnLock", mock.Anything, "abc123").Times(1).Return( - connector.NewResponseWithVersion(200, - &typesv1beta1.Timestamp{Seconds: uint64(1234), Nanos: uint32(567)}, - ), nil) + connector.NewResponseWithVersion(&typesv1beta1.Timestamp{Seconds: uint64(1234), Nanos: uint32(567)}), nil) httpAdapter.UnLock(w, req) resp := w.Result() diff --git a/services/collaboration/pkg/helpers/path.go b/services/collaboration/pkg/helpers/path.go deleted file mode 100644 index 290b85b3b..000000000 --- a/services/collaboration/pkg/helpers/path.go +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index a9196da06..000000000 --- a/services/collaboration/pkg/helpers/version.go +++ /dev/null @@ -1,20 +0,0 @@ -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/middleware/wopicontext.go b/services/collaboration/pkg/middleware/wopicontext.go index b02e1c7c3..c69308a4c 100644 --- a/services/collaboration/pkg/middleware/wopicontext.go +++ b/services/collaboration/pkg/middleware/wopicontext.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "strings" appproviderv1beta1 "github.com/cs3org/go-cs3apis/cs3/app/provider/v1beta1" providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" @@ -109,7 +110,7 @@ func WopiContextAuthMiddleware(cfg *config.Config, next http.Handler) http.Handl ctx = wopiLogger.WithContext(ctx) hashedRef := helpers.HashResourceId(claims.WopiContext.FileReference.GetResourceId()) - fileID := helpers.ParseWopiFileID(cfg, r.URL.Path) + fileID := 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) @@ -167,3 +168,36 @@ func GenerateWopiToken(wopiContext WopiContext, cfg *config.Config) (string, int return accessToken, claims.ExpiresAt.UnixMilli(), err } + +// 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] +}