Skip to content

Commit

Permalink
Allow to list files for a NextCloud directory
Browse files Browse the repository at this point in the history
  • Loading branch information
nono committed May 13, 2024
1 parent 83bfcda commit 3702b3e
Show file tree
Hide file tree
Showing 7 changed files with 300 additions and 6 deletions.
93 changes: 91 additions & 2 deletions docs/nextcloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,102 @@ 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.

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 directory on the NextCloud.

**Note:** a permission on `GET io.cozy.files` is required to use this route.

### Request

```http
GET /remote/nextcloud/4ab2155707bb6613a8b9463daf00381b/Documents HTTP/1.1
Host: cozy.example.net
Authorization: Bearer eyJhbG...
```

### Response

```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": {}
},
{
"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": {}
},
{
"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": {}
},
{
"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": {}
}
],
"meta": {
"count": 5
}
}
```

#### 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 on the NextCloud.

The `:account` parameter is the identifier of the NextCloud `io.cozy.account`.
It is available with the `cozyMetadata.sourceAccountIdentifier` of the shortcut
file for example.

The `*path` parameter is the path of the directory on the NextCloud.

Expand Down
62 changes: 60 additions & 2 deletions model/nextcloud/nextcloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,42 @@ import (
"net/http"
"net/url"
"runtime"
"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"
)

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"`
}

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 nil }

var _ jsonapi.Object = (*File)(nil)

type NextCloud struct {
inst *instance.Instance
accountID string
Expand Down Expand Up @@ -74,6 +100,33 @@ func (nc *NextCloud) Mkdir(path string) error {
return nc.webdav.Mkcol(path)
}

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.ExtractMimeAndClass(item.ContentType)
}
file := &File{
DocID: item.ID,
Type: item.Type,
Name: item.Name,
Size: item.Size,
Mime: mime,
Class: class,
UpdatedAt: item.LastModified,
ETag: item.ETag,
}
files = append(files, file)
}
return files, nil
}

func (nc *NextCloud) fillBasePath(accountDoc *couchdb.JSONDoc) error {
userID, _ := accountDoc.M["webdav_user_id"].(string)
if userID != "" {
Expand All @@ -89,13 +142,15 @@ func (nc *NextCloud) fillBasePath(accountDoc *couchdb.JSONDoc) error {

// 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
}

// 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,
Expand All @@ -109,18 +164,21 @@ func (nc *NextCloud) fetchUserID() (string, error) {
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 {
nc.webdav.Logger.Warnf("cannot fetch NextCloud userID: %d", res.StatusCode)
return "", webdav.ErrInvalidAuth
}
var payload OCSPayload
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
nc.webdav.Logger.Warnf("cannot fetch NextCloud userID: %s", err)
logger.Warnf("cannot fetch NextCloud userID: %s", err)
return "", err
}
return payload.OCS.Data.UserID, nil
Expand Down
3 changes: 3 additions & 0 deletions pkg/consts/doctype.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
2 changes: 1 addition & 1 deletion pkg/couchdb/couchdb.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions pkg/webdav/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ var (
// 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")
)
121 changes: 120 additions & 1 deletion pkg/webdav/webdav.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@
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"
Expand Down Expand Up @@ -42,6 +45,119 @@ func (c *Client) Mkcol(path string) error {
}
}

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
if response.Href == c.BasePath+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 = `<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
<d:prop>
<d:resourcetype />
<d:displayname />
<d:getlastmodified />
<d:getetag />
<d:getcontentlength />
<d:getcontenttype />
<oc:fileid />
</d:prop>
</d:propfind>
`

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{
Expand All @@ -58,11 +174,14 @@ func (c *Client) req(method, path string, headers map[string]string, body io.Rea
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", method, c.Host, path, res.StatusCode)
c.Logger.Infof("%s %s %s: %d (%s)", method, c.Host, path, res.StatusCode, elapsed)
return res, nil
}

Expand Down
Loading

0 comments on commit 3702b3e

Please sign in to comment.