The Move method compared the source and destination paths relative to their own Fs roots, so a server side move between two differently rooted Fs instances on the same remote (as used by --backup-dir and sync moves between remotes) was skipped as "already there" whenever the relative paths coincided. With --backup-dir this destroyed the file which was supposed to be backed up. Compare the full paths including the Fs roots instead.
1308 lines
34 KiB
Go
1308 lines
34 KiB
Go
// 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
|
|
}
|
|
|
|
// About implements the Abouter interface for Uloz.to.
|
|
func (f *Fs) About(ctx context.Context) (*fs.Usage, error) {
|
|
used, err := f.getUsedSize(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
usage := fs.Usage{
|
|
Used: &used,
|
|
}
|
|
|
|
return &usage, nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
func (f *Fs) getUsedSize(ctx context.Context) (int64, error) {
|
|
rootID, err := f.dirCache.RootID(ctx, false)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
opts := rest.Opts{
|
|
Method: "GET",
|
|
Path: fmt.Sprintf("/v6/user/%s/folder/%s/folder-sizes", f.opt.Username, rootID),
|
|
Parameters: url.Values{
|
|
"recursive": []string{"true"},
|
|
},
|
|
}
|
|
|
|
folderSizes := api.FolderSizesResponse{}
|
|
|
|
err = f.pacer.Call(func() (bool, error) {
|
|
resp, err := f.rest.CallJSON(ctx, &opts, nil, &folderSizes)
|
|
return f.shouldRetry(ctx, resp, err, true)
|
|
})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return folderSizes[rootID].Recursive.FilesSize, 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) {
|
|
srcObj, ok := src.(*Object)
|
|
if !ok {
|
|
fs.Debugf(src, "Can't move - not same remote type")
|
|
return nil, fs.ErrorCantMove
|
|
}
|
|
|
|
// src may be on a differently rooted Fs (eg with --backup-dir) so
|
|
// compare the full paths including the Fs roots
|
|
if path.Join(srcObj.fs.root, srcObj.remote) == path.Join(f.root, remote) {
|
|
// Already there, do nothing
|
|
return src, nil
|
|
}
|
|
|
|
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 any) (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,
|
|
DeviceID: fmt.Sprintf("%d", time.Now().UnixNano()),
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
downloadURL := resp.Link
|
|
if resp.Hash != "" {
|
|
if strings.Contains(downloadURL, "?") {
|
|
downloadURL += "&"
|
|
} else {
|
|
downloadURL += "?"
|
|
}
|
|
downloadURL += "hash=" + url.QueryEscape(resp.Hash)
|
|
}
|
|
|
|
opts = rest.Opts{
|
|
Method: "GET",
|
|
RootURL: downloadURL,
|
|
Options: options,
|
|
}
|
|
|
|
var httpResp *http.Response
|
|
|
|
err = o.fs.pacer.Call(func() (bool, error) {
|
|
httpResp, err = o.fs.rest.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 range 2 {
|
|
// 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 "<nil>"
|
|
}
|
|
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)
|
|
)
|