github.com/rclone/rclone@v1.66.1-0.20240517100346-7b89735ae726/backend/koofr/koofr.go (about) 1 // Package koofr provides an interface to the Koofr storage system. 2 package koofr 3 4 import ( 5 "context" 6 "encoding/base64" 7 "errors" 8 "fmt" 9 "io" 10 "net/http" 11 "path" 12 "strings" 13 "time" 14 15 "github.com/rclone/rclone/fs" 16 "github.com/rclone/rclone/fs/config" 17 "github.com/rclone/rclone/fs/config/configmap" 18 "github.com/rclone/rclone/fs/config/configstruct" 19 "github.com/rclone/rclone/fs/config/obscure" 20 "github.com/rclone/rclone/fs/fshttp" 21 "github.com/rclone/rclone/fs/hash" 22 "github.com/rclone/rclone/lib/encoder" 23 24 httpclient "github.com/koofr/go-httpclient" 25 koofrclient "github.com/koofr/go-koofrclient" 26 ) 27 28 // Register Fs with rclone 29 func init() { 30 fs.Register(&fs.RegInfo{ 31 Name: "koofr", 32 Description: "Koofr, Digi Storage and other Koofr-compatible storage providers", 33 NewFs: NewFs, 34 Options: []fs.Option{{ 35 Name: fs.ConfigProvider, 36 Help: "Choose your storage provider.", 37 // NOTE if you add a new provider here, then add it in the 38 // setProviderDefaults() function and update options accordingly 39 Examples: []fs.OptionExample{{ 40 Value: "koofr", 41 Help: "Koofr, https://app.koofr.net/", 42 }, { 43 Value: "digistorage", 44 Help: "Digi Storage, https://storage.rcs-rds.ro/", 45 }, { 46 Value: "other", 47 Help: "Any other Koofr API compatible storage service", 48 }}, 49 }, { 50 Name: "endpoint", 51 Help: "The Koofr API endpoint to use.", 52 Provider: "other", 53 Required: true, 54 }, { 55 Name: "mountid", 56 Help: "Mount ID of the mount to use.\n\nIf omitted, the primary mount is used.", 57 Advanced: true, 58 }, { 59 Name: "setmtime", 60 Help: "Does the backend support setting modification time.\n\nSet this to false if you use a mount ID that points to a Dropbox or Amazon Drive backend.", 61 Default: true, 62 Advanced: true, 63 }, { 64 Name: "user", 65 Help: "Your user name.", 66 Required: true, 67 Sensitive: true, 68 }, { 69 Name: "password", 70 Help: "Your password for rclone generate one at https://app.koofr.net/app/admin/preferences/password.", 71 Provider: "koofr", 72 IsPassword: true, 73 Required: true, 74 }, { 75 Name: "password", 76 Help: "Your password for rclone generate one at https://storage.rcs-rds.ro/app/admin/preferences/password.", 77 Provider: "digistorage", 78 IsPassword: true, 79 Required: true, 80 }, { 81 Name: "password", 82 Help: "Your password for rclone (generate one at your service's settings page).", 83 Provider: "other", 84 IsPassword: true, 85 Required: true, 86 }, { 87 Name: config.ConfigEncoding, 88 Help: config.ConfigEncodingHelp, 89 Advanced: true, 90 // Encode invalid UTF-8 bytes as json doesn't handle them properly. 91 Default: (encoder.Display | 92 encoder.EncodeBackSlash | 93 encoder.EncodeInvalidUtf8), 94 }}, 95 }) 96 } 97 98 // Options represent the configuration of the Koofr backend 99 type Options struct { 100 Provider string `config:"provider"` 101 Endpoint string `config:"endpoint"` 102 MountID string `config:"mountid"` 103 User string `config:"user"` 104 Password string `config:"password"` 105 SetMTime bool `config:"setmtime"` 106 Enc encoder.MultiEncoder `config:"encoding"` 107 } 108 109 // An Fs is a representation of a remote Koofr Fs 110 type Fs struct { 111 name string 112 mountID string 113 root string 114 opt Options 115 features *fs.Features 116 client *koofrclient.KoofrClient 117 } 118 119 // An Object on the remote Koofr Fs 120 type Object struct { 121 fs *Fs 122 remote string 123 info koofrclient.FileInfo 124 } 125 126 func base(pth string) string { 127 rv := path.Base(pth) 128 if rv == "" || rv == "." { 129 rv = "/" 130 } 131 return rv 132 } 133 134 func dir(pth string) string { 135 rv := path.Dir(pth) 136 if rv == "" || rv == "." { 137 rv = "/" 138 } 139 return rv 140 } 141 142 // String returns a string representation of the remote Object 143 func (o *Object) String() string { 144 return o.remote 145 } 146 147 // Remote returns the remote path of the Object, relative to Fs root 148 func (o *Object) Remote() string { 149 return o.remote 150 } 151 152 // ModTime returns the modification time of the Object 153 func (o *Object) ModTime(ctx context.Context) time.Time { 154 return time.Unix(o.info.Modified/1000, (o.info.Modified%1000)*1000*1000) 155 } 156 157 // Size return the size of the Object in bytes 158 func (o *Object) Size() int64 { 159 return o.info.Size 160 } 161 162 // Fs returns a reference to the Koofr Fs containing the Object 163 func (o *Object) Fs() fs.Info { 164 return o.fs 165 } 166 167 // Hash returns an MD5 hash of the Object 168 func (o *Object) Hash(ctx context.Context, typ hash.Type) (string, error) { 169 if typ == hash.MD5 { 170 return o.info.Hash, nil 171 } 172 return "", nil 173 } 174 175 // fullPath returns full path of the remote Object (including Fs root) 176 func (o *Object) fullPath() string { 177 return o.fs.fullPath(o.remote) 178 } 179 180 // Storable returns true if the Object is storable 181 func (o *Object) Storable() bool { 182 return true 183 } 184 185 // SetModTime is not supported 186 func (o *Object) SetModTime(ctx context.Context, mtime time.Time) error { 187 return fs.ErrorCantSetModTimeWithoutDelete 188 } 189 190 // Open opens the Object for reading 191 func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (io.ReadCloser, error) { 192 var sOff, eOff int64 = 0, -1 193 194 fs.FixRangeOption(options, o.Size()) 195 for _, option := range options { 196 switch x := option.(type) { 197 case *fs.SeekOption: 198 sOff = x.Offset 199 case *fs.RangeOption: 200 sOff = x.Start 201 eOff = x.End 202 default: 203 if option.Mandatory() { 204 fs.Logf(o, "Unsupported mandatory option: %v", option) 205 } 206 } 207 } 208 if sOff == 0 && eOff < 0 { 209 return o.fs.client.FilesGet(o.fs.mountID, o.fullPath()) 210 } 211 span := &koofrclient.FileSpan{ 212 Start: sOff, 213 End: eOff, 214 } 215 return o.fs.client.FilesGetRange(o.fs.mountID, o.fullPath(), span) 216 } 217 218 // Update updates the Object contents 219 func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) error { 220 mtime := src.ModTime(ctx).UnixNano() / 1000 / 1000 221 putopts := &koofrclient.PutOptions{ 222 ForceOverwrite: true, 223 NoRename: true, 224 OverwriteIgnoreNonExisting: true, 225 SetModified: &mtime, 226 } 227 fullPath := o.fullPath() 228 dirPath := dir(fullPath) 229 name := base(fullPath) 230 err := o.fs.mkdir(dirPath) 231 if err != nil { 232 return err 233 } 234 info, err := o.fs.client.FilesPutWithOptions(o.fs.mountID, dirPath, name, in, putopts) 235 if err != nil { 236 return err 237 } 238 o.info = *info 239 return nil 240 } 241 242 // Remove deletes the remote Object 243 func (o *Object) Remove(ctx context.Context) error { 244 return o.fs.client.FilesDelete(o.fs.mountID, o.fullPath()) 245 } 246 247 // Name returns the name of the Fs 248 func (f *Fs) Name() string { 249 return f.name 250 } 251 252 // Root returns the root path of the Fs 253 func (f *Fs) Root() string { 254 return f.root 255 } 256 257 // String returns a string representation of the Fs 258 func (f *Fs) String() string { 259 return "koofr:" + f.mountID + ":" + f.root 260 } 261 262 // Features returns the optional features supported by this Fs 263 func (f *Fs) Features() *fs.Features { 264 return f.features 265 } 266 267 // Precision denotes that setting modification times is not supported 268 func (f *Fs) Precision() time.Duration { 269 if !f.opt.SetMTime { 270 return fs.ModTimeNotSupported 271 } 272 return time.Millisecond 273 } 274 275 // Hashes returns a set of hashes are Provided by the Fs 276 func (f *Fs) Hashes() hash.Set { 277 return hash.Set(hash.MD5) 278 } 279 280 // fullPath constructs a full, absolute path from an Fs root relative path, 281 func (f *Fs) fullPath(part string) string { 282 return f.opt.Enc.FromStandardPath(path.Join("/", f.root, part)) 283 } 284 285 func setProviderDefaults(opt *Options) { 286 // handle old, provider-less configs 287 if opt.Provider == "" { 288 if opt.Endpoint == "" || strings.HasPrefix(opt.Endpoint, "https://app.koofr.net") { 289 opt.Provider = "koofr" 290 } else if strings.HasPrefix(opt.Endpoint, "https://storage.rcs-rds.ro") { 291 opt.Provider = "digistorage" 292 } else { 293 opt.Provider = "other" 294 } 295 } 296 // now assign an endpoint 297 if opt.Provider == "koofr" { 298 opt.Endpoint = "https://app.koofr.net" 299 } else if opt.Provider == "digistorage" { 300 opt.Endpoint = "https://storage.rcs-rds.ro" 301 } 302 } 303 304 // NewFs constructs a new filesystem given a root path and rclone configuration options 305 func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (ff fs.Fs, err error) { 306 opt := new(Options) 307 err = configstruct.Set(m, opt) 308 if err != nil { 309 return nil, err 310 } 311 setProviderDefaults(opt) 312 return NewFsFromOptions(ctx, name, root, opt) 313 } 314 315 // NewFsFromOptions constructs a new filesystem given a root path and internal configuration options 316 func NewFsFromOptions(ctx context.Context, name, root string, opt *Options) (ff fs.Fs, err error) { 317 pass, err := obscure.Reveal(opt.Password) 318 if err != nil { 319 return nil, err 320 } 321 httpClient := httpclient.New() 322 httpClient.Client = fshttp.NewClient(ctx) 323 client := koofrclient.NewKoofrClientWithHTTPClient(opt.Endpoint, httpClient) 324 basicAuth := fmt.Sprintf("Basic %s", 325 base64.StdEncoding.EncodeToString([]byte(opt.User+":"+pass))) 326 client.HTTPClient.Headers.Set("Authorization", basicAuth) 327 mounts, err := client.Mounts() 328 if err != nil { 329 return nil, err 330 } 331 f := &Fs{ 332 name: name, 333 root: root, 334 opt: *opt, 335 client: client, 336 } 337 f.features = (&fs.Features{ 338 CaseInsensitive: true, 339 DuplicateFiles: false, 340 BucketBased: false, 341 CanHaveEmptyDirectories: true, 342 }).Fill(ctx, f) 343 for _, m := range mounts { 344 if opt.MountID != "" { 345 if m.Id == opt.MountID { 346 f.mountID = m.Id 347 break 348 } 349 } else if m.IsPrimary { 350 f.mountID = m.Id 351 break 352 } 353 } 354 if f.mountID == "" { 355 if opt.MountID == "" { 356 return nil, errors.New("failed to find primary mount") 357 } 358 return nil, errors.New("failed to find mount " + opt.MountID) 359 } 360 rootFile, err := f.client.FilesInfo(f.mountID, f.opt.Enc.FromStandardPath("/"+f.root)) 361 if err == nil && rootFile.Type != "dir" { 362 f.root = dir(f.root) 363 err = fs.ErrorIsFile 364 } else { 365 err = nil 366 } 367 return f, err 368 } 369 370 // List returns a list of items in a directory 371 func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err error) { 372 files, err := f.client.FilesList(f.mountID, f.fullPath(dir)) 373 if err != nil { 374 return nil, translateErrorsDir(err) 375 } 376 entries = make([]fs.DirEntry, len(files)) 377 for i, file := range files { 378 remote := path.Join(dir, f.opt.Enc.ToStandardName(file.Name)) 379 if file.Type == "dir" { 380 entries[i] = fs.NewDir(remote, time.Time{}) 381 } else { 382 entries[i] = &Object{ 383 fs: f, 384 info: file, 385 remote: remote, 386 } 387 } 388 } 389 return entries, nil 390 } 391 392 // NewObject creates a new remote Object for a given remote path 393 func (f *Fs) NewObject(ctx context.Context, remote string) (obj fs.Object, err error) { 394 info, err := f.client.FilesInfo(f.mountID, f.fullPath(remote)) 395 if err != nil { 396 return nil, translateErrorsObject(err) 397 } 398 if info.Type == "dir" { 399 return nil, fs.ErrorIsDir 400 } 401 return &Object{ 402 fs: f, 403 info: info, 404 remote: remote, 405 }, nil 406 } 407 408 // Put updates a remote Object 409 func (f *Fs) Put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (obj fs.Object, err error) { 410 mtime := src.ModTime(ctx).UnixNano() / 1000 / 1000 411 putopts := &koofrclient.PutOptions{ 412 ForceOverwrite: true, 413 NoRename: true, 414 OverwriteIgnoreNonExisting: true, 415 SetModified: &mtime, 416 } 417 fullPath := f.fullPath(src.Remote()) 418 dirPath := dir(fullPath) 419 name := base(fullPath) 420 err = f.mkdir(dirPath) 421 if err != nil { 422 return nil, err 423 } 424 info, err := f.client.FilesPutWithOptions(f.mountID, dirPath, name, in, putopts) 425 if err != nil { 426 return nil, translateErrorsObject(err) 427 } 428 return &Object{ 429 fs: f, 430 info: *info, 431 remote: src.Remote(), 432 }, nil 433 } 434 435 // PutStream updates a remote Object with a stream of unknown size 436 func (f *Fs) PutStream(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) { 437 return f.Put(ctx, in, src, options...) 438 } 439 440 // isBadRequest is a predicate which holds true iff the error returned was 441 // HTTP status 400 442 func isBadRequest(err error) bool { 443 switch err := err.(type) { 444 case httpclient.InvalidStatusError: 445 if err.Got == http.StatusBadRequest { 446 return true 447 } 448 } 449 return false 450 } 451 452 // translateErrorsDir translates koofr errors to rclone errors (for a dir 453 // operation) 454 func translateErrorsDir(err error) error { 455 switch err := err.(type) { 456 case httpclient.InvalidStatusError: 457 if err.Got == http.StatusNotFound { 458 return fs.ErrorDirNotFound 459 } 460 } 461 return err 462 } 463 464 // translatesErrorsObject translates Koofr errors to rclone errors (for an object operation) 465 func translateErrorsObject(err error) error { 466 switch err := err.(type) { 467 case httpclient.InvalidStatusError: 468 if err.Got == http.StatusNotFound { 469 return fs.ErrorObjectNotFound 470 } 471 } 472 return err 473 } 474 475 // mkdir creates a directory at the given remote path. Creates ancestors if 476 // necessary 477 func (f *Fs) mkdir(fullPath string) error { 478 if fullPath == "/" { 479 return nil 480 } 481 info, err := f.client.FilesInfo(f.mountID, fullPath) 482 if err == nil && info.Type == "dir" { 483 return nil 484 } 485 err = translateErrorsDir(err) 486 if err != nil && err != fs.ErrorDirNotFound { 487 return err 488 } 489 dirs := strings.Split(fullPath, "/") 490 parent := "/" 491 for _, part := range dirs { 492 if part == "" { 493 continue 494 } 495 info, err = f.client.FilesInfo(f.mountID, path.Join(parent, part)) 496 if err != nil || info.Type != "dir" { 497 err = translateErrorsDir(err) 498 if err != nil && err != fs.ErrorDirNotFound { 499 return err 500 } 501 err = f.client.FilesNewFolder(f.mountID, parent, part) 502 if err != nil && !isBadRequest(err) { 503 return err 504 } 505 } 506 parent = path.Join(parent, part) 507 } 508 return nil 509 } 510 511 // Mkdir creates a directory at the given remote path. Creates ancestors if 512 // necessary 513 func (f *Fs) Mkdir(ctx context.Context, dir string) error { 514 fullPath := f.fullPath(dir) 515 return f.mkdir(fullPath) 516 } 517 518 // Rmdir removes an (empty) directory at the given remote path 519 func (f *Fs) Rmdir(ctx context.Context, dir string) error { 520 files, err := f.client.FilesList(f.mountID, f.fullPath(dir)) 521 if err != nil { 522 return translateErrorsDir(err) 523 } 524 if len(files) > 0 { 525 return fs.ErrorDirectoryNotEmpty 526 } 527 err = f.client.FilesDelete(f.mountID, f.fullPath(dir)) 528 if err != nil { 529 return translateErrorsDir(err) 530 } 531 return nil 532 } 533 534 // Copy copies a remote Object to the given path 535 func (f *Fs) Copy(ctx context.Context, src fs.Object, remote string) (fs.Object, error) { 536 dstFullPath := f.fullPath(remote) 537 dstDir := dir(dstFullPath) 538 err := f.mkdir(dstDir) 539 if err != nil { 540 return nil, fs.ErrorCantCopy 541 } 542 mtime := src.ModTime(ctx).UnixNano() / 1000 / 1000 543 err = f.client.FilesCopy((src.(*Object)).fs.mountID, 544 (src.(*Object)).fs.fullPath((src.(*Object)).remote), 545 f.mountID, dstFullPath, koofrclient.CopyOptions{SetModified: &mtime}) 546 if err != nil { 547 return nil, fs.ErrorCantCopy 548 } 549 return f.NewObject(ctx, remote) 550 } 551 552 // Move moves a remote Object to the given path 553 func (f *Fs) Move(ctx context.Context, src fs.Object, remote string) (fs.Object, error) { 554 srcObj := src.(*Object) 555 dstFullPath := f.fullPath(remote) 556 dstDir := dir(dstFullPath) 557 err := f.mkdir(dstDir) 558 if err != nil { 559 return nil, fs.ErrorCantMove 560 } 561 err = f.client.FilesMove(srcObj.fs.mountID, 562 srcObj.fs.fullPath(srcObj.remote), f.mountID, dstFullPath) 563 if err != nil { 564 return nil, fs.ErrorCantMove 565 } 566 return f.NewObject(ctx, remote) 567 } 568 569 // DirMove moves a remote directory to the given path 570 func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string) error { 571 srcFs := src.(*Fs) 572 srcFullPath := srcFs.fullPath(srcRemote) 573 dstFullPath := f.fullPath(dstRemote) 574 if srcFs.mountID == f.mountID && srcFullPath == dstFullPath { 575 return fs.ErrorDirExists 576 } 577 dstDir := dir(dstFullPath) 578 err := f.mkdir(dstDir) 579 if err != nil { 580 return fs.ErrorCantDirMove 581 } 582 err = f.client.FilesMove(srcFs.mountID, srcFullPath, f.mountID, dstFullPath) 583 if err != nil { 584 return fs.ErrorCantDirMove 585 } 586 return nil 587 } 588 589 // About reports space usage (with a MiB precision) 590 func (f *Fs) About(ctx context.Context) (*fs.Usage, error) { 591 mount, err := f.client.MountsDetails(f.mountID) 592 if err != nil { 593 return nil, err 594 } 595 return &fs.Usage{ 596 Total: fs.NewUsageValue(mount.SpaceTotal * 1024 * 1024), 597 Used: fs.NewUsageValue(mount.SpaceUsed * 1024 * 1024), 598 Trashed: nil, 599 Other: nil, 600 Free: fs.NewUsageValue((mount.SpaceTotal - mount.SpaceUsed) * 1024 * 1024), 601 Objects: nil, 602 }, nil 603 } 604 605 // Purge purges the complete Fs 606 func (f *Fs) Purge(ctx context.Context) error { 607 err := translateErrorsDir(f.client.FilesDelete(f.mountID, f.fullPath(""))) 608 return err 609 } 610 611 // linkCreate is a Koofr API request for creating a public link 612 type linkCreate struct { 613 Path string `json:"path"` 614 } 615 616 // link is a Koofr API response to creating a public link 617 type link struct { 618 ID string `json:"id"` 619 Name string `json:"name"` 620 Path string `json:"path"` 621 Counter int64 `json:"counter"` 622 URL string `json:"url"` 623 ShortURL string `json:"shortUrl"` 624 Hash string `json:"hash"` 625 Host string `json:"host"` 626 HasPassword bool `json:"hasPassword"` 627 Password string `json:"password"` 628 ValidFrom int64 `json:"validFrom"` 629 ValidTo int64 `json:"validTo"` 630 PasswordRequired bool `json:"passwordRequired"` 631 } 632 633 // createLink makes a Koofr API call to create a public link 634 func createLink(c *koofrclient.KoofrClient, mountID string, path string) (*link, error) { 635 linkCreate := linkCreate{ 636 Path: path, 637 } 638 linkData := link{} 639 640 request := httpclient.RequestData{ 641 Method: "POST", 642 Path: "/api/v2/mounts/" + mountID + "/links", 643 ExpectedStatus: []int{http.StatusOK, http.StatusCreated}, 644 ReqEncoding: httpclient.EncodingJSON, 645 ReqValue: linkCreate, 646 RespEncoding: httpclient.EncodingJSON, 647 RespValue: &linkData, 648 } 649 650 _, err := c.Request(&request) 651 if err != nil { 652 return nil, err 653 } 654 return &linkData, nil 655 } 656 657 // PublicLink creates a public link to the remote path 658 func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (string, error) { 659 linkData, err := createLink(f.client, f.mountID, f.fullPath(remote)) 660 if err != nil { 661 return "", translateErrorsDir(err) 662 } 663 664 // URL returned by API looks like following: 665 // 666 // https://app.koofr.net/links/35d9fb92-74a3-4930-b4ed-57f123bfb1a6 667 // 668 // Direct url looks like following: 669 // 670 // https://app.koofr.net/content/links/39a6cc01-3b23-477a-8059-c0fb3b0f15de/files/get?path=%2F 671 // 672 // I am not sure about meaning of "path" parameter; in my experiments 673 // it is always "%2F", and omitting it or putting any other value 674 // results in 404. 675 // 676 // There is one more quirk: direct link to file in / returns that file, 677 // direct link to file somewhere else in hierarchy returns zip archive 678 // with one member. 679 link := linkData.URL 680 link = strings.ReplaceAll(link, "/links", "/content/links") 681 link += "/files/get?path=%2F" 682 683 return link, nil 684 }