github.com/rclone/rclone@v1.66.1-0.20240517100346-7b89735ae726/backend/sia/sia.go (about)

     1  // Package sia provides an interface to the Sia storage system.
     2  package sia
     3  
     4  import (
     5  	"context"
     6  	"encoding/json"
     7  	"errors"
     8  	"fmt"
     9  	"io"
    10  	"net/http"
    11  	"net/url"
    12  	"path"
    13  	"strings"
    14  	"time"
    15  
    16  	"github.com/rclone/rclone/backend/sia/api"
    17  	"github.com/rclone/rclone/fs"
    18  	"github.com/rclone/rclone/fs/config"
    19  	"github.com/rclone/rclone/fs/config/configmap"
    20  	"github.com/rclone/rclone/fs/config/configstruct"
    21  	"github.com/rclone/rclone/fs/config/obscure"
    22  	"github.com/rclone/rclone/fs/fserrors"
    23  	"github.com/rclone/rclone/fs/fshttp"
    24  	"github.com/rclone/rclone/fs/hash"
    25  	"github.com/rclone/rclone/lib/encoder"
    26  	"github.com/rclone/rclone/lib/pacer"
    27  	"github.com/rclone/rclone/lib/rest"
    28  )
    29  
    30  const (
    31  	minSleep      = 10 * time.Millisecond
    32  	maxSleep      = 2 * time.Second
    33  	decayConstant = 2 // bigger for slower decay, exponential
    34  )
    35  
    36  // Register with Fs
    37  func init() {
    38  	fs.Register(&fs.RegInfo{
    39  		Name:        "sia",
    40  		Description: "Sia Decentralized Cloud",
    41  		NewFs:       NewFs,
    42  		Options: []fs.Option{{
    43  			Name: "api_url",
    44  			Help: `Sia daemon API URL, like http://sia.daemon.host:9980.
    45  
    46  Note that siad must run with --disable-api-security to open API port for other hosts (not recommended).
    47  Keep default if Sia daemon runs on localhost.`,
    48  			Default:   "http://127.0.0.1:9980",
    49  			Sensitive: true,
    50  		}, {
    51  			Name: "api_password",
    52  			Help: `Sia Daemon API Password.
    53  
    54  Can be found in the apipassword file located in HOME/.sia/ or in the daemon directory.`,
    55  			IsPassword: true,
    56  		}, {
    57  			Name: "user_agent",
    58  			Help: `Siad User Agent
    59  
    60  Sia daemon requires the 'Sia-Agent' user agent by default for security`,
    61  			Default:  "Sia-Agent",
    62  			Advanced: true,
    63  		}, {
    64  			Name:     config.ConfigEncoding,
    65  			Help:     config.ConfigEncodingHelp,
    66  			Advanced: true,
    67  			Default: encoder.EncodeInvalidUtf8 |
    68  				encoder.EncodeCtl |
    69  				encoder.EncodeDel |
    70  				encoder.EncodeHashPercent |
    71  				encoder.EncodeQuestion |
    72  				encoder.EncodeDot |
    73  				encoder.EncodeSlash,
    74  		},
    75  		}})
    76  }
    77  
    78  // Options defines the configuration for this backend
    79  type Options struct {
    80  	APIURL      string               `config:"api_url"`
    81  	APIPassword string               `config:"api_password"`
    82  	UserAgent   string               `config:"user_agent"`
    83  	Enc         encoder.MultiEncoder `config:"encoding"`
    84  }
    85  
    86  // Fs represents a remote siad
    87  type Fs struct {
    88  	name     string       // name of this remote
    89  	root     string       // the path we are working on if any
    90  	opt      Options      // parsed config options
    91  	features *fs.Features // optional features
    92  	srv      *rest.Client // the connection to siad
    93  	pacer    *fs.Pacer    // pacer for API calls
    94  }
    95  
    96  // Object describes a Sia object
    97  type Object struct {
    98  	fs      *Fs
    99  	remote  string
   100  	modTime time.Time
   101  	size    int64
   102  }
   103  
   104  // Return a string version
   105  func (o *Object) String() string {
   106  	if o == nil {
   107  		return "<nil>"
   108  	}
   109  	return o.remote
   110  }
   111  
   112  // Remote returns the remote path
   113  func (o *Object) Remote() string {
   114  	return o.remote
   115  }
   116  
   117  // ModTime is the last modified time (read-only)
   118  func (o *Object) ModTime(ctx context.Context) time.Time {
   119  	return o.modTime
   120  }
   121  
   122  // Size is the file length
   123  func (o *Object) Size() int64 {
   124  	return o.size
   125  }
   126  
   127  // Fs returns the parent Fs
   128  func (o *Object) Fs() fs.Info {
   129  	return o.fs
   130  }
   131  
   132  // Hash is not supported
   133  func (o *Object) Hash(ctx context.Context, ty hash.Type) (string, error) {
   134  	return "", hash.ErrUnsupported
   135  }
   136  
   137  // Storable returns if this object is storable
   138  func (o *Object) Storable() bool {
   139  	return true
   140  }
   141  
   142  // SetModTime is not supported
   143  func (o *Object) SetModTime(ctx context.Context, t time.Time) error {
   144  	return fs.ErrorCantSetModTime
   145  }
   146  
   147  // Open an object for read
   148  func (o *Object) Open(ctx context.Context, options ...fs.OpenOption) (in io.ReadCloser, err error) {
   149  	var optionsFixed []fs.OpenOption
   150  	for _, opt := range options {
   151  		if optRange, ok := opt.(*fs.RangeOption); ok {
   152  			// Ignore range option if file is empty
   153  			if o.Size() == 0 && optRange.Start == 0 && optRange.End > 0 {
   154  				continue
   155  			}
   156  		}
   157  		optionsFixed = append(optionsFixed, opt)
   158  	}
   159  
   160  	var resp *http.Response
   161  	opts := rest.Opts{
   162  		Method:  "GET",
   163  		Path:    path.Join("/renter/stream/", o.fs.root, o.fs.opt.Enc.FromStandardPath(o.remote)),
   164  		Options: optionsFixed,
   165  	}
   166  	err = o.fs.pacer.Call(func() (bool, error) {
   167  		resp, err = o.fs.srv.Call(ctx, &opts)
   168  		return o.fs.shouldRetry(resp, err)
   169  	})
   170  	if err != nil {
   171  		return nil, err
   172  	}
   173  	return resp.Body, err
   174  }
   175  
   176  // Update the object with the contents of the io.Reader
   177  func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (err error) {
   178  	size := src.Size()
   179  	var resp *http.Response
   180  	opts := rest.Opts{
   181  		Method:        "POST",
   182  		Path:          path.Join("/renter/uploadstream/", o.fs.opt.Enc.FromStandardPath(path.Join(o.fs.root, o.remote))),
   183  		Body:          in,
   184  		ContentLength: &size,
   185  		Parameters:    url.Values{},
   186  	}
   187  	opts.Parameters.Set("force", "true")
   188  
   189  	err = o.fs.pacer.Call(func() (bool, error) {
   190  		resp, err = o.fs.srv.Call(ctx, &opts)
   191  		return o.fs.shouldRetry(resp, err)
   192  	})
   193  
   194  	if err == nil {
   195  		err = o.readMetaData(ctx)
   196  	}
   197  
   198  	return err
   199  }
   200  
   201  // Remove an object
   202  func (o *Object) Remove(ctx context.Context) (err error) {
   203  	var resp *http.Response
   204  	opts := rest.Opts{
   205  		Method: "POST",
   206  		Path:   path.Join("/renter/delete/", o.fs.opt.Enc.FromStandardPath(path.Join(o.fs.root, o.remote))),
   207  	}
   208  	err = o.fs.pacer.Call(func() (bool, error) {
   209  		resp, err = o.fs.srv.Call(ctx, &opts)
   210  		return o.fs.shouldRetry(resp, err)
   211  	})
   212  
   213  	return err
   214  }
   215  
   216  // sync the size and other metadata down for the object
   217  func (o *Object) readMetaData(ctx context.Context) (err error) {
   218  	opts := rest.Opts{
   219  		Method: "GET",
   220  		Path:   path.Join("/renter/file/", o.fs.opt.Enc.FromStandardPath(path.Join(o.fs.root, o.remote))),
   221  	}
   222  
   223  	var result api.FileResponse
   224  	var resp *http.Response
   225  	err = o.fs.pacer.Call(func() (bool, error) {
   226  		resp, err = o.fs.srv.CallJSON(ctx, &opts, nil, &result)
   227  		return o.fs.shouldRetry(resp, err)
   228  	})
   229  
   230  	if err != nil {
   231  		return err
   232  	}
   233  
   234  	o.size = int64(result.File.Filesize)
   235  	o.modTime = result.File.ModTime
   236  
   237  	return nil
   238  }
   239  
   240  // Name of the remote (as passed into NewFs)
   241  func (f *Fs) Name() string {
   242  	return f.name
   243  }
   244  
   245  // Root of the remote (as passed into NewFs)
   246  func (f *Fs) Root() string {
   247  	return f.root
   248  }
   249  
   250  // String converts this Fs to a string
   251  func (f *Fs) String() string {
   252  	return fmt.Sprintf("Sia %s", f.opt.APIURL)
   253  }
   254  
   255  // Precision is unsupported because ModTime is not changeable
   256  func (f *Fs) Precision() time.Duration {
   257  	return fs.ModTimeNotSupported
   258  }
   259  
   260  // Hashes are not exposed anywhere
   261  func (f *Fs) Hashes() hash.Set {
   262  	return hash.Set(hash.None)
   263  }
   264  
   265  // Features for this fs
   266  func (f *Fs) Features() *fs.Features {
   267  	return f.features
   268  }
   269  
   270  // List files and directories in a directory
   271  func (f *Fs) List(ctx context.Context, dir string) (entries fs.DirEntries, err error) {
   272  	dirPrefix := f.opt.Enc.FromStandardPath(path.Join(f.root, dir)) + "/"
   273  
   274  	var result api.DirectoriesResponse
   275  	var resp *http.Response
   276  	opts := rest.Opts{
   277  		Method: "GET",
   278  		Path:   path.Join("/renter/dir/", dirPrefix) + "/",
   279  	}
   280  
   281  	err = f.pacer.Call(func() (bool, error) {
   282  		resp, err = f.srv.CallJSON(ctx, &opts, nil, &result)
   283  		return f.shouldRetry(resp, err)
   284  	})
   285  
   286  	if err != nil {
   287  		return nil, err
   288  	}
   289  
   290  	for _, directory := range result.Directories {
   291  		if directory.SiaPath+"/" == dirPrefix {
   292  			continue
   293  		}
   294  
   295  		d := fs.NewDir(f.opt.Enc.ToStandardPath(strings.TrimPrefix(directory.SiaPath, f.opt.Enc.FromStandardPath(f.root)+"/")), directory.MostRecentModTime)
   296  		entries = append(entries, d)
   297  	}
   298  
   299  	for _, file := range result.Files {
   300  		o := &Object{fs: f,
   301  			remote:  f.opt.Enc.ToStandardPath(strings.TrimPrefix(file.SiaPath, f.opt.Enc.FromStandardPath(f.root)+"/")),
   302  			modTime: file.ModTime,
   303  			size:    int64(file.Filesize)}
   304  		entries = append(entries, o)
   305  	}
   306  
   307  	return entries, nil
   308  }
   309  
   310  // NewObject finds the Object at remote.  If it can't be found
   311  // it returns the error fs.ErrorObjectNotFound.
   312  func (f *Fs) NewObject(ctx context.Context, remote string) (o fs.Object, err error) {
   313  	obj := &Object{
   314  		fs:     f,
   315  		remote: remote,
   316  	}
   317  	err = obj.readMetaData(ctx)
   318  	if err != nil {
   319  		return nil, err
   320  	}
   321  
   322  	return obj, nil
   323  }
   324  
   325  // Put the object into the remote siad via uploadstream
   326  func (f *Fs) Put(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) {
   327  	o := &Object{
   328  		fs:      f,
   329  		remote:  src.Remote(),
   330  		modTime: src.ModTime(ctx),
   331  		size:    src.Size(),
   332  	}
   333  
   334  	err := o.Update(ctx, in, src, options...)
   335  	if err == nil {
   336  		return o, nil
   337  	}
   338  
   339  	// Cleanup stray files left after failed upload
   340  	for i := 0; i < 5; i++ {
   341  		cleanObj, cleanErr := f.NewObject(ctx, src.Remote())
   342  		if cleanErr == nil {
   343  			cleanErr = cleanObj.Remove(ctx)
   344  		}
   345  		if cleanErr == nil {
   346  			break
   347  		}
   348  		if cleanErr != fs.ErrorObjectNotFound {
   349  			fs.Logf(f, "%q: cleanup failed upload: %v", src.Remote(), cleanErr)
   350  			break
   351  		}
   352  		time.Sleep(100 * time.Millisecond)
   353  	}
   354  	return nil, err
   355  }
   356  
   357  // PutStream the object into the remote siad via uploadstream
   358  func (f *Fs) PutStream(ctx context.Context, in io.Reader, src fs.ObjectInfo, options ...fs.OpenOption) (fs.Object, error) {
   359  	return f.Put(ctx, in, src, options...)
   360  }
   361  
   362  // Mkdir creates a directory
   363  func (f *Fs) Mkdir(ctx context.Context, dir string) (err error) {
   364  	var resp *http.Response
   365  	opts := rest.Opts{
   366  		Method:     "POST",
   367  		Path:       path.Join("/renter/dir/", f.opt.Enc.FromStandardPath(path.Join(f.root, dir))),
   368  		Parameters: url.Values{},
   369  	}
   370  	opts.Parameters.Set("action", "create")
   371  
   372  	err = f.pacer.Call(func() (bool, error) {
   373  		resp, err = f.srv.Call(ctx, &opts)
   374  		return f.shouldRetry(resp, err)
   375  	})
   376  
   377  	if err == fs.ErrorDirExists {
   378  		err = nil
   379  	}
   380  
   381  	return err
   382  }
   383  
   384  // Rmdir removes a directory
   385  func (f *Fs) Rmdir(ctx context.Context, dir string) (err error) {
   386  	var resp *http.Response
   387  	opts := rest.Opts{
   388  		Method: "GET",
   389  		Path:   path.Join("/renter/dir/", f.opt.Enc.FromStandardPath(path.Join(f.root, dir))),
   390  	}
   391  
   392  	var result api.DirectoriesResponse
   393  	err = f.pacer.Call(func() (bool, error) {
   394  		resp, err = f.srv.CallJSON(ctx, &opts, nil, &result)
   395  		return f.shouldRetry(resp, err)
   396  	})
   397  
   398  	if len(result.Directories) == 0 {
   399  		return fs.ErrorDirNotFound
   400  	} else if len(result.Files) > 0 || len(result.Directories) > 1 {
   401  		return fs.ErrorDirectoryNotEmpty
   402  	}
   403  
   404  	opts = rest.Opts{
   405  		Method:     "POST",
   406  		Path:       path.Join("/renter/dir/", f.opt.Enc.FromStandardPath(path.Join(f.root, dir))),
   407  		Parameters: url.Values{},
   408  	}
   409  	opts.Parameters.Set("action", "delete")
   410  
   411  	err = f.pacer.Call(func() (bool, error) {
   412  		resp, err = f.srv.Call(ctx, &opts)
   413  		return f.shouldRetry(resp, err)
   414  	})
   415  
   416  	return err
   417  }
   418  
   419  // NewFs constructs an Fs from the path
   420  func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, error) {
   421  	// Parse config into Options struct
   422  	opt := new(Options)
   423  	err := configstruct.Set(m, opt)
   424  	if err != nil {
   425  		return nil, err
   426  	}
   427  
   428  	opt.APIURL = strings.TrimSuffix(opt.APIURL, "/")
   429  
   430  	// Parse the endpoint
   431  	u, err := url.Parse(opt.APIURL)
   432  	if err != nil {
   433  		return nil, err
   434  	}
   435  
   436  	rootIsDir := strings.HasSuffix(root, "/")
   437  	root = strings.Trim(root, "/")
   438  
   439  	f := &Fs{
   440  		name: name,
   441  		opt:  *opt,
   442  		root: root,
   443  	}
   444  	f.pacer = fs.NewPacer(ctx, pacer.NewDefault(pacer.MinSleep(minSleep), pacer.MaxSleep(maxSleep), pacer.DecayConstant(decayConstant)))
   445  
   446  	f.features = (&fs.Features{
   447  		CanHaveEmptyDirectories: true,
   448  	}).Fill(ctx, f)
   449  
   450  	// Adjust client config and pass it attached to context
   451  	cliCtx, cliCfg := fs.AddConfig(ctx)
   452  	if opt.UserAgent != "" {
   453  		cliCfg.UserAgent = opt.UserAgent
   454  	}
   455  	f.srv = rest.NewClient(fshttp.NewClient(cliCtx))
   456  	f.srv.SetRoot(u.String())
   457  	f.srv.SetErrorHandler(errorHandler)
   458  
   459  	if opt.APIPassword != "" {
   460  		opt.APIPassword, err = obscure.Reveal(opt.APIPassword)
   461  		if err != nil {
   462  			return nil, fmt.Errorf("couldn't decrypt API password: %w", err)
   463  		}
   464  		f.srv.SetUserPass("", opt.APIPassword)
   465  	}
   466  
   467  	if root != "" && !rootIsDir {
   468  		// Check to see if the root actually an existing file
   469  		remote := path.Base(root)
   470  		f.root = path.Dir(root)
   471  		if f.root == "." {
   472  			f.root = ""
   473  		}
   474  		_, err := f.NewObject(ctx, remote)
   475  		if err != nil {
   476  			if errors.Is(err, fs.ErrorObjectNotFound) || errors.Is(err, fs.ErrorNotAFile) {
   477  				// File doesn't exist so return old f
   478  				f.root = root
   479  				return f, nil
   480  			}
   481  			return nil, err
   482  		}
   483  		// return an error with an fs which points to the parent
   484  		return f, fs.ErrorIsFile
   485  	}
   486  
   487  	return f, nil
   488  }
   489  
   490  // errorHandler translates Siad errors into native rclone filesystem errors.
   491  // Sadly this is using string matching since Siad can't expose meaningful codes.
   492  func errorHandler(resp *http.Response) error {
   493  	body, err := rest.ReadBody(resp)
   494  	if err != nil {
   495  		return fmt.Errorf("error when trying to read error body: %w", err)
   496  	}
   497  	// Decode error response
   498  	errResponse := new(api.Error)
   499  	err = json.Unmarshal(body, &errResponse)
   500  	if err != nil {
   501  		// Set the Message to be the body if we can't parse the JSON
   502  		errResponse.Message = strings.TrimSpace(string(body))
   503  	}
   504  	errResponse.Status = resp.Status
   505  	errResponse.StatusCode = resp.StatusCode
   506  
   507  	msg := strings.Trim(errResponse.Message, "[]")
   508  	code := errResponse.StatusCode
   509  	switch {
   510  	case code == 400 && msg == "no file known with that path":
   511  		return fs.ErrorObjectNotFound
   512  	case code == 400 && strings.HasPrefix(msg, "unable to get the fileinfo from the filesystem") && strings.HasSuffix(msg, "path does not exist"):
   513  		return fs.ErrorObjectNotFound
   514  	case code == 500 && strings.HasPrefix(msg, "failed to create directory") && strings.HasSuffix(msg, "a siadir already exists at that location"):
   515  		return fs.ErrorDirExists
   516  	case code == 500 && strings.HasPrefix(msg, "failed to get directory contents") && strings.HasSuffix(msg, "path does not exist"):
   517  		return fs.ErrorDirNotFound
   518  	case code == 500 && strings.HasSuffix(msg, "no such file or directory"):
   519  		return fs.ErrorDirNotFound
   520  	}
   521  	return errResponse
   522  }
   523  
   524  // shouldRetry returns a boolean as to whether this resp and err
   525  // deserve to be retried.  It returns the err as a convenience
   526  func (f *Fs) shouldRetry(resp *http.Response, err error) (bool, error) {
   527  	return fserrors.ShouldRetry(err), err
   528  }