Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/files.md
Original file line number Diff line number Diff line change
Expand Up @@ -1758,6 +1758,12 @@ restored. Or, after some time, it will be removed from the trash and permanently
destroyed.

The file `trashed` attribute will be set to true.
When a file or folder is moved to the trash, `cozyMetadata` also records:

- `trashedAt`: the server timestamp of the trash action
- `trashedBy`: the request actor that triggered the trash action, with:
- `kind`: `member` or `anonymous-share`
- `displayName` and `domain` for `member`

### GET /files/trash

Expand Down Expand Up @@ -1814,6 +1820,12 @@ Content-Type: application/vnd.api+json
"createdByApp": "drive",
"createdOn": "https://cozy.example.com/",
"updatedAt": "2016-09-20T18:32:49Z",
"trashedAt": "2016-09-20T18:32:49Z",
"trashedBy": {
"kind": "member",
"displayName": "Alice",
"domain": "cozy.example.com"
},
"uploadedAt": "2016-09-20T18:32:49Z",
"uploadedOn": "https://cozy.example.com/",
"uploadedBy": {
Expand Down Expand Up @@ -1852,6 +1864,12 @@ Content-Type: application/vnd.api+json
"createdByApp": "drive",
"createdOn": "https://cozy.example.com/",
"updatedAt": "2016-09-20T18:32:49Z",
"trashedAt": "2016-09-20T18:32:49Z",
"trashedBy": {
"kind": "member",
"displayName": "Alice",
"domain": "cozy.example.com"
},
"uploadedAt": "2016-09-20T18:32:49Z",
"uploadedOn": "https://cozy.example.com/",
"uploadedBy": {
Expand Down
61 changes: 50 additions & 11 deletions docs/shared-drives.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,23 @@ A shared drive is a folder that is shared between several cozy instances. A
member doesn't have the files in their Cozy, but can access them via the stack
playing a proxy role.

For drives created on an organization instance, the sharing payload also
exposes `org_drive: true`. This is an additive classification flag for clients;
it does not change the underlying drive-sharing behavior.

## Creating a shared drive

There are two ways to create a shared drive:

### Simple method: Convert an existing folder
### Simple method: Use `POST /sharings/drives`

Use the [`POST /sharings/drives`](#post-sharingsdrives) endpoint either to:

- convert an existing folder into a shared drive with `folder_id`
- create a brand new shared-drive folder with `name`

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

### Manual method

Expand All @@ -34,6 +42,9 @@ these steps:

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

When a drive was created on an organization instance, its attributes include
`org_drive: true`.

#### Request

```http
Expand All @@ -57,6 +68,7 @@ Content-Type: application/vnd.api+json
"id": "aae62886e79611ef8381fb83ff72e425",
"attributes": {
"drive": true,
"org_drive": true,
"owner": true,
"description": "Drive for the product team",
"app_slug": "drive",
Expand Down Expand Up @@ -106,11 +118,15 @@ Content-Type: application/vnd.api+json

### POST /sharings/drives

Creates a new shared drive from an existing folder. This is an alternative to
the manual process of creating a sharing with `drive: true` - it automatically
validates the folder and creates the sharing with appropriate rules.
Creates a new shared drive. The endpoint supports two mutually exclusive modes:

- pass `folder_id` to convert an existing folder into a shared drive
- pass `name` to create a new folder under the Shared Drives root and share it

The folder must:
If the target Cozy is an organization instance, the created sharing is also
marked with `org_drive: true`.

When `folder_id` is used, the folder must:

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

Or create a brand new shared drive directly:

```json
{
"data": {
"type": "io.cozy.sharings",
"attributes": {
"name": "Product Team"
}
}
}
```

**Attributes:**

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

Exactly one of `folder_id` or `name` must be provided.

**Relationships:**

| Relationship | Description |
Expand Down Expand Up @@ -231,8 +263,8 @@ Content-Type: application/vnd.api+json
| 400 | Bad Request | Invalid JSON body |
| 403 | Forbidden | Insufficient permissions to create a sharing |
| 404 | Not Found | The folder with the given `folder_id` does not exist |
| 409 | Conflict | The folder already has a sharing, is inside a shared folder, or contains a shared subfolder |
| 422 | Unprocessable Entity | Missing `folder_id`, folder is a file, or folder is a system folder |
| 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 |
| 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 |

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

Expand Down Expand Up @@ -737,6 +769,13 @@ drive.
#### POST /sharings/drives/trash/:file-id
#### DELETE /sharings/drives/trash/:file-id

Trash operations keep the same file metadata shape as `/files`: when an item is
moved to the trash, `cozyMetadata.trashedAt` and `cozyMetadata.trashedBy` are
exposed on both the caller side and the replicated shared-drive copies. On
shared-drive requests, `trashedBy.kind` is `member` and
`trashedBy.displayName` / `trashedBy.domain` are derived from the sharing
member linked to the `DriveToken`.

## Share-by-link permissions

The following routes manage share-by-link permissions scoped to files inside a
Expand Down
1 change: 1 addition & 0 deletions docs/sharing-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,7 @@ care of it later.
new recipient
- `false` if only the owner can add a new recipient
- A flag `drive`, that is false for a synchronized sharing
- A flag `org_drive`, present for drives created on an organization instance
- Some technical data (`created_at`, `updated_at`, `app_slug`, `preview_path`,
`triggers`, `credentials`)
- A flag `initial_sync` present only when the initial replication is still
Expand Down
10 changes: 10 additions & 0 deletions model/instance/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,16 @@ func (i *Instance) SlugAndDomain() (string, string) {
return splitted[0], splitted[1]
}

// IsOrganizationInstance returns true when the instance's canonical slug
// matches its organization ID.
func (i *Instance) IsOrganizationInstance() bool {
if i == nil || i.OrgID == "" {
return false
}
slug, _, found := strings.Cut(i.Domain, ".")
return found && slug != "" && slug == i.OrgID
}

// Logger returns the logger associated with the instance
func (i *Instance) Logger() *logger.Entry {
return logger.WithDomain(i.Domain)
Expand Down
23 changes: 23 additions & 0 deletions model/instance/instance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,4 +176,27 @@ func TestInstance(t *testing.T) {
}`
assert.Equal(t, expected, string(bytes))
})

t.Run("IsOrganizationInstance", func(t *testing.T) {
t.Run("TrueWhenSlugMatchesOrgID", func(t *testing.T) {
inst := &instance.Instance{
Domain: "alice.twake.app",
OrgID: "alice",
}
assert.True(t, inst.IsOrganizationInstance())
})

t.Run("FalseWhenSlugDoesNotMatchOrgID", func(t *testing.T) {
inst := &instance.Instance{
Domain: "bob.twake.app",
OrgID: "alice",
}
assert.False(t, inst.IsOrganizationInstance())
})

t.Run("FalseWhenOrgIDIsEmpty", func(t *testing.T) {
inst := &instance.Instance{Domain: "alice.twake.app"}
assert.False(t, inst.IsOrganizationInstance())
})
})
}
20 changes: 20 additions & 0 deletions model/sharing/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -909,6 +909,11 @@ func copySafeFieldsToDir(target map[string]interface{}, dir *vfs.DirDoc) {
dir.CozyMetadata.UpdatedAt = at
}
}
if trashed, ok := meta["trashedAt"].(string); ok {
if at, err := time.Parse(time.RFC3339Nano, trashed); err == nil {
dir.CozyMetadata.TrashedAt = &at
}
}
if updates, ok := meta["updatedByApps"].([]map[string]interface{}); ok {
for _, update := range updates {
if slug, ok := update["slug"].(string); ok {
Expand All @@ -928,6 +933,21 @@ func copySafeFieldsToDir(target map[string]interface{}, dir *vfs.DirDoc) {
}
}
}
if trashedBy, ok := meta["trashedBy"].(map[string]interface{}); ok {
entry := &vfs.TrashedByEntry{}
if kind, ok := trashedBy["kind"].(string); ok {
entry.Kind = kind
}
if displayName, ok := trashedBy["displayName"].(string); ok {
entry.DisplayName = displayName
}
if domain, ok := trashedBy["domain"].(string); ok {
entry.Domain = domain
} else if legacyInstance, ok := trashedBy["instance"].(string); ok {
entry.Domain = legacyInstance
}
dir.CozyMetadata.TrashedBy = entry
}

// No upload* for directories
if account, ok := meta["sourceAccount"].(string); ok {
Expand Down
1 change: 1 addition & 0 deletions model/sharing/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ func (m *Member) CreateSharingRequest(inst *instance.Instance, s *Sharing, c *Cr
&Sharing{
SID: s.SID,
Drive: s.Drive,
OrgDrive: s.OrgDrive,
Active: false,
Owner: false,
Open: s.Open,
Expand Down
1 change: 1 addition & 0 deletions model/sharing/sharing.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ type Sharing struct {

Triggers Triggers `json:"triggers"`
Drive bool `json:"drive,omitempty"`
OrgDrive bool `json:"org_drive,omitempty"` // True for drives created on an organization instance
Active bool `json:"active,omitempty"`
Owner bool `json:"owner,omitempty"`
Open bool `json:"open_sharing,omitempty"`
Expand Down
45 changes: 45 additions & 0 deletions model/vfs/cozy_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@ type UploadedByEntry struct {
Client map[string]string `json:"oauthClient,omitempty"`
}

const (
// TrashedByKindMember identifies a concrete authenticated/member actor.
TrashedByKindMember = "member"
// TrashedByKindAnonymousShare identifies anonymous/public share access.
TrashedByKindAnonymousShare = "anonymous-share"
)

// TrashedByEntry identifies who sent a file or folder to the trash.
type TrashedByEntry struct {
Kind string `json:"kind,omitempty"`
DisplayName string `json:"displayName,omitempty"`
Domain string `json:"domain,omitempty"`
}

// FilesCozyMetadata is an extended version of cozyMetadata with some specific fields.
type FilesCozyMetadata struct {
metadata.CozyMetadata
Expand All @@ -29,6 +43,10 @@ type FilesCozyMetadata struct {
UploadedBy *UploadedByEntry `json:"uploadedBy,omitempty"`
// Instance URL where the content has been changed the last time
UploadedOn string `json:"uploadedOn,omitempty"`
// Date of the last trash action
TrashedAt *time.Time `json:"trashedAt,omitempty"`
// Information about who sent the file or folder to the trash
TrashedBy *TrashedByEntry `json:"trashedBy,omitempty"`
}

// NewCozyMetadata initializes a new FilesCozyMetadata struct
Expand Down Expand Up @@ -65,6 +83,17 @@ func (fcm *FilesCozyMetadata) Clone() *FilesCozyMetadata {
at := *fcm.UploadedAt
cloned.UploadedAt = &at
}
if fcm.TrashedAt != nil {
at := *fcm.TrashedAt
cloned.TrashedAt = &at
}
if fcm.TrashedBy != nil {
cloned.TrashedBy = &TrashedByEntry{
Kind: fcm.TrashedBy.Kind,
DisplayName: fcm.TrashedBy.DisplayName,
Domain: fcm.TrashedBy.Domain,
}
}
return &cloned
}

Expand Down Expand Up @@ -149,6 +178,22 @@ func (fcm *FilesCozyMetadata) ToJSONDoc() map[string]interface{} {
if fcm.UploadedOn != "" {
doc["uploadedOn"] = fcm.UploadedOn
}
if fcm.TrashedAt != nil {
doc["trashedAt"] = *fcm.TrashedAt
}
if fcm.TrashedBy != nil {
trashed := make(map[string]interface{})
if fcm.TrashedBy.Kind != "" {
trashed["kind"] = fcm.TrashedBy.Kind
}
if fcm.TrashedBy.DisplayName != "" {
trashed["displayName"] = fcm.TrashedBy.DisplayName
}
if fcm.TrashedBy.Domain != "" {
trashed["domain"] = fcm.TrashedBy.Domain
}
doc["trashedBy"] = trashed
}
if fcm.SourceAccount != "" {
doc["sourceAccount"] = fcm.SourceAccount
}
Expand Down
30 changes: 30 additions & 0 deletions model/vfs/cozy_metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,33 @@ func TestUpdatedByApp(t *testing.T) {
assert.Equal(t, "alice.cozy.localhost", fcm.UpdatedByApps[2].Instance)
assert.Equal(t, entry.Date, fcm.UpdatedByApps[2].Date)
}

func TestFilesCozyMetadataCloneAndToJSONDoc(t *testing.T) {
trashedAt := time.Now().UTC().Round(0)
fcm := NewCozyMetadata("alice.cozy.localhost")
fcm.TrashedAt = &trashedAt
fcm.TrashedBy = &TrashedByEntry{
Kind: TrashedByKindMember,
DisplayName: "Alice",
Domain: "alice.cozy.localhost",
}

cloned := fcm.Clone()
if assert.NotNil(t, cloned.TrashedAt) {
assert.Equal(t, trashedAt, *cloned.TrashedAt)
}
if assert.NotNil(t, cloned.TrashedBy) {
assert.Equal(t, fcm.TrashedBy, cloned.TrashedBy)
cloned.TrashedBy.DisplayName = "Bob"
assert.Equal(t, "Alice", fcm.TrashedBy.DisplayName)
}

doc := fcm.ToJSONDoc()
assert.Equal(t, trashedAt, doc["trashedAt"])
trashedBy, ok := doc["trashedBy"].(map[string]interface{})
if assert.True(t, ok) {
assert.Equal(t, TrashedByKindMember, trashedBy["kind"])
assert.Equal(t, "Alice", trashedBy["displayName"])
assert.Equal(t, "alice.cozy.localhost", trashedBy["domain"])
}
}
Loading
Loading