mirror of
https://github.com/rclone/rclone.git
synced 2024-11-29 20:04:52 +01:00
2925e1384c
Includes adding support for additional size input suffix Mi and MiB, treated equivalent to M. Extends binary suffix output with letter i, e.g. Ki and Mi. Centralizes creation of bit/byte unit strings.
613 lines
16 KiB
Go
613 lines
16 KiB
Go
package koofr
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"path"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rclone/rclone/fs"
|
|
"github.com/rclone/rclone/fs/config"
|
|
"github.com/rclone/rclone/fs/config/configmap"
|
|
"github.com/rclone/rclone/fs/config/configstruct"
|
|
"github.com/rclone/rclone/fs/config/obscure"
|
|
"github.com/rclone/rclone/fs/fshttp"
|
|
"github.com/rclone/rclone/fs/hash"
|
|
"github.com/rclone/rclone/lib/encoder"
|
|
|
|
httpclient "github.com/koofr/go-httpclient"
|
|
koofrclient "github.com/koofr/go-koofrclient"
|
|
)
|
|
|
|
// Register Fs with rclone
|
|
func init() {
|
|
fs.Register(&fs.RegInfo{
|
|
Name: "koofr",
|
|
Description: "Koofr",
|
|
NewFs: NewFs,
|
|
Options: []fs.Option{{
|
|
Name: "endpoint",
|
|
Help: "The Koofr API endpoint to use",
|
|
Default: "https://app.koofr.net",
|
|
Required: true,
|
|
Advanced: true,
|
|
}, {
|
|
Name: "mountid",
|
|
Help: "Mount ID of the mount to use. If omitted, the primary mount is used.",
|
|
Required: false,
|
|
Default: "",
|
|
Advanced: true,
|
|
}, {
|
|
Name: "setmtime",
|
|
Help: "Does the backend support setting modification time. Set this to false if you use a mount ID that points to a Dropbox or Amazon Drive backend.",
|
|
Default: true,
|
|
Required: true,
|
|
Advanced: true,
|
|
}, {
|
|
Name: "user",
|
|
Help: "Your Koofr user name",
|
|
Required: true,
|
|
}, {
|
|
Name: "password",
|
|
Help: "Your Koofr password for rclone (generate one at https://app.koofr.net/app/admin/preferences/password)",
|
|
IsPassword: true,
|
|
Required: true,
|
|
}, {
|
|
Name: config.ConfigEncoding,
|
|
Help: config.ConfigEncodingHelp,
|
|
Advanced: true,
|
|
// Encode invalid UTF-8 bytes as json doesn't handle them properly.
|
|
Default: (encoder.Display |
|
|
encoder.EncodeBackSlash |
|
|
encoder.EncodeInvalidUtf8),
|
|
}},
|
|
})
|
|
}
|
|
|
|
// Options represent the configuration of the Koofr backend
|
|
type Options struct {
|
|
Endpoint string `config:"endpoint"`
|
|
MountID string `config:"mountid"`
|
|
User string `config:"user"`
|
|
Password string `config:"password"`
|
|
SetMTime bool `config:"setmtime"`
|
|
Enc encoder.MultiEncoder `config:"encoding"`
|
|
}
|
|
|
|
// An Fs is a representation of a remote Koofr Fs
|
|
type Fs struct {
|
|
name string
|
|
mountID string
|
|
root string
|
|
opt Options
|
|
features *fs.Features
|
|
client *koofrclient.KoofrClient
|
|
}
|
|
|
|
// An Object on the remote Koofr Fs
|
|
type Object struct {
|
|
fs *Fs
|
|
remote string
|
|
info koofrclient.FileInfo
|
|
}
|
|
|
|
func base(pth string) string {
|
|
rv := path.Base(pth)
|
|
if rv == "" || rv == "." {
|
|
rv = "/"
|
|
}
|
|
return rv
|
|
}
|
|
|
|
func dir(pth string) string {
|
|
rv := path.Dir(pth)
|
|
if rv == "" || rv == "." {
|
|
rv = "/"
|
|
}
|
|
return rv
|
|
}
|
|
|
|
// String returns a string representation of the remote Object
|
|
func (o *Object) String() string {
|
|
return o.remote
|
|
}
|
|
|
|
// Remote returns the remote path of the Object, relative to Fs root
|
|
func (o *Object) Remote() string {
|
|
return o.remote
|
|
}
|
|
|
|
// ModTime returns the modification time of the Object
|
|
func (o *Object) ModTime(ctx context.Context) time.Time {
|
|
return time.Unix(o.info.Modified/1000, (o.info.Modified%1000)*1000*1000)
|
|
}
|
|
|
|
// Size return the size of the Object in bytes
|
|
func (o *Object) Size() int64 {
|
|
return o.info.Size
|
|
}
|
|
|
|
// Fs returns a reference to the Koofr Fs containing the Object
|
|
func (o *Object) Fs() fs.Info {
|
|
return o.fs
|
|
}
|
|
|
|
// Hash returns an MD5 hash of the Object
|
|
func (o *Object) Hash(ctx context.Context, typ hash.Type) (string, error) {
|
|
if typ == hash.MD5 {
|
|
return o.info.Hash, nil
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
// fullPath returns full path of the remote Object (including Fs root)
|
|
func (o *Object) fullPath() string {
|
|
return o.fs.fullPath(o.remote)
|
|
}
|
|
|
|
// Storable returns true if the Object is storable
|
|
func (o *Object) Storable() bool {
|
|
return true
|
|
}
|
|
|
|
// SetModTime is not supported
|
|
func (o *Object) SetModTime(ctx context.Context, mtime time.Time) error {
|
|
return fs.ErrorCantSetModTimeWithoutDelete
|
|
}
|
|
|
|
// Open opens the Object for reading
|
|
func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (io.ReadCloser, error) {
|
|
var sOff, eOff int64 = 0, -1
|
|
|
|
fs.FixRangeOption(options, o.Size())
|
|
for _, option := range options {
|
|
switch x := option.(type) {
|
|
case *fs.SeekOption:
|
|
sOff = x.Offset
|
|
case *fs.RangeOption:
|
|
sOff = x.Start
|
|
eOff = x.End
|
|
default:
|
|
if option.Mandatory() {
|
|
fs.Logf(o, "Unsupported mandatory option: %v", option)
|
|
}
|
|
}
|
|
}
|
|
if sOff == 0 && eOff < 0 {
|
|
return o.fs.client.FilesGet(o.fs.mountID, o.fullPath())
|
|
}
|
|
span := &koofrclient.FileSpan{
|
|
Start: sOff,
|
|
End: eOff,
|
|
}
|
|
return o.fs.client.FilesGetRange(o.fs.mountID, o.fullPath(), span)
|
|
}
|
|
|
|
// Update updates the Object contents
|
|
func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) error {
|
|
mtime := src.ModTime(ctx).UnixNano() / 1000 / 1000
|
|
putopts := &koofrclient.PutOptions{
|
|
ForceOverwrite: true,
|
|
NoRename: true,
|
|
OverwriteIgnoreNonExisting: true,
|
|
SetModified: &mtime,
|
|
}
|
|
fullPath := o.fullPath()
|
|
dirPath := dir(fullPath)
|
|
name := base(fullPath)
|
|
err := o.fs.mkdir(dirPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
info, err := o.fs.client.FilesPutWithOptions(o.fs.mountID, dirPath, name, in, putopts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
o.info = *info
|
|
return nil
|
|
}
|
|
|
|
// Remove deletes the remote Object
|
|
func (o *Object) Remove(ctx context.Context) error {
|
|
return o.fs.client.FilesDelete(o.fs.mountID, o.fullPath())
|
|
}
|
|
|
|
// Name returns the name of the Fs
|
|
func (f *Fs) Name() string {
|
|
return f.name
|
|
}
|
|
|
|
// Root returns the root path of the Fs
|
|
func (f *Fs) Root() string {
|
|
return f.root
|
|
}
|
|
|
|
// String returns a string representation of the Fs
|
|
func (f *Fs) String() string {
|
|
return "koofr:" + f.mountID + ":" + f.root
|
|
}
|
|
|
|
// Features returns the optional features supported by this Fs
|
|
func (f *Fs) Features() *fs.Features {
|
|
return f.features
|
|
}
|
|
|
|
// Precision denotes that setting modification times is not supported
|
|
func (f *Fs) Precision() time.Duration {
|
|
if !f.opt.SetMTime {
|
|
return fs.ModTimeNotSupported
|
|
}
|
|
return time.Millisecond
|
|
}
|
|
|
|
// Hashes returns a set of hashes are Provided by the Fs
|
|
func (f *Fs) Hashes() hash.Set {
|
|
return hash.Set(hash.MD5)
|
|
}
|
|
|
|
// fullPath constructs a full, absolute path from an Fs root relative path,
|
|
func (f *Fs) fullPath(part string) string {
|
|
return f.opt.Enc.FromStandardPath(path.Join("/", f.root, part))
|
|
}
|
|
|
|
// NewFs constructs a new filesystem given a root path and configuration options
|
|
func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (ff fs.Fs, err error) {
|
|
opt := new(Options)
|
|
err = configstruct.Set(m, opt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pass, err := obscure.Reveal(opt.Password)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
httpClient := httpclient.New()
|
|
httpClient.Client = fshttp.NewClient(ctx)
|
|
client := koofrclient.NewKoofrClientWithHTTPClient(opt.Endpoint, httpClient)
|
|
basicAuth := fmt.Sprintf("Basic %s",
|
|
base64.StdEncoding.EncodeToString([]byte(opt.User+":"+pass)))
|
|
client.HTTPClient.Headers.Set("Authorization", basicAuth)
|
|
mounts, err := client.Mounts()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
f := &Fs{
|
|
name: name,
|
|
root: root,
|
|
opt: *opt,
|
|
client: client,
|
|
}
|
|
f.features = (&fs.Features{
|
|
CaseInsensitive: true,
|
|
DuplicateFiles: false,
|
|
BucketBased: false,
|
|
CanHaveEmptyDirectories: true,
|
|
}).Fill(ctx, f)
|
|
for _, m := range mounts {
|
|
if opt.MountID != "" {
|
|
if m.Id == opt.MountID {
|
|
f.mountID = m.Id
|
|
break
|
|
}
|
|
} else if m.IsPrimary {
|
|
f.mountID = m.Id
|
|
break
|
|
}
|
|
}
|
|
if f.mountID == "" {
|
|
if opt.MountID == "" {
|
|
return nil, errors.New("Failed to find primary mount")
|
|
}
|
|
return nil, errors.New("Failed to find mount " + opt.MountID)
|
|
}
|
|
rootFile, err := f.client.FilesInfo(f.mountID, f.opt.Enc.FromStandardPath("/"+f.root))
|
|
if err == nil && rootFile.Type != "dir" {
|
|
f.root = dir(f.root)
|
|
err = fs.ErrorIsFile
|
|
} else {
|
|
err = nil
|
|
}
|
|
return f, err
|
|
}
|
|
|
|
// List returns a list of items in a directory
|
|
func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err error) {
|
|
files, err := f.client.FilesList(f.mountID, f.fullPath(dir))
|
|
if err != nil {
|
|
return nil, translateErrorsDir(err)
|
|
}
|
|
entries = make([]fs.DirEntry, len(files))
|
|
for i, file := range files {
|
|
remote := path.Join(dir, f.opt.Enc.ToStandardName(file.Name))
|
|
if file.Type == "dir" {
|
|
entries[i] = fs.NewDir(remote, time.Unix(0, 0))
|
|
} else {
|
|
entries[i] = &Object{
|
|
fs: f,
|
|
info: file,
|
|
remote: remote,
|
|
}
|
|
}
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
// NewObject creates a new remote Object for a given remote path
|
|
func (f *Fs) NewObject(ctx context.Context, remote string) (obj fs.Object, err error) {
|
|
info, err := f.client.FilesInfo(f.mountID, f.fullPath(remote))
|
|
if err != nil {
|
|
return nil, translateErrorsObject(err)
|
|
}
|
|
if info.Type == "dir" {
|
|
return nil, fs.ErrorNotAFile
|
|
}
|
|
return &Object{
|
|
fs: f,
|
|
info: info,
|
|
remote: remote,
|
|
}, nil
|
|
}
|
|
|
|
// Put updates a remote Object
|
|
func (f *Fs) Put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (obj fs.Object, err error) {
|
|
mtime := src.ModTime(ctx).UnixNano() / 1000 / 1000
|
|
putopts := &koofrclient.PutOptions{
|
|
ForceOverwrite: true,
|
|
NoRename: true,
|
|
OverwriteIgnoreNonExisting: true,
|
|
SetModified: &mtime,
|
|
}
|
|
fullPath := f.fullPath(src.Remote())
|
|
dirPath := dir(fullPath)
|
|
name := base(fullPath)
|
|
err = f.mkdir(dirPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
info, err := f.client.FilesPutWithOptions(f.mountID, dirPath, name, in, putopts)
|
|
if err != nil {
|
|
return nil, translateErrorsObject(err)
|
|
}
|
|
return &Object{
|
|
fs: f,
|
|
info: *info,
|
|
remote: src.Remote(),
|
|
}, nil
|
|
}
|
|
|
|
// PutStream updates a remote Object with a stream of unknown size
|
|
func (f *Fs) PutStream(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) {
|
|
return f.Put(ctx, in, src, options...)
|
|
}
|
|
|
|
// isBadRequest is a predicate which holds true iff the error returned was
|
|
// HTTP status 400
|
|
func isBadRequest(err error) bool {
|
|
switch err := err.(type) {
|
|
case httpclient.InvalidStatusError:
|
|
if err.Got == http.StatusBadRequest {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// translateErrorsDir translates koofr errors to rclone errors (for a dir
|
|
// operation)
|
|
func translateErrorsDir(err error) error {
|
|
switch err := err.(type) {
|
|
case httpclient.InvalidStatusError:
|
|
if err.Got == http.StatusNotFound {
|
|
return fs.ErrorDirNotFound
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
// translatesErrorsObject translates Koofr errors to rclone errors (for an object operation)
|
|
func translateErrorsObject(err error) error {
|
|
switch err := err.(type) {
|
|
case httpclient.InvalidStatusError:
|
|
if err.Got == http.StatusNotFound {
|
|
return fs.ErrorObjectNotFound
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
// mkdir creates a directory at the given remote path. Creates ancestors if
|
|
// necessary
|
|
func (f *Fs) mkdir(fullPath string) error {
|
|
if fullPath == "/" {
|
|
return nil
|
|
}
|
|
info, err := f.client.FilesInfo(f.mountID, fullPath)
|
|
if err == nil && info.Type == "dir" {
|
|
return nil
|
|
}
|
|
err = translateErrorsDir(err)
|
|
if err != nil && err != fs.ErrorDirNotFound {
|
|
return err
|
|
}
|
|
dirs := strings.Split(fullPath, "/")
|
|
parent := "/"
|
|
for _, part := range dirs {
|
|
if part == "" {
|
|
continue
|
|
}
|
|
info, err = f.client.FilesInfo(f.mountID, path.Join(parent, part))
|
|
if err != nil || info.Type != "dir" {
|
|
err = translateErrorsDir(err)
|
|
if err != nil && err != fs.ErrorDirNotFound {
|
|
return err
|
|
}
|
|
err = f.client.FilesNewFolder(f.mountID, parent, part)
|
|
if err != nil && !isBadRequest(err) {
|
|
return err
|
|
}
|
|
}
|
|
parent = path.Join(parent, part)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Mkdir creates a directory at the given remote path. Creates ancestors if
|
|
// necessary
|
|
func (f *Fs) Mkdir(ctx context.Context, dir string) error {
|
|
fullPath := f.fullPath(dir)
|
|
return f.mkdir(fullPath)
|
|
}
|
|
|
|
// Rmdir removes an (empty) directory at the given remote path
|
|
func (f *Fs) Rmdir(ctx context.Context, dir string) error {
|
|
files, err := f.client.FilesList(f.mountID, f.fullPath(dir))
|
|
if err != nil {
|
|
return translateErrorsDir(err)
|
|
}
|
|
if len(files) > 0 {
|
|
return fs.ErrorDirectoryNotEmpty
|
|
}
|
|
err = f.client.FilesDelete(f.mountID, f.fullPath(dir))
|
|
if err != nil {
|
|
return translateErrorsDir(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Copy copies a remote Object to the given path
|
|
func (f *Fs) Copy(ctx context.Context, src fs.Object, remote string) (fs.Object, error) {
|
|
dstFullPath := f.fullPath(remote)
|
|
dstDir := dir(dstFullPath)
|
|
err := f.mkdir(dstDir)
|
|
if err != nil {
|
|
return nil, fs.ErrorCantCopy
|
|
}
|
|
mtime := src.ModTime(ctx).UnixNano() / 1000 / 1000
|
|
err = f.client.FilesCopy((src.(*Object)).fs.mountID,
|
|
(src.(*Object)).fs.fullPath((src.(*Object)).remote),
|
|
f.mountID, dstFullPath, koofrclient.CopyOptions{SetModified: &mtime})
|
|
if err != nil {
|
|
return nil, fs.ErrorCantCopy
|
|
}
|
|
return f.NewObject(ctx, remote)
|
|
}
|
|
|
|
// Move moves a remote Object to the given path
|
|
func (f *Fs) Move(ctx context.Context, src fs.Object, remote string) (fs.Object, error) {
|
|
srcObj := src.(*Object)
|
|
dstFullPath := f.fullPath(remote)
|
|
dstDir := dir(dstFullPath)
|
|
err := f.mkdir(dstDir)
|
|
if err != nil {
|
|
return nil, fs.ErrorCantMove
|
|
}
|
|
err = f.client.FilesMove(srcObj.fs.mountID,
|
|
srcObj.fs.fullPath(srcObj.remote), f.mountID, dstFullPath)
|
|
if err != nil {
|
|
return nil, fs.ErrorCantMove
|
|
}
|
|
return f.NewObject(ctx, remote)
|
|
}
|
|
|
|
// DirMove moves a remote directory to the given path
|
|
func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string) error {
|
|
srcFs := src.(*Fs)
|
|
srcFullPath := srcFs.fullPath(srcRemote)
|
|
dstFullPath := f.fullPath(dstRemote)
|
|
if srcFs.mountID == f.mountID && srcFullPath == dstFullPath {
|
|
return fs.ErrorDirExists
|
|
}
|
|
dstDir := dir(dstFullPath)
|
|
err := f.mkdir(dstDir)
|
|
if err != nil {
|
|
return fs.ErrorCantDirMove
|
|
}
|
|
err = f.client.FilesMove(srcFs.mountID, srcFullPath, f.mountID, dstFullPath)
|
|
if err != nil {
|
|
return fs.ErrorCantDirMove
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// About reports space usage (with a MiB precision)
|
|
func (f *Fs) About(ctx context.Context) (*fs.Usage, error) {
|
|
mount, err := f.client.MountsDetails(f.mountID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &fs.Usage{
|
|
Total: fs.NewUsageValue(mount.SpaceTotal * 1024 * 1024),
|
|
Used: fs.NewUsageValue(mount.SpaceUsed * 1024 * 1024),
|
|
Trashed: nil,
|
|
Other: nil,
|
|
Free: fs.NewUsageValue((mount.SpaceTotal - mount.SpaceUsed) * 1024 * 1024),
|
|
Objects: nil,
|
|
}, nil
|
|
}
|
|
|
|
// Purge purges the complete Fs
|
|
func (f *Fs) Purge(ctx context.Context) error {
|
|
err := translateErrorsDir(f.client.FilesDelete(f.mountID, f.fullPath("")))
|
|
return err
|
|
}
|
|
|
|
// linkCreate is a Koofr API request for creating a public link
|
|
type linkCreate struct {
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
// link is a Koofr API response to creating a public link
|
|
type link struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
Counter int64 `json:"counter"`
|
|
URL string `json:"url"`
|
|
ShortURL string `json:"shortUrl"`
|
|
Hash string `json:"hash"`
|
|
Host string `json:"host"`
|
|
HasPassword bool `json:"hasPassword"`
|
|
Password string `json:"password"`
|
|
ValidFrom int64 `json:"validFrom"`
|
|
ValidTo int64 `json:"validTo"`
|
|
PasswordRequired bool `json:"passwordRequired"`
|
|
}
|
|
|
|
// createLink makes a Koofr API call to create a public link
|
|
func createLink(c *koofrclient.KoofrClient, mountID string, path string) (*link, error) {
|
|
linkCreate := linkCreate{
|
|
Path: path,
|
|
}
|
|
linkData := link{}
|
|
|
|
request := httpclient.RequestData{
|
|
Method: "POST",
|
|
Path: "/api/v2/mounts/" + mountID + "/links",
|
|
ExpectedStatus: []int{http.StatusOK, http.StatusCreated},
|
|
ReqEncoding: httpclient.EncodingJSON,
|
|
ReqValue: linkCreate,
|
|
RespEncoding: httpclient.EncodingJSON,
|
|
RespValue: &linkData,
|
|
}
|
|
|
|
_, err := c.Request(&request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &linkData, nil
|
|
}
|
|
|
|
// PublicLink creates a public link to the remote path
|
|
func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (string, error) {
|
|
linkData, err := createLink(f.client, f.mountID, f.fullPath(remote))
|
|
if err != nil {
|
|
return "", translateErrorsDir(err)
|
|
}
|
|
return linkData.ShortURL, nil
|
|
}
|