From 38ca4aa743877bd01bc07547c89820e0d08b0a98 Mon Sep 17 00:00:00 2001 From: Bruno Michel Date: Mon, 2 Oct 2023 15:16:05 +0200 Subject: [PATCH] Add the /office/keys/:key endpoint --- docs/office.md | 60 +++++++++++++++++++++++++++++++++++++ model/office/callback.go | 4 +-- model/office/errors.go | 2 ++ model/office/file_by_key.go | 44 +++++++++++++++++++++++++++ model/office/store.go | 15 ++++++++++ web/office/office.go | 17 +++++++++++ web/office/office_test.go | 56 ++++++++++++++++++++++++++++++---- 7 files changed, 191 insertions(+), 7 deletions(-) create mode 100644 model/office/file_by_key.go diff --git a/docs/office.md b/docs/office.md index 28850174564..1ca8edc0c59 100644 --- a/docs/office.md +++ b/docs/office.md @@ -121,6 +121,66 @@ Content-Type: application/vnd.api+json } ``` +### GET /office/keys/:key + +If a document is being edited while a new version is uploaded (via the desktop +for example), the OO webapp should call this endpoint if the user chooses to +continue editing the version on which they were working. A conflict file is +created, so that no work is lost. + +#### Request + +```http +GET /office/keys/7c7ccc2e7137ba774b7e44de HTTP/1.1 +Host: bob.cozy.example +``` + +#### Response + +```http +HTTP/1.1 200 OK +Content-Type: application/vnd.api+json +``` + +```json +{ + "data": { + "type": "io.cozy.files", + "id": "32e07d806f9b0139c541543d7eb8149c", + "meta": { + "rev": "3-18c04daba326" + }, + "attributes": { + "type": "file", + "name": "slideshow (2).pptx", + "trashed": false, + "md5sum": "ODZmYjI2OWQxOTBkMmM4NQo=", + "created_at": "2023-09-30T21:42:05Z", + "updated_at": "2023-09-30T22:38:04Z", + "tags": [], + "metadata": {}, + "size": 12345, + "executable": false, + "class": "slide", + "mime": "application/vnd.ms-powerpoint", + "cozyMetadata": { + "doctypeVersion": "1", + "metadataVersion": 1, + "createdAt": "2023-09-30T21:42:05Z", + "createdByApp": "drive", + "createdOn": "https://bob.cozy.example/", + "updatedAt": "2023-09-30T22:38:04Z", + "uploadedAt": "2023-09-30T22:38:04Z", + "uploadedOn": "https://bob.cozy.example/", + "uploadedBy": { + "slug": "onlyoffice-server" + } + } + } + } +} +``` + ### POST /office/callback This is the callback handler for OnlyOffice. It is called when the document diff --git a/model/office/callback.go b/model/office/callback.go index eb03db633e1..f04bf22c795 100644 --- a/model/office/callback.go +++ b/model/office/callback.go @@ -101,7 +101,7 @@ func checkToken(cfg *config.Office, params CallbackParameters) error { func finalSaveFile(inst *instance.Instance, key, downloadURL string) error { detector, err := GetStore().GetDoc(inst, key) if err != nil || detector == nil || detector.ID == "" || detector.Rev == "" { - return errors.New("invalid key") + return ErrInvalidKey } _, err = saveFile(inst, *detector, downloadURL) @@ -114,7 +114,7 @@ func finalSaveFile(inst *instance.Instance, key, downloadURL string) error { func forceSaveFile(inst *instance.Instance, key, downloadURL string) error { detector, err := GetStore().GetDoc(inst, key) if err != nil || detector == nil || detector.ID == "" || detector.Rev == "" { - return errors.New("invalid key") + return ErrInvalidKey } updated, err := saveFile(inst, *detector, downloadURL) diff --git a/model/office/errors.go b/model/office/errors.go index 935b68ee43f..0c68c7f4902 100644 --- a/model/office/errors.go +++ b/model/office/errors.go @@ -11,4 +11,6 @@ var ( // ErrInternalServerError is used when something goes wrong (like no // connection to redis) ErrInternalServerError = errors.New("Internal server error") + // ErrInvalidKey is used when the key is not found in the store + ErrInvalidKey = errors.New("invalid key") ) diff --git a/model/office/file_by_key.go b/model/office/file_by_key.go new file mode 100644 index 00000000000..e10801bff69 --- /dev/null +++ b/model/office/file_by_key.go @@ -0,0 +1,44 @@ +package office + +import ( + "bytes" + + "github.com/cozy/cozy-stack/model/instance" + "github.com/cozy/cozy-stack/model/vfs" +) + +func GetFileByKey(inst *instance.Instance, key string) (*vfs.FileDoc, error) { + detector, err := GetStore().GetDoc(inst, key) + if err != nil { + return nil, err + } + if detector == nil || detector.ID == "" || detector.Rev == "" { + return nil, ErrInvalidKey + } + + fs := inst.VFS() + file, err := fs.FileByID(detector.ID) + if err != nil { + return nil, err + } + + if file.Rev() == detector.Rev || bytes.Equal(file.MD5Sum, detector.MD5Sum) { + return file, nil + } + + // Manage the conflict + conflictName := vfs.ConflictName(fs, file.DirID, file.DocName, true) + newfile := vfs.CreateFileDocCopy(file, file.DirID, conflictName) + newfile.CozyMetadata = vfs.NewCozyMetadata(inst.PageURL("/", nil)) + newfile.CozyMetadata.UpdatedAt = newfile.UpdatedAt + newfile.CozyMetadata.UploadedAt = &newfile.UpdatedAt + newfile.CozyMetadata.UploadedBy = &vfs.UploadedByEntry{Slug: OOSlug} + if err := fs.CopyFile(file, newfile); err != nil { + return nil, err + } + + updated := conflictDetector{ID: newfile.ID(), Rev: newfile.Rev(), MD5Sum: newfile.MD5Sum} + _ = GetStore().UpdateSecret(inst, key, file.ID(), newfile.ID()) + _ = GetStore().UpdateDoc(inst, key, updated) + return newfile, nil +} diff --git a/model/office/store.go b/model/office/store.go index 8e12e4fb5d8..57b22508b86 100644 --- a/model/office/store.go +++ b/model/office/store.go @@ -23,6 +23,7 @@ type conflictDetector struct { // Store is an object to store and retrieve document server keys <-> id,rev type Store interface { GetSecretByID(db prefixer.Prefixer, id string) (string, error) + UpdateSecret(db prefixer.Prefixer, secret, oldID, newID string) error AddDoc(db prefixer.Prefixer, payload conflictDetector) (string, error) GetDoc(db prefixer.Prefixer, secret string) (*conflictDetector, error) UpdateDoc(db prefixer.Prefixer, secret string, payload conflictDetector) error @@ -97,6 +98,14 @@ func (s *memStore) GetSecretByID(db prefixer.Prefixer, id string) (string, error return s.byID[id], nil } +func (s *memStore) UpdateSecret(db prefixer.Prefixer, secret, oldID, newID string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.byID, oldID) + s.byID[newID] = secret + return nil +} + func (s *memStore) AddDoc(db prefixer.Prefixer, payload conflictDetector) (string, error) { s.mu.Lock() defer s.mu.Unlock() @@ -162,6 +171,12 @@ func (s *redisStore) GetSecretByID(db prefixer.Prefixer, id string) (string, err return s.c.Get(s.ctx, idKey).Result() } +func (s *redisStore) UpdateSecret(db prefixer.Prefixer, secret, oldID, newID string) error { + _ = s.c.Del(s.ctx, docKey(db, oldID)) + idKey := docKey(db, newID) + return s.c.Set(s.ctx, idKey, secret, storeTTL).Err() +} + func (s *redisStore) AddDoc(db prefixer.Prefixer, payload conflictDetector) (string, error) { idKey := docKey(db, payload.ID) v, err := json.Marshal(payload) diff --git a/web/office/office.go b/web/office/office.go index cd5e909ae09..2ae6bfba466 100644 --- a/web/office/office.go +++ b/web/office/office.go @@ -12,6 +12,7 @@ import ( "github.com/cozy/cozy-stack/model/vfs" "github.com/cozy/cozy-stack/pkg/consts" "github.com/cozy/cozy-stack/pkg/jsonapi" + "github.com/cozy/cozy-stack/web/files" "github.com/cozy/cozy-stack/web/middlewares" "github.com/labstack/echo/v4" ) @@ -60,6 +61,19 @@ func Open(c echo.Context) error { return jsonapi.Data(c, http.StatusOK, doc, nil) } +func GetFileByKey(c echo.Context) error { + inst := middlewares.GetInstance(c) + file, err := office.GetFileByKey(inst, c.Param("key")) + if err != nil { + return wrapError(err) + } + if err := middlewares.AllowVFS(c, permission.GET, file); err != nil { + return err + } + doc := files.NewFile(file, inst) + return jsonapi.Data(c, http.StatusOK, doc, nil) +} + // Callback is the handler for OnlyOffice callback requests. // Cf https://api.onlyoffice.com/editors/callback func Callback(c echo.Context) error { @@ -88,6 +102,7 @@ func Callback(c echo.Context) error { // Routes sets the routing for the collaborative edition of office documents. func Routes(router *echo.Group) { router.GET("/:id/open", Open) + router.GET("/keys/:key", GetFileByKey) router.POST("/callback", Callback) } @@ -97,6 +112,8 @@ func wrapError(err error) *jsonapi.Error { return jsonapi.NotFound(err) case office.ErrInternalServerError: return jsonapi.InternalServerError(err) + case office.ErrInvalidKey: + return jsonapi.NotFound(err) case os.ErrNotExist, vfs.ErrParentDoesNotExist, vfs.ErrParentInTrash: return jsonapi.NotFound(err) case sharing.ErrMemberNotFound: diff --git a/web/office/office_test.go b/web/office/office_test.go index 8fc2c81f92c..61a10143cb5 100644 --- a/web/office/office_test.go +++ b/web/office/office_test.go @@ -132,7 +132,7 @@ func TestOffice(t *testing.T) { t.Run("Conflict after an upload", func(t *testing.T) { e := testutils.CreateTestClient(t, ts.URL) - // If a user opens an office document + // When a user opens an office document obj := e.GET("/office/"+fileID+"/open"). WithHeader("Authorization", "Bearer "+token). Expect().Status(200). @@ -144,10 +144,53 @@ func TestOffice(t *testing.T) { document := oo.Value("document").Object() key = document.Value("key").String().NotEmpty().Raw() - // And an upload is made that changes the content of this document + // the key is associated to this file + obj = e.GET("/office/keys/"+key). + WithHeader("Authorization", "Bearer "+token). + Expect().Status(200). + JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). + Object() + data = obj.Value("data").Object() + docID := data.Value("id").String().NotEmpty().Raw() + assert.Equal(t, fileID, docID) + attrs = data.Value("attributes").Object() + name := attrs.Value("name").String().NotEmpty().Raw() + assert.Equal(t, "letter.docx", name) + + // When an upload is made that changes the content of this document, + // the key will now be associated to a conflict file updateFile(t, inst, fileID) + obj = e.GET("/office/keys/"+key). + WithHeader("Authorization", "Bearer "+token). + Expect().Status(200). + JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). + Object() + data = obj.Value("data").Object() + conflictID := data.Value("id").String().NotEmpty().Raw() + assert.NotEqual(t, fileID, conflictID) + meta := data.Value("meta").Object() + conflictRev := meta.Value("rev").String().NotEmpty().Raw() + attrs = data.Value("attributes").Object() + conflictName := attrs.Value("name").String().NotEmpty().Raw() + assert.Equal(t, "letter (2).docx", conflictName) + + // When another user uses the same key, they obtains the same file + obj = e.GET("/office/keys/"+key). + WithHeader("Authorization", "Bearer "+token). + Expect().Status(200). + JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). + Object() + data = obj.Value("data").Object() + anotherID := data.Value("id").String().NotEmpty().Raw() + assert.Equal(t, conflictID, anotherID) + meta = data.Value("meta").Object() + anotherRev := meta.Value("rev").String().NotEmpty().Raw() + assert.Equal(t, conflictRev, anotherRev) + attrs = data.Value("attributes").Object() + anotherName := attrs.Value("name").String().NotEmpty().Raw() + assert.Equal(t, conflictName, anotherName) - // If another user opens the document, a new key is given + // When another user opens the document, a new key is given obj = e.GET("/office/"+fileID+"/open"). WithHeader("Authorization", "Bearer "+token). Expect().Status(200). @@ -160,7 +203,8 @@ func TestOffice(t *testing.T) { newkey := document.Value("key").String().NotEmpty().Raw() assert.NotEqual(t, key, newkey) - // And if the document is saved (first key), a new file is created + // When the document is saved with the first key, it's written to the + // conflict file e.POST("/office/callback"). WithHeader("Content-Type", "application/json"). WithBytes([]byte(fmt.Sprintf(`{ @@ -171,9 +215,11 @@ func TestOffice(t *testing.T) { "users": ["6d5a81d0"] }`, key, ooURL+"/dl"))). Expect().Status(200) - conflict, err := inst.VFS().FileByPath("/letter (2).docx") + conflict, err := inst.VFS().FileByID(conflictID) require.NoError(t, err) + assert.Equal(t, "letter (2).docx", conflict.DocName) assert.Equal(t, "onlyoffice-server", conflict.CozyMetadata.UploadedBy.Slug) + assert.NotEqual(t, conflictRev, conflict.Rev()) }) }