// Package ulozto provides an interface to the Uloz.to storage system. package ulozto import ( "bytes" "context" "encoding/base64" "encoding/gob" "encoding/hex" "errors" "fmt" "io" "net/http" "net/url" "path" "strconv" "strings" "time" "github.com/rclone/rclone/backend/ulozto/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/config/obscure" "github.com/rclone/rclone/fs/fserrors" "github.com/rclone/rclone/fs/fshttp" "github.com/rclone/rclone/fs/hash" "github.com/rclone/rclone/lib/dircache" "github.com/rclone/rclone/lib/encoder" "github.com/rclone/rclone/lib/pacer" "github.com/rclone/rclone/lib/rest" ) // TODO Uloz.to only supports file names of 255 characters or less and silently truncates names that are longer. const ( minSleep = 10 * time.Millisecond maxSleep = 2 * time.Second decayConstant = 2 // bigger for slower decay, exponential rootURL = "https://apis.uloz.to" ) // Options defines the configuration for this backend type Options struct { AppToken string `config:"app_token"` Username string `config:"username"` Password string `config:"password"` RootFolderSlug string `config:"root_folder_slug"` Enc encoder.MultiEncoder `config:"encoding"` ListPageSize int `config:"list_page_size"` } func init() { fs.Register(&fs.RegInfo{ Name: "ulozto", Description: "Uloz.to", NewFs: NewFs, Options: []fs.Option{ { Name: "app_token", Default: "", Help: `The application token identifying the app. An app API key can be either found in the API doc https://uloz.to/upload-resumable-api-beta or obtained from customer service.`, Sensitive: true, }, { Name: "username", Default: "", Help: "The username of the principal to operate as.", Sensitive: true, }, { Name: "password", Default: "", Help: "The password for the user.", IsPassword: true, }, { Name: "root_folder_slug", Help: `If set, rclone will use this folder as the root folder for all operations. For example, if the slug identifies 'foo/bar/', 'ulozto:baz' is equivalent to 'ulozto:foo/bar/baz' without any root slug set.`, Default: "", Advanced: true, Sensitive: true, }, { Name: "list_page_size", Default: 500, Help: "The size of a single page for list commands. 1-500", Advanced: true, }, { Name: config.ConfigEncoding, Help: config.ConfigEncodingHelp, Advanced: true, Default: encoder.Display | encoder.EncodeInvalidUtf8 | encoder.EncodeBackSlash, }, }}) } // Fs represents a remote uloz.to storage 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 rest *rest.Client // REST client with authentication headers set, used to communicate with API endpoints cdn *rest.Client // REST client without authentication headers set, used for CDN payload upload/download dirCache *dircache.DirCache // Map of directory path to directory id pacer *fs.Pacer // pacer for API calls } // NewFs constructs a 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 } // Strip leading and trailing slashes, see https://github.com/rclone/rclone/issues/7796 for details. root = strings.Trim(root, "/") client := fshttp.NewClient(ctx) f := &Fs{ name: name, root: root, opt: *opt, cdn: rest.NewClient(client), rest: rest.NewClient(client).SetRoot(rootURL), pacer: fs.NewPacer(ctx, pacer.NewDefault(pacer.MinSleep(minSleep), pacer.MaxSleep(maxSleep), pacer.DecayConstant(decayConstant))), } f.features = (&fs.Features{ DuplicateFiles: true, CanHaveEmptyDirectories: true, }).Fill(ctx, f) f.rest.SetErrorHandler(errorHandler) f.rest.SetHeader("X-Auth-Token", f.opt.AppToken) auth, err := f.authenticate(ctx) if err != nil { return f, err } var rootSlug string if opt.RootFolderSlug == "" { rootSlug = auth.Session.User.RootFolderSlug } else { rootSlug = opt.RootFolderSlug } f.dirCache = dircache.New(root, rootSlug, f) err = f.dirCache.FindRoot(ctx, false) if errors.Is(err, fs.ErrorDirNotFound) { // All good, we'll create the folder later on. return f, nil } if errors.Is(err, fs.ErrorIsFile) { rootFolder, _ := dircache.SplitPath(root) f.root = rootFolder f.dirCache = dircache.New(rootFolder, rootSlug, f) err = f.dirCache.FindRoot(ctx, false) if err != nil { return f, err } return f, fs.ErrorIsFile } return f, err } // errorHandler parses a non 2xx error response into an error func errorHandler(resp *http.Response) error { // Decode error response errResponse := new(api.Error) err := rest.DecodeJSON(resp, &errResponse) if err != nil { fs.Debugf(nil, "Couldn't decode error response: %v", err) } if errResponse.StatusCode == 0 { errResponse.StatusCode = resp.StatusCode } return errResponse } // retryErrorCodes is a slice of error codes that we will retry var retryErrorCodes = []int{ 429, // Too Many Requests. 500, // Internal Server Error 502, // Bad Gateway 503, // Service Unavailable 504, // Gateway Timeout } // shouldRetry returns a boolean whether this resp and err should be retried. // It also returns the err for convenience. func (f *Fs) shouldRetry(ctx context.Context, resp *http.Response, err error, reauth bool) (bool, error) { if err == nil { return false, nil } if fserrors.ContextError(ctx, &err) { return false, err } var apiErr *api.Error if resp != nil && resp.StatusCode == 401 && errors.As(err, &apiErr) && apiErr.ErrorCode == 70001 { fs.Debugf(nil, "Should retry: %v", err) if reauth { _, err = f.authenticate(ctx) if err != nil { return false, err } } return true, err } return fserrors.ShouldRetry(err) || fserrors.ShouldRetryHTTP(resp, retryErrorCodes), err } func (f *Fs) authenticate(ctx context.Context) (response *api.AuthenticateResponse, err error) { // TODO only reauth once if the token expires // Remove the old user token f.rest.RemoveHeader("X-User-Token") opts := rest.Opts{ Method: "PUT", Path: "/v6/session", } clearPassword, err := obscure.Reveal(f.opt.Password) if err != nil { return nil, err } authRequest := api.AuthenticateRequest{ Login: f.opt.Username, Password: clearPassword, } err = f.pacer.Call(func() (bool, error) { httpResp, err := f.rest.CallJSON(ctx, &opts, &authRequest, &response) return f.shouldRetry(ctx, httpResp, err, false) }) if err != nil { return nil, err } f.rest.SetHeader("X-User-Token", response.TokenID) return response, nil } // UploadSession represents a single Uloz.to upload session. // // Uloz.to supports uploading multiple files at once and committing them atomically. This functionality isn't being used // by the backend implementation and for simplicity, each session corresponds to a single file being uploaded. type UploadSession struct { Filesystem *Fs URL string PrivateSlug string ValidUntil time.Time } func (f *Fs) createUploadSession(ctx context.Context) (session *UploadSession, err error) { session = &UploadSession{ Filesystem: f, } err = session.renewUploadSession(ctx) if err != nil { return nil, err } return session, nil } func (session *UploadSession) renewUploadSession(ctx context.Context) error { opts := rest.Opts{ Method: "POST", Path: "/v5/upload/link", Parameters: url.Values{}, } createUploadURLReq := api.CreateUploadURLRequest{ UserLogin: session.Filesystem.opt.Username, Realm: "ulozto", } if session.PrivateSlug != "" { createUploadURLReq.ExistingSessionSlug = session.PrivateSlug } var err error var response api.CreateUploadURLResponse err = session.Filesystem.pacer.Call(func() (bool, error) { httpResp, err := session.Filesystem.rest.CallJSON(ctx, &opts, &createUploadURLReq, &response) return session.Filesystem.shouldRetry(ctx, httpResp, err, true) }) if err != nil { return err } session.PrivateSlug = response.PrivateSlug session.URL = response.UploadURL session.ValidUntil = response.ValidUntil return nil } func (f *Fs) uploadUnchecked(ctx context.Context, name, parentSlug string, info fs.ObjectInfo, payload io.Reader) (fs.Object, error) { session, err := f.createUploadSession(ctx) if err != nil { return nil, err } hashes := hash.NewHashSet(hash.MD5, hash.SHA256) hasher, err := hash.NewMultiHasherTypes(hashes) if err != nil { return nil, err } payload = io.TeeReader(payload, hasher) encodedName := f.opt.Enc.FromStandardName(name) opts := rest.Opts{ Method: "POST", Body: payload, // Not using Parameters as the session URL has parameters itself RootURL: session.URL + "&batch_file_id=1&is_porn=false", MultipartContentName: "file", MultipartFileName: encodedName, Parameters: url.Values{}, } if info.Size() > 0 { size := info.Size() opts.ContentLength = &size } var uploadResponse api.SendFilePayloadResponse err = f.pacer.CallNoRetry(func() (bool, error) { httpResp, err := f.cdn.CallJSON(ctx, &opts, nil, &uploadResponse) return f.shouldRetry(ctx, httpResp, err, true) }) if err != nil { return nil, err } sha256digest, err := hasher.Sum(hash.SHA256) if err != nil { return nil, err } md5digest, err := hasher.Sum(hash.MD5) if err != nil { return nil, err } if hex.EncodeToString(md5digest) != uploadResponse.Md5 { return nil, errors.New("MD5 digest mismatch") } metadata := DescriptionEncodedMetadata{ Md5Hash: md5digest, Sha256Hash: sha256digest, ModTimeEpochMicros: info.ModTime(ctx).UnixMicro(), } encodedMetadata, err := metadata.encode() if err != nil { return nil, err } // Successfully uploaded, now move the file where it belongs and commit it updateReq := api.BatchUpdateFilePropertiesRequest{ Name: encodedName, FolderSlug: parentSlug, Description: encodedMetadata, Slugs: []string{uploadResponse.Slug}, UploadTokens: map[string]string{uploadResponse.Slug: session.PrivateSlug + ":1"}, } var updateResponse []api.File opts = rest.Opts{ Method: "PATCH", Path: "/v8/file-list/private", Parameters: url.Values{}, } err = f.pacer.Call(func() (bool, error) { httpResp, err := session.Filesystem.rest.CallJSON(ctx, &opts, &updateReq, &updateResponse) return f.shouldRetry(ctx, httpResp, err, true) }) if err != nil { return nil, err } if len(updateResponse) != 1 { return nil, errors.New("unexpected number of files in the response") } opts = rest.Opts{ Method: "PATCH", Path: "/v8/upload-batch/private/" + session.PrivateSlug, Parameters: url.Values{}, } commitRequest := api.CommitUploadBatchRequest{ Status: "confirmed", OwnerLogin: f.opt.Username, } var commitResponse api.CommitUploadBatchResponse err = f.pacer.Call(func() (bool, error) { httpResp, err := session.Filesystem.rest.CallJSON(ctx, &opts, &commitRequest, &commitResponse) return f.shouldRetry(ctx, httpResp, err, true) }) if err != nil { return nil, err } file, err := f.newObjectWithInfo(ctx, info.Remote(), &updateResponse[0]) return file, err } // Put implements the mandatory method fs.Fs.Put. func (f *Fs) Put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) { existingObj, err := f.NewObject(ctx, src.Remote()) switch { case err == nil: return existingObj, existingObj.Update(ctx, in, src, options...) case errors.Is(err, fs.ErrorObjectNotFound): // Not found so create it return f.PutUnchecked(ctx, in, src, options...) default: return nil, err } } // PutUnchecked implements the optional interface fs.PutUncheckeder. // // Uloz.to allows to have multiple files of the same name in the same folder. func (f *Fs) PutUnchecked(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) { filename, folderSlug, err := f.dirCache.FindPath(ctx, src.Remote(), true) if err != nil { return nil, err } return f.uploadUnchecked(ctx, filename, folderSlug, src, in) } // Mkdir implements the mandatory method fs.Fs.Mkdir. func (f *Fs) Mkdir(ctx context.Context, dir string) (err error) { _, err = f.dirCache.FindDir(ctx, dir, true) return err } func (f *Fs) isDirEmpty(ctx context.Context, slug string) (empty bool, err error) { folders, err := f.fetchListFolderPage(ctx, slug, "", 1, 0) if err != nil { return false, err } if len(folders) > 0 { return false, nil } files, err := f.fetchListFilePage(ctx, slug, "", 1, 0) if err != nil { return false, err } if len(files) > 0 { return false, nil } return true, nil } // Rmdir implements the mandatory method fs.Fs.Rmdir. func (f *Fs) Rmdir(ctx context.Context, dir string) error { slug, err := f.dirCache.FindDir(ctx, dir, false) if err != nil { return err } empty, err := f.isDirEmpty(ctx, slug) if err != nil { return err } if !empty { return fs.ErrorDirectoryNotEmpty } opts := rest.Opts{ Method: "DELETE", Path: "/v5/user/" + f.opt.Username + "/folder-list", } req := api.DeleteFoldersRequest{Slugs: []string{slug}} err = f.pacer.Call(func() (bool, error) { httpResp, err := f.rest.CallJSON(ctx, &opts, req, nil) return f.shouldRetry(ctx, httpResp, err, true) }) if err != nil { return err } f.dirCache.FlushDir(dir) return nil } // Move implements the optional method fs.Mover.Move. func (f *Fs) Move(ctx context.Context, src fs.Object, remote string) (fs.Object, error) { if remote == src.Remote() { // Already there, do nothing return src, nil } srcObj, ok := src.(*Object) if !ok { fs.Debugf(src, "Can't move - not same remote type") return nil, fs.ErrorCantMove } filename, folderSlug, err := f.dirCache.FindPath(ctx, remote, true) if err != nil { return nil, err } newObj := &Object{} newObj.copyFrom(srcObj) newObj.remote = remote return newObj, newObj.updateFileProperties(ctx, api.MoveFileRequest{ ParentFolderSlug: folderSlug, NewFilename: filename, }) } // DirMove implements the optional method fs.DirMover.DirMove. 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 } srcSlug, _, srcName, dstParentSlug, dstName, err := f.dirCache.DirMove(ctx, srcFs.dirCache, srcFs.root, srcRemote, f.root, dstRemote) if err != nil { return err } opts := rest.Opts{ Method: "PATCH", Path: "/v6/user/" + f.opt.Username + "/folder-list/parent-folder", } req := api.MoveFolderRequest{ FolderSlugs: []string{srcSlug}, NewParentFolderSlug: dstParentSlug, } err = f.pacer.Call(func() (bool, error) { httpResp, err := f.rest.CallJSON(ctx, &opts, &req, nil) return f.shouldRetry(ctx, httpResp, err, true) }) if err != nil { return err } // The old folder doesn't exist anymore so clear the cache now instead of after renaming srcFs.dirCache.FlushDir(srcRemote) if srcName != dstName { // There's no endpoint to rename the folder alongside moving it, so this has to happen separately. opts = rest.Opts{ Method: "PATCH", Path: "/v7/user/" + f.opt.Username + "/folder/" + srcSlug, } renameReq := api.RenameFolderRequest{ NewName: dstName, } err = f.pacer.Call(func() (bool, error) { httpResp, err := f.rest.CallJSON(ctx, &opts, &renameReq, nil) return f.shouldRetry(ctx, httpResp, err, true) }) return err } return nil } // Name of the remote (as passed into NewFs) func (f *Fs) Name() string { return f.name } // Root of the remote (as passed into NewFs) func (f *Fs) Root() string { return f.root } // String converts this Fs to a string func (f *Fs) String() string { return fmt.Sprintf("uloz.to root '%s'", f.root) } // Features returns the optional features of this Fs func (f *Fs) Features() *fs.Features { return f.features } // Precision return the precision of this Fs func (f *Fs) Precision() time.Duration { return time.Microsecond } // Hashes implements fs.Fs.Hashes by returning the supported hash types of the filesystem. func (f *Fs) Hashes() hash.Set { return hash.NewHashSet(hash.SHA256, hash.MD5) } // DescriptionEncodedMetadata represents a set of metadata encoded as Uloz.to description. // // Uloz.to doesn't support setting metadata such as mtime but allows the user to set an arbitrary description field. // The content of this structure will be serialized and stored in the backend. // // The files themselves are immutable so there's no danger that the file changes, and we'll forget to update the hashes. // It is theoretically possible to rewrite the description to provide incorrect information for a file. However, in case // it's a real attack vector, a nefarious person already has write access to the repo, and the situation is above // rclone's pay grade already. type DescriptionEncodedMetadata struct { Md5Hash []byte // The MD5 hash of the file Sha256Hash []byte // The SHA256 hash of the file ModTimeEpochMicros int64 // The mtime of the file, as set by rclone } func (md *DescriptionEncodedMetadata) encode() (string, error) { b := bytes.Buffer{} e := gob.NewEncoder(&b) err := e.Encode(md) if err != nil { return "", err } // Version the encoded string from the beginning even though we don't need it yet. return "1;" + base64.StdEncoding.EncodeToString(b.Bytes()), nil } func decodeDescriptionMetadata(str string) (*DescriptionEncodedMetadata, error) { // The encoded data starts with a version number which is not a part iof the serialized object spl := strings.SplitN(str, ";", 2) if len(spl) < 2 || spl[0] != "1" { return nil, errors.New("can't decode, unknown encoded metadata version") } m := DescriptionEncodedMetadata{} by, err := base64.StdEncoding.DecodeString(spl[1]) if err != nil { return nil, err } b := bytes.Buffer{} b.Write(by) d := gob.NewDecoder(&b) err = d.Decode(&m) if err != nil { return nil, err } return &m, nil } // Object describes an uloz.to object. // // Valid objects will always have all fields but encodedMetadata set. type Object struct { fs *Fs // what this object is part of remote string // The remote path name string // The file name size int64 // size of the object slug string // ID of the object remoteFsMtime time.Time // The time the object was last modified in the remote fs. // Metadata not available natively and encoded in the description field. May not be present if the encoded metadata // is not present (e.g. if file wasn't uploaded by rclone) or invalid. encodedMetadata *DescriptionEncodedMetadata } // Storable implements the mandatory method fs.ObjectInfo.Storable func (o *Object) Storable() bool { return true } func (o *Object) updateFileProperties(ctx context.Context, req interface{}) (err error) { var resp *api.File opts := rest.Opts{ Method: "PATCH", Path: "/v8/file/" + o.slug + "/private", } err = o.fs.pacer.Call(func() (bool, error) { httpResp, err := o.fs.rest.CallJSON(ctx, &opts, &req, &resp) return o.fs.shouldRetry(ctx, httpResp, err, true) }) if err != nil { return err } return o.setMetaData(resp) } // SetModTime implements the mandatory method fs.Object.SetModTime func (o *Object) SetModTime(ctx context.Context, t time.Time) (err error) { var newMetadata DescriptionEncodedMetadata if o.encodedMetadata == nil { newMetadata = DescriptionEncodedMetadata{} } else { newMetadata = *o.encodedMetadata } newMetadata.ModTimeEpochMicros = t.UnixMicro() encoded, err := newMetadata.encode() if err != nil { return err } return o.updateFileProperties(ctx, api.UpdateDescriptionRequest{ Description: encoded, }) } // Open implements the mandatory method fs.Object.Open func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (rc io.ReadCloser, err error) { opts := rest.Opts{ Method: "POST", Path: "/v5/file/download-link/vipdata", } req := &api.GetDownloadLinkRequest{ Slug: o.slug, UserLogin: o.fs.opt.Username, // Has to be set but doesn't seem to be used server side. DeviceID: "foobar", } var resp *api.GetDownloadLinkResponse err = o.fs.pacer.Call(func() (bool, error) { httpResp, err := o.fs.rest.CallJSON(ctx, &opts, &req, &resp) return o.fs.shouldRetry(ctx, httpResp, err, true) }) if err != nil { return nil, err } opts = rest.Opts{ Method: "GET", RootURL: resp.Link, Options: options, } var httpResp *http.Response err = o.fs.pacer.Call(func() (bool, error) { httpResp, err = o.fs.cdn.Call(ctx, &opts) return o.fs.shouldRetry(ctx, httpResp, err, true) }) if err != nil { return nil, err } return httpResp.Body, err } func (o *Object) copyFrom(other *Object) { o.fs = other.fs o.remote = other.remote o.size = other.size o.slug = other.slug o.remoteFsMtime = other.remoteFsMtime o.encodedMetadata = other.encodedMetadata } // RenamingObjectInfoProxy is a delegating proxy for fs.ObjectInfo // with the option of specifying a different remote path. type RenamingObjectInfoProxy struct { delegate fs.ObjectInfo remote string } // Remote implements fs.ObjectInfo.Remote by delegating to the wrapped instance. func (s *RenamingObjectInfoProxy) String() string { return s.delegate.String() } // Remote implements fs.ObjectInfo.Remote by returning the specified remote path. func (s *RenamingObjectInfoProxy) Remote() string { return s.remote } // ModTime implements fs.ObjectInfo.ModTime by delegating to the wrapped instance. func (s *RenamingObjectInfoProxy) ModTime(ctx context.Context) time.Time { return s.delegate.ModTime(ctx) } // Size implements fs.ObjectInfo.Size by delegating to the wrapped instance. func (s *RenamingObjectInfoProxy) Size() int64 { return s.delegate.Size() } // Fs implements fs.ObjectInfo.Fs by delegating to the wrapped instance. func (s *RenamingObjectInfoProxy) Fs() fs.Info { return s.delegate.Fs() } // Hash implements fs.ObjectInfo.Hash by delegating to the wrapped instance. func (s *RenamingObjectInfoProxy) Hash(ctx context.Context, ty hash.Type) (string, error) { return s.delegate.Hash(ctx, ty) } // Storable implements fs.ObjectInfo.Storable by delegating to the wrapped instance. func (s *RenamingObjectInfoProxy) Storable() bool { return s.delegate.Storable() } // Update implements the mandatory method fs.Object.Update func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) error { // The backend allows to store multiple files with the same name, so simply upload the new file and remove the old // one afterwards. info := &RenamingObjectInfoProxy{ delegate: src, remote: o.Remote(), } newo, err := o.fs.PutUnchecked(ctx, in, info, options...) if err != nil { return err } err = o.Remove(ctx) if err != nil { return err } o.copyFrom(newo.(*Object)) return nil } // Remove implements the mandatory method fs.Object.Remove func (o *Object) Remove(ctx context.Context) error { for i := 0; i < 2; i++ { // First call moves the item to recycle bin, second deletes it for good var err error opts := rest.Opts{ Method: "DELETE", Path: "/v6/file/" + o.slug + "/private", } err = o.fs.pacer.Call(func() (bool, error) { httpResp, err := o.fs.rest.CallJSON(ctx, &opts, nil, nil) return o.fs.shouldRetry(ctx, httpResp, err, true) }) if err != nil { return err } } return nil } // ModTime implements the mandatory method fs.Object.ModTime func (o *Object) ModTime(ctx context.Context) time.Time { if o.encodedMetadata != nil { return time.UnixMicro(o.encodedMetadata.ModTimeEpochMicros) } // The time the object was last modified on the server - a handwavy guess, but we don't have any better return o.remoteFsMtime } // Fs implements the mandatory method fs.Object.Fs func (o *Object) Fs() fs.Info { return o.fs } // String returns the string representation of the remote object reference. func (o *Object) String() string { if o == nil { return "" } return o.remote } // Remote returns the remote path func (o *Object) Remote() string { return o.remote } // Size returns the size of an object in bytes func (o *Object) Size() int64 { return o.size } // Hash implements the mandatory method fs.Object.Hash. // // Supports SHA256 and MD5 hashes. func (o *Object) Hash(ctx context.Context, t hash.Type) (string, error) { if t != hash.MD5 && t != hash.SHA256 { return "", hash.ErrUnsupported } if o.encodedMetadata == nil { return "", nil } switch t { case hash.MD5: return hex.EncodeToString(o.encodedMetadata.Md5Hash), nil case hash.SHA256: return hex.EncodeToString(o.encodedMetadata.Sha256Hash), nil } panic("Should never get here") } // FindLeaf implements dircache.DirCacher.FindLeaf by successively walking through the folder hierarchy until // the desired folder is found, or there's nowhere to continue. func (f *Fs) FindLeaf(ctx context.Context, folderSlug, leaf string) (leafSlug string, found bool, err error) { folders, err := f.listFolders(ctx, folderSlug, leaf) if err != nil { if errors.Is(err, fs.ErrorDirNotFound) { return "", false, nil } return "", false, err } for _, folder := range folders { if folder.Name == leaf { return folder.Slug, true, nil } } // Uloz.to allows creation of multiple files / folders with the same name in the same parent folder. rclone always // expects folder paths to be unique (no other file or folder with the same name should exist). As a result we also // need to look at the files to return the correct error if necessary. files, err := f.listFiles(ctx, folderSlug, leaf) if err != nil { return "", false, err } for _, file := range files { if file.Name == leaf { return "", false, fs.ErrorIsFile } } // The parent folder exists but no file or folder with the given name was found in it. return "", false, nil } // CreateDir implements dircache.DirCacher.CreateDir by creating a folder with the given name under a folder identified // by parentSlug. func (f *Fs) CreateDir(ctx context.Context, parentSlug, leaf string) (newID string, err error) { var folder *api.Folder opts := rest.Opts{ Method: "POST", Path: "/v6/user/" + f.opt.Username + "/folder", Parameters: url.Values{}, } mkdir := api.CreateFolderRequest{ Name: f.opt.Enc.FromStandardName(leaf), ParentFolderSlug: parentSlug, } err = f.pacer.Call(func() (bool, error) { httpResp, err := f.rest.CallJSON(ctx, &opts, &mkdir, &folder) return f.shouldRetry(ctx, httpResp, err, true) }) if err != nil { return "", err } return folder.Slug, nil } func (f *Fs) newObjectWithInfo(ctx context.Context, remote string, info *api.File) (fs.Object, error) { o := &Object{ fs: f, remote: remote, } var err error if info == nil { info, err = f.readMetaDataForPath(ctx, remote) } if err != nil { return nil, err } err = o.setMetaData(info) if err != nil { return nil, err } return o, nil } // readMetaDataForPath reads the metadata from the path func (f *Fs) readMetaDataForPath(ctx context.Context, path string) (info *api.File, err error) { filename, folderSlug, err := f.dirCache.FindPath(ctx, path, false) if err != nil { if errors.Is(err, fs.ErrorDirNotFound) { return nil, fs.ErrorObjectNotFound } return nil, err } files, err := f.listFiles(ctx, folderSlug, filename) if err != nil { return nil, err } for _, file := range files { if file.Name == filename { return &file, nil } } folders, err := f.listFolders(ctx, folderSlug, filename) if err != nil { return nil, err } for _, file := range folders { if file.Name == filename { return nil, fs.ErrorIsDir } } return nil, fs.ErrorObjectNotFound } func (o *Object) setMetaData(info *api.File) (err error) { o.name = info.Name o.size = info.Filesize o.remoteFsMtime = info.LastUserModified o.encodedMetadata, err = decodeDescriptionMetadata(info.Description) if err != nil { fs.Debugf(o, "Couldn't decode metadata: %v", err) } o.slug = info.Slug return nil } // NewObject implements fs.Fs.NewObject. func (f *Fs) NewObject(ctx context.Context, remote string) (fs.Object, error) { return f.newObjectWithInfo(ctx, remote, nil) } // List implements fs.Fs.List by listing all files and folders in the given folder. func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err error) { folderSlug, err := f.dirCache.FindDir(ctx, dir, false) if err != nil { return nil, err } folders, err := f.listFolders(ctx, folderSlug, "") if err != nil { return nil, err } for _, folder := range folders { remote := path.Join(dir, folder.Name) f.dirCache.Put(remote, folder.Slug) entries = append(entries, fs.NewDir(remote, folder.LastUserModified)) } files, err := f.listFiles(ctx, folderSlug, "") if err != nil { return nil, err } for _, file := range files { remote := path.Join(dir, file.Name) remoteFile, err := f.newObjectWithInfo(ctx, remote, &file) if err != nil { return nil, err } entries = append(entries, remoteFile) } return entries, nil } func (f *Fs) fetchListFolderPage( ctx context.Context, folderSlug string, searchQuery string, limit int, offset int) (folders []api.Folder, err error) { opts := rest.Opts{ Method: "GET", Path: "/v9/user/" + f.opt.Username + "/folder/" + folderSlug + "/folder-list", Parameters: url.Values{}, } opts.Parameters.Set("status", "ok") opts.Parameters.Set("limit", strconv.Itoa(limit)) if offset > 0 { opts.Parameters.Set("offset", strconv.Itoa(offset)) } if searchQuery != "" { opts.Parameters.Set("search_query", f.opt.Enc.FromStandardName(searchQuery)) } var respBody *api.ListFoldersResponse err = f.pacer.Call(func() (bool, error) { httpResp, err := f.rest.CallJSON(ctx, &opts, nil, &respBody) return f.shouldRetry(ctx, httpResp, err, true) }) if err != nil { return nil, err } for i := range respBody.Subfolders { respBody.Subfolders[i].Name = f.opt.Enc.ToStandardName(respBody.Subfolders[i].Name) } return respBody.Subfolders, nil } func (f *Fs) listFolders( ctx context.Context, folderSlug string, searchQuery string) (folders []api.Folder, err error) { targetPageSize := f.opt.ListPageSize lastPageSize := targetPageSize offset := 0 for targetPageSize == lastPageSize { page, err := f.fetchListFolderPage(ctx, folderSlug, searchQuery, targetPageSize, offset) if err != nil { var apiErr *api.Error casted := errors.As(err, &apiErr) if casted && apiErr.ErrorCode == 30001 { return nil, fs.ErrorDirNotFound } return nil, err } lastPageSize = len(page) offset += lastPageSize folders = append(folders, page...) } return folders, nil } func (f *Fs) fetchListFilePage( ctx context.Context, folderSlug string, searchQuery string, limit int, offset int) (folders []api.File, err error) { opts := rest.Opts{ Method: "GET", Path: "/v8/user/" + f.opt.Username + "/folder/" + folderSlug + "/file-list", Parameters: url.Values{}, } opts.Parameters.Set("status", "ok") opts.Parameters.Set("limit", strconv.Itoa(limit)) if offset > 0 { opts.Parameters.Set("offset", strconv.Itoa(offset)) } if searchQuery != "" { opts.Parameters.Set("search_query", f.opt.Enc.FromStandardName(searchQuery)) } var respBody *api.ListFilesResponse err = f.pacer.Call(func() (bool, error) { httpResp, err := f.rest.CallJSON(ctx, &opts, nil, &respBody) return f.shouldRetry(ctx, httpResp, err, true) }) if err != nil { return nil, fmt.Errorf("couldn't list files: %w", err) } for i := range respBody.Items { respBody.Items[i].Name = f.opt.Enc.ToStandardName(respBody.Items[i].Name) } return respBody.Items, nil } func (f *Fs) listFiles( ctx context.Context, folderSlug string, searchQuery string) (folders []api.File, err error) { targetPageSize := f.opt.ListPageSize lastPageSize := targetPageSize offset := 0 for targetPageSize == lastPageSize { page, err := f.fetchListFilePage(ctx, folderSlug, searchQuery, targetPageSize, offset) if err != nil { return nil, err } lastPageSize = len(page) offset += lastPageSize folders = append(folders, page...) } return folders, nil } // DirCacheFlush implements the optional fs.DirCacheFlusher interface. func (f *Fs) DirCacheFlush() { f.dirCache.ResetRoot() } // Check the interfaces are satisfied var ( _ fs.Fs = (*Fs)(nil) _ dircache.DirCacher = (*Fs)(nil) _ fs.DirCacheFlusher = (*Fs)(nil) _ fs.PutUncheckeder = (*Fs)(nil) _ fs.Mover = (*Fs)(nil) _ fs.DirMover = (*Fs)(nil) _ fs.Object = (*Object)(nil) _ fs.ObjectInfo = (*RenamingObjectInfoProxy)(nil) )