forked from TrueCloudLab/rclone
Implement WebDAV remote #580
This has special knowledge of Owncloud and Nextcloud to enable more functionality such as mod times.
This commit is contained in:
parent
bcdd79320b
commit
7f3dc9b5c4
14 changed files with 1254 additions and 1 deletions
|
@ -31,6 +31,7 @@ Rclone is a command line program to sync files and directories to and from
|
||||||
* pCloud
|
* pCloud
|
||||||
* QingStor
|
* QingStor
|
||||||
* SFTP
|
* SFTP
|
||||||
|
* Webdav / Owncloud / Nextcloud
|
||||||
* Yandex Disk
|
* Yandex Disk
|
||||||
* The local filesystem
|
* The local filesystem
|
||||||
|
|
||||||
|
|
|
@ -38,6 +38,7 @@ docs = [
|
||||||
"swift.md",
|
"swift.md",
|
||||||
"pcloud.md",
|
"pcloud.md",
|
||||||
"sftp.md",
|
"sftp.md",
|
||||||
|
"webdav.md",
|
||||||
"yandex.md",
|
"yandex.md",
|
||||||
|
|
||||||
"local.md",
|
"local.md",
|
||||||
|
|
|
@ -57,6 +57,7 @@ from various cloud storage systems and using file transfer services, such as:
|
||||||
* pCloud
|
* pCloud
|
||||||
* QingStor
|
* QingStor
|
||||||
* SFTP
|
* SFTP
|
||||||
|
* Webdav / Owncloud / Nextcloud
|
||||||
* Yandex Disk
|
* Yandex Disk
|
||||||
* The local filesystem
|
* The local filesystem
|
||||||
|
|
||||||
|
|
|
@ -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 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="Microsoft OneDrive" home="https://onedrive.live.com/" config="/onedrive/" >}}
|
||||||
* {{< provider name="Minio" home="https://www.minio.io/" config="/s3/#minio" >}}
|
* {{< 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="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="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="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="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="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="SFTP" home="https://en.wikipedia.org/wiki/SFTP" config="/sftp/" >}}
|
||||||
* {{< provider name="Wasabi" home="https://wasabi.com/" config="/s3/#wasabi" >}}
|
* {{< 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="Yandex Disk" home="https://disk.yandex.com/" config="/yandex/" >}}
|
||||||
* {{< provider name="The local filesystem" home="/local/" config="/local/" >}}
|
* {{< provider name="The local filesystem" home="/local/" config="/local/" >}}
|
||||||
|
|
||||||
|
|
|
@ -36,6 +36,7 @@ See the following for detailed instructions for
|
||||||
* [Pcloud](/pcloud/)
|
* [Pcloud](/pcloud/)
|
||||||
* [QingStor](/qingstor/)
|
* [QingStor](/qingstor/)
|
||||||
* [SFTP](/sftp/)
|
* [SFTP](/sftp/)
|
||||||
|
* [WebDAV](/webdav/)
|
||||||
* [Yandex Disk](/yandex/)
|
* [Yandex Disk](/yandex/)
|
||||||
* [The local filesystem](/local/)
|
* [The local filesystem](/local/)
|
||||||
|
|
||||||
|
|
|
@ -33,6 +33,7 @@ Here is an overview of the major features of each cloud storage system.
|
||||||
| pCloud | MD5, SHA1 | Yes | No | No | W |
|
| pCloud | MD5, SHA1 | Yes | No | No | W |
|
||||||
| QingStor | MD5 | No | No | No | R/W |
|
| QingStor | MD5 | No | No | No | R/W |
|
||||||
| SFTP | MD5, SHA1 ‡ | Yes | Depends | No | - |
|
| SFTP | MD5, SHA1 ‡ | Yes | Depends | No | - |
|
||||||
|
| WebDAV | - | Yes †† | Depends | No | - |
|
||||||
| Yandex Disk | MD5 | Yes | No | No | R/W |
|
| Yandex Disk | MD5 | Yes | No | No | R/W |
|
||||||
| The local filesystem | All | Yes | Depends | No | - |
|
| 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`
|
‡ SFTP supports checksums if the same login has shell access and `md5sum`
|
||||||
or `sha1sum` as well as `echo` are in the remote's PATH.
|
or `sha1sum` as well as `echo` are in the remote's PATH.
|
||||||
|
|
||||||
|
†† WebDAV supports modtimes when used with Owncloud and Nextcloud only.
|
||||||
|
|
||||||
### ModTime ###
|
### ModTime ###
|
||||||
|
|
||||||
The cloud storage system supports setting modification times on
|
The cloud storage system supports setting modification times on
|
||||||
|
@ -134,6 +137,7 @@ operations more efficient.
|
||||||
| pCloud | Yes | Yes | Yes | Yes | Yes | No | No |
|
| pCloud | Yes | Yes | Yes | Yes | Yes | No | No |
|
||||||
| QingStor | No | Yes | No | No | No | Yes | No |
|
| QingStor | No | Yes | No | No | No | Yes | No |
|
||||||
| SFTP | No | No | Yes | Yes | No | No | Yes |
|
| 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 |
|
| Yandex Disk | Yes | No | No | No | Yes | Yes | Yes |
|
||||||
| The local filesystem | Yes | No | Yes | Yes | No | No | 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
|
markers but they don't actually have a quicker way of deleting files
|
||||||
other than deleting them individually.
|
other than deleting them individually.
|
||||||
|
|
||||||
|
‡ StreamUpload is not supported with Nextcloud
|
||||||
|
|
||||||
### Copy ###
|
### Copy ###
|
||||||
|
|
||||||
Used when copying an object to and from the same remote. This known
|
Used when copying an object to and from the same remote. This known
|
||||||
|
|
148
docs/content/webdav.md
Normal file
148
docs/content/webdav.md
Normal file
|
@ -0,0 +1,148 @@
|
||||||
|
---
|
||||||
|
title: "WebDAV"
|
||||||
|
description: "Rclone docs for WebDAV"
|
||||||
|
date: "2017-10-01"
|
||||||
|
---
|
||||||
|
|
||||||
|
<i class="fa fa-globe"></i> 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.
|
|
@ -66,6 +66,7 @@
|
||||||
<li><a href="/swift/"><i class="fa fa-space-shuttle"></i> Openstack Swift</a></li>
|
<li><a href="/swift/"><i class="fa fa-space-shuttle"></i> Openstack Swift</a></li>
|
||||||
<li><a href="/pcloud/"><i class="fa fa-cloud"></i> pCloud</a></li>
|
<li><a href="/pcloud/"><i class="fa fa-cloud"></i> pCloud</a></li>
|
||||||
<li><a href="/sftp/"><i class="fa fa-server"></i> SFTP</a></li>
|
<li><a href="/sftp/"><i class="fa fa-server"></i> SFTP</a></li>
|
||||||
|
<li><a href="/webdav/"><i class="fa fa-server"></i> WebDAV</a></li>
|
||||||
<li><a href="/yandex/"><i class="fa fa-space-shuttle"></i> Yandex Disk</a></li>
|
<li><a href="/yandex/"><i class="fa fa-space-shuttle"></i> Yandex Disk</a></li>
|
||||||
<li><a href="/local/"><i class="fa fa-file"></i> The local filesystem</a></li>
|
<li><a href="/local/"><i class="fa fa-file"></i> The local filesystem</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
@ -20,5 +20,6 @@ import (
|
||||||
_ "github.com/ncw/rclone/s3"
|
_ "github.com/ncw/rclone/s3"
|
||||||
_ "github.com/ncw/rclone/sftp"
|
_ "github.com/ncw/rclone/sftp"
|
||||||
_ "github.com/ncw/rclone/swift"
|
_ "github.com/ncw/rclone/swift"
|
||||||
|
_ "github.com/ncw/rclone/webdav"
|
||||||
_ "github.com/ncw/rclone/yandex"
|
_ "github.com/ncw/rclone/yandex"
|
||||||
)
|
)
|
||||||
|
|
|
@ -118,6 +118,11 @@ var (
|
||||||
SubDir: false,
|
SubDir: false,
|
||||||
FastList: false,
|
FastList: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "TestWebdav:",
|
||||||
|
SubDir: false,
|
||||||
|
FastList: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
binary = "fs.test"
|
binary = "fs.test"
|
||||||
// Flags
|
// Flags
|
||||||
|
|
|
@ -165,5 +165,6 @@ func main() {
|
||||||
generateTestProgram(t, fns, "QingStor", buildConstraint("!plan9"))
|
generateTestProgram(t, fns, "QingStor", buildConstraint("!plan9"))
|
||||||
generateTestProgram(t, fns, "AzureBlob", buildConstraint("go1.7"))
|
generateTestProgram(t, fns, "AzureBlob", buildConstraint("go1.7"))
|
||||||
generateTestProgram(t, fns, "Pcloud")
|
generateTestProgram(t, fns, "Pcloud")
|
||||||
|
generateTestProgram(t, fns, "Webdav")
|
||||||
log.Printf("Done")
|
log.Printf("Done")
|
||||||
}
|
}
|
||||||
|
|
111
webdav/api/types.go
Normal file
111
webdav/api/types.go
Normal file
|
@ -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
|
||||||
|
//
|
||||||
|
// <d:error xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns">
|
||||||
|
// <s:exception>Sabre\DAV\Exception\NotFound</s:exception>
|
||||||
|
// <s:message>File with name Photo could not be located</s:message>
|
||||||
|
// </d:error>
|
||||||
|
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
|
||||||
|
}
|
900
webdav/webdav.go
Normal file
900
webdav/webdav.go
Normal file
|
@ -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 "<nil>"
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
)
|
73
webdav/webdav_test.go
Normal file
73
webdav/webdav_test.go
Normal file
|
@ -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) }
|
Loading…
Reference in a new issue