diff --git a/docs/files.md b/docs/files.md index f39aa287b37..41c618706fe 100644 --- a/docs/files.md +++ b/docs/files.md @@ -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 @@ -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": { @@ -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": { diff --git a/docs/shared-drives.md b/docs/shared-drives.md index 1c690d86731..9c79da51907 100644 --- a/docs/shared-drives.md +++ b/docs/shared-drives.md @@ -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 @@ -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 @@ -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", @@ -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) @@ -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 | @@ -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):** @@ -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 diff --git a/docs/sharing-design.md b/docs/sharing-design.md index c9f63a86564..8cea36d470a 100644 --- a/docs/sharing-design.md +++ b/docs/sharing-design.md @@ -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 diff --git a/model/instance/instance.go b/model/instance/instance.go index b8c97507bfe..6159123f452 100644 --- a/model/instance/instance.go +++ b/model/instance/instance.go @@ -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) diff --git a/model/instance/instance_test.go b/model/instance/instance_test.go index d2c8837c56c..36133afd9f5 100644 --- a/model/instance/instance_test.go +++ b/model/instance/instance_test.go @@ -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()) + }) + }) } diff --git a/model/sharing/files.go b/model/sharing/files.go index fe42a6ea938..43b1f03d6fd 100644 --- a/model/sharing/files.go +++ b/model/sharing/files.go @@ -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 { @@ -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 { diff --git a/model/sharing/oauth.go b/model/sharing/oauth.go index 9d5a1be403c..cc77bc7f446 100644 --- a/model/sharing/oauth.go +++ b/model/sharing/oauth.go @@ -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, diff --git a/model/sharing/sharing.go b/model/sharing/sharing.go index e47b338cc0d..92d39b1da4f 100644 --- a/model/sharing/sharing.go +++ b/model/sharing/sharing.go @@ -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"` diff --git a/model/vfs/cozy_metadata.go b/model/vfs/cozy_metadata.go index ad8e8b445a6..331ca81bc8f 100644 --- a/model/vfs/cozy_metadata.go +++ b/model/vfs/cozy_metadata.go @@ -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 @@ -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 @@ -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 } @@ -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 } diff --git a/model/vfs/cozy_metadata_test.go b/model/vfs/cozy_metadata_test.go index a0353ad8981..677050f71f6 100644 --- a/model/vfs/cozy_metadata_test.go +++ b/model/vfs/cozy_metadata_test.go @@ -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"]) + } +} diff --git a/web/files/files.go b/web/files/files.go index 876de92b3c3..c0927900b55 100644 --- a/web/files/files.go +++ b/web/files/files.go @@ -731,10 +731,10 @@ func applyPatch(c echo.Context, fs vfs.VFS, patch *docPatch) (err error) { } } else if patch.Trash { if dir != nil { - UpdateDirCozyMetadata(c, dir) + UpdateDirTrashCozyMetadata(c, dir) dir, err = vfs.TrashDir(fs, dir) } else { - UpdateFileCozyMetadata(c, file, false) + UpdateFileTrashCozyMetadata(c, file) file, err = vfs.TrashFile(fs, file) } } else { @@ -783,10 +783,10 @@ func applyPatches(c echo.Context, fs vfs.VFS, patches []*docPatch) (errors []*js } } else if patch.Trash { if dir != nil { - UpdateDirCozyMetadata(c, dir) + UpdateDirTrashCozyMetadata(c, dir) _, errp = vfs.TrashDir(fs, dir) } else if file != nil { - UpdateFileCozyMetadata(c, file, false) + UpdateFileTrashCozyMetadata(c, file) _, errp = vfs.TrashFile(fs, file) } } else if dir != nil { @@ -1446,7 +1446,7 @@ func Trash(c echo.Context, sharedDrive *sharing.Sharing) error { ensureCleanOldTrashedTrigger(instance) if dir != nil { - UpdateDirCozyMetadata(c, dir) + UpdateDirTrashCozyMetadata(c, dir) doc, errt := vfs.TrashDir(instance.VFS(), dir) if errt != nil { return WrapVfsError(errt) @@ -1454,7 +1454,7 @@ func Trash(c echo.Context, sharedDrive *sharing.Sharing) error { return DirData(c, http.StatusOK, doc, sharedDrive) } - UpdateFileCozyMetadata(c, file, false) + UpdateFileTrashCozyMetadata(c, file) doc, errt := vfs.TrashFile(instance.VFS(), file) if errt != nil { return WrapVfsError(errt) @@ -2489,6 +2489,33 @@ func UpdateFileCozyMetadata(c echo.Context, file *vfs.FileDoc, setUploadFields b } } +func UpdateDirTrashCozyMetadata(c echo.Context, dir *vfs.DirDoc) { + UpdateDirCozyMetadata(c, dir) + setTrashCozyMetadata(c, dir.CozyMetadata) +} + +func UpdateFileTrashCozyMetadata(c echo.Context, file *vfs.FileDoc) { + UpdateFileCozyMetadata(c, file, false) + setTrashCozyMetadata(c, file.CozyMetadata) +} + +func setTrashCozyMetadata(c echo.Context, fcm *vfs.FilesCozyMetadata) { + if fcm == nil { + return + } + trashedAt := fcm.UpdatedAt + fcm.TrashedAt = &trashedAt + if actor, ok := middlewares.GetActor(c); ok { + fcm.TrashedBy = &vfs.TrashedByEntry{ + Kind: actor.Kind, + DisplayName: actor.DisplayName, + Domain: actor.Domain, + } + return + } + fcm.TrashedBy = nil +} + // CozyMetadataFromClaims returns a FilesCozyMetadata struct, with the app // fields filled with information from the permission claims. func CozyMetadataFromClaims(c echo.Context, setUploadFields bool) (*vfs.FilesCozyMetadata, string) { diff --git a/web/files/files_test.go b/web/files/files_test.go index 4359ea4fc74..5d49ecbc36d 100644 --- a/web/files/files_test.go +++ b/web/files/files_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/cozy/cozy-stack/model/instance" "github.com/cozy/cozy-stack/model/instance/lifecycle" "github.com/cozy/cozy-stack/model/permission" "github.com/cozy/cozy-stack/model/vfs" @@ -32,6 +33,16 @@ import ( _ "github.com/cozy/cozy-stack/worker/thumbnail" ) +func expectedTrashDisplayName(t *testing.T, inst *instance.Instance) string { + t.Helper() + name, err := inst.SettingsPublicName() + require.NoError(t, err) + if name == "" { + name = inst.Domain + } + return name +} + func TestFiles(t *testing.T) { if testing.Short() { t.Skip("an instance is required for this test: test skipped due to the use of --short flag") @@ -2813,6 +2824,121 @@ func TestFiles(t *testing.T) { Expect().Status(400) }) + t.Run("FileTrashAttribution", func(t *testing.T) { + e := testutils.CreateTestClient(t, ts.URL) + publicName := expectedTrashDisplayName(t, testInstance) + + fileID = e.POST("/files/"). + WithQuery("Name", "totrashwithattribution"). + WithQuery("Type", "file"). + WithHeader("Content-Type", "text/plain"). + WithHeader("Content-MD5", "UmfjCVWct/albVkURcJJfg=="). + WithHeader("Authorization", "Bearer "+token). + WithBytes([]byte("foo,bar")). + Expect().Status(201). + JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). + Object().Path("$.data.id").String().NotEmpty().Raw() + + obj := e.DELETE("/files/"+fileID). + WithHeader("Authorization", "Bearer "+token). + Expect().Status(200). + JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). + Object() + + obj.Path("$.data.attributes.trashed").Boolean().True() + fcm := obj.Path("$.data.attributes.cozyMetadata").Object() + fcm.Value("trashedAt").String().DateTime(time.RFC3339) + trashedBy := fcm.Value("trashedBy").Object() + trashedBy.Value("kind").String().IsEqual(vfs.TrashedByKindMember) + trashedBy.Value("displayName").String().IsEqual(publicName) + trashedBy.Value("domain").String().IsEqual(testInstance.Domain) + }) + + t.Run("PatchMoveToTrashAttribution", func(t *testing.T) { + e := testutils.CreateTestClient(t, ts.URL) + publicName := expectedTrashDisplayName(t, testInstance) + + fileID = e.POST("/files/"). + WithQuery("Name", "patch-trash-attribution"). + WithQuery("Type", "file"). + WithHeader("Content-Type", "text/plain"). + WithHeader("Content-MD5", "UmfjCVWct/albVkURcJJfg=="). + WithHeader("Authorization", "Bearer "+token). + WithBytes([]byte("foo,bar")). + Expect().Status(201). + JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). + Object().Path("$.data.id").String().NotEmpty().Raw() + + obj := e.PATCH("/files/"+fileID). + WithHeader("Content-Type", "application/json"). + WithHeader("Authorization", "Bearer "+token). + WithBytes([]byte(`{ + "data": { + "type": "file", + "id": "` + fileID + `", + "attributes": { "move_to_trash": true } + } + }`)). + Expect().Status(200). + JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). + Object() + + obj.Path("$.data.attributes.trashed").Boolean().True() + fcm := obj.Path("$.data.attributes.cozyMetadata").Object() + fcm.Value("trashedAt").String().DateTime(time.RFC3339) + trashedBy := fcm.Value("trashedBy").Object() + trashedBy.Value("kind").String().IsEqual(vfs.TrashedByKindMember) + trashedBy.Value("displayName").String().IsEqual(publicName) + trashedBy.Value("domain").String().IsEqual(testInstance.Domain) + }) + + t.Run("DirTrashAttributionTopLevelOnly", func(t *testing.T) { + e := testutils.CreateTestClient(t, ts.URL) + publicName := expectedTrashDisplayName(t, testInstance) + + dirID := e.POST("/files/"). + WithQuery("Name", "dir-trash-attribution"). + WithQuery("Type", "directory"). + WithHeader("Authorization", "Bearer "+token). + Expect().Status(201). + JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). + Object().Path("$.data.id").String().NotEmpty().Raw() + + childID := e.POST("/files/"+dirID). + WithQuery("Name", "child.txt"). + WithQuery("Type", "file"). + WithHeader("Content-Type", "text/plain"). + WithHeader("Content-MD5", "UmfjCVWct/albVkURcJJfg=="). + WithHeader("Authorization", "Bearer "+token). + WithBytes([]byte("foo,bar")). + Expect().Status(201). + JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). + Object().Path("$.data.id").String().NotEmpty().Raw() + + obj := e.DELETE("/files/"+dirID). + WithHeader("Authorization", "Bearer "+token). + Expect().Status(200). + JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). + Object() + + fcm := obj.Path("$.data.attributes.cozyMetadata").Object() + fcm.Value("trashedAt").String().DateTime(time.RFC3339) + trashedBy := fcm.Value("trashedBy").Object() + trashedBy.Value("kind").String().IsEqual(vfs.TrashedByKindMember) + trashedBy.Value("displayName").String().IsEqual(publicName) + trashedBy.Value("domain").String().IsEqual(testInstance.Domain) + + child := e.GET("/files/"+childID). + WithHeader("Authorization", "Bearer "+token). + Expect().Status(200). + JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). + Object() + child.Path("$.data.attributes.trashed").Boolean().True() + childMeta := child.Path("$.data.attributes.cozyMetadata").Object() + childMeta.NotContainsKey("trashedAt") + childMeta.NotContainsKey("trashedBy") + }) + t.Run("ForbidMovingTrashedFile", func(t *testing.T) { e := testutils.CreateTestClient(t, ts.URL) @@ -2863,20 +2989,31 @@ func TestFiles(t *testing.T) { Object().Path("$.data.id").String().NotEmpty().Raw() // Trash the file - e.DELETE("/files/"+fileID). + obj := e.DELETE("/files/"+fileID). WithHeader("Authorization", "Bearer "+token). Expect().Status(200). JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). - Object(). - Path("$.data.attributes.trashed").Boolean().True() + Object() + obj.Path("$.data.attributes.trashed").Boolean().True() + trashedAt := obj.Path("$.data.attributes.cozyMetadata.trashedAt").String().NotEmpty().Raw() + trashedBy := obj.Path("$.data.attributes.cozyMetadata.trashedBy").Object() + trashedByKind := trashedBy.Value("kind").String().NotEmpty().Raw() + trashedByDisplayName := trashedBy.Value("displayName").String().NotEmpty().Raw() + trashedByDomain := trashedBy.Value("domain").String().NotEmpty().Raw() // Restore the file - e.POST("/files/trash/"+fileID). + obj = e.POST("/files/trash/"+fileID). WithHeader("Authorization", "Bearer "+token). Expect().Status(200). JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). - Object(). - Path("$.data.attributes.trashed").Boolean().False() + Object() + obj.Path("$.data.attributes.trashed").Boolean().False() + fcm := obj.Path("$.data.attributes.cozyMetadata").Object() + fcm.Value("trashedAt").String().IsEqual(trashedAt) + restoredBy := fcm.Value("trashedBy").Object() + restoredBy.Value("kind").String().IsEqual(trashedByKind) + restoredBy.Value("displayName").String().IsEqual(trashedByDisplayName) + restoredBy.Value("domain").String().IsEqual(trashedByDomain) // Download the file. e.GET("/files/download"). diff --git a/web/middlewares/actor.go b/web/middlewares/actor.go new file mode 100644 index 00000000000..3627713619a --- /dev/null +++ b/web/middlewares/actor.go @@ -0,0 +1,133 @@ +package middlewares + +import ( + "strings" + + "github.com/cozy/cozy-stack/model/instance" + "github.com/cozy/cozy-stack/model/instance/lifecycle" + "github.com/cozy/cozy-stack/model/permission" + "github.com/cozy/cozy-stack/model/sharing" + "github.com/cozy/cozy-stack/model/vfs" + "github.com/labstack/echo/v4" +) + +const contextRequestActor = "request_actor" + +// Actor describes the authenticated actor associated with the current request. +type Actor struct { + Kind string + DisplayName string + Domain string +} + +// GetActor returns the actor associated with the request context. +func GetActor(c echo.Context) (*Actor, bool) { + v := c.Get(contextRequestActor) + if v != nil { + actor, ok := v.(*Actor) + return actor, ok && actor != nil + } + return nil, false +} + +// SetActor stores the current request actor in the context. +func SetActor(c echo.Context, actor *Actor) { + c.Set(contextRequestActor, actor) +} + +// ResolveRequestActor derives the request actor from the resolved permission. +func ResolveRequestActor(c echo.Context, inst *instance.Instance, pdoc *permission.Permission) error { + if pdoc == nil { + SetActor(c, nil) + return nil + } + + switch pdoc.Type { + case permission.TypeOauth, permission.TypeCLI, permission.TypeWebapp, permission.TypeKonnector: + SetActor(c, actorFromInstance(inst)) + return nil + + case permission.TypeShareInteract: + if _, ok := GetActor(c); ok { + return nil + } + + token := GetRequestToken(c) + if token == "" { + SetActor(c, nil) + return nil + } + + token, err := TransformShortcodeToJWT(inst, token) + if err != nil { + return err + } + + sharingID := pdoc.SourceID + if _, id, ok := strings.Cut(sharingID, "/"); ok { + sharingID = id + } + + sharingDoc, err := sharing.FindSharing(inst, sharingID) + if err != nil { + return err + } + + member, err := sharingDoc.FindMemberByInteractCode(inst, token) + if err != nil { + return err + } + + SetActor(c, actorFromMember(member)) + return nil + + case permission.TypeSharePreview, permission.TypeShareByLink: + SetActor(c, anonymousShareActor()) + return nil + + case permission.TypeRegister: + SetActor(c, nil) + return nil + + default: + SetActor(c, nil) + return nil + } +} + +func actorFromInstance(inst *instance.Instance) *Actor { + displayName, err := inst.SettingsPublicName() + if err != nil || displayName == "" { + displayName = inst.Domain + } + return &Actor{ + Kind: vfs.TrashedByKindMember, + DisplayName: displayName, + Domain: inst.Domain, + } +} + +func actorFromMember(member *sharing.Member) *Actor { + if member == nil { + return nil + } + displayName := member.PublicName + if displayName == "" { + displayName = member.PrimaryName() + } + actor := &Actor{ + Kind: vfs.TrashedByKindMember, + DisplayName: displayName, + Domain: member.InstanceHost(), + } + if actor.Domain != "" { + if inst, err := lifecycle.GetInstance(actor.Domain); err == nil { + actor.Domain = inst.Domain + } + } + return actor +} + +func anonymousShareActor() *Actor { + return &Actor{Kind: vfs.TrashedByKindAnonymousShare} +} diff --git a/web/middlewares/actor_test.go b/web/middlewares/actor_test.go new file mode 100644 index 00000000000..dc43087c427 --- /dev/null +++ b/web/middlewares/actor_test.go @@ -0,0 +1,39 @@ +package middlewares + +import ( + "net/http/httptest" + "testing" + + "github.com/cozy/cozy-stack/model/instance" + "github.com/cozy/cozy-stack/model/permission" + "github.com/cozy/cozy-stack/model/vfs" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/require" +) + +func TestResolveRequestActor(t *testing.T) { + e := echo.New() + req := httptest.NewRequest("GET", "/", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + inst := &instance.Instance{Domain: "alice.cozy.local"} + + t.Run("AnonymousShare", func(t *testing.T) { + err := ResolveRequestActor(c, inst, &permission.Permission{Type: permission.TypeShareByLink}) + require.NoError(t, err) + + actor, ok := GetActor(c) + require.True(t, ok) + require.Equal(t, vfs.TrashedByKindAnonymousShare, actor.Kind) + require.Empty(t, actor.DisplayName) + require.Empty(t, actor.Domain) + }) + + t.Run("RegisterHasNoActor", func(t *testing.T) { + err := ResolveRequestActor(c, inst, &permission.Permission{Type: permission.TypeRegister}) + require.NoError(t, err) + + _, ok := GetActor(c) + require.False(t, ok) + }) +} diff --git a/web/middlewares/permissions.go b/web/middlewares/permissions.go index 485b2fdf830..df72d00b7b7 100644 --- a/web/middlewares/permissions.go +++ b/web/middlewares/permissions.go @@ -407,6 +407,9 @@ func GetPermission(c echo.Context) (*permission.Permission, error) { if err != nil { return nil, err } + if err = ResolveRequestActor(c, inst, pdoc); err != nil { + return nil, err + } c.Set(contextPermissionDoc, pdoc) return pdoc, nil diff --git a/web/sharings/drives.go b/web/sharings/drives.go index f2a9e33c128..585c08184ab 100644 --- a/web/sharings/drives.go +++ b/web/sharings/drives.go @@ -6,6 +6,8 @@ import ( "net/http" "net/http/httputil" "net/url" + "os" + "path" "strings" "time" @@ -55,7 +57,8 @@ func ListSharedDrives(c echo.Context) error { return jsonapi.DataList(c, http.StatusOK, objs, nil) } -// CreateSharedDrive creates a new shared drive from an existing folder. +// CreateSharedDrive creates a new shared drive from an existing folder or +// creates a new folder for it under Shared Drives. // POST /sharings/drives func CreateSharedDrive(c echo.Context) error { inst := middlewares.GetInstance(c) @@ -63,17 +66,33 @@ func CreateSharedDrive(c echo.Context) error { var attrs struct { Description string `json:"description"` FolderID string `json:"folder_id"` + Name string `json:"name"` } obj, err := jsonapi.Bind(c.Request().Body, &attrs) if err != nil { return jsonapi.BadJSON() } - if attrs.FolderID == "" { - return jsonapi.InvalidParameter("folder_id", errors.New("folder_id is required")) + if attrs.FolderID == "" && attrs.Name == "" { + return jsonapi.BadRequest(errors.New("folder_id or name is required")) + } + if attrs.FolderID != "" && attrs.Name != "" { + return jsonapi.BadRequest(errors.New("folder_id and name are mutually exclusive")) } - // Create the sharing from folder first (builds the rules) + if attrs.Name != "" { + parent, err := inst.EnsureSharedDrivesDir() + if err != nil { + return wrapErrors(err) + } + newDir, err := vfs.Mkdir(inst.VFS(), path.Join(parent.Fullpath, attrs.Name), nil) + if err != nil { + return wrapDriveNameErrors(err) + } + attrs.FolderID = newDir.DocID + } + + // Create the sharing from folder first (builds the rules). newSharing, err := sharing.CreateDrive(inst, attrs.FolderID, attrs.Description, "") if err != nil { return wrapErrors(err) @@ -89,6 +108,7 @@ func CreateSharedDrive(c echo.Context) error { if slug != "" && newSharing.AppSlug == "" { newSharing.AppSlug = slug } + newSharing.OrgDrive = inst.IsOrganizationInstance() // Extract recipient IDs from relationships rwGroupIDs, rwContactIDs := extractRecipientIDs(obj, "recipients") @@ -121,6 +141,17 @@ func CreateSharedDrive(c echo.Context) error { return jsonapi.Data(c, http.StatusCreated, as, nil) } +func wrapDriveNameErrors(err error) error { + switch err { + case os.ErrExist: + return jsonapi.Conflict(err) + case vfs.ErrIllegalFilename, vfs.ErrIllegalPath: + return jsonapi.InvalidParameter("name", err) + default: + return wrapErrors(err) + } +} + // extractRecipientIDs extracts group and contact IDs from a JSON:API relationship. func extractRecipientIDs(obj *jsonapi.ObjectMarshalling, relationshipName string) (groupIDs, contactIDs []string) { rel, ok := obj.GetRelationship(relationshipName) diff --git a/web/sharings/drives_test.go b/web/sharings/drives_test.go index b42b8e9491a..57dbf2f674a 100644 --- a/web/sharings/drives_test.go +++ b/web/sharings/drives_test.go @@ -4,6 +4,7 @@ import ( "archive/zip" "bytes" "context" + "encoding/json" "fmt" "io" "net" @@ -544,6 +545,7 @@ func runCoreSharedDrivesTests(t *testing.T, method DriveCreationMethod) { attrs.Value("app_slug").IsEqual("drive") attrs.Value("owner").IsEqual(true) attrs.Value("drive").IsEqual(true) + attrs.NotContainsKey("org_drive") members := attrs.Value("members").Array() members.Length().IsEqual(3) @@ -768,6 +770,7 @@ func TestCreateDriveFromFolder(t *testing.T) { data.Value("id").String().NotEmpty() attrs := data.Value("attributes").Object() attrs.Value("drive").Boolean().IsTrue() + attrs.NotContainsKey("org_drive") attrs.Value("description").String().IsEqual("Project Documents") // Verify the rule was created correctly @@ -805,10 +808,54 @@ func TestCreateDriveFromFolder(t *testing.T) { data.Value("id").String().NotEmpty() attrs := data.Value("attributes").Object() attrs.Value("drive").Boolean().IsTrue() + attrs.NotContainsKey("org_drive") }) - t.Run("FailOnMissingFolderID", func(t *testing.T) { - eOwner.POST("/sharings/drives"). + t.Run("CreateDriveFromName", func(t *testing.T) { + recipientContact := createContact(t, ownerInstance, "Eve", "eve@example.net") + + obj := eOwner.POST("/sharings/drives"). + WithHeader("Authorization", "Bearer "+ownerAppToken). + WithHeader("Content-Type", "application/vnd.api+json"). + WithBytes([]byte(fmt.Sprintf(`{ + "data": { + "type": "%s", + "attributes": { + "name": "BrandNewDrive" + }, + "relationships": { + "recipients": { + "data": [{"id": "%s", "type": "%s"}] + } + } + } + }`, consts.Sharings, recipientContact.ID(), recipientContact.DocType()))). + Expect().Status(201). + JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). + Object() + + data := obj.Value("data").Object() + attrs := data.Value("attributes").Object() + attrs.Value("drive").Boolean().IsTrue() + attrs.Value("description").String().IsEqual("BrandNewDrive") + + rules := attrs.Value("rules").Array() + rules.Length().IsEqual(1) + rule := rules.Value(0).Object() + rule.Value("title").String().IsEqual("BrandNewDrive") + createdDirID := rule.Value("values").Array().Value(0).String().NotEmpty().Raw() + + createdDir, err := ownerInstance.VFS().DirByID(createdDirID) + require.NoError(t, err) + require.Equal(t, consts.SharedDrivesDirID, createdDir.DirID) + + sharedDrivesDir, err := ownerInstance.EnsureSharedDrivesDir() + require.NoError(t, err) + require.Equal(t, sharedDrivesDir.ID(), createdDir.DirID) + }) + + t.Run("FailOnMissingFolderIDAndName", func(t *testing.T) { + resp := eOwner.POST("/sharings/drives"). WithHeader("Authorization", "Bearer "+ownerAppToken). WithHeader("Content-Type", "application/vnd.api+json"). WithBytes([]byte(fmt.Sprintf(`{ @@ -819,7 +866,61 @@ func TestCreateDriveFromFolder(t *testing.T) { } } }`, consts.Sharings))). - Expect().Status(422) + Expect().Status(400) + + resp.JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). + Object().Path("$.errors[0].detail").String(). + Contains("folder_id or name is required") + }) + + t.Run("FailOnBothFolderIDAndName", func(t *testing.T) { + dirID := createRootDirectory(t, eOwner, "AmbiguousDriveFolder", ownerAppToken) + + resp := eOwner.POST("/sharings/drives"). + WithHeader("Authorization", "Bearer "+ownerAppToken). + WithHeader("Content-Type", "application/vnd.api+json"). + WithBytes([]byte(fmt.Sprintf(`{ + "data": { + "type": "%s", + "attributes": { + "folder_id": "%s", + "name": "AmbiguousDrive" + } + } + }`, consts.Sharings, dirID))). + Expect().Status(400) + + resp.JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). + Object().Path("$.errors[0].detail").String(). + Contains("mutually exclusive") + }) + + t.Run("FailOnDuplicateNameInSharedDrives", func(t *testing.T) { + eOwner.POST("/sharings/drives"). + WithHeader("Authorization", "Bearer "+ownerAppToken). + WithHeader("Content-Type", "application/vnd.api+json"). + WithBytes([]byte(fmt.Sprintf(`{ + "data": { + "type": "%s", + "attributes": { + "name": "DuplicateNamedDrive" + } + } + }`, consts.Sharings))). + Expect().Status(201) + + eOwner.POST("/sharings/drives"). + WithHeader("Authorization", "Bearer "+ownerAppToken). + WithHeader("Content-Type", "application/vnd.api+json"). + WithBytes([]byte(fmt.Sprintf(`{ + "data": { + "type": "%s", + "attributes": { + "name": "DuplicateNamedDrive" + } + } + }`, consts.Sharings))). + Expect().Status(409) }) t.Run("FailOnNonexistentFolder", func(t *testing.T) { @@ -1631,6 +1732,182 @@ func TestSharedDriveCreation(t *testing.T) { }) } +func TestOrgDriveFlag(t *testing.T) { + if testing.Short() { + t.Skip("an instance is required for this test: test skipped due to the use of --short flag") + } + + const orgSlug = "org-slug" + + env := setupSharedDrivesEnvWithOwnerOptions(t, &lifecycle.Options{ + Domain: orgSlug + ".example.net", + OrgID: orgSlug, + Email: "owner@example.net", + PublicName: "Owner", + }) + eOwner, _, _ := env.createClients(t) + folderID := createRootDirectory(t, eOwner, "OrgDriveRoot", env.acmeToken) + recipientContact := createContact(t, env.acme, "Betty OrgDrive", "betty-orgdrive@example.net") + + obj := eOwner.POST("/sharings/drives"). + WithHeader("Authorization", "Bearer "+env.acmeToken). + WithHeader("Content-Type", "application/vnd.api+json"). + WithBytes([]byte(fmt.Sprintf(`{ + "data": { + "type": "%s", + "attributes": { + "description": "Organization drive", + "folder_id": "%s" + }, + "relationships": { + "recipients": { + "data": [{"id": "%s", "type": "%s"}] + } + } + } + }`, consts.Sharings, folderID, recipientContact.ID(), consts.Contacts))). + Expect().Status(201). + JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). + Object() + + sharingID := obj.Path("$.data.id").String().NotEmpty().Raw() + attrs := obj.Path("$.data.attributes").Object() + attrs.Value("drive").Boolean().IsTrue() + attrs.Value("org_drive").Boolean().IsTrue() + + getOrgDriveFlag := func(baseURL, token string) (bool, error) { + u, err := url.Parse(baseURL) + if err != nil { + return false, err + } + res, err := request.Req(&request.Options{ + Method: http.MethodGet, + Scheme: u.Scheme, + Domain: u.Host, + Path: "/sharings/" + sharingID, + Headers: request.Headers{ + echo.HeaderAuthorization: "Bearer " + token, + echo.HeaderAccept: "application/vnd.api+json", + }, + }) + if err != nil { + return false, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return false, fmt.Errorf("unexpected status: %d", res.StatusCode) + } + + var payload struct { + Data struct { + Attributes struct { + OrgDrive bool `json:"org_drive"` + } `json:"attributes"` + } `json:"data"` + } + if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { + return false, err + } + return payload.Data.Attributes.OrgDrive, nil + } + + ownerOrgDrive, err := getOrgDriveFlag(env.tsA.URL, env.acmeToken) + require.NoError(t, err) + require.True(t, ownerOrgDrive) + + acceptSharedDrive(t, env.acme, env.betty, "Betty OrgDrive", env.tsA.URL, env.tsB.URL, sharingID) + + require.Eventually(t, func() bool { + recipientOrgDrive, err := getOrgDriveFlag(env.tsB.URL, env.bettyToken) + return err == nil && recipientOrgDrive + }, 10*time.Second, 200*time.Millisecond, "recipient sharing should preserve org_drive") +} + +func TestSharedDriveTrashAttribution(t *testing.T) { + if testing.Short() { + t.Skip("an instance is required for this test: test skipped due to the use of --short flag") + } + + env := setupSharedDrivesEnv(t) + _, eB, _ := env.createClients(t) + + publicName, err := env.betty.SettingsPublicName() + require.NoError(t, err) + + obj := eB.DELETE("/sharings/drives/"+env.firstSharingID+"/"+env.checklistID). + WithHeader("Authorization", "Bearer "+env.bettyToken). + Expect().Status(200). + JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}). + Object() + + obj.Path("$.data.attributes.trashed").Boolean().True() + fcm := obj.Path("$.data.attributes.cozyMetadata").Object() + trashedAt := fcm.Value("trashedAt").String().NotEmpty().Raw() + trashedBy := fcm.Value("trashedBy").Object() + trashedByKind := trashedBy.Value("kind").String().NotEmpty().Raw() + trashedByDisplayName := trashedBy.Value("displayName").String().NotEmpty().Raw() + trashedByDomain := trashedBy.Value("domain").String().NotEmpty().Raw() + require.Equal(t, vfs.TrashedByKindMember, trashedByKind) + require.Equal(t, publicName, trashedByDisplayName) + + type filePayload struct { + Data struct { + Attributes struct { + Trashed bool `json:"trashed"` + CozyMetadata struct { + TrashedAt string `json:"trashedAt"` + TrashedBy struct { + Kind string `json:"kind"` + DisplayName string `json:"displayName"` + Domain string `json:"domain"` + } `json:"trashedBy"` + } `json:"cozyMetadata"` + } `json:"attributes"` + } `json:"data"` + } + + getFilePayload := func(baseURL, token, requestPath string) (*filePayload, error) { + u, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + res, err := request.Req(&request.Options{ + Method: http.MethodGet, + Scheme: u.Scheme, + Domain: u.Host, + Path: requestPath, + Headers: request.Headers{ + echo.HeaderAuthorization: "Bearer " + token, + echo.HeaderAccept: "application/vnd.api+json", + }, + }) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status: %d", res.StatusCode) + } + var payload filePayload + if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { + return nil, err + } + return &payload, nil + } + + require.Eventually(t, func() bool { + payload, err := getFilePayload(env.tsA.URL, env.acmeToken, "/files/"+env.checklistID) + if err != nil { + return false + } + return payload.Data.Attributes.Trashed && + payload.Data.Attributes.CozyMetadata.TrashedAt == trashedAt && + payload.Data.Attributes.CozyMetadata.TrashedBy.Kind == trashedByKind && + payload.Data.Attributes.CozyMetadata.TrashedBy.DisplayName == trashedByDisplayName && + payload.Data.Attributes.CozyMetadata.TrashedBy.Domain == trashedByDomain + }, 10*time.Second, 200*time.Millisecond, "owner should receive recipient trash attribution") +} + func TestSharedDriveDownload(t *testing.T) { if testing.Short() { t.Skip("an instance is required for this test: test skipped due to the use of --short flag") diff --git a/web/sharings/move_test.go b/web/sharings/move_test.go index 5abca1381d9..29b3eec814b 100644 --- a/web/sharings/move_test.go +++ b/web/sharings/move_test.go @@ -71,6 +71,10 @@ func (env *sharedDrivesEnv) createClients(t *testing.T) (*httpexpect.Expect, *ht } func setupSharedDrivesEnv(t *testing.T) *sharedDrivesEnv { + return setupSharedDrivesEnvWithOwnerOptions(t, nil) +} + +func setupSharedDrivesEnvWithOwnerOptions(t *testing.T, ownerOptions *lifecycle.Options) *sharedDrivesEnv { t.Helper() config.UseTestFile(t) @@ -84,7 +88,17 @@ func setupSharedDrivesEnv(t *testing.T) *sharedDrivesEnv { // ACME setupA := testutils.NewSetup(t, strings.ReplaceAll(t.Name(), "/", "_")+"_acme") - acme := setupA.GetTestInstance(&lifecycle.Options{Email: "acme@example.net", PublicName: "ACME"}) + owner := &lifecycle.Options{Email: "acme@example.net", PublicName: "ACME"} + if ownerOptions != nil { + owner = ownerOptions + if owner.Email == "" { + owner.Email = "acme@example.net" + } + if owner.PublicName == "" { + owner.PublicName = "ACME" + } + } + acme := setupA.GetTestInstance(owner) acmeToken := generateAppToken(acme, "drive", "io.cozy.files") tsA := setupA.GetTestServerMultipleRoutes(map[string]func(*echo.Group){ "/files": files.Routes,