github.com/rclone/rclone@v1.66.1-0.20240517100346-7b89735ae726/backend/ulozto/ulozto.go (about) 1 // Package ulozto provides an interface to the Uloz.to storage system. 2 package ulozto 3 4 import ( 5 "bytes" 6 "context" 7 "encoding/base64" 8 "encoding/gob" 9 "encoding/hex" 10 "errors" 11 "fmt" 12 "io" 13 "net/http" 14 "net/url" 15 "path" 16 "strconv" 17 "strings" 18 "time" 19 20 "github.com/rclone/rclone/backend/ulozto/api" 21 "github.com/rclone/rclone/fs" 22 "github.com/rclone/rclone/fs/config" 23 "github.com/rclone/rclone/fs/config/configmap" 24 "github.com/rclone/rclone/fs/config/configstruct" 25 "github.com/rclone/rclone/fs/config/obscure" 26 "github.com/rclone/rclone/fs/fserrors" 27 "github.com/rclone/rclone/fs/fshttp" 28 "github.com/rclone/rclone/fs/hash" 29 "github.com/rclone/rclone/lib/dircache" 30 "github.com/rclone/rclone/lib/encoder" 31 "github.com/rclone/rclone/lib/pacer" 32 "github.com/rclone/rclone/lib/rest" 33 ) 34 35 // TODO Uloz.to only supports file names of 255 characters or less and silently truncates names that are longer. 36 37 const ( 38 minSleep = 10 * time.Millisecond 39 maxSleep = 2 * time.Second 40 decayConstant = 2 // bigger for slower decay, exponential 41 rootURL = "https://apis.uloz.to" 42 ) 43 44 // Options defines the configuration for this backend 45 type Options struct { 46 AppToken string `config:"app_token"` 47 Username string `config:"username"` 48 Password string `config:"password"` 49 RootFolderSlug string `config:"root_folder_slug"` 50 Enc encoder.MultiEncoder `config:"encoding"` 51 ListPageSize int `config:"list_page_size"` 52 } 53 54 func init() { 55 fs.Register(&fs.RegInfo{ 56 Name: "ulozto", 57 Description: "Uloz.to", 58 NewFs: NewFs, 59 Options: []fs.Option{ 60 { 61 Name: "app_token", 62 Default: "", 63 Help: `The application token identifying the app. An app API key can be either found in the API 64 doc https://uloz.to/upload-resumable-api-beta or obtained from customer service.`, 65 Sensitive: true, 66 }, 67 { 68 Name: "username", 69 Default: "", 70 Help: "The username of the principal to operate as.", 71 Sensitive: true, 72 }, 73 { 74 Name: "password", 75 Default: "", 76 Help: "The password for the user.", 77 IsPassword: true, 78 }, 79 { 80 Name: "root_folder_slug", 81 Help: `If set, rclone will use this folder as the root folder for all operations. For example, 82 if the slug identifies 'foo/bar/', 'ulozto:baz' is equivalent to 'ulozto:foo/bar/baz' without 83 any root slug set.`, 84 Default: "", 85 Advanced: true, 86 Sensitive: true, 87 }, 88 { 89 Name: "list_page_size", 90 Default: 500, 91 Help: "The size of a single page for list commands. 1-500", 92 Advanced: true, 93 }, 94 { 95 Name: config.ConfigEncoding, 96 Help: config.ConfigEncodingHelp, 97 Advanced: true, 98 Default: encoder.Display | encoder.EncodeInvalidUtf8 | encoder.EncodeBackSlash, 99 }, 100 }}) 101 } 102 103 // Fs represents a remote uloz.to storage 104 type Fs struct { 105 name string // name of this remote 106 root string // the path we are working on 107 opt Options // parsed options 108 features *fs.Features // optional features 109 rest *rest.Client // REST client with authentication headers set, used to communicate with API endpoints 110 cdn *rest.Client // REST client without authentication headers set, used for CDN payload upload/download 111 dirCache *dircache.DirCache // Map of directory path to directory id 112 pacer *fs.Pacer // pacer for API calls 113 } 114 115 // NewFs constructs a Fs from the path, container:path 116 func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, error) { 117 // Parse config into Options struct 118 opt := new(Options) 119 err := configstruct.Set(m, opt) 120 if err != nil { 121 return nil, err 122 } 123 124 // Strip leading and trailing slashes, see https://github.com/rclone/rclone/issues/7796 for details. 125 root = strings.Trim(root, "/") 126 127 client := fshttp.NewClient(ctx) 128 129 f := &Fs{ 130 name: name, 131 root: root, 132 opt: *opt, 133 cdn: rest.NewClient(client), 134 rest: rest.NewClient(client).SetRoot(rootURL), 135 pacer: fs.NewPacer(ctx, pacer.NewDefault(pacer.MinSleep(minSleep), pacer.MaxSleep(maxSleep), pacer.DecayConstant(decayConstant))), 136 } 137 f.features = (&fs.Features{ 138 DuplicateFiles: true, 139 CanHaveEmptyDirectories: true, 140 }).Fill(ctx, f) 141 f.rest.SetErrorHandler(errorHandler) 142 143 f.rest.SetHeader("X-Auth-Token", f.opt.AppToken) 144 145 auth, err := f.authenticate(ctx) 146 147 if err != nil { 148 return f, err 149 } 150 151 var rootSlug string 152 if opt.RootFolderSlug == "" { 153 rootSlug = auth.Session.User.RootFolderSlug 154 } else { 155 rootSlug = opt.RootFolderSlug 156 } 157 158 f.dirCache = dircache.New(root, rootSlug, f) 159 160 err = f.dirCache.FindRoot(ctx, false) 161 162 if errors.Is(err, fs.ErrorDirNotFound) { 163 // All good, we'll create the folder later on. 164 return f, nil 165 } 166 167 if errors.Is(err, fs.ErrorIsFile) { 168 rootFolder, _ := dircache.SplitPath(root) 169 f.root = rootFolder 170 f.dirCache = dircache.New(rootFolder, rootSlug, f) 171 err = f.dirCache.FindRoot(ctx, false) 172 if err != nil { 173 return f, err 174 } 175 return f, fs.ErrorIsFile 176 } 177 178 return f, err 179 } 180 181 // errorHandler parses a non 2xx error response into an error 182 func errorHandler(resp *http.Response) error { 183 // Decode error response 184 errResponse := new(api.Error) 185 err := rest.DecodeJSON(resp, &errResponse) 186 if err != nil { 187 fs.Debugf(nil, "Couldn't decode error response: %v", err) 188 } 189 if errResponse.StatusCode == 0 { 190 errResponse.StatusCode = resp.StatusCode 191 } 192 return errResponse 193 } 194 195 // retryErrorCodes is a slice of error codes that we will retry 196 var retryErrorCodes = []int{ 197 429, // Too Many Requests. 198 500, // Internal Server Error 199 502, // Bad Gateway 200 503, // Service Unavailable 201 504, // Gateway Timeout 202 } 203 204 // shouldRetry returns a boolean whether this resp and err should be retried. 205 // It also returns the err for convenience. 206 func (f *Fs) shouldRetry(ctx context.Context, resp *http.Response, err error, reauth bool) (bool, error) { 207 if err == nil { 208 return false, nil 209 } 210 211 if fserrors.ContextError(ctx, &err) { 212 return false, err 213 } 214 215 var apiErr *api.Error 216 if resp != nil && resp.StatusCode == 401 && errors.As(err, &apiErr) && apiErr.ErrorCode == 70001 { 217 fs.Debugf(nil, "Should retry: %v", err) 218 219 if reauth { 220 _, err = f.authenticate(ctx) 221 if err != nil { 222 return false, err 223 } 224 } 225 226 return true, err 227 } 228 229 return fserrors.ShouldRetry(err) || fserrors.ShouldRetryHTTP(resp, retryErrorCodes), err 230 } 231 232 func (f *Fs) authenticate(ctx context.Context) (response *api.AuthenticateResponse, err error) { 233 // TODO only reauth once if the token expires 234 235 // Remove the old user token 236 f.rest.RemoveHeader("X-User-Token") 237 238 opts := rest.Opts{ 239 Method: "PUT", 240 Path: "/v6/session", 241 } 242 243 clearPassword, err := obscure.Reveal(f.opt.Password) 244 if err != nil { 245 return nil, err 246 } 247 authRequest := api.AuthenticateRequest{ 248 Login: f.opt.Username, 249 Password: clearPassword, 250 } 251 252 err = f.pacer.Call(func() (bool, error) { 253 httpResp, err := f.rest.CallJSON(ctx, &opts, &authRequest, &response) 254 return f.shouldRetry(ctx, httpResp, err, false) 255 }) 256 257 if err != nil { 258 return nil, err 259 } 260 261 f.rest.SetHeader("X-User-Token", response.TokenID) 262 263 return response, nil 264 } 265 266 // UploadSession represents a single Uloz.to upload session. 267 // 268 // Uloz.to supports uploading multiple files at once and committing them atomically. This functionality isn't being used 269 // by the backend implementation and for simplicity, each session corresponds to a single file being uploaded. 270 type UploadSession struct { 271 Filesystem *Fs 272 URL string 273 PrivateSlug string 274 ValidUntil time.Time 275 } 276 277 func (f *Fs) createUploadSession(ctx context.Context) (session *UploadSession, err error) { 278 session = &UploadSession{ 279 Filesystem: f, 280 } 281 282 err = session.renewUploadSession(ctx) 283 if err != nil { 284 return nil, err 285 } 286 287 return session, nil 288 } 289 290 func (session *UploadSession) renewUploadSession(ctx context.Context) error { 291 opts := rest.Opts{ 292 Method: "POST", 293 Path: "/v5/upload/link", 294 Parameters: url.Values{}, 295 } 296 297 createUploadURLReq := api.CreateUploadURLRequest{ 298 UserLogin: session.Filesystem.opt.Username, 299 Realm: "ulozto", 300 } 301 302 if session.PrivateSlug != "" { 303 createUploadURLReq.ExistingSessionSlug = session.PrivateSlug 304 } 305 306 var err error 307 var response api.CreateUploadURLResponse 308 309 err = session.Filesystem.pacer.Call(func() (bool, error) { 310 httpResp, err := session.Filesystem.rest.CallJSON(ctx, &opts, &createUploadURLReq, &response) 311 return session.Filesystem.shouldRetry(ctx, httpResp, err, true) 312 }) 313 314 if err != nil { 315 return err 316 } 317 318 session.PrivateSlug = response.PrivateSlug 319 session.URL = response.UploadURL 320 session.ValidUntil = response.ValidUntil 321 322 return nil 323 } 324 325 func (f *Fs) uploadUnchecked(ctx context.Context, name, parentSlug string, info fs.ObjectInfo, payload io.Reader) (fs.Object, error) { 326 session, err := f.createUploadSession(ctx) 327 328 if err != nil { 329 return nil, err 330 } 331 332 hashes := hash.NewHashSet(hash.MD5, hash.SHA256) 333 hasher, err := hash.NewMultiHasherTypes(hashes) 334 335 if err != nil { 336 return nil, err 337 } 338 339 payload = io.TeeReader(payload, hasher) 340 341 encodedName := f.opt.Enc.FromStandardName(name) 342 343 opts := rest.Opts{ 344 Method: "POST", 345 Body: payload, 346 // Not using Parameters as the session URL has parameters itself 347 RootURL: session.URL + "&batch_file_id=1&is_porn=false", 348 MultipartContentName: "file", 349 MultipartFileName: encodedName, 350 Parameters: url.Values{}, 351 } 352 if info.Size() > 0 { 353 size := info.Size() 354 opts.ContentLength = &size 355 } 356 357 var uploadResponse api.SendFilePayloadResponse 358 359 err = f.pacer.CallNoRetry(func() (bool, error) { 360 httpResp, err := f.cdn.CallJSON(ctx, &opts, nil, &uploadResponse) 361 return f.shouldRetry(ctx, httpResp, err, true) 362 }) 363 364 if err != nil { 365 return nil, err 366 } 367 368 sha256digest, err := hasher.Sum(hash.SHA256) 369 if err != nil { 370 return nil, err 371 } 372 373 md5digest, err := hasher.Sum(hash.MD5) 374 if err != nil { 375 return nil, err 376 } 377 378 if hex.EncodeToString(md5digest) != uploadResponse.Md5 { 379 return nil, errors.New("MD5 digest mismatch") 380 } 381 382 metadata := DescriptionEncodedMetadata{ 383 Md5Hash: md5digest, 384 Sha256Hash: sha256digest, 385 ModTimeEpochMicros: info.ModTime(ctx).UnixMicro(), 386 } 387 388 encodedMetadata, err := metadata.encode() 389 390 if err != nil { 391 return nil, err 392 } 393 394 // Successfully uploaded, now move the file where it belongs and commit it 395 updateReq := api.BatchUpdateFilePropertiesRequest{ 396 Name: encodedName, 397 FolderSlug: parentSlug, 398 Description: encodedMetadata, 399 Slugs: []string{uploadResponse.Slug}, 400 UploadTokens: map[string]string{uploadResponse.Slug: session.PrivateSlug + ":1"}, 401 } 402 403 var updateResponse []api.File 404 405 opts = rest.Opts{ 406 Method: "PATCH", 407 Path: "/v8/file-list/private", 408 Parameters: url.Values{}, 409 } 410 411 err = f.pacer.Call(func() (bool, error) { 412 httpResp, err := session.Filesystem.rest.CallJSON(ctx, &opts, &updateReq, &updateResponse) 413 return f.shouldRetry(ctx, httpResp, err, true) 414 }) 415 416 if err != nil { 417 return nil, err 418 } 419 420 if len(updateResponse) != 1 { 421 return nil, errors.New("unexpected number of files in the response") 422 } 423 424 opts = rest.Opts{ 425 Method: "PATCH", 426 Path: "/v8/upload-batch/private/" + session.PrivateSlug, 427 Parameters: url.Values{}, 428 } 429 430 commitRequest := api.CommitUploadBatchRequest{ 431 Status: "confirmed", 432 OwnerLogin: f.opt.Username, 433 } 434 435 var commitResponse api.CommitUploadBatchResponse 436 437 err = f.pacer.Call(func() (bool, error) { 438 httpResp, err := session.Filesystem.rest.CallJSON(ctx, &opts, &commitRequest, &commitResponse) 439 return f.shouldRetry(ctx, httpResp, err, true) 440 }) 441 442 if err != nil { 443 return nil, err 444 } 445 446 file, err := f.newObjectWithInfo(ctx, info.Remote(), &updateResponse[0]) 447 448 return file, err 449 } 450 451 // Put implements the mandatory method fs.Fs.Put. 452 func (f *Fs) Put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) { 453 existingObj, err := f.NewObject(ctx, src.Remote()) 454 455 switch { 456 case err == nil: 457 return existingObj, existingObj.Update(ctx, in, src, options...) 458 case errors.Is(err, fs.ErrorObjectNotFound): 459 // Not found so create it 460 return f.PutUnchecked(ctx, in, src, options...) 461 default: 462 return nil, err 463 } 464 } 465 466 // PutUnchecked implements the optional interface fs.PutUncheckeder. 467 // 468 // Uloz.to allows to have multiple files of the same name in the same folder. 469 func (f *Fs) PutUnchecked(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) { 470 filename, folderSlug, err := f.dirCache.FindPath(ctx, src.Remote(), true) 471 472 if err != nil { 473 return nil, err 474 } 475 476 return f.uploadUnchecked(ctx, filename, folderSlug, src, in) 477 } 478 479 // Mkdir implements the mandatory method fs.Fs.Mkdir. 480 func (f *Fs) Mkdir(ctx context.Context, dir string) (err error) { 481 _, err = f.dirCache.FindDir(ctx, dir, true) 482 return err 483 } 484 485 func (f *Fs) isDirEmpty(ctx context.Context, slug string) (empty bool, err error) { 486 folders, err := f.fetchListFolderPage(ctx, slug, "", 1, 0) 487 488 if err != nil { 489 return false, err 490 } 491 492 if len(folders) > 0 { 493 return false, nil 494 } 495 496 files, err := f.fetchListFilePage(ctx, slug, "", 1, 0) 497 498 if err != nil { 499 return false, err 500 } 501 502 if len(files) > 0 { 503 return false, nil 504 } 505 506 return true, nil 507 } 508 509 // Rmdir implements the mandatory method fs.Fs.Rmdir. 510 func (f *Fs) Rmdir(ctx context.Context, dir string) error { 511 slug, err := f.dirCache.FindDir(ctx, dir, false) 512 513 if err != nil { 514 return err 515 } 516 517 empty, err := f.isDirEmpty(ctx, slug) 518 519 if err != nil { 520 return err 521 } 522 523 if !empty { 524 return fs.ErrorDirectoryNotEmpty 525 } 526 527 opts := rest.Opts{ 528 Method: "DELETE", 529 Path: "/v5/user/" + f.opt.Username + "/folder-list", 530 } 531 532 req := api.DeleteFoldersRequest{Slugs: []string{slug}} 533 err = f.pacer.Call(func() (bool, error) { 534 httpResp, err := f.rest.CallJSON(ctx, &opts, req, nil) 535 return f.shouldRetry(ctx, httpResp, err, true) 536 }) 537 538 if err != nil { 539 return err 540 } 541 542 f.dirCache.FlushDir(dir) 543 544 return nil 545 } 546 547 // Move implements the optional method fs.Mover.Move. 548 func (f *Fs) Move(ctx context.Context, src fs.Object, remote string) (fs.Object, error) { 549 if remote == src.Remote() { 550 // Already there, do nothing 551 return src, nil 552 } 553 554 srcObj, ok := src.(*Object) 555 if !ok { 556 fs.Debugf(src, "Can't move - not same remote type") 557 return nil, fs.ErrorCantMove 558 } 559 560 filename, folderSlug, err := f.dirCache.FindPath(ctx, remote, true) 561 562 if err != nil { 563 return nil, err 564 } 565 566 newObj := &Object{} 567 newObj.copyFrom(srcObj) 568 newObj.remote = remote 569 570 return newObj, newObj.updateFileProperties(ctx, api.MoveFileRequest{ 571 ParentFolderSlug: folderSlug, 572 NewFilename: filename, 573 }) 574 } 575 576 // DirMove implements the optional method fs.DirMover.DirMove. 577 func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string) error { 578 srcFs, ok := src.(*Fs) 579 if !ok { 580 fs.Debugf(srcFs, "Can't move directory - not same remote type") 581 return fs.ErrorCantDirMove 582 } 583 584 srcSlug, _, srcName, dstParentSlug, dstName, err := f.dirCache.DirMove(ctx, srcFs.dirCache, srcFs.root, srcRemote, f.root, dstRemote) 585 if err != nil { 586 return err 587 } 588 589 opts := rest.Opts{ 590 Method: "PATCH", 591 Path: "/v6/user/" + f.opt.Username + "/folder-list/parent-folder", 592 } 593 594 req := api.MoveFolderRequest{ 595 FolderSlugs: []string{srcSlug}, 596 NewParentFolderSlug: dstParentSlug, 597 } 598 599 err = f.pacer.Call(func() (bool, error) { 600 httpResp, err := f.rest.CallJSON(ctx, &opts, &req, nil) 601 return f.shouldRetry(ctx, httpResp, err, true) 602 }) 603 604 if err != nil { 605 return err 606 } 607 608 // The old folder doesn't exist anymore so clear the cache now instead of after renaming 609 srcFs.dirCache.FlushDir(srcRemote) 610 611 if srcName != dstName { 612 // There's no endpoint to rename the folder alongside moving it, so this has to happen separately. 613 opts = rest.Opts{ 614 Method: "PATCH", 615 Path: "/v7/user/" + f.opt.Username + "/folder/" + srcSlug, 616 } 617 618 renameReq := api.RenameFolderRequest{ 619 NewName: dstName, 620 } 621 622 err = f.pacer.Call(func() (bool, error) { 623 httpResp, err := f.rest.CallJSON(ctx, &opts, &renameReq, nil) 624 return f.shouldRetry(ctx, httpResp, err, true) 625 }) 626 627 return err 628 } 629 630 return nil 631 } 632 633 // Name of the remote (as passed into NewFs) 634 func (f *Fs) Name() string { 635 return f.name 636 } 637 638 // Root of the remote (as passed into NewFs) 639 func (f *Fs) Root() string { 640 return f.root 641 } 642 643 // String converts this Fs to a string 644 func (f *Fs) String() string { 645 return fmt.Sprintf("uloz.to root '%s'", f.root) 646 } 647 648 // Features returns the optional features of this Fs 649 func (f *Fs) Features() *fs.Features { 650 return f.features 651 } 652 653 // Precision return the precision of this Fs 654 func (f *Fs) Precision() time.Duration { 655 return time.Microsecond 656 } 657 658 // Hashes implements fs.Fs.Hashes by returning the supported hash types of the filesystem. 659 func (f *Fs) Hashes() hash.Set { 660 return hash.NewHashSet(hash.SHA256, hash.MD5) 661 } 662 663 // DescriptionEncodedMetadata represents a set of metadata encoded as Uloz.to description. 664 // 665 // Uloz.to doesn't support setting metadata such as mtime but allows the user to set an arbitrary description field. 666 // The content of this structure will be serialized and stored in the backend. 667 // 668 // The files themselves are immutable so there's no danger that the file changes, and we'll forget to update the hashes. 669 // It is theoretically possible to rewrite the description to provide incorrect information for a file. However, in case 670 // it's a real attack vector, a nefarious person already has write access to the repo, and the situation is above 671 // rclone's pay grade already. 672 type DescriptionEncodedMetadata struct { 673 Md5Hash []byte // The MD5 hash of the file 674 Sha256Hash []byte // The SHA256 hash of the file 675 ModTimeEpochMicros int64 // The mtime of the file, as set by rclone 676 } 677 678 func (md *DescriptionEncodedMetadata) encode() (string, error) { 679 b := bytes.Buffer{} 680 e := gob.NewEncoder(&b) 681 err := e.Encode(md) 682 if err != nil { 683 return "", err 684 } 685 // Version the encoded string from the beginning even though we don't need it yet. 686 return "1;" + base64.StdEncoding.EncodeToString(b.Bytes()), nil 687 } 688 689 func decodeDescriptionMetadata(str string) (*DescriptionEncodedMetadata, error) { 690 // The encoded data starts with a version number which is not a part iof the serialized object 691 spl := strings.SplitN(str, ";", 2) 692 693 if len(spl) < 2 || spl[0] != "1" { 694 return nil, errors.New("can't decode, unknown encoded metadata version") 695 } 696 697 m := DescriptionEncodedMetadata{} 698 by, err := base64.StdEncoding.DecodeString(spl[1]) 699 if err != nil { 700 return nil, err 701 } 702 b := bytes.Buffer{} 703 b.Write(by) 704 d := gob.NewDecoder(&b) 705 err = d.Decode(&m) 706 if err != nil { 707 return nil, err 708 } 709 return &m, nil 710 } 711 712 // Object describes an uloz.to object. 713 // 714 // Valid objects will always have all fields but encodedMetadata set. 715 type Object struct { 716 fs *Fs // what this object is part of 717 remote string // The remote path 718 name string // The file name 719 size int64 // size of the object 720 slug string // ID of the object 721 remoteFsMtime time.Time // The time the object was last modified in the remote fs. 722 // Metadata not available natively and encoded in the description field. May not be present if the encoded metadata 723 // is not present (e.g. if file wasn't uploaded by rclone) or invalid. 724 encodedMetadata *DescriptionEncodedMetadata 725 } 726 727 // Storable implements the mandatory method fs.ObjectInfo.Storable 728 func (o *Object) Storable() bool { 729 return true 730 } 731 732 func (o *Object) updateFileProperties(ctx context.Context, req interface{}) (err error) { 733 var resp *api.File 734 735 opts := rest.Opts{ 736 Method: "PATCH", 737 Path: "/v8/file/" + o.slug + "/private", 738 } 739 740 err = o.fs.pacer.Call(func() (bool, error) { 741 httpResp, err := o.fs.rest.CallJSON(ctx, &opts, &req, &resp) 742 return o.fs.shouldRetry(ctx, httpResp, err, true) 743 }) 744 745 if err != nil { 746 return err 747 } 748 749 return o.setMetaData(resp) 750 } 751 752 // SetModTime implements the mandatory method fs.Object.SetModTime 753 func (o *Object) SetModTime(ctx context.Context, t time.Time) (err error) { 754 var newMetadata DescriptionEncodedMetadata 755 if o.encodedMetadata == nil { 756 newMetadata = DescriptionEncodedMetadata{} 757 } else { 758 newMetadata = *o.encodedMetadata 759 } 760 761 newMetadata.ModTimeEpochMicros = t.UnixMicro() 762 encoded, err := newMetadata.encode() 763 if err != nil { 764 return err 765 } 766 return o.updateFileProperties(ctx, api.UpdateDescriptionRequest{ 767 Description: encoded, 768 }) 769 } 770 771 // Open implements the mandatory method fs.Object.Open 772 func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (rc io.ReadCloser, err error) { 773 opts := rest.Opts{ 774 Method: "POST", 775 Path: "/v5/file/download-link/vipdata", 776 } 777 778 req := &api.GetDownloadLinkRequest{ 779 Slug: o.slug, 780 UserLogin: o.fs.opt.Username, 781 // Has to be set but doesn't seem to be used server side. 782 DeviceID: "foobar", 783 } 784 785 var resp *api.GetDownloadLinkResponse 786 787 err = o.fs.pacer.Call(func() (bool, error) { 788 httpResp, err := o.fs.rest.CallJSON(ctx, &opts, &req, &resp) 789 return o.fs.shouldRetry(ctx, httpResp, err, true) 790 }) 791 if err != nil { 792 return nil, err 793 } 794 795 opts = rest.Opts{ 796 Method: "GET", 797 RootURL: resp.Link, 798 Options: options, 799 } 800 801 var httpResp *http.Response 802 803 err = o.fs.pacer.Call(func() (bool, error) { 804 httpResp, err = o.fs.cdn.Call(ctx, &opts) 805 return o.fs.shouldRetry(ctx, httpResp, err, true) 806 }) 807 if err != nil { 808 return nil, err 809 } 810 return httpResp.Body, err 811 } 812 813 func (o *Object) copyFrom(other *Object) { 814 o.fs = other.fs 815 o.remote = other.remote 816 o.size = other.size 817 o.slug = other.slug 818 o.remoteFsMtime = other.remoteFsMtime 819 o.encodedMetadata = other.encodedMetadata 820 } 821 822 // RenamingObjectInfoProxy is a delegating proxy for fs.ObjectInfo 823 // with the option of specifying a different remote path. 824 type RenamingObjectInfoProxy struct { 825 delegate fs.ObjectInfo 826 remote string 827 } 828 829 // Remote implements fs.ObjectInfo.Remote by delegating to the wrapped instance. 830 func (s *RenamingObjectInfoProxy) String() string { 831 return s.delegate.String() 832 } 833 834 // Remote implements fs.ObjectInfo.Remote by returning the specified remote path. 835 func (s *RenamingObjectInfoProxy) Remote() string { 836 return s.remote 837 } 838 839 // ModTime implements fs.ObjectInfo.ModTime by delegating to the wrapped instance. 840 func (s *RenamingObjectInfoProxy) ModTime(ctx context.Context) time.Time { 841 return s.delegate.ModTime(ctx) 842 } 843 844 // Size implements fs.ObjectInfo.Size by delegating to the wrapped instance. 845 func (s *RenamingObjectInfoProxy) Size() int64 { 846 return s.delegate.Size() 847 } 848 849 // Fs implements fs.ObjectInfo.Fs by delegating to the wrapped instance. 850 func (s *RenamingObjectInfoProxy) Fs() fs.Info { 851 return s.delegate.Fs() 852 } 853 854 // Hash implements fs.ObjectInfo.Hash by delegating to the wrapped instance. 855 func (s *RenamingObjectInfoProxy) Hash(ctx context.Context, ty hash.Type) (string, error) { 856 return s.delegate.Hash(ctx, ty) 857 } 858 859 // Storable implements fs.ObjectInfo.Storable by delegating to the wrapped instance. 860 func (s *RenamingObjectInfoProxy) Storable() bool { 861 return s.delegate.Storable() 862 } 863 864 // Update implements the mandatory method fs.Object.Update 865 func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) error { 866 // The backend allows to store multiple files with the same name, so simply upload the new file and remove the old 867 // one afterwards. 868 info := &RenamingObjectInfoProxy{ 869 delegate: src, 870 remote: o.Remote(), 871 } 872 newo, err := o.fs.PutUnchecked(ctx, in, info, options...) 873 874 if err != nil { 875 return err 876 } 877 878 err = o.Remove(ctx) 879 if err != nil { 880 return err 881 } 882 883 o.copyFrom(newo.(*Object)) 884 885 return nil 886 } 887 888 // Remove implements the mandatory method fs.Object.Remove 889 func (o *Object) Remove(ctx context.Context) error { 890 for i := 0; i < 2; i++ { 891 // First call moves the item to recycle bin, second deletes it for good 892 var err error 893 opts := rest.Opts{ 894 Method: "DELETE", 895 Path: "/v6/file/" + o.slug + "/private", 896 } 897 err = o.fs.pacer.Call(func() (bool, error) { 898 httpResp, err := o.fs.rest.CallJSON(ctx, &opts, nil, nil) 899 return o.fs.shouldRetry(ctx, httpResp, err, true) 900 }) 901 if err != nil { 902 return err 903 } 904 } 905 906 return nil 907 } 908 909 // ModTime implements the mandatory method fs.Object.ModTime 910 func (o *Object) ModTime(ctx context.Context) time.Time { 911 if o.encodedMetadata != nil { 912 return time.UnixMicro(o.encodedMetadata.ModTimeEpochMicros) 913 } 914 915 // The time the object was last modified on the server - a handwavy guess, but we don't have any better 916 return o.remoteFsMtime 917 918 } 919 920 // Fs implements the mandatory method fs.Object.Fs 921 func (o *Object) Fs() fs.Info { 922 return o.fs 923 } 924 925 // String returns the string representation of the remote object reference. 926 func (o *Object) String() string { 927 if o == nil { 928 return "<nil>" 929 } 930 return o.remote 931 } 932 933 // Remote returns the remote path 934 func (o *Object) Remote() string { 935 return o.remote 936 } 937 938 // Size returns the size of an object in bytes 939 func (o *Object) Size() int64 { 940 return o.size 941 } 942 943 // Hash implements the mandatory method fs.Object.Hash. 944 // 945 // Supports SHA256 and MD5 hashes. 946 func (o *Object) Hash(ctx context.Context, t hash.Type) (string, error) { 947 if t != hash.MD5 && t != hash.SHA256 { 948 return "", hash.ErrUnsupported 949 } 950 951 if o.encodedMetadata == nil { 952 return "", nil 953 } 954 955 switch t { 956 case hash.MD5: 957 return hex.EncodeToString(o.encodedMetadata.Md5Hash), nil 958 case hash.SHA256: 959 return hex.EncodeToString(o.encodedMetadata.Sha256Hash), nil 960 } 961 962 panic("Should never get here") 963 } 964 965 // FindLeaf implements dircache.DirCacher.FindLeaf by successively walking through the folder hierarchy until 966 // the desired folder is found, or there's nowhere to continue. 967 func (f *Fs) FindLeaf(ctx context.Context, folderSlug, leaf string) (leafSlug string, found bool, err error) { 968 folders, err := f.listFolders(ctx, folderSlug, leaf) 969 if err != nil { 970 if errors.Is(err, fs.ErrorDirNotFound) { 971 return "", false, nil 972 } 973 return "", false, err 974 } 975 976 for _, folder := range folders { 977 if folder.Name == leaf { 978 return folder.Slug, true, nil 979 } 980 } 981 982 // Uloz.to allows creation of multiple files / folders with the same name in the same parent folder. rclone always 983 // expects folder paths to be unique (no other file or folder with the same name should exist). As a result we also 984 // need to look at the files to return the correct error if necessary. 985 files, err := f.listFiles(ctx, folderSlug, leaf) 986 if err != nil { 987 return "", false, err 988 } 989 990 for _, file := range files { 991 if file.Name == leaf { 992 return "", false, fs.ErrorIsFile 993 } 994 } 995 996 // The parent folder exists but no file or folder with the given name was found in it. 997 return "", false, nil 998 } 999 1000 // CreateDir implements dircache.DirCacher.CreateDir by creating a folder with the given name under a folder identified 1001 // by parentSlug. 1002 func (f *Fs) CreateDir(ctx context.Context, parentSlug, leaf string) (newID string, err error) { 1003 var folder *api.Folder 1004 opts := rest.Opts{ 1005 Method: "POST", 1006 Path: "/v6/user/" + f.opt.Username + "/folder", 1007 Parameters: url.Values{}, 1008 } 1009 mkdir := api.CreateFolderRequest{ 1010 Name: f.opt.Enc.FromStandardName(leaf), 1011 ParentFolderSlug: parentSlug, 1012 } 1013 err = f.pacer.Call(func() (bool, error) { 1014 httpResp, err := f.rest.CallJSON(ctx, &opts, &mkdir, &folder) 1015 return f.shouldRetry(ctx, httpResp, err, true) 1016 }) 1017 if err != nil { 1018 return "", err 1019 } 1020 return folder.Slug, nil 1021 } 1022 1023 func (f *Fs) newObjectWithInfo(ctx context.Context, remote string, info *api.File) (*Object, error) { 1024 o := &Object{ 1025 fs: f, 1026 remote: remote, 1027 } 1028 var err error 1029 1030 if info == nil { 1031 info, err = f.readMetaDataForPath(ctx, remote) 1032 } 1033 1034 if err != nil { 1035 return nil, err 1036 } 1037 1038 err = o.setMetaData(info) 1039 if err != nil { 1040 return nil, err 1041 } 1042 return o, nil 1043 } 1044 1045 // readMetaDataForPath reads the metadata from the path 1046 func (f *Fs) readMetaDataForPath(ctx context.Context, path string) (info *api.File, err error) { 1047 filename, folderSlug, err := f.dirCache.FindPath(ctx, path, false) 1048 if err != nil { 1049 if errors.Is(err, fs.ErrorDirNotFound) { 1050 return nil, fs.ErrorObjectNotFound 1051 } 1052 return nil, err 1053 } 1054 1055 files, err := f.listFiles(ctx, folderSlug, filename) 1056 1057 if err != nil { 1058 return nil, err 1059 } 1060 1061 for _, file := range files { 1062 if file.Name == filename { 1063 return &file, nil 1064 } 1065 } 1066 1067 folders, err := f.listFolders(ctx, folderSlug, filename) 1068 1069 if err != nil { 1070 return nil, err 1071 } 1072 1073 for _, file := range folders { 1074 if file.Name == filename { 1075 return nil, fs.ErrorIsDir 1076 } 1077 } 1078 1079 return nil, fs.ErrorObjectNotFound 1080 } 1081 1082 func (o *Object) setMetaData(info *api.File) (err error) { 1083 o.name = info.Name 1084 o.size = info.Filesize 1085 o.remoteFsMtime = info.LastUserModified 1086 o.encodedMetadata, err = decodeDescriptionMetadata(info.Description) 1087 if err != nil { 1088 fs.Debugf(o, "Couldn't decode metadata: %v", err) 1089 } 1090 o.slug = info.Slug 1091 return nil 1092 } 1093 1094 // NewObject implements fs.Fs.NewObject. 1095 func (f *Fs) NewObject(ctx context.Context, remote string) (fs.Object, error) { 1096 return f.newObjectWithInfo(ctx, remote, nil) 1097 } 1098 1099 // List implements fs.Fs.List by listing all files and folders in the given folder. 1100 func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err error) { 1101 folderSlug, err := f.dirCache.FindDir(ctx, dir, false) 1102 if err != nil { 1103 return nil, err 1104 } 1105 1106 folders, err := f.listFolders(ctx, folderSlug, "") 1107 if err != nil { 1108 return nil, err 1109 } 1110 1111 for _, folder := range folders { 1112 remote := path.Join(dir, folder.Name) 1113 f.dirCache.Put(remote, folder.Slug) 1114 entries = append(entries, fs.NewDir(remote, folder.LastUserModified)) 1115 } 1116 1117 files, err := f.listFiles(ctx, folderSlug, "") 1118 if err != nil { 1119 return nil, err 1120 } 1121 1122 for _, file := range files { 1123 remote := path.Join(dir, file.Name) 1124 remoteFile, err := f.newObjectWithInfo(ctx, remote, &file) 1125 if err != nil { 1126 return nil, err 1127 } 1128 entries = append(entries, remoteFile) 1129 } 1130 1131 return entries, nil 1132 } 1133 1134 func (f *Fs) fetchListFolderPage( 1135 ctx context.Context, 1136 folderSlug string, 1137 searchQuery string, 1138 limit int, 1139 offset int) (folders []api.Folder, err error) { 1140 1141 opts := rest.Opts{ 1142 Method: "GET", 1143 Path: "/v9/user/" + f.opt.Username + "/folder/" + folderSlug + "/folder-list", 1144 Parameters: url.Values{}, 1145 } 1146 1147 opts.Parameters.Set("status", "ok") 1148 opts.Parameters.Set("limit", strconv.Itoa(limit)) 1149 if offset > 0 { 1150 opts.Parameters.Set("offset", strconv.Itoa(offset)) 1151 } 1152 1153 if searchQuery != "" { 1154 opts.Parameters.Set("search_query", f.opt.Enc.FromStandardName(searchQuery)) 1155 } 1156 1157 var respBody *api.ListFoldersResponse 1158 1159 err = f.pacer.Call(func() (bool, error) { 1160 httpResp, err := f.rest.CallJSON(ctx, &opts, nil, &respBody) 1161 return f.shouldRetry(ctx, httpResp, err, true) 1162 }) 1163 1164 if err != nil { 1165 return nil, err 1166 } 1167 1168 for i := range respBody.Subfolders { 1169 respBody.Subfolders[i].Name = f.opt.Enc.ToStandardName(respBody.Subfolders[i].Name) 1170 } 1171 1172 return respBody.Subfolders, nil 1173 } 1174 1175 func (f *Fs) listFolders( 1176 ctx context.Context, 1177 folderSlug string, 1178 searchQuery string) (folders []api.Folder, err error) { 1179 1180 targetPageSize := f.opt.ListPageSize 1181 lastPageSize := targetPageSize 1182 offset := 0 1183 1184 for targetPageSize == lastPageSize { 1185 page, err := f.fetchListFolderPage(ctx, folderSlug, searchQuery, targetPageSize, offset) 1186 if err != nil { 1187 var apiErr *api.Error 1188 casted := errors.As(err, &apiErr) 1189 if casted && apiErr.ErrorCode == 30001 { 1190 return nil, fs.ErrorDirNotFound 1191 } 1192 return nil, err 1193 } 1194 lastPageSize = len(page) 1195 offset += lastPageSize 1196 folders = append(folders, page...) 1197 } 1198 1199 return folders, nil 1200 } 1201 1202 func (f *Fs) fetchListFilePage( 1203 ctx context.Context, 1204 folderSlug string, 1205 searchQuery string, 1206 limit int, 1207 offset int) (folders []api.File, err error) { 1208 1209 opts := rest.Opts{ 1210 Method: "GET", 1211 Path: "/v8/user/" + f.opt.Username + "/folder/" + folderSlug + "/file-list", 1212 Parameters: url.Values{}, 1213 } 1214 opts.Parameters.Set("status", "ok") 1215 opts.Parameters.Set("limit", strconv.Itoa(limit)) 1216 if offset > 0 { 1217 opts.Parameters.Set("offset", strconv.Itoa(offset)) 1218 } 1219 1220 if searchQuery != "" { 1221 opts.Parameters.Set("search_query", f.opt.Enc.FromStandardName(searchQuery)) 1222 } 1223 1224 var respBody *api.ListFilesResponse 1225 1226 err = f.pacer.Call(func() (bool, error) { 1227 httpResp, err := f.rest.CallJSON(ctx, &opts, nil, &respBody) 1228 return f.shouldRetry(ctx, httpResp, err, true) 1229 }) 1230 1231 if err != nil { 1232 return nil, fmt.Errorf("couldn't list files: %w", err) 1233 } 1234 1235 for i := range respBody.Items { 1236 respBody.Items[i].Name = f.opt.Enc.ToStandardName(respBody.Items[i].Name) 1237 } 1238 1239 return respBody.Items, nil 1240 } 1241 1242 func (f *Fs) listFiles( 1243 ctx context.Context, 1244 folderSlug string, 1245 searchQuery string) (folders []api.File, err error) { 1246 1247 targetPageSize := f.opt.ListPageSize 1248 lastPageSize := targetPageSize 1249 offset := 0 1250 1251 for targetPageSize == lastPageSize { 1252 page, err := f.fetchListFilePage(ctx, folderSlug, searchQuery, targetPageSize, offset) 1253 if err != nil { 1254 return nil, err 1255 } 1256 lastPageSize = len(page) 1257 offset += lastPageSize 1258 folders = append(folders, page...) 1259 } 1260 1261 return folders, nil 1262 } 1263 1264 // DirCacheFlush implements the optional fs.DirCacheFlusher interface. 1265 func (f *Fs) DirCacheFlush() { 1266 f.dirCache.ResetRoot() 1267 } 1268 1269 // Check the interfaces are satisfied 1270 var ( 1271 _ fs.Fs = (*Fs)(nil) 1272 _ dircache.DirCacher = (*Fs)(nil) 1273 _ fs.DirCacheFlusher = (*Fs)(nil) 1274 _ fs.PutUncheckeder = (*Fs)(nil) 1275 _ fs.Mover = (*Fs)(nil) 1276 _ fs.DirMover = (*Fs)(nil) 1277 _ fs.Object = (*Object)(nil) 1278 _ fs.ObjectInfo = (*RenamingObjectInfoProxy)(nil) 1279 )