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  }