github.com/rclone/rclone@v1.66.1-0.20240517100346-7b89735ae726/backend/http/http.go (about) 1 // Package http provides a filesystem interface using golang.org/net/http 2 // 3 // It treats HTML pages served from the endpoint as directory 4 // listings, and includes any links found as files. 5 package http 6 7 import ( 8 "context" 9 "errors" 10 "fmt" 11 "io" 12 "mime" 13 "net/http" 14 "net/url" 15 "path" 16 "strings" 17 "sync" 18 "time" 19 20 "github.com/rclone/rclone/fs" 21 "github.com/rclone/rclone/fs/config/configmap" 22 "github.com/rclone/rclone/fs/config/configstruct" 23 "github.com/rclone/rclone/fs/fshttp" 24 "github.com/rclone/rclone/fs/hash" 25 "github.com/rclone/rclone/lib/rest" 26 "golang.org/x/net/html" 27 ) 28 29 var ( 30 errorReadOnly = errors.New("http remotes are read only") 31 timeUnset = time.Unix(0, 0) 32 ) 33 34 func init() { 35 fsi := &fs.RegInfo{ 36 Name: "http", 37 Description: "HTTP", 38 NewFs: NewFs, 39 CommandHelp: commandHelp, 40 Options: []fs.Option{{ 41 Name: "url", 42 Help: "URL of HTTP host to connect to.\n\nE.g. \"https://example.com\", or \"https://user:pass@example.com\" to use a username and password.", 43 Required: true, 44 }, { 45 Name: "headers", 46 Help: `Set HTTP headers for all transactions. 47 48 Use this to set additional HTTP headers for all transactions. 49 50 The input format is comma separated list of key,value pairs. Standard 51 [CSV encoding](https://godoc.org/encoding/csv) may be used. 52 53 For example, to set a Cookie use 'Cookie,name=value', or '"Cookie","name=value"'. 54 55 You can set multiple headers, e.g. '"Cookie","name=value","Authorization","xxx"'.`, 56 Default: fs.CommaSepList{}, 57 Advanced: true, 58 }, { 59 Name: "no_slash", 60 Help: `Set this if the site doesn't end directories with /. 61 62 Use this if your target website does not use / on the end of 63 directories. 64 65 A / on the end of a path is how rclone normally tells the difference 66 between files and directories. If this flag is set, then rclone will 67 treat all files with Content-Type: text/html as directories and read 68 URLs from them rather than downloading them. 69 70 Note that this may cause rclone to confuse genuine HTML files with 71 directories.`, 72 Default: false, 73 Advanced: true, 74 }, { 75 Name: "no_head", 76 Help: `Don't use HEAD requests. 77 78 HEAD requests are mainly used to find file sizes in dir listing. 79 If your site is being very slow to load then you can try this option. 80 Normally rclone does a HEAD request for each potential file in a 81 directory listing to: 82 83 - find its size 84 - check it really exists 85 - check to see if it is a directory 86 87 If you set this option, rclone will not do the HEAD request. This will mean 88 that directory listings are much quicker, but rclone won't have the times or 89 sizes of any files, and some files that don't exist may be in the listing.`, 90 Default: false, 91 Advanced: true, 92 }, { 93 Name: "no_escape", 94 Help: "Do not escape URL metacharacters in path names.", 95 Default: false, 96 }}, 97 } 98 fs.Register(fsi) 99 } 100 101 // Options defines the configuration for this backend 102 type Options struct { 103 Endpoint string `config:"url"` 104 NoSlash bool `config:"no_slash"` 105 NoHead bool `config:"no_head"` 106 Headers fs.CommaSepList `config:"headers"` 107 NoEscape bool `config:"no_escape"` 108 } 109 110 // Fs stores the interface to the remote HTTP files 111 type Fs struct { 112 name string 113 root string 114 features *fs.Features // optional features 115 opt Options // options for this backend 116 ci *fs.ConfigInfo // global config 117 endpoint *url.URL 118 endpointURL string // endpoint as a string 119 httpClient *http.Client 120 } 121 122 // Object is a remote object that has been stat'd (so it exists, but is not necessarily open for reading) 123 type Object struct { 124 fs *Fs 125 remote string 126 size int64 127 modTime time.Time 128 contentType string 129 } 130 131 // statusError returns an error if the res contained an error 132 func statusError(res *http.Response, err error) error { 133 if err != nil { 134 return err 135 } 136 if res.StatusCode < 200 || res.StatusCode > 299 { 137 _ = res.Body.Close() 138 return fmt.Errorf("HTTP Error: %s", res.Status) 139 } 140 return nil 141 } 142 143 // getFsEndpoint decides if url is to be considered a file or directory, 144 // and returns a proper endpoint url to use for the fs. 145 func getFsEndpoint(ctx context.Context, client *http.Client, url string, opt *Options) (string, bool) { 146 // If url ends with '/' it is already a proper url always assumed to be a directory. 147 if url[len(url)-1] == '/' { 148 return url, false 149 } 150 151 // If url does not end with '/' we send a HEAD request to decide 152 // if it is directory or file, and if directory appends the missing 153 // '/', or if file returns the directory url to parent instead. 154 createFileResult := func() (string, bool) { 155 fs.Debugf(nil, "If path is a directory you must add a trailing '/'") 156 parent, _ := path.Split(url) 157 return parent, true 158 } 159 createDirResult := func() (string, bool) { 160 fs.Debugf(nil, "To avoid the initial HEAD request add a trailing '/' to the path") 161 return url + "/", false 162 } 163 164 // If HEAD requests are not allowed we just have to assume it is a file. 165 if opt.NoHead { 166 fs.Debugf(nil, "Assuming path is a file as --http-no-head is set") 167 return createFileResult() 168 } 169 170 // Use a client which doesn't follow redirects so the server 171 // doesn't redirect http://host/dir to http://host/dir/ 172 noRedir := *client 173 noRedir.CheckRedirect = func(req *http.Request, via []*http.Request) error { 174 return http.ErrUseLastResponse 175 } 176 req, err := http.NewRequestWithContext(ctx, "HEAD", url, nil) 177 if err != nil { 178 fs.Debugf(nil, "Assuming path is a file as HEAD request could not be created: %v", err) 179 return createFileResult() 180 } 181 addHeaders(req, opt) 182 res, err := noRedir.Do(req) 183 184 if err != nil { 185 fs.Debugf(nil, "Assuming path is a file as HEAD request could not be sent: %v", err) 186 return createFileResult() 187 } 188 if res.StatusCode == http.StatusNotFound { 189 fs.Debugf(nil, "Assuming path is a directory as HEAD response is it does not exist as a file (%s)", res.Status) 190 return createDirResult() 191 } 192 if res.StatusCode == http.StatusMovedPermanently || 193 res.StatusCode == http.StatusFound || 194 res.StatusCode == http.StatusSeeOther || 195 res.StatusCode == http.StatusTemporaryRedirect || 196 res.StatusCode == http.StatusPermanentRedirect { 197 redir := res.Header.Get("Location") 198 if redir != "" { 199 if redir[len(redir)-1] == '/' { 200 fs.Debugf(nil, "Assuming path is a directory as HEAD response is redirect (%s) to a path that ends with '/': %s", res.Status, redir) 201 return createDirResult() 202 } 203 fs.Debugf(nil, "Assuming path is a file as HEAD response is redirect (%s) to a path that does not end with '/': %s", res.Status, redir) 204 return createFileResult() 205 } 206 fs.Debugf(nil, "Assuming path is a file as HEAD response is redirect (%s) but no location header", res.Status) 207 return createFileResult() 208 } 209 if res.StatusCode < 200 || res.StatusCode > 299 { 210 // Example is 403 (http.StatusForbidden) for servers not allowing HEAD requests. 211 fs.Debugf(nil, "Assuming path is a file as HEAD response is an error (%s)", res.Status) 212 return createFileResult() 213 } 214 215 fs.Debugf(nil, "Assuming path is a file as HEAD response is success (%s)", res.Status) 216 return createFileResult() 217 } 218 219 // Make the http connection with opt 220 func (f *Fs) httpConnection(ctx context.Context, opt *Options) (isFile bool, err error) { 221 if len(opt.Headers)%2 != 0 { 222 return false, errors.New("odd number of headers supplied") 223 } 224 225 if !strings.HasSuffix(opt.Endpoint, "/") { 226 opt.Endpoint += "/" 227 } 228 229 // Parse the endpoint and stick the root onto it 230 base, err := url.Parse(opt.Endpoint) 231 if err != nil { 232 return false, err 233 } 234 u, err := rest.URLJoin(base, rest.URLPathEscape(f.root)) 235 if err != nil { 236 return false, err 237 } 238 239 client := fshttp.NewClient(ctx) 240 241 endpoint, isFile := getFsEndpoint(ctx, client, u.String(), opt) 242 fs.Debugf(nil, "Root: %s", endpoint) 243 u, err = url.Parse(endpoint) 244 if err != nil { 245 return false, err 246 } 247 248 // Update f with the new parameters 249 f.httpClient = client 250 f.endpoint = u 251 f.endpointURL = u.String() 252 return isFile, nil 253 } 254 255 // NewFs creates a new Fs object from the name and root. It connects to 256 // the host specified in the config file. 257 func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, error) { 258 // Parse config into Options struct 259 opt := new(Options) 260 err := configstruct.Set(m, opt) 261 if err != nil { 262 return nil, err 263 } 264 265 ci := fs.GetConfig(ctx) 266 f := &Fs{ 267 name: name, 268 root: root, 269 opt: *opt, 270 ci: ci, 271 } 272 f.features = (&fs.Features{ 273 CanHaveEmptyDirectories: true, 274 }).Fill(ctx, f) 275 276 // Make the http connection 277 isFile, err := f.httpConnection(ctx, opt) 278 if err != nil { 279 return nil, err 280 } 281 282 if isFile { 283 // return an error with an fs which points to the parent 284 return f, fs.ErrorIsFile 285 } 286 287 if !strings.HasSuffix(f.endpointURL, "/") { 288 return nil, errors.New("internal error: url doesn't end with /") 289 } 290 291 return f, nil 292 } 293 294 // Name returns the configured name of the file system 295 func (f *Fs) Name() string { 296 return f.name 297 } 298 299 // Root returns the root for the filesystem 300 func (f *Fs) Root() string { 301 return f.root 302 } 303 304 // String returns the URL for the filesystem 305 func (f *Fs) String() string { 306 return f.endpointURL 307 } 308 309 // Features returns the optional features of this Fs 310 func (f *Fs) Features() *fs.Features { 311 return f.features 312 } 313 314 // Precision is the remote http file system's modtime precision, which we have no way of knowing. We estimate at 1s 315 func (f *Fs) Precision() time.Duration { 316 return time.Second 317 } 318 319 // NewObject creates a new remote http file object 320 func (f *Fs) NewObject(ctx context.Context, remote string) (fs.Object, error) { 321 o := &Object{ 322 fs: f, 323 remote: remote, 324 } 325 err := o.head(ctx) 326 if err != nil { 327 return nil, err 328 } 329 return o, nil 330 } 331 332 // Join's the remote onto the base URL 333 func (f *Fs) url(remote string) string { 334 if f.opt.NoEscape { 335 // Directly concatenate without escaping, no_escape behavior 336 return f.endpointURL + remote 337 } 338 // Default behavior 339 return f.endpointURL + rest.URLPathEscape(remote) 340 } 341 342 // Errors returned by parseName 343 var ( 344 errURLJoinFailed = errors.New("URLJoin failed") 345 errFoundQuestionMark = errors.New("found ? in URL") 346 errHostMismatch = errors.New("host mismatch") 347 errSchemeMismatch = errors.New("scheme mismatch") 348 errNotUnderRoot = errors.New("not under root") 349 errNameIsEmpty = errors.New("name is empty") 350 errNameContainsSlash = errors.New("name contains /") 351 ) 352 353 // parseName turns a name as found in the page into a remote path or returns an error 354 func parseName(base *url.URL, name string) (string, error) { 355 // make URL absolute 356 u, err := rest.URLJoin(base, name) 357 if err != nil { 358 return "", errURLJoinFailed 359 } 360 // check it doesn't have URL parameters 361 uStr := u.String() 362 if strings.Contains(uStr, "?") { 363 return "", errFoundQuestionMark 364 } 365 // check that this is going back to the same host and scheme 366 if base.Host != u.Host { 367 return "", errHostMismatch 368 } 369 if base.Scheme != u.Scheme { 370 return "", errSchemeMismatch 371 } 372 // check has path prefix 373 if !strings.HasPrefix(u.Path, base.Path) { 374 return "", errNotUnderRoot 375 } 376 // calculate the name relative to the base 377 name = u.Path[len(base.Path):] 378 // mustn't be empty 379 if name == "" { 380 return "", errNameIsEmpty 381 } 382 // mustn't contain a / - we are looking for a single level directory 383 slash := strings.Index(name, "/") 384 if slash >= 0 && slash != len(name)-1 { 385 return "", errNameContainsSlash 386 } 387 return name, nil 388 } 389 390 // Parse turns HTML for a directory into names 391 // base should be the base URL to resolve any relative names from 392 func parse(base *url.URL, in io.Reader) (names []string, err error) { 393 doc, err := html.Parse(in) 394 if err != nil { 395 return nil, err 396 } 397 var ( 398 walk func(*html.Node) 399 seen = make(map[string]struct{}) 400 ) 401 walk = func(n *html.Node) { 402 if n.Type == html.ElementNode && n.Data == "a" { 403 for _, a := range n.Attr { 404 if a.Key == "href" { 405 name, err := parseName(base, a.Val) 406 if err == nil { 407 if _, found := seen[name]; !found { 408 names = append(names, name) 409 seen[name] = struct{}{} 410 } 411 } 412 break 413 } 414 } 415 } 416 for c := n.FirstChild; c != nil; c = c.NextSibling { 417 walk(c) 418 } 419 } 420 walk(doc) 421 return names, nil 422 } 423 424 // Adds the configured headers to the request if any 425 func addHeaders(req *http.Request, opt *Options) { 426 for i := 0; i < len(opt.Headers); i += 2 { 427 key := opt.Headers[i] 428 value := opt.Headers[i+1] 429 req.Header.Add(key, value) 430 } 431 } 432 433 // Adds the configured headers to the request if any 434 func (f *Fs) addHeaders(req *http.Request) { 435 addHeaders(req, &f.opt) 436 } 437 438 // Read the directory passed in 439 func (f *Fs) readDir(ctx context.Context, dir string) (names []string, err error) { 440 URL := f.url(dir) 441 u, err := url.Parse(URL) 442 if err != nil { 443 return nil, fmt.Errorf("failed to readDir: %w", err) 444 } 445 if !strings.HasSuffix(URL, "/") { 446 return nil, fmt.Errorf("internal error: readDir URL %q didn't end in /", URL) 447 } 448 // Do the request 449 req, err := http.NewRequestWithContext(ctx, "GET", URL, nil) 450 if err != nil { 451 return nil, fmt.Errorf("readDir failed: %w", err) 452 } 453 f.addHeaders(req) 454 res, err := f.httpClient.Do(req) 455 if err == nil { 456 defer fs.CheckClose(res.Body, &err) 457 if res.StatusCode == http.StatusNotFound { 458 return nil, fs.ErrorDirNotFound 459 } 460 } 461 err = statusError(res, err) 462 if err != nil { 463 return nil, fmt.Errorf("failed to readDir: %w", err) 464 } 465 466 contentType := strings.SplitN(res.Header.Get("Content-Type"), ";", 2)[0] 467 switch contentType { 468 case "text/html": 469 names, err = parse(u, res.Body) 470 if err != nil { 471 return nil, fmt.Errorf("readDir: %w", err) 472 } 473 default: 474 return nil, fmt.Errorf("can't parse content type %q", contentType) 475 } 476 return names, nil 477 } 478 479 // List the objects and directories in dir into entries. The 480 // entries can be returned in any order but should be for a 481 // complete directory. 482 // 483 // dir should be "" to list the root, and should not have 484 // trailing slashes. 485 // 486 // This should return ErrDirNotFound if the directory isn't 487 // found. 488 func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err error) { 489 if !strings.HasSuffix(dir, "/") && dir != "" { 490 dir += "/" 491 } 492 names, err := f.readDir(ctx, dir) 493 if err != nil { 494 return nil, fmt.Errorf("error listing %q: %w", dir, err) 495 } 496 var ( 497 entriesMu sync.Mutex // to protect entries 498 wg sync.WaitGroup 499 checkers = f.ci.Checkers 500 in = make(chan string, checkers) 501 ) 502 add := func(entry fs.DirEntry) { 503 entriesMu.Lock() 504 entries = append(entries, entry) 505 entriesMu.Unlock() 506 } 507 for i := 0; i < checkers; i++ { 508 wg.Add(1) 509 go func() { 510 defer wg.Done() 511 for remote := range in { 512 file := &Object{ 513 fs: f, 514 remote: remote, 515 } 516 switch err := file.head(ctx); err { 517 case nil: 518 add(file) 519 case fs.ErrorNotAFile: 520 // ...found a directory not a file 521 add(fs.NewDir(remote, time.Time{})) 522 default: 523 fs.Debugf(remote, "skipping because of error: %v", err) 524 } 525 } 526 }() 527 } 528 for _, name := range names { 529 isDir := name[len(name)-1] == '/' 530 name = strings.TrimRight(name, "/") 531 remote := path.Join(dir, name) 532 if isDir { 533 add(fs.NewDir(remote, time.Time{})) 534 } else { 535 in <- remote 536 } 537 } 538 close(in) 539 wg.Wait() 540 return entries, nil 541 } 542 543 // Put in to the remote path with the modTime given of the given size 544 // 545 // May create the object even if it returns an error - if so 546 // will return the object and the error, otherwise will return 547 // nil and the error 548 func (f *Fs) Put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) { 549 return nil, errorReadOnly 550 } 551 552 // PutStream uploads to the remote path with the modTime given of indeterminate size 553 func (f *Fs) PutStream(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) { 554 return nil, errorReadOnly 555 } 556 557 // Fs is the filesystem this remote http file object is located within 558 func (o *Object) Fs() fs.Info { 559 return o.fs 560 } 561 562 // String returns the URL to the remote HTTP file 563 func (o *Object) String() string { 564 if o == nil { 565 return "<nil>" 566 } 567 return o.remote 568 } 569 570 // Remote the name of the remote HTTP file, relative to the fs root 571 func (o *Object) Remote() string { 572 return o.remote 573 } 574 575 // Hash returns "" since HTTP (in Go or OpenSSH) doesn't support remote calculation of hashes 576 func (o *Object) Hash(ctx context.Context, r hash.Type) (string, error) { 577 return "", hash.ErrUnsupported 578 } 579 580 // Size returns the size in bytes of the remote http file 581 func (o *Object) Size() int64 { 582 return o.size 583 } 584 585 // ModTime returns the modification time of the remote http file 586 func (o *Object) ModTime(ctx context.Context) time.Time { 587 return o.modTime 588 } 589 590 // url returns the native url of the object 591 func (o *Object) url() string { 592 return o.fs.url(o.remote) 593 } 594 595 // head sends a HEAD request to update info fields in the Object 596 func (o *Object) head(ctx context.Context) error { 597 if o.fs.opt.NoHead { 598 o.size = -1 599 o.modTime = timeUnset 600 o.contentType = fs.MimeType(ctx, o) 601 return nil 602 } 603 url := o.url() 604 req, err := http.NewRequestWithContext(ctx, "HEAD", url, nil) 605 if err != nil { 606 return fmt.Errorf("stat failed: %w", err) 607 } 608 o.fs.addHeaders(req) 609 res, err := o.fs.httpClient.Do(req) 610 if err == nil && res.StatusCode == http.StatusNotFound { 611 return fs.ErrorObjectNotFound 612 } 613 err = statusError(res, err) 614 if err != nil { 615 return fmt.Errorf("failed to stat: %w", err) 616 } 617 return o.decodeMetadata(ctx, res) 618 } 619 620 // decodeMetadata updates info fields in the Object according to HTTP response headers 621 func (o *Object) decodeMetadata(ctx context.Context, res *http.Response) error { 622 t, err := http.ParseTime(res.Header.Get("Last-Modified")) 623 if err != nil { 624 t = timeUnset 625 } 626 o.modTime = t 627 o.contentType = res.Header.Get("Content-Type") 628 o.size = rest.ParseSizeFromHeaders(res.Header) 629 630 // If NoSlash is set then check ContentType to see if it is a directory 631 if o.fs.opt.NoSlash { 632 mediaType, _, err := mime.ParseMediaType(o.contentType) 633 if err != nil { 634 return fmt.Errorf("failed to parse Content-Type: %q: %w", o.contentType, err) 635 } 636 if mediaType == "text/html" { 637 return fs.ErrorNotAFile 638 } 639 } 640 return nil 641 } 642 643 // SetModTime sets the modification and access time to the specified time 644 // 645 // it also updates the info field 646 func (o *Object) SetModTime(ctx context.Context, modTime time.Time) error { 647 return errorReadOnly 648 } 649 650 // Storable returns whether the remote http file is a regular file (not a directory, symbolic link, block device, character device, named pipe, etc.) 651 func (o *Object) Storable() bool { 652 return true 653 } 654 655 // Open a remote http file object for reading. Seek is supported 656 func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (in io.ReadCloser, err error) { 657 url := o.url() 658 req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 659 if err != nil { 660 return nil, fmt.Errorf("Open failed: %w", err) 661 } 662 663 // Add optional headers 664 for k, v := range fs.OpenOptionHeaders(options) { 665 req.Header.Add(k, v) 666 } 667 o.fs.addHeaders(req) 668 669 // Do the request 670 res, err := o.fs.httpClient.Do(req) 671 err = statusError(res, err) 672 if err != nil { 673 return nil, fmt.Errorf("Open failed: %w", err) 674 } 675 if err = o.decodeMetadata(ctx, res); err != nil { 676 return nil, fmt.Errorf("decodeMetadata failed: %w", err) 677 } 678 return res.Body, nil 679 } 680 681 // Hashes returns hash.HashNone to indicate remote hashing is unavailable 682 func (f *Fs) Hashes() hash.Set { 683 return hash.Set(hash.None) 684 } 685 686 // Mkdir makes the root directory of the Fs object 687 func (f *Fs) Mkdir(ctx context.Context, dir string) error { 688 return errorReadOnly 689 } 690 691 // Remove a remote http file object 692 func (o *Object) Remove(ctx context.Context) error { 693 return errorReadOnly 694 } 695 696 // Rmdir removes the root directory of the Fs object 697 func (f *Fs) Rmdir(ctx context.Context, dir string) error { 698 return errorReadOnly 699 } 700 701 // Update in to the object with the modTime given of the given size 702 func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) error { 703 return errorReadOnly 704 } 705 706 // MimeType of an Object if known, "" otherwise 707 func (o *Object) MimeType(ctx context.Context) string { 708 return o.contentType 709 } 710 711 var commandHelp = []fs.CommandHelp{{ 712 Name: "set", 713 Short: "Set command for updating the config parameters.", 714 Long: `This set command can be used to update the config parameters 715 for a running http backend. 716 717 Usage Examples: 718 719 rclone backend set remote: [-o opt_name=opt_value] [-o opt_name2=opt_value2] 720 rclone rc backend/command command=set fs=remote: [-o opt_name=opt_value] [-o opt_name2=opt_value2] 721 rclone rc backend/command command=set fs=remote: -o url=https://example.com 722 723 The option keys are named as they are in the config file. 724 725 This rebuilds the connection to the http backend when it is called with 726 the new parameters. Only new parameters need be passed as the values 727 will default to those currently in use. 728 729 It doesn't return anything. 730 `, 731 }} 732 733 // Command the backend to run a named command 734 // 735 // The command run is name 736 // args may be used to read arguments from 737 // opts may be used to read optional arguments from 738 // 739 // The result should be capable of being JSON encoded 740 // If it is a string or a []string it will be shown to the user 741 // otherwise it will be JSON encoded and shown to the user like that 742 func (f *Fs) Command(ctx context.Context, name string, arg []string, opt map[string]string) (out interface{}, err error) { 743 switch name { 744 case "set": 745 newOpt := f.opt 746 err := configstruct.Set(configmap.Simple(opt), &newOpt) 747 if err != nil { 748 return nil, fmt.Errorf("reading config: %w", err) 749 } 750 _, err = f.httpConnection(ctx, &newOpt) 751 if err != nil { 752 return nil, fmt.Errorf("updating session: %w", err) 753 } 754 f.opt = newOpt 755 keys := []string{} 756 for k := range opt { 757 keys = append(keys, k) 758 } 759 fs.Logf(f, "Updated config values: %s", strings.Join(keys, ", ")) 760 return nil, nil 761 default: 762 return nil, fs.ErrorCommandNotFound 763 } 764 } 765 766 // Check the interfaces are satisfied 767 var ( 768 _ fs.Fs = &Fs{} 769 _ fs.PutStreamer = &Fs{} 770 _ fs.Object = &Object{} 771 _ fs.MimeTyper = &Object{} 772 _ fs.Commander = &Fs{} 773 )