Skip to content

Commit

Permalink
Add the /office/keys/:key endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
nono committed Oct 2, 2023
1 parent 2e9c596 commit 38ca4aa
Show file tree
Hide file tree
Showing 7 changed files with 191 additions and 7 deletions.
60 changes: 60 additions & 0 deletions docs/office.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions model/office/callback.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions model/office/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
)
44 changes: 44 additions & 0 deletions model/office/file_by_key.go
Original file line number Diff line number Diff line change
@@ -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
}
15 changes: 15 additions & 0 deletions model/office/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions web/office/office.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}

Expand All @@ -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:
Expand Down
56 changes: 51 additions & 5 deletions web/office/office_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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).
Expand All @@ -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(`{
Expand All @@ -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())
})
}

Expand Down

0 comments on commit 38ca4aa

Please sign in to comment.