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 {