2015-09-23 01:47:16 +08:00
|
|
|
// Package dropbox provides an interface to Dropbox object storage
|
2014-07-09 04:59:30 +08:00
|
|
|
package dropbox
|
|
|
|
|
2017-05-22 04:35:33 +08:00
|
|
|
// FIXME dropbox for business would be quite easy to add
|
|
|
|
|
2014-07-09 04:59:30 +08:00
|
|
|
/*
|
2017-06-12 05:43:31 +08:00
|
|
|
The Case folding of PathDisplay problem
|
2017-05-22 04:35:33 +08:00
|
|
|
|
2017-06-12 05:43:31 +08:00
|
|
|
From the docs:
|
2014-07-09 04:59:30 +08:00
|
|
|
|
2017-05-22 04:35:33 +08:00
|
|
|
path_display String. The cased path to be used for display purposes
|
|
|
|
only. In rare instances the casing will not correctly match the user's
|
|
|
|
filesystem, but this behavior will match the path provided in the Core
|
|
|
|
API v1, and at least the last path component will have the correct
|
|
|
|
casing. Changes to only the casing of paths won't be returned by
|
|
|
|
list_folder/continue. This field will be null if the file or folder is
|
|
|
|
not mounted. This field is optional.
|
2017-06-05 23:14:24 +08:00
|
|
|
|
2017-08-10 19:06:50 +08:00
|
|
|
We solve this by not implementing the ListR interface. The dropbox
|
|
|
|
remote will recurse directory by directory only using the last element
|
|
|
|
of path_display and all will be well.
|
2014-07-09 04:59:30 +08:00
|
|
|
*/
|
|
|
|
|
|
|
|
import (
|
|
|
|
"fmt"
|
|
|
|
"io"
|
|
|
|
"log"
|
2014-07-14 18:24:04 +08:00
|
|
|
"path"
|
2015-08-21 01:36:06 +08:00
|
|
|
"regexp"
|
2014-07-09 04:59:30 +08:00
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
2017-08-08 17:13:15 +08:00
|
|
|
"github.com/dropbox/dropbox-sdk-go-unofficial/dropbox"
|
2018-12-05 23:11:41 +08:00
|
|
|
"github.com/dropbox/dropbox-sdk-go-unofficial/dropbox/auth"
|
2018-05-28 17:50:31 +08:00
|
|
|
"github.com/dropbox/dropbox-sdk-go-unofficial/dropbox/common"
|
2017-08-08 17:13:15 +08:00
|
|
|
"github.com/dropbox/dropbox-sdk-go-unofficial/dropbox/files"
|
2018-03-29 15:10:19 +08:00
|
|
|
"github.com/dropbox/dropbox-sdk-go-unofficial/dropbox/sharing"
|
2018-09-18 08:35:12 +08:00
|
|
|
"github.com/dropbox/dropbox-sdk-go-unofficial/dropbox/team"
|
2018-04-17 05:19:25 +08:00
|
|
|
"github.com/dropbox/dropbox-sdk-go-unofficial/dropbox/users"
|
2014-07-09 04:59:30 +08:00
|
|
|
"github.com/ncw/rclone/fs"
|
2018-01-13 00:30:54 +08:00
|
|
|
"github.com/ncw/rclone/fs/config"
|
2018-05-15 01:06:57 +08:00
|
|
|
"github.com/ncw/rclone/fs/config/configmap"
|
|
|
|
"github.com/ncw/rclone/fs/config/configstruct"
|
2018-01-19 04:19:55 +08:00
|
|
|
"github.com/ncw/rclone/fs/config/obscure"
|
2018-01-13 00:30:54 +08:00
|
|
|
"github.com/ncw/rclone/fs/fserrors"
|
|
|
|
"github.com/ncw/rclone/fs/hash"
|
2018-01-12 00:29:20 +08:00
|
|
|
"github.com/ncw/rclone/lib/oauthutil"
|
|
|
|
"github.com/ncw/rclone/lib/pacer"
|
2018-01-13 00:30:54 +08:00
|
|
|
"github.com/ncw/rclone/lib/readers"
|
2016-06-12 22:06:02 +08:00
|
|
|
"github.com/pkg/errors"
|
2017-05-29 00:55:18 +08:00
|
|
|
"golang.org/x/oauth2"
|
2014-07-09 04:59:30 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
// Constants
|
|
|
|
const (
|
2017-05-22 04:35:33 +08:00
|
|
|
rcloneClientID = "5jcck7diasz0rqy"
|
|
|
|
rcloneEncryptedClientSecret = "fRS5vVLr2v6FbyXYnIgjwBuUAt0osq_QZTXAEcmZ7g"
|
2017-05-29 00:55:18 +08:00
|
|
|
minSleep = 10 * time.Millisecond
|
|
|
|
maxSleep = 2 * time.Second
|
|
|
|
decayConstant = 2 // bigger for slower decay, exponential
|
2015-08-26 02:01:37 +08:00
|
|
|
// Upload chunk size - setting too small makes uploads slow.
|
2017-11-22 01:30:50 +08:00
|
|
|
// Chunks are buffered into memory for retries.
|
|
|
|
//
|
|
|
|
// Speed vs chunk size uploading a 1 GB file on 2017-11-22
|
|
|
|
//
|
|
|
|
// Chunk Size MB, Speed Mbyte/s, % of max
|
|
|
|
// 1 1.364 11%
|
|
|
|
// 2 2.443 19%
|
|
|
|
// 4 4.288 33%
|
|
|
|
// 8 6.79 52%
|
|
|
|
// 16 8.916 69%
|
|
|
|
// 24 10.195 79%
|
|
|
|
// 32 10.427 81%
|
|
|
|
// 40 10.96 85%
|
|
|
|
// 48 11.828 91%
|
|
|
|
// 56 11.763 91%
|
|
|
|
// 64 12.047 93%
|
|
|
|
// 96 12.302 95%
|
|
|
|
// 128 12.945 100%
|
|
|
|
//
|
|
|
|
// Choose 48MB which is 91% of Maximum speed. rclone by
|
|
|
|
// default does 4 transfers so this should use 4*48MB = 192MB
|
|
|
|
// by default.
|
2018-09-07 19:02:27 +08:00
|
|
|
defaultChunkSize = 48 * fs.MebiByte
|
|
|
|
maxChunkSize = 150 * fs.MebiByte
|
2018-05-15 01:06:57 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
var (
|
|
|
|
// Description of how to auth for this app
|
|
|
|
dropboxConfig = &oauth2.Config{
|
|
|
|
Scopes: []string{},
|
|
|
|
// Endpoint: oauth2.Endpoint{
|
|
|
|
// AuthURL: "https://www.dropbox.com/1/oauth2/authorize",
|
|
|
|
// TokenURL: "https://api.dropboxapi.com/1/oauth2/token",
|
|
|
|
// },
|
|
|
|
Endpoint: dropbox.OAuthEndpoint(""),
|
|
|
|
ClientID: rcloneClientID,
|
|
|
|
ClientSecret: obscure.MustReveal(rcloneEncryptedClientSecret),
|
|
|
|
RedirectURL: oauthutil.RedirectLocalhostURL,
|
|
|
|
}
|
|
|
|
// A regexp matching path names for files Dropbox ignores
|
|
|
|
// See https://www.dropbox.com/en/help/145 - Ignored files
|
|
|
|
ignoredFiles = regexp.MustCompile(`(?i)(^|/)(desktop\.ini|thumbs\.db|\.ds_store|icon\r|\.dropbox|\.dropbox.attr)$`)
|
2015-08-26 02:01:37 +08:00
|
|
|
)
|
2015-08-21 01:36:06 +08:00
|
|
|
|
2014-07-09 04:59:30 +08:00
|
|
|
// Register with Fs
|
|
|
|
func init() {
|
2016-02-18 19:35:25 +08:00
|
|
|
fs.Register(&fs.RegInfo{
|
2016-02-16 02:11:53 +08:00
|
|
|
Name: "dropbox",
|
|
|
|
Description: "Dropbox",
|
|
|
|
NewFs: NewFs,
|
2018-05-15 01:06:57 +08:00
|
|
|
Config: func(name string, m configmap.Mapper) {
|
|
|
|
err := oauthutil.ConfigNoOffline("dropbox", name, m, dropboxConfig)
|
2017-05-22 04:35:33 +08:00
|
|
|
if err != nil {
|
|
|
|
log.Fatalf("Failed to configure token: %v", err)
|
|
|
|
}
|
|
|
|
},
|
2014-07-09 04:59:30 +08:00
|
|
|
Options: []fs.Option{{
|
2018-01-30 04:04:41 +08:00
|
|
|
Name: config.ConfigClientID,
|
2018-05-15 01:06:57 +08:00
|
|
|
Help: "Dropbox App Client Id\nLeave blank normally.",
|
2014-07-09 04:59:30 +08:00
|
|
|
}, {
|
2018-01-30 04:04:41 +08:00
|
|
|
Name: config.ConfigClientSecret,
|
2018-05-15 01:06:57 +08:00
|
|
|
Help: "Dropbox App Client Secret\nLeave blank normally.",
|
|
|
|
}, {
|
2018-10-02 01:36:15 +08:00
|
|
|
Name: "chunk_size",
|
|
|
|
Help: fmt.Sprintf(`Upload chunk size. (< %v).
|
|
|
|
|
|
|
|
Any files larger than this will be uploaded in chunks of this size.
|
|
|
|
|
|
|
|
Note that chunks are buffered in memory (one at a time) so rclone can
|
|
|
|
deal with retries. Setting this larger will increase the speed
|
|
|
|
slightly (at most 10%% for 128MB in tests) at the cost of using more
|
|
|
|
memory. It can be set smaller if you are tight on memory.`, fs.SizeSuffix(maxChunkSize)),
|
2018-05-15 01:06:57 +08:00
|
|
|
Default: fs.SizeSuffix(defaultChunkSize),
|
|
|
|
Advanced: true,
|
2018-09-18 08:35:12 +08:00
|
|
|
}, {
|
|
|
|
Name: "impersonate",
|
|
|
|
Help: "Impersonate this user when using a business account.",
|
|
|
|
Default: "",
|
|
|
|
Advanced: true,
|
2014-07-09 04:59:30 +08:00
|
|
|
}},
|
|
|
|
})
|
2018-05-15 01:06:57 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
// Options defines the configuration for this backend
|
|
|
|
type Options struct {
|
2018-09-18 08:35:12 +08:00
|
|
|
ChunkSize fs.SizeSuffix `config:"chunk_size"`
|
|
|
|
Impersonate string `config:"impersonate"`
|
2014-07-09 04:59:30 +08:00
|
|
|
}
|
|
|
|
|
2015-11-07 19:14:46 +08:00
|
|
|
// Fs represents a remote dropbox server
|
|
|
|
type Fs struct {
|
2018-03-29 15:10:19 +08:00
|
|
|
name string // name of this remote
|
|
|
|
root string // the path we are working on
|
2018-05-15 01:06:57 +08:00
|
|
|
opt Options // parsed options
|
2018-03-29 15:10:19 +08:00
|
|
|
features *fs.Features // optional features
|
|
|
|
srv files.Client // the connection to the dropbox server
|
2018-05-28 17:50:31 +08:00
|
|
|
sharing sharing.Client // as above, but for generating sharing links
|
2018-04-17 05:19:25 +08:00
|
|
|
users users.Client // as above, but for accessing user information
|
2018-09-18 08:35:12 +08:00
|
|
|
team team.Client // for the Teams API
|
2018-03-29 15:10:19 +08:00
|
|
|
slashRoot string // root with "/" prefix, lowercase
|
|
|
|
slashRootSlash string // root with "/" prefix and postfix, lowercase
|
|
|
|
pacer *pacer.Pacer // To pace the API calls
|
2018-05-28 17:50:31 +08:00
|
|
|
ns string // The namespace we are using or "" for none
|
2014-07-09 04:59:30 +08:00
|
|
|
}
|
|
|
|
|
2015-11-07 19:14:46 +08:00
|
|
|
// Object describes a dropbox object
|
2017-05-26 22:09:31 +08:00
|
|
|
//
|
|
|
|
// Dropbox Objects always have full metadata
|
2015-11-07 19:14:46 +08:00
|
|
|
type Object struct {
|
2017-05-26 22:09:31 +08:00
|
|
|
fs *Fs // what this object is part of
|
|
|
|
remote string // The remote path
|
|
|
|
bytes int64 // size of the object
|
|
|
|
modTime time.Time // time it was last modified
|
|
|
|
hash string // content_hash of the object
|
2014-07-09 04:59:30 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
// ------------------------------------------------------------
|
|
|
|
|
2015-09-23 01:47:16 +08:00
|
|
|
// Name of the remote (as passed into NewFs)
|
2015-11-07 19:14:46 +08:00
|
|
|
func (f *Fs) Name() string {
|
2015-08-22 23:53:11 +08:00
|
|
|
return f.name
|
|
|
|
}
|
|
|
|
|
2015-09-23 01:47:16 +08:00
|
|
|
// Root of the remote (as passed into NewFs)
|
2015-11-07 19:14:46 +08:00
|
|
|
func (f *Fs) Root() string {
|
2015-09-02 03:45:27 +08:00
|
|
|
return f.root
|
|
|
|
}
|
|
|
|
|
2015-11-07 19:14:46 +08:00
|
|
|
// String converts this Fs to a string
|
|
|
|
func (f *Fs) String() string {
|
2014-07-09 04:59:30 +08:00
|
|
|
return fmt.Sprintf("Dropbox root '%s'", f.root)
|
|
|
|
}
|
|
|
|
|
2017-01-14 01:21:47 +08:00
|
|
|
// Features returns the optional features of this Fs
|
|
|
|
func (f *Fs) Features() *fs.Features {
|
|
|
|
return f.features
|
|
|
|
}
|
|
|
|
|
2017-05-29 00:55:18 +08:00
|
|
|
// shouldRetry returns a boolean as to whether this err deserves to be
|
|
|
|
// retried. It returns the err as a convenience
|
|
|
|
func shouldRetry(err error) (bool, error) {
|
|
|
|
if err == nil {
|
|
|
|
return false, err
|
|
|
|
}
|
|
|
|
baseErrString := errors.Cause(err).Error()
|
2018-12-05 23:11:41 +08:00
|
|
|
// handle any official Retry-After header from Dropbox's SDK first
|
|
|
|
switch e := err.(type) {
|
|
|
|
case auth.RateLimitAPIError:
|
|
|
|
if e.RateLimitError.RetryAfter > 0 {
|
|
|
|
fs.Debugf(baseErrString, "Too many requests or write operations. Trying again in %d seconds.", e.RateLimitError.RetryAfter)
|
|
|
|
time.Sleep(time.Duration(e.RateLimitError.RetryAfter) * time.Second)
|
|
|
|
}
|
|
|
|
return true, err
|
|
|
|
}
|
|
|
|
// Keep old behaviour for backward compatibility
|
2017-05-29 00:55:18 +08:00
|
|
|
if strings.Contains(baseErrString, "too_many_write_operations") || strings.Contains(baseErrString, "too_many_requests") {
|
|
|
|
return true, err
|
|
|
|
}
|
2018-01-13 00:30:54 +08:00
|
|
|
return fserrors.ShouldRetry(err), err
|
2017-05-29 00:55:18 +08:00
|
|
|
}
|
|
|
|
|
2018-09-07 19:02:27 +08:00
|
|
|
func checkUploadChunkSize(cs fs.SizeSuffix) error {
|
|
|
|
const minChunkSize = fs.Byte
|
|
|
|
if cs < minChunkSize {
|
|
|
|
return errors.Errorf("%s is less than %s", cs, minChunkSize)
|
|
|
|
}
|
|
|
|
if cs > maxChunkSize {
|
|
|
|
return errors.Errorf("%s is greater than %s", cs, maxChunkSize)
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (f *Fs) setUploadChunkSize(cs fs.SizeSuffix) (old fs.SizeSuffix, err error) {
|
|
|
|
err = checkUploadChunkSize(cs)
|
|
|
|
if err == nil {
|
|
|
|
old, f.opt.ChunkSize = f.opt.ChunkSize, cs
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2015-11-07 19:14:46 +08:00
|
|
|
// NewFs contstructs an Fs from the path, container:path
|
2018-05-15 01:06:57 +08:00
|
|
|
func NewFs(name, root string, m configmap.Mapper) (fs.Fs, error) {
|
|
|
|
// Parse config into Options struct
|
|
|
|
opt := new(Options)
|
|
|
|
err := configstruct.Set(m, opt)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
2018-09-07 19:02:27 +08:00
|
|
|
err = checkUploadChunkSize(opt.ChunkSize)
|
|
|
|
if err != nil {
|
|
|
|
return nil, errors.Wrap(err, "dropbox: chunk size")
|
2015-08-26 02:01:37 +08:00
|
|
|
}
|
2017-05-22 04:35:33 +08:00
|
|
|
|
|
|
|
// Convert the old token if it exists. The old token was just
|
|
|
|
// just a string, the new one is a JSON blob
|
2018-05-15 01:06:57 +08:00
|
|
|
oldToken, ok := m.Get(config.ConfigToken)
|
|
|
|
oldToken = strings.TrimSpace(oldToken)
|
|
|
|
if ok && oldToken != "" && oldToken[0] != '{' {
|
2017-05-22 04:35:33 +08:00
|
|
|
fs.Infof(name, "Converting token to new format")
|
|
|
|
newToken := fmt.Sprintf(`{"access_token":"%s","token_type":"bearer","expiry":"0001-01-01T00:00:00Z"}`, oldToken)
|
2018-01-13 00:30:54 +08:00
|
|
|
err := config.SetValueAndSave(name, config.ConfigToken, newToken)
|
2017-05-22 04:35:33 +08:00
|
|
|
if err != nil {
|
|
|
|
return nil, errors.Wrap(err, "NewFS convert token")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-05-15 01:06:57 +08:00
|
|
|
oAuthClient, _, err := oauthutil.NewClient(name, m, dropboxConfig)
|
2015-09-22 14:31:12 +08:00
|
|
|
if err != nil {
|
2018-05-28 17:50:31 +08:00
|
|
|
return nil, errors.Wrap(err, "failed to configure dropbox")
|
2017-05-22 04:35:33 +08:00
|
|
|
}
|
|
|
|
|
2015-11-07 19:14:46 +08:00
|
|
|
f := &Fs{
|
2018-05-28 17:50:31 +08:00
|
|
|
name: name,
|
2018-05-15 01:06:57 +08:00
|
|
|
opt: *opt,
|
2018-05-28 17:50:31 +08:00
|
|
|
pacer: pacer.New().SetMinSleep(minSleep).SetMaxSleep(maxSleep).SetDecayConstant(decayConstant),
|
|
|
|
}
|
|
|
|
config := dropbox.Config{
|
|
|
|
LogLevel: dropbox.LogOff, // logging in the SDK: LogOff, LogDebug, LogInfo
|
|
|
|
Client: oAuthClient, // maybe???
|
|
|
|
HeaderGenerator: f.headerGenerator,
|
2014-07-09 04:59:30 +08:00
|
|
|
}
|
2018-09-18 08:35:12 +08:00
|
|
|
|
|
|
|
// NOTE: needs to be created pre-impersonation so we can look up the impersonated user
|
|
|
|
f.team = team.New(config)
|
|
|
|
|
|
|
|
if opt.Impersonate != "" {
|
|
|
|
|
|
|
|
user := team.UserSelectorArg{
|
|
|
|
Email: opt.Impersonate,
|
|
|
|
}
|
|
|
|
user.Tag = "email"
|
|
|
|
|
|
|
|
members := []*team.UserSelectorArg{&user}
|
|
|
|
args := team.NewMembersGetInfoArgs(members)
|
|
|
|
|
|
|
|
memberIds, err := f.team.MembersGetInfo(args)
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
return nil, errors.Wrapf(err, "invalid dropbox team member: %q", opt.Impersonate)
|
|
|
|
}
|
|
|
|
|
|
|
|
config.AsMemberID = memberIds[0].MemberInfo.Profile.MemberProfile.TeamMemberId
|
|
|
|
}
|
|
|
|
|
2018-05-28 17:50:31 +08:00
|
|
|
f.srv = files.New(config)
|
|
|
|
f.sharing = sharing.New(config)
|
|
|
|
f.users = users.New(config)
|
2017-08-09 22:27:43 +08:00
|
|
|
f.features = (&fs.Features{
|
|
|
|
CaseInsensitive: true,
|
|
|
|
ReadMimeType: true,
|
|
|
|
CanHaveEmptyDirectories: true,
|
|
|
|
}).Fill(f)
|
2014-07-14 18:24:04 +08:00
|
|
|
f.setRoot(root)
|
2014-07-09 04:59:30 +08:00
|
|
|
|
2018-05-28 17:50:31 +08:00
|
|
|
// If root starts with / then use the actual root
|
|
|
|
if strings.HasPrefix(root, "/") {
|
|
|
|
var acc *users.FullAccount
|
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
|
|
acc, err = f.users.GetCurrentAccount()
|
|
|
|
return shouldRetry(err)
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return nil, errors.Wrap(err, "get current account failed")
|
|
|
|
}
|
|
|
|
switch x := acc.RootInfo.(type) {
|
|
|
|
case *common.TeamRootInfo:
|
|
|
|
f.ns = x.RootNamespaceId
|
|
|
|
case *common.UserRootInfo:
|
|
|
|
f.ns = x.RootNamespaceId
|
|
|
|
default:
|
|
|
|
return nil, errors.Errorf("unknown RootInfo type %v %T", acc.RootInfo, acc.RootInfo)
|
|
|
|
}
|
|
|
|
fs.Debugf(f, "Using root namespace %q", f.ns)
|
|
|
|
}
|
|
|
|
|
2014-07-14 18:24:04 +08:00
|
|
|
// See if the root is actually an object
|
2017-05-22 04:35:33 +08:00
|
|
|
_, err = f.getFileMetadata(f.slashRoot)
|
|
|
|
if err == nil {
|
2014-07-14 18:24:04 +08:00
|
|
|
newRoot := path.Dir(f.root)
|
|
|
|
if newRoot == "." {
|
|
|
|
newRoot = ""
|
2014-07-12 19:38:30 +08:00
|
|
|
}
|
2014-07-14 18:24:04 +08:00
|
|
|
f.setRoot(newRoot)
|
2016-06-22 01:01:53 +08:00
|
|
|
// return an error with an fs which points to the parent
|
|
|
|
return f, fs.ErrorIsFile
|
2014-07-14 18:24:04 +08:00
|
|
|
}
|
2014-07-09 04:59:30 +08:00
|
|
|
return f, nil
|
|
|
|
}
|
|
|
|
|
2018-05-28 17:50:31 +08:00
|
|
|
// headerGenerator for dropbox sdk
|
|
|
|
func (f *Fs) headerGenerator(hostType string, style string, namespace string, route string) map[string]string {
|
|
|
|
if f.ns == "" {
|
|
|
|
return map[string]string{}
|
|
|
|
}
|
|
|
|
return map[string]string{
|
|
|
|
"Dropbox-API-Path-Root": `{".tag": "namespace_id", "namespace_id": "` + f.ns + `"}`,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2014-07-14 18:24:04 +08:00
|
|
|
// Sets root in f
|
2015-11-07 19:14:46 +08:00
|
|
|
func (f *Fs) setRoot(root string) {
|
2014-07-14 18:24:04 +08:00
|
|
|
f.root = strings.Trim(root, "/")
|
2018-04-05 22:28:40 +08:00
|
|
|
f.slashRoot = "/" + f.root
|
2014-07-14 18:24:04 +08:00
|
|
|
f.slashRootSlash = f.slashRoot
|
2018-04-05 22:28:40 +08:00
|
|
|
if f.root != "" {
|
2014-07-14 18:24:04 +08:00
|
|
|
f.slashRootSlash += "/"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-05-22 04:35:33 +08:00
|
|
|
// getMetadata gets the metadata for a file or directory
|
|
|
|
func (f *Fs) getMetadata(objPath string) (entry files.IsMetadata, notFound bool, err error) {
|
2017-05-29 00:55:18 +08:00
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
|
|
entry, err = f.srv.GetMetadata(&files.GetMetadataArg{Path: objPath})
|
|
|
|
return shouldRetry(err)
|
|
|
|
})
|
2017-05-22 04:35:33 +08:00
|
|
|
if err != nil {
|
|
|
|
switch e := err.(type) {
|
|
|
|
case files.GetMetadataAPIError:
|
|
|
|
switch e.EndpointError.Path.Tag {
|
|
|
|
case files.LookupErrorNotFound:
|
|
|
|
notFound = true
|
|
|
|
err = nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// getFileMetadata gets the metadata for a file
|
|
|
|
func (f *Fs) getFileMetadata(filePath string) (fileInfo *files.FileMetadata, err error) {
|
|
|
|
entry, notFound, err := f.getMetadata(filePath)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
if notFound {
|
|
|
|
return nil, fs.ErrorObjectNotFound
|
|
|
|
}
|
|
|
|
fileInfo, ok := entry.(*files.FileMetadata)
|
|
|
|
if !ok {
|
|
|
|
return nil, fs.ErrorNotAFile
|
|
|
|
}
|
|
|
|
return fileInfo, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// getDirMetadata gets the metadata for a directory
|
|
|
|
func (f *Fs) getDirMetadata(dirPath string) (dirInfo *files.FolderMetadata, err error) {
|
|
|
|
entry, notFound, err := f.getMetadata(dirPath)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
if notFound {
|
|
|
|
return nil, fs.ErrorDirNotFound
|
|
|
|
}
|
|
|
|
dirInfo, ok := entry.(*files.FolderMetadata)
|
|
|
|
if !ok {
|
|
|
|
return nil, fs.ErrorIsFile
|
|
|
|
}
|
|
|
|
return dirInfo, nil
|
|
|
|
}
|
|
|
|
|
2016-06-26 04:58:34 +08:00
|
|
|
// Return an Object from a path
|
2014-07-30 00:50:07 +08:00
|
|
|
//
|
2016-06-26 04:23:20 +08:00
|
|
|
// If it can't be found it returns the error fs.ErrorObjectNotFound.
|
2017-05-22 04:35:33 +08:00
|
|
|
func (f *Fs) newObjectWithInfo(remote string, info *files.FileMetadata) (fs.Object, error) {
|
2017-02-25 23:23:27 +08:00
|
|
|
o := &Object{
|
2015-11-07 19:14:46 +08:00
|
|
|
fs: f,
|
|
|
|
remote: remote,
|
2014-07-09 04:59:30 +08:00
|
|
|
}
|
2017-02-25 23:23:27 +08:00
|
|
|
var err error
|
2014-07-12 18:46:45 +08:00
|
|
|
if info != nil {
|
2017-02-25 19:09:57 +08:00
|
|
|
err = o.setMetadataFromEntry(info)
|
2014-07-09 04:59:30 +08:00
|
|
|
} else {
|
2017-02-25 19:09:57 +08:00
|
|
|
err = o.readEntryAndSetMetadata()
|
|
|
|
}
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
2014-07-09 04:59:30 +08:00
|
|
|
}
|
2016-06-26 04:23:20 +08:00
|
|
|
return o, nil
|
2014-07-09 04:59:30 +08:00
|
|
|
}
|
|
|
|
|
2016-06-26 04:23:20 +08:00
|
|
|
// 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) {
|
2016-06-26 04:58:34 +08:00
|
|
|
return f.newObjectWithInfo(remote, nil)
|
2014-07-09 04:59:30 +08:00
|
|
|
}
|
|
|
|
|
2017-06-12 05:43:31 +08:00
|
|
|
// 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) {
|
2016-04-24 04:46:52 +08:00
|
|
|
root := f.slashRoot
|
|
|
|
if dir != "" {
|
|
|
|
root += "/" + dir
|
|
|
|
}
|
2017-05-22 04:35:33 +08:00
|
|
|
|
|
|
|
started := false
|
|
|
|
var res *files.ListFolderResult
|
2014-07-12 18:46:45 +08:00
|
|
|
for {
|
2017-05-22 04:35:33 +08:00
|
|
|
if !started {
|
|
|
|
arg := files.ListFolderArg{
|
|
|
|
Path: root,
|
2017-06-12 05:43:31 +08:00
|
|
|
Recursive: false,
|
2017-05-22 04:35:33 +08:00
|
|
|
}
|
|
|
|
if root == "/" {
|
|
|
|
arg.Path = "" // Specify root folder as empty string
|
|
|
|
}
|
2017-05-29 00:55:18 +08:00
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
|
|
res, err = f.srv.ListFolder(&arg)
|
|
|
|
return shouldRetry(err)
|
|
|
|
})
|
2017-05-22 04:35:33 +08:00
|
|
|
if err != nil {
|
|
|
|
switch e := err.(type) {
|
|
|
|
case files.ListFolderAPIError:
|
|
|
|
switch e.EndpointError.Path.Tag {
|
|
|
|
case files.LookupErrorNotFound:
|
|
|
|
err = fs.ErrorDirNotFound
|
|
|
|
}
|
2016-05-07 21:50:35 +08:00
|
|
|
}
|
2017-06-12 05:43:31 +08:00
|
|
|
return nil, err
|
2017-05-22 04:35:33 +08:00
|
|
|
}
|
2017-07-08 04:20:07 +08:00
|
|
|
started = true
|
2017-05-22 04:35:33 +08:00
|
|
|
} else {
|
|
|
|
arg := files.ListFolderContinueArg{
|
|
|
|
Cursor: res.Cursor,
|
|
|
|
}
|
2017-05-29 00:55:18 +08:00
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
|
|
res, err = f.srv.ListFolderContinue(&arg)
|
|
|
|
return shouldRetry(err)
|
|
|
|
})
|
2017-05-22 04:35:33 +08:00
|
|
|
if err != nil {
|
2017-06-12 05:43:31 +08:00
|
|
|
return nil, errors.Wrap(err, "list continue")
|
2017-05-22 04:35:33 +08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
for _, entry := range res.Entries {
|
|
|
|
var fileInfo *files.FileMetadata
|
|
|
|
var folderInfo *files.FolderMetadata
|
|
|
|
var metadata *files.Metadata
|
|
|
|
switch info := entry.(type) {
|
|
|
|
case *files.FolderMetadata:
|
|
|
|
folderInfo = info
|
|
|
|
metadata = &info.Metadata
|
|
|
|
case *files.FileMetadata:
|
|
|
|
fileInfo = info
|
|
|
|
metadata = &info.Metadata
|
|
|
|
default:
|
|
|
|
fs.Errorf(f, "Unknown type %T", entry)
|
|
|
|
continue
|
|
|
|
}
|
2015-05-24 02:56:48 +08:00
|
|
|
|
2017-08-10 19:06:50 +08:00
|
|
|
// Only the last element is reliably cased in PathDisplay
|
|
|
|
entryPath := metadata.PathDisplay
|
|
|
|
leaf := path.Base(entryPath)
|
|
|
|
remote := path.Join(dir, leaf)
|
2017-05-22 04:35:33 +08:00
|
|
|
if folderInfo != nil {
|
2017-08-10 19:06:50 +08:00
|
|
|
d := fs.NewDir(remote, time.Now())
|
|
|
|
entries = append(entries, d)
|
2017-05-22 04:35:33 +08:00
|
|
|
} else if fileInfo != nil {
|
2017-08-10 19:06:50 +08:00
|
|
|
o, err := f.newObjectWithInfo(remote, fileInfo)
|
2017-05-22 04:35:33 +08:00
|
|
|
if err != nil {
|
2017-06-12 05:43:31 +08:00
|
|
|
return nil, err
|
2017-05-22 04:35:33 +08:00
|
|
|
}
|
2017-06-12 05:43:31 +08:00
|
|
|
entries = append(entries, o)
|
2014-07-12 18:46:45 +08:00
|
|
|
}
|
2014-07-09 04:59:30 +08:00
|
|
|
}
|
2017-05-22 04:35:33 +08:00
|
|
|
if !res.HasMore {
|
2016-05-07 21:50:35 +08:00
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
2017-06-12 05:43:31 +08:00
|
|
|
return entries, nil
|
2014-07-09 04:59:30 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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
|
2017-05-28 19:44:22 +08:00
|
|
|
func (f *Fs) Put(in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) {
|
2015-11-07 19:14:46 +08:00
|
|
|
// Temporary Object under construction
|
|
|
|
o := &Object{
|
|
|
|
fs: f,
|
2016-02-18 19:35:25 +08:00
|
|
|
remote: src.Remote(),
|
2015-11-07 19:14:46 +08:00
|
|
|
}
|
2017-05-28 19:44:22 +08:00
|
|
|
return o, o.Update(in, src, options...)
|
2014-07-09 04:59:30 +08:00
|
|
|
}
|
|
|
|
|
2017-08-04 03:42:35 +08:00
|
|
|
// 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...)
|
|
|
|
}
|
|
|
|
|
2014-07-09 04:59:30 +08:00
|
|
|
// Mkdir creates the container if it doesn't exist
|
2016-11-26 05:52:43 +08:00
|
|
|
func (f *Fs) Mkdir(dir string) error {
|
|
|
|
root := path.Join(f.slashRoot, dir)
|
2017-05-22 04:35:33 +08:00
|
|
|
|
|
|
|
// can't create or run metadata on root
|
|
|
|
if root == "/" {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// check directory doesn't exist
|
|
|
|
_, err := f.getDirMetadata(root)
|
2014-07-13 17:51:47 +08:00
|
|
|
if err == nil {
|
2017-05-22 04:35:33 +08:00
|
|
|
return nil // directory exists already
|
|
|
|
} else if err != fs.ErrorDirNotFound {
|
|
|
|
return err // some other error
|
|
|
|
}
|
|
|
|
|
|
|
|
// create it
|
|
|
|
arg2 := files.CreateFolderArg{
|
|
|
|
Path: root,
|
2014-07-13 17:51:47 +08:00
|
|
|
}
|
2017-05-29 00:55:18 +08:00
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
2017-08-09 19:58:48 +08:00
|
|
|
_, err = f.srv.CreateFolderV2(&arg2)
|
2017-05-29 00:55:18 +08:00
|
|
|
return shouldRetry(err)
|
|
|
|
})
|
2014-07-09 04:59:30 +08:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Rmdir deletes the container
|
|
|
|
//
|
|
|
|
// Returns an error if it isn't empty
|
2016-11-26 05:52:43 +08:00
|
|
|
func (f *Fs) Rmdir(dir string) error {
|
|
|
|
root := path.Join(f.slashRoot, dir)
|
2017-05-22 04:35:33 +08:00
|
|
|
|
|
|
|
// can't remove root
|
|
|
|
if root == "/" {
|
|
|
|
return errors.New("can't remove root directory")
|
|
|
|
}
|
|
|
|
|
|
|
|
// check directory exists
|
|
|
|
_, err := f.getDirMetadata(root)
|
2014-07-09 04:59:30 +08:00
|
|
|
if err != nil {
|
2017-05-22 04:35:33 +08:00
|
|
|
return errors.Wrap(err, "Rmdir")
|
|
|
|
}
|
|
|
|
|
|
|
|
// check directory empty
|
|
|
|
arg := files.ListFolderArg{
|
|
|
|
Path: root,
|
|
|
|
Recursive: false,
|
|
|
|
}
|
|
|
|
if root == "/" {
|
|
|
|
arg.Path = "" // Specify root folder as empty string
|
|
|
|
}
|
2017-05-29 00:55:18 +08:00
|
|
|
var res *files.ListFolderResult
|
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
|
|
res, err = f.srv.ListFolder(&arg)
|
|
|
|
return shouldRetry(err)
|
|
|
|
})
|
2017-05-22 04:35:33 +08:00
|
|
|
if err != nil {
|
|
|
|
return errors.Wrap(err, "Rmdir")
|
2014-07-09 04:59:30 +08:00
|
|
|
}
|
2017-05-22 04:35:33 +08:00
|
|
|
if len(res.Entries) != 0 {
|
2016-06-12 22:06:02 +08:00
|
|
|
return errors.New("directory not empty")
|
2014-07-09 04:59:30 +08:00
|
|
|
}
|
2017-05-22 04:35:33 +08:00
|
|
|
|
|
|
|
// remove it
|
2017-05-29 00:55:18 +08:00
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
2017-08-09 19:58:48 +08:00
|
|
|
_, err = f.srv.DeleteV2(&files.DeleteArg{Path: root})
|
2017-05-29 00:55:18 +08:00
|
|
|
return shouldRetry(err)
|
|
|
|
})
|
2016-11-26 05:52:43 +08:00
|
|
|
return err
|
2014-07-09 04:59:30 +08:00
|
|
|
}
|
|
|
|
|
2015-09-23 01:47:16 +08:00
|
|
|
// Precision returns the precision
|
2015-11-07 19:14:46 +08:00
|
|
|
func (f *Fs) Precision() time.Duration {
|
2017-05-22 04:35:33 +08:00
|
|
|
return time.Second
|
2014-07-09 04:59:30 +08:00
|
|
|
}
|
|
|
|
|
2015-02-15 02:48:08 +08:00
|
|
|
// 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
|
2015-11-07 19:14:46 +08:00
|
|
|
func (f *Fs) Copy(src fs.Object, remote string) (fs.Object, error) {
|
|
|
|
srcObj, ok := src.(*Object)
|
2015-02-15 02:48:08 +08:00
|
|
|
if !ok {
|
2017-02-09 19:01:20 +08:00
|
|
|
fs.Debugf(src, "Can't copy - not same remote type")
|
2015-02-15 02:48:08 +08:00
|
|
|
return nil, fs.ErrorCantCopy
|
|
|
|
}
|
|
|
|
|
2015-11-07 19:14:46 +08:00
|
|
|
// Temporary Object under construction
|
|
|
|
dstObj := &Object{
|
|
|
|
fs: f,
|
|
|
|
remote: remote,
|
|
|
|
}
|
2015-02-15 02:48:08 +08:00
|
|
|
|
2017-05-22 04:35:33 +08:00
|
|
|
// Copy
|
|
|
|
arg := files.RelocationArg{}
|
|
|
|
arg.FromPath = srcObj.remotePath()
|
|
|
|
arg.ToPath = dstObj.remotePath()
|
2017-05-29 00:55:18 +08:00
|
|
|
var err error
|
2017-09-25 03:01:30 +08:00
|
|
|
var result *files.RelocationResult
|
2017-05-29 00:55:18 +08:00
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
2017-09-25 03:01:30 +08:00
|
|
|
result, err = f.srv.CopyV2(&arg)
|
2017-05-29 00:55:18 +08:00
|
|
|
return shouldRetry(err)
|
|
|
|
})
|
2015-02-15 02:48:08 +08:00
|
|
|
if err != nil {
|
2016-06-12 22:06:02 +08:00
|
|
|
return nil, errors.Wrap(err, "copy failed")
|
2015-02-15 02:48:08 +08:00
|
|
|
}
|
2017-05-22 04:35:33 +08:00
|
|
|
|
|
|
|
// Set the metadata
|
2017-09-25 03:01:30 +08:00
|
|
|
fileInfo, ok := result.Metadata.(*files.FileMetadata)
|
2017-05-22 04:35:33 +08:00
|
|
|
if !ok {
|
|
|
|
return nil, fs.ErrorNotAFile
|
|
|
|
}
|
|
|
|
err = dstObj.setMetadataFromEntry(fileInfo)
|
2017-02-25 19:09:57 +08:00
|
|
|
if err != nil {
|
|
|
|
return nil, errors.Wrap(err, "copy failed")
|
|
|
|
}
|
2017-05-22 04:35:33 +08:00
|
|
|
|
2015-02-15 02:48:08 +08:00
|
|
|
return dstObj, nil
|
|
|
|
}
|
|
|
|
|
2014-07-09 04:59:30 +08:00
|
|
|
// Purge deletes all the files and the container
|
|
|
|
//
|
2014-07-13 17:53:53 +08:00
|
|
|
// 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()
|
2017-05-29 00:55:18 +08:00
|
|
|
func (f *Fs) Purge() (err error) {
|
2014-07-13 17:53:53 +08:00
|
|
|
// Let dropbox delete the filesystem tree
|
2017-05-29 00:55:18 +08:00
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
2017-08-09 19:58:48 +08:00
|
|
|
_, err = f.srv.DeleteV2(&files.DeleteArg{Path: f.slashRoot})
|
2017-05-29 00:55:18 +08:00
|
|
|
return shouldRetry(err)
|
|
|
|
})
|
2014-07-09 04:59:30 +08:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2015-09-01 04:05:51 +08:00
|
|
|
// 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
|
2015-11-07 19:14:46 +08:00
|
|
|
func (f *Fs) Move(src fs.Object, remote string) (fs.Object, error) {
|
|
|
|
srcObj, ok := src.(*Object)
|
2015-09-01 04:05:51 +08:00
|
|
|
if !ok {
|
2017-02-09 19:01:20 +08:00
|
|
|
fs.Debugf(src, "Can't move - not same remote type")
|
2015-09-01 04:05:51 +08:00
|
|
|
return nil, fs.ErrorCantMove
|
|
|
|
}
|
|
|
|
|
2015-11-07 19:14:46 +08:00
|
|
|
// Temporary Object under construction
|
|
|
|
dstObj := &Object{
|
|
|
|
fs: f,
|
|
|
|
remote: remote,
|
|
|
|
}
|
2015-09-01 04:05:51 +08:00
|
|
|
|
2017-05-22 04:35:33 +08:00
|
|
|
// Do the move
|
|
|
|
arg := files.RelocationArg{}
|
|
|
|
arg.FromPath = srcObj.remotePath()
|
|
|
|
arg.ToPath = dstObj.remotePath()
|
2017-05-29 00:55:18 +08:00
|
|
|
var err error
|
2017-09-25 03:01:30 +08:00
|
|
|
var result *files.RelocationResult
|
2017-05-29 00:55:18 +08:00
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
2017-09-25 03:01:30 +08:00
|
|
|
result, err = f.srv.MoveV2(&arg)
|
2017-05-29 00:55:18 +08:00
|
|
|
return shouldRetry(err)
|
|
|
|
})
|
2015-09-01 04:05:51 +08:00
|
|
|
if err != nil {
|
2016-06-12 22:06:02 +08:00
|
|
|
return nil, errors.Wrap(err, "move failed")
|
2015-09-01 04:05:51 +08:00
|
|
|
}
|
2017-05-22 04:35:33 +08:00
|
|
|
|
|
|
|
// Set the metadata
|
2017-09-25 03:01:30 +08:00
|
|
|
fileInfo, ok := result.Metadata.(*files.FileMetadata)
|
2017-05-22 04:35:33 +08:00
|
|
|
if !ok {
|
|
|
|
return nil, fs.ErrorNotAFile
|
|
|
|
}
|
|
|
|
err = dstObj.setMetadataFromEntry(fileInfo)
|
2017-02-25 19:09:57 +08:00
|
|
|
if err != nil {
|
|
|
|
return nil, errors.Wrap(err, "move failed")
|
|
|
|
}
|
2015-09-01 04:05:51 +08:00
|
|
|
return dstObj, nil
|
|
|
|
}
|
|
|
|
|
2018-03-29 15:10:19 +08:00
|
|
|
// PublicLink adds a "readable by anyone with link" permission on the given file or folder.
|
|
|
|
func (f *Fs) PublicLink(remote string) (link string, err error) {
|
|
|
|
absPath := "/" + path.Join(f.Root(), remote)
|
|
|
|
fs.Debugf(f, "attempting to share '%s' (absolute path: %s)", remote, absPath)
|
|
|
|
createArg := sharing.CreateSharedLinkWithSettingsArg{
|
|
|
|
Path: absPath,
|
|
|
|
}
|
|
|
|
var linkRes sharing.IsSharedLinkMetadata
|
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
2018-05-28 17:50:31 +08:00
|
|
|
linkRes, err = f.sharing.CreateSharedLinkWithSettings(&createArg)
|
2018-03-29 15:10:19 +08:00
|
|
|
return shouldRetry(err)
|
|
|
|
})
|
|
|
|
|
|
|
|
if err != nil && strings.Contains(err.Error(), sharing.CreateSharedLinkWithSettingsErrorSharedLinkAlreadyExists) {
|
|
|
|
fs.Debugf(absPath, "has a public link already, attempting to retrieve it")
|
|
|
|
listArg := sharing.ListSharedLinksArg{
|
|
|
|
Path: absPath,
|
|
|
|
DirectOnly: true,
|
|
|
|
}
|
|
|
|
var listRes *sharing.ListSharedLinksResult
|
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
2018-05-28 17:50:31 +08:00
|
|
|
listRes, err = f.sharing.ListSharedLinks(&listArg)
|
2018-03-29 15:10:19 +08:00
|
|
|
return shouldRetry(err)
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if len(listRes.Links) == 0 {
|
|
|
|
err = errors.New("Dropbox says the sharing link already exists, but list came back empty")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
linkRes = listRes.Links[0]
|
|
|
|
}
|
|
|
|
if err == nil {
|
|
|
|
switch res := linkRes.(type) {
|
|
|
|
case *sharing.FileLinkMetadata:
|
|
|
|
link = res.Url
|
|
|
|
case *sharing.FolderLinkMetadata:
|
|
|
|
link = res.Url
|
|
|
|
default:
|
|
|
|
err = fmt.Errorf("Don't know how to extract link, response has unknown format: %T", res)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2017-02-06 05:20:56 +08:00
|
|
|
// DirMove moves src, srcRemote to this remote at dstRemote
|
|
|
|
// using server side move operations.
|
2015-09-01 04:05:51 +08:00
|
|
|
//
|
|
|
|
// 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
|
2017-02-06 05:20:56 +08:00
|
|
|
func (f *Fs) DirMove(src fs.Fs, srcRemote, dstRemote string) error {
|
2015-11-07 19:14:46 +08:00
|
|
|
srcFs, ok := src.(*Fs)
|
2015-09-01 04:05:51 +08:00
|
|
|
if !ok {
|
2017-02-09 19:01:20 +08:00
|
|
|
fs.Debugf(srcFs, "Can't move directory - not same remote type")
|
2015-09-01 04:05:51 +08:00
|
|
|
return fs.ErrorCantDirMove
|
|
|
|
}
|
2017-02-06 05:20:56 +08:00
|
|
|
srcPath := path.Join(srcFs.slashRoot, srcRemote)
|
|
|
|
dstPath := path.Join(f.slashRoot, dstRemote)
|
2015-09-01 04:05:51 +08:00
|
|
|
|
|
|
|
// Check if destination exists
|
2017-10-02 18:21:16 +08:00
|
|
|
_, err := f.getDirMetadata(dstPath)
|
2017-05-22 04:35:33 +08:00
|
|
|
if err == nil {
|
2015-09-01 04:05:51 +08:00
|
|
|
return fs.ErrorDirExists
|
2017-05-22 04:35:33 +08:00
|
|
|
} else if err != fs.ErrorDirNotFound {
|
|
|
|
return err
|
2015-09-01 04:05:51 +08:00
|
|
|
}
|
|
|
|
|
2017-02-06 05:20:56 +08:00
|
|
|
// Make sure the parent directory exists
|
|
|
|
// ...apparently not necessary
|
|
|
|
|
2015-09-01 04:05:51 +08:00
|
|
|
// Do the move
|
2017-05-22 04:35:33 +08:00
|
|
|
arg := files.RelocationArg{}
|
|
|
|
arg.FromPath = srcPath
|
|
|
|
arg.ToPath = dstPath
|
2017-05-29 00:55:18 +08:00
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
2017-09-25 03:01:30 +08:00
|
|
|
_, err = f.srv.MoveV2(&arg)
|
2017-05-29 00:55:18 +08:00
|
|
|
return shouldRetry(err)
|
|
|
|
})
|
2015-09-01 04:05:51 +08:00
|
|
|
if err != nil {
|
2016-06-12 22:06:02 +08:00
|
|
|
return errors.Wrap(err, "MoveDir failed")
|
2015-09-01 04:05:51 +08:00
|
|
|
}
|
2017-05-22 04:35:33 +08:00
|
|
|
|
2015-09-01 04:05:51 +08:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2018-04-17 05:19:25 +08:00
|
|
|
// About gets quota information
|
|
|
|
func (f *Fs) About() (usage *fs.Usage, err error) {
|
|
|
|
var q *users.SpaceUsage
|
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
|
|
q, err = f.users.GetSpaceUsage()
|
|
|
|
return shouldRetry(err)
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return nil, errors.Wrap(err, "about failed")
|
|
|
|
}
|
|
|
|
var total uint64
|
|
|
|
if q.Allocation != nil {
|
|
|
|
if q.Allocation.Individual != nil {
|
|
|
|
total += q.Allocation.Individual.Allocated
|
|
|
|
}
|
|
|
|
if q.Allocation.Team != nil {
|
|
|
|
total += q.Allocation.Team.Allocated
|
|
|
|
}
|
|
|
|
}
|
|
|
|
usage = &fs.Usage{
|
|
|
|
Total: fs.NewUsageValue(int64(total)), // quota of bytes that can be used
|
|
|
|
Used: fs.NewUsageValue(int64(q.Used)), // bytes in use
|
|
|
|
Free: fs.NewUsageValue(int64(total - q.Used)), // bytes which can be uploaded before reaching the quota
|
|
|
|
}
|
|
|
|
return usage, nil
|
|
|
|
}
|
|
|
|
|
2016-01-11 20:39:33 +08:00
|
|
|
// Hashes returns the supported hash sets.
|
2018-01-13 00:30:54 +08:00
|
|
|
func (f *Fs) Hashes() hash.Set {
|
2018-01-19 04:27:52 +08:00
|
|
|
return hash.Set(hash.Dropbox)
|
2016-01-11 20:39:33 +08:00
|
|
|
}
|
|
|
|
|
2014-07-09 04:59:30 +08:00
|
|
|
// ------------------------------------------------------------
|
|
|
|
|
2015-09-23 01:47:16 +08:00
|
|
|
// Fs returns the parent Fs
|
2016-02-18 19:35:25 +08:00
|
|
|
func (o *Object) Fs() fs.Info {
|
2015-11-07 19:14:46 +08:00
|
|
|
return o.fs
|
2014-07-09 04:59:30 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
// Return a string version
|
2015-11-07 19:14:46 +08:00
|
|
|
func (o *Object) String() string {
|
2014-07-09 04:59:30 +08:00
|
|
|
if o == nil {
|
|
|
|
return "<nil>"
|
|
|
|
}
|
|
|
|
return o.remote
|
|
|
|
}
|
|
|
|
|
2015-09-23 01:47:16 +08:00
|
|
|
// Remote returns the remote path
|
2015-11-07 19:14:46 +08:00
|
|
|
func (o *Object) Remote() string {
|
2014-07-09 04:59:30 +08:00
|
|
|
return o.remote
|
|
|
|
}
|
|
|
|
|
2017-05-26 22:09:31 +08:00
|
|
|
// Hash returns the dropbox special hash
|
2018-01-13 00:30:54 +08:00
|
|
|
func (o *Object) Hash(t hash.Type) (string, error) {
|
2018-01-19 04:27:52 +08:00
|
|
|
if t != hash.Dropbox {
|
|
|
|
return "", hash.ErrUnsupported
|
2017-05-26 22:09:31 +08:00
|
|
|
}
|
|
|
|
err := o.readMetaData()
|
|
|
|
if err != nil {
|
|
|
|
return "", errors.Wrap(err, "failed to read hash from metadata")
|
|
|
|
}
|
|
|
|
return o.hash, nil
|
2014-07-09 04:59:30 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
// Size returns the size of an object in bytes
|
2015-11-07 19:14:46 +08:00
|
|
|
func (o *Object) Size() int64 {
|
2014-07-09 04:59:30 +08:00
|
|
|
return o.bytes
|
|
|
|
}
|
|
|
|
|
2017-05-22 04:35:33 +08:00
|
|
|
// setMetadataFromEntry sets the fs data from a files.FileMetadata
|
2014-07-10 07:17:40 +08:00
|
|
|
//
|
|
|
|
// This isn't a complete set of metadata and has an inacurate date
|
2017-05-22 04:35:33 +08:00
|
|
|
func (o *Object) setMetadataFromEntry(info *files.FileMetadata) error {
|
|
|
|
o.bytes = int64(info.Size)
|
|
|
|
o.modTime = info.ClientModified
|
2017-05-26 22:09:31 +08:00
|
|
|
o.hash = info.ContentHash
|
2017-02-25 19:09:57 +08:00
|
|
|
return nil
|
2014-07-09 04:59:30 +08:00
|
|
|
}
|
|
|
|
|
2017-05-22 04:35:33 +08:00
|
|
|
// Reads the entry for a file from dropbox
|
|
|
|
func (o *Object) readEntry() (*files.FileMetadata, error) {
|
|
|
|
return o.fs.getFileMetadata(o.remotePath())
|
2014-07-10 07:17:40 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
// Read entry if not set and set metadata from it
|
2015-11-07 19:14:46 +08:00
|
|
|
func (o *Object) readEntryAndSetMetadata() error {
|
2014-07-10 07:17:40 +08:00
|
|
|
// Last resort set time from client
|
|
|
|
if !o.modTime.IsZero() {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
entry, err := o.readEntry()
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2017-02-25 19:09:57 +08:00
|
|
|
return o.setMetadataFromEntry(entry)
|
2014-07-10 07:17:40 +08:00
|
|
|
}
|
|
|
|
|
2014-07-09 04:59:30 +08:00
|
|
|
// Returns the remote path for the object
|
2015-11-07 19:14:46 +08:00
|
|
|
func (o *Object) remotePath() string {
|
|
|
|
return o.fs.slashRootSlash + o.remote
|
2014-07-09 04:59:30 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
// readMetaData gets the info if it hasn't already been fetched
|
2015-11-07 19:14:46 +08:00
|
|
|
func (o *Object) readMetaData() (err error) {
|
2017-05-26 22:09:31 +08:00
|
|
|
if !o.modTime.IsZero() {
|
2014-07-09 04:59:30 +08:00
|
|
|
return nil
|
|
|
|
}
|
2014-07-10 07:17:40 +08:00
|
|
|
// Last resort
|
2014-07-26 01:19:49 +08:00
|
|
|
return o.readEntryAndSetMetadata()
|
2014-07-09 04:59:30 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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
|
2015-11-07 19:14:46 +08:00
|
|
|
func (o *Object) ModTime() time.Time {
|
2014-07-09 04:59:30 +08:00
|
|
|
err := o.readMetaData()
|
|
|
|
if err != nil {
|
2017-02-10 01:08:51 +08:00
|
|
|
fs.Debugf(o, "Failed to read metadata: %v", err)
|
2014-07-09 04:59:30 +08:00
|
|
|
return time.Now()
|
|
|
|
}
|
|
|
|
return o.modTime
|
|
|
|
}
|
|
|
|
|
2015-09-23 01:47:16 +08:00
|
|
|
// SetModTime sets the modification time of the local fs object
|
2014-07-10 07:17:40 +08:00
|
|
|
//
|
|
|
|
// Commits the datastore
|
2016-03-22 23:07:10 +08:00
|
|
|
func (o *Object) SetModTime(modTime time.Time) error {
|
2017-05-22 04:35:33 +08:00
|
|
|
// Dropbox doesn't have a way of doing this so returning this
|
2017-06-13 20:58:39 +08:00
|
|
|
// error will cause the file to be deleted first then
|
|
|
|
// re-uploaded to set the time.
|
2018-04-06 22:34:56 +08:00
|
|
|
return fs.ErrorCantSetModTimeWithoutDelete
|
2014-07-09 04:59:30 +08:00
|
|
|
}
|
|
|
|
|
2015-09-23 01:47:16 +08:00
|
|
|
// Storable returns whether this object is storable
|
2015-11-07 19:14:46 +08:00
|
|
|
func (o *Object) Storable() bool {
|
2014-07-09 04:59:30 +08:00
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
// Open an object for read
|
2016-09-10 18:29:57 +08:00
|
|
|
func (o *Object) Open(options ...fs.OpenOption) (in io.ReadCloser, err error) {
|
2017-05-22 04:35:33 +08:00
|
|
|
headers := fs.OpenOptionHeaders(options)
|
|
|
|
arg := files.DownloadArg{Path: o.remotePath(), ExtraHeaders: headers}
|
2017-05-29 00:55:18 +08:00
|
|
|
err = o.fs.pacer.Call(func() (bool, error) {
|
|
|
|
_, in, err = o.fs.srv.Download(&arg)
|
|
|
|
return shouldRetry(err)
|
|
|
|
})
|
2017-05-22 04:35:33 +08:00
|
|
|
|
|
|
|
switch e := err.(type) {
|
|
|
|
case files.DownloadAPIError:
|
|
|
|
// Don't attempt to retry copyright violation errors
|
|
|
|
if e.EndpointError.Path.Tag == files.LookupErrorRestrictedContent {
|
2018-01-13 00:30:54 +08:00
|
|
|
return nil, fserrors.NoRetryError(err)
|
2016-09-10 18:29:57 +08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-05-22 04:35:33 +08:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// uploadChunked uploads the object in parts
|
|
|
|
//
|
2017-08-04 03:42:35 +08:00
|
|
|
// Will work optimally if size is >= uploadChunkSize. If the size is either
|
|
|
|
// unknown (i.e. -1) or smaller than uploadChunkSize, the method incurs an
|
|
|
|
// avoidable request to the Dropbox API that does not carry payload.
|
|
|
|
func (o *Object) uploadChunked(in0 io.Reader, commitInfo *files.CommitInfo, size int64) (entry *files.FileMetadata, err error) {
|
2018-05-15 01:06:57 +08:00
|
|
|
chunkSize := int64(o.fs.opt.ChunkSize)
|
2017-08-04 03:42:35 +08:00
|
|
|
chunks := 0
|
|
|
|
if size != -1 {
|
|
|
|
chunks = int(size/chunkSize) + 1
|
|
|
|
}
|
2018-01-13 00:30:54 +08:00
|
|
|
in := readers.NewCountingReader(in0)
|
2017-11-23 18:59:28 +08:00
|
|
|
buf := make([]byte, int(chunkSize))
|
2017-08-04 03:42:35 +08:00
|
|
|
|
|
|
|
fmtChunk := func(cur int, last bool) {
|
|
|
|
if chunks == 0 && last {
|
|
|
|
fs.Debugf(o, "Streaming chunk %d/%d", cur, cur)
|
|
|
|
} else if chunks == 0 {
|
|
|
|
fs.Debugf(o, "Streaming chunk %d/unknown", cur)
|
|
|
|
} else {
|
|
|
|
fs.Debugf(o, "Uploading chunk %d/%d", cur, chunks)
|
|
|
|
}
|
|
|
|
}
|
2017-05-22 04:35:33 +08:00
|
|
|
|
2017-08-04 03:42:35 +08:00
|
|
|
// write the first chunk
|
|
|
|
fmtChunk(1, false)
|
2017-05-29 00:55:18 +08:00
|
|
|
var res *files.UploadSessionStartResult
|
2018-01-13 00:30:54 +08:00
|
|
|
chunk := readers.NewRepeatableLimitReaderBuffer(in, buf, chunkSize)
|
2017-11-08 17:18:16 +08:00
|
|
|
err = o.fs.pacer.Call(func() (bool, error) {
|
|
|
|
// seek to the start in case this is a retry
|
2018-04-07 02:53:06 +08:00
|
|
|
if _, err = chunk.Seek(0, io.SeekStart); err != nil {
|
2017-11-08 17:18:16 +08:00
|
|
|
return false, nil
|
|
|
|
}
|
|
|
|
res, err = o.fs.srv.UploadSessionStart(&files.UploadSessionStartArg{}, chunk)
|
2017-05-29 00:55:18 +08:00
|
|
|
return shouldRetry(err)
|
|
|
|
})
|
2017-05-22 04:35:33 +08:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
cursor := files.UploadSessionCursor{
|
|
|
|
SessionId: res.SessionId,
|
2017-08-04 03:42:35 +08:00
|
|
|
Offset: 0,
|
2017-05-22 04:35:33 +08:00
|
|
|
}
|
|
|
|
appendArg := files.UploadSessionAppendArg{
|
|
|
|
Cursor: &cursor,
|
|
|
|
Close: false,
|
|
|
|
}
|
|
|
|
|
|
|
|
// write more whole chunks (if any)
|
2017-08-04 03:42:35 +08:00
|
|
|
currentChunk := 2
|
|
|
|
for {
|
|
|
|
if chunks > 0 && currentChunk >= chunks {
|
|
|
|
// if the size is known, only upload full chunks. Remaining bytes are uploaded with
|
|
|
|
// the UploadSessionFinish request.
|
|
|
|
break
|
2017-08-19 20:07:23 +08:00
|
|
|
} else if chunks == 0 && in.BytesRead()-cursor.Offset < uint64(chunkSize) {
|
2017-08-04 03:42:35 +08:00
|
|
|
// if the size is unknown, upload as long as we can read full chunks from the reader.
|
|
|
|
// The UploadSessionFinish request will not contain any payload.
|
|
|
|
break
|
|
|
|
}
|
2017-08-19 20:07:23 +08:00
|
|
|
cursor.Offset = in.BytesRead()
|
2017-08-04 03:42:35 +08:00
|
|
|
fmtChunk(currentChunk, false)
|
2018-01-13 00:30:54 +08:00
|
|
|
chunk = readers.NewRepeatableLimitReaderBuffer(in, buf, chunkSize)
|
2017-11-08 17:18:16 +08:00
|
|
|
err = o.fs.pacer.Call(func() (bool, error) {
|
|
|
|
// seek to the start in case this is a retry
|
2018-04-07 02:53:06 +08:00
|
|
|
if _, err = chunk.Seek(0, io.SeekStart); err != nil {
|
2017-11-08 17:18:16 +08:00
|
|
|
return false, nil
|
|
|
|
}
|
|
|
|
err = o.fs.srv.UploadSessionAppendV2(&appendArg, chunk)
|
2017-11-21 18:03:44 +08:00
|
|
|
// after the first chunk is uploaded, we retry everything
|
|
|
|
return err != nil, err
|
2017-05-29 00:55:18 +08:00
|
|
|
})
|
2017-05-22 04:35:33 +08:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
2016-07-04 20:45:10 +08:00
|
|
|
}
|
2017-08-04 03:42:35 +08:00
|
|
|
currentChunk++
|
2016-07-04 20:45:10 +08:00
|
|
|
}
|
2017-05-22 04:35:33 +08:00
|
|
|
|
|
|
|
// write the remains
|
2017-08-19 20:07:23 +08:00
|
|
|
cursor.Offset = in.BytesRead()
|
2017-05-22 04:35:33 +08:00
|
|
|
args := &files.UploadSessionFinishArg{
|
|
|
|
Cursor: &cursor,
|
|
|
|
Commit: commitInfo,
|
|
|
|
}
|
2017-08-04 03:42:35 +08:00
|
|
|
fmtChunk(currentChunk, true)
|
2018-01-13 00:30:54 +08:00
|
|
|
chunk = readers.NewRepeatableReaderBuffer(in, buf)
|
2017-11-08 17:18:16 +08:00
|
|
|
err = o.fs.pacer.Call(func() (bool, error) {
|
|
|
|
// seek to the start in case this is a retry
|
2018-04-07 02:53:06 +08:00
|
|
|
if _, err = chunk.Seek(0, io.SeekStart); err != nil {
|
2017-11-08 17:18:16 +08:00
|
|
|
return false, nil
|
|
|
|
}
|
|
|
|
entry, err = o.fs.srv.UploadSessionFinish(args, chunk)
|
2017-11-21 18:03:44 +08:00
|
|
|
// after the first chunk is uploaded, we retry everything
|
|
|
|
return err != nil, err
|
2017-05-29 00:55:18 +08:00
|
|
|
})
|
2017-05-22 04:35:33 +08:00
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
return entry, nil
|
2014-07-09 04:59:30 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
// Update the already existing object
|
|
|
|
//
|
|
|
|
// Copy the reader into the object updating modTime and size
|
|
|
|
//
|
|
|
|
// The new object may have been created if an error is returned
|
2017-05-28 19:44:22 +08:00
|
|
|
func (o *Object) Update(in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) error {
|
2015-08-21 01:36:06 +08:00
|
|
|
remote := o.remotePath()
|
|
|
|
if ignoredFiles.MatchString(remote) {
|
2017-02-09 19:01:20 +08:00
|
|
|
fs.Logf(o, "File name disallowed - not uploading")
|
2015-08-21 01:36:06 +08:00
|
|
|
return nil
|
|
|
|
}
|
2017-05-22 04:35:33 +08:00
|
|
|
commitInfo := files.NewCommitInfo(o.remotePath())
|
|
|
|
commitInfo.Mode.Tag = "overwrite"
|
|
|
|
// The Dropbox API only accepts timestamps in UTC with second precision.
|
|
|
|
commitInfo.ClientModified = src.ModTime().UTC().Round(time.Second)
|
|
|
|
|
|
|
|
size := src.Size()
|
|
|
|
var err error
|
|
|
|
var entry *files.FileMetadata
|
2018-05-15 01:06:57 +08:00
|
|
|
if size > int64(o.fs.opt.ChunkSize) || size == -1 {
|
2017-05-22 04:35:33 +08:00
|
|
|
entry, err = o.uploadChunked(in, commitInfo, size)
|
|
|
|
} else {
|
2017-05-29 00:55:18 +08:00
|
|
|
err = o.fs.pacer.CallNoRetry(func() (bool, error) {
|
|
|
|
entry, err = o.fs.srv.Upload(commitInfo, in)
|
|
|
|
return shouldRetry(err)
|
|
|
|
})
|
2017-05-22 04:35:33 +08:00
|
|
|
}
|
2014-07-09 04:59:30 +08:00
|
|
|
if err != nil {
|
2016-06-12 22:06:02 +08:00
|
|
|
return errors.Wrap(err, "upload failed")
|
2014-07-09 04:59:30 +08:00
|
|
|
}
|
2017-02-25 19:09:57 +08:00
|
|
|
return o.setMetadataFromEntry(entry)
|
2014-07-09 04:59:30 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
// Remove an object
|
2017-05-29 00:55:18 +08:00
|
|
|
func (o *Object) Remove() (err error) {
|
2017-11-08 17:18:16 +08:00
|
|
|
err = o.fs.pacer.Call(func() (bool, error) {
|
2017-08-09 19:58:48 +08:00
|
|
|
_, err = o.fs.srv.DeleteV2(&files.DeleteArg{Path: o.remotePath()})
|
2017-05-29 00:55:18 +08:00
|
|
|
return shouldRetry(err)
|
|
|
|
})
|
2014-07-09 04:59:30 +08:00
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check the interfaces are satisfied
|
2015-09-01 04:05:51 +08:00
|
|
|
var (
|
2018-03-29 15:10:19 +08:00
|
|
|
_ fs.Fs = (*Fs)(nil)
|
|
|
|
_ fs.Copier = (*Fs)(nil)
|
|
|
|
_ fs.Purger = (*Fs)(nil)
|
|
|
|
_ fs.PutStreamer = (*Fs)(nil)
|
|
|
|
_ fs.Mover = (*Fs)(nil)
|
|
|
|
_ fs.PublicLinker = (*Fs)(nil)
|
|
|
|
_ fs.DirMover = (*Fs)(nil)
|
2018-04-17 05:19:25 +08:00
|
|
|
_ fs.Abouter = (*Fs)(nil)
|
2018-03-29 15:10:19 +08:00
|
|
|
_ fs.Object = (*Object)(nil)
|
2015-09-01 04:05:51 +08:00
|
|
|
)
|