diff --git a/docs/nextcloud.md b/docs/nextcloud.md index 95bc9cbdabd..fef8fce107a 100644 --- a/docs/nextcloud.md +++ b/docs/nextcloud.md @@ -45,6 +45,7 @@ Content-Type: application/vnd.api+json "attributes": { "type": "directory", "name": "Images", + "path": "/Documents/Images", "updated_at": "Thu, 02 May 2024 09:29:53 GMT", "etag": "\"66335d11c4b91\"" }, @@ -59,6 +60,7 @@ Content-Type: application/vnd.api+json "attributes": { "type": "file", "name": "BugBounty.pdf", + "path": "/Documents/BugBounty.pdf", "size": 2947, "mime": "application/pdf", "class": "pdf", @@ -76,6 +78,7 @@ Content-Type: application/vnd.api+json "attributes": { "type": "directory", "name": "Music", + "name": "/Documents/Music", "updated_at": "Thu, 02 May 2024 09:28:37 GMT", "etag": "\"66335cc55204b\"" }, @@ -90,6 +93,7 @@ Content-Type: application/vnd.api+json "attributes": { "type": "directory", "name": "Video", + "path": "/Documents/Video", "updated_at": "Thu, 02 May 2024 09:29:53 GMT", "etag": "\"66335d11c2318\"" }, @@ -430,3 +434,127 @@ HTTP/1.1 204 No Content - 400 Bad Request, when the account is not configured for NextCloud - 401 Unauthorized, when authentication to the NextCloud fails - 404 Not Found, when the account is not found or the file is not found on the Cozy + +## GET /remote/nextcloud/:account/trash/* + +This route can be used to list the files and directories inside the trashbin +of NextCloud. + +### Request (list) + +```http +GET /remote/nextcloud/4ab2155707bb6613a8b9463daf00381b/trash/ HTTP/1.1 +Host: cozy.example.net +Authorization: Bearer eyJhbG... +``` + +### Response (list) + +```http +HTTP/1.1 200 OK +Content-Type: application/vnd.api+json +``` + +```json +{ + "data": [ + { + "type": "io.cozy.remote.nextcloud.files", + "id": "613281", + "attributes": { + "type": "directory", + "name": "Old", + "path": "/trash/Old.d93571568", + "updated_at": "Tue, 25 Jun 2024 14:31:44 GMT", + "etag": "1719326384" + }, + "meta": {}, + "links": { + "self": "https://nextcloud.example.net/apps/files/trashbin/613281?dir=/Old" + } + } + ] +} +``` + +#### Status codes + +- 200 OK, for a success +- 401 Unauthorized, when authentication to the NextCloud fails +- 404 Not Found, when the account is not found or the directory is not found on the NextCloud + +## POST /remote/nextcloud/:account/restore/*path + +This route can be used to restore a file/directory from the trashbin on the +NextCloud. + +The `:account` parameter is the identifier of the NextCloud `io.cozy.account`. + +The `*path` parameter is the path of the file on the NextCloud. + +**Note:** a permission on `POST io.cozy.files` is required to use this route. + +### Request + +```http +POST /remote/nextcloud/4ab2155707bb6613a8b9463daf00381b/restore/trash/Old.d93571568 HTTP/1.1 +Host: cozy.example.net +Authorization: Bearer eyJhbG... +``` + +### Response + +```http +HTTP/1.1 204 No Content +``` + +#### Status codes + +- 204 No Content, when the file/directory has been restored +- 400 Bad Request, when the account is not configured for NextCloud +- 401 Unauthorized, when authentication to the NextCloud fails +- 404 Not Found, when the account is not found or the file/directory is not found on the NextCloud +- 409 Conflict, when a directory or file already exists where the file/directory should be restored on the NextCloud. + +## DELETE /remote/nextcloud/:account/trash/* + +This route can be used to delete a file in the trash. + +### Request + +```http +DELETE /remote/nextcloud/4ab2155707bb6613a8b9463daf00381b/trash/document-v1.docx.d64283654 HTTP/1.1 +Host: cozy.example.net +Authorization: Bearer eyJhbG... +``` + +### Response + +```http +HTTP/1.1 204 No Content +``` + +#### Status codes + +- 204 No Content, when the file/directory has been put in the trash +- 400 Bad Request, when the account is not configured for NextCloud, or the `To` parameter is missing +- 401 Unauthorized, when authentication to the NextCloud fails +- 404 Not Found, when the account is not found or the file/directory is not found on the NextCloud + +## DELETE /remote/nextcloud/:account/trash + +This route can be used to empty the trash bin on NextCloud. + +### Request + +```http +DELETE /remote/nextcloud/4ab2155707bb6613a8b9463daf00381b/trash HTTP/1.1 +Host: cozy.example.net +Authorization: Bearer eyJhbG... +``` + +### Response + +```http +HTTP/1.1 204 No Content +``` diff --git a/model/nextcloud/nextcloud.go b/model/nextcloud/nextcloud.go index f3446b42c0c..22701cb2e92 100644 --- a/model/nextcloud/nextcloud.go +++ b/model/nextcloud/nextcloud.go @@ -10,6 +10,7 @@ import ( "path/filepath" "runtime" "strconv" + "strings" "time" "github.com/cozy/cozy-stack/model/account" @@ -35,6 +36,7 @@ type File struct { DocID string `json:"id,omitempty"` Type string `json:"type"` Name string `json:"name"` + Path string `json:"path"` Size uint64 `json:"size,omitempty"` Mime string `json:"mime,omitempty"` Class string `json:"class,omitempty"` @@ -62,6 +64,7 @@ var _ jsonapi.Object = (*File)(nil) type NextCloud struct { inst *instance.Instance accountID string + userID string webdav *webdav.Client } @@ -99,6 +102,7 @@ func New(inst *instance.Instance, accountID string) (*NextCloud, error) { Host: u.Host, Username: username, Password: password, + BasePath: "/remote.php/dav", Logger: logger, } nc := &NextCloud{ @@ -106,41 +110,60 @@ func New(inst *instance.Instance, accountID string) (*NextCloud, error) { accountID: accountID, webdav: webdav, } - if err := nc.fillBasePath(&doc); err != nil { + if err := nc.fillUserID(&doc); err != nil { return nil, err } return nc, nil } func (nc *NextCloud) Download(path string) (*webdav.Download, error) { - return nc.webdav.Get(path) + return nc.webdav.Get("/files/" + nc.userID + "/" + path) } func (nc *NextCloud) Upload(path, mime string, contentLength int64, body io.Reader) error { headers := map[string]string{ echo.HeaderContentType: mime, } + path = "/files/" + nc.userID + "/" + path return nc.webdav.Put(path, contentLength, headers, body) } func (nc *NextCloud) Mkdir(path string) error { - return nc.webdav.Mkcol(path) + return nc.webdav.Mkcol("/files/" + nc.userID + "/" + path) } func (nc *NextCloud) Delete(path string) error { - return nc.webdav.Delete(path) + return nc.webdav.Delete("/files/" + nc.userID + "/" + path) } func (nc *NextCloud) Move(oldPath, newPath string) error { + oldPath = "/files/" + nc.userID + "/" + oldPath + newPath = "/files/" + nc.userID + "/" + newPath return nc.webdav.Move(oldPath, newPath) } func (nc *NextCloud) Copy(oldPath, newPath string) error { + oldPath = "/files/" + nc.userID + "/" + oldPath + newPath = "/files/" + nc.userID + "/" + newPath return nc.webdav.Copy(oldPath, newPath) } +func (nc *NextCloud) Restore(path string) error { + path = "/trashbin/" + nc.userID + "/" + path + dst := "/trashbin/" + nc.userID + "/restore/" + filepath.Base(path) + return nc.webdav.Move(path, dst) +} + +func (nc *NextCloud) DeleteTrash(path string) error { + return nc.webdav.Delete("/trashbin/" + nc.userID + "/" + path) +} + +func (nc *NextCloud) EmptyTrash() error { + return nc.webdav.Delete("/trashbin/" + nc.userID + "/trash") +} + func (nc *NextCloud) ListFiles(path string) ([]jsonapi.Object, error) { - items, err := nc.webdav.List(path) + items, err := nc.webdav.List("/files/" + nc.userID + "/" + path) if err != nil { return nil, err } @@ -155,6 +178,7 @@ func (nc *NextCloud) ListFiles(path string) ([]jsonapi.Object, error) { DocID: item.ID, Type: item.Type, Name: item.Name, + Path: "/" + filepath.Join(path, filepath.Base(item.Href)), Size: item.Size, Mime: mime, Class: class, @@ -167,7 +191,38 @@ func (nc *NextCloud) ListFiles(path string) ([]jsonapi.Object, error) { return files, nil } +func (nc *NextCloud) ListTrashed(path string) ([]jsonapi.Object, error) { + path = "/trash/" + path + items, err := nc.webdav.List("/trashbin/" + nc.userID + path) + if err != nil { + return nil, err + } + + var files []jsonapi.Object + for _, item := range items { + var mime, class string + if item.Type == "file" { + mime, class = vfs.ExtractMimeAndClassFromFilename(item.TrashedName) + } + file := &File{ + DocID: item.ID, + Type: item.Type, + Name: item.TrashedName, + Path: filepath.Join(path, filepath.Base(item.Href)), + Size: item.Size, + Mime: mime, + Class: class, + UpdatedAt: item.LastModified, + ETag: item.ETag, + url: nc.buildTrashedURL(item, path), + } + files = append(files, file) + } + return files, nil +} + func (nc *NextCloud) Downstream(path, dirID string, kind OperationKind, cozyMetadata *vfs.FilesCozyMetadata) (*vfs.FileDoc, error) { + path = "/files/" + nc.userID + "/" + path dl, err := nc.webdav.Get(path) if err != nil { return nil, err @@ -215,6 +270,7 @@ func (nc *NextCloud) Downstream(path, dirID string, kind OperationKind, cozyMeta } func (nc *NextCloud) Upstream(path, from string, kind OperationKind) error { + path = "/files/" + nc.userID + "/" + path fs := nc.inst.VFS() doc, err := fs.FileByID(from) if err != nil { @@ -238,10 +294,10 @@ func (nc *NextCloud) Upstream(path, from string, kind OperationKind) error { return nil } -func (nc *NextCloud) fillBasePath(accountDoc *couchdb.JSONDoc) error { +func (nc *NextCloud) fillUserID(accountDoc *couchdb.JSONDoc) error { userID, _ := accountDoc.M["webdav_user_id"].(string) if userID != "" { - nc.webdav.BasePath = "/remote.php/dav/files/" + userID + nc.userID = userID return nil } @@ -249,7 +305,7 @@ func (nc *NextCloud) fillBasePath(accountDoc *couchdb.JSONDoc) error { if err != nil { return err } - nc.webdav.BasePath = "/remote.php/dav/files/" + userID + nc.userID = userID // Try to persist the userID to avoid fetching it for every WebDAV request accountDoc.M["webdav_user_id"] = userID @@ -266,6 +322,28 @@ func (nc *NextCloud) buildURL(item webdav.Item, path string) string { Path: "/apps/files/files/" + item.ID, RawQuery: "dir=/" + path, } + if item.Type == "directory" { + if !strings.HasSuffix(u.RawQuery, "/") { + u.RawQuery += "/" + } + u.RawQuery += item.Name + } + return u.String() +} + +func (nc *NextCloud) buildTrashedURL(item webdav.Item, path string) string { + u := &url.URL{ + Scheme: nc.webdav.Scheme, + Host: nc.webdav.Host, + Path: "/apps/files/trashbin/" + item.ID, + RawQuery: "dir=" + strings.TrimPrefix(path, "/trash"), + } + if item.Type == "directory" { + if !strings.HasSuffix(u.RawQuery, "/") { + u.RawQuery += "/" + } + u.RawQuery += item.TrashedName + } return u.String() } diff --git a/pkg/webdav/webdav.go b/pkg/webdav/webdav.go index 5b14573b073..abbf3171003 100644 --- a/pkg/webdav/webdav.go +++ b/pkg/webdav/webdav.go @@ -68,12 +68,11 @@ func (c *Client) Move(oldPath, newPath string) error { u := url.URL{ Scheme: c.Scheme, Host: c.Host, - User: url.UserPassword(c.Username, c.Password), Path: c.BasePath + fixSlashes(newPath), } headers := map[string]string{ "Destination": u.String(), - "Overwrite": "F", + "Overwrite": "T", } res, err := c.req("MOVE", oldPath, 0, headers, nil) if err != nil { @@ -98,7 +97,6 @@ func (c *Client) Copy(oldPath, newPath string) error { u := url.URL{ Scheme: c.Scheme, Host: c.Host, - User: url.UserPassword(c.Username, c.Password), Path: c.BasePath + fixSlashes(newPath), } headers := map[string]string{ @@ -242,7 +240,9 @@ func (c *Client) List(path string) ([]Item, error) { item := Item{ ID: props.FileID, Type: "directory", + Href: href, Name: props.Name, + TrashedName: props.TrashedName, LastModified: props.LastModified, ETag: props.ETag, } @@ -263,7 +263,9 @@ func (c *Client) List(path string) ([]Item, error) { type Item struct { ID string Type string + Href string Name string + TrashedName string Size uint64 ContentType string LastModified string @@ -284,6 +286,7 @@ type props struct { Status string `xml:"status"` Type xml.Name `xml:"prop>resourcetype>collection"` Name string `xml:"prop>displayname"` + TrashedName string `xml:"prop>trashbin-filename"` Size string `xml:"prop>getcontentlength"` ContentType string `xml:"prop>getcontenttype"` LastModified string `xml:"prop>getlastmodified"` @@ -301,6 +304,7 @@ const ListFilesPayload = ` + ` diff --git a/web/remote/nextcloud.go b/web/remote/nextcloud.go index 64a6013f402..09d526716a6 100644 --- a/web/remote/nextcloud.go +++ b/web/remote/nextcloud.go @@ -23,9 +23,66 @@ import ( "github.com/ncw/swift/v2" ) +func nextcloudGetTrash(c echo.Context) error { + inst := middlewares.GetInstance(c) + if err := middlewares.AllowWholeType(c, permission.GET, consts.Files); err != nil { + return err + } + + accountID := c.Param("account") + nc, err := nextcloud.New(inst, accountID) + if err != nil { + return wrapNextcloudErrors(err) + } + + path := c.Param("*") + files, err := nc.ListTrashed(path) + if err != nil { + return wrapNextcloudErrors(err) + } + return jsonapi.DataList(c, http.StatusOK, files, nil) +} + +func nextcloudDeleteTrash(c echo.Context) error { + inst := middlewares.GetInstance(c) + if err := middlewares.AllowWholeType(c, permission.DELETE, consts.Files); err != nil { + return err + } + + accountID := c.Param("account") + nc, err := nextcloud.New(inst, accountID) + if err != nil { + return wrapNextcloudErrors(err) + } + + path := "/trash/" + c.Param("*") + if err := nc.DeleteTrash(path); err != nil { + return wrapNextcloudErrors(err) + } + return c.NoContent(http.StatusNoContent) +} + +func nextcloudEmptyTrash(c echo.Context) error { + inst := middlewares.GetInstance(c) + if err := middlewares.AllowWholeType(c, permission.DELETE, consts.Files); err != nil { + return err + } + + accountID := c.Param("account") + nc, err := nextcloud.New(inst, accountID) + if err != nil { + return wrapNextcloudErrors(err) + } + + if err := nc.EmptyTrash(); err != nil { + return wrapNextcloudErrors(err) + } + return c.NoContent(http.StatusNoContent) +} + func nextcloudGet(c echo.Context) error { inst := middlewares.GetInstance(c) - if err := middlewares.AllowWholeType(c, permission.PUT, consts.Files); err != nil { + if err := middlewares.AllowWholeType(c, permission.GET, consts.Files); err != nil { return err } @@ -245,8 +302,30 @@ func nextcloudUpstream(c echo.Context) error { return c.NoContent(http.StatusNoContent) } +func nextcloudRestore(c echo.Context) error { + inst := middlewares.GetInstance(c) + if err := middlewares.AllowWholeType(c, permission.POST, consts.Files); err != nil { + return err + } + + accountID := c.Param("account") + nc, err := nextcloud.New(inst, accountID) + if err != nil { + return wrapNextcloudErrors(err) + } + + path := c.Param("*") + if err := nc.Restore(path); err != nil { + return wrapNextcloudErrors(err) + } + return c.NoContent(http.StatusNoContent) +} + func nextcloudRoutes(router *echo.Group) { group := router.Group("/nextcloud/:account") + group.GET("/trash/*", nextcloudGetTrash) + group.DELETE("/trash/*", nextcloudDeleteTrash) + group.DELETE("/trash", nextcloudEmptyTrash) group.GET("/*", nextcloudGet) group.PUT("/*", nextcloudPut) group.DELETE("/*", nextcloudDelete) @@ -254,6 +333,7 @@ func nextcloudRoutes(router *echo.Group) { group.POST("/copy/*", nextcloudCopy) group.POST("/downstream/*", nextcloudDownstream) group.POST("/upstream/*", nextcloudUpstream) + group.POST("/restore/*", nextcloudRestore) } func wrapNextcloudErrors(err error) error {