3b3625037c
This commit adds support for pixeldrain's experimental filesystem API.
397 lines
11 KiB
Go
397 lines
11 KiB
Go
package pixeldrain
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rclone/rclone/fs"
|
|
"github.com/rclone/rclone/fs/fserrors"
|
|
"github.com/rclone/rclone/lib/rest"
|
|
)
|
|
|
|
// FilesystemPath is the object which is returned from the pixeldrain API when
|
|
// running the stat command on a path. It includes the node information for all
|
|
// the members of the path and for all the children of the requested directory.
|
|
type FilesystemPath struct {
|
|
Path []FilesystemNode `json:"path"`
|
|
BaseIndex int `json:"base_index"`
|
|
Children []FilesystemNode `json:"children"`
|
|
}
|
|
|
|
// Base returns the base node of the path, this is the node that the path points
|
|
// to
|
|
func (fsp *FilesystemPath) Base() FilesystemNode {
|
|
return fsp.Path[fsp.BaseIndex]
|
|
}
|
|
|
|
// FilesystemNode is a single node in the pixeldrain filesystem. Usually part of
|
|
// a Path or Children slice. The Node is also returned as response from update
|
|
// commands, if requested
|
|
type FilesystemNode struct {
|
|
Type string `json:"type"`
|
|
Path string `json:"path"`
|
|
Name string `json:"name"`
|
|
Created time.Time `json:"created"`
|
|
Modified time.Time `json:"modified"`
|
|
ModeOctal string `json:"mode_octal"`
|
|
|
|
// File params
|
|
FileSize int64 `json:"file_size"`
|
|
FileType string `json:"file_type"`
|
|
SHA256Sum string `json:"sha256_sum"`
|
|
|
|
// ID is only filled in when the file/directory is publicly shared
|
|
ID string `json:"id,omitempty"`
|
|
}
|
|
|
|
// ChangeLog is a log of changes that happened in a filesystem. Changes returned
|
|
// from the API are on chronological order from old to new. A change log can be
|
|
// requested for any directory or file, but change logging needs to be enabled
|
|
// with the update API before any log entries will be made. Changes are logged
|
|
// for 24 hours after logging was enabled. Each time a change log is requested
|
|
// the timer is reset to 24 hours.
|
|
type ChangeLog []ChangeLogEntry
|
|
|
|
// ChangeLogEntry is a single entry in a directory's change log. It contains the
|
|
// time at which the change occurred. The path relative to the requested
|
|
// directory and the action that was performend (update, move or delete). In
|
|
// case of a move operation the new path of the file is stored in the path_new
|
|
// field
|
|
type ChangeLogEntry struct {
|
|
Time time.Time `json:"time"`
|
|
Path string `json:"path"`
|
|
PathNew string `json:"path_new"`
|
|
Action string `json:"action"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
// UserInfo contains information about the logged in user
|
|
type UserInfo struct {
|
|
Username string `json:"username"`
|
|
Subscription SubscriptionType `json:"subscription"`
|
|
StorageSpaceUsed int64 `json:"storage_space_used"`
|
|
}
|
|
|
|
// SubscriptionType contains information about a subscription type. It's not the
|
|
// active subscription itself, only the properties of the subscription. Like the
|
|
// perks and cost
|
|
type SubscriptionType struct {
|
|
Name string `json:"name"`
|
|
StorageSpace int64 `json:"storage_space"`
|
|
}
|
|
|
|
// APIError is the error type returned by the pixeldrain API
|
|
type APIError struct {
|
|
StatusCode string `json:"value"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
func (e APIError) Error() string { return e.StatusCode }
|
|
|
|
// Generalized errors which are caught in our own handlers and translated to
|
|
// more specific errors from the fs package.
|
|
var (
|
|
errNotFound = errors.New("pd api: path not found")
|
|
errExists = errors.New("pd api: node already exists")
|
|
errAuthenticationFailed = errors.New("pd api: authentication failed")
|
|
)
|
|
|
|
func apiErrorHandler(resp *http.Response) (err error) {
|
|
var e APIError
|
|
if err = json.NewDecoder(resp.Body).Decode(&e); err != nil {
|
|
return fmt.Errorf("failed to parse error json: %w", err)
|
|
}
|
|
|
|
// We close the body here so that the API handlers can be sure that the
|
|
// response body is not still open when an error was returned
|
|
if err = resp.Body.Close(); err != nil {
|
|
return fmt.Errorf("failed to close resp body: %w", err)
|
|
}
|
|
|
|
if e.StatusCode == "path_not_found" {
|
|
return errNotFound
|
|
} else if e.StatusCode == "directory_not_empty" {
|
|
return fs.ErrorDirectoryNotEmpty
|
|
} else if e.StatusCode == "node_already_exists" {
|
|
return errExists
|
|
} else if e.StatusCode == "authentication_failed" {
|
|
return errAuthenticationFailed
|
|
} else if e.StatusCode == "permission_denied" {
|
|
return fs.ErrorPermissionDenied
|
|
}
|
|
|
|
return e
|
|
}
|
|
|
|
var retryErrorCodes = []int{
|
|
429, // Too Many Requests.
|
|
500, // Internal Server Error
|
|
502, // Bad Gateway
|
|
503, // Service Unavailable
|
|
504, // Gateway Timeout
|
|
}
|
|
|
|
// shouldRetry returns a boolean as to whether this resp and err deserve to be
|
|
// retried. It returns the err as a convenience so it can be used as the return
|
|
// value in the pacer function
|
|
func shouldRetry(ctx context.Context, resp *http.Response, err error) (bool, error) {
|
|
if fserrors.ContextError(ctx, &err) {
|
|
return false, err
|
|
}
|
|
return fserrors.ShouldRetry(err) || fserrors.ShouldRetryHTTP(resp, retryErrorCodes), err
|
|
}
|
|
|
|
// paramsFromMetadata turns the fs.Metadata into instructions the pixeldrain API
|
|
// can understand.
|
|
func paramsFromMetadata(meta fs.Metadata) (params url.Values) {
|
|
params = make(url.Values)
|
|
|
|
if modified, ok := meta["mtime"]; ok {
|
|
params.Set("modified", modified)
|
|
}
|
|
if created, ok := meta["btime"]; ok {
|
|
params.Set("created", created)
|
|
}
|
|
if mode, ok := meta["mode"]; ok {
|
|
params.Set("mode", mode)
|
|
}
|
|
if shared, ok := meta["shared"]; ok {
|
|
params.Set("shared", shared)
|
|
}
|
|
if loggingEnabled, ok := meta["logging_enabled"]; ok {
|
|
params.Set("logging_enabled", loggingEnabled)
|
|
}
|
|
|
|
return params
|
|
}
|
|
|
|
// nodeToObject converts a single FilesystemNode API response to an object. The
|
|
// node is usually a single element from a directory listing
|
|
func (f *Fs) nodeToObject(node FilesystemNode) (o *Object) {
|
|
// Trim the path prefix. The path prefix is hidden from rclone during all
|
|
// operations. Saving it here would confuse rclone a lot. So instead we
|
|
// strip it here and add it back for every API request we need to perform
|
|
node.Path = strings.TrimPrefix(node.Path, f.pathPrefix)
|
|
return &Object{fs: f, base: node}
|
|
}
|
|
|
|
func (f *Fs) nodeToDirectory(node FilesystemNode) fs.DirEntry {
|
|
return fs.NewDir(strings.TrimPrefix(node.Path, f.pathPrefix), node.Modified).SetID(node.ID)
|
|
}
|
|
|
|
func (f *Fs) escapePath(p string) (out string) {
|
|
// Add the path prefix, encode all the parts and combine them together
|
|
var parts = strings.Split(f.pathPrefix+p, "/")
|
|
for i := range parts {
|
|
parts[i] = url.PathEscape(parts[i])
|
|
}
|
|
return strings.Join(parts, "/")
|
|
}
|
|
|
|
func (f *Fs) put(
|
|
ctx context.Context,
|
|
path string,
|
|
body io.Reader,
|
|
meta fs.Metadata,
|
|
options []fs.OpenOption,
|
|
) (node FilesystemNode, err error) {
|
|
var params = paramsFromMetadata(meta)
|
|
|
|
// Tell the server to automatically create parent directories if they don't
|
|
// exist yet
|
|
params.Set("make_parents", "true")
|
|
|
|
return node, f.pacer.Call(func() (bool, error) {
|
|
resp, err := f.srv.CallJSON(
|
|
ctx,
|
|
&rest.Opts{
|
|
Method: "PUT",
|
|
Path: f.escapePath(path),
|
|
Body: body,
|
|
Parameters: params,
|
|
Options: options,
|
|
},
|
|
nil,
|
|
&node,
|
|
)
|
|
return shouldRetry(ctx, resp, err)
|
|
})
|
|
}
|
|
|
|
func (f *Fs) read(ctx context.Context, path string, options []fs.OpenOption) (in io.ReadCloser, err error) {
|
|
var resp *http.Response
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err = f.srv.Call(ctx, &rest.Opts{
|
|
Method: "GET",
|
|
Path: f.escapePath(path),
|
|
Options: options,
|
|
})
|
|
return shouldRetry(ctx, resp, err)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return resp.Body, err
|
|
}
|
|
|
|
func (f *Fs) stat(ctx context.Context, path string) (fsp FilesystemPath, err error) {
|
|
return fsp, f.pacer.Call(func() (bool, error) {
|
|
resp, err := f.srv.CallJSON(
|
|
ctx,
|
|
&rest.Opts{
|
|
Method: "GET",
|
|
Path: f.escapePath(path),
|
|
// To receive node info from the pixeldrain API you need to add the
|
|
// ?stat query. Without it pixeldrain will return the file contents
|
|
// in the URL points to a file
|
|
Parameters: url.Values{"stat": []string{""}},
|
|
},
|
|
nil,
|
|
&fsp,
|
|
)
|
|
return shouldRetry(ctx, resp, err)
|
|
})
|
|
}
|
|
|
|
func (f *Fs) changeLog(ctx context.Context, start, end time.Time) (changeLog ChangeLog, err error) {
|
|
return changeLog, f.pacer.Call(func() (bool, error) {
|
|
resp, err := f.srv.CallJSON(
|
|
ctx,
|
|
&rest.Opts{
|
|
Method: "GET",
|
|
Path: f.escapePath(""),
|
|
Parameters: url.Values{
|
|
"change_log": []string{""},
|
|
"start": []string{start.Format(time.RFC3339Nano)},
|
|
"end": []string{end.Format(time.RFC3339Nano)},
|
|
},
|
|
},
|
|
nil,
|
|
&changeLog,
|
|
)
|
|
return shouldRetry(ctx, resp, err)
|
|
})
|
|
}
|
|
|
|
func (f *Fs) update(ctx context.Context, path string, fields fs.Metadata) (node FilesystemNode, err error) {
|
|
var params = paramsFromMetadata(fields)
|
|
params.Set("action", "update")
|
|
|
|
return node, f.pacer.Call(func() (bool, error) {
|
|
resp, err := f.srv.CallJSON(
|
|
ctx,
|
|
&rest.Opts{
|
|
Method: "POST",
|
|
Path: f.escapePath(path),
|
|
MultipartParams: params,
|
|
},
|
|
nil,
|
|
&node,
|
|
)
|
|
return shouldRetry(ctx, resp, err)
|
|
})
|
|
}
|
|
|
|
func (f *Fs) mkdir(ctx context.Context, dir string) (err error) {
|
|
return f.pacer.Call(func() (bool, error) {
|
|
resp, err := f.srv.CallJSON(
|
|
ctx,
|
|
&rest.Opts{
|
|
Method: "POST",
|
|
Path: f.escapePath(dir),
|
|
MultipartParams: url.Values{"action": []string{"mkdirall"}},
|
|
NoResponse: true,
|
|
},
|
|
nil,
|
|
nil,
|
|
)
|
|
return shouldRetry(ctx, resp, err)
|
|
})
|
|
}
|
|
|
|
var errIncompatibleSourceFS = errors.New("source filesystem is not the same as target")
|
|
|
|
// Renames a file on the server side. Can be used for both directories and files
|
|
func (f *Fs) rename(ctx context.Context, src fs.Fs, from, to string, meta fs.Metadata) (node FilesystemNode, err error) {
|
|
srcFs, ok := src.(*Fs)
|
|
if !ok {
|
|
// This is not a pixeldrain FS, can't move
|
|
return node, errIncompatibleSourceFS
|
|
} else if srcFs.opt.RootFolderID != f.opt.RootFolderID {
|
|
// Path is not in the same root dir, can't move
|
|
return node, errIncompatibleSourceFS
|
|
}
|
|
|
|
var params = paramsFromMetadata(meta)
|
|
params.Set("action", "rename")
|
|
|
|
// The target is always in our own filesystem so here we use our
|
|
// own pathPrefix
|
|
params.Set("target", f.pathPrefix+to)
|
|
|
|
// Create parent directories if the parent directory of the file
|
|
// does not exist yet
|
|
params.Set("make_parents", "true")
|
|
|
|
return node, f.pacer.Call(func() (bool, error) {
|
|
resp, err := f.srv.CallJSON(
|
|
ctx,
|
|
&rest.Opts{
|
|
Method: "POST",
|
|
// Important: We use the source FS path prefix here
|
|
Path: srcFs.escapePath(from),
|
|
MultipartParams: params,
|
|
},
|
|
nil,
|
|
&node,
|
|
)
|
|
return shouldRetry(ctx, resp, err)
|
|
})
|
|
}
|
|
|
|
func (f *Fs) delete(ctx context.Context, path string, recursive bool) (err error) {
|
|
var params url.Values
|
|
if recursive {
|
|
// Tell the server to recursively delete all child files
|
|
params = url.Values{"recursive": []string{"true"}}
|
|
}
|
|
|
|
return f.pacer.Call(func() (bool, error) {
|
|
resp, err := f.srv.CallJSON(
|
|
ctx,
|
|
&rest.Opts{
|
|
Method: "DELETE",
|
|
Path: f.escapePath(path),
|
|
Parameters: params,
|
|
NoResponse: true,
|
|
},
|
|
nil, nil,
|
|
)
|
|
return shouldRetry(ctx, resp, err)
|
|
})
|
|
}
|
|
|
|
func (f *Fs) userInfo(ctx context.Context) (user UserInfo, err error) {
|
|
return user, f.pacer.Call(func() (bool, error) {
|
|
resp, err := f.srv.CallJSON(
|
|
ctx,
|
|
&rest.Opts{
|
|
Method: "GET",
|
|
// The default RootURL points at the filesystem endpoint. We can't
|
|
// use that to request user information. So here we override it to
|
|
// the user endpoint
|
|
RootURL: f.opt.APIURL + "/user",
|
|
},
|
|
nil,
|
|
&user,
|
|
)
|
|
return shouldRetry(ctx, resp, err)
|
|
})
|
|
}
|