Repository: rclone/rclone
Pull request #4280 (2020-05-26 12:06:27)

s3: implement link sharing
 #### What is the purpose of this change?
 Add link sharing to S3
 
 #### Was the change discussed in an issue or in the forum before?
 #2178
 
 #### Checklist
 * [x]  I have read the [contribution guidelines](https://github.com/ncw/rclone/blob/master/CONTRIBUTING.md#submitting-a-pull-request).
 * [ ]  I have added tests for all changes in this PR if appropriate.
 * [x]  I have added documentation for the changes if appropriate.
 * [x]  All commit messages are in [house style](https://github.com/ncw/rclone/blob/master/CONTRIBUTING.md#commit-messages).
 * [x]  I'm done, this Pull Request is ready for review :-)



ncw: I think this code looks fine! I will give a detailed review when I've finished getting the 1.52 release out so apologies for the delay...

Patch:
diff --git a/backend/b2/b2.go b/backend/b2/b2.go
index df8e9fc3f..5698c672b 100644
--- a/backend/b2/b2.go
+++ b/backend/b2/b2.go
@@ -1357,7 +1357,7 @@ func (f *Fs) getDownloadAuthorization(ctx context.Context, bucket, remote string
 }
 
 // PublicLink returns a link for downloading without account
-func (f *Fs) PublicLink(ctx context.Context, remote string) (link string, err error) {
+func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (link string, err error) {
 	bucket, bucketPath := f.split(remote)
 	var RootURL string
 	if f.opt.DownloadURL == "" {
diff --git a/backend/box/box.go b/backend/box/box.go
index 7b5bed948..b720047aa 100644
--- a/backend/box/box.go
+++ b/backend/box/box.go
@@ -1024,7 +1024,7 @@ func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string
 }
 
 // PublicLink adds a "readable by anyone with link" permission on the given file or folder.
-func (f *Fs) PublicLink(ctx context.Context, remote string) (string, error) {
+func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (string, error) {
 	id, err := f.dirCache.FindDir(ctx, remote, false)
 	var opts rest.Opts
 	if err == nil {
diff --git a/backend/crypt/crypt.go b/backend/crypt/crypt.go
index 16735ac57..592760560 100644
--- a/backend/crypt/crypt.go
+++ b/backend/crypt/crypt.go
@@ -656,7 +656,7 @@ func (f *Fs) DirCacheFlush() {
 }
 
 // PublicLink generates a public link to the remote path (usually readable by anyone)
-func (f *Fs) PublicLink(ctx context.Context, remote string) (string, error) {
+func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (string, error) {
 	do := f.Fs.Features().PublicLink
 	if do == nil {
 		return "", errors.New("PublicLink not supported")
@@ -664,9 +664,9 @@ func (f *Fs) PublicLink(ctx context.Context, remote string) (string, error) {
 	o, err := f.NewObject(ctx, remote)
 	if err != nil {
 		// assume it is a directory
-		return do(ctx, f.cipher.EncryptDirName(remote))
+		return do(ctx, f.cipher.EncryptDirName(remote), expire, unlink)
 	}
-	return do(ctx, o.(*Object).Object.Remote())
+	return do(ctx, o.(*Object).Object.Remote(), expire, unlink)
 }
 
 // ChangeNotify calls the passed function with a path
diff --git a/backend/drive/drive.go b/backend/drive/drive.go
index 2115fe559..81446cdba 100755
--- a/backend/drive/drive.go
+++ b/backend/drive/drive.go
@@ -2488,7 +2488,7 @@ func (f *Fs) Move(ctx context.Context, src fs.Object, remote string) (fs.Object,
 }
 
 // PublicLink adds a "readable by anyone with link" permission on the given file or folder.
-func (f *Fs) PublicLink(ctx context.Context, remote string) (link string, err error) {
+func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (link string, err error) {
 	id, err := f.dirCache.FindDir(ctx, remote, false)
 	if err == nil {
 		fs.Debugf(f, "attempting to share directory '%s'", remote)
diff --git a/backend/dropbox/dropbox.go b/backend/dropbox/dropbox.go
index 7af321da3..ca07db62d 100755
--- a/backend/dropbox/dropbox.go
+++ b/backend/dropbox/dropbox.go
@@ -782,11 +782,14 @@ func (f *Fs) Move(ctx context.Context, src fs.Object, remote string) (fs.Object,
 }
 
 // PublicLink adds a "readable by anyone with link" permission on the given file or folder.
-func (f *Fs) PublicLink(ctx context.Context, remote string) (link string, err error) {
+func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (link string, err error) {
 	absPath := f.opt.Enc.FromStandardPath(path.Join(f.slashRoot, remote))
 	fs.Debugf(f, "attempting to share '%s' (absolute path: %s)", remote, absPath)
 	createArg := sharing.CreateSharedLinkWithSettingsArg{
 		Path: absPath,
+		Settings: &sharing.SharedLinkSettings{
+			Expires: time.Now().Add(time.Duration(expire)),
+		},
 	}
 	var linkRes sharing.IsSharedLinkMetadata
 	err = f.pacer.Call(func() (bool, error) {
diff --git a/backend/jottacloud/jottacloud.go b/backend/jottacloud/jottacloud.go
index 1103c3926..3fa81866a 100644
--- a/backend/jottacloud/jottacloud.go
+++ b/backend/jottacloud/jottacloud.go
@@ -150,11 +150,6 @@ func init() {
 			Help:     "Delete files permanently rather than putting them into the trash.",
 			Default:  false,
 			Advanced: true,
-		}, {
-			Name:     "unlink",
-			Help:     "Remove existing public link to file/folder with link command rather than creating.\nDefault is false, meaning link command will create or retrieve public link.",
-			Default:  false,
-			Advanced: true,
 		}, {
 			Name:     "upload_resume_limit",
 			Help:     "Files bigger than this can be resumed if the upload fail's.",
@@ -181,7 +176,6 @@ type Options struct {
 	MD5MemoryThreshold fs.SizeSuffix        `config:"md5_memory_limit"`
 	TrashedOnly        bool                 `config:"trashed_only"`
 	HardDelete         bool                 `config:"hard_delete"`
-	Unlink             bool                 `config:"unlink"`
 	UploadThreshold    fs.SizeSuffix        `config:"upload_resume_limit"`
 	Enc                encoder.MultiEncoder `config:"encoding"`
 }
@@ -1002,14 +996,14 @@ func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string
 }
 
 // PublicLink generates a public link to the remote path (usually readable by anyone)
-func (f *Fs) PublicLink(ctx context.Context, remote string) (link string, err error) {
+func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (link string, err error) {
 	opts := rest.Opts{
 		Method:     "GET",
 		Path:       f.filePath(remote),
 		Parameters: url.Values{},
 	}
 
-	if f.opt.Unlink {
+	if unlink {
 		opts.Parameters.Set("mode", "disableShare")
 	} else {
 		opts.Parameters.Set("mode", "enableShare")
@@ -1029,12 +1023,12 @@ func (f *Fs) PublicLink(ctx context.Context, remote string) (link string, err er
 		}
 	}
 	if err != nil {
-		if f.opt.Unlink {
+		if unlink {
 			return "", errors.Wrap(err, "couldn't remove public link")
 		}
 		return "", errors.Wrap(err, "couldn't create public link")
 	}
-	if f.opt.Unlink {
+	if unlink {
 		if result.PublicSharePath != "" {
 			return "", errors.Errorf("couldn't remove public link - %q", result.PublicSharePath)
 		}
diff --git a/backend/koofr/koofr.go b/backend/koofr/koofr.go
index 32b7374ca..f51368513 100644
--- a/backend/koofr/koofr.go
+++ b/backend/koofr/koofr.go
@@ -603,7 +603,7 @@ func createLink(c *koofrclient.KoofrClient, mountID string, path string) (*link,
 }
 
 // PublicLink creates a public link to the remote path
-func (f *Fs) PublicLink(ctx context.Context, remote string) (string, error) {
+func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (string, error) {
 	linkData, err := createLink(f.client, f.mountID, f.fullPath(remote))
 	if err != nil {
 		return "", translateErrorsDir(err)
diff --git a/backend/mailru/mailru.go b/backend/mailru/mailru.go
index 5f317953e..b1a61b76e 100644
--- a/backend/mailru/mailru.go
+++ b/backend/mailru/mailru.go
@@ -1450,7 +1450,7 @@ func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string
 }
 
 // PublicLink generates a public link to the remote path (usually readable by anyone)
-func (f *Fs) PublicLink(ctx context.Context, remote string) (link string, err error) {
+func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (link string, err error) {
 	// fs.Debugf(f, ">>> PublicLink %q", remote)
 
 	token, err := f.accessToken()
diff --git a/backend/mega/mega.go b/backend/mega/mega.go
index 925bd3b59..efb23ff9d 100644
--- a/backend/mega/mega.go
+++ b/backend/mega/mega.go
@@ -836,7 +836,7 @@ func (f *Fs) Hashes() hash.Set {
 }
 
 // PublicLink generates a public link to the remote path (usually readable by anyone)
-func (f *Fs) PublicLink(ctx context.Context, remote string) (link string, err error) {
+func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (link string, err error) {
 	root, err := f.findRoot(false)
 	if err != nil {
 		return "", errors.Wrap(err, "PublicLink failed to find root node")
diff --git a/backend/onedrive/onedrive.go b/backend/onedrive/onedrive.go
index 8e764543e..202433742 100755
--- a/backend/onedrive/onedrive.go
+++ b/backend/onedrive/onedrive.go
@@ -1311,7 +1311,7 @@ func (f *Fs) Hashes() hash.Set {
 }
 
 // PublicLink returns a link for downloading without account.
-func (f *Fs) PublicLink(ctx context.Context, remote string) (link string, err error) {
+func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (link string, err error) {
 	info, _, err := f.readMetaDataForPath(ctx, f.rootPath(remote))
 	if err != nil {
 		return "", err
diff --git a/backend/premiumizeme/premiumizeme.go b/backend/premiumizeme/premiumizeme.go
index f2ec858d5..b80f5a749 100644
--- a/backend/premiumizeme/premiumizeme.go
+++ b/backend/premiumizeme/premiumizeme.go
@@ -822,7 +822,7 @@ func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string
 }
 
 // PublicLink adds a "readable by anyone with link" permission on the given file or folder.
-func (f *Fs) PublicLink(ctx context.Context, remote string) (string, error) {
+func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (string, error) {
 	_, err := f.dirCache.FindDir(ctx, remote, false)
 	if err == nil {
 		return "", fs.ErrorCantShareDirectories
diff --git a/backend/s3/s3.go b/backend/s3/s3.go
index c6201492c..306d2a17b 100644
--- a/backend/s3/s3.go
+++ b/backend/s3/s3.go
@@ -1959,6 +1959,20 @@ func (f *Fs) getMemoryPool(size int64) *pool.Pool {
 	)
 }
 
+// PublicLink generates a public link to the remote path (usually readable by anyone)
+func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (link string, err error) {
+	if _, err := f.NewObject(ctx, remote); err != nil {
+		return "", err
+	}
+	bucket, bucketPath := f.split(remote)
+	httpReq, _ := f.c.GetObjectRequest(&s3.GetObjectInput{
+		Bucket: &bucket,
+		Key:    &bucketPath,
+	})
+
+	return httpReq.Presign(time.Duration(expire))
+}
+
 // ------------------------------------------------------------
 
 // Fs returns the parent Fs
diff --git a/backend/seafile/seafile.go b/backend/seafile/seafile.go
index 75ab5afad..544e72a4e 100644
--- a/backend/seafile/seafile.go
+++ b/backend/seafile/seafile.go
@@ -972,7 +972,7 @@ func (f *Fs) UserInfo(ctx context.Context) (map[string]string, error) {
 // ==================== Optional Interface fs.PublicLinker ====================
 
 // PublicLink generates a public link to the remote path (usually readable by anyone)
-func (f *Fs) PublicLink(ctx context.Context, remote string) (string, error) {
+func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (string, error) {
 	libraryName, filePath := f.splitPath(remote)
 	if libraryName == "" {
 		// We cannot share the whole seafile server, we need at least a library
diff --git a/backend/sugarsync/sugarsync.go b/backend/sugarsync/sugarsync.go
index 7507de3ec..8c22d680f 100644
--- a/backend/sugarsync/sugarsync.go
+++ b/backend/sugarsync/sugarsync.go
@@ -1099,7 +1099,7 @@ func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string
 }
 
 // PublicLink adds a "readable by anyone with link" permission on the given file or folder.
-func (f *Fs) PublicLink(ctx context.Context, remote string) (string, error) {
+func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (string, error) {
 	obj, err := f.NewObject(ctx, remote)
 	if err != nil {
 		return "", err
diff --git a/backend/yandex/yandex.go b/backend/yandex/yandex.go
index b5b638588..570f3891d 100644
--- a/backend/yandex/yandex.go
+++ b/backend/yandex/yandex.go
@@ -73,11 +73,6 @@ func init() {
 		}, {
 			Name: config.ConfigClientSecret,
 			Help: "Yandex Client Secret\nLeave blank normally.",
-		}, {
-			Name:     "unlink",
-			Help:     "Remove existing public link to file/folder with link command rather than creating.\nDefault is false, meaning link command will create or retrieve public link.",
-			Default:  false,
-			Advanced: true,
 		}, {
 			Name:     config.ConfigEncoding,
 			Help:     config.ConfigEncodingHelp,
@@ -92,9 +87,8 @@ func init() {
 
 // Options defines the configuration for this backend
 type Options struct {
-	Token  string               `config:"token"`
-	Unlink bool                 `config:"unlink"`
-	Enc    encoder.MultiEncoder `config:"encoding"`
+	Token string               `config:"token"`
+	Enc   encoder.MultiEncoder `config:"encoding"`
 }
 
 // Fs represents a remote yandex
@@ -801,9 +795,9 @@ func (f *Fs) DirMove(ctx context.Context, src fs.Fs, srcRemote, dstRemote string
 }
 
 // PublicLink generates a public link to the remote path (usually readable by anyone)
-func (f *Fs) PublicLink(ctx context.Context, remote string) (link string, err error) {
+func (f *Fs) PublicLink(ctx context.Context, remote string, expire fs.Duration, unlink bool) (link string, err error) {
 	var path string
-	if f.opt.Unlink {
+	if unlink {
 		path = "/resources/unpublish"
 	} else {
 		path = "/resources/publish"
@@ -830,7 +824,7 @@ func (f *Fs) PublicLink(ctx context.Context, remote string) (link string, err er
 		}
 	}
 	if err != nil {
-		if f.opt.Unlink {
+		if unlink {
 			return "", errors.Wrap(err, "couldn't remove public link")
 		}
 		return "", errors.Wrap(err, "couldn't create public link")
diff --git a/cmd/link/link.go b/cmd/link/link.go
index 4ea470f85..bf72b659e 100644
--- a/cmd/link/link.go
+++ b/cmd/link/link.go
@@ -3,35 +3,57 @@ package link
 import (
 	"context"
 	"fmt"
+	"time"
 
 	"github.com/rclone/rclone/cmd"
+	"github.com/rclone/rclone/fs"
+	"github.com/rclone/rclone/fs/config/flags"
 	"github.com/rclone/rclone/fs/operations"
 	"github.com/spf13/cobra"
 )
 
+var (
+	expire = fs.Duration(time.Hour * 24 * 365 * 100)
+	unlink = false
+)
+
 func init() {
 	cmd.Root.AddCommand(commandDefinition)
+	cmdFlags := commandDefinition.Flags()
+	flags.FVarP(cmdFlags, &expire, "expire", "", "The amount of time that the link will be valid")
+	flags.BoolVarP(cmdFlags, &unlink, "unlink", "", unlink, "Remove existing public link to file/folder")
 }
 
 var commandDefinition = &cobra.Command{
 	Use:   "link remote:path",
 	Short: `Generate public link to file/folder.`,
-	Long: `
-rclone link will create or retrieve a public link to the given file or folder.
+	Long: `rclone link will create, retrieve or remove a public link to the given
+file or folder.
 
     rclone link remote:path/to/file
     rclone link remote:path/to/folder/
+    rclone link --unlink remote:path/to/folder/
+    rclone link --expire 1d remote:path/to/file
+
+If you supply the --expire flag, it will set the expiration time
+otherwise it will use the default (100 years). **Note** not all
+backends support the --expire flag - if the backend doesn't support it
+then the link returned won't expire.
+
+Use the --unlink flag to remove existing public links to the file or
+folder. **Note** not all backends support "--unlink" flag - those that
+don't will just ignore it.
 
-If successful, the last line of the output will contain the link. Exact
-capabilities depend on the remote, but the link will always be created with
-the least constraints – e.g. no expiry, no password protection, accessible
-without account.
+If successful, the last line of the output will contain the
+link. Exact capabilities depend on the remote, but the link will
+always by default be created with the least constraints – e.g. no
+expiry, no password protection, accessible without account.
 `,
 	Run: func(command *cobra.Command, args []string) {
 		cmd.CheckArgs(1, 1, command, args)
 		fsrc, remote := cmd.NewFsFile(args[0])
 		cmd.Run(false, false, command, func() error {
-			link, err := operations.PublicLink(context.Background(), fsrc, remote)
+			link, err := operations.PublicLink(context.Background(), fsrc, remote, expire, unlink)
 			if err != nil {
 				return err
 			}
diff --git a/fs/fs.go b/fs/fs.go
index 6fd55099c..121898a97 100644
--- a/fs/fs.go
+++ b/fs/fs.go
@@ -575,7 +575,7 @@ type Features struct {
 	DirCacheFlush func()
 
 	// PublicLink generates a public link to the remote path (usually readable by anyone)
-	PublicLink func(ctx context.Context, remote string) (string, error)
+	PublicLink func(ctx context.Context, remote string, expire Duration, unlink bool) (string, error)
 
 	// Put in to the remote path with the modTime given of the given size
 	//
@@ -988,7 +988,7 @@ type PutStreamer interface {
 // PublicLinker is an optional interface for Fs
 type PublicLinker interface {
 	// PublicLink generates a public link to the remote path (usually readable by anyone)
-	PublicLink(ctx context.Context, remote string) (string, error)
+	PublicLink(ctx context.Context, remote string, expire Duration, unlink bool) (string, error)
 }
 
 // MergeDirser is an option interface for Fs
diff --git a/fs/operations/operations.go b/fs/operations/operations.go
index adf32e072..02b0c8c71 100644
--- a/fs/operations/operations.go
+++ b/fs/operations/operations.go
@@ -1416,12 +1416,12 @@ func Rcat(ctx context.Context, fdst fs.Fs, dstFileName string, in io.ReadCloser,
 }
 
 // PublicLink adds a "readable by anyone with link" permission on the given file or folder.
-func PublicLink(ctx context.Context, f fs.Fs, remote string) (string, error) {
+func PublicLink(ctx context.Context, f fs.Fs, remote string, expire fs.Duration, unlink bool) (string, error) {
 	doPublicLink := f.Features().PublicLink
 	if doPublicLink == nil {
 		return "", errors.Errorf("%v doesn't support public links", f)
 	}
-	return doPublicLink(ctx, remote)
+	return doPublicLink(ctx, remote, expire, unlink)
 }
 
 // Rmdirs removes any empty directories (or directories only
diff --git a/fs/operations/rc.go b/fs/operations/rc.go
index 291124902..6482612bc 100644
--- a/fs/operations/rc.go
+++ b/fs/operations/rc.go
@@ -271,6 +271,8 @@ func init() {
 
 - fs - a remote name string eg "drive:"
 - remote - a path within that remote eg "dir"
+- unlink - boolean - if set removes the link rather than adding it (optional)
+- expire - string - the expiry time of the link eg "1d" (optional)
 
 Returns
 
@@ -287,7 +289,12 @@ func rcPublicLink(ctx context.Context, in rc.Params) (out rc.Params, err error)
 	if err != nil {
 		return nil, err
 	}
-	url, err := PublicLink(ctx, f, remote)
+	unlink, _ := in.GetBool("unlink")
+	expire, err := in.GetDuration("expire")
+	if err != nil && !rc.IsErrParamNotFound(err) {
+		return nil, err
+	}
+	url, err := PublicLink(ctx, f, remote, fs.Duration(expire), unlink)
 	if err != nil {
 		return nil, err
 	}
diff --git a/fs/rc/params.go b/fs/rc/params.go
index cf8bf95d3..415cb4e32 100644
--- a/fs/rc/params.go
+++ b/fs/rc/params.go
@@ -7,8 +7,11 @@ import (
 	"fmt"
 	"math"
 	"strconv"
+	"time"
 
 	"github.com/pkg/errors"
+
+	"github.com/rclone/rclone/fs"
 )
 
 // Params is the input and output type for the Func
@@ -212,3 +215,16 @@ func (p Params) GetStructMissingOK(key string, out interface{}) error {
 	}
 	return p.GetStruct(key, out)
 }
+
+// GetDuration get the duration parameters from in
+func (p Params) GetDuration(key string) (time.Duration, error) {
+	s, err := p.GetString(key)
+	if err != nil {
+		return 0, err
+	}
+	duration, err := fs.ParseDuration(s)
+	if err != nil {
+		return 0, ErrParamInvalid{errors.Wrap(err, "parse duration")}
+	}
+	return duration, nil
+}


Test patch:
diff --git a/fs/operations/rc_test.go b/fs/operations/rc_test.go
index 36117c284..3f3dd99bd 100644
--- a/fs/operations/rc_test.go
+++ b/fs/operations/rc_test.go
@@ -192,7 +192,7 @@ func TestRcDeletefile(t *testing.T) {
 	fstest.CheckItems(t, r.Fremote, file2)
 }
 
-// operations/list: List the given remote and path in JSON format
+// operations/list: List the given remote and path in JSON format.
 func TestRcList(t *testing.T) {
 	r, call := rcNewRun(t, "operations/list")
 	defer r.Finalise()
@@ -402,6 +402,8 @@ func TestRcPublicLink(t *testing.T) {
 	in := rc.Params{
 		"fs":     r.FremoteName,
 		"remote": "",
+		"expire": "5m",
+		"unlink": false,
 	}
 	_, err := call.Fn(context.Background(), in)
 	require.Error(t, err)
diff --git a/fs/rc/params_test.go b/fs/rc/params_test.go
index 4b7a25349..27529f70b 100644
--- a/fs/rc/params_test.go
+++ b/fs/rc/params_test.go
@@ -3,10 +3,13 @@ package rc
 import (
 	"fmt"
 	"testing"
+	"time"
 
 	"github.com/pkg/errors"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+
+	"github.com/rclone/rclone/fs"
 )
 
 func TestErrParamNotFoundError(t *testing.T) {
@@ -173,6 +176,57 @@ func TestParamsGetFloat64(t *testing.T) {
 	assert.Equal(t, true, IsErrParamInvalid(e3), e3.Error())
 }
 
+func TestParamsGetDuration(t *testing.T) {
+	for _, test := range []struct {
+		value     interface{}
+		result    time.Duration
+		errString string
+	}{
+		{"86400", time.Hour * 24, ""},
+		{"1y", time.Hour * 24 * 365, ""},
+		{"60", time.Minute * 1, ""},
+		{"0", 0, ""},
+		{"-45", -time.Second * 45, ""},
+		{"2", time.Second * 2, ""},
+		{"2h4m7s", time.Hour*2 + 4*time.Minute + 7*time.Second, ""},
+		{"3d", time.Hour * 24 * 3, ""},
+		{"off", time.Duration(fs.DurationOff), ""},
+		{"", 0, "parse duration"},
+		{12, 0, "expecting string"},
+		{"34y", time.Hour * 24 * 365 * 34, ""},
+		{"30d", time.Hour * 24 * 30, ""},
+		{"2M", time.Hour * 24 * 60, ""},
+		{"wrong", 0, "parse duration"},
+	} {
+		t.Run(fmt.Sprintf("%T=%v", test.value, test.value), func(t *testing.T) {
+			in := Params{
+				"key": test.value,
+			}
+			v1, e1 := in.GetDuration("key")
+			if test.errString == "" {
+				require.NoError(t, e1)
+				assert.Equal(t, test.result, v1)
+			} else {
+				require.NotNil(t, e1)
+				require.Error(t, e1)
+				assert.Contains(t, e1.Error(), test.errString)
+				assert.Equal(t, time.Duration(0), v1)
+			}
+		})
+	}
+	in := Params{
+		"notDuration": []string{"a", "b"},
+	}
+	v2, e2 := in.GetDuration("notOK")
+	assert.Error(t, e2)
+	assert.Equal(t, time.Duration(0), v2)
+	assert.Equal(t, ErrParamNotFound("notOK"), e2)
+	v3, e3 := in.GetDuration("notDuration")
+	assert.Error(t, e3)
+	assert.Equal(t, time.Duration(0), v3)
+	assert.Equal(t, true, IsErrParamInvalid(e3), e3.Error())
+}
+
 func TestParamsGetBool(t *testing.T) {
 	for _, test := range []struct {
 		value     interface{}
diff --git a/fstest/fstests/fstests.go b/fstest/fstests/fstests.go
index d8701ee61..be5bff060 100644
--- a/fstest/fstests/fstests.go
+++ b/fstest/fstests/fstests.go
@@ -1467,29 +1467,29 @@ func Run(t *testing.T, opt *Opt) {
 				}
 
 				// if object not found
-				link, err := doPublicLink(ctx, file1.Path+"_does_not_exist")
+				link, err := doPublicLink(ctx, file1.Path+"_does_not_exist", fs.Duration(0), false)
 				require.Error(t, err, "Expected to get error when file doesn't exist")
 				require.Equal(t, "", link, "Expected link to be empty on error")
 
 				// sharing file for the first time
-				link1, err := doPublicLink(ctx, file1.Path)
+				link1, err := doPublicLink(ctx, file1.Path, fs.Duration(0), false)
 				require.NoError(t, err)
 				require.NotEqual(t, "", link1, "Link should not be empty")
 
-				link2, err := doPublicLink(ctx, file2.Path)
+				link2, err := doPublicLink(ctx, file2.Path, fs.Duration(0), false)
 				require.NoError(t, err)
 				require.NotEqual(t, "", link2, "Link should not be empty")
 
 				require.NotEqual(t, link1, link2, "Links to different files should differ")
 
 				// sharing file for the 2nd time
-				link1, err = doPublicLink(ctx, file1.Path)
+				link1, err = doPublicLink(ctx, file1.Path, fs.Duration(0), false)
 				require.NoError(t, err)
 				require.NotEqual(t, "", link1, "Link should not be empty")
 
 				// sharing directory for the first time
 				path := path.Dir(file2.Path)
-				link3, err := doPublicLink(ctx, path)
+				link3, err := doPublicLink(ctx, path, fs.Duration(0), false)
 				if err != nil && errors.Cause(err) == fs.ErrorCantShareDirectories {
 					t.Log("skipping directory tests as not supported on this backend")
 				} else {
@@ -1497,7 +1497,7 @@ func Run(t *testing.T, opt *Opt) {
 					require.NotEqual(t, "", link3, "Link should not be empty")
 
 					// sharing directory for the second time
-					link3, err = doPublicLink(ctx, path)
+					link3, err = doPublicLink(ctx, path, fs.Duration(0), false)
 					require.NoError(t, err)
 					require.NotEqual(t, "", link3, "Link should not be empty")
 
@@ -1511,7 +1511,7 @@ func Run(t *testing.T, opt *Opt) {
 					_, err = subRemote.Put(ctx, buf, obji)
 					require.NoError(t, err)
 
-					link4, err := subRemote.Features().PublicLink(ctx, "")
+					link4, err := subRemote.Features().PublicLink(ctx, "", fs.Duration(0), false)
 					require.NoError(t, err, "Sharing root in a sub-remote should work")
 					require.NotEqual(t, "", link4, "Link should not be empty")
 				}