Skip to content

Commit 208560e

Browse files
authored
Shared drives in admin panel (#4718)
## Summary - allow `POST /sharings/drives` to create a new shared drive directly - expose `org_drive` so clients can distinguish organization "shared drives" - track who trashed a file or folder via `cozyMetadata` ## Changes #### Add `org_drive` for organization-backed drives Shared drives created from an organization instance are now marked with the `org_drive` flag. This includes: - a new `Instance.IsOrganizationInstance()` helper - `org_drive` on sharing payloads - propagation of the flag to clients and recipients #### Track trash actors in `cozyMetadata` When a file or folder is moved to trash, the stack now stores: - `trashedAt` - `trashedBy` `trashedBy` is typed and currently supports: - `kind: "member"` - `kind: "anonymous-share"` For member actors, the payload also includes: - `displayName` - `domain` This metadata is resolved in middleware and then written by the files layer, so the metadata is preserved across normal access, shared-drive access, and replication flows.
2 parents bed84d4 + 460e3c6 commit 208560e

18 files changed

Lines changed: 880 additions & 31 deletions

docs/files.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1758,6 +1758,12 @@ restored. Or, after some time, it will be removed from the trash and permanently
17581758
destroyed.
17591759

17601760
The file `trashed` attribute will be set to true.
1761+
When a file or folder is moved to the trash, `cozyMetadata` also records:
1762+
1763+
- `trashedAt`: the server timestamp of the trash action
1764+
- `trashedBy`: the request actor that triggered the trash action, with:
1765+
- `kind`: `member` or `anonymous-share`
1766+
- `displayName` and `domain` for `member`
17611767

17621768
### GET /files/trash
17631769

@@ -1814,6 +1820,12 @@ Content-Type: application/vnd.api+json
18141820
"createdByApp": "drive",
18151821
"createdOn": "https://cozy.example.com/",
18161822
"updatedAt": "2016-09-20T18:32:49Z",
1823+
"trashedAt": "2016-09-20T18:32:49Z",
1824+
"trashedBy": {
1825+
"kind": "member",
1826+
"displayName": "Alice",
1827+
"domain": "cozy.example.com"
1828+
},
18171829
"uploadedAt": "2016-09-20T18:32:49Z",
18181830
"uploadedOn": "https://cozy.example.com/",
18191831
"uploadedBy": {
@@ -1852,6 +1864,12 @@ Content-Type: application/vnd.api+json
18521864
"createdByApp": "drive",
18531865
"createdOn": "https://cozy.example.com/",
18541866
"updatedAt": "2016-09-20T18:32:49Z",
1867+
"trashedAt": "2016-09-20T18:32:49Z",
1868+
"trashedBy": {
1869+
"kind": "member",
1870+
"displayName": "Alice",
1871+
"domain": "cozy.example.com"
1872+
},
18551873
"uploadedAt": "2016-09-20T18:32:49Z",
18561874
"uploadedOn": "https://cozy.example.com/",
18571875
"uploadedBy": {

docs/shared-drives.md

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,23 @@ A shared drive is a folder that is shared between several cozy instances. A
66
member doesn't have the files in their Cozy, but can access them via the stack
77
playing a proxy role.
88

9+
For drives created on an organization instance, the sharing payload also
10+
exposes `org_drive: true`. This is an additive classification flag for clients;
11+
it does not change the underlying drive-sharing behavior.
12+
913
## Creating a shared drive
1014

1115
There are two ways to create a shared drive:
1216

13-
### Simple method: Convert an existing folder
17+
### Simple method: Use `POST /sharings/drives`
18+
19+
Use the [`POST /sharings/drives`](#post-sharingsdrives) endpoint either to:
20+
21+
- convert an existing folder into a shared drive with `folder_id`
22+
- create a brand new shared-drive folder with `name`
1423

15-
Use the [`POST /sharings/drives`](#post-sharingsdrives) endpoint to convert any
16-
existing folder into a shared drive. This is the recommended approach as it
17-
handles all validation and setup automatically.
24+
This is the recommended approach as it handles validation and setup
25+
automatically.
1826

1927
### Manual method
2028

@@ -34,6 +42,9 @@ these steps:
3442

3543
The `GET /sharings/drives` route returns the list of shared drives.
3644

45+
When a drive was created on an organization instance, its attributes include
46+
`org_drive: true`.
47+
3748
#### Request
3849

3950
```http
@@ -57,6 +68,7 @@ Content-Type: application/vnd.api+json
5768
"id": "aae62886e79611ef8381fb83ff72e425",
5869
"attributes": {
5970
"drive": true,
71+
"org_drive": true,
6072
"owner": true,
6173
"description": "Drive for the product team",
6274
"app_slug": "drive",
@@ -106,11 +118,15 @@ Content-Type: application/vnd.api+json
106118

107119
### POST /sharings/drives
108120

109-
Creates a new shared drive from an existing folder. This is an alternative to
110-
the manual process of creating a sharing with `drive: true` - it automatically
111-
validates the folder and creates the sharing with appropriate rules.
121+
Creates a new shared drive. The endpoint supports two mutually exclusive modes:
122+
123+
- pass `folder_id` to convert an existing folder into a shared drive
124+
- pass `name` to create a new folder under the Shared Drives root and share it
112125

113-
The folder must:
126+
If the target Cozy is an organization instance, the created sharing is also
127+
marked with `org_drive: true`.
128+
129+
When `folder_id` is used, the folder must:
114130

115131
- Exist and be a directory (not a file)
116132
- Not be a system folder (root, trash, shared-with-me, shared-drives, no-longer-shared)
@@ -152,13 +168,29 @@ Accept: application/vnd.api+json
152168
}
153169
```
154170

171+
Or create a brand new shared drive directly:
172+
173+
```json
174+
{
175+
"data": {
176+
"type": "io.cozy.sharings",
177+
"attributes": {
178+
"name": "Product Team"
179+
}
180+
}
181+
}
182+
```
183+
155184
**Attributes:**
156185

157186
| Attribute | Required | Description |
158187
|---------------|----------|-------------|
159-
| `folder_id` | Yes | The ID of the existing folder to convert into a shared drive |
188+
| `folder_id` | No | The ID of the existing folder to convert into a shared drive |
189+
| `name` | No | The name of the folder to create under Shared Drives for a new shared drive |
160190
| `description` | No | A description for the shared drive. If not provided, defaults to the folder name |
161191

192+
Exactly one of `folder_id` or `name` must be provided.
193+
162194
**Relationships:**
163195

164196
| Relationship | Description |
@@ -231,8 +263,8 @@ Content-Type: application/vnd.api+json
231263
| 400 | Bad Request | Invalid JSON body |
232264
| 403 | Forbidden | Insufficient permissions to create a sharing |
233265
| 404 | Not Found | The folder with the given `folder_id` does not exist |
234-
| 409 | Conflict | The folder already has a sharing, is inside a shared folder, or contains a shared subfolder |
235-
| 422 | Unprocessable Entity | Missing `folder_id`, folder is a file, or folder is a system folder |
266+
| 409 | Conflict | The folder already has a sharing, is inside a shared folder, contains a shared subfolder, or the new `name` already exists in Shared Drives |
267+
| 422 | Unprocessable Entity | Invalid request: missing both `folder_id` and `name`, both provided together, folder is a file, folder is a system folder, or the new `name` is invalid |
236268

237269
**Example error (folder already shared):**
238270

@@ -737,6 +769,13 @@ drive.
737769
#### POST /sharings/drives/trash/:file-id
738770
#### DELETE /sharings/drives/trash/:file-id
739771

772+
Trash operations keep the same file metadata shape as `/files`: when an item is
773+
moved to the trash, `cozyMetadata.trashedAt` and `cozyMetadata.trashedBy` are
774+
exposed on both the caller side and the replicated shared-drive copies. On
775+
shared-drive requests, `trashedBy.kind` is `member` and
776+
`trashedBy.displayName` / `trashedBy.domain` are derived from the sharing
777+
member linked to the `DriveToken`.
778+
740779
## Share-by-link permissions
741780

742781
The following routes manage share-by-link permissions scoped to files inside a

docs/sharing-design.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,7 @@ care of it later.
519519
new recipient
520520
- `false` if only the owner can add a new recipient
521521
- A flag `drive`, that is false for a synchronized sharing
522+
- A flag `org_drive`, present for drives created on an organization instance
522523
- Some technical data (`created_at`, `updated_at`, `app_slug`, `preview_path`,
523524
`triggers`, `credentials`)
524525
- A flag `initial_sync` present only when the initial replication is still

model/instance/instance.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,16 @@ func (i *Instance) SlugAndDomain() (string, string) {
217217
return splitted[0], splitted[1]
218218
}
219219

220+
// IsOrganizationInstance returns true when the instance's canonical slug
221+
// matches its organization ID.
222+
func (i *Instance) IsOrganizationInstance() bool {
223+
if i == nil || i.OrgID == "" {
224+
return false
225+
}
226+
slug, _, found := strings.Cut(i.Domain, ".")
227+
return found && slug != "" && slug == i.OrgID
228+
}
229+
220230
// Logger returns the logger associated with the instance
221231
func (i *Instance) Logger() *logger.Entry {
222232
return logger.WithDomain(i.Domain)

model/instance/instance_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,4 +176,27 @@ func TestInstance(t *testing.T) {
176176
}`
177177
assert.Equal(t, expected, string(bytes))
178178
})
179+
180+
t.Run("IsOrganizationInstance", func(t *testing.T) {
181+
t.Run("TrueWhenSlugMatchesOrgID", func(t *testing.T) {
182+
inst := &instance.Instance{
183+
Domain: "alice.twake.app",
184+
OrgID: "alice",
185+
}
186+
assert.True(t, inst.IsOrganizationInstance())
187+
})
188+
189+
t.Run("FalseWhenSlugDoesNotMatchOrgID", func(t *testing.T) {
190+
inst := &instance.Instance{
191+
Domain: "bob.twake.app",
192+
OrgID: "alice",
193+
}
194+
assert.False(t, inst.IsOrganizationInstance())
195+
})
196+
197+
t.Run("FalseWhenOrgIDIsEmpty", func(t *testing.T) {
198+
inst := &instance.Instance{Domain: "alice.twake.app"}
199+
assert.False(t, inst.IsOrganizationInstance())
200+
})
201+
})
179202
}

model/sharing/files.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -909,6 +909,11 @@ func copySafeFieldsToDir(target map[string]interface{}, dir *vfs.DirDoc) {
909909
dir.CozyMetadata.UpdatedAt = at
910910
}
911911
}
912+
if trashed, ok := meta["trashedAt"].(string); ok {
913+
if at, err := time.Parse(time.RFC3339Nano, trashed); err == nil {
914+
dir.CozyMetadata.TrashedAt = &at
915+
}
916+
}
912917
if updates, ok := meta["updatedByApps"].([]map[string]interface{}); ok {
913918
for _, update := range updates {
914919
if slug, ok := update["slug"].(string); ok {
@@ -928,6 +933,21 @@ func copySafeFieldsToDir(target map[string]interface{}, dir *vfs.DirDoc) {
928933
}
929934
}
930935
}
936+
if trashedBy, ok := meta["trashedBy"].(map[string]interface{}); ok {
937+
entry := &vfs.TrashedByEntry{}
938+
if kind, ok := trashedBy["kind"].(string); ok {
939+
entry.Kind = kind
940+
}
941+
if displayName, ok := trashedBy["displayName"].(string); ok {
942+
entry.DisplayName = displayName
943+
}
944+
if domain, ok := trashedBy["domain"].(string); ok {
945+
entry.Domain = domain
946+
} else if legacyInstance, ok := trashedBy["instance"].(string); ok {
947+
entry.Domain = legacyInstance
948+
}
949+
dir.CozyMetadata.TrashedBy = entry
950+
}
931951

932952
// No upload* for directories
933953
if account, ok := meta["sourceAccount"].(string); ok {

model/sharing/oauth.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ func (m *Member) CreateSharingRequest(inst *instance.Instance, s *Sharing, c *Cr
101101
&Sharing{
102102
SID: s.SID,
103103
Drive: s.Drive,
104+
OrgDrive: s.OrgDrive,
104105
Active: false,
105106
Owner: false,
106107
Open: s.Open,

model/sharing/sharing.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ type Sharing struct {
5656

5757
Triggers Triggers `json:"triggers"`
5858
Drive bool `json:"drive,omitempty"`
59+
OrgDrive bool `json:"org_drive,omitempty"` // True for drives created on an organization instance
5960
Active bool `json:"active,omitempty"`
6061
Owner bool `json:"owner,omitempty"`
6162
Open bool `json:"open_sharing,omitempty"`

model/vfs/cozy_metadata.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,20 @@ type UploadedByEntry struct {
1818
Client map[string]string `json:"oauthClient,omitempty"`
1919
}
2020

21+
const (
22+
// TrashedByKindMember identifies a concrete authenticated/member actor.
23+
TrashedByKindMember = "member"
24+
// TrashedByKindAnonymousShare identifies anonymous/public share access.
25+
TrashedByKindAnonymousShare = "anonymous-share"
26+
)
27+
28+
// TrashedByEntry identifies who sent a file or folder to the trash.
29+
type TrashedByEntry struct {
30+
Kind string `json:"kind,omitempty"`
31+
DisplayName string `json:"displayName,omitempty"`
32+
Domain string `json:"domain,omitempty"`
33+
}
34+
2135
// FilesCozyMetadata is an extended version of cozyMetadata with some specific fields.
2236
type FilesCozyMetadata struct {
2337
metadata.CozyMetadata
@@ -29,6 +43,10 @@ type FilesCozyMetadata struct {
2943
UploadedBy *UploadedByEntry `json:"uploadedBy,omitempty"`
3044
// Instance URL where the content has been changed the last time
3145
UploadedOn string `json:"uploadedOn,omitempty"`
46+
// Date of the last trash action
47+
TrashedAt *time.Time `json:"trashedAt,omitempty"`
48+
// Information about who sent the file or folder to the trash
49+
TrashedBy *TrashedByEntry `json:"trashedBy,omitempty"`
3250
}
3351

3452
// NewCozyMetadata initializes a new FilesCozyMetadata struct
@@ -65,6 +83,17 @@ func (fcm *FilesCozyMetadata) Clone() *FilesCozyMetadata {
6583
at := *fcm.UploadedAt
6684
cloned.UploadedAt = &at
6785
}
86+
if fcm.TrashedAt != nil {
87+
at := *fcm.TrashedAt
88+
cloned.TrashedAt = &at
89+
}
90+
if fcm.TrashedBy != nil {
91+
cloned.TrashedBy = &TrashedByEntry{
92+
Kind: fcm.TrashedBy.Kind,
93+
DisplayName: fcm.TrashedBy.DisplayName,
94+
Domain: fcm.TrashedBy.Domain,
95+
}
96+
}
6897
return &cloned
6998
}
7099

@@ -149,6 +178,22 @@ func (fcm *FilesCozyMetadata) ToJSONDoc() map[string]interface{} {
149178
if fcm.UploadedOn != "" {
150179
doc["uploadedOn"] = fcm.UploadedOn
151180
}
181+
if fcm.TrashedAt != nil {
182+
doc["trashedAt"] = *fcm.TrashedAt
183+
}
184+
if fcm.TrashedBy != nil {
185+
trashed := make(map[string]interface{})
186+
if fcm.TrashedBy.Kind != "" {
187+
trashed["kind"] = fcm.TrashedBy.Kind
188+
}
189+
if fcm.TrashedBy.DisplayName != "" {
190+
trashed["displayName"] = fcm.TrashedBy.DisplayName
191+
}
192+
if fcm.TrashedBy.Domain != "" {
193+
trashed["domain"] = fcm.TrashedBy.Domain
194+
}
195+
doc["trashedBy"] = trashed
196+
}
152197
if fcm.SourceAccount != "" {
153198
doc["sourceAccount"] = fcm.SourceAccount
154199
}

model/vfs/cozy_metadata_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,33 @@ func TestUpdatedByApp(t *testing.T) {
8989
assert.Equal(t, "alice.cozy.localhost", fcm.UpdatedByApps[2].Instance)
9090
assert.Equal(t, entry.Date, fcm.UpdatedByApps[2].Date)
9191
}
92+
93+
func TestFilesCozyMetadataCloneAndToJSONDoc(t *testing.T) {
94+
trashedAt := time.Now().UTC().Round(0)
95+
fcm := NewCozyMetadata("alice.cozy.localhost")
96+
fcm.TrashedAt = &trashedAt
97+
fcm.TrashedBy = &TrashedByEntry{
98+
Kind: TrashedByKindMember,
99+
DisplayName: "Alice",
100+
Domain: "alice.cozy.localhost",
101+
}
102+
103+
cloned := fcm.Clone()
104+
if assert.NotNil(t, cloned.TrashedAt) {
105+
assert.Equal(t, trashedAt, *cloned.TrashedAt)
106+
}
107+
if assert.NotNil(t, cloned.TrashedBy) {
108+
assert.Equal(t, fcm.TrashedBy, cloned.TrashedBy)
109+
cloned.TrashedBy.DisplayName = "Bob"
110+
assert.Equal(t, "Alice", fcm.TrashedBy.DisplayName)
111+
}
112+
113+
doc := fcm.ToJSONDoc()
114+
assert.Equal(t, trashedAt, doc["trashedAt"])
115+
trashedBy, ok := doc["trashedBy"].(map[string]interface{})
116+
if assert.True(t, ok) {
117+
assert.Equal(t, TrashedByKindMember, trashedBy["kind"])
118+
assert.Equal(t, "Alice", trashedBy["displayName"])
119+
assert.Equal(t, "alice.cozy.localhost", trashedBy["domain"])
120+
}
121+
}

0 commit comments

Comments
 (0)