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