mirror of
https://github.com/rclone/rclone.git
synced 2025-12-11 22:14:05 +01:00
Some checks failed
build / windows (push) Has been cancelled
build / other_os (push) Has been cancelled
build / mac_amd64 (push) Has been cancelled
build / mac_arm64 (push) Has been cancelled
build / linux (push) Has been cancelled
build / go1.24 (push) Has been cancelled
build / linux_386 (push) Has been cancelled
build / lint (push) Has been cancelled
build / android-all (push) Has been cancelled
Build & Push Docker Images / Build Docker Image for linux/386 (push) Has been cancelled
Build & Push Docker Images / Build Docker Image for linux/amd64 (push) Has been cancelled
Build & Push Docker Images / Build Docker Image for linux/arm/v6 (push) Has been cancelled
Build & Push Docker Images / Build Docker Image for linux/arm/v7 (push) Has been cancelled
Build & Push Docker Images / Build Docker Image for linux/arm64 (push) Has been cancelled
Build & Push Docker Images / Merge & Push Final Docker Image (push) Has been cancelled
1018 lines
28 KiB
Go
1018 lines
28 KiB
Go
// Package shade provides an interface to the Shade storage system.
|
||
package shade
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"path"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/rclone/rclone/backend/shade/api"
|
||
"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/fshttp"
|
||
"github.com/rclone/rclone/fs/hash"
|
||
"github.com/rclone/rclone/fs/object"
|
||
"github.com/rclone/rclone/lib/encoder"
|
||
"github.com/rclone/rclone/lib/pacer"
|
||
"github.com/rclone/rclone/lib/rest"
|
||
)
|
||
|
||
const (
|
||
defaultEndpoint = "https://fs.shade.inc" // Default local development endpoint
|
||
apiEndpoint = "https://api.shade.inc" // API endpoint for getting tokens
|
||
minSleep = 10 * time.Millisecond // Minimum sleep time for the pacer
|
||
maxSleep = 5 * time.Minute // Maximum sleep time for the pacer
|
||
decayConstant = 1 // Bigger for slower decay, exponential
|
||
defaultChunkSize = int64(64 * 1024 * 1024) // Default chunk size (64MB)
|
||
minChunkSize = int64(5 * 1024 * 1024) // Minimum chunk size (5MB) - S3 requirement
|
||
maxChunkSize = int64(5 * 1024 * 1024 * 1024) // Maximum chunk size (5GB)
|
||
maxUploadParts = 10000 // maximum allowed number of parts in a multipart upload
|
||
)
|
||
|
||
// Register with Fs
|
||
func init() {
|
||
fs.Register(&fs.RegInfo{
|
||
Name: "shade",
|
||
Description: "Shade FS",
|
||
NewFs: NewFS,
|
||
Options: []fs.Option{{
|
||
Name: "drive_id",
|
||
Help: "The ID of your drive, see this in the drive settings. Individual rclone configs must be made per drive.",
|
||
Required: true,
|
||
Sensitive: false,
|
||
}, {
|
||
Name: "api_key",
|
||
Help: "An API key for your account.",
|
||
Required: true,
|
||
Sensitive: true,
|
||
}, {
|
||
Name: "endpoint",
|
||
Help: "Endpoint for the service.\n\nLeave blank normally.",
|
||
Advanced: true,
|
||
}, {
|
||
Name: "chunk_size",
|
||
Help: "Chunk size to use for uploading.\n\nAny files larger than this will be uploaded in chunks of this size.\n\nNote that this is stored in memory per transfer, so increasing it will\nincrease memory usage.\n\nMinimum is 5MB, maximum is 5GB.",
|
||
Default: fs.SizeSuffix(defaultChunkSize),
|
||
Advanced: true,
|
||
}, {
|
||
Name: "upload_concurrency",
|
||
Help: `Concurrency for multipart uploads and copies. This is the number of chunks of the same file that are uploaded concurrently for multipart uploads and copies.`,
|
||
Default: 4,
|
||
Advanced: true,
|
||
}, {
|
||
Name: "max_upload_parts",
|
||
Help: "Maximum amount of parts in a multipart upload.",
|
||
Default: maxUploadParts,
|
||
Advanced: true,
|
||
}, {
|
||
Name: "token",
|
||
Help: "JWT Token for performing Shade FS operations. Don't set this value - rclone will set it automatically",
|
||
Default: "",
|
||
Advanced: true,
|
||
}, {
|
||
Name: "token_expiry",
|
||
Help: "JWT Token Expiration time. Don't set this value - rclone will set it automatically",
|
||
Default: "",
|
||
Advanced: true,
|
||
}, {
|
||
Name: config.ConfigEncoding,
|
||
Help: config.ConfigEncodingHelp,
|
||
Advanced: true,
|
||
Default: encoder.Display |
|
||
encoder.EncodeBackSlash |
|
||
encoder.EncodeInvalidUtf8,
|
||
}},
|
||
})
|
||
}
|
||
|
||
// refreshJWTToken retrieves or refreshes the ShadeFS token
|
||
func (f *Fs) refreshJWTToken(ctx context.Context) (string, error) {
|
||
f.tokenMu.Lock()
|
||
defer f.tokenMu.Unlock()
|
||
// Return existing token if it's still valid
|
||
checkTime := f.tokenExp.Add(-2 * time.Minute)
|
||
//If the token expires in less than two minutes, just get a new one
|
||
if f.token != "" && time.Now().Before(checkTime) {
|
||
return f.token, nil
|
||
}
|
||
|
||
// Token has expired or doesn't exist, get a new one
|
||
opts := rest.Opts{
|
||
Method: "GET",
|
||
RootURL: apiEndpoint,
|
||
Path: fmt.Sprintf("/workspaces/drives/%s/shade-fs-token", f.drive),
|
||
ExtraHeaders: map[string]string{
|
||
"Authorization": f.opt.APIKey,
|
||
},
|
||
}
|
||
|
||
var err error
|
||
var tokenStr string
|
||
|
||
err = f.pacer.Call(func() (bool, error) {
|
||
res, err := f.apiSrv.Call(ctx, &opts)
|
||
if err != nil {
|
||
fs.Debugf(f, "Token request failed: %v", err)
|
||
return false, err
|
||
}
|
||
|
||
defer fs.CheckClose(res.Body, &err)
|
||
|
||
if res.StatusCode != http.StatusOK {
|
||
fs.Debugf(f, "Token request failed with code: %d", res.StatusCode)
|
||
return res.StatusCode == http.StatusTooManyRequests, fmt.Errorf("failed to get ShadeFS token, status: %d", res.StatusCode)
|
||
}
|
||
|
||
// Read token directly as plain text
|
||
tokenBytes, err := io.ReadAll(res.Body)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
|
||
tokenStr = strings.TrimSpace(string(tokenBytes))
|
||
return false, nil
|
||
})
|
||
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
if tokenStr == "" {
|
||
return "", fmt.Errorf("empty token received from server")
|
||
}
|
||
|
||
parts := strings.Split(tokenStr, ".")
|
||
if len(parts) < 2 {
|
||
return "", fmt.Errorf("invalid token received from server")
|
||
}
|
||
// Decode the payload (2nd part of the token)
|
||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||
if err != nil {
|
||
return "", fmt.Errorf("invalid token received from server")
|
||
}
|
||
var claims map[string]interface{}
|
||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||
return "", err
|
||
}
|
||
var exp int64
|
||
// Extract exp/
|
||
if v, ok := claims["exp"].(float64); ok {
|
||
exp = int64(v)
|
||
}
|
||
|
||
f.token = tokenStr
|
||
f.tokenExp = time.Unix(exp, 0)
|
||
|
||
f.m.Set("token", f.token)
|
||
f.m.Set("token_expiry", f.tokenExp.Format(time.RFC3339))
|
||
|
||
return f.token, nil
|
||
}
|
||
|
||
func (f *Fs) callAPI(ctx context.Context, method, path string, response interface{}) (*http.Response, error) {
|
||
token, err := f.refreshJWTToken(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
opts := rest.Opts{
|
||
Method: method,
|
||
Path: path,
|
||
RootURL: f.endpoint,
|
||
ExtraHeaders: map[string]string{
|
||
"Authorization": "Bearer " + token,
|
||
},
|
||
}
|
||
var res *http.Response
|
||
err = f.pacer.Call(func() (bool, error) {
|
||
if response != nil {
|
||
res, err = f.srv.CallJSON(ctx, &opts, nil, response)
|
||
} else {
|
||
res, err = f.srv.Call(ctx, &opts)
|
||
}
|
||
if err != nil {
|
||
return res != nil && res.StatusCode == http.StatusTooManyRequests, err
|
||
}
|
||
return false, nil
|
||
})
|
||
return res, err
|
||
}
|
||
|
||
// Options defines the configuration for this backend
|
||
type Options struct {
|
||
Drive string `config:"drive_id"`
|
||
APIKey string `config:"api_key"`
|
||
Endpoint string `config:"endpoint"`
|
||
ChunkSize fs.SizeSuffix `config:"chunk_size"`
|
||
MaxUploadParts int `config:"max_upload_parts"`
|
||
Concurrency int `config:"upload_concurrency"`
|
||
Token string `config:"token"`
|
||
TokenExpiry string `config:"token_expiry"`
|
||
Encoding encoder.MultiEncoder
|
||
}
|
||
|
||
// Fs represents a shade remote
|
||
type Fs struct {
|
||
name string // name of this remote
|
||
root string // the path we are working on
|
||
opt Options // parsed options
|
||
features *fs.Features // optional features
|
||
srv *rest.Client // REST client for ShadeFS API
|
||
apiSrv *rest.Client // REST client for Shade API
|
||
endpoint string // endpoint for ShadeFS
|
||
drive string // drive ID
|
||
pacer *fs.Pacer // pacer for API calls
|
||
token string // ShadeFS token
|
||
tokenExp time.Time // Token expiration time
|
||
tokenMu sync.Mutex
|
||
m configmap.Mapper //Config Mapper to store tokens for future use
|
||
recursive bool
|
||
createdDirs map[string]bool // Cache of directories we've created
|
||
createdDirMu sync.RWMutex // Mutex for createdDirs map
|
||
}
|
||
|
||
// Object describes a ShadeFS object
|
||
type Object struct {
|
||
fs *Fs // what this object is part of
|
||
remote string // The remote path
|
||
mtime int64 // Modified time
|
||
size int64 // Size of the object
|
||
original string //Presigned download link
|
||
}
|
||
|
||
// Directory describes a ShadeFS directory
|
||
type Directory struct {
|
||
fs *Fs // Reference to the filesystem
|
||
remote string // Path to the directory
|
||
mtime int64 // Modification time
|
||
size int64 // Size (typically 0 for directories)
|
||
}
|
||
|
||
// 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 returns a description of the FS
|
||
func (f *Fs) String() string {
|
||
return fmt.Sprintf("Shade drive %s path %s", f.opt.Drive, f.root)
|
||
}
|
||
|
||
// Precision returns the precision of the ModTimes
|
||
func (f *Fs) Precision() time.Duration {
|
||
return fs.ModTimeNotSupported
|
||
}
|
||
|
||
// 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(ctx context.Context, src fs.Object, remote string) (fs.Object, error) {
|
||
srcObj, ok := src.(*Object)
|
||
if !ok {
|
||
fs.Debugf(src, "Can't move - not same remote type")
|
||
return nil, fs.ErrorCantMove
|
||
}
|
||
|
||
token, err := f.refreshJWTToken(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
//Need to make sure destination exists
|
||
err = f.ensureParentDirectories(ctx, remote)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Create temporary object
|
||
o := &Object{
|
||
fs: f,
|
||
remote: remote,
|
||
}
|
||
fromFullPath := path.Join(src.Fs().Root(), srcObj.remote)
|
||
toFullPath := path.Join(f.root, remote)
|
||
|
||
// Build query parameters
|
||
params := url.Values{}
|
||
params.Set("path", remote)
|
||
params.Set("from", fromFullPath)
|
||
params.Set("to", toFullPath)
|
||
|
||
opts := rest.Opts{
|
||
Method: "POST",
|
||
Path: fmt.Sprintf("/%s/fs/move?%s", f.drive, params.Encode()),
|
||
ExtraHeaders: map[string]string{
|
||
"Authorization": "Bearer " + token,
|
||
},
|
||
}
|
||
|
||
err = o.fs.pacer.Call(func() (bool, error) {
|
||
resp, err := f.srv.Call(ctx, &opts)
|
||
|
||
if err != nil && resp.StatusCode == http.StatusBadRequest {
|
||
fs.Debugf(f, "Bad token from server: %v", token)
|
||
}
|
||
|
||
return resp != nil && resp.StatusCode == http.StatusTooManyRequests, err
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return o, nil
|
||
}
|
||
|
||
// 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(ctx context.Context, 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
|
||
}
|
||
//Need to check if destination exists
|
||
fullPath := f.buildFullPath(dstRemote)
|
||
var response api.ListDirResponse
|
||
res, _ := f.callAPI(ctx, "GET", fmt.Sprintf("/%s/fs/attr?path=%s", f.drive, fullPath), &response)
|
||
|
||
if res.StatusCode != http.StatusNotFound {
|
||
return fs.ErrorDirExists
|
||
}
|
||
|
||
err := f.ensureParentDirectories(ctx, dstRemote)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
o := &Object{
|
||
fs: srcFs,
|
||
remote: srcRemote,
|
||
}
|
||
|
||
_, err = f.Move(ctx, o, dstRemote)
|
||
return err
|
||
}
|
||
|
||
// Hashes returns the supported hash types
|
||
func (f *Fs) Hashes() hash.Set {
|
||
return hash.Set(hash.None)
|
||
}
|
||
|
||
// Features returns the optional features of this Fs
|
||
func (f *Fs) Features() *fs.Features {
|
||
return f.features
|
||
}
|
||
|
||
// NewFS constructs an FS from the path, container:path
|
||
func NewFS(ctx context.Context, 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
|
||
}
|
||
|
||
fs.Debugf(nil, "Creating new ShadeFS backend with drive: %s", opt.Drive)
|
||
|
||
f := &Fs{
|
||
name: name,
|
||
root: root,
|
||
opt: *opt,
|
||
drive: opt.Drive,
|
||
m: m,
|
||
srv: rest.NewClient(fshttp.NewClient(ctx)).SetRoot(defaultEndpoint),
|
||
apiSrv: rest.NewClient(fshttp.NewClient(ctx)),
|
||
pacer: fs.NewPacer(ctx, pacer.NewDefault(pacer.MinSleep(minSleep), pacer.MaxSleep(maxSleep), pacer.DecayConstant(decayConstant))),
|
||
recursive: true,
|
||
createdDirs: make(map[string]bool),
|
||
token: opt.Token,
|
||
}
|
||
|
||
f.features = &fs.Features{
|
||
// Initially set minimal features
|
||
// We'll expand this in a future iteration
|
||
CanHaveEmptyDirectories: true,
|
||
Move: f.Move,
|
||
DirMove: f.DirMove,
|
||
OpenChunkWriter: f.OpenChunkWriter,
|
||
}
|
||
|
||
if opt.TokenExpiry != "" {
|
||
tokenExpiry, err := time.Parse(time.RFC3339, opt.TokenExpiry)
|
||
if err != nil {
|
||
fs.Errorf(nil, "Failed to parse token_expiry option: %v", err)
|
||
} else {
|
||
f.tokenExp = tokenExpiry
|
||
}
|
||
}
|
||
|
||
// Set the endpoint
|
||
if opt.Endpoint == "" {
|
||
f.endpoint = defaultEndpoint
|
||
} else {
|
||
f.endpoint = opt.Endpoint
|
||
}
|
||
|
||
// Validate and set chunk size
|
||
if opt.ChunkSize == 0 {
|
||
opt.ChunkSize = fs.SizeSuffix(defaultChunkSize)
|
||
} else if opt.ChunkSize < fs.SizeSuffix(minChunkSize) {
|
||
return nil, fmt.Errorf("chunk_size %d is less than minimum %d", opt.ChunkSize, minChunkSize)
|
||
} else if opt.ChunkSize > fs.SizeSuffix(maxChunkSize) {
|
||
return nil, fmt.Errorf("chunk_size %d is greater than maximum %d", opt.ChunkSize, maxChunkSize)
|
||
}
|
||
|
||
// Ensure root doesn't have trailing slash
|
||
f.root = strings.Trim(f.root, "/")
|
||
|
||
// Check that we can log in by getting a token
|
||
_, err = f.refreshJWTToken(ctx)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to get ShadeFS token: %w", err)
|
||
}
|
||
|
||
var response api.ListDirResponse
|
||
_, _ = f.callAPI(ctx, "GET", fmt.Sprintf("/%s/fs/attr?path=%s", f.drive, url.QueryEscape(root)), &response)
|
||
|
||
if response.Type == "file" {
|
||
//Specified a single file path, not a directory.
|
||
f.root = filepath.Dir(f.root)
|
||
return f, fs.ErrorIsFile
|
||
}
|
||
return f, nil
|
||
}
|
||
|
||
// NewObject finds the Object at remote
|
||
func (f *Fs) NewObject(ctx context.Context, remote string) (fs.Object, error) {
|
||
|
||
fullPath := f.buildFullPath(remote)
|
||
|
||
var response api.ListDirResponse
|
||
res, err := f.callAPI(ctx, "GET", fmt.Sprintf("/%s/fs/attr?path=%s", f.drive, fullPath), &response)
|
||
|
||
if res != nil && res.StatusCode == http.StatusNotFound {
|
||
return nil, fs.ErrorObjectNotFound
|
||
}
|
||
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if res != nil && res.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("attr failed with status code: %d", res.StatusCode)
|
||
}
|
||
|
||
if response.Type == "tree" {
|
||
return nil, fs.ErrorIsDir
|
||
}
|
||
|
||
if response.Type != "file" {
|
||
return nil, fmt.Errorf("path is not a file: %s", remote)
|
||
}
|
||
|
||
return &Object{
|
||
fs: f,
|
||
remote: remote,
|
||
mtime: response.Mtime,
|
||
size: response.Size,
|
||
}, nil
|
||
}
|
||
|
||
// Put uploads a file
|
||
func (f *Fs) Put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) {
|
||
// Create temporary object
|
||
o := &Object{
|
||
fs: f,
|
||
remote: src.Remote(),
|
||
}
|
||
return o, o.Update(ctx, in, src, options...)
|
||
}
|
||
|
||
// List the objects and directories in dir into entries
|
||
func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err error) {
|
||
|
||
fullPath := f.buildFullPath(dir)
|
||
|
||
var response []api.ListDirResponse
|
||
res, err := f.callAPI(ctx, "GET", fmt.Sprintf("/%s/fs/listdir?path=%s", f.drive, fullPath), &response)
|
||
if err != nil {
|
||
fs.Debugf(f, "Error from List call: %v", err)
|
||
return nil, fs.ErrorDirNotFound
|
||
}
|
||
|
||
if res.StatusCode == http.StatusNotFound {
|
||
fs.Debugf(f, "Directory not found")
|
||
return nil, fs.ErrorDirNotFound
|
||
}
|
||
|
||
if res.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("listdir failed with status code: %d", res.StatusCode)
|
||
}
|
||
|
||
for _, r := range response {
|
||
if r.Draft {
|
||
continue
|
||
}
|
||
|
||
// Make path relative to f.root
|
||
entryPath := strings.TrimPrefix(r.Path, "/")
|
||
if f.root != "" {
|
||
if !strings.HasPrefix(entryPath, f.root) {
|
||
continue
|
||
}
|
||
entryPath = strings.TrimPrefix(strings.TrimPrefix(entryPath, f.root), "/")
|
||
}
|
||
|
||
if r.Type == "file" {
|
||
entries = append(entries, &Object{
|
||
fs: f,
|
||
remote: entryPath,
|
||
mtime: r.Mtime,
|
||
size: r.Size,
|
||
})
|
||
} else if r.Type == "tree" {
|
||
dirEntry := &Directory{
|
||
fs: f,
|
||
remote: entryPath,
|
||
mtime: r.Mtime,
|
||
size: r.Size, // Typically 0 for directories
|
||
}
|
||
entries = append(entries, dirEntry)
|
||
} else {
|
||
fs.Debugf(f, "Unknown entry type: %s for path: %s", r.Type, entryPath)
|
||
}
|
||
}
|
||
|
||
return entries, nil
|
||
}
|
||
|
||
// ensureParentDirectories creates all parent directories for a given path
|
||
func (f *Fs) ensureParentDirectories(ctx context.Context, remotePath string) error {
|
||
// Build the full path including root
|
||
fullPath := remotePath
|
||
if f.root != "" {
|
||
fullPath = path.Join(f.root, remotePath)
|
||
}
|
||
|
||
// Get the parent directory path
|
||
parentDir := path.Dir(fullPath)
|
||
|
||
// If parent is root, empty, or current dir, nothing to create
|
||
if parentDir == "" || parentDir == "." || parentDir == "/" {
|
||
return nil
|
||
}
|
||
|
||
// Ensure the full parent directory path exists
|
||
return f.ensureDirectoryPath(ctx, parentDir)
|
||
}
|
||
|
||
// ensureDirectoryPath creates all directories in a path
|
||
func (f *Fs) ensureDirectoryPath(ctx context.Context, dirPath string) error {
|
||
// Check cache first
|
||
f.createdDirMu.RLock()
|
||
if f.createdDirs[dirPath] {
|
||
f.createdDirMu.RUnlock()
|
||
return nil
|
||
}
|
||
f.createdDirMu.RUnlock()
|
||
|
||
// Build list of all directories that need to be created
|
||
var dirsToCreate []string
|
||
currentPath := dirPath
|
||
|
||
for currentPath != "" && currentPath != "." && currentPath != "/" {
|
||
// Check if this directory is already in cache
|
||
f.createdDirMu.RLock()
|
||
inCache := f.createdDirs[currentPath]
|
||
f.createdDirMu.RUnlock()
|
||
|
||
if !inCache {
|
||
dirsToCreate = append([]string{currentPath}, dirsToCreate...)
|
||
}
|
||
currentPath = path.Dir(currentPath)
|
||
}
|
||
|
||
// If all directories are cached, we're done
|
||
if len(dirsToCreate) == 0 {
|
||
return nil
|
||
}
|
||
|
||
// Create each directory in order
|
||
for _, dir := range dirsToCreate {
|
||
|
||
fullPath := url.QueryEscape(dir)
|
||
res, err := f.callAPI(ctx, "POST", fmt.Sprintf("/%s/fs/mkdir?path=%s", f.drive, fullPath), nil)
|
||
|
||
// If directory already exists, that's fine
|
||
if err == nil && res != nil {
|
||
if res.StatusCode == http.StatusConflict || res.StatusCode == http.StatusUnprocessableEntity {
|
||
f.createdDirMu.Lock()
|
||
f.createdDirs[dir] = true
|
||
f.createdDirMu.Unlock()
|
||
} else if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusCreated {
|
||
fs.Debugf(f, "Failed to create directory %s: status code %d", dir, res.StatusCode)
|
||
} else {
|
||
f.createdDirMu.Lock()
|
||
f.createdDirs[dir] = true
|
||
f.createdDirMu.Unlock()
|
||
}
|
||
|
||
fs.CheckClose(res.Body, &err)
|
||
} else if err != nil {
|
||
fs.Debugf(f, "Error creating directory %s: %v", dir, err)
|
||
// Continue anyway
|
||
continue
|
||
}
|
||
}
|
||
|
||
// Mark the full path as created in cache
|
||
f.createdDirMu.Lock()
|
||
f.createdDirs[dirPath] = true
|
||
f.createdDirMu.Unlock()
|
||
|
||
return nil
|
||
}
|
||
|
||
// Mkdir creates the container if it doesn't exist
|
||
func (f *Fs) Mkdir(ctx context.Context, dir string) error {
|
||
|
||
// Build the full path for the directory
|
||
fullPath := dir
|
||
if dir == "" {
|
||
// If dir is empty, we're creating the root directory
|
||
if f.root != "" && f.root != "/" && f.root != "." {
|
||
fullPath = f.root
|
||
} else {
|
||
// Nothing to create
|
||
return nil
|
||
}
|
||
} else if f.root != "" {
|
||
fullPath = path.Join(f.root, dir)
|
||
}
|
||
|
||
// Ensure all parent directories exist first
|
||
if err := f.ensureDirectoryPath(ctx, fullPath); err != nil {
|
||
return fmt.Errorf("failed to create directory path: %w", err)
|
||
}
|
||
|
||
// Add to cache
|
||
f.createdDirMu.Lock()
|
||
f.createdDirs[fullPath] = true
|
||
f.createdDirMu.Unlock()
|
||
|
||
return nil
|
||
}
|
||
|
||
// Rmdir deletes the root folder
|
||
//
|
||
// Returns an error if it isn't empty
|
||
func (f *Fs) Rmdir(ctx context.Context, dir string) error {
|
||
fullPath := f.buildFullPath(dir)
|
||
|
||
if fullPath == "" {
|
||
return errors.New("cannot delete root directory")
|
||
}
|
||
|
||
var response []api.ListDirResponse
|
||
res, err := f.callAPI(ctx, "GET", fmt.Sprintf("/%s/fs/listdir?path=%s", f.drive, fullPath), &response)
|
||
|
||
if res != nil && res.StatusCode != http.StatusOK {
|
||
return err
|
||
}
|
||
|
||
if len(response) > 0 {
|
||
return fs.ErrorDirectoryNotEmpty
|
||
}
|
||
|
||
// Use the delete endpoint which handles both files and directories
|
||
res, err = f.callAPI(ctx, "POST", fmt.Sprintf("/%s/fs/delete?path=%s", f.drive, fullPath), nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer fs.CheckClose(res.Body, &err)
|
||
|
||
if res.StatusCode == http.StatusNotFound {
|
||
return fs.ErrorDirNotFound
|
||
}
|
||
|
||
if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusCreated {
|
||
return fmt.Errorf("rmdir failed with status code: %d", res.StatusCode)
|
||
}
|
||
|
||
f.createdDirMu.Lock()
|
||
defer f.createdDirMu.Unlock()
|
||
unescapedPath, err := url.QueryUnescape(fullPath)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
f.createdDirs[unescapedPath] = false
|
||
|
||
return nil
|
||
}
|
||
|
||
// Attempts to construct the full path for an object query-escaped
|
||
func (f *Fs) buildFullPath(remote string) string {
|
||
if f.root == "" {
|
||
return url.QueryEscape(remote)
|
||
}
|
||
return url.QueryEscape(path.Join(f.root, remote))
|
||
}
|
||
|
||
// -------------------------------------------------
|
||
// Object implementation
|
||
// -------------------------------------------------
|
||
|
||
// Fs returns the parent Fs
|
||
func (o *Object) Fs() fs.Info {
|
||
return o.fs
|
||
}
|
||
|
||
// String returns a description of the Object
|
||
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 requested hash of the object content
|
||
func (o *Object) Hash(ctx context.Context, t hash.Type) (string, error) {
|
||
return "", hash.ErrUnsupported
|
||
}
|
||
|
||
// Size returns the size of the object
|
||
func (o *Object) Size() int64 {
|
||
return o.size
|
||
}
|
||
|
||
// ModTime returns the modification date of the object
|
||
func (o *Object) ModTime(context.Context) time.Time {
|
||
return time.Unix(0, o.mtime*int64(time.Millisecond))
|
||
}
|
||
|
||
// SetModTime sets the modification time of the object
|
||
func (o *Object) SetModTime(context.Context, time.Time) error {
|
||
// Not implemented for now
|
||
return fs.ErrorCantSetModTime
|
||
}
|
||
|
||
// Storable returns whether this object is storable
|
||
func (o *Object) Storable() bool {
|
||
return true
|
||
}
|
||
|
||
// Open an object for read
|
||
func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (io.ReadCloser, error) {
|
||
|
||
if o.size == 0 {
|
||
// Empty file: return an empty reader
|
||
return io.NopCloser(bytes.NewReader(nil)), nil
|
||
}
|
||
fs.FixRangeOption(options, o.size)
|
||
|
||
token, err := o.fs.refreshJWTToken(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
fullPath := o.fs.buildFullPath(o.remote)
|
||
// Construct the initial request URL
|
||
downloadURL := fmt.Sprintf("%s/%s/fs/download?path=%s", o.fs.endpoint, o.fs.drive, fullPath)
|
||
|
||
// Create HTTP request manually
|
||
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
||
if err != nil {
|
||
fs.Debugf(o.fs, "Failed to create request: %v", err)
|
||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||
}
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
|
||
// Use pacer to manage retries and rate limiting
|
||
var res *http.Response
|
||
err = o.fs.pacer.Call(func() (bool, error) {
|
||
|
||
if res != nil {
|
||
err = res.Body.Close()
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
}
|
||
|
||
client := http.Client{
|
||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||
return http.ErrUseLastResponse // Don't follow redirects
|
||
},
|
||
}
|
||
res, err = client.Do(req)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
return res.StatusCode == http.StatusTooManyRequests, nil
|
||
})
|
||
|
||
if err != nil {
|
||
return nil, fmt.Errorf("download request failed: %w", err)
|
||
}
|
||
if res == nil {
|
||
return nil, fmt.Errorf("no response received from initial request")
|
||
}
|
||
|
||
// Handle response based on status code
|
||
switch res.StatusCode {
|
||
case http.StatusOK:
|
||
return res.Body, nil
|
||
|
||
case http.StatusTemporaryRedirect:
|
||
// Read the presigned URL from the body
|
||
bodyBytes, err := io.ReadAll(res.Body)
|
||
fs.CheckClose(res.Body, &err) // Close body after reading
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to read redirect body: %w", err)
|
||
}
|
||
|
||
presignedURL := strings.TrimSpace(string(bodyBytes))
|
||
o.original = presignedURL //Save for later for hashing
|
||
|
||
client := rest.NewClient(fshttp.NewClient(ctx)).SetRoot(presignedURL)
|
||
var downloadRes *http.Response
|
||
opts := rest.Opts{
|
||
Method: "GET",
|
||
Path: "",
|
||
Options: options,
|
||
}
|
||
|
||
err = o.fs.pacer.Call(func() (bool, error) {
|
||
downloadRes, err = client.Call(ctx, &opts)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if downloadRes == nil {
|
||
return false, fmt.Errorf("failed to fetch presigned URL")
|
||
}
|
||
return downloadRes.StatusCode == http.StatusTooManyRequests, nil
|
||
})
|
||
|
||
if err != nil {
|
||
return nil, fmt.Errorf("presigned URL request failed: %w", err)
|
||
}
|
||
if downloadRes == nil {
|
||
return nil, fmt.Errorf("no response received from presigned URL request")
|
||
}
|
||
|
||
if downloadRes.StatusCode != http.StatusOK && downloadRes.StatusCode != http.StatusPartialContent {
|
||
body, _ := io.ReadAll(downloadRes.Body)
|
||
fs.CheckClose(downloadRes.Body, &err)
|
||
return nil, fmt.Errorf("presigned URL request failed with status %d: %q", downloadRes.StatusCode, string(body))
|
||
}
|
||
|
||
return downloadRes.Body, nil
|
||
|
||
default:
|
||
body, _ := io.ReadAll(res.Body)
|
||
fs.CheckClose(res.Body, &err)
|
||
return nil, fmt.Errorf("download failed with status %d: %q", res.StatusCode, string(body))
|
||
}
|
||
}
|
||
|
||
// Update in to the object with the modTime given of the given size
|
||
//
|
||
// When called from outside an Fs by rclone, src.Size() will always be >= 0.
|
||
// But for unknown-sized objects (indicated by src.Size() == -1), Upload should either
|
||
// return an error or update the object properly (rather than e.g. calling panic).
|
||
func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) error {
|
||
|
||
//Need to ensure parent directories exist before updating
|
||
err := o.fs.ensureParentDirectories(ctx, o.remote)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
//If the source remote is different from this object's remote, as in we're updating a file with some other file's data,
|
||
//need to construct a new object info in order to correctly upload to THIS object, not the src one
|
||
var srcInfo fs.ObjectInfo
|
||
if o.remote != src.Remote() {
|
||
srcInfo = object.NewStaticObjectInfo(o.remote, src.ModTime(ctx), src.Size(), true, nil, o.Fs())
|
||
} else {
|
||
srcInfo = src
|
||
}
|
||
|
||
return o.uploadMultipart(ctx, srcInfo, in, options...)
|
||
}
|
||
|
||
// Remove removes the object
|
||
func (o *Object) Remove(ctx context.Context) error {
|
||
|
||
fullPath := o.fs.buildFullPath(o.remote)
|
||
|
||
res, err := o.fs.callAPI(ctx, "POST", fmt.Sprintf("/%s/fs/delete?path=%s", o.fs.drive, fullPath), nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer fs.CheckClose(res.Body, &err) // Ensure body is closed
|
||
|
||
if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusCreated {
|
||
return fmt.Errorf("object removal failed with status code: %d", res.StatusCode)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// -------------------------------------------------
|
||
// Directory implementation
|
||
// -------------------------------------------------
|
||
|
||
// Remote returns the remote path
|
||
func (d *Directory) Remote() string {
|
||
return d.remote
|
||
}
|
||
|
||
// ModTime returns the modification time
|
||
func (d *Directory) ModTime(context.Context) time.Time {
|
||
return time.Unix(0, d.mtime*int64(time.Millisecond))
|
||
}
|
||
|
||
// Size returns the size (0 for directories)
|
||
func (d *Directory) Size() int64 {
|
||
return d.size
|
||
}
|
||
|
||
// Fs returns the filesystem info
|
||
func (d *Directory) Fs() fs.Info {
|
||
return d.fs
|
||
}
|
||
|
||
// Hash is unsupported for directories
|
||
func (d *Directory) Hash(context.Context, hash.Type) (string, error) {
|
||
return "", hash.ErrUnsupported
|
||
}
|
||
|
||
// SetModTime is unsupported for directories
|
||
func (d *Directory) SetModTime(context.Context, time.Time) error {
|
||
return fs.ErrorCantSetModTime
|
||
}
|
||
|
||
// Storable indicates directories aren’t storable as files
|
||
func (d *Directory) Storable() bool {
|
||
return false
|
||
}
|
||
|
||
// Open returns an error for directories
|
||
func (d *Directory) Open() (io.ReadCloser, error) {
|
||
return nil, fs.ErrorIsDir
|
||
}
|
||
|
||
// Items returns the number of items in the directory (-1 if unknown)
|
||
func (d *Directory) Items() int64 {
|
||
return -1 // Unknown
|
||
}
|
||
|
||
// ID returns the directory ID (empty if not applicable)
|
||
func (d *Directory) ID() string {
|
||
return ""
|
||
}
|
||
|
||
func (d *Directory) String() string {
|
||
return fmt.Sprintf("Directory: %s", d.remote)
|
||
}
|
||
|
||
var (
|
||
_ fs.Fs = &Fs{}
|
||
_ fs.Object = &Object{}
|
||
_ fs.Directory = &Directory{}
|
||
|
||
_ fs.Mover = &Fs{}
|
||
_ fs.DirMover = &Fs{}
|
||
)
|