Skip to content

Commit 7326b36

Browse files
authored
feat: add open route for excalidraw documents (#4808)
## Summary - Add `GET /excalidraw/:file-id/open` returning where to open a `.excalidraw` document (owner instance, subdomain, protocol, sharecode), typed as `io.cozy.files`. - Add the shared drive variant `GET /sharings/drives/:id/excalidraw/:file-id/open`. - Add `ExcalidrawOpener` in `model/sharing`, reusing `FileOpener` for the local/shared and drive/cozy-to-cozy routing, with no collaborative-edition infra (whole-file open/save). - Validate the file by its `.excalidraw` extension via the new `ExcalidrawExtension` constant. ## Updated - Add `GET /editor/:file-id/open` returning where to open a file-backed editor document (owner instance, subdomain, protocol, target `file_id`, sharecode), typed as `io.cozy.files`. - Add the shared drive variant `GET /sharings/drives/:id/editor/:file-id/open`. - Use the generic editor opener for Excalidraw files instead of adding an Excalidraw-specific route. - Reuse shared `FileOpener` logic for local vs owner-instance routing across editor, notes, and office open flows. - Keep Notes and OnlyOffice public routes and response contracts unchanged because they need editor-specific params/setup. - Force shared editor opens to read-only unless the `io.cozy.files` permission grants write access.
2 parents b835101 + be3e2eb commit 7326b36

12 files changed

Lines changed: 494 additions & 44 deletions

File tree

docs/files.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1069,6 +1069,61 @@ parameter in the query-string (1 is the first page).
10691069
GET /files/download?Path=/Documents/hello.txt&Dl=1 HTTP/1.1
10701070
```
10711071

1072+
### GET /editor/:file-id/open
1073+
1074+
Return the parameters to build the URL where a file can be opened by an editor
1075+
application. This route is generic for files stored as `io.cozy.files`; it does
1076+
not require a dedicated editor doctype.
1077+
1078+
The caller must have `GET` access to the file. Share-by-link, share-preview,
1079+
and share-interact tokens are also supported when the token gives access to the
1080+
file or to a shared directory that contains it.
1081+
1082+
If the file is part of a sharing, the response can point to another instance
1083+
where collaborative edition should happen. In that case, the JSON:API `id`
1084+
remains the file id known by the caller, while `file_id` is the id to use on the
1085+
instance returned in the response.
1086+
1087+
#### Query-String
1088+
1089+
| Parameter | Description |
1090+
| ----------- | ----------- |
1091+
| SharingID | Identifier of the Cozy-to-Cozy sharing, used when the stack forwards an open request |
1092+
| MemberIndex | Index of the sharing member, used when the stack forwards an open request |
1093+
| ReadOnly | Set to `true` when the returned sharecode must only grant read access |
1094+
1095+
#### Request
1096+
1097+
```http
1098+
GET /editor/32e07d806f9b0139c541543d7eb8149c/open HTTP/1.1
1099+
Host: bob.cozy.example
1100+
Accept: application/vnd.api+json
1101+
```
1102+
1103+
#### Response
1104+
1105+
```http
1106+
HTTP/1.1 200 OK
1107+
Content-Type: application/vnd.api+json
1108+
```
1109+
1110+
```json
1111+
{
1112+
"data": {
1113+
"type": "io.cozy.files",
1114+
"id": "32e07d806f9b0139c541543d7eb8149c",
1115+
"attributes": {
1116+
"file_id": "b05e7c306f9c0139c542543d7eb8149c",
1117+
"subdomain": "flat",
1118+
"protocol": "https",
1119+
"instance": "alice.cozy.example",
1120+
"sharecode": "543d7eb8149c",
1121+
"public_name": "Bob"
1122+
}
1123+
}
1124+
}
1125+
```
1126+
10721127
### GET /files/:file-id/thumbnails/:secret/:format
10731128

10741129
Get a thumbnail of a file (for an image & pdf only). `:format` can be `tiny` (96x96)

docs/shared-drives.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1056,6 +1056,18 @@ Identical to [`GET /notes/:file-id/open`](notes.md#get-notesidopen).
10561056
Returns the parameters to open an office document. Identical to
10571057
[`GET /office/:file-id/open`](office.md#get-officeidopen).
10581058

1059+
## Editors
1060+
1061+
### GET /sharings/drives/:id/editor/:file-id/open
1062+
1063+
Return the parameters to open a file from a shared drive with an editor.
1064+
Identical to [`GET /editor/:file-id/open`](files.md#get-editorfile-idopen), but
1065+
scoped to the shared drive.
1066+
1067+
Recipients are resolved through the shared-drive owner instance, so the returned
1068+
`instance`, `file_id`, and `sharecode` are the values to use for opening the
1069+
file on that owner instance.
1070+
10591071
## Shortcuts
10601072

10611073
### GET /sharings/drives/:id/shortcuts/:file-id

model/sharing/open_editor.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package sharing
2+
3+
import (
4+
"github.com/cozy/cozy-stack/model/instance"
5+
"github.com/cozy/cozy-stack/model/settings"
6+
"github.com/cozy/cozy-stack/pkg/consts"
7+
"github.com/cozy/cozy-stack/pkg/couchdb"
8+
"github.com/cozy/cozy-stack/pkg/jsonapi"
9+
)
10+
11+
// apiEditorURL holds the parameters to build the URL where a file can be
12+
// opened by an editor. It uses the io.cozy.files doctype, as no dedicated
13+
// doctype is needed for editors that only need to open a regular file.
14+
type apiEditorURL struct {
15+
DocID string `json:"_id,omitempty"`
16+
FileID string `json:"file_id"`
17+
Protocol string `json:"protocol"`
18+
Subdomain string `json:"subdomain"`
19+
Instance string `json:"instance"`
20+
Sharecode string `json:"sharecode,omitempty"`
21+
PublicName string `json:"public_name,omitempty"`
22+
}
23+
24+
func (e *apiEditorURL) ID() string { return e.DocID }
25+
func (e *apiEditorURL) Rev() string { return "" }
26+
func (e *apiEditorURL) DocType() string { return consts.Files }
27+
func (e *apiEditorURL) Clone() couchdb.Doc { cloned := *e; return &cloned }
28+
func (e *apiEditorURL) SetID(id string) { e.DocID = id }
29+
func (e *apiEditorURL) SetRev(rev string) {}
30+
func (e *apiEditorURL) Relationships() jsonapi.RelationshipMap { return nil }
31+
func (e *apiEditorURL) Included() []jsonapi.Object { return nil }
32+
func (e *apiEditorURL) Links() *jsonapi.LinksList { return nil }
33+
func (e *apiEditorURL) Fetch(field string) []string { return nil }
34+
35+
// EditorOpener can be used to find the parameters for creating the URL where a
36+
// file can be opened by an editor.
37+
type EditorOpener struct {
38+
*FileOpener
39+
}
40+
41+
// OpenEditor returns an EditorOpener for the given file.
42+
func OpenEditor(inst *instance.Instance, fileID string) (*EditorOpener, error) {
43+
file, err := inst.VFS().FileByID(fileID)
44+
if err != nil {
45+
return nil, err
46+
}
47+
48+
opener, err := NewFileOpener(inst, file)
49+
if err != nil {
50+
return nil, err
51+
}
52+
return &EditorOpener{opener}, nil
53+
}
54+
55+
// GetResult looks if the file can be opened locally or not, which code can be
56+
// used in case of a shared file, and other parameters, and returns the
57+
// information.
58+
func (o *EditorOpener) GetResult(memberIndex int, readOnly bool) (jsonapi.Object, error) {
59+
prepared, err := o.PrepareOpenFileRequest(memberIndex, readOnly)
60+
if err != nil {
61+
return nil, err
62+
}
63+
var result *apiEditorURL
64+
if prepared.Opts == nil {
65+
result, err = o.openLocalFile(prepared.MemberIndex, prepared.ReadOnly)
66+
} else {
67+
result, err = o.openSharedFile(prepared)
68+
}
69+
if err != nil {
70+
return nil, err
71+
}
72+
73+
// Keep JSON:API data.id local to the caller. FileID remains the file id on
74+
// the instance returned in Instance, which can differ for cozy-to-cozy shares.
75+
result.DocID = o.File.ID()
76+
if name, err := settings.PublicName(o.Inst); err == nil {
77+
result.PublicName = name
78+
}
79+
return result, nil
80+
}
81+
82+
func (o *EditorOpener) openLocalFile(memberIndex int, readOnly bool) (*apiEditorURL, error) {
83+
params, err := o.OpenLocalFileForMember(memberIndex, readOnly)
84+
if err != nil {
85+
return nil, err
86+
}
87+
doc := apiEditorURL{
88+
FileID: params.FileID,
89+
Protocol: params.Protocol,
90+
Subdomain: params.Subdomain,
91+
Instance: params.Instance,
92+
Sharecode: params.Sharecode,
93+
}
94+
return &doc, nil
95+
}
96+
97+
func (o *EditorOpener) openSharedFile(prepared *PreparedRequest) (*apiEditorURL, error) {
98+
res, err := o.RequestSharedFile(prepared, "/editor/"+prepared.XoredID+"/open")
99+
if res != nil {
100+
defer res.Body.Close()
101+
}
102+
if err != nil {
103+
return nil, ErrInternalServerError
104+
}
105+
var doc apiEditorURL
106+
if _, err := jsonapi.Bind(res.Body, &doc); err != nil {
107+
return nil, err
108+
}
109+
return &doc, nil
110+
}

model/sharing/open_file.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,29 @@ func (o *FileOpener) OpenLocalFile(code string) OpenFileParameters {
267267
return params
268268
}
269269

270+
// OpenLocalFileForMember returns the parameters for opening the file locally
271+
// with a sharecode resolved for the member.
272+
func (o *FileOpener) OpenLocalFileForMember(memberIndex int, readOnly bool) (OpenFileParameters, error) {
273+
code, err := o.GetSharecode(memberIndex, readOnly)
274+
if err != nil {
275+
return OpenFileParameters{}, err
276+
}
277+
return o.OpenLocalFile(code), nil
278+
}
279+
280+
// PrepareOpenFileRequest returns the parameters for opening the file on the
281+
// instance where collaborative editing should happen. If Opts is nil, the file
282+
// should be opened locally with MemberIndex and ReadOnly.
283+
func (o *FileOpener) PrepareOpenFileRequest(memberIndex int, readOnly bool) (*PreparedRequest, error) {
284+
if o.ShouldOpenLocally() {
285+
return &PreparedRequest{
286+
MemberIndex: memberIndex,
287+
ReadOnly: readOnly,
288+
}, nil
289+
}
290+
return o.PrepareRequestForSharedFile()
291+
}
292+
270293
// PreparedRequest contains the parameters to make a request to another
271294
// instance for opening a shared file. If it is not possible, Opts will be
272295
// empty and the MemberIndex and ReadOnly fields can be used for opening
@@ -358,3 +381,15 @@ func (o *FileOpener) PrepareRequestForSharedFile() (*PreparedRequest, error) {
358381
}
359382
return &prepared, nil
360383
}
384+
385+
// RequestSharedFile executes a prepared open request on another instance and
386+
// refreshes the sharing token if needed.
387+
func (o *FileOpener) RequestSharedFile(prepared *PreparedRequest, path string) (*http.Response, error) {
388+
prepared.Opts.Path = path
389+
res, err := request.Req(prepared.Opts)
390+
if res != nil && res.StatusCode/100 == 4 {
391+
res, err = RefreshToken(o.Inst, res, err, o.Sharing, prepared.Creator,
392+
prepared.Creds, prepared.Opts, nil)
393+
}
394+
return res, err
395+
}

model/sharing/open_note.go

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package sharing
22

33
import (
4-
"github.com/cozy/cozy-stack/client/request"
54
"github.com/cozy/cozy-stack/model/instance"
65
"github.com/cozy/cozy-stack/model/note"
76
"github.com/cozy/cozy-stack/model/settings"
@@ -59,12 +58,15 @@ func OpenNote(inst *instance.Instance, fileID string) (*NoteOpener, error) {
5958
// GetResult looks if the note can be opened locally or not, which code can be
6059
// used in case of a shared note, and other parameters.. and returns the information.
6160
func (o *NoteOpener) GetResult(memberIndex int, readOnly bool) (jsonapi.Object, error) {
61+
prepared, err := o.PrepareOpenFileRequest(memberIndex, readOnly)
62+
if err != nil {
63+
return nil, err
64+
}
6265
var result *apiNoteURL
63-
var err error
64-
if o.ShouldOpenLocally() {
65-
result, err = o.openLocalNote(memberIndex, readOnly)
66+
if prepared.Opts == nil {
67+
result, err = o.openLocalNote(prepared.MemberIndex, prepared.ReadOnly)
6668
} else {
67-
result, err = o.openSharedNote()
69+
result, err = o.openSharedNote(prepared)
6870
}
6971
if err != nil {
7072
return nil, err
@@ -86,11 +88,10 @@ func (o *NoteOpener) openLocalNote(memberIndex int, readOnly bool) (*apiNoteURL,
8688
// continue to work.
8789
_ = note.SetupTrigger(o.Inst, o.File.ID())
8890

89-
code, err := o.GetSharecode(memberIndex, readOnly)
91+
params, err := o.OpenLocalFileForMember(memberIndex, readOnly)
9092
if err != nil {
9193
return nil, err
9294
}
93-
params := o.OpenLocalFile(code)
9495
doc := apiNoteURL{
9596
NoteID: params.FileID,
9697
Protocol: params.Protocol,
@@ -101,25 +102,14 @@ func (o *NoteOpener) openLocalNote(memberIndex int, readOnly bool) (*apiNoteURL,
101102
return &doc, nil
102103
}
103104

104-
func (o *NoteOpener) openSharedNote() (*apiNoteURL, error) {
105-
prepared, err := o.PrepareRequestForSharedFile()
106-
if err != nil {
107-
return nil, err
108-
}
109-
if prepared.Opts == nil {
110-
return o.openLocalNote(prepared.MemberIndex, prepared.ReadOnly)
111-
}
112-
113-
prepared.Opts.Path = "/notes/" + prepared.XoredID + "/open"
114-
res, err := request.Req(prepared.Opts)
115-
if res != nil && res.StatusCode/100 == 4 {
116-
res, err = RefreshToken(o.Inst, res, err, o.Sharing, prepared.Creator,
117-
prepared.Creds, prepared.Opts, nil)
105+
func (o *NoteOpener) openSharedNote(prepared *PreparedRequest) (*apiNoteURL, error) {
106+
res, err := o.RequestSharedFile(prepared, "/notes/"+prepared.XoredID+"/open")
107+
if res != nil {
108+
defer res.Body.Close()
118109
}
119110
if err != nil {
120111
return nil, ErrInternalServerError
121112
}
122-
defer res.Body.Close()
123113
var doc apiNoteURL
124114
if _, err := jsonapi.Bind(res.Body, &doc); err != nil {
125115
return nil, err

model/sharing/open_office.go

Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"bytes"
55
"net/url"
66

7-
"github.com/cozy/cozy-stack/client/request"
87
"github.com/cozy/cozy-stack/model/instance"
98
"github.com/cozy/cozy-stack/model/office"
109
"github.com/cozy/cozy-stack/model/settings"
@@ -138,12 +137,15 @@ func OpenOffice(inst *instance.Instance, fileID string) (*OfficeOpener, error) {
138137
// used in case of a shared office document, and other parameters.. and returns
139138
// the information.
140139
func (o *OfficeOpener) GetResult(memberIndex int, readOnly bool) (jsonapi.Object, error) {
140+
prepared, err := o.PrepareOpenFileRequest(memberIndex, readOnly)
141+
if err != nil {
142+
return nil, err
143+
}
141144
var result *apiOfficeURL
142-
var err error
143-
if o.ShouldOpenLocally() {
144-
result, err = o.openLocalDocument(memberIndex, readOnly)
145+
if prepared.Opts == nil {
146+
result, err = o.openLocalDocument(prepared.MemberIndex, prepared.ReadOnly)
145147
} else {
146-
result, err = o.openSharedDocument()
148+
result, err = o.openSharedDocument(prepared)
147149
}
148150
if err != nil {
149151
return nil, err
@@ -160,11 +162,10 @@ func (o *OfficeOpener) openLocalDocument(memberIndex int, readOnly bool) (*apiOf
160162
}
161163

162164
// Create a local result
163-
code, err := o.GetSharecode(memberIndex, readOnly)
165+
params, err := o.OpenLocalFileForMember(memberIndex, readOnly)
164166
if err != nil {
165167
return nil, err
166168
}
167-
params := o.OpenLocalFile(code)
168169
doc := apiOfficeURL{
169170
DocID: params.FileID,
170171
Protocol: params.Protocol,
@@ -246,20 +247,10 @@ func (o *OfficeOpener) openLocalDocument(memberIndex int, readOnly bool) (*apiOf
246247
return &doc, nil
247248
}
248249

249-
func (o *OfficeOpener) openSharedDocument() (*apiOfficeURL, error) {
250-
prepared, err := o.PrepareRequestForSharedFile()
251-
if err != nil {
252-
return nil, err
253-
}
254-
if prepared.Opts == nil {
255-
return o.openLocalDocument(prepared.MemberIndex, prepared.ReadOnly)
256-
}
257-
258-
prepared.Opts.Path = "/office/" + prepared.XoredID + "/open"
259-
res, err := request.Req(prepared.Opts)
260-
if res != nil && res.StatusCode/100 == 4 {
261-
res, err = RefreshToken(o.Inst, res, err, o.Sharing, prepared.Creator,
262-
prepared.Creds, prepared.Opts, nil)
250+
func (o *OfficeOpener) openSharedDocument(prepared *PreparedRequest) (*apiOfficeURL, error) {
251+
res, err := o.RequestSharedFile(prepared, "/office/"+prepared.XoredID+"/open")
252+
if res != nil {
253+
defer res.Body.Close()
263254
}
264255
if res != nil && res.StatusCode == 404 {
265256
return o.openLocalDocument(prepared.MemberIndex, prepared.ReadOnly)
@@ -268,7 +259,6 @@ func (o *OfficeOpener) openSharedDocument() (*apiOfficeURL, error) {
268259
o.Inst.Logger().WithNamespace("office").Infof("openSharedDocument error: %s", err)
269260
return nil, ErrInternalServerError
270261
}
271-
defer res.Body.Close()
272262
var doc apiOfficeURL
273263
if _, err := jsonapi.Bind(res.Body, &doc); err != nil {
274264
return nil, err

0 commit comments

Comments
 (0)