diff --git a/docs/README.md b/docs/README.md index 5272e1d76aa..502c16ae827 100644 --- a/docs/README.md +++ b/docs/README.md @@ -98,6 +98,7 @@ designing new services. - `/public` - [Public](public.md) - `/realtime` - [Realtime](realtime.md) - `/remote` - [Proxy for remote data/API](remote.md) + - [NextCloud](nextcloud.md) - `/settings` - [Settings](settings.md) - [Terms of Services](user-action-required.md) - `/sharings` - [Sharing](sharing.md) diff --git a/docs/nextcloud.md b/docs/nextcloud.md new file mode 100644 index 00000000000..11603fc1690 --- /dev/null +++ b/docs/nextcloud.md @@ -0,0 +1,425 @@ +[Table of contents](README.md#table-of-contents) + +# Proxy for a remote NextCloud + +The nextcloud konnector can be used to create an `io.cozy.account` for a +NextCloud. Then, the stack can be used as a client for this NextCloud account. +Currently, it supports files operations via WebDAV. + +## GET /remote/nextcloud/:account/*path + +This route can be used to list the files and subdirectories inside a directory +of NextCloud. + +With `Dl=1` in the query-string, it can also be used to download a file. + +The `:account` parameter is the identifier of the NextCloud `io.cozy.account`. +It is available with the `cozyMetadata.sourceAccount` of the shortcut file for +example. + +The `*path` parameter is the path of the file/directory on the NextCloud. + +**Note:** a permission on `GET io.cozy.files` is required to use this route. + +### Request (list) + +```http +GET /remote/nextcloud/4ab2155707bb6613a8b9463daf00381b/Documents 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": "192172", + "attributes": { + "type": "directory", + "name": "Images", + "updated_at": "Thu, 02 May 2024 09:29:53 GMT", + "etag": "\"66335d11c4b91\"" + }, + "meta": {}, + "links": { + "self": "https://nextcloud.example.net/apps/files/files/192172?dir=/Documents" + } + }, + { + "type": "io.cozy.remote.nextcloud.files", + "id": "208937", + "attributes": { + "type": "file", + "name": "BugBounty.pdf", + "size": 2947, + "mime": "application/pdf", + "class": "pdf", + "updated_at": "Mon, 14 Jan 2019 08:22:21 GMT", + "etag": "\"dd1a602431671325b7c1538f829248d9\"" + }, + "meta": {}, + "links": { + "self": "https://nextcloud.example.net/apps/files/files/208937?dir=/Documents" + } + }, + { + "type": "io.cozy.remote.nextcloud.files", + "id": "615827", + "attributes": { + "type": "directory", + "name": "Music", + "updated_at": "Thu, 02 May 2024 09:28:37 GMT", + "etag": "\"66335cc55204b\"" + }, + "meta": {}, + "links": { + "self": "https://nextcloud.example.net/apps/files/files/615827?dir=/Documents" + } + }, + { + "type": "io.cozy.remote.nextcloud.files", + "id": "615828", + "attributes": { + "type": "directory", + "name": "Video", + "updated_at": "Thu, 02 May 2024 09:29:53 GMT", + "etag": "\"66335d11c2318\"" + }, + "meta": {}, + "links": { + "self": "https://nextcloud.example.net/apps/files/files/615828?dir=/Documents" + } + } + ], + "meta": { + "count": 5 + } +} +``` + +### Request (download) + +```http +GET /remote/nextcloud/4ab2155707bb6613a8b9463daf00381b/Documents/Wallpaper.jpg?Dl=1 HTTP/1.1 +Host: cozy.example.net +Authorization: Bearer eyJhbG... +``` + +### Response (download) + +```http +HTTP/1.1 200 OK +Content-Type: image/jpeg +Content-Length: 12345 +Content-Disposition: attachment; filename="Wallpaper.jpg" + +... +``` + +#### 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 + +## PUT /remote/nextcloud/:account/*path + +This route can be used to create a directory, or upload a file, on the +NextCloud. The query-string parameter `Type` should be `file` when uploading a +file. + +The `:account` parameter is the identifier of the NextCloud `io.cozy.account`. + +The `*path` parameter is the path of the file/directory on the NextCloud. + +**Note:** a permission on `POST io.cozy.files` is required to use this route. + +### Request (directory) + +```http +PUT /remote/nextcloud/4ab2155707bb6613a8b9463daf00381b/Documents/Images/Clouds?Type=directory HTTP/1.1 +Host: cozy.example.net +Authorization: Bearer eyJhbG... +``` + +### Response (directory) + +```http +HTTP/1.1 201 Created +Content-Type: application/json +``` + +```json +{ + "ok": true +} +``` + +### Request (file) + +```http +PUT /remote/nextcloud/4ab2155707bb6613a8b9463daf00381b/Documents/Images/sunset.jpg?Type=file HTTP/1.1 +Host: cozy.example.net +Authorization: Bearer eyJhbG... +Content-Type: image/jpeg +Content-Length: 54321 + +... +``` + +### Response (file) + +```http +HTTP/1.1 201 Created +Content-Type: application/json +``` + +```json +{ + "ok": true +} +``` + +#### Status codes + +- 201 Created, when the directory has been created +- 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 parent directory is not found on the NextCloud +- 409 Conflict, when a directory or file already exists at this path on the NextCloud. + +## DELETE /remote/nextcloud/:account/*path + +This route can be used to put a file or directory in the NextCloud trash. + +The `:account` parameter is the identifier of the NextCloud `io.cozy.account`. + +The `*path` parameter is the path of the file/directory on the NextCloud. + +**Note:** a permission on `DELETE io.cozy.files` is required to use this route. + +### Request + +```http +DELETE /remote/nextcloud/4ab2155707bb6613a8b9463daf00381b/Documents/Images/Clouds 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 + +## POST /remote/nextcloud/:account/move/*path + +This route can be used to move or rename a file/directory on the NextCloud. +The new path must be given with the `To` parameter in the query-string. + +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/move/Documents/wallpaper.jpg?To=/Wallpaper.jpg 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 moved +- 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 file already exists with the new name on the NextCloud. + +## POST /remote/nextcloud/:account/copy/*path + +This route can be used to create a copy of a file in the same directory, with a +copy suffix in its name. The new name can be optionaly given with the `Name` +parameter in the query-string. + +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/copy/Documents/wallpaper.jpg HTTP/1.1 +Host: cozy.example.net +Authorization: Bearer eyJhbG... +``` + +### Response + +```http +HTTP/1.1 201 Created +Content-Type: application/json +``` + +```json +{ + "ok": true +} +``` + +#### Status codes + +- 201 Created, when the file has been copied +- 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 file already exists with the new name on the NextCloud. + +## POST /remote/nextcloud/:account/downstream/*path + +This route can be used to move a file from the NextCloud to the Cozy. + +The `:account` parameter is the identifier of the NextCloud `io.cozy.account`. + +The `*path` parameter is the path of the file on the NextCloud. + +The `To` parameter in the query-string must be given, as the ID of the +directory on the Cozy where the file will be put. + +**Note:** a permission on `POST io.cozy.files` is required to use this route. + +### Request + +```http +POST /remote/nextcloud/4ab2155707bb6613a8b9463daf00381b/downstream/Documents/Images/sunset.jpg?To=b3ecbc00f4ba013c2bf418c04daba326 HTTP/1.1 +Host: cozy.example.net +Authorization: Bearer eyJhbG... +``` + +### Response + +```http +HTTP/1.1 201 Created +Content-Type: application/vnd.api+json +``` + +```json +{ + "data": { + "type": "io.cozy.files", + "id": "7b41fb7c31e87eeaf13a54bc32001830", + "attributes": { + "type": "file", + "name": "sunset.jpg", + "dir_id": "b3ecbc00f4ba013c2bf418c04daba326", + "created_at": "2024-05-15T09:24:39.460655706+02:00", + "updated_at": "2024-05-15T09:24:39.460655706+02:00", + "size": "54321", + "md5sum": "1B2M2Y8AsgTpgAmY7PhCfg==", + "mime": "image/jpeg", + "class": "image", + "executable": false, + "trashed": false, + "encrypted": false, + "cozyMetadata": { + "doctypeVersion": "1", + "metadataVersion": 1, + "createdAt": "2024-05-15T09:24:38.971901347+02:00", + "updatedAt": "2024-05-15T09:24:38.971901347+02:00", + "createdOn": "https://cozy.example.net/", + "uploadedAt": "2024-05-15T09:24:38.971901347+02:00", + "uploadedOn": "https://cozy.example.net/" + } + }, + "meta": { + "rev": "1-cfed435c4ad72b911b31ed775e3024df" + }, + "links": { + "self": "/files/7b41fb7c31e87eeaf13a54bc32001830" + }, + "relationships": { + "parent": { + "links": { + "related": "/files/b3ecbc00f4ba013c2bf418c04daba326" + }, + "data": { + "id": "b3ecbc00f4ba013c2bf418c04daba326", + "type": "io.cozy.files" + } + }, + "referenced_by": { + "links": { + "self": "/files/7b41fb7c31e87eeaf13a54bc32001830/relationships/references" + } + } + } + } +} +``` + +#### Status codes + +- 201 Created, when the file has been moved from the NextCloud to the Cozy +- 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 NextCloud + +## POST /remote/nextcloud/:account/upstream/*path + +This route can be used to move a file from the Cozy to 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. + +The `From` parameter in the query-string must be given, as the ID of the +file on the Cozy that will be moved. + +**Note:** a permission on `POST io.cozy.files` is required to use this route. + +### Request + +```http +POST /remote/nextcloud/4ab2155707bb6613a8b9463daf00381b/upstream/Documents/Images/sunset2.jpg?From=7b41fb7c31e87eeaf13a54bc32001830 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 has been moved from the Cozy to the NextCloud +- 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 diff --git a/docs/toc.yml b/docs/toc.yml index 159b1a564f4..3eeab163039 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -49,6 +49,7 @@ - "/permissions - Permissions": ./permissions.md - "/realtime - Realtime": ./realtime.md - "/remote - Proxy for remote data/API": ./remote.md + - " /remote/nextcloud - NextCloud": ./nextcloud.md - "/settings - Settings": ./settings.md - " /settings - Terms of Services": ./user-action-required.md - "/sharings - Sharing": ./sharing.md diff --git a/model/nextcloud/errors.go b/model/nextcloud/errors.go new file mode 100644 index 00000000000..4a6711fe543 --- /dev/null +++ b/model/nextcloud/errors.go @@ -0,0 +1,12 @@ +package nextcloud + +import "errors" + +var ( + // ErrAccountNotFound is used when the no account can be found with the + // given ID. + ErrAccountNotFound = errors.New("account not found") + // ErrInvalidAccount is used when the account cannot be used to connect to + // NextCloud. + ErrInvalidAccount = errors.New("invalid NextCloud account") +) diff --git a/model/nextcloud/nextcloud.go b/model/nextcloud/nextcloud.go new file mode 100644 index 00000000000..1cd95a6a7c8 --- /dev/null +++ b/model/nextcloud/nextcloud.go @@ -0,0 +1,304 @@ +// Package nextcloud is a client library for NextCloud. It only supports files +// via Webdav for the moment. +package nextcloud + +import ( + "encoding/json" + "io" + "net/http" + "net/url" + "path/filepath" + "runtime" + "strconv" + "time" + + "github.com/cozy/cozy-stack/model/account" + "github.com/cozy/cozy-stack/model/instance" + "github.com/cozy/cozy-stack/model/vfs" + build "github.com/cozy/cozy-stack/pkg/config" + "github.com/cozy/cozy-stack/pkg/consts" + "github.com/cozy/cozy-stack/pkg/couchdb" + "github.com/cozy/cozy-stack/pkg/jsonapi" + "github.com/cozy/cozy-stack/pkg/safehttp" + "github.com/cozy/cozy-stack/pkg/webdav" + "github.com/labstack/echo/v4" +) + +type File struct { + DocID string `json:"id,omitempty"` + Type string `json:"type"` + Name string `json:"name"` + Size uint64 `json:"size,omitempty"` + Mime string `json:"mime,omitempty"` + Class string `json:"class,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + ETag string `json:"etag,omitempty"` + url string +} + +func (f *File) ID() string { return f.DocID } +func (f *File) Rev() string { return "" } +func (f *File) DocType() string { return consts.NextCloudFiles } +func (f *File) SetID(id string) { f.DocID = id } +func (f *File) SetRev(id string) {} +func (f *File) Clone() couchdb.Doc { panic("nextcloud.File should not be cloned") } +func (f *File) Included() []jsonapi.Object { return nil } +func (f *File) Relationships() jsonapi.RelationshipMap { return nil } +func (f *File) Links() *jsonapi.LinksList { + return &jsonapi.LinksList{ + Self: f.url, + } +} + +var _ jsonapi.Object = (*File)(nil) + +type NextCloud struct { + inst *instance.Instance + accountID string + webdav *webdav.Client +} + +func New(inst *instance.Instance, accountID string) (*NextCloud, error) { + var doc couchdb.JSONDoc + err := couchdb.GetDoc(inst, consts.Accounts, accountID, &doc) + if err != nil { + if couchdb.IsNotFoundError(err) { + return nil, ErrAccountNotFound + } + return nil, err + } + account.Decrypt(doc) + + if doc.M == nil || doc.M["account_type"] != "nextcloud" { + return nil, ErrInvalidAccount + } + auth, ok := doc.M["auth"].(map[string]interface{}) + if !ok { + return nil, ErrInvalidAccount + } + ncURL, _ := auth["url"].(string) + if ncURL == "" { + return nil, ErrInvalidAccount + } + u, err := url.Parse(ncURL) + if err != nil { + return nil, ErrInvalidAccount + } + username, _ := auth["login"].(string) + password, _ := auth["password"].(string) + logger := inst.Logger().WithNamespace("nextcloud") + webdav := &webdav.Client{ + Scheme: u.Scheme, + Host: u.Host, + Username: username, + Password: password, + Logger: logger, + } + nc := &NextCloud{ + inst: inst, + accountID: accountID, + webdav: webdav, + } + if err := nc.fillBasePath(&doc); err != nil { + return nil, err + } + return nc, nil +} + +func (nc *NextCloud) Download(path string) (*webdav.Download, error) { + return nc.webdav.Get(path) +} + +func (nc *NextCloud) Upload(path, mime string, body io.Reader) error { + headers := map[string]string{ + echo.HeaderContentType: mime, + } + return nc.webdav.Put(path, headers, body) +} + +func (nc *NextCloud) Mkdir(path string) error { + return nc.webdav.Mkcol(path) +} + +func (nc *NextCloud) Delete(path string) error { + return nc.webdav.Delete(path) +} + +func (nc *NextCloud) Move(oldPath, newPath string) error { + return nc.webdav.Move(oldPath, newPath) +} + +func (nc *NextCloud) Copy(oldPath, newPath string) error { + return nc.webdav.Copy(oldPath, newPath) +} + +func (nc *NextCloud) ListFiles(path string) ([]jsonapi.Object, error) { + items, err := nc.webdav.List(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.Name) + } + file := &File{ + DocID: item.ID, + Type: item.Type, + Name: item.Name, + Size: item.Size, + Mime: mime, + Class: class, + UpdatedAt: item.LastModified, + ETag: item.ETag, + url: nc.buildURL(item, path), + } + files = append(files, file) + } + return files, nil +} + +func (nc *NextCloud) Downstream(path, dirID string, cozyMetadata *vfs.FilesCozyMetadata) (*vfs.FileDoc, error) { + dl, err := nc.webdav.Get(path) + if err != nil { + return nil, err + } + defer dl.Content.Close() + + size, _ := strconv.Atoi(dl.Length) + mime, class := vfs.ExtractMimeAndClass(dl.Mime) + doc, err := vfs.NewFileDoc( + filepath.Base(path), + dirID, + int64(size), + nil, // md5sum + mime, + class, + time.Now(), + false, // executable + false, // trashed + false, // encrypted + nil, // tags + ) + if err != nil { + return nil, err + } + doc.CozyMetadata = cozyMetadata + + fs := nc.inst.VFS() + file, err := fs.CreateFile(doc, nil) + if err != nil { + return nil, err + } + + _, err = io.Copy(file, dl.Content) + if cerr := file.Close(); err == nil && cerr != nil { + return nil, cerr + } + if err != nil { + return nil, err + } + + _ = nc.webdav.Delete(path) + return doc, nil +} + +func (nc *NextCloud) Upstream(path, from string) error { + fs := nc.inst.VFS() + doc, err := fs.FileByID(from) + if err != nil { + return err + } + f, err := fs.OpenFile(doc) + if err != nil { + return err + } + defer f.Close() + + headers := map[string]string{ + echo.HeaderContentType: doc.Mime, + echo.HeaderContentLength: strconv.Itoa(int(doc.ByteSize)), + } + if err := nc.webdav.Put(path, headers, f); err != nil { + return err + } + _ = fs.DestroyFile(doc) + return nil +} + +func (nc *NextCloud) fillBasePath(accountDoc *couchdb.JSONDoc) error { + userID, _ := accountDoc.M["webdav_user_id"].(string) + if userID != "" { + nc.webdav.BasePath = "/remote.php/dav/files/" + userID + return nil + } + + userID, err := nc.fetchUserID() + if err != nil { + return err + } + nc.webdav.BasePath = "/remote.php/dav/files/" + userID + + // Try to persist the userID to avoid fetching it for every WebDAV request + accountDoc.M["webdav_user_id"] = userID + accountDoc.Type = consts.Accounts + account.Encrypt(*accountDoc) + _ = couchdb.UpdateDoc(nc.inst, accountDoc) + return nil +} + +func (nc *NextCloud) buildURL(item webdav.Item, path string) string { + u := &url.URL{ + Scheme: nc.webdav.Scheme, + Host: nc.webdav.Host, + Path: "/apps/files/files/" + item.ID, + RawQuery: "dir=/" + path, + } + return u.String() +} + +// https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-status-api.html#fetch-your-own-status +func (nc *NextCloud) fetchUserID() (string, error) { + logger := nc.webdav.Logger + u := url.URL{ + Scheme: nc.webdav.Scheme, + Host: nc.webdav.Host, + User: url.UserPassword(nc.webdav.Username, nc.webdav.Password), + Path: "/ocs/v2.php/apps/user_status/api/v1/user_status", + } + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "cozy-stack "+build.Version+" ("+runtime.Version()+")") + req.Header.Set("OCS-APIRequest", "true") + req.Header.Set("Accept", "application/json") + start := time.Now() + res, err := safehttp.ClientWithKeepAlive.Do(req) + elapsed := time.Since(start) + if err != nil { + logger.Warnf("user_status %s: %s (%s)", u.Host, err, elapsed) + return "", err + } + defer res.Body.Close() + logger.Infof("user_status %s: %d (%s)", u.Host, res.StatusCode, elapsed) + if res.StatusCode != 200 { + return "", webdav.ErrInvalidAuth + } + var payload OCSPayload + if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { + logger.Warnf("cannot fetch NextCloud userID: %s", err) + return "", err + } + return payload.OCS.Data.UserID, nil +} + +type OCSPayload struct { + OCS struct { + Data struct { + UserID string `json:"userId"` + } `json:"data"` + } `json:"ocs"` +} diff --git a/pkg/consts/doctype.go b/pkg/consts/doctype.go index 92a15a3bcb4..2675e72716f 100644 --- a/pkg/consts/doctype.go +++ b/pkg/consts/doctype.go @@ -139,4 +139,7 @@ const ( // SourceAccountIdentifier doc type is used to link a directory to the // konnector account that imports documents inside it. SourceAccountIdentifier = "io.cozy.accounts.sourceAccountIdentifier" + // NextCloudFiles doc type is used when listing files from a NextCloud via + // WebDAV. + NextCloudFiles = "io.cozy.remote.nextcloud.files" ) diff --git a/pkg/couchdb/couchdb.go b/pkg/couchdb/couchdb.go index c6c45620a83..c2fb9da5abf 100644 --- a/pkg/couchdb/couchdb.go +++ b/pkg/couchdb/couchdb.go @@ -322,7 +322,7 @@ func makeRequest(db prefixer.Prefixer, doctype, method, path string, reqbody int return err } if resbody == nil { - // Flush the body, so that the connecion can be reused by keep-alive + // Flush the body, so that the connection can be reused by keep-alive _, _ = io.Copy(io.Discard, resp.Body) return nil } diff --git a/pkg/jsonapi/errors.go b/pkg/jsonapi/errors.go index 92ee039e985..db70cd27882 100644 --- a/pkg/jsonapi/errors.go +++ b/pkg/jsonapi/errors.go @@ -64,6 +64,15 @@ func BadRequest(err error) *Error { } } +// Unauthorized returns a 401 formatted error +func Unauthorized(err error) *Error { + return &Error{ + Status: http.StatusUnauthorized, + Title: "Unauthorized", + Detail: err.Error(), + } +} + // BadJSON returns a 400 formatted error meaning the json input is // malformed. func BadJSON() *Error { diff --git a/pkg/webdav/errors.go b/pkg/webdav/errors.go new file mode 100644 index 00000000000..ade8fbda87c --- /dev/null +++ b/pkg/webdav/errors.go @@ -0,0 +1,19 @@ +package webdav + +import "errors" + +var ( + // ErrInvalidAuth is used when an authentication error occurs (invalid + // credentials). + ErrInvalidAuth = errors.New("invalid authentication") + // ErrAlreadyExist is used when trying to create a directory that already + // exists. + ErrAlreadyExist = errors.New("it already exists") + // ErrParentNotFound is used when trying to create a directory and the + // parent directory does not exist. + ErrParentNotFound = errors.New("parent directory does not exist") + // ErrNotFound is used when the given file/directory has not been found. + ErrNotFound = errors.New("file/directory not found") + // ErrInternalServerError is used when something unexpected happens. + ErrInternalServerError = errors.New("internal server error") +) diff --git a/pkg/webdav/webdav.go b/pkg/webdav/webdav.go new file mode 100644 index 00000000000..3ef346909cb --- /dev/null +++ b/pkg/webdav/webdav.go @@ -0,0 +1,338 @@ +// Package webdav is a webdav client library. +package webdav + +import ( + "encoding/xml" + "io" + "net/http" + "net/url" + "runtime" + "strconv" + "strings" + "time" + + build "github.com/cozy/cozy-stack/pkg/config" + "github.com/cozy/cozy-stack/pkg/logger" + "github.com/cozy/cozy-stack/pkg/safehttp" + "github.com/labstack/echo/v4" +) + +type Client struct { + Scheme string + Host string + Username string + Password string + BasePath string + Logger *logger.Entry +} + +func (c *Client) Mkcol(path string) error { + res, err := c.req("MKCOL", path, nil, nil) + if err != nil { + return err + } + defer res.Body.Close() + switch res.StatusCode { + case 201: + return nil + case 401, 403: + return ErrInvalidAuth + case 405: + return ErrAlreadyExist + case 409: + return ErrParentNotFound + default: + return ErrInternalServerError + } +} + +func (c *Client) Delete(path string) error { + res, err := c.req("DELETE", path, nil, nil) + if err != nil { + return err + } + defer res.Body.Close() + switch res.StatusCode { + case 204: + return nil + case 401, 403: + return ErrInvalidAuth + case 404: + return ErrNotFound + default: + return ErrInternalServerError + } +} + +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", + } + res, err := c.req("MOVE", oldPath, headers, nil) + if err != nil { + return err + } + defer res.Body.Close() + switch res.StatusCode { + case 201, 204: + return nil + case 401, 403: + return ErrInvalidAuth + case 404, 409: + return ErrNotFound + case 412: + return ErrAlreadyExist + default: + return ErrInternalServerError + } +} + +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{ + "Destination": u.String(), + "Overwrite": "F", + } + res, err := c.req("COPY", oldPath, headers, nil) + if err != nil { + return err + } + defer res.Body.Close() + switch res.StatusCode { + case 201, 204: + return nil + case 401, 403: + return ErrInvalidAuth + case 404, 409: + return ErrNotFound + case 412: + return ErrAlreadyExist + default: + return ErrInternalServerError + } +} + +func (c *Client) Put(path string, headers map[string]string, body io.Reader) error { + res, err := c.req("PUT", path, headers, body) + if err != nil { + return err + } + defer res.Body.Close() + switch res.StatusCode { + case 201: + return nil + case 401, 403: + return ErrInvalidAuth + case 405: + return ErrAlreadyExist + case 404, 409: + return ErrParentNotFound + default: + return ErrInternalServerError + } +} + +func (c *Client) Get(path string) (*Download, error) { + res, err := c.req("GET", path, nil, nil) + if err != nil { + return nil, err + } + + if res.StatusCode == 200 { + return &Download{ + Content: res.Body, + ETag: res.Header.Get("Etag"), + Length: res.Header.Get(echo.HeaderContentLength), + Mime: res.Header.Get(echo.HeaderContentType), + LastModified: res.Header.Get(echo.HeaderLastModified), + }, nil + } + + defer res.Body.Close() + switch res.StatusCode { + case 401, 403: + return nil, ErrInvalidAuth + case 404: + return nil, ErrNotFound + default: + return nil, ErrInternalServerError + } +} + +type Download struct { + Content io.ReadCloser + ETag string + Length string + Mime string + LastModified string +} + +func (c *Client) List(path string) ([]Item, error) { + path = fixSlashes(path) + headers := map[string]string{ + "Content-Type": "application/xml;charset=UTF-8", + "Accept": "application/xml", + "Depth": "1", + } + payload := strings.NewReader(ListFilesPayload) + res, err := c.req("PROPFIND", path, headers, payload) + if err != nil { + return nil, err + } + defer func() { + // Flush the body, so that the connection can be reused by keep-alive + _, _ = io.Copy(io.Discard, res.Body) + _ = res.Body.Close() + }() + + switch res.StatusCode { + case 200, 207: + // OK continue the work + case 401, 403: + return nil, ErrInvalidAuth + case 404: + return nil, ErrNotFound + default: + return nil, ErrInternalServerError + } + + // https://docs.nextcloud.com/server/20/developer_manual/client_apis/WebDAV/basic.html#requesting-properties + var multistatus multistatus + if err := xml.NewDecoder(res.Body).Decode(&multistatus); err != nil { + return nil, err + } + + var items []Item + for _, response := range multistatus.Responses { + // We want only the children, not the directory itself + parts := strings.Split(strings.TrimPrefix(response.Href, c.BasePath), "/") + for i, part := range parts { + if p, err := url.PathUnescape(part); err == nil { + parts[i] = p + } + } + href := strings.Join(parts, "/") + if href == path { + continue + } + + for _, props := range response.Props { + // Only looks for the HTTP/1.1 200 OK status + parts := strings.Split(props.Status, " ") + if len(parts) < 2 || parts[1] != "200" { + continue + } + item := Item{ + ID: props.FileID, + Type: "directory", + Name: props.Name, + LastModified: props.LastModified, + ETag: props.ETag, + } + if props.Type.Local == "" { + item.Type = "file" + if props.Size != "" { + if size, err := strconv.ParseUint(props.Size, 10, 64); err == nil { + item.Size = size + } + } + } + items = append(items, item) + } + } + return items, nil +} + +type Item struct { + ID string + Type string + Name string + Size uint64 + ContentType string + LastModified string + ETag string +} + +type multistatus struct { + XMLName xml.Name `xml:"multistatus"` + Responses []response `xml:"response"` +} + +type response struct { + Href string `xml:"DAV: href"` + Props []props `xml:"DAV: propstat"` +} + +type props struct { + Status string `xml:"status"` + Type xml.Name `xml:"prop>resourcetype>collection"` + Name string `xml:"prop>displayname"` + Size string `xml:"prop>getcontentlength"` + ContentType string `xml:"prop>getcontenttype"` + LastModified string `xml:"prop>getlastmodified"` + ETag string `xml:"prop>getetag"` + FileID string `xml:"prop>fileid"` +} + +const ListFilesPayload = ` + + + + + + + + + + + +` + +func (c *Client) req(method, path string, headers map[string]string, body io.Reader) (*http.Response, error) { + path = c.BasePath + fixSlashes(path) + u := url.URL{ + Scheme: c.Scheme, + Host: c.Host, + User: url.UserPassword(c.Username, c.Password), + Path: path, + } + req, err := http.NewRequest(method, u.String(), body) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "cozy-stack "+build.Version+" ("+runtime.Version()+")") + for k, v := range headers { + req.Header.Set(k, v) + } + start := time.Now() + res, err := safehttp.ClientWithKeepAlive.Do(req) + elapsed := time.Since(start) + if err != nil { + c.Logger.Warnf("%s %s %s: %s (%s)", method, c.Host, path, err, elapsed) + return nil, err + } + c.Logger.Infof("%s %s %s: %d (%s)", method, c.Host, path, res.StatusCode, elapsed) + return res, nil +} + +func fixSlashes(s string) string { + if !strings.HasPrefix(s, "/") { + s = "/" + s + } + if !strings.HasSuffix(s, "/") { + s += "/" + } + return s +} diff --git a/tests/system/Gemfile b/tests/system/Gemfile index 3b6412a23cf..6458774e282 100644 --- a/tests/system/Gemfile +++ b/tests/system/Gemfile @@ -10,4 +10,5 @@ gem "pry" gem "pry-rescue" gem "pry-stack_explorer" gem "rest-client" +gem "testcontainers" gem "uuid" diff --git a/tests/system/Gemfile.lock b/tests/system/Gemfile.lock index 5d28f620f9f..87c26888f66 100644 --- a/tests/system/Gemfile.lock +++ b/tests/system/Gemfile.lock @@ -7,9 +7,13 @@ GEM coderay (1.1.3) concurrent-ruby (1.2.2) debug_inspector (1.1.0) + docker-api (2.2.0) + excon (>= 0.47.0) + multi_json domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) eventmachine (1.2.7) + excon (0.110.0) faker (3.1.1) i18n (>= 1.8.11, < 2) faye-websocket (0.11.0) @@ -29,6 +33,7 @@ GEM mime-types-data (3.2019.1009) mini_mime (1.0.3) minitest (5.11.3) + multi_json (1.15.0) netrc (0.11.0) pbkdf2-ruby (0.2.1) pry (0.14.1) @@ -46,6 +51,10 @@ GEM mime-types (>= 1.16, < 4.0) netrc (~> 0.8) systemu (2.6.5) + testcontainers (0.2.0) + testcontainers-core (= 0.2.0) + testcontainers-core (0.2.0) + docker-api (~> 2.2) unf (0.1.4) unf_ext unf_ext (0.0.7.7) @@ -69,6 +78,7 @@ DEPENDENCIES pry-rescue pry-stack_explorer rest-client + testcontainers uuid BUNDLED WITH diff --git a/tests/system/boot.rb b/tests/system/boot.rb index 839e55350ab..5bd744e025c 100644 --- a/tests/system/boot.rb +++ b/tests/system/boot.rb @@ -2,6 +2,7 @@ require 'base64' require 'date' require 'digest' +require 'erb' require 'faker' require 'fileutils' require 'mini_mime' @@ -10,6 +11,7 @@ require 'pbkdf2' require 'pry' require 'rest-client' +require 'testcontainers' require 'uuid' AmazingPrint.pry! diff --git a/tests/system/lib/account.rb b/tests/system/lib/account.rb index 7a43f2c94a0..caba64d6dc4 100644 --- a/tests/system/lib/account.rb +++ b/tests/system/lib/account.rb @@ -15,6 +15,7 @@ def initialize(opts = {}) @failure = opts[:failure] @type = opts[:type] @auth = opts[:auth] + @webdav_user_id = opts[:webdav_user_id] end def as_json @@ -25,7 +26,8 @@ def as_json failure: @failure }, account_type: @type, - auth: @auth + auth: @auth, + webdav_user_id: @webdav_user_id }.compact if @aggregator json[:relationships] = { diff --git a/tests/system/lib/nextcloud.rb b/tests/system/lib/nextcloud.rb new file mode 100644 index 00000000000..d3e13f966cd --- /dev/null +++ b/tests/system/lib/nextcloud.rb @@ -0,0 +1,79 @@ +class Nextcloud + def initialize(inst, account_id) + @inst = inst + @token = @inst.token_for "io.cozy.files" + @base_path = "/remote/nextcloud/#{account_id}" + end + + def list(path) + opts = { + accept: :json, + authorization: "Bearer #{@token}" + } + res = @inst.client["#{@base_path}#{encode_path path}"].get opts + JSON.parse(res.body) + end + + def move(path, to) + opts = { + authorization: "Bearer #{@token}" + } + @inst.client["#{@base_path}/move#{encode_path path}?To=#{to}"].post nil, opts + end + + def copy(path, name) + opts = { + authorization: "Bearer #{@token}" + } + @inst.client["#{@base_path}/copy#{encode_path path}?Name=#{name}"].post nil, opts + end + + def mkdir(path) + opts = { + authorization: "Bearer #{@token}" + } + @inst.client["#{@base_path}#{encode_path path}?Type=directory"].put nil, opts + end + + def upload(path, filename) + mime = MiniMime.lookup_by_filename(filename).content_type + content = File.read filename + opts = { + authorization: "Bearer #{@token}", + :"content-type" => mime + } + @inst.client["#{@base_path}#{encode_path path}?Type=file"].put content, opts + end + + def download(path) + opts = { + authorization: "Bearer #{@token}" + } + @inst.client["#{@base_path}#{encode_path path}?Dl=1"].get(opts).body + end + + def upstream(path, from) + opts = { + authorization: "Bearer #{@token}" + } + @inst.client["#{@base_path}/upstream#{encode_path path}?From=#{from}"].post nil, opts + end + + def downstream(path, to) + opts = { + authorization: "Bearer #{@token}" + } + @inst.client["#{@base_path}/downstream#{encode_path path}?To=#{to}"].post nil, opts + end + + def delete(path) + opts = { + authorization: "Bearer #{@token}" + } + @inst.client["#{@base_path}#{encode_path path}"].delete opts + end + + def encode_path(path) + path.split("/").map { |s| ERB::Util.url_encode s }.join("/") + end +end diff --git a/tests/system/nextcloud/before-starting/create-account.sh b/tests/system/nextcloud/before-starting/create-account.sh new file mode 100755 index 00000000000..8ef19e1416b --- /dev/null +++ b/tests/system/nextcloud/before-starting/create-account.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +# echo -ne fred | md5sum +export OC_PASS=570a90bfbf8c7eab5dc5d4e26832d5b1 +php occ user:add --password-from-env --display-name="Fred" --group="users" fred diff --git a/tests/system/tests/webdav_nextcloud.rb b/tests/system/tests/webdav_nextcloud.rb new file mode 100644 index 00000000000..e805a8d7244 --- /dev/null +++ b/tests/system/tests/webdav_nextcloud.rb @@ -0,0 +1,85 @@ +require_relative '../boot' +require 'minitest/autorun' +require 'pry-rescue/minitest' unless ENV['CI'] + + +describe "NextCloud" do + it "can be used with WebDAV" do + Helpers.scenario "webdav_nextcloud" + Helpers.start_mailhog + + container = Testcontainers::DockerContainer.new("nextcloud:latest") + container.add_exposed_ports(80) + volume = File.expand_path("../nextcloud/before-starting", __dir__) + container.add_filesystem_bind volume, "/docker-entrypoint-hooks.d/before-starting" + container.add_env "SQLITE_DATABASE", "nextcloud" + container.add_env "NEXTCLOUD_ADMIN_USER", "root" + container.add_env "NEXTCLOUD_ADMIN_PASSWORD", "63a9f0ea7bb98050796b649e85481845" + container.add_wait_for :logs, /apache2 -D FOREGROUND/ + + puts "Start NextCloud container...".green + container.use do + host = container.host + port = container.first_mapped_port + user = "fred" + pass = "570a90bfbf8c7eab5dc5d4e26832d5b1" + + inst = Instance.create name: "Fred" + auth = { login: user, password: pass, url: "http://#{host}:#{port}/" } + # We need to put the webdav_user_id in the document, as the user_status + # endpoint from NextCloud doesn't work well in the docker container (it + # responds with a 404 if the user has logged-in, and sometimes, it even + # responds with a 500 after that). + account = Account.create inst, type: "nextcloud", + name: "NextCloud", + auth: auth, + webdav_user_id: "fred" + + nextcloud = Nextcloud.new inst, account.couch_id + dir_name = "#{Faker::Superhero.name} ⚡️" + nextcloud.mkdir "/#{dir_name}" + + file_name = "1. #{Faker::Science.science}.jpg" + file_path = "../fixtures/wet-cozy_20160910__M4Dz.jpg" + nextcloud.upload "/#{dir_name}/#{file_name}", file_path + expected = File.read(file_path, encoding: Encoding::ASCII_8BIT) + content = nextcloud.download "/#{dir_name}/#{file_name}" + assert_equal content, expected + + opts = CozyFile.options_from_fixture("README.md") + file = CozyFile.create inst, opts + other_name = "2. #{file.name}" + nextcloud.upstream "/#{dir_name}/#{other_name}", file.couch_id + + list = nextcloud.list "/#{dir_name}" + assert_equal 2, list.dig("meta", "count") + assert_equal "file", list.dig("data", 0, "attributes", "type") + assert_equal file_name, list.dig("data", 0, "attributes", "name") + assert_equal File.size(file_path), list.dig("data", 0, "attributes", "size") + assert_equal "image/jpeg", list.dig("data", 0, "attributes", "mime") + assert_equal "image", list.dig("data", 0, "attributes", "class") + assert_equal "file", list.dig("data", 1, "attributes", "type") + assert_equal other_name, list.dig("data", 1, "attributes", "name") + assert_equal File.size("README.md"), list.dig("data", 1, "attributes", "size") + assert_equal "text/markdown", list.dig("data", 1, "attributes", "mime") + assert_equal "text", list.dig("data", 1, "attributes", "class") + + copy_name = "#{Faker::Mountain.name}.jpg" + nextcloud.copy "/#{dir_name}/#{file_name}", copy_name + nextcloud.move "/#{dir_name}/#{copy_name}", "/#{copy_name}" + content = nextcloud.download "/#{copy_name}" + assert_equal content, expected + + nextcloud.downstream "/#{dir_name}/#{file_name}", Folder::ROOT_DIR + f = CozyFile.find_by_path inst, "/#{file_name}" + assert_equal File.size(file_path), f.size.to_i + assert_equal "image/jpeg", f.mime + + nextcloud.delete "/#{dir_name}/#{other_name}" + list = nextcloud.list "/#{dir_name}" + assert_equal 0, list.dig("meta", "count") + end + + container.remove + end +end diff --git a/web/remote/nextcloud.go b/web/remote/nextcloud.go new file mode 100644 index 00000000000..ef8dfdc68b3 --- /dev/null +++ b/web/remote/nextcloud.go @@ -0,0 +1,264 @@ +package remote + +import ( + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/cozy/cozy-stack/model/nextcloud" + "github.com/cozy/cozy-stack/model/permission" + "github.com/cozy/cozy-stack/model/vfs" + "github.com/cozy/cozy-stack/pkg/config/config" + "github.com/cozy/cozy-stack/pkg/consts" + "github.com/cozy/cozy-stack/pkg/jsonapi" + "github.com/cozy/cozy-stack/pkg/webdav" + "github.com/cozy/cozy-stack/web/files" + "github.com/cozy/cozy-stack/web/middlewares" + "github.com/labstack/echo/v4" + "github.com/ncw/swift/v2" +) + +func nextcloudGet(c echo.Context) error { + inst := middlewares.GetInstance(c) + if err := middlewares.AllowWholeType(c, permission.PUT, 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 c.QueryParam("Dl") == "1" { + return nextcloudDownload(c, nc, path) + } + + files, err := nc.ListFiles(path) + if err != nil { + return wrapNextcloudErrors(err) + } + return jsonapi.DataList(c, http.StatusOK, files, nil) +} + +func nextcloudDownload(c echo.Context, nc *nextcloud.NextCloud, path string) error { + f, err := nc.Download(path) + if err != nil { + return wrapNextcloudErrors(err) + } + defer f.Content.Close() + + w := c.Response() + header := w.Header() + filename := filepath.Base(path) + disposition := vfs.ContentDisposition("attachment", filename) + header.Set(echo.HeaderContentDisposition, disposition) + header.Set(echo.HeaderContentType, f.Mime) + if f.Length != "" { + header.Set(echo.HeaderContentLength, f.Length) + } + if f.LastModified != "" { + header.Set(echo.HeaderLastModified, f.LastModified) + } + if f.ETag != "" { + header.Set("Etag", f.ETag) + } + if !config.GetConfig().CSPDisabled { + middlewares.AppendCSPRule(c, "form-action", "'none'") + } + + w.WriteHeader(http.StatusOK) + _, err = io.Copy(w, f.Content) + return err +} + +func nextcloudPut(c echo.Context) error { + inst := middlewares.GetInstance(c) + if err := middlewares.AllowWholeType(c, permission.PUT, 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 c.QueryParam("Type") == "file" { + return nextcloudUpload(c, nc, path) + } + + if err := nc.Mkdir(path); err != nil { + return wrapNextcloudErrors(err) + } + return c.JSON(http.StatusCreated, echo.Map{"ok": true}) +} + +func nextcloudUpload(c echo.Context, nc *nextcloud.NextCloud, path string) error { + req := c.Request() + mime := req.Header.Get(echo.HeaderContentType) + if err := nc.Upload(path, mime, req.Body); err != nil { + return wrapNextcloudErrors(err) + } + return c.JSON(http.StatusCreated, echo.Map{"ok": true}) +} + +func nextcloudDelete(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 := c.Param("*") + if err := nc.Delete(path); err != nil { + return wrapNextcloudErrors(err) + } + return c.NoContent(http.StatusNoContent) +} + +func nextcloudMove(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) + } + + oldPath := c.Param("*") + newPath := c.QueryParam("To") + if newPath == "" { + return jsonapi.BadRequest(errors.New("missing To parameter")) + } + + if err := nc.Move(oldPath, newPath); err != nil { + return wrapNextcloudErrors(err) + } + return c.NoContent(http.StatusNoContent) +} + +func nextcloudCopy(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) + } + + oldPath := c.Param("*") + newPath := oldPath + if newName := c.QueryParam("Name"); newName != "" { + newPath = filepath.Join(filepath.Dir(oldPath), newName) + } else { + ext := filepath.Ext(oldPath) + base := strings.TrimSuffix(oldPath, ext) + suffix := inst.Translate("File copy Suffix") + newPath = fmt.Sprintf("%s (%s)%s", base, suffix, ext) + } + + if err := nc.Copy(oldPath, newPath); err != nil { + return wrapNextcloudErrors(err) + } + return c.JSON(http.StatusCreated, echo.Map{"ok": true}) +} + +func nextcloudDownstream(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("*") + to := c.QueryParam("To") + if to == "" { + return jsonapi.BadRequest(errors.New("missing To parameter")) + } + + cozyMetadata, _ := files.CozyMetadataFromClaims(c, true) + f, err := nc.Downstream(path, to, cozyMetadata) + if err != nil { + return wrapNextcloudErrors(err) + } + obj := files.NewFile(f, inst) + return jsonapi.Data(c, http.StatusCreated, obj, nil) +} + +func nextcloudUpstream(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("*") + from := c.QueryParam("From") + if from == "" { + return jsonapi.BadRequest(errors.New("missing From parameter")) + } + + if err := nc.Upstream(path, from); err != nil { + return wrapNextcloudErrors(err) + } + return c.NoContent(http.StatusNoContent) +} + +func nextcloudRoutes(router *echo.Group) { + group := router.Group("/nextcloud/:account") + group.GET("/*", nextcloudGet) + group.PUT("/*", nextcloudPut) + group.DELETE("/*", nextcloudDelete) + group.POST("/move/*", nextcloudMove) + group.POST("/copy/*", nextcloudCopy) + group.POST("/downstream/*", nextcloudDownstream) + group.POST("/upstream/*", nextcloudUpstream) +} + +func wrapNextcloudErrors(err error) error { + switch err { + case nextcloud.ErrAccountNotFound: + return jsonapi.NotFound(err) + case nextcloud.ErrInvalidAccount: + return jsonapi.BadRequest(err) + case webdav.ErrInvalidAuth: + return jsonapi.Unauthorized(err) + case webdav.ErrAlreadyExist, vfs.ErrConflict: + return jsonapi.Conflict(err) + case webdav.ErrParentNotFound: + return jsonapi.NotFound(err) + case webdav.ErrNotFound, os.ErrNotExist, swift.ObjectNotFound: + return jsonapi.NotFound(err) + case webdav.ErrInternalServerError: + return jsonapi.InternalServerError(err) + } + return err +} diff --git a/web/remote/remote.go b/web/remote/remote.go index 52492af9e90..d732d37ee29 100644 --- a/web/remote/remote.go +++ b/web/remote/remote.go @@ -1,3 +1,5 @@ +// Package remote is the used for the /remote routes. They are intended for +// requesting data that is not in the Cozy itself, but in a remote place. package remote import ( @@ -82,6 +84,8 @@ func Routes(router *echo.Group) { router.GET("/:doctype", remoteGet) router.POST("/:doctype", remotePost) router.GET("/assets/:asset-name", remoteAsset) + + nextcloudRoutes(router) } func wrapRemoteErr(err error) error {