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