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 2, 2024
1 parent 83bfcda commit c7a0b7c
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,16 +6,105 @@ 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.

## PUT /remote/nextcloud/:account/*path
## GET /remote/nextcloud/:account/*path

This route can be used to create a directory on the NextCloud.
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.sourceAccountIdentifier` 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`.

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

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

### Request
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")
)
Loading

0 comments on commit c7a0b7c

Please sign in to comment.