diff --git a/README.md b/README.md index 5b3b0c70b..9f30938f5 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Rclone is a command line program to sync files and directories to and from * pCloud * QingStor * SFTP + * Webdav / Owncloud / Nextcloud * Yandex Disk * The local filesystem diff --git a/bin/make_manual.py b/bin/make_manual.py index f3f989cf1..a221e3423 100755 --- a/bin/make_manual.py +++ b/bin/make_manual.py @@ -38,6 +38,7 @@ docs = [ "swift.md", "pcloud.md", "sftp.md", + "webdav.md", "yandex.md", "local.md", diff --git a/cmd/cmd.go b/cmd/cmd.go index 710cc83f5..71f3c5242 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -57,6 +57,7 @@ from various cloud storage systems and using file transfer services, such as: * pCloud * QingStor * SFTP + * Webdav / Owncloud / Nextcloud * Yandex Disk * The local filesystem diff --git a/docs/content/about.md b/docs/content/about.md index de506a208..34e2c6f0e 100644 --- a/docs/content/about.md +++ b/docs/content/about.md @@ -29,14 +29,17 @@ Rclone is a command line program to sync files and directories to and from: * {{< provider name="Microsoft Azure Blob Storage" home="https://azure.microsoft.com/en-us/services/storage/blobs/" config="/azureblob/" >}} * {{< provider name="Microsoft OneDrive" home="https://onedrive.live.com/" config="/onedrive/" >}} * {{< provider name="Minio" home="https://www.minio.io/" config="/s3/#minio" >}} +* {{< provider name="Nextloud" home="https://nextcloud.com/" config="/webdav/#nextcloud" >}} * {{< provider name="OVH" home="https://www.ovh.co.uk/public-cloud/storage/object-storage/" config="/swift/" >}} * {{< provider name="Openstack Swift" home="https://docs.openstack.org/swift/latest/" config="/swift/" >}} -* {{< provider name="pCloud" home="https://www.pcloud.com/" config="/pcloud/" >}} * {{< provider name="Oracle Cloud Storage" home="https://cloud.oracle.com/storage-opc" config="/swift/" >}} +* {{< provider name="Ownloud" home="https://owncloud.org/" config="/webdav/#owncloud" >}} +* {{< provider name="pCloud" home="https://www.pcloud.com/" config="/pcloud/" >}} * {{< provider name="QingStor" home="https://www.qingcloud.com/products/storage" config="/qingstor/" >}} * {{< provider name="Rackspace Cloud Files" home="https://www.rackspace.com/cloud/files" config="/swift/" >}} * {{< provider name="SFTP" home="https://en.wikipedia.org/wiki/SFTP" config="/sftp/" >}} * {{< provider name="Wasabi" home="https://wasabi.com/" config="/s3/#wasabi" >}} +* {{< provider name="WebDAV" home="https://en.wikipedia.org/wiki/WebDAV" config="/webdav/" >}} * {{< provider name="Yandex Disk" home="https://disk.yandex.com/" config="/yandex/" >}} * {{< provider name="The local filesystem" home="/local/" config="/local/" >}} diff --git a/docs/content/docs.md b/docs/content/docs.md index 3ecb05ca6..019ce4368 100644 --- a/docs/content/docs.md +++ b/docs/content/docs.md @@ -36,6 +36,7 @@ See the following for detailed instructions for * [Pcloud](/pcloud/) * [QingStor](/qingstor/) * [SFTP](/sftp/) + * [WebDAV](/webdav/) * [Yandex Disk](/yandex/) * [The local filesystem](/local/) diff --git a/docs/content/overview.md b/docs/content/overview.md index 513d2cc17..683729bd5 100644 --- a/docs/content/overview.md +++ b/docs/content/overview.md @@ -33,6 +33,7 @@ Here is an overview of the major features of each cloud storage system. | pCloud | MD5, SHA1 | Yes | No | No | W | | QingStor | MD5 | No | No | No | R/W | | SFTP | MD5, SHA1 ‡ | Yes | Depends | No | - | +| WebDAV | - | Yes †† | Depends | No | - | | Yandex Disk | MD5 | Yes | No | No | R/W | | The local filesystem | All | Yes | Depends | No | - | @@ -53,6 +54,8 @@ This is an SHA256 sum of all the 4MB block SHA256s. ‡ SFTP supports checksums if the same login has shell access and `md5sum` or `sha1sum` as well as `echo` are in the remote's PATH. +†† WebDAV supports modtimes when used with Owncloud and Nextcloud only. + ### ModTime ### The cloud storage system supports setting modification times on @@ -134,6 +137,7 @@ operations more efficient. | pCloud | Yes | Yes | Yes | Yes | Yes | No | No | | QingStor | No | Yes | No | No | No | Yes | No | | SFTP | No | No | Yes | Yes | No | No | Yes | +| WebDAV | Yes | Yes | Yes | Yes | No | No | Yes ‡ | | Yandex Disk | Yes | No | No | No | Yes | Yes | Yes | | The local filesystem | Yes | No | Yes | Yes | No | No | Yes | @@ -146,6 +150,8 @@ the directory. markers but they don't actually have a quicker way of deleting files other than deleting them individually. +‡ StreamUpload is not supported with Nextcloud + ### Copy ### Used when copying an object to and from the same remote. This known diff --git a/docs/content/webdav.md b/docs/content/webdav.md new file mode 100644 index 000000000..778a6db64 --- /dev/null +++ b/docs/content/webdav.md @@ -0,0 +1,148 @@ +--- +title: "WebDAV" +description: "Rclone docs for WebDAV" +date: "2017-10-01" +--- + + WebDAV +----------------------------------------- + +Paths are specified as `remote:path` + +Paths may be as deep as required, eg `remote:directory/subdirectory`. + +To configure the WebDAV remote you will need to have a URL for it, and +a username and password. If you know what kind of system you are +connecting to then rclone can enable extra features. + +Here is an example of how to make a remote called `remote`. First run: + + rclone config + +This will guide you through an interactive setup process: + +``` +No remotes found - make a new one +n) New remote +s) Set configuration password +q) Quit config +n/s/q> n +name> remote +Type of storage to configure. +Choose a number from below, or type in your own value + 1 / Amazon Drive + \ "amazon cloud drive" + 2 / Amazon S3 (also Dreamhost, Ceph, Minio) + \ "s3" + 3 / Backblaze B2 + \ "b2" + 4 / Box + \ "box" + 5 / Dropbox + \ "dropbox" + 6 / Encrypt/Decrypt a remote + \ "crypt" + 7 / FTP Connection + \ "ftp" + 8 / Google Cloud Storage (this is not Google Drive) + \ "google cloud storage" + 9 / Google Drive + \ "drive" +10 / Hubic + \ "hubic" +11 / Local Disk + \ "local" +12 / Microsoft Azure Blob Storage + \ "azureblob" +13 / Microsoft OneDrive + \ "onedrive" +14 / Openstack Swift (Rackspace Cloud Files, Memset Memstore, OVH) + \ "swift" +15 / Pcloud + \ "pcloud" +16 / QingClound Object Storage + \ "qingstor" +17 / SSH/SFTP Connection + \ "sftp" +18 / WebDAV + \ "webdav" +19 / Yandex Disk + \ "yandex" +20 / http Connection + \ "http" +Storage> webdav +URL of http host to connect to +Choose a number from below, or type in your own value + 1 / Connect to example.com + \ "https://example.com" +url> https://example.com/remote.php/webdav/ +Name of the WebDAV site/service/software you are using +Choose a number from below, or type in your own value + 1 / Nextcloud + \ "nextcloud" + 2 / Owncloud + \ "owncloud" + 3 / Other site/service or software + \ "other" +vendor> 1 +User name +user> user +Password. +y) Yes type in my own password +g) Generate random password +n) No leave this optional password blank +y/g/n> y +Enter the password: +password: +Confirm the password: +password: +Remote config +-------------------- +[remote] +url = https://example.com/remote.php/webdav/ +vendor = nextcloud +user = user +pass = *** ENCRYPTED *** +-------------------- +y) Yes this is OK +e) Edit this remote +d) Delete this remote +y/e/d> y +``` + +Once configured you can then use `rclone` like this, + +List directories in top level of your WebDAV + + rclone lsd remote: + +List all the files in your WebDAV + + rclone ls remote: + +To copy a local directory to an WebDAV directory called backup + + rclone copy /home/source remote:backup + +### Modified time and hashes ### + +Plain WebDAV does not support modified times. However when used with +Owncloud or Nextcloud rclone will support modified times. + +Hashes are not supported. + +### Owncloud ### + +Click on the settings cog in the bottom right of the page and this +will show the WebDAV URL that rclone needs in the config step. It +will look something like `https://example.com/remote.php/webdav/`. + +Owncloud supports modified times using the `X-OC-Mtime` header. + +### Nextcloud ### + +This is configured in an identical way to Owncloud. Note that +Nextcloud does not support streaming of files (`rcat`) whereas +Owncloud does. This [may be +fixed](https://github.com/nextcloud/nextcloud-snap/issues/365) in the +future. diff --git a/docs/layouts/chrome/navbar.html b/docs/layouts/chrome/navbar.html index 27c6b23b2..354dd26a2 100644 --- a/docs/layouts/chrome/navbar.html +++ b/docs/layouts/chrome/navbar.html @@ -66,6 +66,7 @@
  • Openstack Swift
  • pCloud
  • SFTP
  • +
  • WebDAV
  • Yandex Disk
  • The local filesystem
  • diff --git a/fs/all/all.go b/fs/all/all.go index c0bcd9982..99333fb6b 100644 --- a/fs/all/all.go +++ b/fs/all/all.go @@ -20,5 +20,6 @@ import ( _ "github.com/ncw/rclone/s3" _ "github.com/ncw/rclone/sftp" _ "github.com/ncw/rclone/swift" + _ "github.com/ncw/rclone/webdav" _ "github.com/ncw/rclone/yandex" ) diff --git a/fs/test_all.go b/fs/test_all.go index a890c8daf..85d393f31 100644 --- a/fs/test_all.go +++ b/fs/test_all.go @@ -118,6 +118,11 @@ var ( SubDir: false, FastList: false, }, + { + Name: "TestWebdav:", + SubDir: false, + FastList: false, + }, } binary = "fs.test" // Flags diff --git a/fstest/fstests/gen_tests.go b/fstest/fstests/gen_tests.go index 2c9f1e128..23427d7d3 100644 --- a/fstest/fstests/gen_tests.go +++ b/fstest/fstests/gen_tests.go @@ -165,5 +165,6 @@ func main() { generateTestProgram(t, fns, "QingStor", buildConstraint("!plan9")) generateTestProgram(t, fns, "AzureBlob", buildConstraint("go1.7")) generateTestProgram(t, fns, "Pcloud") + generateTestProgram(t, fns, "Webdav") log.Printf("Done") } diff --git a/webdav/api/types.go b/webdav/api/types.go new file mode 100644 index 000000000..776b2e712 --- /dev/null +++ b/webdav/api/types.go @@ -0,0 +1,111 @@ +// Package api has type definitions for webdav +package api + +import ( + "encoding/xml" + "regexp" + "strconv" + "time" +) + +const ( + // Wed, 27 Sep 2017 14:28:34 GMT + timeFormat = time.RFC1123 +) + +// Multistatus contains responses returned from an HTTP 207 return code +type Multistatus struct { + Responses []Response `xml:"response"` +} + +// Response contains an Href the response it about and its properties +type Response struct { + Href string `xml:"href"` + Props Prop `xml:"propstat"` +} + +// Prop is the properties of a response +type Prop struct { + Status string `xml:"DAV: status"` + Name string `xml:"DAV: prop>displayname,omitempty"` + Type xml.Name `xml:"DAV: prop>resourcetype>collection,omitempty"` + Size int64 `xml:"DAV: prop>getcontentlength,omitempty"` + Modified Time `xml:"DAV: prop>getlastmodified,omitempty"` +} + +// Parse a status of the form "HTTP/1.1 200 OK", +var parseStatus = regexp.MustCompile(`^HTTP/[0-9.]+\s+(\d+)\s+(.*)$`) + +// StatusOK examines the Status and returns an OK flag +func (p *Prop) StatusOK() bool { + match := parseStatus.FindStringSubmatch(p.Status) + if len(match) < 3 { + return false + } + code, err := strconv.Atoi(match[1]) + if err != nil { + return false + } + if code >= 200 && code < 300 { + return true + } + return false +} + +// PropValue is a tagged name and value +type PropValue struct { + XMLName xml.Name `xml:""` + Value string `xml:",chardata"` +} + +// Error is used to desribe webdav errors +// +// +// Sabre\DAV\Exception\NotFound +// File with name Photo could not be located +// +type Error struct { + Exception string `xml:"exception,omitempty"` + Message string `xml:"message,omitempty"` + Status string + StatusCode int +} + +// Error returns a string for the error and statistifes the error interface +func (e *Error) Error() string { + if e.Message != "" { + return e.Message + } + if e.Exception != "" { + return e.Exception + } + if e.Status != "" { + return e.Status + } + return "Webdav Error" +} + +// Time represents represents date and time information for the +// webdav API marshalling to and from timeFormat +type Time time.Time + +// MarshalXML turns a Time into XML +func (t *Time) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + timeString := (*time.Time)(t).Format(timeFormat) + return e.EncodeElement(timeString, start) +} + +// UnmarshalXML turns XML into a Time +func (t *Time) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + var v string + err := d.DecodeElement(&v, &start) + if err != nil { + return err + } + newT, err := time.Parse(timeFormat, v) + if err != nil { + return err + } + *t = Time(newT) + return nil +} diff --git a/webdav/webdav.go b/webdav/webdav.go new file mode 100644 index 000000000..9372626f8 --- /dev/null +++ b/webdav/webdav.go @@ -0,0 +1,900 @@ +// Package webdav provides an interface to the Webdav +// object storage system. +package webdav + +// Owncloud: Getting Oc-Checksum: +// SHA1:f572d396fae9206628714fb2ce00f72e94f2258f on HEAD but not on +// nextcloud? + +// docs for file webdav +// https://docs.nextcloud.com/server/12/developer_manual/client_apis/WebDAV/index.html + +// indicates checksums can be set as metadata here +// https://github.com/nextcloud/server/issues/6129 +// owncloud seems to have checksums as metadata though - can read them + +// SetModTime might be possible +// https://stackoverflow.com/questions/3579608/webdav-can-a-client-modify-the-mtime-of-a-file +// ...support for a PROPSET to lastmodified (mind the missing get) which does the utime() call might be an option. +// For example the ownCloud WebDAV server does it that way. + +import ( + "fmt" + "io" + "net/http" + "net/url" + "path" + "regexp" + "strings" + "time" + + "github.com/ncw/rclone/fs" + "github.com/ncw/rclone/pacer" + "github.com/ncw/rclone/rest" + "github.com/ncw/rclone/webdav/api" + "github.com/pkg/errors" +) + +const ( + minSleep = 10 * time.Millisecond + maxSleep = 2 * time.Second + decayConstant = 2 // bigger for slower decay, exponential +) + +// Register with Fs +func init() { + fs.Register(&fs.RegInfo{ + Name: "webdav", + Description: "Webdav", + NewFs: NewFs, + Options: []fs.Option{{ + Name: "url", + Help: "URL of http host to connect to", + Optional: false, + Examples: []fs.OptionExample{{ + Value: "https://example.com", + Help: "Connect to example.com", + }}, + }, { + Name: "vendor", + Help: "Name of the Webdav site/service/software you are using", + Optional: false, + Examples: []fs.OptionExample{{ + Value: "nextcloud", + Help: "Nextcloud", + }, { + Value: "owncloud", + Help: "Owncloud", + }, { + Value: "other", + Help: "Other site/service or software", + }}, + }, { + Name: "user", + Help: "User name", + Optional: true, + }, { + Name: "pass", + Help: "Password.", + Optional: true, + IsPassword: true, + }}, + }) +} + +// Fs represents a remote webdav +type Fs struct { + name string // name of this remote + root string // the path we are working on + features *fs.Features // optional features + endpoint *url.URL // URL of the host + endpointURL string // endpoint as a string + srv *rest.Client // the connection to the one drive server + pacer *pacer.Pacer // pacer for API calls + user string // username + pass string // password + vendor string // name of the vendor + precision time.Duration // mod time precision + canStream bool // set if can stream + useOCMtime bool // set if can use X-OC-Mtime +} + +// Object describes a webdav object +// +// Will definitely have info but maybe not meta +type Object struct { + fs *Fs // what this object is part of + remote string // The remote path + hasMetaData bool // whether info below has been set + size int64 // size of the object + modTime time.Time // modification time of the object + id string // ID of the object + sha1 string // SHA-1 of the object content +} + +// ------------------------------------------------------------ + +// Name of the remote (as passed into NewFs) +func (f *Fs) Name() string { + return f.name +} + +// Root of the remote (as passed into NewFs) +func (f *Fs) Root() string { + return f.root +} + +// String converts this Fs to a string +func (f *Fs) String() string { + return fmt.Sprintf("webdav root '%s'", f.root) +} + +// Features returns the optional features of this Fs +func (f *Fs) Features() *fs.Features { + return f.features +} + +// Pattern to match a webdav path +var matcher = regexp.MustCompile(`^([^/]*)(.*)$`) + +// parsePath parses an webdav 'url' +func parsePath(path string) (root string) { + root = strings.Trim(path, "/") + return +} + +// retryErrorCodes is a slice of error codes that we will retry +var retryErrorCodes = []int{ + 429, // Too Many Requests. + 500, // Internal Server Error + 502, // Bad Gateway + 503, // Service Unavailable + 504, // Gateway Timeout + 509, // Bandwidth Limit Exceeded +} + +// shouldRetry returns a boolean as to whether this resp and err +// deserve to be retried. It returns the err as a convenience +func shouldRetry(resp *http.Response, err error) (bool, error) { + return fs.ShouldRetry(err) || fs.ShouldRetryHTTP(resp, retryErrorCodes), err +} + +// readMetaDataForPath reads the metadata from the path +func (f *Fs) readMetaDataForPath(path string) (info *api.Prop, err error) { + // FIXME how do we read back additional properties? + opts := rest.Opts{ + Method: "PROPFIND", + Path: f.filePath(path), + } + var result api.Multistatus + var resp *http.Response + err = f.pacer.Call(func() (bool, error) { + resp, err = f.srv.CallXML(&opts, nil, &result) + return shouldRetry(resp, err) + }) + if apiErr, ok := err.(*api.Error); ok { + // does not exist + if apiErr.StatusCode == http.StatusNotFound { + return nil, fs.ErrorObjectNotFound + } + } + if err != nil { + return nil, errors.Wrap(err, "read metadata failed") + } + if len(result.Responses) < 1 { + return nil, fs.ErrorObjectNotFound + } + item := result.Responses[0] + if !item.Props.StatusOK() { + return nil, fs.ErrorObjectNotFound + } + if strings.HasSuffix(item.Href, "/") { + return nil, fs.ErrorNotAFile + } + return &item.Props, nil +} + +// errorHandler parses a non 2xx error response into an error +func errorHandler(resp *http.Response) error { + // Decode error response + errResponse := new(api.Error) + err := rest.DecodeXML(resp, &errResponse) + if err != nil { + fs.Debugf(nil, "Couldn't decode error response: %v", err) + } + errResponse.Status = resp.Status + errResponse.StatusCode = resp.StatusCode + return errResponse +} + +// addShlash makes sure s is terminated with a / if non empty +func addSlash(s string) string { + if s != "" && !strings.HasSuffix(s, "/") { + s += "/" + } + return s +} + +// filePath returns a file path (f.root, file) +func (f *Fs) filePath(file string) string { + return rest.URLEscape(path.Join(f.root, file)) +} + +// dirPath returns a directory path (f.root, dir) +func (f *Fs) dirPath(dir string) string { + return addSlash(f.filePath(dir)) +} + +// filePath returns a file path (f.root, remote) +func (o *Object) filePath() string { + return o.fs.filePath(o.remote) +} + +// NewFs constructs an Fs from the path, container:path +func NewFs(name, root string) (fs.Fs, error) { + endpoint := fs.ConfigFileGet(name, "url") + if !strings.HasSuffix(endpoint, "/") { + endpoint += "/" + } + + user := fs.ConfigFileGet(name, "user") + pass := fs.ConfigFileGet(name, "pass") + if pass != "" { + var err error + pass, err = fs.Reveal(pass) + if err != nil { + return nil, errors.Wrap(err, "couldn't decrypt password") + } + } + vendor := fs.ConfigFileGet(name, "vendor") + + // Parse the endpoint + u, err := url.Parse(endpoint) + if err != nil { + return nil, err + } + + f := &Fs{ + name: name, + root: root, + endpoint: u, + endpointURL: u.String(), + srv: rest.NewClient(fs.Config.Client()).SetRoot(u.String()).SetUserPass(user, pass), + pacer: pacer.New().SetMinSleep(minSleep).SetMaxSleep(maxSleep).SetDecayConstant(decayConstant), + user: user, + pass: pass, + precision: fs.ModTimeNotSupported, + } + f.features = (&fs.Features{ + CanHaveEmptyDirectories: true, + }).Fill(f) + f.srv.SetErrorHandler(errorHandler) + f.setQuirks(vendor) + + if root != "" { + // Check to see if the root actually an existing file + remote := path.Base(root) + f.root = path.Dir(root) + if f.root == "." { + f.root = "" + } + _, err := f.NewObject(remote) + if err != nil { + if errors.Cause(err) == fs.ErrorObjectNotFound || errors.Cause(err) == fs.ErrorNotAFile { + // File doesn't exist so return old f + f.root = root + return f, nil + } + return nil, err + } + // return an error with an fs which points to the parent + return f, fs.ErrorIsFile + } + return f, nil +} + +// setQuirks adjusts the Fs for the vendor passed in +func (f *Fs) setQuirks(vendor string) { + if vendor == "" { + vendor = "other" + } + f.vendor = vendor + switch vendor { + case "owncloud": + f.canStream = true + f.precision = time.Second + f.useOCMtime = true + case "nextcloud": + f.precision = time.Second + f.useOCMtime = true + case "other": + default: + fs.Debugf(f, "Unknown vendor %q", vendor) + } + + // Remove PutStream from optional features + if !f.canStream { + f.features.PutStream = nil + } +} + +// Return an Object from a path +// +// If it can't be found it returns the error fs.ErrorObjectNotFound. +func (f *Fs) newObjectWithInfo(remote string, info *api.Prop) (fs.Object, error) { + o := &Object{ + fs: f, + remote: remote, + } + var err error + if info != nil { + // Set info + err = o.setMetaData(info) + } else { + err = o.readMetaData() // reads info and meta, returning an error + } + if err != nil { + return nil, err + } + return o, nil +} + +// NewObject finds the Object at remote. If it can't be found +// it returns the error fs.ErrorObjectNotFound. +func (f *Fs) NewObject(remote string) (fs.Object, error) { + return f.newObjectWithInfo(remote, nil) +} + +// list the objects into the function supplied +// +// If directories is set it only sends directories +// User function to process a File item from listAll +// +// Should return true to finish processing +type listAllFn func(string, bool, *api.Prop) bool + +// Lists the directory required calling the user function on each item found +// +// If the user fn ever returns true then it early exits with found = true +func (f *Fs) listAll(dir string, directoriesOnly bool, filesOnly bool, fn listAllFn) (found bool, err error) { + opts := rest.Opts{ + Method: "PROPFIND", + Path: f.dirPath(dir), // FIXME Should not start with / + } + var result api.Multistatus + var resp *http.Response + err = f.pacer.Call(func() (bool, error) { + resp, err = f.srv.CallXML(&opts, nil, &result) + return shouldRetry(resp, err) + }) + if err != nil { + if apiErr, ok := err.(*api.Error); ok { + // does not exist + if apiErr.StatusCode == http.StatusNotFound { + return found, fs.ErrorDirNotFound + } + } + return found, errors.Wrap(err, "couldn't list files") + } + //fmt.Printf("result = %#v", &result) + baseURL, err := rest.URLJoin(f.endpoint, opts.Path) + if err != nil { + return false, errors.Wrap(err, "couldn't join URL") + } + for i := range result.Responses { + item := &result.Responses[i] + + // Collections must end in / + isDir := strings.HasSuffix(item.Href, "/") + + // Find name + u, err := rest.URLJoin(baseURL, item.Href) + if err != nil { + fs.Errorf(nil, "URL Join failed for %q and %q: %v", baseURL, item.Href, err) + continue + } + if !strings.HasPrefix(u.Path, baseURL.Path) { + fs.Debugf(nil, "Item with unknown path received: %q, %q", item.Href, u.Path) + continue + } + remote := path.Join(dir, u.Path[len(baseURL.Path):]) + if strings.HasSuffix(remote, "/") { + remote = remote[:len(remote)-1] + } + + // the listing contains info about itself which we ignore + if remote == dir { + continue + } + + // Check OK + if !item.Props.StatusOK() { + fs.Debugf(remote, "Ignoring item with bad status %q", item.Props.Status) + continue + } + + if isDir { + if filesOnly { + continue + } + } else { + if directoriesOnly { + continue + } + } + // item.Name = restoreReservedChars(item.Name) + if fn(remote, isDir, &item.Props) { + found = true + break + } + } + return +} + +// List the objects and directories in dir into entries. The +// entries can be returned in any order but should be for a +// complete directory. +// +// dir should be "" to list the root, and should not have +// trailing slashes. +// +// This should return ErrDirNotFound if the directory isn't +// found. +func (f *Fs) List(dir string) (entries fs.DirEntries, err error) { + var iErr error + _, err = f.listAll(dir, false, false, func(remote string, isDir bool, info *api.Prop) bool { + if isDir { + d := fs.NewDir(remote, time.Time(info.Modified)) + // .SetID(info.ID) + // FIXME more info from dir? can set size, items? + entries = append(entries, d) + } else { + o, err := f.newObjectWithInfo(remote, info) + if err != nil { + iErr = err + return true + } + entries = append(entries, o) + } + return false + }) + if err != nil { + return nil, err + } + if iErr != nil { + return nil, iErr + } + return entries, nil +} + +// Creates from the parameters passed in a half finished Object which +// must have setMetaData called on it +// +// Used to create new objects +func (f *Fs) createObject(remote string, modTime time.Time, size int64) (o *Object) { + // Temporary Object under construction + o = &Object{ + fs: f, + remote: remote, + size: size, + modTime: modTime, + } + return o +} + +// Put the object +// +// Copy the reader in to the new object which is returned +// +// The new object may have been created if an error is returned +func (f *Fs) Put(in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) { + o := f.createObject(src.Remote(), src.ModTime(), src.Size()) + return o, o.Update(in, src, options...) +} + +// PutStream uploads to the remote path with the modTime given of indeterminate size +func (f *Fs) PutStream(in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) { + return f.Put(in, src, options...) +} + +// mkParentDir makes the parent of the native path dirPath if +// necessary and any directories above that +func (f *Fs) mkParentDir(dirPath string) error { + parent := path.Dir(dirPath) + if parent == "." { + parent = "" + } + return f.mkdir(parent) +} + +// mkdir makes the directory and parents using native paths +func (f *Fs) mkdir(dirPath string) error { + // We assume the root is already ceated + if dirPath == "" { + return nil + } + opts := rest.Opts{ + Method: "MKCOL", + Path: dirPath, + NoResponse: true, + } + err := f.pacer.Call(func() (bool, error) { + resp, err := f.srv.Call(&opts) + return shouldRetry(resp, err) + }) + if apiErr, ok := err.(*api.Error); ok { + // already exists + if apiErr.StatusCode == http.StatusMethodNotAllowed { + return nil + } + // parent does not exists + if apiErr.StatusCode == http.StatusConflict { + err = f.mkParentDir(dirPath) + if err == nil { + err = f.mkdir(dirPath) + } + } + } + return err +} + +// Mkdir creates the directory if it doesn't exist +func (f *Fs) Mkdir(dir string) error { + dirPath := f.dirPath(dir) + return f.mkdir(dirPath) +} + +// dirNotEmpty returns true if the directory exists and is not Empty +// +// if the directory does not exist then err will be ErrorDirNotFound +func (f *Fs) dirNotEmpty(dir string) (found bool, err error) { + return f.listAll(dir, false, false, func(remote string, isDir bool, info *api.Prop) bool { + return true + }) +} + +// purgeCheck removes the root directory, if check is set then it +// refuses to do so if it has anything in +func (f *Fs) purgeCheck(dir string, check bool) error { + if check { + notEmpty, err := f.dirNotEmpty(dir) + if err != nil { + return err + } + if notEmpty { + return fs.ErrorDirectoryNotEmpty + } + } + opts := rest.Opts{ + Method: "DELETE", + Path: f.dirPath(dir), + NoResponse: true, + } + var resp *http.Response + var err error + err = f.pacer.Call(func() (bool, error) { + resp, err = f.srv.CallXML(&opts, nil, nil) + return shouldRetry(resp, err) + }) + if err != nil { + return errors.Wrap(err, "rmdir failed") + } + // FIXME parse Multistatus response + return nil +} + +// Rmdir deletes the root folder +// +// Returns an error if it isn't empty +func (f *Fs) Rmdir(dir string) error { + return f.purgeCheck(dir, true) +} + +// Precision return the precision of this Fs +func (f *Fs) Precision() time.Duration { + return f.precision +} + +// Copy or Move src to this remote using server side copy operations. +// +// This is stored with the remote path given +// +// It returns the destination Object and a possible error +// +// Will only be called if src.Fs().Name() == f.Name() +// +// If it isn't possible then return fs.ErrorCantCopy/fs.ErrorCantMove +func (f *Fs) copyOrMove(src fs.Object, remote string, method string) (fs.Object, error) { + srcObj, ok := src.(*Object) + if !ok { + fs.Debugf(src, "Can't copy - not same remote type") + if method == "COPY" { + return nil, fs.ErrorCantCopy + } + return nil, fs.ErrorCantMove + } + dstPath := f.filePath(remote) + err := f.mkParentDir(dstPath) + if err != nil { + return nil, errors.Wrap(err, "Copy mkParentDir failed") + } + var resp *http.Response + opts := rest.Opts{ + Method: method, + Path: srcObj.filePath(), + NoResponse: true, + ExtraHeaders: map[string]string{ + "Destination": path.Join(f.endpoint.Path, dstPath), + "Overwrite": "F", + }, + } + if f.useOCMtime { + opts.ExtraHeaders["X-OC-Mtime"] = fmt.Sprintf("%f", float64(src.ModTime().UnixNano())/1E9) + } + err = f.pacer.Call(func() (bool, error) { + resp, err = f.srv.Call(&opts) + return shouldRetry(resp, err) + }) + if err != nil { + return nil, errors.Wrap(err, "Copy call failed") + } + dstObj, err := f.NewObject(remote) + if err != nil { + return nil, errors.Wrap(err, "Copy NewObject failed") + } + return dstObj, nil +} + +// Copy src to this remote using server side copy operations. +// +// This is stored with the remote path given +// +// It returns the destination Object and a possible error +// +// Will only be called if src.Fs().Name() == f.Name() +// +// If it isn't possible then return fs.ErrorCantCopy +func (f *Fs) Copy(src fs.Object, remote string) (fs.Object, error) { + return f.copyOrMove(src, remote, "COPY") +} + +// Purge deletes all the files and the container +// +// Optional interface: Only implement this if you have a way of +// deleting all the files quicker than just running Remove() on the +// result of List() +func (f *Fs) Purge() error { + return f.purgeCheck("", false) +} + +// Move src to this remote using server side move operations. +// +// This is stored with the remote path given +// +// It returns the destination Object and a possible error +// +// Will only be called if src.Fs().Name() == f.Name() +// +// If it isn't possible then return fs.ErrorCantMove +func (f *Fs) Move(src fs.Object, remote string) (fs.Object, error) { + return f.copyOrMove(src, remote, "MOVE") +} + +// DirMove moves src, srcRemote to this remote at dstRemote +// using server side move operations. +// +// Will only be called if src.Fs().Name() == f.Name() +// +// If it isn't possible then return fs.ErrorCantDirMove +// +// If destination exists then return fs.ErrorDirExists +func (f *Fs) DirMove(src fs.Fs, srcRemote, dstRemote string) error { + srcFs, ok := src.(*Fs) + if !ok { + fs.Debugf(srcFs, "Can't move directory - not same remote type") + return fs.ErrorCantDirMove + } + srcPath := srcFs.filePath(srcRemote) + dstPath := f.filePath(dstRemote) + + // Check if destination exists + _, err := f.dirNotEmpty(dstRemote) + if err == nil { + return fs.ErrorDirExists + } + if err != fs.ErrorDirNotFound { + return errors.Wrap(err, "DirMove dirExists dst failed") + } + + // Make sure the parent directory exists + err = f.mkParentDir(dstPath) + if err != nil { + return errors.Wrap(err, "DirMove mkParentDir dst failed") + } + + var resp *http.Response + opts := rest.Opts{ + Method: "MOVE", + Path: addSlash(srcPath), + NoResponse: true, + ExtraHeaders: map[string]string{ + "Destination": addSlash(path.Join(f.endpoint.Path, dstPath)), + "Overwrite": "F", + }, + } + err = f.pacer.Call(func() (bool, error) { + resp, err = f.srv.Call(&opts) + return shouldRetry(resp, err) + }) + if err != nil { + return errors.Wrap(err, "DirMove MOVE call failed") + } + return nil +} + +// Hashes returns the supported hash sets. +func (f *Fs) Hashes() fs.HashSet { + return fs.HashSet(fs.HashNone) +} + +// ------------------------------------------------------------ + +// Fs returns the parent Fs +func (o *Object) Fs() fs.Info { + return o.fs +} + +// Return a string version +func (o *Object) String() string { + if o == nil { + return "" + } + return o.remote +} + +// Remote returns the remote path +func (o *Object) Remote() string { + return o.remote +} + +// Hash returns the SHA-1 of an object returning a lowercase hex string +func (o *Object) Hash(t fs.HashType) (string, error) { + if t != fs.HashSHA1 { + return "", fs.ErrHashUnsupported + } + return o.sha1, nil +} + +// Size returns the size of an object in bytes +func (o *Object) Size() int64 { + err := o.readMetaData() + if err != nil { + fs.Logf(o, "Failed to read metadata: %v", err) + return 0 + } + return o.size +} + +// setMetaData sets the metadata from info +func (o *Object) setMetaData(info *api.Prop) (err error) { + o.hasMetaData = true + o.size = info.Size + o.modTime = time.Time(info.Modified) + return nil +} + +// readMetaData gets the metadata if it hasn't already been fetched +// +// it also sets the info +func (o *Object) readMetaData() (err error) { + if o.hasMetaData { + return nil + } + info, err := o.fs.readMetaDataForPath(o.remote) + if err != nil { + return err + } + return o.setMetaData(info) +} + +// ModTime returns the modification time of the object +// +// It attempts to read the objects mtime and if that isn't present the +// LastModified returned in the http headers +func (o *Object) ModTime() time.Time { + err := o.readMetaData() + if err != nil { + fs.Logf(o, "Failed to read metadata: %v", err) + return time.Now() + } + return o.modTime +} + +// SetModTime sets the modification time of the local fs object +func (o *Object) SetModTime(modTime time.Time) error { + return fs.ErrorCantSetModTime +} + +// Storable returns a boolean showing whether this object storable +func (o *Object) Storable() bool { + return true +} + +// Open an object for read +func (o *Object) Open(options ...fs.OpenOption) (in io.ReadCloser, err error) { + var resp *http.Response + opts := rest.Opts{ + Method: "GET", + Path: o.filePath(), + Options: options, + } + err = o.fs.pacer.Call(func() (bool, error) { + resp, err = o.fs.srv.Call(&opts) + return shouldRetry(resp, err) + }) + if err != nil { + return nil, err + } + return resp.Body, err +} + +// Update the object with the contents of the io.Reader, modTime and size +// +// If existing is set then it updates the object rather than creating a new one +// +// The new object may have been created if an error is returned +func (o *Object) Update(in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (err error) { + err = o.fs.mkParentDir(o.filePath()) + if err != nil { + return errors.Wrap(err, "Update mkParentDir failed") + } + + size := src.Size() + var resp *http.Response + opts := rest.Opts{ + Method: "PUT", + Path: o.filePath(), + Body: in, + NoResponse: true, + ContentLength: &size, // FIXME this isn't necessary with owncloud - See https://github.com/nextcloud/nextcloud-snap/issues/365 + } + if o.fs.useOCMtime { + opts.ExtraHeaders = map[string]string{ + "X-OC-Mtime": fmt.Sprintf("%f", float64(src.ModTime().UnixNano())/1E9), + } + } + err = o.fs.pacer.CallNoRetry(func() (bool, error) { + resp, err = o.fs.srv.Call(&opts) + return shouldRetry(resp, err) + }) + if err != nil { + return err + } + // read metadata from remote + o.hasMetaData = false + return o.readMetaData() +} + +// Remove an object +func (o *Object) Remove() error { + opts := rest.Opts{ + Method: "DELETE", + Path: o.filePath(), + NoResponse: true, + } + return o.fs.pacer.Call(func() (bool, error) { + resp, err := o.fs.srv.Call(&opts) + return shouldRetry(resp, err) + }) +} + +// Check the interfaces are satisfied +var ( + _ fs.Fs = (*Fs)(nil) + _ fs.Purger = (*Fs)(nil) + _ fs.PutStreamer = (*Fs)(nil) + _ fs.Copier = (*Fs)(nil) + _ fs.Mover = (*Fs)(nil) + _ fs.DirMover = (*Fs)(nil) + _ fs.Object = (*Object)(nil) +) diff --git a/webdav/webdav_test.go b/webdav/webdav_test.go new file mode 100644 index 000000000..0e455af72 --- /dev/null +++ b/webdav/webdav_test.go @@ -0,0 +1,73 @@ +// Test Webdav filesystem interface +// +// Automatically generated - DO NOT EDIT +// Regenerate with: make gen_tests +package webdav_test + +import ( + "testing" + + "github.com/ncw/rclone/fs" + "github.com/ncw/rclone/fstest/fstests" + "github.com/ncw/rclone/webdav" +) + +func TestSetup(t *testing.T) { + fstests.NilObject = fs.Object((*webdav.Object)(nil)) + fstests.RemoteName = "TestWebdav:" +} + +// Generic tests for the Fs +func TestInit(t *testing.T) { fstests.TestInit(t) } +func TestFsString(t *testing.T) { fstests.TestFsString(t) } +func TestFsName(t *testing.T) { fstests.TestFsName(t) } +func TestFsRoot(t *testing.T) { fstests.TestFsRoot(t) } +func TestFsRmdirEmpty(t *testing.T) { fstests.TestFsRmdirEmpty(t) } +func TestFsRmdirNotFound(t *testing.T) { fstests.TestFsRmdirNotFound(t) } +func TestFsMkdir(t *testing.T) { fstests.TestFsMkdir(t) } +func TestFsMkdirRmdirSubdir(t *testing.T) { fstests.TestFsMkdirRmdirSubdir(t) } +func TestFsListEmpty(t *testing.T) { fstests.TestFsListEmpty(t) } +func TestFsListDirEmpty(t *testing.T) { fstests.TestFsListDirEmpty(t) } +func TestFsListRDirEmpty(t *testing.T) { fstests.TestFsListRDirEmpty(t) } +func TestFsNewObjectNotFound(t *testing.T) { fstests.TestFsNewObjectNotFound(t) } +func TestFsPutFile1(t *testing.T) { fstests.TestFsPutFile1(t) } +func TestFsPutError(t *testing.T) { fstests.TestFsPutError(t) } +func TestFsPutFile2(t *testing.T) { fstests.TestFsPutFile2(t) } +func TestFsUpdateFile1(t *testing.T) { fstests.TestFsUpdateFile1(t) } +func TestFsListDirFile2(t *testing.T) { fstests.TestFsListDirFile2(t) } +func TestFsListRDirFile2(t *testing.T) { fstests.TestFsListRDirFile2(t) } +func TestFsListDirRoot(t *testing.T) { fstests.TestFsListDirRoot(t) } +func TestFsListRDirRoot(t *testing.T) { fstests.TestFsListRDirRoot(t) } +func TestFsListSubdir(t *testing.T) { fstests.TestFsListSubdir(t) } +func TestFsListRSubdir(t *testing.T) { fstests.TestFsListRSubdir(t) } +func TestFsListLevel2(t *testing.T) { fstests.TestFsListLevel2(t) } +func TestFsListRLevel2(t *testing.T) { fstests.TestFsListRLevel2(t) } +func TestFsListFile1(t *testing.T) { fstests.TestFsListFile1(t) } +func TestFsNewObject(t *testing.T) { fstests.TestFsNewObject(t) } +func TestFsListFile1and2(t *testing.T) { fstests.TestFsListFile1and2(t) } +func TestFsNewObjectDir(t *testing.T) { fstests.TestFsNewObjectDir(t) } +func TestFsCopy(t *testing.T) { fstests.TestFsCopy(t) } +func TestFsMove(t *testing.T) { fstests.TestFsMove(t) } +func TestFsDirMove(t *testing.T) { fstests.TestFsDirMove(t) } +func TestFsRmdirFull(t *testing.T) { fstests.TestFsRmdirFull(t) } +func TestFsPrecision(t *testing.T) { fstests.TestFsPrecision(t) } +func TestFsDirChangeNotify(t *testing.T) { fstests.TestFsDirChangeNotify(t) } +func TestObjectString(t *testing.T) { fstests.TestObjectString(t) } +func TestObjectFs(t *testing.T) { fstests.TestObjectFs(t) } +func TestObjectRemote(t *testing.T) { fstests.TestObjectRemote(t) } +func TestObjectHashes(t *testing.T) { fstests.TestObjectHashes(t) } +func TestObjectModTime(t *testing.T) { fstests.TestObjectModTime(t) } +func TestObjectMimeType(t *testing.T) { fstests.TestObjectMimeType(t) } +func TestObjectSetModTime(t *testing.T) { fstests.TestObjectSetModTime(t) } +func TestObjectSize(t *testing.T) { fstests.TestObjectSize(t) } +func TestObjectOpen(t *testing.T) { fstests.TestObjectOpen(t) } +func TestObjectOpenSeek(t *testing.T) { fstests.TestObjectOpenSeek(t) } +func TestObjectPartialRead(t *testing.T) { fstests.TestObjectPartialRead(t) } +func TestObjectUpdate(t *testing.T) { fstests.TestObjectUpdate(t) } +func TestObjectStorable(t *testing.T) { fstests.TestObjectStorable(t) } +func TestFsIsFile(t *testing.T) { fstests.TestFsIsFile(t) } +func TestFsIsFileNotFound(t *testing.T) { fstests.TestFsIsFileNotFound(t) } +func TestObjectRemove(t *testing.T) { fstests.TestObjectRemove(t) } +func TestFsPutStream(t *testing.T) { fstests.TestFsPutStream(t) } +func TestObjectPurge(t *testing.T) { fstests.TestObjectPurge(t) } +func TestFinalise(t *testing.T) { fstests.TestFinalise(t) }