瀏覽代碼

WebClient/REST API: add sharing support

Nicola Murino 3 年之前
父節點
當前提交
3bc58f5988
共有 48 個文件被更改,包括 3947 次插入386 次删除
  1. 2 2
      README.md
  2. 4 2
      common/common.go
  3. 2 1
      common/connection.go
  4. 1 0
      dataprovider/actions.go
  5. 11 7
      dataprovider/admin.go
  6. 5 6
      dataprovider/apikey.go
  7. 323 48
      dataprovider/bolt.go
  8. 78 13
      dataprovider/dataprovider.go
  9. 277 8
      dataprovider/memory.go
  10. 74 2
      dataprovider/mysql.go
  11. 76 2
      dataprovider/pgsql.go
  12. 274 0
      dataprovider/share.go
  13. 239 1
      dataprovider/sqlcommon.go
  14. 74 2
      dataprovider/sqlite.go
  15. 47 0
      dataprovider/sqlqueries.go
  16. 15 10
      dataprovider/user.go
  17. 3 1
      docs/web-client.md
  18. 28 27
      go.mod
  19. 40 24
      go.sum
  20. 18 7
      httpd/api_http_user.go
  21. 1 0
      httpd/api_keys.go
  22. 32 0
      httpd/api_maintenance.go
  23. 1 2
      httpd/api_mfa.go
  24. 232 0
      httpd/api_shares.go
  25. 9 1
      httpd/api_utils.go
  26. 2 2
      httpd/auth_utils.go
  27. 11 0
      httpd/httpd.go
  28. 826 163
      httpd/httpd_test.go
  29. 65 4
      httpd/internal_test.go
  30. 361 26
      httpd/schema/openapi.yaml
  31. 25 1
      httpd/server.go
  32. 4 2
      httpd/webadmin.go
  33. 236 2
      httpd/webclient.go
  34. 2 0
      sdk/plugin/plugin.go
  35. 2 1
      sdk/user.go
  36. 4 0
      service/service.go
  37. 1 1
      service/service_windows.go
  38. 2 0
      service/signals_windows.go
  39. 0 2
      templates/webadmin/folders.html
  40. 1 1
      templates/webadmin/fsconfig.html
  41. 7 0
      templates/webclient/base.html
  42. 1 1
      templates/webclient/editfile.html
  43. 28 1
      templates/webclient/files.html
  44. 209 0
      templates/webclient/share.html
  45. 257 0
      templates/webclient/shares.html
  46. 3 3
      tests/eventsearcher/go.mod
  47. 23 10
      tests/eventsearcher/go.sum
  48. 11 0
      util/util.go

+ 2 - 2
README.md

@@ -6,7 +6,7 @@
 [![Docker Pulls](https://img.shields.io/docker/pulls/drakkan/sftpgo)](https://hub.docker.com/r/drakkan/sftpgo)
 [![Docker Pulls](https://img.shields.io/docker/pulls/drakkan/sftpgo)](https://hub.docker.com/r/drakkan/sftpgo)
 [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go)
 [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go)
 
 
-Fully featured and highly configurable SFTP server with optional FTP/S and WebDAV support, written in Go.
+Fully featured and highly configurable SFTP server with optional HTTP, FTP/S and WebDAV support, written in Go.
 Several storage backends are supported: local filesystem, encrypted local filesystem, S3 (compatible) Object Storage, Google Cloud Storage, Azure Blob Storage, SFTP.
 Several storage backends are supported: local filesystem, encrypted local filesystem, S3 (compatible) Object Storage, Google Cloud Storage, Azure Blob Storage, SFTP.
 
 
 ## Features
 ## Features
@@ -20,7 +20,7 @@ Several storage backends are supported: local filesystem, encrypted local filesy
 - Per user and per directory virtual permissions, for each exposed path you can allow or deny: directory listing, upload, overwrite, download, delete, rename, create directories, create symlinks, change owner/group/file mode.
 - Per user and per directory virtual permissions, for each exposed path you can allow or deny: directory listing, upload, overwrite, download, delete, rename, create directories, create symlinks, change owner/group/file mode.
 - [REST API](./docs/rest-api.md) for users and folders management, data retention, backup, restore and real time reports of the active connections with possibility of forcibly closing a connection.
 - [REST API](./docs/rest-api.md) for users and folders management, data retention, backup, restore and real time reports of the active connections with possibility of forcibly closing a connection.
 - [Web based administration interface](./docs/web-admin.md) to easily manage users, folders and connections.
 - [Web based administration interface](./docs/web-admin.md) to easily manage users, folders and connections.
-- [Web client interface](./docs/web-client.md) so that end users can change their credentials and browse their files.
+- [Web client interface](./docs/web-client.md) so that end users can change their credentials, manage and share their files.
 - Public key and password authentication. Multiple public keys per user are supported.
 - Public key and password authentication. Multiple public keys per user are supported.
 - SSH user [certificate authentication](https://cvsweb.openbsd.org/src/usr.bin/ssh/PROTOCOL.certkeys?rev=1.8).
 - SSH user [certificate authentication](https://cvsweb.openbsd.org/src/usr.bin/ssh/PROTOCOL.certkeys?rev=1.8).
 - Keyboard interactive authentication. You can easily setup a customizable multi-factor authentication.
 - Keyboard interactive authentication. You can easily setup a customizable multi-factor authentication.

+ 4 - 2
common/common.go

@@ -80,6 +80,7 @@ const (
 	ProtocolFTP           = "FTP"
 	ProtocolFTP           = "FTP"
 	ProtocolWebDAV        = "DAV"
 	ProtocolWebDAV        = "DAV"
 	ProtocolHTTP          = "HTTP"
 	ProtocolHTTP          = "HTTP"
+	ProtocolHTTPShare     = "HTTPShare"
 	ProtocolDataRetention = "DataRetention"
 	ProtocolDataRetention = "DataRetention"
 )
 )
 
 
@@ -122,8 +123,9 @@ var (
 	QuotaScans            ActiveScans
 	QuotaScans            ActiveScans
 	idleTimeoutTicker     *time.Ticker
 	idleTimeoutTicker     *time.Ticker
 	idleTimeoutTickerDone chan bool
 	idleTimeoutTickerDone chan bool
-	supportedProtocols    = []string{ProtocolSFTP, ProtocolSCP, ProtocolSSH, ProtocolFTP, ProtocolWebDAV, ProtocolHTTP}
-	disconnHookProtocols  = []string{ProtocolSFTP, ProtocolSCP, ProtocolSSH, ProtocolFTP}
+	supportedProtocols    = []string{ProtocolSFTP, ProtocolSCP, ProtocolSSH, ProtocolFTP, ProtocolWebDAV,
+		ProtocolHTTP, ProtocolHTTPShare}
+	disconnHookProtocols = []string{ProtocolSFTP, ProtocolSCP, ProtocolSSH, ProtocolFTP}
 	// the map key is the protocol, for each protocol we can have multiple rate limiters
 	// the map key is the protocol, for each protocol we can have multiple rate limiters
 	rateLimiters map[string][]*rateLimiter
 	rateLimiters map[string][]*rateLimiter
 )
 )

+ 2 - 1
common/connection.go

@@ -226,7 +226,7 @@ func (c *BaseConnection) ListDir(virtualPath string) ([]os.FileInfo, error) {
 	}
 	}
 	files, err := fs.ReadDir(fsPath)
 	files, err := fs.ReadDir(fsPath)
 	if err != nil {
 	if err != nil {
-		c.Log(logger.LevelWarn, "error listing directory: %+v", err)
+		c.Log(logger.LevelDebug, "error listing directory: %+v", err)
 		return nil, c.GetFsError(fs, err)
 		return nil, c.GetFsError(fs, err)
 	}
 	}
 	return c.User.AddVirtualDirs(files, virtualPath), nil
 	return c.User.AddVirtualDirs(files, virtualPath), nil
@@ -494,6 +494,7 @@ func (c *BaseConnection) DoStat(virtualPath string, mode int) (os.FileInfo, erro
 		info, err = fs.Stat(c.getRealFsPath(fsPath))
 		info, err = fs.Stat(c.getRealFsPath(fsPath))
 	}
 	}
 	if err != nil {
 	if err != nil {
+		c.Log(logger.LevelDebug, "stat error for path %#v: %+v", virtualPath, err)
 		return info, c.GetFsError(fs, err)
 		return info, c.GetFsError(fs, err)
 	}
 	}
 	if vfs.IsCryptOsFs(fs) {
 	if vfs.IsCryptOsFs(fs) {

+ 1 - 0
dataprovider/actions.go

@@ -29,6 +29,7 @@ const (
 	actionObjectUser   = "user"
 	actionObjectUser   = "user"
 	actionObjectAdmin  = "admin"
 	actionObjectAdmin  = "admin"
 	actionObjectAPIKey = "api_key"
 	actionObjectAPIKey = "api_key"
+	actionObjectShare  = "share"
 )
 )
 
 
 func executeAction(operation, executor, ip, objectType, objectName string, object plugin.Renderer) {
 func executeAction(operation, executor, ip, objectType, objectName string, object plugin.Renderer) {

+ 11 - 7
dataprovider/admin.go

@@ -131,7 +131,7 @@ func (a *Admin) CountUnusedRecoveryCodes() int {
 	return unused
 	return unused
 }
 }
 
 
-func (a *Admin) checkPassword() error {
+func (a *Admin) hashPassword() error {
 	if a.Password != "" && !util.IsStringPrefixInSlice(a.Password, internalHashPwdPrefixes) {
 	if a.Password != "" && !util.IsStringPrefixInSlice(a.Password, internalHashPwdPrefixes) {
 		if config.PasswordValidation.Admins.MinEntropy > 0 {
 		if config.PasswordValidation.Admins.MinEntropy > 0 {
 			if err := passwordvalidator.Validate(a.Password, config.PasswordValidation.Admins.MinEntropy); err != nil {
 			if err := passwordvalidator.Validate(a.Password, config.PasswordValidation.Admins.MinEntropy); err != nil {
@@ -211,7 +211,7 @@ func (a *Admin) validate() error {
 	if !config.SkipNaturalKeysValidation && !usernameRegex.MatchString(a.Username) {
 	if !config.SkipNaturalKeysValidation && !usernameRegex.MatchString(a.Username) {
 		return util.NewValidationError(fmt.Sprintf("username %#v is not valid, the following characters are allowed: a-zA-Z0-9-_.~", a.Username))
 		return util.NewValidationError(fmt.Sprintf("username %#v is not valid, the following characters are allowed: a-zA-Z0-9-_.~", a.Username))
 	}
 	}
-	if err := a.checkPassword(); err != nil {
+	if err := a.hashPassword(); err != nil {
 		return err
 		return err
 	}
 	}
 	if err := a.validatePermissions(); err != nil {
 	if err := a.validatePermissions(); err != nil {
@@ -238,7 +238,11 @@ func (a *Admin) CheckPassword(password string) (bool, error) {
 		}
 		}
 		return true, nil
 		return true, nil
 	}
 	}
-	return argon2id.ComparePasswordAndHash(password, a.Password)
+	match, err := argon2id.ComparePasswordAndHash(password, a.Password)
+	if !match || err != nil {
+		return false, ErrInvalidCredentials
+	}
+	return match, err
 }
 }
 
 
 // CanLoginFromIP returns true if login from the given IP is allowed
 // CanLoginFromIP returns true if login from the given IP is allowed
@@ -361,14 +365,14 @@ func (a *Admin) GetValidPerms() []string {
 
 
 // GetInfoString returns admin's info as string.
 // GetInfoString returns admin's info as string.
 func (a *Admin) GetInfoString() string {
 func (a *Admin) GetInfoString() string {
-	var result string
+	var result strings.Builder
 	if a.Email != "" {
 	if a.Email != "" {
-		result = fmt.Sprintf("Email: %v. ", a.Email)
+		result.WriteString(fmt.Sprintf("Email: %v. ", a.Email))
 	}
 	}
 	if len(a.Filters.AllowList) > 0 {
 	if len(a.Filters.AllowList) > 0 {
-		result += fmt.Sprintf("Allowed IP/Mask: %v. ", len(a.Filters.AllowList))
+		result.WriteString(fmt.Sprintf("Allowed IP/Mask: %v. ", len(a.Filters.AllowList)))
 	}
 	}
-	return result
+	return result.String()
 }
 }
 
 
 // CanManageMFA returns true if the admin can add a multi-factor authentication configuration
 // CanManageMFA returns true if the admin can add a multi-factor authentication configuration

+ 5 - 6
dataprovider/apikey.go

@@ -7,7 +7,6 @@ import (
 	"time"
 	"time"
 
 
 	"github.com/alexedwards/argon2id"
 	"github.com/alexedwards/argon2id"
-	"github.com/lithammer/shortuuid/v3"
 	"golang.org/x/crypto/bcrypt"
 	"golang.org/x/crypto/bcrypt"
 
 
 	"github.com/drakkan/sftpgo/v2/logger"
 	"github.com/drakkan/sftpgo/v2/logger"
@@ -93,12 +92,12 @@ func (k *APIKey) RenderAsJSON(reload bool) ([]byte, error) {
 	return json.Marshal(k)
 	return json.Marshal(k)
 }
 }
 
 
-// HideConfidentialData hides admin confidential data
+// HideConfidentialData hides API key confidential data
 func (k *APIKey) HideConfidentialData() {
 func (k *APIKey) HideConfidentialData() {
 	k.Key = ""
 	k.Key = ""
 }
 }
 
 
-func (k *APIKey) checkKey() error {
+func (k *APIKey) hashKey() error {
 	if k.Key != "" && !util.IsStringPrefixInSlice(k.Key, internalHashPwdPrefixes) {
 	if k.Key != "" && !util.IsStringPrefixInSlice(k.Key, internalHashPwdPrefixes) {
 		if config.PasswordHashing.Algo == HashingAlgoBcrypt {
 		if config.PasswordHashing.Algo == HashingAlgoBcrypt {
 			hashed, err := bcrypt.GenerateFromPassword([]byte(k.Key), config.PasswordHashing.BcryptOptions.Cost)
 			hashed, err := bcrypt.GenerateFromPassword([]byte(k.Key), config.PasswordHashing.BcryptOptions.Cost)
@@ -121,8 +120,8 @@ func (k *APIKey) generateKey() {
 	if k.KeyID != "" || k.Key != "" {
 	if k.KeyID != "" || k.Key != "" {
 		return
 		return
 	}
 	}
-	k.KeyID = shortuuid.New()
-	k.Key = shortuuid.New()
+	k.KeyID = util.GenerateUniqueID()
+	k.Key = util.GenerateUniqueID()
 	k.plainKey = k.Key
 	k.plainKey = k.Key
 }
 }
 
 
@@ -139,7 +138,7 @@ func (k *APIKey) validate() error {
 		return util.NewValidationError(fmt.Sprintf("invalid scope: %v", k.Scope))
 		return util.NewValidationError(fmt.Sprintf("invalid scope: %v", k.Scope))
 	}
 	}
 	k.generateKey()
 	k.generateKey()
-	if err := k.checkKey(); err != nil {
+	if err := k.hashKey(); err != nil {
 		return err
 		return err
 	}
 	}
 	if k.User != "" && k.Admin != "" {
 	if k.User != "" && k.Admin != "" {

+ 323 - 48
dataprovider/bolt.go

@@ -20,7 +20,7 @@ import (
 )
 )
 
 
 const (
 const (
-	boltDatabaseVersion = 13
+	boltDatabaseVersion = 14
 )
 )
 
 
 var (
 var (
@@ -28,8 +28,11 @@ var (
 	foldersBucket   = []byte("folders")
 	foldersBucket   = []byte("folders")
 	adminsBucket    = []byte("admins")
 	adminsBucket    = []byte("admins")
 	apiKeysBucket   = []byte("api_keys")
 	apiKeysBucket   = []byte("api_keys")
+	sharesBucket    = []byte("shares")
 	dbVersionBucket = []byte("db_version")
 	dbVersionBucket = []byte("db_version")
 	dbVersionKey    = []byte("version")
 	dbVersionKey    = []byte("version")
+	boltBuckets     = [][]byte{usersBucket, foldersBucket, adminsBucket, apiKeysBucket,
+		sharesBucket, dbVersionBucket}
 )
 )
 
 
 // BoltProvider auth provider for bolt key/value store
 // BoltProvider auth provider for bolt key/value store
@@ -57,50 +60,16 @@ func initializeBoltProvider(basePath string) error {
 		Timeout:      5 * time.Second})
 		Timeout:      5 * time.Second})
 	if err == nil {
 	if err == nil {
 		providerLog(logger.LevelDebug, "bolt key store handle created")
 		providerLog(logger.LevelDebug, "bolt key store handle created")
-		err = dbHandle.Update(func(tx *bolt.Tx) error {
-			_, e := tx.CreateBucketIfNotExists(usersBucket)
-			return e
-		})
-		if err != nil {
-			providerLog(logger.LevelWarn, "error creating users bucket: %v", err)
-			return err
-		}
-		if err != nil {
-			providerLog(logger.LevelWarn, "error creating username idx bucket: %v", err)
-			return err
-		}
-		err = dbHandle.Update(func(tx *bolt.Tx) error {
-			_, e := tx.CreateBucketIfNotExists(foldersBucket)
-			return e
-		})
-		if err != nil {
-			providerLog(logger.LevelWarn, "error creating folders bucket: %v", err)
-			return err
-		}
-		err = dbHandle.Update(func(tx *bolt.Tx) error {
-			_, e := tx.CreateBucketIfNotExists(adminsBucket)
-			return e
-		})
-		if err != nil {
-			providerLog(logger.LevelWarn, "error creating admins bucket: %v", err)
-			return err
-		}
-		err = dbHandle.Update(func(tx *bolt.Tx) error {
-			_, e := tx.CreateBucketIfNotExists(apiKeysBucket)
-			return e
-		})
-		if err != nil {
-			providerLog(logger.LevelWarn, "error creating api keys bucket: %v", err)
-			return err
-		}
-		err = dbHandle.Update(func(tx *bolt.Tx) error {
-			_, e := tx.CreateBucketIfNotExists(dbVersionBucket)
-			return e
-		})
-		if err != nil {
-			providerLog(logger.LevelWarn, "error creating database version bucket: %v", err)
-			return err
+
+		for _, bucket := range boltBuckets {
+			if err := dbHandle.Update(func(tx *bolt.Tx) error {
+				_, e := tx.CreateBucketIfNotExists(bucket)
+				return e
+			}); err != nil {
+				providerLog(logger.LevelWarn, "error creating bucket %#v: %v", string(bucket), err)
+			}
 		}
 		}
+
 		provider = &BoltProvider{dbHandle: dbHandle}
 		provider = &BoltProvider{dbHandle: dbHandle}
 	} else {
 	} else {
 		providerLog(logger.LevelWarn, "error creating bolt key/value store handler: %v", err)
 		providerLog(logger.LevelWarn, "error creating bolt key/value store handler: %v", err)
@@ -638,6 +607,9 @@ func (p *BoltProvider) deleteUser(user *User) error {
 		if err := deleteRelatedAPIKey(tx, user.Username, APIKeyScopeUser); err != nil {
 		if err := deleteRelatedAPIKey(tx, user.Username, APIKeyScopeUser); err != nil {
 			return err
 			return err
 		}
 		}
+		if err := deleteRelatedShares(tx, user.Username); err != nil {
+			return err
+		}
 		return bucket.Delete([]byte(user.Username))
 		return bucket.Delete([]byte(user.Username))
 	})
 	})
 }
 }
@@ -995,6 +967,16 @@ func (p *BoltProvider) addAPIKey(apiKey *APIKey) error {
 		apiKey.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		apiKey.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		apiKey.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		apiKey.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		apiKey.LastUseAt = 0
 		apiKey.LastUseAt = 0
+		if apiKey.User != "" {
+			if err := p.userExistsInternal(tx, apiKey.User); err != nil {
+				return util.NewValidationError(fmt.Sprintf("related user %#v does not exists", apiKey.User))
+			}
+		}
+		if apiKey.Admin != "" {
+			if err := p.adminExistsInternal(tx, apiKey.Admin); err != nil {
+				return util.NewValidationError(fmt.Sprintf("related admin %#v does not exists", apiKey.User))
+			}
+		}
 		buf, err := json.Marshal(apiKey)
 		buf, err := json.Marshal(apiKey)
 		if err != nil {
 		if err != nil {
 			return err
 			return err
@@ -1030,6 +1012,16 @@ func (p *BoltProvider) updateAPIKey(apiKey *APIKey) error {
 		apiKey.CreatedAt = oldAPIKey.CreatedAt
 		apiKey.CreatedAt = oldAPIKey.CreatedAt
 		apiKey.LastUseAt = oldAPIKey.LastUseAt
 		apiKey.LastUseAt = oldAPIKey.LastUseAt
 		apiKey.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 		apiKey.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
+		if apiKey.User != "" {
+			if err := p.userExistsInternal(tx, apiKey.User); err != nil {
+				return util.NewValidationError(fmt.Sprintf("related user %#v does not exists", apiKey.User))
+			}
+		}
+		if apiKey.Admin != "" {
+			if err := p.adminExistsInternal(tx, apiKey.Admin); err != nil {
+				return util.NewValidationError(fmt.Sprintf("related admin %#v does not exists", apiKey.User))
+			}
+		}
 		buf, err := json.Marshal(apiKey)
 		buf, err := json.Marshal(apiKey)
 		if err != nil {
 		if err != nil {
 			return err
 			return err
@@ -1038,7 +1030,7 @@ func (p *BoltProvider) updateAPIKey(apiKey *APIKey) error {
 	})
 	})
 }
 }
 
 
-func (p *BoltProvider) deleteAPIKeys(apiKey *APIKey) error {
+func (p *BoltProvider) deleteAPIKey(apiKey *APIKey) error {
 	return p.dbHandle.Update(func(tx *bolt.Tx) error {
 	return p.dbHandle.Update(func(tx *bolt.Tx) error {
 		bucket, err := getAPIKeysBucket(tx)
 		bucket, err := getAPIKeysBucket(tx)
 		if err != nil {
 		if err != nil {
@@ -1127,6 +1119,224 @@ func (p *BoltProvider) dumpAPIKeys() ([]APIKey, error) {
 	return apiKeys, err
 	return apiKeys, err
 }
 }
 
 
+func (p *BoltProvider) shareExists(shareID, username string) (Share, error) {
+	var share Share
+	err := p.dbHandle.View(func(tx *bolt.Tx) error {
+		bucket, err := getSharesBucket(tx)
+		if err != nil {
+			return err
+		}
+
+		s := bucket.Get([]byte(shareID))
+		if s == nil {
+			return util.NewRecordNotFoundError(fmt.Sprintf("Share %v does not exist", shareID))
+		}
+		if err := json.Unmarshal(s, &share); err != nil {
+			return err
+		}
+		if username != "" && share.Username != username {
+			return util.NewRecordNotFoundError(fmt.Sprintf("Share %v does not exist", shareID))
+		}
+		return nil
+	})
+	return share, err
+}
+
+func (p *BoltProvider) addShare(share *Share) error {
+	err := share.validate()
+	if err != nil {
+		return err
+	}
+	return p.dbHandle.Update(func(tx *bolt.Tx) error {
+		bucket, err := getSharesBucket(tx)
+		if err != nil {
+			return err
+		}
+		if a := bucket.Get([]byte(share.ShareID)); a != nil {
+			return fmt.Errorf("share %v already exists", share.ShareID)
+		}
+		id, err := bucket.NextSequence()
+		if err != nil {
+			return err
+		}
+		share.ID = int64(id)
+		share.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
+		share.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
+		share.LastUseAt = 0
+		share.UsedTokens = 0
+		if err := p.userExistsInternal(tx, share.Username); err != nil {
+			return util.NewValidationError(fmt.Sprintf("related user %#v does not exists", share.Username))
+		}
+		buf, err := json.Marshal(share)
+		if err != nil {
+			return err
+		}
+		return bucket.Put([]byte(share.ShareID), buf)
+	})
+}
+
+func (p *BoltProvider) updateShare(share *Share) error {
+	if err := share.validate(); err != nil {
+		return err
+	}
+
+	return p.dbHandle.Update(func(tx *bolt.Tx) error {
+		bucket, err := getSharesBucket(tx)
+		if err != nil {
+			return err
+		}
+		var a []byte
+
+		if a = bucket.Get([]byte(share.ShareID)); a == nil {
+			return util.NewRecordNotFoundError(fmt.Sprintf("Share %v does not exist", share.ShareID))
+		}
+		var oldObject Share
+		if err = json.Unmarshal(a, &oldObject); err != nil {
+			return err
+		}
+
+		share.ID = oldObject.ID
+		share.ShareID = oldObject.ShareID
+		share.UsedTokens = oldObject.UsedTokens
+		share.CreatedAt = oldObject.CreatedAt
+		share.LastUseAt = oldObject.LastUseAt
+		share.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
+		if err := p.userExistsInternal(tx, share.Username); err != nil {
+			return util.NewValidationError(fmt.Sprintf("related user %#v does not exists", share.Username))
+		}
+		buf, err := json.Marshal(share)
+		if err != nil {
+			return err
+		}
+		return bucket.Put([]byte(share.ShareID), buf)
+	})
+}
+
+func (p *BoltProvider) deleteShare(share *Share) error {
+	return p.dbHandle.Update(func(tx *bolt.Tx) error {
+		bucket, err := getSharesBucket(tx)
+		if err != nil {
+			return err
+		}
+
+		if bucket.Get([]byte(share.ShareID)) == nil {
+			return util.NewRecordNotFoundError(fmt.Sprintf("Share %v does not exist", share.ShareID))
+		}
+
+		return bucket.Delete([]byte(share.ShareID))
+	})
+}
+
+func (p *BoltProvider) getShares(limit int, offset int, order, username string) ([]Share, error) {
+	shares := make([]Share, 0, limit)
+
+	err := p.dbHandle.View(func(tx *bolt.Tx) error {
+		bucket, err := getSharesBucket(tx)
+		if err != nil {
+			return err
+		}
+		cursor := bucket.Cursor()
+		itNum := 0
+		if order == OrderASC {
+			for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
+				var share Share
+				if err := json.Unmarshal(v, &share); err != nil {
+					return err
+				}
+				if share.Username != username {
+					continue
+				}
+				itNum++
+				if itNum <= offset {
+					continue
+				}
+				share.HideConfidentialData()
+				shares = append(shares, share)
+				if len(shares) >= limit {
+					break
+				}
+			}
+			return nil
+		}
+		for k, v := cursor.Last(); k != nil; k, v = cursor.Prev() {
+			var share Share
+			err = json.Unmarshal(v, &share)
+			if err != nil {
+				return err
+			}
+			if share.Username != username {
+				continue
+			}
+			itNum++
+			if itNum <= offset {
+				continue
+			}
+			share.HideConfidentialData()
+			shares = append(shares, share)
+			if len(shares) >= limit {
+				break
+			}
+		}
+		return nil
+	})
+
+	return shares, err
+}
+
+func (p *BoltProvider) dumpShares() ([]Share, error) {
+	shares := make([]Share, 0, 30)
+	err := p.dbHandle.View(func(tx *bolt.Tx) error {
+		bucket, err := getSharesBucket(tx)
+		if err != nil {
+			return err
+		}
+
+		cursor := bucket.Cursor()
+		for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
+			var share Share
+			err = json.Unmarshal(v, &share)
+			if err != nil {
+				return err
+			}
+			shares = append(shares, share)
+		}
+		return err
+	})
+
+	return shares, err
+}
+
+func (p *BoltProvider) updateShareLastUse(shareID string, numTokens int) error {
+	return p.dbHandle.Update(func(tx *bolt.Tx) error {
+		bucket, err := getSharesBucket(tx)
+		if err != nil {
+			return err
+		}
+		var u []byte
+		if u = bucket.Get([]byte(shareID)); u == nil {
+			return util.NewRecordNotFoundError(fmt.Sprintf("share %#v does not exist, unable to update last use", shareID))
+		}
+		var share Share
+		err = json.Unmarshal(u, &share)
+		if err != nil {
+			return err
+		}
+		share.LastUseAt = util.GetTimeAsMsSinceEpoch(time.Now())
+		share.UsedTokens += numTokens
+		buf, err := json.Marshal(share)
+		if err != nil {
+			return err
+		}
+		err = bucket.Put([]byte(shareID), buf)
+		if err != nil {
+			providerLog(logger.LevelWarn, "error updating last use for share %#v: %v", shareID, err)
+			return err
+		}
+		providerLog(logger.LevelDebug, "last use updated for share %#v", shareID)
+		return nil
+	})
+}
+
 func (p *BoltProvider) close() error {
 func (p *BoltProvider) close() error {
 	return p.dbHandle.Close()
 	return p.dbHandle.Close()
 }
 }
@@ -1155,11 +1365,13 @@ func (p *BoltProvider) migrateDatabase() error {
 		logger.ErrorToConsole("%v", err)
 		logger.ErrorToConsole("%v", err)
 		return err
 		return err
 	case version == 10:
 	case version == 10:
-		return updateBoltDatabaseVersion(p.dbHandle, 13)
+		return updateBoltDatabaseVersion(p.dbHandle, 14)
 	case version == 11:
 	case version == 11:
-		return updateBoltDatabaseVersion(p.dbHandle, 13)
+		return updateBoltDatabaseVersion(p.dbHandle, 14)
 	case version == 12:
 	case version == 12:
-		return updateBoltDatabaseVersion(p.dbHandle, 13)
+		return updateBoltDatabaseVersion(p.dbHandle, 14)
+	case version == 13:
+		return updateBoltDatabaseVersion(p.dbHandle, 14)
 	default:
 	default:
 		if version > boltDatabaseVersion {
 		if version > boltDatabaseVersion {
 			providerLog(logger.LevelWarn, "database version %v is newer than the supported one: %v", version,
 			providerLog(logger.LevelWarn, "database version %v is newer than the supported one: %v", version,
@@ -1181,6 +1393,8 @@ func (p *BoltProvider) revertDatabase(targetVersion int) error {
 		return errors.New("current version match target version, nothing to do")
 		return errors.New("current version match target version, nothing to do")
 	}
 	}
 	switch dbVersion.Version {
 	switch dbVersion.Version {
+	case 14:
+		return updateBoltDatabaseVersion(p.dbHandle, 10)
 	case 13:
 	case 13:
 		return updateBoltDatabaseVersion(p.dbHandle, 10)
 		return updateBoltDatabaseVersion(p.dbHandle, 10)
 	case 12:
 	case 12:
@@ -1297,6 +1511,57 @@ func removeUserFromFolderMapping(folder *vfs.VirtualFolder, user *User, bucket *
 	return err
 	return err
 }
 }
 
 
+func (p *BoltProvider) adminExistsInternal(tx *bolt.Tx, username string) error {
+	bucket, err := getAdminsBucket(tx)
+	if err != nil {
+		return err
+	}
+	a := bucket.Get([]byte(username))
+	if a == nil {
+		return util.NewRecordNotFoundError(fmt.Sprintf("admin %v does not exist", username))
+	}
+	return nil
+}
+
+func (p *BoltProvider) userExistsInternal(tx *bolt.Tx, username string) error {
+	bucket, err := getUsersBucket(tx)
+	if err != nil {
+		return err
+	}
+	u := bucket.Get([]byte(username))
+	if u == nil {
+		return util.NewRecordNotFoundError(fmt.Sprintf("username %#v does not exist", username))
+	}
+	return nil
+}
+
+func deleteRelatedShares(tx *bolt.Tx, username string) error {
+	bucket, err := getSharesBucket(tx)
+	if err != nil {
+		return err
+	}
+	var toRemove []string
+	cursor := bucket.Cursor()
+	for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
+		var share Share
+		err = json.Unmarshal(v, &share)
+		if err != nil {
+			return err
+		}
+		if share.Username == username {
+			toRemove = append(toRemove, share.ShareID)
+		}
+	}
+
+	for _, k := range toRemove {
+		if err := bucket.Delete([]byte(k)); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
 func deleteRelatedAPIKey(tx *bolt.Tx, username string, scope APIKeyScope) error {
 func deleteRelatedAPIKey(tx *bolt.Tx, username string, scope APIKeyScope) error {
 	bucket, err := getAPIKeysBucket(tx)
 	bucket, err := getAPIKeysBucket(tx)
 	if err != nil {
 	if err != nil {
@@ -1330,6 +1595,16 @@ func deleteRelatedAPIKey(tx *bolt.Tx, username string, scope APIKeyScope) error
 	return nil
 	return nil
 }
 }
 
 
+func getSharesBucket(tx *bolt.Tx) (*bolt.Bucket, error) {
+	var err error
+
+	bucket := tx.Bucket(sharesBucket)
+	if bucket == nil {
+		err = errors.New("unable to find shares bucket, bolt database structure not correcly defined")
+	}
+	return bucket, err
+}
+
 func getAPIKeysBucket(tx *bolt.Tx) (*bolt.Bucket, error) {
 func getAPIKeysBucket(tx *bolt.Tx) (*bolt.Bucket, error) {
 	var err error
 	var err error
 
 

+ 78 - 13
dataprovider/dataprovider.go

@@ -70,7 +70,7 @@ const (
 	CockroachDataProviderName = "cockroachdb"
 	CockroachDataProviderName = "cockroachdb"
 	// DumpVersion defines the version for the dump.
 	// DumpVersion defines the version for the dump.
 	// For restore/load we support the current version and the previous one
 	// For restore/load we support the current version and the previous one
-	DumpVersion = 9
+	DumpVersion = 10
 
 
 	argonPwdPrefix            = "$argon2id$"
 	argonPwdPrefix            = "$argon2id$"
 	bcryptPwdPrefix           = "$2a$"
 	bcryptPwdPrefix           = "$2a$"
@@ -131,14 +131,15 @@ var (
 	// ErrNoInitRequired defines the error returned by InitProvider if no inizialization/update is required
 	// ErrNoInitRequired defines the error returned by InitProvider if no inizialization/update is required
 	ErrNoInitRequired = errors.New("the data provider is up to date")
 	ErrNoInitRequired = errors.New("the data provider is up to date")
 	// ErrInvalidCredentials defines the error to return if the supplied credentials are invalid
 	// ErrInvalidCredentials defines the error to return if the supplied credentials are invalid
-	ErrInvalidCredentials   = errors.New("invalid credentials")
-	isAdminCreated          = int32(0)
-	validTLSUsernames       = []string{string(sdk.TLSUsernameNone), string(sdk.TLSUsernameCN)}
-	config                  Config
-	provider                Provider
-	sqlPlaceholders         []string
-	internalHashPwdPrefixes = []string{argonPwdPrefix, bcryptPwdPrefix}
-	hashPwdPrefixes         = []string{argonPwdPrefix, bcryptPwdPrefix, pbkdf2SHA1Prefix, pbkdf2SHA256Prefix,
+	ErrInvalidCredentials    = errors.New("invalid credentials")
+	ErrLoginNotAllowedFromIP = errors.New("login is not allowed from this IP")
+	isAdminCreated           = int32(0)
+	validTLSUsernames        = []string{string(sdk.TLSUsernameNone), string(sdk.TLSUsernameCN)}
+	config                   Config
+	provider                 Provider
+	sqlPlaceholders          []string
+	internalHashPwdPrefixes  = []string{argonPwdPrefix, bcryptPwdPrefix}
+	hashPwdPrefixes          = []string{argonPwdPrefix, bcryptPwdPrefix, pbkdf2SHA1Prefix, pbkdf2SHA256Prefix,
 		pbkdf2SHA512Prefix, pbkdf2SHA256B64SaltPrefix, md5cryptPwdPrefix, md5cryptApr1PwdPrefix, sha512cryptPwdPrefix}
 		pbkdf2SHA512Prefix, pbkdf2SHA256B64SaltPrefix, md5cryptPwdPrefix, md5cryptApr1PwdPrefix, sha512cryptPwdPrefix}
 	pbkdfPwdPrefixes        = []string{pbkdf2SHA1Prefix, pbkdf2SHA256Prefix, pbkdf2SHA512Prefix, pbkdf2SHA256B64SaltPrefix}
 	pbkdfPwdPrefixes        = []string{pbkdf2SHA1Prefix, pbkdf2SHA256Prefix, pbkdf2SHA512Prefix, pbkdf2SHA256B64SaltPrefix}
 	pbkdfPwdB64SaltPrefixes = []string{pbkdf2SHA256B64SaltPrefix}
 	pbkdfPwdB64SaltPrefixes = []string{pbkdf2SHA256B64SaltPrefix}
@@ -156,6 +157,7 @@ var (
 	sqlTableFoldersMapping  = "folders_mapping"
 	sqlTableFoldersMapping  = "folders_mapping"
 	sqlTableAdmins          = "admins"
 	sqlTableAdmins          = "admins"
 	sqlTableAPIKeys         = "api_keys"
 	sqlTableAPIKeys         = "api_keys"
+	sqlTableShares          = "shares"
 	sqlTableSchemaVersion   = "schema_version"
 	sqlTableSchemaVersion   = "schema_version"
 	argon2Params            *argon2id.Params
 	argon2Params            *argon2id.Params
 	lastLoginMinDelay       = 10 * time.Minute
 	lastLoginMinDelay       = 10 * time.Minute
@@ -368,6 +370,7 @@ type BackupData struct {
 	Folders []vfs.BaseVirtualFolder `json:"folders"`
 	Folders []vfs.BaseVirtualFolder `json:"folders"`
 	Admins  []Admin                 `json:"admins"`
 	Admins  []Admin                 `json:"admins"`
 	APIKeys []APIKey                `json:"api_keys"`
 	APIKeys []APIKey                `json:"api_keys"`
+	Shares  []Share                 `json:"shares"`
 	Version int                     `json:"version"`
 	Version int                     `json:"version"`
 }
 }
 
 
@@ -436,10 +439,17 @@ type Provider interface {
 	apiKeyExists(keyID string) (APIKey, error)
 	apiKeyExists(keyID string) (APIKey, error)
 	addAPIKey(apiKey *APIKey) error
 	addAPIKey(apiKey *APIKey) error
 	updateAPIKey(apiKey *APIKey) error
 	updateAPIKey(apiKey *APIKey) error
-	deleteAPIKeys(apiKey *APIKey) error
+	deleteAPIKey(apiKey *APIKey) error
 	getAPIKeys(limit int, offset int, order string) ([]APIKey, error)
 	getAPIKeys(limit int, offset int, order string) ([]APIKey, error)
 	dumpAPIKeys() ([]APIKey, error)
 	dumpAPIKeys() ([]APIKey, error)
 	updateAPIKeyLastUse(keyID string) error
 	updateAPIKeyLastUse(keyID string) error
+	shareExists(shareID, username string) (Share, error)
+	addShare(share *Share) error
+	updateShare(share *Share) error
+	deleteShare(share *Share) error
+	getShares(limit int, offset int, order, username string) ([]Share, error)
+	dumpShares() ([]Share, error)
+	updateShareLastUse(shareID string, numTokens int) error
 	checkAvailability() error
 	checkAvailability() error
 	close() error
 	close() error
 	reloadConfig() error
 	reloadConfig() error
@@ -574,10 +584,11 @@ func validateSQLTablesPrefix() error {
 		sqlTableFoldersMapping = config.SQLTablesPrefix + sqlTableFoldersMapping
 		sqlTableFoldersMapping = config.SQLTablesPrefix + sqlTableFoldersMapping
 		sqlTableAdmins = config.SQLTablesPrefix + sqlTableAdmins
 		sqlTableAdmins = config.SQLTablesPrefix + sqlTableAdmins
 		sqlTableAPIKeys = config.SQLTablesPrefix + sqlTableAPIKeys
 		sqlTableAPIKeys = config.SQLTablesPrefix + sqlTableAPIKeys
+		sqlTableShares = config.SQLTablesPrefix + sqlTableShares
 		sqlTableSchemaVersion = config.SQLTablesPrefix + sqlTableSchemaVersion
 		sqlTableSchemaVersion = config.SQLTablesPrefix + sqlTableSchemaVersion
 		providerLog(logger.LevelDebug, "sql table for users %#v, folders %#v folders mapping %#v admins %#v "+
 		providerLog(logger.LevelDebug, "sql table for users %#v, folders %#v folders mapping %#v admins %#v "+
-			"api keys %#v schema version %#v", sqlTableUsers, sqlTableFolders, sqlTableFoldersMapping, sqlTableAdmins,
-			sqlTableAPIKeys, sqlTableSchemaVersion)
+			"api keys %#v shares %#v schema version %#v", sqlTableUsers, sqlTableFolders, sqlTableFoldersMapping,
+			sqlTableAdmins, sqlTableAPIKeys, sqlTableShares, sqlTableSchemaVersion)
 	}
 	}
 	return nil
 	return nil
 }
 }
@@ -831,6 +842,11 @@ func CheckKeyboardInteractiveAuth(username, authHook string, client ssh.Keyboard
 	return doKeyboardInteractiveAuth(&user, authHook, client, ip, protocol)
 	return doKeyboardInteractiveAuth(&user, authHook, client, ip, protocol)
 }
 }
 
 
+// UpdateShareLastUse updates the LastUseAt and UsedTokens for the given share
+func UpdateShareLastUse(share *Share, numTokens int) error {
+	return provider.updateShareLastUse(share.ShareID, numTokens)
+}
+
 // UpdateAPIKeyLastUse updates the LastUseAt field for the given API key
 // UpdateAPIKeyLastUse updates the LastUseAt field for the given API key
 func UpdateAPIKeyLastUse(apiKey *APIKey) error {
 func UpdateAPIKeyLastUse(apiKey *APIKey) error {
 	lastUse := util.GetTimeFromMsecSinceEpoch(apiKey.LastUseAt)
 	lastUse := util.GetTimeFromMsecSinceEpoch(apiKey.LastUseAt)
@@ -928,6 +944,45 @@ func GetUsedVirtualFolderQuota(name string) (int, int64, error) {
 	return files + delayedFiles, size + delayedSize, err
 	return files + delayedFiles, size + delayedSize, err
 }
 }
 
 
+// AddShare adds a new share
+func AddShare(share *Share, executor, ipAddress string) error {
+	err := provider.addShare(share)
+	if err == nil {
+		executeAction(operationAdd, executor, ipAddress, actionObjectShare, share.ShareID, share)
+	}
+	return err
+}
+
+// UpdateShare updates an existing share
+func UpdateShare(share *Share, executor, ipAddress string) error {
+	err := provider.updateShare(share)
+	if err == nil {
+		executeAction(operationUpdate, executor, ipAddress, actionObjectShare, share.ShareID, share)
+	}
+	return err
+}
+
+// DeleteShare deletes an existing share
+func DeleteShare(shareID string, executor, ipAddress string) error {
+	share, err := provider.shareExists(shareID, executor)
+	if err != nil {
+		return err
+	}
+	err = provider.deleteShare(&share)
+	if err == nil {
+		executeAction(operationDelete, executor, ipAddress, actionObjectShare, shareID, &share)
+	}
+	return err
+}
+
+// ShareExists returns the share with the given ID if it exists
+func ShareExists(shareID, username string) (Share, error) {
+	if shareID == "" {
+		return Share{}, util.NewRecordNotFoundError(fmt.Sprintf("Share %#v does not exist", shareID))
+	}
+	return provider.shareExists(shareID, username)
+}
+
 // AddAPIKey adds a new API key
 // AddAPIKey adds a new API key
 func AddAPIKey(apiKey *APIKey, executor, ipAddress string) error {
 func AddAPIKey(apiKey *APIKey, executor, ipAddress string) error {
 	err := provider.addAPIKey(apiKey)
 	err := provider.addAPIKey(apiKey)
@@ -952,7 +1007,7 @@ func DeleteAPIKey(keyID string, executor, ipAddress string) error {
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
-	err = provider.deleteAPIKeys(&apiKey)
+	err = provider.deleteAPIKey(&apiKey)
 	if err == nil {
 	if err == nil {
 		executeAction(operationDelete, executor, ipAddress, actionObjectAPIKey, apiKey.KeyID, &apiKey)
 		executeAction(operationDelete, executor, ipAddress, actionObjectAPIKey, apiKey.KeyID, &apiKey)
 	}
 	}
@@ -1066,6 +1121,11 @@ func ReloadConfig() error {
 	return provider.reloadConfig()
 	return provider.reloadConfig()
 }
 }
 
 
+// GetShares returns an array of shares respecting limit and offset
+func GetShares(limit, offset int, order, username string) ([]Share, error) {
+	return provider.getShares(limit, offset, order, username)
+}
+
 // GetAPIKeys returns an array of API keys respecting limit and offset
 // GetAPIKeys returns an array of API keys respecting limit and offset
 func GetAPIKeys(limit, offset int, order string) ([]APIKey, error) {
 func GetAPIKeys(limit, offset int, order string) ([]APIKey, error) {
 	return provider.getAPIKeys(limit, offset, order)
 	return provider.getAPIKeys(limit, offset, order)
@@ -1154,10 +1214,15 @@ func DumpData() (BackupData, error) {
 	if err != nil {
 	if err != nil {
 		return data, err
 		return data, err
 	}
 	}
+	shares, err := provider.dumpShares()
+	if err != nil {
+		return data, err
+	}
 	data.Users = users
 	data.Users = users
 	data.Folders = folders
 	data.Folders = folders
 	data.Admins = admins
 	data.Admins = admins
 	data.APIKeys = apiKeys
 	data.APIKeys = apiKeys
+	data.Shares = shares
 	data.Version = DumpVersion
 	data.Version = DumpVersion
 	return data, err
 	return data, err
 }
 }

+ 277 - 8
dataprovider/memory.go

@@ -40,6 +40,10 @@ type memoryProviderHandle struct {
 	apiKeys map[string]APIKey
 	apiKeys map[string]APIKey
 	// slice with ordered API keys KeyID
 	// slice with ordered API keys KeyID
 	apiKeysIDs []string
 	apiKeysIDs []string
+	// map for shares, shareID is the key
+	shares map[string]Share
+	// slice with ordered shares shareID
+	sharesIDs []string
 }
 }
 
 
 // MemoryProvider auth provider for a memory store
 // MemoryProvider auth provider for a memory store
@@ -66,6 +70,8 @@ func initializeMemoryProvider(basePath string) {
 			adminsUsernames: []string{},
 			adminsUsernames: []string{},
 			apiKeys:         make(map[string]APIKey),
 			apiKeys:         make(map[string]APIKey),
 			apiKeysIDs:      []string{},
 			apiKeysIDs:      []string{},
+			shares:          make(map[string]Share),
+			sharesIDs:       []string{},
 			configFile:      configFile,
 			configFile:      configFile,
 		},
 		},
 	}
 	}
@@ -328,6 +334,7 @@ func (p *MemoryProvider) deleteUser(user *User) error {
 	}
 	}
 	sort.Strings(p.dbHandle.usernames)
 	sort.Strings(p.dbHandle.usernames)
 	p.deleteAPIKeysWithUser(user.Username)
 	p.deleteAPIKeysWithUser(user.Username)
+	p.deleteSharesWithUser(user.Username)
 	return nil
 	return nil
 }
 }
 
 
@@ -869,6 +876,16 @@ func (p *MemoryProvider) addAPIKey(apiKey *APIKey) error {
 	if err == nil {
 	if err == nil {
 		return fmt.Errorf("API key %#v already exists", apiKey.KeyID)
 		return fmt.Errorf("API key %#v already exists", apiKey.KeyID)
 	}
 	}
+	if apiKey.User != "" {
+		if _, err := p.userExistsInternal(apiKey.User); err != nil {
+			return util.NewValidationError(fmt.Sprintf("related user %#v does not exists", apiKey.User))
+		}
+	}
+	if apiKey.Admin != "" {
+		if _, err := p.adminExistsInternal(apiKey.Admin); err != nil {
+			return util.NewValidationError(fmt.Sprintf("related admin %#v does not exists", apiKey.User))
+		}
+	}
 	apiKey.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 	apiKey.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 	apiKey.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 	apiKey.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
 	apiKey.LastUseAt = 0
 	apiKey.LastUseAt = 0
@@ -893,6 +910,16 @@ func (p *MemoryProvider) updateAPIKey(apiKey *APIKey) error {
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
+	if apiKey.User != "" {
+		if _, err := p.userExistsInternal(apiKey.User); err != nil {
+			return util.NewValidationError(fmt.Sprintf("related user %#v does not exists", apiKey.User))
+		}
+	}
+	if apiKey.Admin != "" {
+		if _, err := p.adminExistsInternal(apiKey.Admin); err != nil {
+			return util.NewValidationError(fmt.Sprintf("related admin %#v does not exists", apiKey.User))
+		}
+	}
 	apiKey.ID = k.ID
 	apiKey.ID = k.ID
 	apiKey.KeyID = k.KeyID
 	apiKey.KeyID = k.KeyID
 	apiKey.Key = k.Key
 	apiKey.Key = k.Key
@@ -903,7 +930,7 @@ func (p *MemoryProvider) updateAPIKey(apiKey *APIKey) error {
 	return nil
 	return nil
 }
 }
 
 
-func (p *MemoryProvider) deleteAPIKeys(apiKey *APIKey) error {
+func (p *MemoryProvider) deleteAPIKey(apiKey *APIKey) error {
 	p.dbHandle.Lock()
 	p.dbHandle.Lock()
 	defer p.dbHandle.Unlock()
 	defer p.dbHandle.Unlock()
 	if p.dbHandle.isClosed {
 	if p.dbHandle.isClosed {
@@ -915,12 +942,8 @@ func (p *MemoryProvider) deleteAPIKeys(apiKey *APIKey) error {
 	}
 	}
 
 
 	delete(p.dbHandle.apiKeys, apiKey.KeyID)
 	delete(p.dbHandle.apiKeys, apiKey.KeyID)
-	// this could be more efficient
-	p.dbHandle.apiKeysIDs = make([]string, 0, len(p.dbHandle.apiKeys))
-	for keyID := range p.dbHandle.apiKeys {
-		p.dbHandle.apiKeysIDs = append(p.dbHandle.apiKeysIDs, keyID)
-	}
-	sort.Strings(p.dbHandle.apiKeysIDs)
+	p.updateAPIKeysOrdering()
+
 	return nil
 	return nil
 }
 }
 
 
@@ -986,19 +1009,235 @@ func (p *MemoryProvider) dumpAPIKeys() ([]APIKey, error) {
 }
 }
 
 
 func (p *MemoryProvider) deleteAPIKeysWithUser(username string) {
 func (p *MemoryProvider) deleteAPIKeysWithUser(username string) {
+	found := false
 	for k, v := range p.dbHandle.apiKeys {
 	for k, v := range p.dbHandle.apiKeys {
 		if v.User == username {
 		if v.User == username {
 			delete(p.dbHandle.apiKeys, k)
 			delete(p.dbHandle.apiKeys, k)
+			found = true
 		}
 		}
 	}
 	}
+	if found {
+		p.updateAPIKeysOrdering()
+	}
 }
 }
 
 
 func (p *MemoryProvider) deleteAPIKeysWithAdmin(username string) {
 func (p *MemoryProvider) deleteAPIKeysWithAdmin(username string) {
+	found := false
 	for k, v := range p.dbHandle.apiKeys {
 	for k, v := range p.dbHandle.apiKeys {
 		if v.Admin == username {
 		if v.Admin == username {
 			delete(p.dbHandle.apiKeys, k)
 			delete(p.dbHandle.apiKeys, k)
+			found = true
 		}
 		}
 	}
 	}
+	if found {
+		p.updateAPIKeysOrdering()
+	}
+}
+
+func (p *MemoryProvider) deleteSharesWithUser(username string) {
+	found := false
+	for k, v := range p.dbHandle.shares {
+		if v.Username == username {
+			delete(p.dbHandle.shares, k)
+			found = true
+		}
+	}
+	if found {
+		p.updateSharesOrdering()
+	}
+}
+
+func (p *MemoryProvider) updateAPIKeysOrdering() {
+	// this could be more efficient
+	p.dbHandle.apiKeysIDs = make([]string, 0, len(p.dbHandle.apiKeys))
+	for keyID := range p.dbHandle.apiKeys {
+		p.dbHandle.apiKeysIDs = append(p.dbHandle.apiKeysIDs, keyID)
+	}
+	sort.Strings(p.dbHandle.apiKeysIDs)
+}
+
+func (p *MemoryProvider) updateSharesOrdering() {
+	// this could be more efficient
+	p.dbHandle.sharesIDs = make([]string, 0, len(p.dbHandle.shares))
+	for shareID := range p.dbHandle.shares {
+		p.dbHandle.sharesIDs = append(p.dbHandle.sharesIDs, shareID)
+	}
+	sort.Strings(p.dbHandle.sharesIDs)
+}
+
+func (p *MemoryProvider) shareExistsInternal(shareID, username string) (Share, error) {
+	if val, ok := p.dbHandle.shares[shareID]; ok {
+		if username != "" && val.Username != username {
+			return Share{}, util.NewRecordNotFoundError(fmt.Sprintf("Share %#v does not exist", shareID))
+		}
+		return val.getACopy(), nil
+	}
+	return Share{}, util.NewRecordNotFoundError(fmt.Sprintf("Share %#v does not exist", shareID))
+}
+
+func (p *MemoryProvider) shareExists(shareID, username string) (Share, error) {
+	p.dbHandle.Lock()
+	defer p.dbHandle.Unlock()
+	if p.dbHandle.isClosed {
+		return Share{}, errMemoryProviderClosed
+	}
+	return p.shareExistsInternal(shareID, username)
+}
+
+func (p *MemoryProvider) addShare(share *Share) error {
+	err := share.validate()
+	if err != nil {
+		return err
+	}
+
+	p.dbHandle.Lock()
+	defer p.dbHandle.Unlock()
+	if p.dbHandle.isClosed {
+		return errMemoryProviderClosed
+	}
+
+	_, err = p.shareExistsInternal(share.ShareID, share.Username)
+	if err == nil {
+		return fmt.Errorf("share %#v already exists", share.ShareID)
+	}
+	if _, err := p.userExistsInternal(share.Username); err != nil {
+		return util.NewValidationError(fmt.Sprintf("related user %#v does not exists", share.Username))
+	}
+	share.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
+	share.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
+	share.LastUseAt = 0
+	share.UsedTokens = 0
+	p.dbHandle.shares[share.ShareID] = share.getACopy()
+	p.dbHandle.sharesIDs = append(p.dbHandle.sharesIDs, share.ShareID)
+	sort.Strings(p.dbHandle.sharesIDs)
+	return nil
+}
+
+func (p *MemoryProvider) updateShare(share *Share) error {
+	err := share.validate()
+	if err != nil {
+		return err
+	}
+
+	p.dbHandle.Lock()
+	defer p.dbHandle.Unlock()
+	if p.dbHandle.isClosed {
+		return errMemoryProviderClosed
+	}
+	s, err := p.shareExistsInternal(share.ShareID, share.Username)
+	if err != nil {
+		return err
+	}
+	if _, err := p.userExistsInternal(share.Username); err != nil {
+		return util.NewValidationError(fmt.Sprintf("related user %#v does not exists", share.Username))
+	}
+	share.ID = s.ID
+	share.ShareID = s.ShareID
+	share.UsedTokens = s.UsedTokens
+	share.CreatedAt = s.CreatedAt
+	share.LastUseAt = s.LastUseAt
+	share.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now())
+	p.dbHandle.shares[share.ShareID] = share.getACopy()
+	return nil
+}
+
+func (p *MemoryProvider) deleteShare(share *Share) error {
+	p.dbHandle.Lock()
+	defer p.dbHandle.Unlock()
+	if p.dbHandle.isClosed {
+		return errMemoryProviderClosed
+	}
+	_, err := p.shareExistsInternal(share.ShareID, share.Username)
+	if err != nil {
+		return err
+	}
+
+	delete(p.dbHandle.shares, share.ShareID)
+	p.updateSharesOrdering()
+
+	return nil
+}
+
+func (p *MemoryProvider) getShares(limit int, offset int, order, username string) ([]Share, error) {
+	p.dbHandle.Lock()
+	defer p.dbHandle.Unlock()
+
+	if p.dbHandle.isClosed {
+		return []Share{}, errMemoryProviderClosed
+	}
+	if limit <= 0 {
+		return []Share{}, nil
+	}
+	shares := make([]Share, 0, limit)
+	itNum := 0
+	if order == OrderDESC {
+		for i := len(p.dbHandle.sharesIDs) - 1; i >= 0; i-- {
+			shareID := p.dbHandle.sharesIDs[i]
+			s := p.dbHandle.shares[shareID]
+			if s.Username != username {
+				continue
+			}
+			itNum++
+			if itNum <= offset {
+				continue
+			}
+			share := s.getACopy()
+			share.HideConfidentialData()
+			shares = append(shares, share)
+			if len(shares) >= limit {
+				break
+			}
+		}
+	} else {
+		for _, shareID := range p.dbHandle.sharesIDs {
+			s := p.dbHandle.shares[shareID]
+			if s.Username != username {
+				continue
+			}
+			itNum++
+			if itNum <= offset {
+				continue
+			}
+			share := s.getACopy()
+			share.HideConfidentialData()
+			shares = append(shares, share)
+			if len(shares) >= limit {
+				break
+			}
+		}
+	}
+
+	return shares, nil
+}
+
+func (p *MemoryProvider) dumpShares() ([]Share, error) {
+	p.dbHandle.Lock()
+	defer p.dbHandle.Unlock()
+
+	shares := make([]Share, 0, len(p.dbHandle.shares))
+	if p.dbHandle.isClosed {
+		return shares, errMemoryProviderClosed
+	}
+	for _, s := range p.dbHandle.shares {
+		shares = append(shares, s)
+	}
+	return shares, nil
+}
+
+func (p *MemoryProvider) updateShareLastUse(shareID string, numTokens int) error {
+	p.dbHandle.Lock()
+	defer p.dbHandle.Unlock()
+	if p.dbHandle.isClosed {
+		return errMemoryProviderClosed
+	}
+	share, err := p.shareExistsInternal(shareID, "")
+	if err != nil {
+		return err
+	}
+	share.LastUseAt = util.GetTimeAsMsSinceEpoch(time.Now())
+	share.UsedTokens += numTokens
+	p.dbHandle.shares[share.ShareID] = share
+	return nil
 }
 }
 
 
 func (p *MemoryProvider) getNextID() int64 {
 func (p *MemoryProvider) getNextID() int64 {
@@ -1040,6 +1279,10 @@ func (p *MemoryProvider) clear() {
 	p.dbHandle.vfolders = make(map[string]vfs.BaseVirtualFolder)
 	p.dbHandle.vfolders = make(map[string]vfs.BaseVirtualFolder)
 	p.dbHandle.admins = make(map[string]Admin)
 	p.dbHandle.admins = make(map[string]Admin)
 	p.dbHandle.adminsUsernames = []string{}
 	p.dbHandle.adminsUsernames = []string{}
+	p.dbHandle.apiKeys = make(map[string]APIKey)
+	p.dbHandle.apiKeysIDs = []string{}
+	p.dbHandle.shares = make(map[string]Share)
+	p.dbHandle.sharesIDs = []string{}
 }
 }
 
 
 func (p *MemoryProvider) reloadConfig() error {
 func (p *MemoryProvider) reloadConfig() error {
@@ -1091,13 +1334,39 @@ func (p *MemoryProvider) reloadConfig() error {
 		return err
 		return err
 	}
 	}
 
 
+	if err := p.restoreShares(&dump); err != nil {
+		return err
+	}
+
 	providerLog(logger.LevelDebug, "config loaded from file: %#v", p.dbHandle.configFile)
 	providerLog(logger.LevelDebug, "config loaded from file: %#v", p.dbHandle.configFile)
 	return nil
 	return nil
 }
 }
 
 
+func (p *MemoryProvider) restoreShares(dump *BackupData) error {
+	for _, share := range dump.Shares {
+		s, err := p.shareExists(share.ShareID, "")
+		share := share // pin
+		if err == nil {
+			share.ID = s.ID
+			err = UpdateShare(&share, ActionExecutorSystem, "")
+			if err != nil {
+				providerLog(logger.LevelWarn, "error updating share %#v: %v", share.ShareID, err)
+				return err
+			}
+		} else {
+			err = AddShare(&share, ActionExecutorSystem, "")
+			if err != nil {
+				providerLog(logger.LevelWarn, "error adding share %#v: %v", share.ShareID, err)
+				return err
+			}
+		}
+	}
+	return nil
+}
+
 func (p *MemoryProvider) restoreAPIKeys(dump *BackupData) error {
 func (p *MemoryProvider) restoreAPIKeys(dump *BackupData) error {
 	for _, apiKey := range dump.APIKeys {
 	for _, apiKey := range dump.APIKeys {
-		if apiKey.KeyID == "" {
+		if apiKey.Key == "" {
 			return fmt.Errorf("cannot restore an empty API key: %+v", apiKey)
 			return fmt.Errorf("cannot restore an empty API key: %+v", apiKey)
 		}
 		}
 		k, err := p.apiKeyExists(apiKey.KeyID)
 		k, err := p.apiKeyExists(apiKey.KeyID)

+ 74 - 2
dataprovider/mysql.go

@@ -66,6 +66,15 @@ const (
 
 
 	mysqlV13SQL     = "ALTER TABLE `{{users}}` ADD COLUMN `email` varchar(255) NULL;"
 	mysqlV13SQL     = "ALTER TABLE `{{users}}` ADD COLUMN `email` varchar(255) NULL;"
 	mysqlV13DownSQL = "ALTER TABLE `{{users}}` DROP COLUMN `email`;"
 	mysqlV13DownSQL = "ALTER TABLE `{{users}}` DROP COLUMN `email`;"
+	mysqlV14SQL     = "CREATE TABLE `{{shares}}` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, " +
+		"`share_id` varchar(60) NOT NULL UNIQUE, `name` varchar(255) NOT NULL, `description` varchar(512) NULL, " +
+		"`scope` integer NOT NULL, `paths` longtext NOT NULL, `created_at` bigint NOT NULL, " +
+		"`updated_at` bigint NOT NULL, `last_use_at` bigint NOT NULL, `expires_at` bigint NOT NULL, " +
+		"`password` longtext NULL, `max_tokens` integer NOT NULL, `used_tokens` integer NOT NULL, " +
+		"`allow_from` longtext NULL, `user_id` integer NOT NULL);" +
+		"ALTER TABLE `{{shares}}` ADD CONSTRAINT `{{prefix}}shares_user_id_fk_users_id` " +
+		"FOREIGN KEY (`user_id`) REFERENCES `{{users}}` (`id`) ON DELETE CASCADE;"
+	mysqlV14DownSQL = "DROP TABLE `{{shares}}` CASCADE;"
 )
 )
 
 
 // MySQLProvider auth provider for MySQL/MariaDB database
 // MySQLProvider auth provider for MySQL/MariaDB database
@@ -251,7 +260,7 @@ func (p *MySQLProvider) updateAPIKey(apiKey *APIKey) error {
 	return sqlCommonUpdateAPIKey(apiKey, p.dbHandle)
 	return sqlCommonUpdateAPIKey(apiKey, p.dbHandle)
 }
 }
 
 
-func (p *MySQLProvider) deleteAPIKeys(apiKey *APIKey) error {
+func (p *MySQLProvider) deleteAPIKey(apiKey *APIKey) error {
 	return sqlCommonDeleteAPIKey(apiKey, p.dbHandle)
 	return sqlCommonDeleteAPIKey(apiKey, p.dbHandle)
 }
 }
 
 
@@ -267,6 +276,34 @@ func (p *MySQLProvider) updateAPIKeyLastUse(keyID string) error {
 	return sqlCommonUpdateAPIKeyLastUse(keyID, p.dbHandle)
 	return sqlCommonUpdateAPIKeyLastUse(keyID, p.dbHandle)
 }
 }
 
 
+func (p *MySQLProvider) shareExists(shareID, username string) (Share, error) {
+	return sqlCommonGetShareByID(shareID, username, p.dbHandle)
+}
+
+func (p *MySQLProvider) addShare(share *Share) error {
+	return sqlCommonAddShare(share, p.dbHandle)
+}
+
+func (p *MySQLProvider) updateShare(share *Share) error {
+	return sqlCommonUpdateShare(share, p.dbHandle)
+}
+
+func (p *MySQLProvider) deleteShare(share *Share) error {
+	return sqlCommonDeleteShare(share, p.dbHandle)
+}
+
+func (p *MySQLProvider) getShares(limit int, offset int, order, username string) ([]Share, error) {
+	return sqlCommonGetShares(limit, offset, order, username, p.dbHandle)
+}
+
+func (p *MySQLProvider) dumpShares() ([]Share, error) {
+	return sqlCommonDumpShares(p.dbHandle)
+}
+
+func (p *MySQLProvider) updateShareLastUse(shareID string, numTokens int) error {
+	return sqlCommonUpdateShareLastUse(shareID, numTokens, p.dbHandle)
+}
+
 func (p *MySQLProvider) close() error {
 func (p *MySQLProvider) close() error {
 	return p.dbHandle.Close()
 	return p.dbHandle.Close()
 }
 }
@@ -291,6 +328,7 @@ func (p *MySQLProvider) initializeDatabase() error {
 	return sqlCommonExecSQLAndUpdateDBVersion(p.dbHandle, strings.Split(initialSQL, ";"), 10)
 	return sqlCommonExecSQLAndUpdateDBVersion(p.dbHandle, strings.Split(initialSQL, ";"), 10)
 }
 }
 
 
+//nolint:dupl
 func (p *MySQLProvider) migrateDatabase() error {
 func (p *MySQLProvider) migrateDatabase() error {
 	dbVersion, err := sqlCommonGetDatabaseVersion(p.dbHandle, true)
 	dbVersion, err := sqlCommonGetDatabaseVersion(p.dbHandle, true)
 	if err != nil {
 	if err != nil {
@@ -312,6 +350,8 @@ func (p *MySQLProvider) migrateDatabase() error {
 		return updateMySQLDatabaseFromV11(p.dbHandle)
 		return updateMySQLDatabaseFromV11(p.dbHandle)
 	case version == 12:
 	case version == 12:
 		return updateMySQLDatabaseFromV12(p.dbHandle)
 		return updateMySQLDatabaseFromV12(p.dbHandle)
+	case version == 13:
+		return updateMySQLDatabaseFromV13(p.dbHandle)
 	default:
 	default:
 		if version > sqlDatabaseVersion {
 		if version > sqlDatabaseVersion {
 			providerLog(logger.LevelWarn, "database version %v is newer than the supported one: %v", version,
 			providerLog(logger.LevelWarn, "database version %v is newer than the supported one: %v", version,
@@ -334,6 +374,8 @@ func (p *MySQLProvider) revertDatabase(targetVersion int) error {
 	}
 	}
 
 
 	switch dbVersion.Version {
 	switch dbVersion.Version {
+	case 14:
+		return downgradeMySQLDatabaseFromV14(p.dbHandle)
 	case 13:
 	case 13:
 		return downgradeMySQLDatabaseFromV13(p.dbHandle)
 		return downgradeMySQLDatabaseFromV13(p.dbHandle)
 	case 12:
 	case 12:
@@ -360,7 +402,21 @@ func updateMySQLDatabaseFromV11(dbHandle *sql.DB) error {
 }
 }
 
 
 func updateMySQLDatabaseFromV12(dbHandle *sql.DB) error {
 func updateMySQLDatabaseFromV12(dbHandle *sql.DB) error {
-	return updateMySQLDatabaseFrom12To13(dbHandle)
+	if err := updateMySQLDatabaseFrom12To13(dbHandle); err != nil {
+		return err
+	}
+	return updateMySQLDatabaseFromV13(dbHandle)
+}
+
+func updateMySQLDatabaseFromV13(dbHandle *sql.DB) error {
+	return updateMySQLDatabaseFrom13To14(dbHandle)
+}
+
+func downgradeMySQLDatabaseFromV14(dbHandle *sql.DB) error {
+	if err := downgradeMySQLDatabaseFrom14To13(dbHandle); err != nil {
+		return err
+	}
+	return downgradeMySQLDatabaseFromV13(dbHandle)
 }
 }
 
 
 func downgradeMySQLDatabaseFromV13(dbHandle *sql.DB) error {
 func downgradeMySQLDatabaseFromV13(dbHandle *sql.DB) error {
@@ -381,6 +437,22 @@ func downgradeMySQLDatabaseFromV11(dbHandle *sql.DB) error {
 	return downgradeMySQLDatabaseFrom11To10(dbHandle)
 	return downgradeMySQLDatabaseFrom11To10(dbHandle)
 }
 }
 
 
+func updateMySQLDatabaseFrom13To14(dbHandle *sql.DB) error {
+	logger.InfoToConsole("updating database version: 13 -> 14")
+	providerLog(logger.LevelInfo, "updating database version: 13 -> 14")
+	sql := strings.ReplaceAll(mysqlV14SQL, "{{shares}}", sqlTableShares)
+	sql = strings.ReplaceAll(sql, "{{users}}", sqlTableUsers)
+	sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 14)
+}
+
+func downgradeMySQLDatabaseFrom14To13(dbHandle *sql.DB) error {
+	logger.InfoToConsole("downgrading database version: 14 -> 13")
+	providerLog(logger.LevelInfo, "downgrading database version: 14 -> 13")
+	sql := strings.ReplaceAll(mysqlV14DownSQL, "{{shares}}", sqlTableShares)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, strings.Split(sql, ";"), 13)
+}
+
 func updateMySQLDatabaseFrom12To13(dbHandle *sql.DB) error {
 func updateMySQLDatabaseFrom12To13(dbHandle *sql.DB) error {
 	logger.InfoToConsole("updating database version: 12 -> 13")
 	logger.InfoToConsole("updating database version: 12 -> 13")
 	providerLog(logger.LevelInfo, "updating database version: 12 -> 13")
 	providerLog(logger.LevelInfo, "updating database version: 12 -> 13")

+ 76 - 2
dataprovider/pgsql.go

@@ -78,6 +78,17 @@ ALTER TABLE "{{admins}}" DROP COLUMN "last_login" CASCADE;
 `
 `
 	pgsqlV13SQL     = `ALTER TABLE "{{users}}" ADD COLUMN "email" varchar(255) NULL;`
 	pgsqlV13SQL     = `ALTER TABLE "{{users}}" ADD COLUMN "email" varchar(255) NULL;`
 	pgsqlV13DownSQL = `ALTER TABLE "{{users}}" DROP COLUMN "email" CASCADE;`
 	pgsqlV13DownSQL = `ALTER TABLE "{{users}}" DROP COLUMN "email" CASCADE;`
+	pgsqlV14SQL     = `CREATE TABLE "{{shares}}" ("id" serial NOT NULL PRIMARY KEY,
+"share_id" varchar(60) NOT NULL UNIQUE, "name" varchar(255) NOT NULL, "description" varchar(512) NULL,
+"scope" integer NOT NULL, "paths" text NOT NULL, "created_at" bigint NOT NULL, "updated_at" bigint NOT NULL,
+"last_use_at" bigint NOT NULL, "expires_at" bigint NOT NULL, "password" text NULL,
+"max_tokens" integer NOT NULL, "used_tokens" integer NOT NULL, "allow_from" text NULL,
+"user_id" integer NOT NULL);
+ALTER TABLE "{{shares}}" ADD CONSTRAINT "{{prefix}}shares_user_id_fk_users_id" FOREIGN KEY ("user_id")
+REFERENCES "{{users}}" ("id") MATCH SIMPLE ON UPDATE NO ACTION ON DELETE CASCADE;
+CREATE INDEX "{{prefix}}shares_user_id_idx" ON "{{shares}}" ("user_id");
+`
+	pgsqlV14DownSQL = `DROP TABLE "{{shares}}" CASCADE;`
 )
 )
 
 
 // PGSQLProvider auth provider for PostgreSQL database
 // PGSQLProvider auth provider for PostgreSQL database
@@ -263,7 +274,7 @@ func (p *PGSQLProvider) updateAPIKey(apiKey *APIKey) error {
 	return sqlCommonUpdateAPIKey(apiKey, p.dbHandle)
 	return sqlCommonUpdateAPIKey(apiKey, p.dbHandle)
 }
 }
 
 
-func (p *PGSQLProvider) deleteAPIKeys(apiKey *APIKey) error {
+func (p *PGSQLProvider) deleteAPIKey(apiKey *APIKey) error {
 	return sqlCommonDeleteAPIKey(apiKey, p.dbHandle)
 	return sqlCommonDeleteAPIKey(apiKey, p.dbHandle)
 }
 }
 
 
@@ -279,6 +290,34 @@ func (p *PGSQLProvider) updateAPIKeyLastUse(keyID string) error {
 	return sqlCommonUpdateAPIKeyLastUse(keyID, p.dbHandle)
 	return sqlCommonUpdateAPIKeyLastUse(keyID, p.dbHandle)
 }
 }
 
 
+func (p *PGSQLProvider) shareExists(shareID, username string) (Share, error) {
+	return sqlCommonGetShareByID(shareID, username, p.dbHandle)
+}
+
+func (p *PGSQLProvider) addShare(share *Share) error {
+	return sqlCommonAddShare(share, p.dbHandle)
+}
+
+func (p *PGSQLProvider) updateShare(share *Share) error {
+	return sqlCommonUpdateShare(share, p.dbHandle)
+}
+
+func (p *PGSQLProvider) deleteShare(share *Share) error {
+	return sqlCommonDeleteShare(share, p.dbHandle)
+}
+
+func (p *PGSQLProvider) getShares(limit int, offset int, order, username string) ([]Share, error) {
+	return sqlCommonGetShares(limit, offset, order, username, p.dbHandle)
+}
+
+func (p *PGSQLProvider) dumpShares() ([]Share, error) {
+	return sqlCommonDumpShares(p.dbHandle)
+}
+
+func (p *PGSQLProvider) updateShareLastUse(shareID string, numTokens int) error {
+	return sqlCommonUpdateShareLastUse(shareID, numTokens, p.dbHandle)
+}
+
 func (p *PGSQLProvider) close() error {
 func (p *PGSQLProvider) close() error {
 	return p.dbHandle.Close()
 	return p.dbHandle.Close()
 }
 }
@@ -309,6 +348,7 @@ func (p *PGSQLProvider) initializeDatabase() error {
 	return sqlCommonExecSQLAndUpdateDBVersion(p.dbHandle, []string{initialSQL}, 10)
 	return sqlCommonExecSQLAndUpdateDBVersion(p.dbHandle, []string{initialSQL}, 10)
 }
 }
 
 
+//nolint:dupl
 func (p *PGSQLProvider) migrateDatabase() error {
 func (p *PGSQLProvider) migrateDatabase() error {
 	dbVersion, err := sqlCommonGetDatabaseVersion(p.dbHandle, true)
 	dbVersion, err := sqlCommonGetDatabaseVersion(p.dbHandle, true)
 	if err != nil {
 	if err != nil {
@@ -330,6 +370,8 @@ func (p *PGSQLProvider) migrateDatabase() error {
 		return updatePGSQLDatabaseFromV11(p.dbHandle)
 		return updatePGSQLDatabaseFromV11(p.dbHandle)
 	case version == 12:
 	case version == 12:
 		return updatePGSQLDatabaseFromV12(p.dbHandle)
 		return updatePGSQLDatabaseFromV12(p.dbHandle)
+	case version == 13:
+		return updatePGSQLDatabaseFromV13(p.dbHandle)
 	default:
 	default:
 		if version > sqlDatabaseVersion {
 		if version > sqlDatabaseVersion {
 			providerLog(logger.LevelWarn, "database version %v is newer than the supported one: %v", version,
 			providerLog(logger.LevelWarn, "database version %v is newer than the supported one: %v", version,
@@ -352,6 +394,8 @@ func (p *PGSQLProvider) revertDatabase(targetVersion int) error {
 	}
 	}
 
 
 	switch dbVersion.Version {
 	switch dbVersion.Version {
+	case 14:
+		return downgradePGSQLDatabaseFromV14(p.dbHandle)
 	case 13:
 	case 13:
 		return downgradePGSQLDatabaseFromV13(p.dbHandle)
 		return downgradePGSQLDatabaseFromV13(p.dbHandle)
 	case 12:
 	case 12:
@@ -378,7 +422,21 @@ func updatePGSQLDatabaseFromV11(dbHandle *sql.DB) error {
 }
 }
 
 
 func updatePGSQLDatabaseFromV12(dbHandle *sql.DB) error {
 func updatePGSQLDatabaseFromV12(dbHandle *sql.DB) error {
-	return updatePGSQLDatabaseFrom12To13(dbHandle)
+	if err := updatePGSQLDatabaseFrom12To13(dbHandle); err != nil {
+		return err
+	}
+	return updatePGSQLDatabaseFromV13(dbHandle)
+}
+
+func updatePGSQLDatabaseFromV13(dbHandle *sql.DB) error {
+	return updatePGSQLDatabaseFrom13To14(dbHandle)
+}
+
+func downgradePGSQLDatabaseFromV14(dbHandle *sql.DB) error {
+	if err := downgradePGSQLDatabaseFrom14To13(dbHandle); err != nil {
+		return err
+	}
+	return downgradePGSQLDatabaseFromV13(dbHandle)
 }
 }
 
 
 func downgradePGSQLDatabaseFromV13(dbHandle *sql.DB) error {
 func downgradePGSQLDatabaseFromV13(dbHandle *sql.DB) error {
@@ -399,6 +457,22 @@ func downgradePGSQLDatabaseFromV11(dbHandle *sql.DB) error {
 	return downgradePGSQLDatabaseFrom11To10(dbHandle)
 	return downgradePGSQLDatabaseFrom11To10(dbHandle)
 }
 }
 
 
+func updatePGSQLDatabaseFrom13To14(dbHandle *sql.DB) error {
+	logger.InfoToConsole("updating database version: 13 -> 14")
+	providerLog(logger.LevelInfo, "updating database version: 13 -> 14")
+	sql := strings.ReplaceAll(pgsqlV14SQL, "{{shares}}", sqlTableShares)
+	sql = strings.ReplaceAll(sql, "{{users}}", sqlTableUsers)
+	sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 14)
+}
+
+func downgradePGSQLDatabaseFrom14To13(dbHandle *sql.DB) error {
+	logger.InfoToConsole("downgrading database version: 14 -> 13")
+	providerLog(logger.LevelInfo, "downgrading database version: 14 -> 13")
+	sql := strings.ReplaceAll(pgsqlV14DownSQL, "{{shares}}", sqlTableShares)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 13)
+}
+
 func updatePGSQLDatabaseFrom12To13(dbHandle *sql.DB) error {
 func updatePGSQLDatabaseFrom12To13(dbHandle *sql.DB) error {
 	logger.InfoToConsole("updating database version: 12 -> 13")
 	logger.InfoToConsole("updating database version: 12 -> 13")
 	providerLog(logger.LevelInfo, "updating database version: 12 -> 13")
 	providerLog(logger.LevelInfo, "updating database version: 12 -> 13")

+ 274 - 0
dataprovider/share.go

@@ -0,0 +1,274 @@
+package dataprovider
+
+import (
+	"encoding/json"
+	"fmt"
+	"net"
+	"strings"
+	"time"
+
+	"github.com/alexedwards/argon2id"
+	"golang.org/x/crypto/bcrypt"
+
+	"github.com/drakkan/sftpgo/v2/logger"
+	"github.com/drakkan/sftpgo/v2/util"
+)
+
+// ShareScope defines the supported share scopes
+type ShareScope int
+
+// Supported share scopes
+const (
+	ShareScopeRead ShareScope = iota + 1
+	ShareScopeWrite
+)
+
+const (
+	redactedPassword = "[**redacted**]"
+)
+
+// Share defines files and or directories shared with external users
+type Share struct {
+	// Database unique identifier
+	ID int64 `json:"-"`
+	// Unique ID used to access this object
+	ShareID     string     `json:"id"`
+	Name        string     `json:"name"`
+	Description string     `json:"description,omitempty"`
+	Scope       ShareScope `json:"scope"`
+	// Paths to files or directories, for ShareScopeWrite it must be exactly one directory
+	Paths []string `json:"paths"`
+	// Username who shared this object
+	Username  string `json:"username"`
+	CreatedAt int64  `json:"created_at"`
+	UpdatedAt int64  `json:"updated_at"`
+	// 0 means never used
+	LastUseAt int64 `json:"last_use_at,omitempty"`
+	// ExpiresAt expiration date/time as unix timestamp in milliseconds, 0 means no expiration
+	ExpiresAt int64 `json:"expires_at,omitempty"`
+	// Optional password to protect the share
+	Password string `json:"password"`
+	// Limit the available access tokens, 0 means no limit
+	MaxTokens int `json:"max_tokens,omitempty"`
+	// Used tokens
+	UsedTokens int `json:"used_tokens,omitempty"`
+	// Limit the share availability to these IPs/CIDR networks
+	AllowFrom []string `json:"allow_from,omitempty"`
+}
+
+// GetScopeAsString returns the share's scope as string.
+// Used in web pages
+func (s *Share) GetScopeAsString() string {
+	switch s.Scope {
+	case ShareScopeRead:
+		return "Read"
+	default:
+		return "Write"
+	}
+}
+
+// GetInfoString returns share's info as string.
+func (s *Share) GetInfoString() string {
+	var result strings.Builder
+	if s.ExpiresAt > 0 {
+		t := util.GetTimeFromMsecSinceEpoch(s.ExpiresAt)
+		result.WriteString(fmt.Sprintf("Expiration: %v. ", t.Format("2006-01-02 15:04"))) // YYYY-MM-DD HH:MM
+	}
+	if s.MaxTokens > 0 {
+		result.WriteString(fmt.Sprintf("Usage: %v/%v. ", s.UsedTokens, s.MaxTokens))
+	}
+	if len(s.AllowFrom) > 0 {
+		result.WriteString(fmt.Sprintf("Allowed IP/Mask: %v. ", len(s.AllowFrom)))
+	}
+	if s.Password != "" {
+		result.WriteString("Password protected.")
+	}
+	return result.String()
+}
+
+// GetAllowedFromAsString returns the allowed IP as comma separated string
+func (s *Share) GetAllowedFromAsString() string {
+	return strings.Join(s.AllowFrom, ",")
+}
+
+func (s *Share) getACopy() Share {
+	allowFrom := make([]string, len(s.AllowFrom))
+	copy(allowFrom, s.AllowFrom)
+
+	return Share{
+		ID:          s.ID,
+		ShareID:     s.ShareID,
+		Name:        s.Name,
+		Description: s.Description,
+		Scope:       s.Scope,
+		Paths:       s.Paths,
+		Username:    s.Username,
+		CreatedAt:   s.CreatedAt,
+		UpdatedAt:   s.UpdatedAt,
+		LastUseAt:   s.LastUseAt,
+		ExpiresAt:   s.ExpiresAt,
+		Password:    s.Password,
+		MaxTokens:   s.MaxTokens,
+		UsedTokens:  s.UsedTokens,
+		AllowFrom:   allowFrom,
+	}
+}
+
+// RenderAsJSON implements the renderer interface used within plugins
+func (s *Share) RenderAsJSON(reload bool) ([]byte, error) {
+	if reload {
+		share, err := provider.shareExists(s.ShareID, s.Username)
+		if err != nil {
+			providerLog(logger.LevelWarn, "unable to reload share before rendering as json: %v", err)
+			return nil, err
+		}
+		share.HideConfidentialData()
+		return json.Marshal(share)
+	}
+	s.HideConfidentialData()
+	return json.Marshal(s)
+}
+
+// HideConfidentialData hides share confidential data
+func (s *Share) HideConfidentialData() {
+	if s.Password != "" {
+		s.Password = redactedPassword
+	}
+}
+
+// HasRedactedPassword returns true if this share has a redacted password
+func (s *Share) HasRedactedPassword() bool {
+	return s.Password == redactedPassword
+}
+
+func (s *Share) hashPassword() error {
+	if s.Password != "" && !util.IsStringPrefixInSlice(s.Password, internalHashPwdPrefixes) {
+		if config.PasswordHashing.Algo == HashingAlgoBcrypt {
+			hashed, err := bcrypt.GenerateFromPassword([]byte(s.Password), config.PasswordHashing.BcryptOptions.Cost)
+			if err != nil {
+				return err
+			}
+			s.Password = string(hashed)
+		} else {
+			hashed, err := argon2id.CreateHash(s.Password, argon2Params)
+			if err != nil {
+				return err
+			}
+			s.Password = hashed
+		}
+	}
+	return nil
+}
+
+func (s *Share) validatePaths() error {
+	var paths []string
+	for _, p := range s.Paths {
+		p = strings.TrimSpace(p)
+		if p != "" {
+			paths = append(paths, p)
+		}
+	}
+	s.Paths = paths
+	if len(s.Paths) == 0 {
+		return util.NewValidationError("at least a shared path is required")
+	}
+	for idx := range s.Paths {
+		s.Paths[idx] = util.CleanPath(s.Paths[idx])
+	}
+	s.Paths = util.RemoveDuplicates(s.Paths)
+	if s.Scope == ShareScopeWrite && len(s.Paths) != 1 {
+		return util.NewValidationError("the write share scope requires exactly one path")
+	}
+	return nil
+}
+
+func (s *Share) validate() error {
+	if s.ShareID == "" {
+		return util.NewValidationError("share_id is mandatory")
+	}
+	if s.Name == "" {
+		return util.NewValidationError("name is mandatory")
+	}
+	if s.Scope != ShareScopeRead && s.Scope != ShareScopeWrite {
+		return util.NewValidationError(fmt.Sprintf("invalid scope: %v", s.Scope))
+	}
+	if err := s.validatePaths(); err != nil {
+		return err
+	}
+	if s.ExpiresAt > 0 {
+		if s.ExpiresAt < util.GetTimeAsMsSinceEpoch(time.Now()) {
+			return util.NewValidationError("expiration must be in the future")
+		}
+	} else {
+		s.ExpiresAt = 0
+	}
+	if s.MaxTokens < 0 {
+		return util.NewValidationError("invalid max tokens")
+	}
+	if s.Username == "" {
+		return util.NewValidationError("username is mandatory")
+	}
+	if s.HasRedactedPassword() {
+		return util.NewValidationError("cannot save a share with a redacted password")
+	}
+	if err := s.hashPassword(); err != nil {
+		return err
+	}
+	for _, IPMask := range s.AllowFrom {
+		_, _, err := net.ParseCIDR(IPMask)
+		if err != nil {
+			return util.NewValidationError(fmt.Sprintf("could not parse allow from entry %#v : %v", IPMask, err))
+		}
+	}
+	return nil
+}
+
+// CheckPassword verifies the share password if set
+func (s *Share) CheckPassword(password string) (bool, error) {
+	if s.Password == "" {
+		return true, nil
+	}
+	if password == "" {
+		return false, ErrInvalidCredentials
+	}
+	if strings.HasPrefix(s.Password, bcryptPwdPrefix) {
+		if err := bcrypt.CompareHashAndPassword([]byte(s.Password), []byte(password)); err != nil {
+			return false, ErrInvalidCredentials
+		}
+		return true, nil
+	}
+	match, err := argon2id.ComparePasswordAndHash(password, s.Password)
+	if !match || err != nil {
+		return false, ErrInvalidCredentials
+	}
+	return match, err
+}
+
+// IsUsable checks if the share is usable from the specified IP
+func (s *Share) IsUsable(ip string) (bool, error) {
+	if s.MaxTokens > 0 && s.UsedTokens >= s.MaxTokens {
+		return false, util.NewRecordNotFoundError("max share usage exceeded")
+	}
+	if s.ExpiresAt > 0 {
+		if s.ExpiresAt < util.GetTimeAsMsSinceEpoch(time.Now()) {
+			return false, util.NewRecordNotFoundError("share expired")
+		}
+	}
+	if len(s.AllowFrom) == 0 {
+		return true, nil
+	}
+	parsedIP := net.ParseIP(ip)
+	if parsedIP == nil {
+		return false, ErrLoginNotAllowedFromIP
+	}
+	for _, ipMask := range s.AllowFrom {
+		_, network, err := net.ParseCIDR(ipMask)
+		if err != nil {
+			continue
+		}
+		if network.Contains(parsedIP) {
+			return true, nil
+		}
+	}
+	return false, ErrLoginNotAllowedFromIP
+}

+ 239 - 1
dataprovider/sqlcommon.go

@@ -19,7 +19,7 @@ import (
 )
 )
 
 
 const (
 const (
-	sqlDatabaseVersion     = 13
+	sqlDatabaseVersion     = 14
 	defaultSQLQueryTimeout = 10 * time.Second
 	defaultSQLQueryTimeout = 10 * time.Second
 	longSQLQueryTimeout    = 60 * time.Second
 	longSQLQueryTimeout    = 60 * time.Second
 )
 )
@@ -34,10 +34,189 @@ type sqlScanner interface {
 	Scan(dest ...interface{}) error
 	Scan(dest ...interface{}) error
 }
 }
 
 
+func sqlCommonGetShareByID(shareID, username string, dbHandle sqlQuerier) (Share, error) {
+	var share Share
+	ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
+	defer cancel()
+
+	filterUser := username != ""
+	q := getShareByIDQuery(filterUser)
+	stmt, err := dbHandle.PrepareContext(ctx, q)
+	if err != nil {
+		providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err)
+		return share, err
+	}
+	defer stmt.Close()
+	var row *sql.Row
+	if filterUser {
+		row = stmt.QueryRowContext(ctx, shareID, username)
+	} else {
+		row = stmt.QueryRowContext(ctx, shareID)
+	}
+
+	return getShareFromDbRow(row)
+}
+
+func sqlCommonAddShare(share *Share, dbHandle *sql.DB) error {
+	err := share.validate()
+	if err != nil {
+		return err
+	}
+
+	user, err := provider.userExists(share.Username)
+	if err != nil {
+		return util.NewValidationError(fmt.Sprintf("unable to validate user %#v", share.Username))
+	}
+
+	paths, err := json.Marshal(share.Paths)
+	if err != nil {
+		return err
+	}
+	allowFrom := ""
+	if len(share.AllowFrom) > 0 {
+		res, err := json.Marshal(share.AllowFrom)
+		if err == nil {
+			allowFrom = string(res)
+		}
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
+	defer cancel()
+	q := getAddShareQuery()
+	stmt, err := dbHandle.PrepareContext(ctx, q)
+	if err != nil {
+		providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err)
+		return err
+	}
+	defer stmt.Close()
+
+	_, err = stmt.ExecContext(ctx, share.ShareID, share.Name, share.Description, share.Scope,
+		string(paths), util.GetTimeAsMsSinceEpoch(time.Now()), util.GetTimeAsMsSinceEpoch(time.Now()),
+		share.LastUseAt, share.ExpiresAt, share.Password, share.MaxTokens, allowFrom, user.ID)
+	return err
+}
+
+func sqlCommonUpdateShare(share *Share, dbHandle *sql.DB) error {
+	err := share.validate()
+	if err != nil {
+		return err
+	}
+
+	paths, err := json.Marshal(share.Paths)
+	if err != nil {
+		return err
+	}
+
+	allowFrom := ""
+	if len(share.AllowFrom) > 0 {
+		res, err := json.Marshal(share.AllowFrom)
+		if err == nil {
+			allowFrom = string(res)
+		}
+	}
+
+	user, err := provider.userExists(share.Username)
+	if err != nil {
+		return util.NewValidationError(fmt.Sprintf("unable to validate user %#v", share.Username))
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
+	defer cancel()
+	q := getUpdateShareQuery()
+	stmt, err := dbHandle.PrepareContext(ctx, q)
+	if err != nil {
+		providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err)
+		return err
+	}
+	defer stmt.Close()
+
+	_, err = stmt.ExecContext(ctx, share.Name, share.Description, share.Scope, string(paths),
+		util.GetTimeAsMsSinceEpoch(time.Now()), share.ExpiresAt, share.Password, share.MaxTokens,
+		allowFrom, user.ID, share.ShareID)
+	return err
+}
+
+func sqlCommonDeleteShare(share *Share, dbHandle *sql.DB) error {
+	ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
+	defer cancel()
+
+	q := getDeleteShareQuery()
+	stmt, err := dbHandle.PrepareContext(ctx, q)
+	if err != nil {
+		providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err)
+		return err
+	}
+	defer stmt.Close()
+	_, err = stmt.ExecContext(ctx, share.ShareID)
+	return err
+}
+
+func sqlCommonGetShares(limit, offset int, order, username string, dbHandle sqlQuerier) ([]Share, error) {
+	shares := make([]Share, 0, limit)
+
+	ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
+	defer cancel()
+	q := getSharesQuery(order)
+	stmt, err := dbHandle.PrepareContext(ctx, q)
+	if err != nil {
+		providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err)
+		return nil, err
+	}
+	defer stmt.Close()
+
+	rows, err := stmt.QueryContext(ctx, username, limit, offset)
+	if err != nil {
+		return shares, err
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		s, err := getShareFromDbRow(rows)
+		if err != nil {
+			return shares, err
+		}
+		s.HideConfidentialData()
+		shares = append(shares, s)
+	}
+
+	return shares, rows.Err()
+}
+
+func sqlCommonDumpShares(dbHandle sqlQuerier) ([]Share, error) {
+	shares := make([]Share, 0, 30)
+
+	ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
+	defer cancel()
+	q := getDumpSharesQuery()
+	stmt, err := dbHandle.PrepareContext(ctx, q)
+	if err != nil {
+		providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err)
+		return nil, err
+	}
+	defer stmt.Close()
+
+	rows, err := stmt.QueryContext(ctx)
+	if err != nil {
+		return shares, err
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		s, err := getShareFromDbRow(rows)
+		if err != nil {
+			return shares, err
+		}
+		shares = append(shares, s)
+	}
+
+	return shares, rows.Err()
+}
+
 func sqlCommonGetAPIKeyByID(keyID string, dbHandle sqlQuerier) (APIKey, error) {
 func sqlCommonGetAPIKeyByID(keyID string, dbHandle sqlQuerier) (APIKey, error) {
 	var apiKey APIKey
 	var apiKey APIKey
 	ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
 	ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
 	defer cancel()
 	defer cancel()
+
 	q := getAPIKeyByIDQuery()
 	q := getAPIKeyByIDQuery()
 	stmt, err := dbHandle.PrepareContext(ctx, q)
 	stmt, err := dbHandle.PrepareContext(ctx, q)
 	if err != nil {
 	if err != nil {
@@ -468,6 +647,25 @@ func sqlCommonGetUsedQuota(username string, dbHandle *sql.DB) (int, int64, error
 	return usedFiles, usedSize, err
 	return usedFiles, usedSize, err
 }
 }
 
 
+func sqlCommonUpdateShareLastUse(shareID string, numTokens int, dbHandle *sql.DB) error {
+	ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
+	defer cancel()
+	q := getUpdateShareLastUseQuery()
+	stmt, err := dbHandle.PrepareContext(ctx, q)
+	if err != nil {
+		providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err)
+		return err
+	}
+	defer stmt.Close()
+	_, err = stmt.ExecContext(ctx, util.GetTimeAsMsSinceEpoch(time.Now()), numTokens, shareID)
+	if err == nil {
+		providerLog(logger.LevelDebug, "last use updated for shared object %#v", shareID)
+	} else {
+		providerLog(logger.LevelWarn, "error updating last use for shared object %#v: %v", shareID, err)
+	}
+	return err
+}
+
 func sqlCommonUpdateAPIKeyLastUse(keyID string, dbHandle *sql.DB) error {
 func sqlCommonUpdateAPIKeyLastUse(keyID string, dbHandle *sql.DB) error {
 	ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
 	ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout)
 	defer cancel()
 	defer cancel()
@@ -739,6 +937,46 @@ func sqlCommonGetUsers(limit int, offset int, order string, dbHandle sqlQuerier)
 	return getUsersWithVirtualFolders(ctx, users, dbHandle)
 	return getUsersWithVirtualFolders(ctx, users, dbHandle)
 }
 }
 
 
+func getShareFromDbRow(row sqlScanner) (Share, error) {
+	var share Share
+	var description, password, allowFrom, paths sql.NullString
+
+	err := row.Scan(&share.ShareID, &share.Name, &description, &share.Scope,
+		&paths, &share.Username, &share.CreatedAt, &share.UpdatedAt,
+		&share.LastUseAt, &share.ExpiresAt, &password, &share.MaxTokens,
+		&share.UsedTokens, &allowFrom)
+	if err != nil {
+		if err == sql.ErrNoRows {
+			return share, util.NewRecordNotFoundError(err.Error())
+		}
+		return share, err
+	}
+	if paths.Valid {
+		var list []string
+		err = json.Unmarshal([]byte(paths.String), &list)
+		if err != nil {
+			return share, err
+		}
+		share.Paths = list
+	} else {
+		return share, errors.New("unable to decode shared paths")
+	}
+	if description.Valid {
+		share.Description = description.String
+	}
+	if password.Valid {
+		share.Password = password.String
+	}
+	if allowFrom.Valid {
+		var list []string
+		err = json.Unmarshal([]byte(allowFrom.String), &list)
+		if err == nil {
+			share.AllowFrom = list
+		}
+	}
+	return share, nil
+}
+
 func getAPIKeyFromDbRow(row sqlScanner) (APIKey, error) {
 func getAPIKeyFromDbRow(row sqlScanner) (APIKey, error) {
 	var apiKey APIKey
 	var apiKey APIKey
 	var userID, adminID sql.NullInt64
 	var userID, adminID sql.NullInt64

+ 74 - 2
dataprovider/sqlite.go

@@ -69,6 +69,15 @@ ALTER TABLE "{{admins}}" DROP COLUMN "last_login";
 `
 `
 	sqliteV13SQL     = `ALTER TABLE "{{users}}" ADD COLUMN "email" varchar(255) NULL;`
 	sqliteV13SQL     = `ALTER TABLE "{{users}}" ADD COLUMN "email" varchar(255) NULL;`
 	sqliteV13DownSQL = `ALTER TABLE "{{users}}" DROP COLUMN "email";`
 	sqliteV13DownSQL = `ALTER TABLE "{{users}}" DROP COLUMN "email";`
+	sqliteV14SQL     = `CREATE TABLE "{{shares}}" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
+"share_id" varchar(60) NOT NULL UNIQUE, "name" varchar(255) NOT NULL, "description" varchar(512) NULL,
+"scope" integer NOT NULL, "paths" text NOT NULL, "created_at" bigint NOT NULL, "updated_at" bigint NOT NULL,
+"last_use_at" bigint NOT NULL, "expires_at" bigint NOT NULL, "password" text NULL, "max_tokens" integer NOT NULL,
+"used_tokens" integer NOT NULL, "allow_from" text NULL,
+"user_id" integer NOT NULL REFERENCES "{{users}}" ("id") ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED);
+CREATE INDEX "{{prefix}}shares_user_id_idx" ON "{{shares}}" ("user_id");
+`
+	sqliteV14DownSQL = `DROP TABLE "{{shares}}";`
 )
 )
 
 
 // SQLiteProvider auth provider for SQLite database
 // SQLiteProvider auth provider for SQLite database
@@ -247,7 +256,7 @@ func (p *SQLiteProvider) updateAPIKey(apiKey *APIKey) error {
 	return sqlCommonUpdateAPIKey(apiKey, p.dbHandle)
 	return sqlCommonUpdateAPIKey(apiKey, p.dbHandle)
 }
 }
 
 
-func (p *SQLiteProvider) deleteAPIKeys(apiKey *APIKey) error {
+func (p *SQLiteProvider) deleteAPIKey(apiKey *APIKey) error {
 	return sqlCommonDeleteAPIKey(apiKey, p.dbHandle)
 	return sqlCommonDeleteAPIKey(apiKey, p.dbHandle)
 }
 }
 
 
@@ -263,6 +272,34 @@ func (p *SQLiteProvider) updateAPIKeyLastUse(keyID string) error {
 	return sqlCommonUpdateAPIKeyLastUse(keyID, p.dbHandle)
 	return sqlCommonUpdateAPIKeyLastUse(keyID, p.dbHandle)
 }
 }
 
 
+func (p *SQLiteProvider) shareExists(shareID, username string) (Share, error) {
+	return sqlCommonGetShareByID(shareID, username, p.dbHandle)
+}
+
+func (p *SQLiteProvider) addShare(share *Share) error {
+	return sqlCommonAddShare(share, p.dbHandle)
+}
+
+func (p *SQLiteProvider) updateShare(share *Share) error {
+	return sqlCommonUpdateShare(share, p.dbHandle)
+}
+
+func (p *SQLiteProvider) deleteShare(share *Share) error {
+	return sqlCommonDeleteShare(share, p.dbHandle)
+}
+
+func (p *SQLiteProvider) getShares(limit int, offset int, order, username string) ([]Share, error) {
+	return sqlCommonGetShares(limit, offset, order, username, p.dbHandle)
+}
+
+func (p *SQLiteProvider) dumpShares() ([]Share, error) {
+	return sqlCommonDumpShares(p.dbHandle)
+}
+
+func (p *SQLiteProvider) updateShareLastUse(shareID string, numTokens int) error {
+	return sqlCommonUpdateShareLastUse(shareID, numTokens, p.dbHandle)
+}
+
 func (p *SQLiteProvider) close() error {
 func (p *SQLiteProvider) close() error {
 	return p.dbHandle.Close()
 	return p.dbHandle.Close()
 }
 }
@@ -287,6 +324,7 @@ func (p *SQLiteProvider) initializeDatabase() error {
 	return sqlCommonExecSQLAndUpdateDBVersion(p.dbHandle, []string{initialSQL}, 10)
 	return sqlCommonExecSQLAndUpdateDBVersion(p.dbHandle, []string{initialSQL}, 10)
 }
 }
 
 
+//nolint:dupl
 func (p *SQLiteProvider) migrateDatabase() error {
 func (p *SQLiteProvider) migrateDatabase() error {
 	dbVersion, err := sqlCommonGetDatabaseVersion(p.dbHandle, true)
 	dbVersion, err := sqlCommonGetDatabaseVersion(p.dbHandle, true)
 	if err != nil {
 	if err != nil {
@@ -308,6 +346,8 @@ func (p *SQLiteProvider) migrateDatabase() error {
 		return updateSQLiteDatabaseFromV11(p.dbHandle)
 		return updateSQLiteDatabaseFromV11(p.dbHandle)
 	case version == 12:
 	case version == 12:
 		return updateSQLiteDatabaseFromV12(p.dbHandle)
 		return updateSQLiteDatabaseFromV12(p.dbHandle)
+	case version == 13:
+		return updateSQLiteDatabaseFromV13(p.dbHandle)
 	default:
 	default:
 		if version > sqlDatabaseVersion {
 		if version > sqlDatabaseVersion {
 			providerLog(logger.LevelWarn, "database version %v is newer than the supported one: %v", version,
 			providerLog(logger.LevelWarn, "database version %v is newer than the supported one: %v", version,
@@ -330,6 +370,8 @@ func (p *SQLiteProvider) revertDatabase(targetVersion int) error {
 	}
 	}
 
 
 	switch dbVersion.Version {
 	switch dbVersion.Version {
+	case 14:
+		return downgradeSQLiteDatabaseFromV14(p.dbHandle)
 	case 13:
 	case 13:
 		return downgradeSQLiteDatabaseFromV13(p.dbHandle)
 		return downgradeSQLiteDatabaseFromV13(p.dbHandle)
 	case 12:
 	case 12:
@@ -356,7 +398,21 @@ func updateSQLiteDatabaseFromV11(dbHandle *sql.DB) error {
 }
 }
 
 
 func updateSQLiteDatabaseFromV12(dbHandle *sql.DB) error {
 func updateSQLiteDatabaseFromV12(dbHandle *sql.DB) error {
-	return updateSQLiteDatabaseFrom12To13(dbHandle)
+	if err := updateSQLiteDatabaseFrom12To13(dbHandle); err != nil {
+		return err
+	}
+	return updateSQLiteDatabaseFromV13(dbHandle)
+}
+
+func updateSQLiteDatabaseFromV13(dbHandle *sql.DB) error {
+	return updateSQLiteDatabaseFrom13To14(dbHandle)
+}
+
+func downgradeSQLiteDatabaseFromV14(dbHandle *sql.DB) error {
+	if err := downgradeSQLiteDatabaseFrom14To13(dbHandle); err != nil {
+		return err
+	}
+	return downgradeSQLiteDatabaseFromV13(dbHandle)
 }
 }
 
 
 func downgradeSQLiteDatabaseFromV13(dbHandle *sql.DB) error {
 func downgradeSQLiteDatabaseFromV13(dbHandle *sql.DB) error {
@@ -377,6 +433,22 @@ func downgradeSQLiteDatabaseFromV11(dbHandle *sql.DB) error {
 	return downgradeSQLiteDatabaseFrom11To10(dbHandle)
 	return downgradeSQLiteDatabaseFrom11To10(dbHandle)
 }
 }
 
 
+func updateSQLiteDatabaseFrom13To14(dbHandle *sql.DB) error {
+	logger.InfoToConsole("updating database version: 13 -> 14")
+	providerLog(logger.LevelInfo, "updating database version: 13 -> 14")
+	sql := strings.ReplaceAll(sqliteV14SQL, "{{shares}}", sqlTableShares)
+	sql = strings.ReplaceAll(sql, "{{users}}", sqlTableUsers)
+	sql = strings.ReplaceAll(sql, "{{prefix}}", config.SQLTablesPrefix)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 14)
+}
+
+func downgradeSQLiteDatabaseFrom14To13(dbHandle *sql.DB) error {
+	logger.InfoToConsole("downgrading database version: 14 -> 13")
+	providerLog(logger.LevelInfo, "downgrading database version: 14 -> 13")
+	sql := strings.ReplaceAll(sqliteV14DownSQL, "{{shares}}", sqlTableShares)
+	return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 13)
+}
+
 func updateSQLiteDatabaseFrom12To13(dbHandle *sql.DB) error {
 func updateSQLiteDatabaseFrom12To13(dbHandle *sql.DB) error {
 	logger.InfoToConsole("updating database version: 12 -> 13")
 	logger.InfoToConsole("updating database version: 12 -> 13")
 	providerLog(logger.LevelInfo, "updating database version: 12 -> 13")
 	providerLog(logger.LevelInfo, "updating database version: 12 -> 13")

+ 47 - 0
dataprovider/sqlqueries.go

@@ -15,6 +15,8 @@ const (
 	selectFolderFields = "id,path,used_quota_size,used_quota_files,last_quota_update,name,description,filesystem"
 	selectFolderFields = "id,path,used_quota_size,used_quota_files,last_quota_update,name,description,filesystem"
 	selectAdminFields  = "id,username,password,status,email,permissions,filters,additional_info,description,created_at,updated_at,last_login"
 	selectAdminFields  = "id,username,password,status,email,permissions,filters,additional_info,description,created_at,updated_at,last_login"
 	selectAPIKeyFields = "key_id,name,api_key,scope,created_at,updated_at,last_use_at,expires_at,description,user_id,admin_id"
 	selectAPIKeyFields = "key_id,name,api_key,scope,created_at,updated_at,last_use_at,expires_at,description,user_id,admin_id"
+	selectShareFields  = "s.share_id,s.name,s.description,s.scope,s.paths,u.username,s.created_at,s.updated_at,s.last_use_at," +
+		"s.expires_at,s.password,s.max_tokens,s.used_tokens,s.allow_from"
 )
 )
 
 
 func getSQLPlaceholders() []string {
 func getSQLPlaceholders() []string {
@@ -59,6 +61,46 @@ func getDeleteAdminQuery() string {
 	return fmt.Sprintf(`DELETE FROM %v WHERE username = %v`, sqlTableAdmins, sqlPlaceholders[0])
 	return fmt.Sprintf(`DELETE FROM %v WHERE username = %v`, sqlTableAdmins, sqlPlaceholders[0])
 }
 }
 
 
+func getShareByIDQuery(filterUser bool) string {
+	if filterUser {
+		return fmt.Sprintf(`SELECT %v FROM %v s INNER JOIN %v u ON s.user_id = u.id WHERE s.share_id = %v AND u.username = %v`,
+			selectShareFields, sqlTableShares, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1])
+	}
+	return fmt.Sprintf(`SELECT %v FROM %v s INNER JOIN %v u ON s.user_id = u.id WHERE s.share_id = %v`,
+		selectShareFields, sqlTableShares, sqlTableUsers, sqlPlaceholders[0])
+}
+
+func getSharesQuery(order string) string {
+	return fmt.Sprintf(`SELECT %v FROM %v s INNER JOIN %v u ON s.user_id = u.id WHERE u.username = %v ORDER BY s.share_id %v LIMIT %v OFFSET %v`,
+		selectShareFields, sqlTableShares, sqlTableUsers, sqlPlaceholders[0], order, sqlPlaceholders[1], sqlPlaceholders[2])
+}
+
+func getDumpSharesQuery() string {
+	return fmt.Sprintf(`SELECT %v FROM %v s INNER JOIN %v u ON s.user_id = u.id`,
+		selectShareFields, sqlTableShares, sqlTableUsers)
+}
+
+func getAddShareQuery() string {
+	return fmt.Sprintf(`INSERT INTO %v (share_id,name,description,scope,paths,created_at,updated_at,last_use_at,
+		expires_at,password,max_tokens,used_tokens,allow_from,user_id) VALUES (%v,%v,%v,%v,%v,%v,%v,%v,%v,%v,%v,0,%v,%v)`,
+		sqlTableShares, sqlPlaceholders[0], sqlPlaceholders[1],
+		sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6],
+		sqlPlaceholders[7], sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10], sqlPlaceholders[11],
+		sqlPlaceholders[12])
+}
+
+func getUpdateShareQuery() string {
+	return fmt.Sprintf(`UPDATE %v SET name=%v,description=%v,scope=%v,paths=%v,updated_at=%v,expires_at=%v,
+		password=%v,max_tokens=%v,allow_from=%v,user_id=%v WHERE share_id = %v`, sqlTableShares,
+		sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4],
+		sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7], sqlPlaceholders[8], sqlPlaceholders[9],
+		sqlPlaceholders[10])
+}
+
+func getDeleteShareQuery() string {
+	return fmt.Sprintf(`DELETE FROM %v WHERE share_id = %v`, sqlTableShares, sqlPlaceholders[0])
+}
+
 func getAPIKeyByIDQuery() string {
 func getAPIKeyByIDQuery() string {
 	return fmt.Sprintf(`SELECT %v FROM %v WHERE key_id = %v`, selectAPIKeyFields, sqlTableAPIKeys, sqlPlaceholders[0])
 	return fmt.Sprintf(`SELECT %v FROM %v WHERE key_id = %v`, selectAPIKeyFields, sqlTableAPIKeys, sqlPlaceholders[0])
 }
 }
@@ -177,6 +219,11 @@ func getUpdateAPIKeyLastUseQuery() string {
 	return fmt.Sprintf(`UPDATE %v SET last_use_at = %v WHERE key_id = %v`, sqlTableAPIKeys, sqlPlaceholders[0], sqlPlaceholders[1])
 	return fmt.Sprintf(`UPDATE %v SET last_use_at = %v WHERE key_id = %v`, sqlTableAPIKeys, sqlPlaceholders[0], sqlPlaceholders[1])
 }
 }
 
 
+func getUpdateShareLastUseQuery() string {
+	return fmt.Sprintf(`UPDATE %v SET last_use_at = %v, used_tokens = used_tokens +%v WHERE share_id = %v`,
+		sqlTableShares, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2])
+}
+
 func getQuotaQuery() string {
 func getQuotaQuery() string {
 	return fmt.Sprintf(`SELECT used_quota_size,used_quota_files FROM %v WHERE username = %v`, sqlTableUsers,
 	return fmt.Sprintf(`SELECT used_quota_size,used_quota_files FROM %v WHERE username = %v`, sqlTableUsers,
 		sqlPlaceholders[0])
 		sqlPlaceholders[0])

+ 15 - 10
dataprovider/user.go

@@ -727,6 +727,11 @@ func (u *User) CanManageMFA() bool {
 	return len(mfa.GetAvailableTOTPConfigs()) > 0
 	return len(mfa.GetAvailableTOTPConfigs()) > 0
 }
 }
 
 
+// CanManageShares returns true if the user can add, update and list shares
+func (u *User) CanManageShares() bool {
+	return !util.IsStringInSlice(sdk.WebClientSharesDisabled, u.Filters.WebClient)
+}
+
 // CanChangePassword returns true if this user is allowed to change its password
 // CanChangePassword returns true if this user is allowed to change its password
 func (u *User) CanChangePassword() bool {
 func (u *User) CanChangePassword() bool {
 	return !util.IsStringInSlice(sdk.WebClientPasswordChangeDisabled, u.Filters.WebClient)
 	return !util.IsStringInSlice(sdk.WebClientPasswordChangeDisabled, u.Filters.WebClient)
@@ -943,33 +948,33 @@ func (u *User) GetBandwidthAsString() string {
 // Storage provider, number of public keys, max sessions, uid,
 // Storage provider, number of public keys, max sessions, uid,
 // gid, denied and allowed IP/Mask are returned
 // gid, denied and allowed IP/Mask are returned
 func (u *User) GetInfoString() string {
 func (u *User) GetInfoString() string {
-	var result string
+	var result strings.Builder
 	if u.LastLogin > 0 {
 	if u.LastLogin > 0 {
 		t := util.GetTimeFromMsecSinceEpoch(u.LastLogin)
 		t := util.GetTimeFromMsecSinceEpoch(u.LastLogin)
-		result += fmt.Sprintf("Last login: %v ", t.Format("2006-01-02 15:04")) // YYYY-MM-DD HH:MM
+		result.WriteString(fmt.Sprintf("Last login: %v. ", t.Format("2006-01-02 15:04"))) // YYYY-MM-DD HH:MM
 	}
 	}
 	if u.FsConfig.Provider != sdk.LocalFilesystemProvider {
 	if u.FsConfig.Provider != sdk.LocalFilesystemProvider {
-		result += fmt.Sprintf("Storage: %s ", u.FsConfig.Provider.ShortInfo())
+		result.WriteString(fmt.Sprintf("Storage: %s. ", u.FsConfig.Provider.ShortInfo()))
 	}
 	}
 	if len(u.PublicKeys) > 0 {
 	if len(u.PublicKeys) > 0 {
-		result += fmt.Sprintf("Public keys: %v ", len(u.PublicKeys))
+		result.WriteString(fmt.Sprintf("Public keys: %v. ", len(u.PublicKeys)))
 	}
 	}
 	if u.MaxSessions > 0 {
 	if u.MaxSessions > 0 {
-		result += fmt.Sprintf("Max sessions: %v ", u.MaxSessions)
+		result.WriteString(fmt.Sprintf("Max sessions: %v. ", u.MaxSessions))
 	}
 	}
 	if u.UID > 0 {
 	if u.UID > 0 {
-		result += fmt.Sprintf("UID: %v ", u.UID)
+		result.WriteString(fmt.Sprintf("UID: %v. ", u.UID))
 	}
 	}
 	if u.GID > 0 {
 	if u.GID > 0 {
-		result += fmt.Sprintf("GID: %v ", u.GID)
+		result.WriteString(fmt.Sprintf("GID: %v. ", u.GID))
 	}
 	}
 	if len(u.Filters.DeniedIP) > 0 {
 	if len(u.Filters.DeniedIP) > 0 {
-		result += fmt.Sprintf("Denied IP/Mask: %v ", len(u.Filters.DeniedIP))
+		result.WriteString(fmt.Sprintf("Denied IP/Mask: %v. ", len(u.Filters.DeniedIP)))
 	}
 	}
 	if len(u.Filters.AllowedIP) > 0 {
 	if len(u.Filters.AllowedIP) > 0 {
-		result += fmt.Sprintf("Allowed IP/Mask: %v ", len(u.Filters.AllowedIP))
+		result.WriteString(fmt.Sprintf("Allowed IP/Mask: %v", len(u.Filters.AllowedIP)))
 	}
 	}
-	return result
+	return result.String()
 }
 }
 
 
 // GetStatusAsString returns the user status as a string
 // GetStatusAsString returns the user status as a string

+ 3 - 1
docs/web-client.md

@@ -1,6 +1,8 @@
 # Web Client
 # Web Client
 
 
-SFTPGo provides a basic front-end web interface for your users. It allows end-users to browse and download their files and change their credentials.
+SFTPGo provides a basic front-end web interface for your users. It allows end-users to browse and manage their files and change their credentials.
+
+Each user can create HTTP/S links to externally share files and folders securely, by setting limits to the number of downloads/uploads, protecting the share with a password, limiting access by source IP address, setting an automatic expiration date.
 
 
 The web client user interface also allows you to edit plain text files up to 512KB in size.
 The web client user interface also allows you to edit plain text files up to 512KB in size.
 
 

+ 28 - 27
go.mod

@@ -7,10 +7,9 @@ require (
 	github.com/Azure/azure-storage-blob-go v0.14.0
 	github.com/Azure/azure-storage-blob-go v0.14.0
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
 	github.com/alexedwards/argon2id v0.0.0-20210511081203-7d35d68092b8
 	github.com/alexedwards/argon2id v0.0.0-20210511081203-7d35d68092b8
-	github.com/aws/aws-sdk-go v1.41.13
+	github.com/aws/aws-sdk-go v1.41.19
 	github.com/cockroachdb/cockroach-go/v2 v2.2.1
 	github.com/cockroachdb/cockroach-go/v2 v2.2.1
 	github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b
 	github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b
-	github.com/fatih/color v1.13.0 // indirect
 	github.com/fclairamb/ftpserverlib v0.16.0
 	github.com/fclairamb/ftpserverlib v0.16.0
 	github.com/fclairamb/go-log v0.1.0
 	github.com/fclairamb/go-log v0.1.0
 	github.com/go-chi/chi/v5 v5.0.5
 	github.com/go-chi/chi/v5 v5.0.5
@@ -19,42 +18,33 @@ require (
 	github.com/go-sql-driver/mysql v1.6.0
 	github.com/go-sql-driver/mysql v1.6.0
 	github.com/golang/mock v1.6.0
 	github.com/golang/mock v1.6.0
 	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
 	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
+	github.com/google/uuid v1.3.0
 	github.com/grandcat/zeroconf v1.0.0
 	github.com/grandcat/zeroconf v1.0.0
-	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
 	github.com/hashicorp/go-hclog v1.0.0
 	github.com/hashicorp/go-hclog v1.0.0
 	github.com/hashicorp/go-plugin v1.4.3
 	github.com/hashicorp/go-plugin v1.4.3
 	github.com/hashicorp/go-retryablehttp v0.7.0
 	github.com/hashicorp/go-retryablehttp v0.7.0
-	github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87 // indirect
 	github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126
 	github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126
 	github.com/klauspost/compress v1.13.6
 	github.com/klauspost/compress v1.13.6
-	github.com/kr/text v0.2.0 // indirect
-	github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect
 	github.com/lestrrat-go/jwx v1.2.9
 	github.com/lestrrat-go/jwx v1.2.9
 	github.com/lib/pq v1.10.3
 	github.com/lib/pq v1.10.3
 	github.com/lithammer/shortuuid/v3 v3.0.7
 	github.com/lithammer/shortuuid/v3 v3.0.7
-	github.com/mattn/go-isatty v0.0.14 // indirect
 	github.com/mattn/go-sqlite3 v1.14.9
 	github.com/mattn/go-sqlite3 v1.14.9
 	github.com/mhale/smtpd v0.8.0
 	github.com/mhale/smtpd v0.8.0
-	github.com/miekg/dns v1.1.43 // indirect
 	github.com/minio/sio v0.3.0
 	github.com/minio/sio v0.3.0
-	github.com/mitchellh/go-testing-interface v1.14.1 // indirect
-	github.com/oklog/run v1.1.0 // indirect
 	github.com/otiai10/copy v1.6.0
 	github.com/otiai10/copy v1.6.0
 	github.com/pires/go-proxyproto v0.6.1
 	github.com/pires/go-proxyproto v0.6.1
 	github.com/pkg/sftp v1.13.4
 	github.com/pkg/sftp v1.13.4
 	github.com/pquerna/otp v1.3.0
 	github.com/pquerna/otp v1.3.0
 	github.com/prometheus/client_golang v1.11.0
 	github.com/prometheus/client_golang v1.11.0
-	github.com/prometheus/common v0.32.1 // indirect
 	github.com/rs/cors v1.8.0
 	github.com/rs/cors v1.8.0
 	github.com/rs/xid v1.3.0
 	github.com/rs/xid v1.3.0
-	github.com/rs/zerolog v1.25.0
-	github.com/russross/blackfriday/v2 v2.1.0 // indirect
-	github.com/shirou/gopsutil/v3 v3.21.9
+	github.com/rs/zerolog v1.26.0
+	github.com/shirou/gopsutil/v3 v3.21.10
 	github.com/spf13/afero v1.6.0
 	github.com/spf13/afero v1.6.0
 	github.com/spf13/cobra v1.2.1
 	github.com/spf13/cobra v1.2.1
 	github.com/spf13/viper v1.9.0
 	github.com/spf13/viper v1.9.0
 	github.com/stretchr/testify v1.7.0
 	github.com/stretchr/testify v1.7.0
-	github.com/studio-b12/gowebdav v0.0.0-20210917133250-a3a86976a1df
+	github.com/studio-b12/gowebdav v0.0.0-20211106090535-29e74efa701f
 	github.com/wagslane/go-password-validator v0.3.0
 	github.com/wagslane/go-password-validator v0.3.0
 	github.com/xhit/go-simple-mail/v2 v2.10.0
 	github.com/xhit/go-simple-mail/v2 v2.10.0
 	github.com/yl2chen/cidranger v1.0.2
 	github.com/yl2chen/cidranger v1.0.2
@@ -62,12 +52,11 @@ require (
 	go.uber.org/automaxprocs v1.4.0
 	go.uber.org/automaxprocs v1.4.0
 	gocloud.dev v0.24.0
 	gocloud.dev v0.24.0
 	golang.org/x/crypto v0.0.0-20210915214749-c084706c2272
 	golang.org/x/crypto v0.0.0-20210915214749-c084706c2272
-	golang.org/x/net v0.0.0-20211020060615-d418f374d309
-	golang.org/x/sys v0.0.0-20211029165221-6e7872819dc8
+	golang.org/x/net v0.0.0-20211105192438-b53810dc28af
+	golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c
 	golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac
 	golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac
 	google.golang.org/api v0.60.0
 	google.golang.org/api v0.60.0
-	google.golang.org/genproto v0.0.0-20211029142109-e255c875f7c7 // indirect
-	google.golang.org/grpc v1.41.0
+	google.golang.org/grpc v1.42.0
 	google.golang.org/protobuf v1.27.1
 	google.golang.org/protobuf v1.27.1
 	gopkg.in/natefinch/lumberjack.v2 v2.0.0
 	gopkg.in/natefinch/lumberjack.v2 v2.0.0
 )
 )
@@ -81,42 +70,53 @@ require (
 	github.com/cenkalti/backoff v2.2.1+incompatible // indirect
 	github.com/cenkalti/backoff v2.2.1+incompatible // indirect
 	github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect
 	github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect
 	github.com/cespare/xxhash/v2 v2.1.2 // indirect
 	github.com/cespare/xxhash/v2 v2.1.2 // indirect
-	github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403 // indirect
-	github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158 // indirect
+	github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 // indirect
+	github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1 // indirect
 	github.com/coreos/go-systemd/v22 v22.3.2 // indirect
 	github.com/coreos/go-systemd/v22 v22.3.2 // indirect
 	github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
 	github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
-	github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0 // indirect
-	github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021 // indirect
-	github.com/envoyproxy/protoc-gen-validate v0.1.0 // indirect
+	github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
+	github.com/envoyproxy/go-control-plane v0.10.0 // indirect
+	github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect
+	github.com/fatih/color v1.13.0 // indirect
 	github.com/fsnotify/fsnotify v1.5.1 // indirect
 	github.com/fsnotify/fsnotify v1.5.1 // indirect
 	github.com/go-ole/go-ole v1.2.6 // indirect
 	github.com/go-ole/go-ole v1.2.6 // indirect
 	github.com/goccy/go-json v0.7.10 // indirect
 	github.com/goccy/go-json v0.7.10 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
 	github.com/golang/protobuf v1.5.2 // indirect
 	github.com/golang/protobuf v1.5.2 // indirect
 	github.com/google/go-cmp v0.5.6 // indirect
 	github.com/google/go-cmp v0.5.6 // indirect
-	github.com/google/uuid v1.3.0 // indirect
 	github.com/googleapis/gax-go/v2 v2.1.1 // indirect
 	github.com/googleapis/gax-go/v2 v2.1.1 // indirect
+	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
 	github.com/hashicorp/hcl v1.0.0 // indirect
 	github.com/hashicorp/hcl v1.0.0 // indirect
+	github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87 // indirect
 	github.com/inconshreveable/mousetrap v1.0.0 // indirect
 	github.com/inconshreveable/mousetrap v1.0.0 // indirect
 	github.com/jmespath/go-jmespath v0.4.0 // indirect
 	github.com/jmespath/go-jmespath v0.4.0 // indirect
 	github.com/klauspost/cpuid/v2 v2.0.9 // indirect
 	github.com/klauspost/cpuid/v2 v2.0.9 // indirect
 	github.com/kr/fs v0.1.0 // indirect
 	github.com/kr/fs v0.1.0 // indirect
+	github.com/kr/text v0.2.0 // indirect
+	github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect
 	github.com/lestrrat-go/blackmagic v1.0.0 // indirect
 	github.com/lestrrat-go/blackmagic v1.0.0 // indirect
 	github.com/lestrrat-go/httpcc v1.0.0 // indirect
 	github.com/lestrrat-go/httpcc v1.0.0 // indirect
 	github.com/lestrrat-go/iter v1.0.1 // indirect
 	github.com/lestrrat-go/iter v1.0.1 // indirect
 	github.com/lestrrat-go/option v1.0.0 // indirect
 	github.com/lestrrat-go/option v1.0.0 // indirect
+	github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
 	github.com/magiconair/properties v1.8.5 // indirect
 	github.com/magiconair/properties v1.8.5 // indirect
 	github.com/mattn/go-colorable v0.1.11 // indirect
 	github.com/mattn/go-colorable v0.1.11 // indirect
 	github.com/mattn/go-ieproxy v0.0.1 // indirect
 	github.com/mattn/go-ieproxy v0.0.1 // indirect
+	github.com/mattn/go-isatty v0.0.14 // indirect
 	github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
 	github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
+	github.com/miekg/dns v1.1.43 // indirect
 	github.com/minio/sha256-simd v1.0.0 // indirect
 	github.com/minio/sha256-simd v1.0.0 // indirect
+	github.com/mitchellh/go-testing-interface v1.14.1 // indirect
 	github.com/mitchellh/mapstructure v1.4.2 // indirect
 	github.com/mitchellh/mapstructure v1.4.2 // indirect
+	github.com/oklog/run v1.1.0 // indirect
 	github.com/pelletier/go-toml v1.9.4 // indirect
 	github.com/pelletier/go-toml v1.9.4 // indirect
 	github.com/pkg/errors v0.9.1 // indirect
 	github.com/pkg/errors v0.9.1 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/prometheus/client_model v0.2.0 // indirect
 	github.com/prometheus/client_model v0.2.0 // indirect
+	github.com/prometheus/common v0.32.1 // indirect
 	github.com/prometheus/procfs v0.7.3 // indirect
 	github.com/prometheus/procfs v0.7.3 // indirect
+	github.com/russross/blackfriday/v2 v2.1.0 // indirect
 	github.com/spf13/cast v1.4.1 // indirect
 	github.com/spf13/cast v1.4.1 // indirect
 	github.com/spf13/jwalterweatherman v1.1.0 // indirect
 	github.com/spf13/jwalterweatherman v1.1.0 // indirect
 	github.com/spf13/pflag v1.0.5 // indirect
 	github.com/spf13/pflag v1.0.5 // indirect
@@ -124,10 +124,11 @@ require (
 	github.com/tklauser/go-sysconf v0.3.9 // indirect
 	github.com/tklauser/go-sysconf v0.3.9 // indirect
 	github.com/tklauser/numcpus v0.3.0 // indirect
 	github.com/tklauser/numcpus v0.3.0 // indirect
 	go.opencensus.io v0.23.0 // indirect
 	go.opencensus.io v0.23.0 // indirect
-	golang.org/x/oauth2 v0.0.0-20211028175245-ba495a64dcb5 // indirect
+	golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect
 	golang.org/x/text v0.3.7 // indirect
 	golang.org/x/text v0.3.7 // indirect
 	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
 	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
+	google.golang.org/genproto v0.0.0-20211104193956-4c6863e31247 // indirect
 	gopkg.in/ini.v1 v1.63.2 // indirect
 	gopkg.in/ini.v1 v1.63.2 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 	gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
 	gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
@@ -137,5 +138,5 @@ replace (
 	github.com/eikenb/pipeat => github.com/drakkan/pipeat v0.0.0-20210805162858-70e57fa8a639
 	github.com/eikenb/pipeat => github.com/drakkan/pipeat v0.0.0-20210805162858-70e57fa8a639
 	github.com/jlaffaye/ftp => github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9
 	github.com/jlaffaye/ftp => github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9
 	golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20210918082254-e7eb8487714b
 	golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20210918082254-e7eb8487714b
-	golang.org/x/net => github.com/drakkan/net v0.0.0-20211023135414-8d45d13382c8
+	golang.org/x/net => github.com/drakkan/net v0.0.0-20211106121348-90772e49e64e
 )
 )

+ 40 - 24
go.sum

@@ -137,8 +137,8 @@ github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZo
 github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go v1.38.68/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go v1.38.68/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go v1.40.34/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
 github.com/aws/aws-sdk-go v1.40.34/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
-github.com/aws/aws-sdk-go v1.41.13 h1:wGgr6jkHdGExF33phfOqijFq7ZF+h7a6FXvJc77GpTc=
-github.com/aws/aws-sdk-go v1.41.13/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
+github.com/aws/aws-sdk-go v1.41.19 h1:9QR2WTNj5bFdrNjRY9SeoG+3hwQmKXGX16851vdh+N8=
+github.com/aws/aws-sdk-go v1.41.19/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
 github.com/aws/aws-sdk-go-v2 v1.7.0/go.mod h1:tb9wi5s61kTDA5qCkcDbt3KRVV74GGslQkl/DRdX/P4=
 github.com/aws/aws-sdk-go-v2 v1.7.0/go.mod h1:tb9wi5s61kTDA5qCkcDbt3KRVV74GGslQkl/DRdX/P4=
 github.com/aws/aws-sdk-go-v2 v1.9.0/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
 github.com/aws/aws-sdk-go-v2 v1.9.0/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
 github.com/aws/aws-sdk-go-v2/config v1.7.0/go.mod h1:w9+nMZ7soXCe5nT46Ri354SNhXDQ6v+V5wqDjnZE+GY=
 github.com/aws/aws-sdk-go-v2/config v1.7.0/go.mod h1:w9+nMZ7soXCe5nT46Ri354SNhXDQ6v+V5wqDjnZE+GY=
@@ -182,11 +182,15 @@ github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
 github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
 github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
 github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
-github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403 h1:cqQfy1jclcSy/FwLjemeg3SR1yaINm74aQyupQ0Bl8M=
 github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
 github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
+github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 h1:hzAQntlaYRkVSFEfj9OTWlVV1H155FMD8BTKktLv0QI=
+github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
 github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
-github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158 h1:CevA8fI91PAnP8vpnXuB8ZYAZ5wqY86nAbxfgK8tWO4=
 github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1 h1:zH8ljVhhq7yC0MIeUL/IviMtY8hx2mK8cN9wEYb8ggw=
+github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
 github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
 github.com/cockroachdb/cockroach-go/v2 v2.2.1 h1:nZte1DDdL9iu8IV0YPmX8l9Lg2+HRJ3CMvkT3iG52rc=
 github.com/cockroachdb/cockroach-go/v2 v2.2.1 h1:nZte1DDdL9iu8IV0YPmX8l9Lg2+HRJ3CMvkT3iG52rc=
 github.com/cockroachdb/cockroach-go/v2 v2.2.1/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI=
 github.com/cockroachdb/cockroach-go/v2 v2.2.1/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI=
@@ -207,8 +211,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
 github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210816181553-5444fa50b93d/go.mod h1:tmAIfUFEirG/Y8jhZ9M+h36obRZAk/1fcSpXwAVlfqE=
 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210816181553-5444fa50b93d/go.mod h1:tmAIfUFEirG/Y8jhZ9M+h36obRZAk/1fcSpXwAVlfqE=
-github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0 h1:Fe5DW39aaoS/fqZiYlylEqQWIKznnbatWSHpWdFA3oQ=
-github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
+github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
 github.com/denisenkom/go-mssqldb v0.9.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
 github.com/denisenkom/go-mssqldb v0.9.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
 github.com/devigned/tab v0.1.1/go.mod h1:XG9mPq0dFghrYvoBF3xdRrJzSTX1b7IQrvaL9mzjeJY=
 github.com/devigned/tab v0.1.1/go.mod h1:XG9mPq0dFghrYvoBF3xdRrJzSTX1b7IQrvaL9mzjeJY=
 github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
 github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
@@ -218,8 +222,8 @@ github.com/drakkan/crypto v0.0.0-20210918082254-e7eb8487714b h1:MZY6RAQFVhJous68
 github.com/drakkan/crypto v0.0.0-20210918082254-e7eb8487714b/go.mod h1:0hNoheD1tVu/m8WMkw/chBXf5VpwzL5fHQU25k79NKo=
 github.com/drakkan/crypto v0.0.0-20210918082254-e7eb8487714b/go.mod h1:0hNoheD1tVu/m8WMkw/chBXf5VpwzL5fHQU25k79NKo=
 github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9 h1:LPH1dEblAOO/LoG7yHPMtBLXhQmjaga91/DDjWk9jWA=
 github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9 h1:LPH1dEblAOO/LoG7yHPMtBLXhQmjaga91/DDjWk9jWA=
 github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9/go.mod h1:2lmrmq866uF2tnje75wQHzmPXhmSWUt7Gyx2vgK1RCU=
 github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9/go.mod h1:2lmrmq866uF2tnje75wQHzmPXhmSWUt7Gyx2vgK1RCU=
-github.com/drakkan/net v0.0.0-20211023135414-8d45d13382c8 h1:xjuGl7Do3QtkkIaEOHME5EAG/dSi03ahxuqkmh9tx9A=
-github.com/drakkan/net v0.0.0-20211023135414-8d45d13382c8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+github.com/drakkan/net v0.0.0-20211106121348-90772e49e64e h1:om9H3anUwjKmPDdAdNiVB96Fcwnt7t8B4C1f8ivrm0U=
+github.com/drakkan/net v0.0.0-20211106121348-90772e49e64e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 github.com/drakkan/pipeat v0.0.0-20210805162858-70e57fa8a639 h1:8tfGdb4kg/YCvAbIrsMazgoNtnqdOqQVDKW12uUCuuU=
 github.com/drakkan/pipeat v0.0.0-20210805162858-70e57fa8a639 h1:8tfGdb4kg/YCvAbIrsMazgoNtnqdOqQVDKW12uUCuuU=
 github.com/drakkan/pipeat v0.0.0-20210805162858-70e57fa8a639/go.mod h1:kltMsfRMTHSFdMbK66XdS8mfMW77+FZA1fGY1xYMF84=
 github.com/drakkan/pipeat v0.0.0-20210805162858-70e57fa8a639/go.mod h1:kltMsfRMTHSFdMbK66XdS8mfMW77+FZA1fGY1xYMF84=
 github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
 github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
@@ -234,10 +238,12 @@ github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5y
 github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
 github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
 github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
 github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
 github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
 github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
-github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021 h1:fP+fF0up6oPY49OrjPrhIJ8yQfdIM85NXMLkMg1EXVs=
 github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
 github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
-github.com/envoyproxy/protoc-gen-validate v0.1.0 h1:EQciDnbrYxy13PgWoY8AqoxGiPrpgBZ1R8UNe3ddc+A=
+github.com/envoyproxy/go-control-plane v0.10.0 h1:WVt4HEPbdRbRD/PKKPbPnIVavO6gk/h673jWyIJ016k=
+github.com/envoyproxy/go-control-plane v0.10.0/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/envoyproxy/protoc-gen-validate v0.6.2 h1:JiO+kJTpmYGjEodY7O1Zk8oZcNz1+f30UtwtXoFUPzE=
+github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
 github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
 github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
 github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
@@ -454,6 +460,7 @@ github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87 h1:xixZ2bWeofWV68J
 github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
 github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
 github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
 github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
+github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
 github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
 github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
@@ -573,6 +580,9 @@ github.com/lib/pq v1.10.3 h1:v9QZf2Sn6AmjXtQeFpdoq/eaNtYP6IN+7lcrygsIAtg=
 github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8=
 github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8=
 github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts=
 github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts=
+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
+github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
+github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=
 github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls=
 github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls=
 github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
 github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
 github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
 github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
@@ -710,8 +720,8 @@ github.com/rs/xid v1.3.0 h1:6NjYksEUlhurdVehpc7S7dk6DAmcKv8V9gG0FsVN2U4=
 github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
 github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
 github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
 github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
 github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
 github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
-github.com/rs/zerolog v1.25.0 h1:Rj7XygbUHKUlDPcVdoLyR91fJBsduXj5fRxyqIQj/II=
-github.com/rs/zerolog v1.25.0/go.mod h1:7KHcEGe0QZPOm2IE4Kpb5rTh6n1h2hIgS5OOnu1rUaI=
+github.com/rs/zerolog v1.26.0 h1:ORM4ibhEZeTeQlCojCK2kPz1ogAY4bGs4tD+SaAdGaE=
+github.com/rs/zerolog v1.26.0/go.mod h1:yBiM87lvSqX8h0Ww4sdzNSkVYZ8dL2xjZJG1lAuGZEo=
 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -721,8 +731,8 @@ github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdh
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
-github.com/shirou/gopsutil/v3 v3.21.9 h1:Vn4MUz2uXhqLSiCbGFRc0DILbMVLAY92DSkT8bsYrHg=
-github.com/shirou/gopsutil/v3 v3.21.9/go.mod h1:YWp/H8Qs5fVmf17v7JNZzA0mPJ+mS2e9JdiUF9LlKzQ=
+github.com/shirou/gopsutil/v3 v3.21.10 h1:flTg1DrnV/UVrBqjLgVgDJzx6lf+91rC64/dBHmO2IA=
+github.com/shirou/gopsutil/v3 v3.21.10/go.mod h1:t75NhzCZ/dYyPQjyQmrAYP6c8+LCdFANeBMdLPCNnew=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
 github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
 github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
 github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
 github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
@@ -736,6 +746,7 @@ github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1
 github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
 github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
 github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
 github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
 github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
 github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
+github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=
 github.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY=
 github.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY=
 github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
 github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
 github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
 github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
@@ -763,8 +774,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
 github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/studio-b12/gowebdav v0.0.0-20210917133250-a3a86976a1df h1:C+J/LwTqP8gRPt1MdSzBNZP0OYuDm5wsmDKgwpLjYzo=
-github.com/studio-b12/gowebdav v0.0.0-20210917133250-a3a86976a1df/go.mod h1:gCcfDlA1Y7GqOaeEKw5l9dOGx1VLdc/HuQSlQAaZ30s=
+github.com/studio-b12/gowebdav v0.0.0-20211106090535-29e74efa701f h1:SLJx0nHhb2ZLlYNMAbrYsjwmVwXx4yRT48lNIxOp7ts=
+github.com/studio-b12/gowebdav v0.0.0-20211106090535-29e74efa701f/go.mod h1:gCcfDlA1Y7GqOaeEKw5l9dOGx1VLdc/HuQSlQAaZ30s=
 github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
 github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
 github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
 github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
 github.com/tklauser/go-sysconf v0.3.9 h1:JeUVdAOWhhxVcU6Eqr/ATFHgXk/mmiItdKeJPev3vTo=
 github.com/tklauser/go-sysconf v0.3.9 h1:JeUVdAOWhhxVcU6Eqr/ATFHgXk/mmiItdKeJPev3vTo=
@@ -784,6 +795,7 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
 github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
 github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
 github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
 go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
 go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
 go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
 go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
@@ -877,8 +889,8 @@ golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ
 golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
-golang.org/x/oauth2 v0.0.0-20211028175245-ba495a64dcb5 h1:v79phzBz03tsVCUTbvTBmmC3CUXF5mKYt7DA4ZVldpM=
-golang.org/x/oauth2 v0.0.0-20211028175245-ba495a64dcb5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 h1:RerP+noqYHUQ8CMRcPlC2nvTa4dcBIjegkuWdcUDuqg=
+golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -963,15 +975,18 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210816074244-15123e1e1f71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210816074244-15123e1e1f71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211029165221-6e7872819dc8 h1:M69LAlWZCshgp0QSzyDcSsSIejIEeuaCVpmwcKwyLMk=
-golang.org/x/sys v0.0.0-20211029165221-6e7872819dc8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c h1:+8miTPjMCTXwih7BQmvWwd0PjdBZq2MKp/qQaahSzEM=
+golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1056,6 +1071,7 @@ golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
 golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -1174,8 +1190,8 @@ google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEc
 google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211016002631-37fc39342514/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211016002631-37fc39342514/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211021150943-2b146023228c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211021150943-2b146023228c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211029142109-e255c875f7c7 h1:aaSaYY/DIDJy3f/JLXWv6xJ1mBQSRnQ1s5JhAFTnzO4=
-google.golang.org/genproto v0.0.0-20211029142109-e255c875f7c7/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20211104193956-4c6863e31247 h1:ZONpjmFT5e+I/0/xE3XXbG5OIvX2hRYzol04MhKBl2E=
+google.golang.org/genproto v0.0.0-20211104193956-4c6863e31247/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
@@ -1202,8 +1218,8 @@ google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQ
 google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
 google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
 google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
 google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
 google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
 google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
-google.golang.org/grpc v1.41.0 h1:f+PlOh7QV4iIJkPrx5NQ7qaNGFQ3OTse67yaDHfju4E=
-google.golang.org/grpc v1.41.0/go.mod h1:U3l9uK9J0sini8mHphKoXyaqDA/8VyGnDee1zzIUK6k=
+google.golang.org/grpc v1.42.0 h1:XT2/MFpuPFsEX2fWh3YQtHkZ+WYZFQRfaUgLZYj/p6A=
+google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
 google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
 google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=

+ 18 - 7
httpd/api_http_user.go

@@ -5,6 +5,7 @@ import (
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
 	"io"
 	"io"
+	"mime/multipart"
 	"net/http"
 	"net/http"
 	"os"
 	"os"
 	"path"
 	"path"
@@ -187,17 +188,23 @@ func uploadUserFiles(w http.ResponseWriter, r *http.Request) {
 	defer r.MultipartForm.RemoveAll() //nolint:errcheck
 	defer r.MultipartForm.RemoveAll() //nolint:errcheck
 
 
 	parentDir := util.CleanPath(r.URL.Query().Get("path"))
 	parentDir := util.CleanPath(r.URL.Query().Get("path"))
-	files := r.MultipartForm.File["filename"]
+	files := r.MultipartForm.File["filenames"]
 	if len(files) == 0 {
 	if len(files) == 0 {
-		sendAPIResponse(w, r, err, "No files uploaded!", http.StatusBadRequest)
+		sendAPIResponse(w, r, nil, "No files uploaded!", http.StatusBadRequest)
 		return
 		return
 	}
 	}
+	doUploadFiles(w, r, connection, parentDir, files)
+}
 
 
+func doUploadFiles(w http.ResponseWriter, r *http.Request, connection *Connection, parentDir string,
+	files []*multipart.FileHeader,
+) int {
+	uploaded := 0
 	for _, f := range files {
 	for _, f := range files {
 		file, err := f.Open()
 		file, err := f.Open()
 		if err != nil {
 		if err != nil {
 			sendAPIResponse(w, r, err, fmt.Sprintf("Unable to read uploaded file %#v", f.Filename), getMappedStatusCode(err))
 			sendAPIResponse(w, r, err, fmt.Sprintf("Unable to read uploaded file %#v", f.Filename), getMappedStatusCode(err))
-			return
+			return uploaded
 		}
 		}
 		defer file.Close()
 		defer file.Close()
 
 
@@ -205,21 +212,23 @@ func uploadUserFiles(w http.ResponseWriter, r *http.Request) {
 		writer, err := connection.getFileWriter(filePath)
 		writer, err := connection.getFileWriter(filePath)
 		if err != nil {
 		if err != nil {
 			sendAPIResponse(w, r, err, fmt.Sprintf("Unable to write file %#v", f.Filename), getMappedStatusCode(err))
 			sendAPIResponse(w, r, err, fmt.Sprintf("Unable to write file %#v", f.Filename), getMappedStatusCode(err))
-			return
+			return uploaded
 		}
 		}
 		_, err = io.Copy(writer, file)
 		_, err = io.Copy(writer, file)
 		if err != nil {
 		if err != nil {
 			writer.Close() //nolint:errcheck
 			writer.Close() //nolint:errcheck
 			sendAPIResponse(w, r, err, fmt.Sprintf("Error saving file %#v", f.Filename), getMappedStatusCode(err))
 			sendAPIResponse(w, r, err, fmt.Sprintf("Error saving file %#v", f.Filename), getMappedStatusCode(err))
-			return
+			return uploaded
 		}
 		}
 		err = writer.Close()
 		err = writer.Close()
 		if err != nil {
 		if err != nil {
 			sendAPIResponse(w, r, err, fmt.Sprintf("Error closing file %#v", f.Filename), getMappedStatusCode(err))
 			sendAPIResponse(w, r, err, fmt.Sprintf("Error closing file %#v", f.Filename), getMappedStatusCode(err))
-			return
+			return uploaded
 		}
 		}
+		uploaded++
 	}
 	}
 	sendAPIResponse(w, r, nil, "Upload completed", http.StatusCreated)
 	sendAPIResponse(w, r, nil, "Upload completed", http.StatusCreated)
+	return uploaded
 }
 }
 
 
 func renameUserFile(w http.ResponseWriter, r *http.Request) {
 func renameUserFile(w http.ResponseWriter, r *http.Request) {
@@ -300,8 +309,10 @@ func getUserFilesAsZipStream(w http.ResponseWriter, r *http.Request) {
 		filesList[idx] = util.CleanPath(filesList[idx])
 		filesList[idx] = util.CleanPath(filesList[idx])
 	}
 	}
 
 
+	filesList = util.RemoveDuplicates(filesList)
+
 	w.Header().Set("Content-Disposition", "attachment; filename=\"sftpgo-download.zip\"")
 	w.Header().Set("Content-Disposition", "attachment; filename=\"sftpgo-download.zip\"")
-	renderCompressedFiles(w, connection, baseDir, filesList)
+	renderCompressedFiles(w, connection, baseDir, filesList, nil)
 }
 }
 
 
 func getUserPublicKeys(w http.ResponseWriter, r *http.Request) {
 func getUserPublicKeys(w http.ResponseWriter, r *http.Request) {

+ 1 - 0
httpd/api_keys.go

@@ -55,6 +55,7 @@ func addAPIKey(w http.ResponseWriter, r *http.Request) {
 	apiKey.ID = 0
 	apiKey.ID = 0
 	apiKey.KeyID = ""
 	apiKey.KeyID = ""
 	apiKey.Key = ""
 	apiKey.Key = ""
+	apiKey.LastUseAt = 0
 	err = dataprovider.AddAPIKey(&apiKey, claims.Username, util.GetIPFromRemoteAddress(r.RemoteAddr))
 	err = dataprovider.AddAPIKey(&apiKey, claims.Username, util.GetIPFromRemoteAddress(r.RemoteAddr))
 	if err != nil {
 	if err != nil {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		sendAPIResponse(w, r, err, "", getRespStatus(err))

+ 32 - 0
httpd/api_maintenance.go

@@ -182,6 +182,10 @@ func restoreBackup(content []byte, inputFile string, scanQuota, mode int, execut
 		return err
 		return err
 	}
 	}
 
 
+	if err = RestoreShares(dump.Shares, inputFile, mode, executor, ipAddress); err != nil {
+		return err
+	}
+
 	logger.Debug(logSender, "", "backup restored, users: %v, folders: %v, admins: %vs",
 	logger.Debug(logSender, "", "backup restored, users: %v, folders: %v, admins: %vs",
 		len(dump.Users), len(dump.Folders), len(dump.Admins))
 		len(dump.Users), len(dump.Folders), len(dump.Admins))
 
 
@@ -244,6 +248,34 @@ func RestoreFolders(folders []vfs.BaseVirtualFolder, inputFile string, mode, sca
 	return nil
 	return nil
 }
 }
 
 
+// RestoreShares restores the specified shares
+func RestoreShares(shares []dataprovider.Share, inputFile string, mode int, executor,
+	ipAddress string,
+) error {
+	for _, share := range shares {
+		share := share // pin
+		s, err := dataprovider.ShareExists(share.ShareID, "")
+		if err == nil {
+			if mode == 1 {
+				logger.Debug(logSender, "", "loaddata mode 1, existing share %#v not updated", share.ShareID)
+				continue
+			}
+			share.ID = s.ID
+			err = dataprovider.UpdateShare(&share, executor, ipAddress)
+			share.Password = redactedSecret
+			logger.Debug(logSender, "", "restoring existing share: %+v, dump file: %#v, error: %v", share, inputFile, err)
+		} else {
+			err = dataprovider.AddShare(&share, executor, ipAddress)
+			share.Password = redactedSecret
+			logger.Debug(logSender, "", "adding new share: %+v, dump file: %#v, error: %v", share, inputFile, err)
+		}
+		if err != nil {
+			return fmt.Errorf("unable to restore share %#v: %w", share.ShareID, err)
+		}
+	}
+	return nil
+}
+
 // RestoreAPIKeys restores the specified API keys
 // RestoreAPIKeys restores the specified API keys
 func RestoreAPIKeys(apiKeys []dataprovider.APIKey, inputFile string, mode int, executor, ipAddress string) error {
 func RestoreAPIKeys(apiKeys []dataprovider.APIKey, inputFile string, mode int, executor, ipAddress string) error {
 	for _, apiKey := range apiKeys {
 	for _, apiKey := range apiKeys {

+ 1 - 2
httpd/api_mfa.go

@@ -6,7 +6,6 @@ import (
 	"strings"
 	"strings"
 
 
 	"github.com/go-chi/render"
 	"github.com/go-chi/render"
-	"github.com/lithammer/shortuuid/v3"
 
 
 	"github.com/drakkan/sftpgo/v2/dataprovider"
 	"github.com/drakkan/sftpgo/v2/dataprovider"
 	"github.com/drakkan/sftpgo/v2/kms"
 	"github.com/drakkan/sftpgo/v2/kms"
@@ -198,7 +197,7 @@ func generateRecoveryCodes(w http.ResponseWriter, r *http.Request) {
 }
 }
 
 
 func getNewRecoveryCode() string {
 func getNewRecoveryCode() string {
-	return fmt.Sprintf("RC-%v", strings.ToUpper(shortuuid.New()))
+	return fmt.Sprintf("RC-%v", strings.ToUpper(util.GenerateUniqueID()))
 }
 }
 
 
 func saveUserTOTPConfig(username string, r *http.Request, recoveryCodes []sdk.RecoveryCode) error {
 func saveUserTOTPConfig(username string, r *http.Request, recoveryCodes []sdk.RecoveryCode) error {

+ 232 - 0
httpd/api_shares.go

@@ -0,0 +1,232 @@
+package httpd
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/go-chi/render"
+	"github.com/rs/xid"
+
+	"github.com/drakkan/sftpgo/v2/common"
+	"github.com/drakkan/sftpgo/v2/dataprovider"
+	"github.com/drakkan/sftpgo/v2/util"
+)
+
+func getShares(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	claims, err := getTokenClaims(r)
+	if err != nil || claims.Username == "" {
+		sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
+		return
+	}
+	limit, offset, order, err := getSearchFilters(w, r)
+	if err != nil {
+		return
+	}
+
+	shares, err := dataprovider.GetShares(limit, offset, order, claims.Username)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
+	render.JSON(w, r, shares)
+}
+
+func getShareByID(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	claims, err := getTokenClaims(r)
+	if err != nil || claims.Username == "" {
+		sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
+		return
+	}
+	shareID := getURLParam(r, "id")
+	share, err := dataprovider.ShareExists(shareID, claims.Username)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
+	share.HideConfidentialData()
+
+	render.JSON(w, r, share)
+}
+
+func addShare(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	claims, err := getTokenClaims(r)
+	if err != nil || claims.Username == "" {
+		sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
+		return
+	}
+	var share dataprovider.Share
+	err = render.DecodeJSON(r.Body, &share)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
+		return
+	}
+	share.ID = 0
+	share.ShareID = util.GenerateUniqueID()
+	share.LastUseAt = 0
+	share.Username = claims.Username
+	if share.Name == "" {
+		share.Name = share.ShareID
+	}
+	err = dataprovider.AddShare(&share, claims.Username, util.GetIPFromRemoteAddress(r.RemoteAddr))
+	if err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
+	w.Header().Add("Location", fmt.Sprintf("%v/%v", userSharesPath, share.ShareID))
+	w.Header().Add("X-Object-ID", share.ShareID)
+	sendAPIResponse(w, r, nil, "Share created", http.StatusCreated)
+}
+
+func updateShare(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	claims, err := getTokenClaims(r)
+	if err != nil || claims.Username == "" {
+		sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
+		return
+	}
+	shareID := getURLParam(r, "id")
+	share, err := dataprovider.ShareExists(shareID, claims.Username)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
+
+	oldPassword := share.Password
+	err = render.DecodeJSON(r.Body, &share)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
+		return
+	}
+
+	share.ShareID = shareID
+	share.Username = claims.Username
+	if share.Password == redactedSecret {
+		share.Password = oldPassword
+	}
+	if err := dataprovider.UpdateShare(&share, claims.Username, util.GetIPFromRemoteAddress(r.RemoteAddr)); err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
+	sendAPIResponse(w, r, nil, "Share updated", http.StatusOK)
+}
+
+func deleteShare(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	shareID := getURLParam(r, "id")
+	claims, err := getTokenClaims(r)
+	if err != nil || claims.Username == "" {
+		sendAPIResponse(w, r, err, "Invalid token claims", http.StatusBadRequest)
+		return
+	}
+
+	err = dataprovider.DeleteShare(shareID, claims.Username, util.GetIPFromRemoteAddress(r.RemoteAddr))
+	if err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return
+	}
+	sendAPIResponse(w, r, err, "Share deleted", http.StatusOK)
+}
+
+func downloadFromShare(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	share, connection, err := checkPublicShare(w, r, dataprovider.ShareScopeRead)
+	if err != nil {
+		return
+	}
+
+	common.Connections.Add(connection)
+	defer common.Connections.Remove(connection.GetID())
+
+	dataprovider.UpdateShareLastUse(&share, 1) //nolint:errcheck
+	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"share-%v.zip\"", share.ShareID))
+	renderCompressedFiles(w, connection, "/", share.Paths, &share)
+}
+
+func uploadToShare(w http.ResponseWriter, r *http.Request) {
+	if maxUploadFileSize > 0 {
+		r.Body = http.MaxBytesReader(w, r.Body, maxUploadFileSize)
+	}
+	share, connection, err := checkPublicShare(w, r, dataprovider.ShareScopeWrite)
+	if err != nil {
+		return
+	}
+
+	common.Connections.Add(connection)
+	defer common.Connections.Remove(connection.GetID())
+
+	err = r.ParseMultipartForm(maxMultipartMem)
+	if err != nil {
+		sendAPIResponse(w, r, err, "Unable to parse multipart form", http.StatusBadRequest)
+		return
+	}
+	defer r.MultipartForm.RemoveAll() //nolint:errcheck
+
+	files := r.MultipartForm.File["filenames"]
+	if len(files) == 0 {
+		sendAPIResponse(w, r, nil, "No files uploaded!", http.StatusBadRequest)
+		return
+	}
+	if share.MaxTokens > 0 {
+		if len(files) > (share.MaxTokens - share.UsedTokens) {
+			sendAPIResponse(w, r, nil, "Allowed usage exceeded", http.StatusBadRequest)
+			return
+		}
+	}
+	dataprovider.UpdateShareLastUse(&share, len(files)) //nolint:errcheck
+
+	numUploads := doUploadFiles(w, r, connection, share.Paths[0], files)
+	if numUploads != len(files) {
+		dataprovider.UpdateShareLastUse(&share, numUploads-len(files)) //nolint:errcheck
+	}
+}
+
+func checkPublicShare(w http.ResponseWriter, r *http.Request, shareShope dataprovider.ShareScope,
+) (dataprovider.Share, *Connection, error) {
+	shareID := getURLParam(r, "id")
+	share, err := dataprovider.ShareExists(shareID, "")
+	if err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return share, nil, err
+	}
+	if share.Scope != shareShope {
+		sendAPIResponse(w, r, nil, "Invalid share scope", http.StatusForbidden)
+		return share, nil, errors.New("invalid share scope")
+	}
+	ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
+	ok, err := share.IsUsable(ipAddr)
+	if !ok || err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return share, nil, errors.New("login not allowed")
+	}
+	if share.Password != "" {
+		_, password, ok := r.BasicAuth()
+		if !ok {
+			w.Header().Set(common.HTTPAuthenticationHeader, basicRealm)
+			sendAPIResponse(w, r, nil, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+			return share, nil, dataprovider.ErrInvalidCredentials
+		}
+		match, err := share.CheckPassword(password)
+		if !match || err != nil {
+			w.Header().Set(common.HTTPAuthenticationHeader, basicRealm)
+			sendAPIResponse(w, r, nil, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
+			return share, nil, dataprovider.ErrInvalidCredentials
+		}
+	}
+	user, err := dataprovider.UserExists(share.Username)
+	if err != nil {
+		sendAPIResponse(w, r, err, "", getRespStatus(err))
+		return share, nil, err
+	}
+	connID := xid.New().String()
+	connection := &Connection{
+		BaseConnection: common.NewBaseConnection(connID, common.ProtocolHTTPShare, util.GetHTTPLocalAddress(r),
+			r.RemoteAddr, user),
+		request: r,
+	}
+
+	return share, connection, nil
+}

+ 9 - 1
httpd/api_utils.go

@@ -168,7 +168,9 @@ func getSearchFilters(w http.ResponseWriter, r *http.Request) (int, int, string,
 	return limit, offset, order, err
 	return limit, offset, order, err
 }
 }
 
 
-func renderCompressedFiles(w http.ResponseWriter, conn *Connection, baseDir string, files []string) {
+func renderCompressedFiles(w http.ResponseWriter, conn *Connection, baseDir string, files []string,
+	share *dataprovider.Share,
+) {
 	w.Header().Set("Content-Type", "application/zip")
 	w.Header().Set("Content-Type", "application/zip")
 	w.Header().Set("Accept-Ranges", "none")
 	w.Header().Set("Accept-Ranges", "none")
 	w.Header().Set("Content-Transfer-Encoding", "binary")
 	w.Header().Set("Content-Transfer-Encoding", "binary")
@@ -179,11 +181,17 @@ func renderCompressedFiles(w http.ResponseWriter, conn *Connection, baseDir stri
 	for _, file := range files {
 	for _, file := range files {
 		fullPath := path.Join(baseDir, file)
 		fullPath := path.Join(baseDir, file)
 		if err := addZipEntry(wr, conn, fullPath, baseDir); err != nil {
 		if err := addZipEntry(wr, conn, fullPath, baseDir); err != nil {
+			if share != nil {
+				dataprovider.UpdateShareLastUse(share, -1) //nolint:errcheck
+			}
 			panic(http.ErrAbortHandler)
 			panic(http.ErrAbortHandler)
 		}
 		}
 	}
 	}
 	if err := wr.Close(); err != nil {
 	if err := wr.Close(); err != nil {
 		conn.Log(logger.LevelWarn, "unable to close zip file: %v", err)
 		conn.Log(logger.LevelWarn, "unable to close zip file: %v", err)
+		if share != nil {
+			dataprovider.UpdateShareLastUse(share, -1) //nolint:errcheck
+		}
 		panic(http.ErrAbortHandler)
 		panic(http.ErrAbortHandler)
 	}
 	}
 }
 }

+ 2 - 2
httpd/auth_utils.go

@@ -38,8 +38,8 @@ var (
 	tokenDuration = 20 * time.Minute
 	tokenDuration = 20 * time.Minute
 	// csrf token duration is greater than normal token duration to reduce issues
 	// csrf token duration is greater than normal token duration to reduce issues
 	// with the login form
 	// with the login form
-	csrfTokenDuration = 6 * time.Hour
-	tokenRefreshMin   = 10 * time.Minute
+	csrfTokenDuration     = 6 * time.Hour
+	tokenRefreshThreshold = 10 * time.Minute
 )
 )
 
 
 type jwtTokenClaims struct {
 type jwtTokenClaims struct {

+ 11 - 0
httpd/httpd.go

@@ -74,10 +74,12 @@ const (
 	userTOTPSavePath                      = "/api/v2/user/totp/save"
 	userTOTPSavePath                      = "/api/v2/user/totp/save"
 	user2FARecoveryCodesPath              = "/api/v2/user/2fa/recoverycodes"
 	user2FARecoveryCodesPath              = "/api/v2/user/2fa/recoverycodes"
 	userProfilePath                       = "/api/v2/user/profile"
 	userProfilePath                       = "/api/v2/user/profile"
+	userSharesPath                        = "/api/v2/user/shares"
 	retentionBasePath                     = "/api/v2/retention/users"
 	retentionBasePath                     = "/api/v2/retention/users"
 	retentionChecksPath                   = "/api/v2/retention/users/checks"
 	retentionChecksPath                   = "/api/v2/retention/users/checks"
 	fsEventsPath                          = "/api/v2/events/fs"
 	fsEventsPath                          = "/api/v2/events/fs"
 	providerEventsPath                    = "/api/v2/events/provider"
 	providerEventsPath                    = "/api/v2/events/provider"
+	sharesPath                            = "/api/v2/shares"
 	healthzPath                           = "/healthz"
 	healthzPath                           = "/healthz"
 	webRootPathDefault                    = "/"
 	webRootPathDefault                    = "/"
 	webBasePathDefault                    = "/web"
 	webBasePathDefault                    = "/web"
@@ -116,6 +118,8 @@ const (
 	webClientTwoFactorPathDefault         = "/web/client/twofactor"
 	webClientTwoFactorPathDefault         = "/web/client/twofactor"
 	webClientTwoFactorRecoveryPathDefault = "/web/client/twofactor-recovery"
 	webClientTwoFactorRecoveryPathDefault = "/web/client/twofactor-recovery"
 	webClientFilesPathDefault             = "/web/client/files"
 	webClientFilesPathDefault             = "/web/client/files"
+	webClientSharesPathDefault            = "/web/client/shares"
+	webClientSharePathDefault             = "/web/client/share"
 	webClientEditFilePathDefault          = "/web/client/editfile"
 	webClientEditFilePathDefault          = "/web/client/editfile"
 	webClientDirsPathDefault              = "/web/client/dirs"
 	webClientDirsPathDefault              = "/web/client/dirs"
 	webClientDownloadZipPathDefault       = "/web/client/downloadzip"
 	webClientDownloadZipPathDefault       = "/web/client/downloadzip"
@@ -127,6 +131,7 @@ const (
 	webClientRecoveryCodesPathDefault     = "/web/client/recoverycodes"
 	webClientRecoveryCodesPathDefault     = "/web/client/recoverycodes"
 	webChangeClientPwdPathDefault         = "/web/client/changepwd"
 	webChangeClientPwdPathDefault         = "/web/client/changepwd"
 	webClientLogoutPathDefault            = "/web/client/logout"
 	webClientLogoutPathDefault            = "/web/client/logout"
+	webClientPubSharesPathDefault         = "/web/client/pubshares"
 	webStaticFilesPathDefault             = "/static"
 	webStaticFilesPathDefault             = "/static"
 	// MaxRestoreSize defines the max size for the loaddata input file
 	// MaxRestoreSize defines the max size for the loaddata input file
 	MaxRestoreSize       = 10485760 // 10 MB
 	MaxRestoreSize       = 10485760 // 10 MB
@@ -182,6 +187,8 @@ var (
 	webClientTwoFactorPath         string
 	webClientTwoFactorPath         string
 	webClientTwoFactorRecoveryPath string
 	webClientTwoFactorRecoveryPath string
 	webClientFilesPath             string
 	webClientFilesPath             string
+	webClientSharesPath            string
+	webClientSharePath             string
 	webClientEditFilePath          string
 	webClientEditFilePath          string
 	webClientDirsPath              string
 	webClientDirsPath              string
 	webClientDownloadZipPath       string
 	webClientDownloadZipPath       string
@@ -192,6 +199,7 @@ var (
 	webClientTOTPValidatePath      string
 	webClientTOTPValidatePath      string
 	webClientTOTPSavePath          string
 	webClientTOTPSavePath          string
 	webClientRecoveryCodesPath     string
 	webClientRecoveryCodesPath     string
+	webClientPubSharesPath         string
 	webClientLogoutPath            string
 	webClientLogoutPath            string
 	webStaticFilesPath             string
 	webStaticFilesPath             string
 	// max upload size for http clients, 1GB by default
 	// max upload size for http clients, 1GB by default
@@ -517,6 +525,9 @@ func updateWebClientURLs(baseURL string) {
 	webClientTwoFactorPath = path.Join(baseURL, webClientTwoFactorPathDefault)
 	webClientTwoFactorPath = path.Join(baseURL, webClientTwoFactorPathDefault)
 	webClientTwoFactorRecoveryPath = path.Join(baseURL, webClientTwoFactorRecoveryPathDefault)
 	webClientTwoFactorRecoveryPath = path.Join(baseURL, webClientTwoFactorRecoveryPathDefault)
 	webClientFilesPath = path.Join(baseURL, webClientFilesPathDefault)
 	webClientFilesPath = path.Join(baseURL, webClientFilesPathDefault)
+	webClientSharesPath = path.Join(baseURL, webClientSharesPathDefault)
+	webClientPubSharesPath = path.Join(baseURL, webClientPubSharesPathDefault)
+	webClientSharePath = path.Join(baseURL, webClientSharePathDefault)
 	webClientEditFilePath = path.Join(baseURL, webClientEditFilePathDefault)
 	webClientEditFilePath = path.Join(baseURL, webClientEditFilePathDefault)
 	webClientDirsPath = path.Join(baseURL, webClientDirsPathDefault)
 	webClientDirsPath = path.Join(baseURL, webClientDirsPathDefault)
 	webClientDownloadZipPath = path.Join(baseURL, webClientDownloadZipPathDefault)
 	webClientDownloadZipPath = path.Join(baseURL, webClientDownloadZipPathDefault)

File diff suppressed because it is too large
+ 826 - 163
httpd/httpd_test.go


+ 65 - 4
httpd/internal_test.go

@@ -383,6 +383,31 @@ func TestInvalidToken(t *testing.T) {
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
 	assert.Contains(t, rr.Body.String(), "Invalid token claims")
 	assert.Contains(t, rr.Body.String(), "Invalid token claims")
 
 
+	rr = httptest.NewRecorder()
+	getShares(rr, req)
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Invalid token claims")
+
+	rr = httptest.NewRecorder()
+	getShareByID(rr, req)
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Invalid token claims")
+
+	rr = httptest.NewRecorder()
+	addShare(rr, req)
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Invalid token claims")
+
+	rr = httptest.NewRecorder()
+	updateShare(rr, req)
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Invalid token claims")
+
+	rr = httptest.NewRecorder()
+	deleteShare(rr, req)
+	assert.Equal(t, http.StatusBadRequest, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Invalid token claims")
+
 	rr = httptest.NewRecorder()
 	rr = httptest.NewRecorder()
 	getUserPublicKeys(rr, req)
 	getUserPublicKeys(rr, req)
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
 	assert.Equal(t, http.StatusBadRequest, rr.Code)
@@ -771,6 +796,13 @@ func TestCreateTokenError(t *testing.T) {
 	assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
 	assert.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
 	assert.Contains(t, rr.Body.String(), "invalid URL escape")
 	assert.Contains(t, rr.Body.String(), "invalid URL escape")
 
 
+	req, _ = http.NewRequest(http.MethodPost, webChangeClientPwdPath+"?a=a%K3%AO%GA", bytes.NewBuffer([]byte(form.Encode())))
+
+	_, err = getShareFromPostFields(req)
+	if assert.Error(t, err) {
+		assert.Contains(t, err.Error(), "invalid URL escape")
+	}
+
 	username := "webclientuser"
 	username := "webclientuser"
 	user = dataprovider.User{
 	user = dataprovider.User{
 		BaseUser: sdk.BaseUser{
 		BaseUser: sdk.BaseUser{
@@ -1471,7 +1503,8 @@ func TestCompressorAbortHandler(t *testing.T) {
 		BaseConnection: common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, "", "", dataprovider.User{}),
 		BaseConnection: common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, "", "", dataprovider.User{}),
 		request:        nil,
 		request:        nil,
 	}
 	}
-	renderCompressedFiles(&failingWriter{}, connection, "", nil)
+	share := &dataprovider.Share{}
+	renderCompressedFiles(&failingWriter{}, connection, "", nil, share)
 }
 }
 
 
 func TestZipErrors(t *testing.T) {
 func TestZipErrors(t *testing.T) {
@@ -1811,7 +1844,7 @@ func TestChangeUserPwd(t *testing.T) {
 	}
 	}
 }
 }
 
 
-func TestGetFilesInvalidClaims(t *testing.T) {
+func TestWebUserInvalidClaims(t *testing.T) {
 	server := httpdServer{}
 	server := httpdServer{}
 	server.initializeRouter()
 	server.initializeRouter()
 
 
@@ -1856,6 +1889,34 @@ func TestGetFilesInvalidClaims(t *testing.T) {
 	handleClientEditFile(rr, req)
 	handleClientEditFile(rr, req)
 	assert.Equal(t, http.StatusForbidden, rr.Code)
 	assert.Equal(t, http.StatusForbidden, rr.Code)
 	assert.Contains(t, rr.Body.String(), "Invalid token claims")
 	assert.Contains(t, rr.Body.String(), "Invalid token claims")
+
+	rr = httptest.NewRecorder()
+	req, _ = http.NewRequest(http.MethodGet, webClientSharePath, nil)
+	req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
+	handleClientUpdateShareGet(rr, req)
+	assert.Equal(t, http.StatusForbidden, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Invalid token claims")
+
+	rr = httptest.NewRecorder()
+	req, _ = http.NewRequest(http.MethodPost, webClientSharePath, nil)
+	req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
+	handleClientAddSharePost(rr, req)
+	assert.Equal(t, http.StatusForbidden, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Invalid token claims")
+
+	rr = httptest.NewRecorder()
+	req, _ = http.NewRequest(http.MethodPost, webClientSharePath+"/id", nil)
+	req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
+	handleClientUpdateSharePost(rr, req)
+	assert.Equal(t, http.StatusForbidden, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Invalid token claims")
+
+	rr = httptest.NewRecorder()
+	req, _ = http.NewRequest(http.MethodGet, webClientSharesPath, nil)
+	req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
+	handleClientGetShares(rr, req)
+	assert.Equal(t, http.StatusForbidden, rr.Code)
+	assert.Contains(t, rr.Body.String(), "Invalid token claims")
 }
 }
 
 
 func TestInvalidClaims(t *testing.T) {
 func TestInvalidClaims(t *testing.T) {
@@ -1883,7 +1944,7 @@ func TestInvalidClaims(t *testing.T) {
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
 	req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
 	handleWebClientProfilePost(rr, req)
 	handleWebClientProfilePost(rr, req)
-	assert.Equal(t, http.StatusInternalServerError, rr.Code)
+	assert.Equal(t, http.StatusForbidden, rr.Code)
 
 
 	admin := dataprovider.Admin{
 	admin := dataprovider.Admin{
 		Username: "",
 		Username: "",
@@ -1903,7 +1964,7 @@ func TestInvalidClaims(t *testing.T) {
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 	req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
 	req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"]))
 	handleWebAdminProfilePost(rr, req)
 	handleWebAdminProfilePost(rr, req)
-	assert.Equal(t, http.StatusInternalServerError, rr.Code)
+	assert.Equal(t, http.StatusForbidden, rr.Code)
 }
 }
 
 
 func TestTLSReq(t *testing.T) {
 func TestTLSReq(t *testing.T) {

+ 361 - 26
httpd/schema/openapi.yaml

@@ -12,14 +12,16 @@ tags:
   - name: users
   - name: users
   - name: data retention
   - name: data retention
   - name: events
   - name: events
-  - name: users API
+  - name: user APIs
+  - name: public shares
 info:
 info:
   title: SFTPGo
   title: SFTPGo
   description: |
   description: |
-    SFTPGo allows to securely share your files over SFTP and optionally FTP/S and WebDAV as well.
+    SFTPGo allows to securely share your files over SFTP, HTTP and optionally FTP/S and WebDAV as well.
     Several storage backends are supported and they are configurable per user, so you can serve a local directory for a user and an S3 bucket (or part of it) for another one.
     Several storage backends are supported and they are configurable per user, so you can serve a local directory for a user and an S3 bucket (or part of it) for another one.
     SFTPGo also supports virtual folders, a virtual folder can use any of the supported storage backends. So you can have, for example, an S3 user that exposes a GCS bucket (or part of it) on a specified path and an encrypted local filesystem on another one.
     SFTPGo also supports virtual folders, a virtual folder can use any of the supported storage backends. So you can have, for example, an S3 user that exposes a GCS bucket (or part of it) on a specified path and an encrypted local filesystem on another one.
     Virtual folders can be private or shared among multiple users, for shared virtual folders you can define different quota limits for each user.
     Virtual folders can be private or shared among multiple users, for shared virtual folders you can define different quota limits for each user.
+    SFTPGo allows to create HTTP/S links to externally share files and folders securely, by setting limits to the number of downloads/uploads, protecting the share with a password, limiting access by source IP address, setting an automatic expiration date.
   version: 2.1.2-dev
   version: 2.1.2-dev
   contact:
   contact:
     name: API support
     name: API support
@@ -51,6 +53,81 @@ paths:
               schema:
               schema:
                 type: string
                 type: string
                 example: ok
                 example: ok
+  /shares/{id}:
+    parameters:
+      - name: id
+        in: path
+        description: the share id
+        required: true
+        schema:
+          type: string
+    get:
+      security:
+        - BasicAuth: []
+      tags:
+        - public shares
+      summary: Download shared files and folders as a single zip file
+      description: A zip file, containing the shared files and folders, will be generated on the fly and returned as response body. Only folders and regular files will be included in the zip. The share must be defined with the read scope and the associated user must have list and download permissions
+      operationId: get_share
+      responses:
+        '200':
+          description: successful operation
+          content:
+            'application/zip':
+              schema:
+                type: string
+                format: binary
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+    post:
+      security:
+        - BasicAuth: []
+      tags:
+        - public shares
+      summary: Upload one or more files to the shared path
+      description: The share must be defined with the read scope and the associated user must have the upload permission
+      operationId: upload_to_share
+      requestBody:
+        content:
+          multipart/form-data:
+            schema:
+              type: object
+              properties:
+                filenames:
+                  type: array
+                  items:
+                    type: string
+                    format: binary
+                  minItems: 1
+                  uniqueItems: true
+        required: true
+      responses:
+        '201':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/ApiResponse'
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
   /token:
   /token:
     get:
     get:
       security:
       security:
@@ -2555,7 +2632,7 @@ paths:
       security:
       security:
         - BearerAuth: []
         - BearerAuth: []
       tags:
       tags:
-        - users API
+        - user APIs
       summary: Change user password
       summary: Change user password
       description: Changes the password for the logged in user
       description: Changes the password for the logged in user
       operationId: change_user_password
       operationId: change_user_password
@@ -2585,7 +2662,7 @@ paths:
       security:
       security:
         - BearerAuth: []
         - BearerAuth: []
       tags:
       tags:
-        - users API
+        - user APIs
       deprecated: true
       deprecated: true
       summary: Get the user's public keys
       summary: Get the user's public keys
       description: 'Returns the public keys for the logged in user. Deprecated please use "/user/profile" instead'
       description: 'Returns the public keys for the logged in user. Deprecated please use "/user/profile" instead'
@@ -2613,7 +2690,7 @@ paths:
       security:
       security:
         - BearerAuth: []
         - BearerAuth: []
       tags:
       tags:
-        - users API
+        - user APIs
       deprecated: true
       deprecated: true
       summary: Set the user's public keys
       summary: Set the user's public keys
       description: 'Sets the public keys for the logged in user. Public keys must be in OpenSSH format. Deprecated please use "/user/profile" instead'
       description: 'Sets the public keys for the logged in user. Public keys must be in OpenSSH format. Deprecated please use "/user/profile" instead'
@@ -2650,7 +2727,7 @@ paths:
       security:
       security:
         - BearerAuth: []
         - BearerAuth: []
       tags:
       tags:
-        - users API
+        - user APIs
       summary: Get user profile
       summary: Get user profile
       description: 'Returns the profile for the logged in user'
       description: 'Returns the profile for the logged in user'
       operationId: get_user_profile
       operationId: get_user_profile
@@ -2673,7 +2750,7 @@ paths:
       security:
       security:
         - BearerAuth: []
         - BearerAuth: []
       tags:
       tags:
-        - users API
+        - user APIs
       summary: Update user profile
       summary: Update user profile
       description: 'Allows to update the profile for the logged in user'
       description: 'Allows to update the profile for the logged in user'
       operationId: update_user_profile
       operationId: update_user_profile
@@ -2705,7 +2782,7 @@ paths:
       security:
       security:
         - BearerAuth: []
         - BearerAuth: []
       tags:
       tags:
-        - users API
+        - user APIs
       summary: Get recovery codes
       summary: Get recovery codes
       description: 'Returns the recovery codes for the logged in user. Recovery codes can be used if the user loses access to their second factor auth device. Recovery codes are returned unencrypted'
       description: 'Returns the recovery codes for the logged in user. Recovery codes can be used if the user loses access to their second factor auth device. Recovery codes are returned unencrypted'
       operationId: get_user_recovery_codes
       operationId: get_user_recovery_codes
@@ -2730,7 +2807,7 @@ paths:
       security:
       security:
         - BearerAuth: []
         - BearerAuth: []
       tags:
       tags:
-        - users API
+        - user APIs
       summary: Generate recovery codes
       summary: Generate recovery codes
       description: 'Generates new recovery codes for the logged in user. Generating new recovery codes you automatically invalidate old ones'
       description: 'Generates new recovery codes for the logged in user. Generating new recovery codes you automatically invalidate old ones'
       operationId: generate_user_recovery_codes
       operationId: generate_user_recovery_codes
@@ -2758,7 +2835,7 @@ paths:
       security:
       security:
         - BearerAuth: []
         - BearerAuth: []
       tags:
       tags:
-        - users API
+        - user APIs
       summary: Get available TOTP configuration
       summary: Get available TOTP configuration
       description: Returns the available TOTP configurations for the logged in user
       description: Returns the available TOTP configurations for the logged in user
       operationId: get_user_totp_configs
       operationId: get_user_totp_configs
@@ -2784,7 +2861,7 @@ paths:
       security:
       security:
         - BearerAuth: []
         - BearerAuth: []
       tags:
       tags:
-        - users API
+        - user APIs
       summary: Generate a new TOTP secret
       summary: Generate a new TOTP secret
       description: 'Generates a new TOTP secret, including the QR code as png, using the specified configuration for the logged in user'
       description: 'Generates a new TOTP secret, including the QR code as png, using the specified configuration for the logged in user'
       operationId: generate_user_totp_secret
       operationId: generate_user_totp_secret
@@ -2831,7 +2908,7 @@ paths:
       security:
       security:
         - BearerAuth: []
         - BearerAuth: []
       tags:
       tags:
-        - users API
+        - user APIs
       summary: Validate a one time authentication code
       summary: Validate a one time authentication code
       description: 'Checks if the given authentication code can be validated using the specified secret and config name'
       description: 'Checks if the given authentication code can be validated using the specified secret and config name'
       operationId: validate_user_totp_secret
       operationId: validate_user_totp_secret
@@ -2875,7 +2952,7 @@ paths:
       security:
       security:
         - BearerAuth: []
         - BearerAuth: []
       tags:
       tags:
-        - users API
+        - user APIs
       summary: Save a TOTP config
       summary: Save a TOTP config
       description: 'Saves the specified TOTP config for the logged in user'
       description: 'Saves the specified TOTP config for the logged in user'
       operationId: save_user_totp_config
       operationId: save_user_totp_config
@@ -2904,10 +2981,194 @@ paths:
           $ref: '#/components/responses/InternalServerError'
           $ref: '#/components/responses/InternalServerError'
         default:
         default:
           $ref: '#/components/responses/DefaultResponse'
           $ref: '#/components/responses/DefaultResponse'
+  /user/shares:
+    get:
+      tags:
+        - user APIs
+      summary: List user shares
+      description: Returns the share for the logged in user
+      operationId: get_user_shares
+      parameters:
+        - in: query
+          name: offset
+          schema:
+            type: integer
+            minimum: 0
+            default: 0
+          required: false
+        - in: query
+          name: limit
+          schema:
+            type: integer
+            minimum: 1
+            maximum: 500
+            default: 100
+          required: false
+          description: 'The maximum number of items to return. Max value is 500, default is 100'
+        - in: query
+          name: order
+          required: false
+          description: Ordering shares by ID. Default ASC
+          schema:
+            type: string
+            enum:
+              - ASC
+              - DESC
+            example: ASC
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/Share'
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+    post:
+      tags:
+        - user APIs
+      summary: Add a share
+      operationId: add_share
+      description: 'Adds a new share. The share id will be auto-generated'
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/Share'
+      responses:
+        '201':
+          description: successful operation
+          headers:
+            X-Object-ID:
+              schema:
+                type: string
+              description: ID for the new created share
+            Location:
+              schema:
+                type: string
+              description: URL to retrieve the details for the new created share
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiResponse'
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+  '/user/shares/{id}':
+    parameters:
+      - name: id
+        in: path
+        description: the share id
+        required: true
+        schema:
+          type: string
+    get:
+      tags:
+        - user APIs
+      summary: Get share by id
+      description: Returns a share by id for the logged in user
+      operationId: get_user_share_by_id
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Share'
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '404':
+          $ref: '#/components/responses/NotFound'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+    put:
+      tags:
+        - user APIs
+      summary: Update share
+      description: 'Updates an existing share belonging to the logged in user'
+      operationId: update_user_share
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/Share'
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiResponse'
+              example:
+                message: Share updated
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '404':
+          $ref: '#/components/responses/NotFound'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
+    delete:
+      tags:
+        - user APIs
+      summary: Delete share
+      description: 'Deletes an existing share belonging to the logged in user'
+      operationId: delete_user_share
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiResponse'
+              example:
+                message: Share deleted
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '404':
+          $ref: '#/components/responses/NotFound'
+        '500':
+          $ref: '#/components/responses/InternalServerError'
+        default:
+          $ref: '#/components/responses/DefaultResponse'
   /user/folder:
   /user/folder:
     get:
     get:
       tags:
       tags:
-        - users API
+        - user APIs
       summary: Read folders contents
       summary: Read folders contents
       description: Returns the contents of the specified folder for the logged in user. Please use '/user/dirs' instead
       description: Returns the contents of the specified folder for the logged in user. Please use '/user/dirs' instead
       operationId: get_user_folder_contents
       operationId: get_user_folder_contents
@@ -2940,7 +3201,7 @@ paths:
   /user/dirs:
   /user/dirs:
     get:
     get:
       tags:
       tags:
-        - users API
+        - user APIs
       summary: Read directory contents
       summary: Read directory contents
       description: Returns the contents of the specified directory for the logged in user
       description: Returns the contents of the specified directory for the logged in user
       operationId: get_user_dir_contents
       operationId: get_user_dir_contents
@@ -2971,7 +3232,7 @@ paths:
           $ref: '#/components/responses/DefaultResponse'
           $ref: '#/components/responses/DefaultResponse'
     post:
     post:
       tags:
       tags:
-        - users API
+        - user APIs
       summary: Create a directory
       summary: Create a directory
       description: Create a directory for the logged in user
       description: Create a directory for the logged in user
       operationId: create_user_dir
       operationId: create_user_dir
@@ -3003,7 +3264,7 @@ paths:
           $ref: '#/components/responses/DefaultResponse'
           $ref: '#/components/responses/DefaultResponse'
     patch:
     patch:
       tags:
       tags:
-        - users API
+        - user APIs
       summary: Rename a directory
       summary: Rename a directory
       description: Rename a directory for the logged in user. The rename is allowed for empty directory or for non empty, local directories, with no virtual folders inside
       description: Rename a directory for the logged in user. The rename is allowed for empty directory or for non empty, local directories, with no virtual folders inside
       operationId: rename_user_dir
       operationId: rename_user_dir
@@ -3041,7 +3302,7 @@ paths:
           $ref: '#/components/responses/DefaultResponse'
           $ref: '#/components/responses/DefaultResponse'
     delete:
     delete:
       tags:
       tags:
-        - users API
+        - user APIs
       summary: Delete a directory
       summary: Delete a directory
       description: Delete a directory for the logged in user. Only empty directories can be deleted
       description: Delete a directory for the logged in user. Only empty directories can be deleted
       operationId: delete_user_dir
       operationId: delete_user_dir
@@ -3074,7 +3335,7 @@ paths:
   /user/file:
   /user/file:
     get:
     get:
       tags:
       tags:
-        - users API
+        - user APIs
       summary: Download a single file
       summary: Download a single file
       description: Returns the file contents as response body. Please use '/user/files' instead
       description: Returns the file contents as response body. Please use '/user/files' instead
       operationId: get_user_file
       operationId: get_user_file
@@ -3114,7 +3375,7 @@ paths:
   /user/files:
   /user/files:
     get:
     get:
       tags:
       tags:
-        - users API
+        - user APIs
       summary: Download a single file
       summary: Download a single file
       description: Returns the file contents as response body
       description: Returns the file contents as response body
       operationId: download_user_file
       operationId: download_user_file
@@ -3152,7 +3413,7 @@ paths:
           $ref: '#/components/responses/DefaultResponse'
           $ref: '#/components/responses/DefaultResponse'
     post:
     post:
       tags:
       tags:
-        - users API
+        - user APIs
       summary: Upload files
       summary: Upload files
       description: Upload one or more files for the logged in user
       description: Upload one or more files for the logged in user
       operationId: create_user_files
       operationId: create_user_files
@@ -3168,7 +3429,7 @@ paths:
             schema:
             schema:
               type: object
               type: object
               properties:
               properties:
-                filename:
+                filenames:
                   type: array
                   type: array
                   items:
                   items:
                     type: string
                     type: string
@@ -3197,7 +3458,7 @@ paths:
           $ref: '#/components/responses/DefaultResponse'
           $ref: '#/components/responses/DefaultResponse'
     patch:
     patch:
       tags:
       tags:
-        - users API
+        - user APIs
       summary: Rename a file
       summary: Rename a file
       description: Rename a file for the logged in user
       description: Rename a file for the logged in user
       operationId: rename_user_file
       operationId: rename_user_file
@@ -3235,7 +3496,7 @@ paths:
           $ref: '#/components/responses/DefaultResponse'
           $ref: '#/components/responses/DefaultResponse'
     delete:
     delete:
       tags:
       tags:
-        - users API
+        - user APIs
       summary: Delete a file
       summary: Delete a file
       description: Delete a file for the logged in user.
       description: Delete a file for the logged in user.
       operationId: delete_user_file
       operationId: delete_user_file
@@ -3268,7 +3529,7 @@ paths:
   /user/streamzip:
   /user/streamzip:
     post:
     post:
       tags:
       tags:
-        - users API
+        - user APIs
       summary: Download multiple files and folders as a single zip file
       summary: Download multiple files and folders as a single zip file
       description: A zip file, containing the specified files and folders, will be generated on the fly and returned as response body. Only folders and regular files will be included in the zip
       description: A zip file, containing the specified files and folders, will be generated on the fly and returned as response body. Only folders and regular files will be included in the zip
       operationId: streamzip
       operationId: streamzip
@@ -3489,6 +3750,7 @@ components:
         - password-change-disabled
         - password-change-disabled
         - api-key-auth-change-disabled
         - api-key-auth-change-disabled
         - info-change-disabled
         - info-change-disabled
+        - shares-disabled
       description: |
       description: |
         Options:
         Options:
           * `publickey-change-disabled` - changing SSH public keys is not allowed
           * `publickey-change-disabled` - changing SSH public keys is not allowed
@@ -3497,6 +3759,7 @@ components:
           * `password-change-disabled` - changing password is not allowed
           * `password-change-disabled` - changing password is not allowed
           * `api-key-auth-change-disabled` - enabling/disabling API key authentication is not allowed
           * `api-key-auth-change-disabled` - enabling/disabling API key authentication is not allowed
           * `info-change-disabled` - changing info such as email and description is not allowed
           * `info-change-disabled` - changing info such as email and description is not allowed
+          * `shares-disabled` - sharing files and directories with external users is disabled
     RetentionCheckNotification:
     RetentionCheckNotification:
       type: string
       type: string
       enum:
       enum:
@@ -3515,6 +3778,15 @@ components:
         Options:
         Options:
           * `1` - admin scope. The API key will be used to impersonate an SFTPGo admin
           * `1` - admin scope. The API key will be used to impersonate an SFTPGo admin
           * `2` - user scope. The API key will be used to impersonate an SFTPGo user
           * `2` - user scope. The API key will be used to impersonate an SFTPGo user
+    ShareScope:
+      type: integer
+      enum:
+        - 1
+        - 2
+      description: |
+        Options:
+          * `1` - read scope
+          * `2` - write scope
     TOTPHMacAlgo:
     TOTPHMacAlgo:
       type: string
       type: string
       enum:
       enum:
@@ -3562,6 +3834,7 @@ components:
         - user
         - user
         - admin
         - admin
         - api_key
         - api_key
+        - share
     TOTPConfig:
     TOTPConfig:
       type: object
       type: object
       properties:
       properties:
@@ -4506,6 +4779,62 @@ components:
         score:
         score:
           type: integer
           type: integer
           description: if 0 the host is not listed
           description: if 0 the host is not listed
+    Share:
+      type: object
+      properties:
+        id:
+          type: string
+          description: auto-generated unique share identifier
+        name:
+          type: string
+        description:
+          type: string
+          description: optional description
+        scope:
+          $ref: '#/components/schemas/ShareScope'
+        paths:
+          type: array
+          items:
+            type: string
+          description: 'paths to files or directories, for share scope write this array must contain exactly one directory. Paths will not be validated on save so you can also create them after creating the share'
+          example:
+            - '/dir1'
+            - '/dir2/file.txt'
+            - '/dir3/subdir'
+        username:
+          type: string
+        created_at:
+          type: integer
+          format: int64
+          description: 'creation time as unix timestamp in milliseconds'
+        updated_at:
+          type: integer
+          format: int64
+          description: 'last update time as unix timestamp in milliseconds'
+        last_use_at:
+          type: integer
+          format: int64
+          description: last use time as unix timestamp in milliseconds
+        expires_at:
+          type: integer
+          format: int64
+          description: 'optional share expiration, as unix timestamp in milliseconds. 0 means no expiration'
+        password:
+          type: string
+          description: 'optional password to protect the share. The special value "[**redacted**]" means that a password has been set, you can use this value if you want to preserve the current password when you update a share'
+        max_tokens:
+          type: integer
+          description: 'maximum allowed access tokens. 0 means no limit'
+        used_tokens:
+          type: integer
+        allow_from:
+          type: array
+          items:
+            type: string
+          description: 'Limit the share availability to these IP/Mask. IP/Mask must be in CIDR notation as defined in RFC 4632 and RFC 4291, for example "192.0.2.0/24" or "2001:db8::/32". An empty list means no restrictions'
+          example:
+            - 192.0.2.0/24
+            - '2001:db8::/32'
     BackupData:
     BackupData:
       type: object
       type: object
       properties:
       properties:
@@ -4522,7 +4851,13 @@ components:
           items:
           items:
             $ref: '#/components/schemas/Admin'
             $ref: '#/components/schemas/Admin'
         api_keys:
         api_keys:
-          $ref: '#/components/schemas/APIKey'
+          type: array
+          items:
+            $ref: '#/components/schemas/APIKey'
+        shares:
+          type: array
+          items:
+            $ref: '#/components/schemas/Share'
         version:
         version:
           type: integer
           type: integer
     PwdChange:
     PwdChange:

+ 25 - 1
httpd/server.go

@@ -721,7 +721,7 @@ func (s *httpdServer) checkCookieExpiration(w http.ResponseWriter, r *http.Reque
 	if tokenClaims.Username == "" || tokenClaims.Signature == "" {
 	if tokenClaims.Username == "" || tokenClaims.Signature == "" {
 		return
 		return
 	}
 	}
-	if time.Until(token.Expiration()) > tokenRefreshMin {
+	if time.Until(token.Expiration()) > tokenRefreshThreshold {
 		return
 		return
 	}
 	}
 	if util.IsStringInSlice(tokenAudienceWebClient, token.Audience()) {
 	if util.IsStringInSlice(tokenAudienceWebClient, token.Audience()) {
@@ -896,6 +896,10 @@ func (s *httpdServer) initializeRouter() {
 		render.PlainText(w, r, "ok")
 		render.PlainText(w, r, "ok")
 	})
 	})
 
 
+	// share API exposed to external users
+	s.router.Get(sharesPath+"/{id}", downloadFromShare)
+	s.router.Post(sharesPath+"/{id}", uploadToShare)
+
 	s.router.Get(tokenPath, s.getToken)
 	s.router.Get(tokenPath, s.getToken)
 
 
 	s.router.Group(func(router chi.Router) {
 	s.router.Group(func(router chi.Router) {
@@ -1036,6 +1040,11 @@ func (s *httpdServer) initializeRouter() {
 		router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Patch(userFilesPath, renameUserFile)
 		router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Patch(userFilesPath, renameUserFile)
 		router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Delete(userFilesPath, deleteUserFile)
 		router.With(checkHTTPUserPerm(sdk.WebClientWriteDisabled)).Delete(userFilesPath, deleteUserFile)
 		router.Post(userStreamZipPath, getUserFilesAsZipStream)
 		router.Post(userStreamZipPath, getUserFilesAsZipStream)
+		router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled)).Get(userSharesPath, getShares)
+		router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled)).Post(userSharesPath, addShare)
+		router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled)).Get(userSharesPath+"/{id}", getShareByID)
+		router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled)).Put(userSharesPath+"/{id}", updateShare)
+		router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled)).Delete(userSharesPath+"/{id}", deleteShare)
 	})
 	})
 
 
 	if s.enableWebAdmin || s.enableWebClient {
 	if s.enableWebAdmin || s.enableWebClient {
@@ -1083,6 +1092,9 @@ func (s *httpdServer) initializeRouter() {
 		s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie),
 		s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie),
 			jwtAuthenticatorPartial(tokenAudienceWebClientPartial)).
 			jwtAuthenticatorPartial(tokenAudienceWebClientPartial)).
 			Post(webClientTwoFactorRecoveryPath, s.handleWebClientTwoFactorRecoveryPost)
 			Post(webClientTwoFactorRecoveryPath, s.handleWebClientTwoFactorRecoveryPost)
+		// share API exposed to external users
+		s.router.Get(webClientPubSharesPath+"/{id}", downloadFromShare)
+		s.router.Post(webClientPubSharesPath+"/{id}", uploadToShare)
 
 
 		s.router.Group(func(router chi.Router) {
 		s.router.Group(func(router chi.Router) {
 			router.Use(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie))
 			router.Use(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie))
@@ -1124,6 +1136,18 @@ func (s *httpdServer) initializeRouter() {
 				Get(webClientRecoveryCodesPath, getRecoveryCodes)
 				Get(webClientRecoveryCodesPath, getRecoveryCodes)
 			router.With(checkHTTPUserPerm(sdk.WebClientMFADisabled), verifyCSRFHeader).
 			router.With(checkHTTPUserPerm(sdk.WebClientMFADisabled), verifyCSRFHeader).
 				Post(webClientRecoveryCodesPath, generateRecoveryCodes)
 				Post(webClientRecoveryCodesPath, generateRecoveryCodes)
+			router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled), s.refreshCookie).
+				Get(webClientSharesPath, handleClientGetShares)
+			router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled), s.refreshCookie).
+				Get(webClientSharePath, handleClientAddShareGet)
+			router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled)).Post(webClientSharePath,
+				handleClientAddSharePost)
+			router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled), s.refreshCookie).
+				Get(webClientSharePath+"/{id}", handleClientUpdateShareGet)
+			router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled)).
+				Post(webClientSharePath+"/{id}", handleClientUpdateSharePost)
+			router.With(checkHTTPUserPerm(sdk.WebClientSharesDisabled), verifyCSRFHeader).
+				Delete(webClientSharePath+"/{id}", deleteShare)
 		})
 		})
 	}
 	}
 
 

+ 4 - 2
httpd/webadmin.go

@@ -510,11 +510,13 @@ func renderAdminSetupPage(w http.ResponseWriter, r *http.Request, username, erro
 func renderAddUpdateAdminPage(w http.ResponseWriter, r *http.Request, admin *dataprovider.Admin,
 func renderAddUpdateAdminPage(w http.ResponseWriter, r *http.Request, admin *dataprovider.Admin,
 	error string, isAdd bool) {
 	error string, isAdd bool) {
 	currentURL := webAdminPath
 	currentURL := webAdminPath
+	title := "Add a new admin"
 	if !isAdd {
 	if !isAdd {
 		currentURL = fmt.Sprintf("%v/%v", webAdminPath, url.PathEscape(admin.Username))
 		currentURL = fmt.Sprintf("%v/%v", webAdminPath, url.PathEscape(admin.Username))
+		title = "Update admin"
 	}
 	}
 	data := adminPage{
 	data := adminPage{
-		basePage: getBasePageData("Add a new user", currentURL, r),
+		basePage: getBasePageData(title, currentURL, r),
 		Admin:    admin,
 		Admin:    admin,
 		Error:    error,
 		Error:    error,
 		IsAdd:    isAdd,
 		IsAdd:    isAdd,
@@ -1093,7 +1095,7 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
 	}
 	}
 	expirationDateMillis := int64(0)
 	expirationDateMillis := int64(0)
 	expirationDateString := r.Form.Get("expiration_date")
 	expirationDateString := r.Form.Get("expiration_date")
-	if len(strings.TrimSpace(expirationDateString)) > 0 {
+	if strings.TrimSpace(expirationDateString) != "" {
 		expirationDate, err := time.Parse(webDateTimeFormat, expirationDateString)
 		expirationDate, err := time.Parse(webDateTimeFormat, expirationDateString)
 		if err != nil {
 		if err != nil {
 			return user, err
 			return user, err

+ 236 - 2
httpd/webclient.go

@@ -11,6 +11,7 @@ import (
 	"os"
 	"os"
 	"path"
 	"path"
 	"path/filepath"
 	"path/filepath"
+	"strconv"
 	"strings"
 	"strings"
 	"time"
 	"time"
 
 
@@ -39,7 +40,10 @@ const (
 	templateClientTwoFactorRecovery = "twofactor-recovery.html"
 	templateClientTwoFactorRecovery = "twofactor-recovery.html"
 	templateClientMFA               = "mfa.html"
 	templateClientMFA               = "mfa.html"
 	templateClientEditFile          = "editfile.html"
 	templateClientEditFile          = "editfile.html"
+	templateClientShare             = "share.html"
+	templateClientShares            = "shares.html"
 	pageClientFilesTitle            = "My Files"
 	pageClientFilesTitle            = "My Files"
+	pageClientSharesTitle           = "Shares"
 	pageClientProfileTitle          = "My Profile"
 	pageClientProfileTitle          = "My Profile"
 	pageClientChangePwdTitle        = "Change password"
 	pageClientChangePwdTitle        = "Change password"
 	pageClient2FATitle              = "Two-factor auth"
 	pageClient2FATitle              = "Two-factor auth"
@@ -70,6 +74,8 @@ type baseClientPage struct {
 	Title        string
 	Title        string
 	CurrentURL   string
 	CurrentURL   string
 	FilesURL     string
 	FilesURL     string
+	SharesURL    string
+	ShareURL     string
 	ProfileURL   string
 	ProfileURL   string
 	ChangePwdURL string
 	ChangePwdURL string
 	StaticURL    string
 	StaticURL    string
@@ -77,6 +83,7 @@ type baseClientPage struct {
 	MFAURL       string
 	MFAURL       string
 	MFATitle     string
 	MFATitle     string
 	FilesTitle   string
 	FilesTitle   string
+	SharesTitle  string
 	ProfileTitle string
 	ProfileTitle string
 	Version      string
 	Version      string
 	CSRFToken    string
 	CSRFToken    string
@@ -106,6 +113,7 @@ type filesPage struct {
 	CanRename     bool
 	CanRename     bool
 	CanDelete     bool
 	CanDelete     bool
 	CanDownload   bool
 	CanDownload   bool
+	CanShare      bool
 	Error         string
 	Error         string
 	Paths         []dirMapping
 	Paths         []dirMapping
 }
 }
@@ -142,6 +150,19 @@ type clientMFAPage struct {
 	Protocols       []string
 	Protocols       []string
 }
 }
 
 
+type clientSharesPage struct {
+	baseClientPage
+	Shares              []dataprovider.Share
+	BasePublicSharesURL string
+}
+
+type clientSharePage struct {
+	baseClientPage
+	Share *dataprovider.Share
+	Error string
+	IsAdd bool
+}
+
 func getFileObjectURL(baseDir, name string) string {
 func getFileObjectURL(baseDir, name string) string {
 	return fmt.Sprintf("%v?path=%v&_=%v", webClientFilesPath, url.QueryEscape(path.Join(baseDir, name)), time.Now().UTC().Unix())
 	return fmt.Sprintf("%v?path=%v&_=%v", webClientFilesPath, url.QueryEscape(path.Join(baseDir, name)), time.Now().UTC().Unix())
 }
 }
@@ -162,6 +183,14 @@ func loadClientTemplates(templatesPath string) {
 		filepath.Join(templatesPath, templateClientDir, templateClientBase),
 		filepath.Join(templatesPath, templateClientDir, templateClientBase),
 		filepath.Join(templatesPath, templateClientDir, templateClientEditFile),
 		filepath.Join(templatesPath, templateClientDir, templateClientEditFile),
 	}
 	}
+	sharesPaths := []string{
+		filepath.Join(templatesPath, templateClientDir, templateClientBase),
+		filepath.Join(templatesPath, templateClientDir, templateClientShares),
+	}
+	sharePaths := []string{
+		filepath.Join(templatesPath, templateClientDir, templateClientBase),
+		filepath.Join(templatesPath, templateClientDir, templateClientShare),
+	}
 	profilePaths := []string{
 	profilePaths := []string{
 		filepath.Join(templatesPath, templateClientDir, templateClientBase),
 		filepath.Join(templatesPath, templateClientDir, templateClientBase),
 		filepath.Join(templatesPath, templateClientDir, templateClientProfile),
 		filepath.Join(templatesPath, templateClientDir, templateClientProfile),
@@ -200,6 +229,8 @@ func loadClientTemplates(templatesPath string) {
 	twoFactorTmpl := util.LoadTemplate(nil, twoFactorPath...)
 	twoFactorTmpl := util.LoadTemplate(nil, twoFactorPath...)
 	twoFactorRecoveryTmpl := util.LoadTemplate(nil, twoFactorRecoveryPath...)
 	twoFactorRecoveryTmpl := util.LoadTemplate(nil, twoFactorRecoveryPath...)
 	editFileTmpl := util.LoadTemplate(nil, editFilePath...)
 	editFileTmpl := util.LoadTemplate(nil, editFilePath...)
+	sharesTmpl := util.LoadTemplate(nil, sharesPaths...)
+	shareTmpl := util.LoadTemplate(nil, sharePaths...)
 
 
 	clientTemplates[templateClientFiles] = filesTmpl
 	clientTemplates[templateClientFiles] = filesTmpl
 	clientTemplates[templateClientProfile] = profileTmpl
 	clientTemplates[templateClientProfile] = profileTmpl
@@ -210,6 +241,8 @@ func loadClientTemplates(templatesPath string) {
 	clientTemplates[templateClientTwoFactor] = twoFactorTmpl
 	clientTemplates[templateClientTwoFactor] = twoFactorTmpl
 	clientTemplates[templateClientTwoFactorRecovery] = twoFactorRecoveryTmpl
 	clientTemplates[templateClientTwoFactorRecovery] = twoFactorRecoveryTmpl
 	clientTemplates[templateClientEditFile] = editFileTmpl
 	clientTemplates[templateClientEditFile] = editFileTmpl
+	clientTemplates[templateClientShares] = sharesTmpl
+	clientTemplates[templateClientShare] = shareTmpl
 }
 }
 
 
 func getBaseClientPageData(title, currentURL string, r *http.Request) baseClientPage {
 func getBaseClientPageData(title, currentURL string, r *http.Request) baseClientPage {
@@ -223,6 +256,8 @@ func getBaseClientPageData(title, currentURL string, r *http.Request) baseClient
 		Title:        title,
 		Title:        title,
 		CurrentURL:   currentURL,
 		CurrentURL:   currentURL,
 		FilesURL:     webClientFilesPath,
 		FilesURL:     webClientFilesPath,
+		SharesURL:    webClientSharesPath,
+		ShareURL:     webClientSharePath,
 		ProfileURL:   webClientProfilePath,
 		ProfileURL:   webClientProfilePath,
 		ChangePwdURL: webChangeClientPwdPath,
 		ChangePwdURL: webChangeClientPwdPath,
 		StaticURL:    webStaticFilesPath,
 		StaticURL:    webStaticFilesPath,
@@ -230,6 +265,7 @@ func getBaseClientPageData(title, currentURL string, r *http.Request) baseClient
 		MFAURL:       webClientMFAPath,
 		MFAURL:       webClientMFAPath,
 		MFATitle:     pageClient2FATitle,
 		MFATitle:     pageClient2FATitle,
 		FilesTitle:   pageClientFilesTitle,
 		FilesTitle:   pageClientFilesTitle,
+		SharesTitle:  pageClientSharesTitle,
 		ProfileTitle: pageClientProfileTitle,
 		ProfileTitle: pageClientProfileTitle,
 		Version:      fmt.Sprintf("%v-%v", v.Version, v.CommitHash),
 		Version:      fmt.Sprintf("%v-%v", v.Version, v.CommitHash),
 		CSRFToken:    csrfToken,
 		CSRFToken:    csrfToken,
@@ -331,6 +367,24 @@ func renderEditFilePage(w http.ResponseWriter, r *http.Request, fileName, fileDa
 	renderClientTemplate(w, templateClientEditFile, data)
 	renderClientTemplate(w, templateClientEditFile, data)
 }
 }
 
 
+func renderAddUpdateSharePage(w http.ResponseWriter, r *http.Request, share *dataprovider.Share,
+	error string, isAdd bool) {
+	currentURL := webClientSharePath
+	title := "Add a new share"
+	if !isAdd {
+		currentURL = fmt.Sprintf("%v/%v", webClientSharePath, url.PathEscape(share.ShareID))
+		title = "Update share"
+	}
+	data := clientSharePage{
+		baseClientPage: getBaseClientPageData(title, currentURL, r),
+		Share:          share,
+		Error:          error,
+		IsAdd:          isAdd,
+	}
+
+	renderClientTemplate(w, templateClientShare, data)
+}
+
 func renderFilesPage(w http.ResponseWriter, r *http.Request, dirName, error string, user dataprovider.User) {
 func renderFilesPage(w http.ResponseWriter, r *http.Request, dirName, error string, user dataprovider.User) {
 	data := filesPage{
 	data := filesPage{
 		baseClientPage: getBaseClientPageData(pageClientFilesTitle, webClientFilesPath, r),
 		baseClientPage: getBaseClientPageData(pageClientFilesTitle, webClientFilesPath, r),
@@ -343,6 +397,7 @@ func renderFilesPage(w http.ResponseWriter, r *http.Request, dirName, error stri
 		CanRename:      user.CanRenameFromWeb(dirName, dirName),
 		CanRename:      user.CanRenameFromWeb(dirName, dirName),
 		CanDelete:      user.CanDeleteFromWeb(dirName),
 		CanDelete:      user.CanDeleteFromWeb(dirName),
 		CanDownload:    user.HasPerm(dataprovider.PermDownload, dirName),
 		CanDownload:    user.HasPerm(dataprovider.PermDownload, dirName),
+		CanShare:       user.CanManageShares(),
 	}
 	}
 	paths := []dirMapping{}
 	paths := []dirMapping{}
 	if dirName != "/" {
 	if dirName != "/" {
@@ -442,7 +497,7 @@ func handleWebClientDownloadZip(w http.ResponseWriter, r *http.Request) {
 	}
 	}
 
 
 	w.Header().Set("Content-Disposition", "attachment; filename=\"sftpgo-download.zip\"")
 	w.Header().Set("Content-Disposition", "attachment; filename=\"sftpgo-download.zip\"")
-	renderCompressedFiles(w, connection, name, filesList)
+	renderCompressedFiles(w, connection, name, filesList, nil)
 }
 }
 
 
 func handleClientGetDirContents(w http.ResponseWriter, r *http.Request) {
 func handleClientGetDirContents(w http.ResponseWriter, r *http.Request) {
@@ -635,6 +690,152 @@ func handleClientEditFile(w http.ResponseWriter, r *http.Request) {
 	renderEditFilePage(w, r, name, b.String())
 	renderEditFilePage(w, r, name, b.String())
 }
 }
 
 
+func handleClientAddShareGet(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	share := &dataprovider.Share{Scope: dataprovider.ShareScopeRead}
+	dirName := "/"
+	if _, ok := r.URL.Query()["path"]; ok {
+		dirName = util.CleanPath(r.URL.Query().Get("path"))
+	}
+
+	if _, ok := r.URL.Query()["files"]; ok {
+		files := r.URL.Query().Get("files")
+		var filesList []string
+		err := json.Unmarshal([]byte(files), &filesList)
+		if err != nil {
+			renderClientMessagePage(w, r, "Invalid share list", "", http.StatusBadRequest, err, "")
+			return
+		}
+		for _, f := range filesList {
+			if f != "" {
+				share.Paths = append(share.Paths, path.Join(dirName, f))
+			}
+		}
+	}
+
+	renderAddUpdateSharePage(w, r, share, "", true)
+}
+
+func handleClientUpdateShareGet(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	claims, err := getTokenClaims(r)
+	if err != nil || claims.Username == "" {
+		renderClientForbiddenPage(w, r, "Invalid token claims")
+		return
+	}
+	shareID := getURLParam(r, "id")
+	share, err := dataprovider.ShareExists(shareID, claims.Username)
+	if err == nil {
+		share.HideConfidentialData()
+		renderAddUpdateSharePage(w, r, &share, "", false)
+	} else if _, ok := err.(*util.RecordNotFoundError); ok {
+		renderClientNotFoundPage(w, r, err)
+	} else {
+		renderClientInternalServerErrorPage(w, r, err)
+	}
+}
+
+func handleClientAddSharePost(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	claims, err := getTokenClaims(r)
+	if err != nil || claims.Username == "" {
+		renderClientForbiddenPage(w, r, "Invalid token claims")
+		return
+	}
+	share, err := getShareFromPostFields(r)
+	if err != nil {
+		renderAddUpdateSharePage(w, r, share, err.Error(), true)
+		return
+	}
+	if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil {
+		renderClientForbiddenPage(w, r, err.Error())
+		return
+	}
+	share.ID = 0
+	share.ShareID = util.GenerateUniqueID()
+	share.LastUseAt = 0
+	share.Username = claims.Username
+	err = dataprovider.AddShare(share, claims.Username, util.GetIPFromRemoteAddress(r.RemoteAddr))
+	if err == nil {
+		http.Redirect(w, r, webClientSharesPath, http.StatusSeeOther)
+	} else {
+		renderAddUpdateSharePage(w, r, share, err.Error(), true)
+	}
+}
+
+func handleClientUpdateSharePost(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	claims, err := getTokenClaims(r)
+	if err != nil || claims.Username == "" {
+		renderClientForbiddenPage(w, r, "Invalid token claims")
+		return
+	}
+	shareID := getURLParam(r, "id")
+	share, err := dataprovider.ShareExists(shareID, claims.Username)
+	if _, ok := err.(*util.RecordNotFoundError); ok {
+		renderClientNotFoundPage(w, r, err)
+		return
+	} else if err != nil {
+		renderClientInternalServerErrorPage(w, r, err)
+		return
+	}
+	updatedShare, err := getShareFromPostFields(r)
+	if err != nil {
+		renderAddUpdateSharePage(w, r, updatedShare, err.Error(), false)
+		return
+	}
+	if err := verifyCSRFToken(r.Form.Get(csrfFormToken)); err != nil {
+		renderClientForbiddenPage(w, r, err.Error())
+		return
+	}
+	updatedShare.ShareID = shareID
+	updatedShare.Username = claims.Username
+	if updatedShare.Password == redactedSecret {
+		updatedShare.Password = share.Password
+	}
+	err = dataprovider.UpdateShare(updatedShare, claims.Username, util.GetIPFromRemoteAddress(r.RemoteAddr))
+	if err == nil {
+		http.Redirect(w, r, webClientSharesPath, http.StatusSeeOther)
+	} else {
+		renderAddUpdateSharePage(w, r, updatedShare, err.Error(), false)
+	}
+}
+
+func handleClientGetShares(w http.ResponseWriter, r *http.Request) {
+	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
+	claims, err := getTokenClaims(r)
+	if err != nil || claims.Username == "" {
+		renderClientForbiddenPage(w, r, "Invalid token claims")
+		return
+	}
+	limit := defaultQueryLimit
+	if _, ok := r.URL.Query()["qlimit"]; ok {
+		var err error
+		limit, err = strconv.Atoi(r.URL.Query().Get("qlimit"))
+		if err != nil {
+			limit = defaultQueryLimit
+		}
+	}
+	shares := make([]dataprovider.Share, 0, limit)
+	for {
+		s, err := dataprovider.GetShares(limit, len(shares), dataprovider.OrderASC, claims.Username)
+		if err != nil {
+			renderInternalServerErrorPage(w, r, err)
+			return
+		}
+		shares = append(shares, s...)
+		if len(s) < limit {
+			break
+		}
+	}
+	data := clientSharesPage{
+		baseClientPage:      getBaseClientPageData(pageClientSharesTitle, webClientSharesPath, r),
+		Shares:              shares,
+		BasePublicSharesURL: webClientPubSharesPath,
+	}
+	renderClientTemplate(w, templateClientShares, data)
+}
+
 func handleClientGetProfile(w http.ResponseWriter, r *http.Request) {
 func handleClientGetProfile(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	renderClientProfilePage(w, r, "")
 	renderClientProfilePage(w, r, "")
@@ -678,7 +879,7 @@ func handleWebClientProfilePost(w http.ResponseWriter, r *http.Request) {
 	}
 	}
 	claims, err := getTokenClaims(r)
 	claims, err := getTokenClaims(r)
 	if err != nil || claims.Username == "" {
 	if err != nil || claims.Username == "" {
-		renderClientProfilePage(w, r, "Invalid token claims")
+		renderClientForbiddenPage(w, r, "Invalid token claims")
 		return
 		return
 	}
 	}
 	user, err := dataprovider.UserExists(claims.Username)
 	user, err := dataprovider.UserExists(claims.Username)
@@ -723,3 +924,36 @@ func handleWebClientTwoFactorRecovery(w http.ResponseWriter, r *http.Request) {
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	renderClientTwoFactorRecoveryPage(w, "")
 	renderClientTwoFactorRecoveryPage(w, "")
 }
 }
+
+func getShareFromPostFields(r *http.Request) (*dataprovider.Share, error) {
+	share := &dataprovider.Share{}
+	if err := r.ParseForm(); err != nil {
+		return share, err
+	}
+	share.Name = r.Form.Get("name")
+	share.Description = r.Form.Get("description")
+	share.Paths = r.Form["paths"]
+	share.Password = r.Form.Get("password")
+	share.AllowFrom = getSliceFromDelimitedValues(r.Form.Get("allowed_ip"), ",")
+	scope, err := strconv.Atoi(r.Form.Get("scope"))
+	if err != nil {
+		return share, err
+	}
+	share.Scope = dataprovider.ShareScope(scope)
+	maxTokens, err := strconv.Atoi(r.Form.Get("max_tokens"))
+	if err != nil {
+		return share, err
+	}
+	share.MaxTokens = maxTokens
+	expirationDateMillis := int64(0)
+	expirationDateString := r.Form.Get("expiration_date")
+	if strings.TrimSpace(expirationDateString) != "" {
+		expirationDate, err := time.Parse(webDateTimeFormat, expirationDateString)
+		if err != nil {
+			return share, err
+		}
+		expirationDateMillis = util.GetTimeAsMsSinceEpoch(expirationDate)
+	}
+	share.ExpiresAt = expirationDateMillis
+	return share, nil
+}

+ 2 - 0
sdk/plugin/plugin.go

@@ -93,6 +93,7 @@ type Manager struct {
 
 
 // Initialize initializes the configured plugins
 // Initialize initializes the configured plugins
 func Initialize(configs []Config, logVerbose bool) error {
 func Initialize(configs []Config, logVerbose bool) error {
+	logger.Debug(logSender, "", "initialize")
 	Handler = Manager{
 	Handler = Manager{
 		Configs:    configs,
 		Configs:    configs,
 		done:       make(chan bool),
 		done:       make(chan bool),
@@ -495,6 +496,7 @@ func (m *Manager) restartSearcherPlugin(config Config) {
 
 
 // Cleanup releases all the active plugins
 // Cleanup releases all the active plugins
 func (m *Manager) Cleanup() {
 func (m *Manager) Cleanup() {
+	logger.Debug(logSender, "", "cleanup")
 	atomic.StoreInt32(&m.closed, 1)
 	atomic.StoreInt32(&m.closed, 1)
 	close(m.done)
 	close(m.done)
 	m.notifLock.Lock()
 	m.notifLock.Lock()

+ 2 - 1
sdk/user.go

@@ -15,12 +15,13 @@ const (
 	WebClientPasswordChangeDisabled   = "password-change-disabled"
 	WebClientPasswordChangeDisabled   = "password-change-disabled"
 	WebClientAPIKeyAuthChangeDisabled = "api-key-auth-change-disabled"
 	WebClientAPIKeyAuthChangeDisabled = "api-key-auth-change-disabled"
 	WebClientInfoChangeDisabled       = "info-change-disabled"
 	WebClientInfoChangeDisabled       = "info-change-disabled"
+	WebClientSharesDisabled           = "shares-disabled"
 )
 )
 
 
 var (
 var (
 	// WebClientOptions defines the available options for the web client interface/user REST API
 	// WebClientOptions defines the available options for the web client interface/user REST API
 	WebClientOptions = []string{WebClientWriteDisabled, WebClientPasswordChangeDisabled, WebClientPubKeyChangeDisabled,
 	WebClientOptions = []string{WebClientWriteDisabled, WebClientPasswordChangeDisabled, WebClientPubKeyChangeDisabled,
-		WebClientMFADisabled, WebClientAPIKeyAuthChangeDisabled, WebClientInfoChangeDisabled}
+		WebClientMFADisabled, WebClientAPIKeyAuthChangeDisabled, WebClientInfoChangeDisabled, WebClientSharesDisabled}
 	// UserTypes defines the supported user type hints for auth plugins
 	// UserTypes defines the supported user type hints for auth plugins
 	UserTypes = []string{string(UserTypeLDAP), string(UserTypeOS)}
 	UserTypes = []string{string(UserTypeLDAP), string(UserTypeOS)}
 )
 )

+ 4 - 0
service/service.go

@@ -316,5 +316,9 @@ func (s *Service) restoreDump(dump *dataprovider.BackupData) error {
 	if err != nil {
 	if err != nil {
 		return fmt.Errorf("unable to restore API keys from file %#v: %v", s.LoadDataFrom, err)
 		return fmt.Errorf("unable to restore API keys from file %#v: %v", s.LoadDataFrom, err)
 	}
 	}
+	err = httpd.RestoreShares(dump.Shares, s.LoadDataFrom, s.LoadDataMode, dataprovider.ActionExecutorSystem, "")
+	if err != nil {
+		return fmt.Errorf("unable to restore API keys from file %#v: %v", s.LoadDataFrom, err)
+	}
 	return nil
 	return nil
 }
 }

+ 1 - 1
service/service_windows.go

@@ -113,6 +113,7 @@ loop:
 			changes <- svc.Status{State: svc.StopPending}
 			changes <- svc.Status{State: svc.StopPending}
 			wasStopped <- true
 			wasStopped <- true
 			s.Service.Stop()
 			s.Service.Stop()
+			plugin.Handler.Cleanup()
 			break loop
 			break loop
 		case svc.ParamChange:
 		case svc.ParamChange:
 			logger.Debug(logSender, "", "Received reload request")
 			logger.Debug(logSender, "", "Received reload request")
@@ -331,7 +332,6 @@ func (s *WindowsService) Stop() error {
 			return fmt.Errorf("could not retrieve service status: %v", err)
 			return fmt.Errorf("could not retrieve service status: %v", err)
 		}
 		}
 	}
 	}
-	plugin.Handler.Cleanup()
 	return nil
 	return nil
 }
 }
 
 

+ 2 - 0
service/signals_windows.go

@@ -5,6 +5,7 @@ import (
 	"os/signal"
 	"os/signal"
 
 
 	"github.com/drakkan/sftpgo/v2/logger"
 	"github.com/drakkan/sftpgo/v2/logger"
+	"github.com/drakkan/sftpgo/v2/sdk/plugin"
 )
 )
 
 
 func registerSignals() {
 func registerSignals() {
@@ -13,6 +14,7 @@ func registerSignals() {
 	go func() {
 	go func() {
 		for range c {
 		for range c {
 			logger.Debug(logSender, "", "Received interrupt request")
 			logger.Debug(logSender, "", "Received interrupt request")
+			plugin.Handler.Cleanup()
 			os.Exit(0)
 			os.Exit(0)
 		}
 		}
 	}()
 	}()

+ 0 - 2
templates/webadmin/folders.html

@@ -11,7 +11,6 @@
 {{end}}
 {{end}}
 
 
 {{define "page_body"}}
 {{define "page_body"}}
-
 <div id="errorMsg" class="card mb-4 border-left-warning" style="display: none;">
 <div id="errorMsg" class="card mb-4 border-left-warning" style="display: none;">
     <div id="errorTxt" class="card-body text-form-error"></div>
     <div id="errorTxt" class="card-body text-form-error"></div>
 </div>
 </div>
@@ -50,7 +49,6 @@
         </div>
         </div>
     </div>
     </div>
 </div>
 </div>
-
 {{end}}
 {{end}}
 
 
 {{define "dialog"}}
 {{define "dialog"}}

+ 1 - 1
templates/webadmin/fsconfig.html

@@ -105,7 +105,7 @@
                     placeholder="" value="{{.S3Config.DownloadPartMaxTime}}" min="0"
                     placeholder="" value="{{.S3Config.DownloadPartMaxTime}}" min="0"
                     aria-describedby="S3DownloadTimeoutHelpBlock">
                     aria-describedby="S3DownloadTimeoutHelpBlock">
                 <small id="S3DownloadTimeoutHelpBlock" class="form-text text-muted">
                 <small id="S3DownloadTimeoutHelpBlock" class="form-text text-muted">
-                    Max time limit, in seconds, to download a single part (5MB). 0 means no limit
+                    Max time limit, in seconds, to download a single part. 0 means no limit
                 </small>
                 </small>
             </div>
             </div>
             <div class="col-sm-2"></div>
             <div class="col-sm-2"></div>

+ 7 - 0
templates/webclient/base.html

@@ -78,6 +78,13 @@
                     <span>{{.FilesTitle}}</span>
                     <span>{{.FilesTitle}}</span>
                 </a>
                 </a>
             </li>
             </li>
+            {{if .LoggedUser.CanManageShares}}
+            <li class="nav-item {{if eq .CurrentURL .SharesURL}}active{{end}}">
+                <a class="nav-link" href="{{.SharesURL}}">
+                    <i class="fas fa-share-alt"></i>
+                    <span>{{.SharesTitle}}</span></a>
+            </li>
+            {{end}}
 
 
             <li class="nav-item {{if eq .CurrentURL .ProfileURL}}active{{end}}">
             <li class="nav-item {{if eq .CurrentURL .ProfileURL}}active{{end}}">
                 <a class="nav-link" href="{{.ProfileURL}}">
                 <a class="nav-link" href="{{.ProfileURL}}">

+ 1 - 1
templates/webclient/editfile.html

@@ -132,7 +132,7 @@
         var path = '{{.FilesURL}}?path={{.CurrentDir}}';
         var path = '{{.FilesURL}}?path={{.CurrentDir}}';
         var data = new FormData();
         var data = new FormData();
         var blob = new Blob([cm.getValue()]);
         var blob = new Blob([cm.getValue()]);
-        data.append("filename", new File([blob], "{{.Name}}"));
+        data.append("filenames", new File([blob], "{{.Name}}"));
 
 
         $.ajax({
         $.ajax({
                 url: path,
                 url: path,

+ 28 - 1
templates/webclient/files.html

@@ -92,7 +92,7 @@
             </div>
             </div>
             <form id="upload_files_form" action="" method="POST" enctype="multipart/form-data">
             <form id="upload_files_form" action="" method="POST" enctype="multipart/form-data">
                 <div class="modal-body">
                 <div class="modal-body">
-                    <input type="file" class="form-control-file" id="files_name" name="filename" required multiple>
+                    <input type="file" class="form-control-file" id="files_name" name="filenames" required multiple>
                 </div>
                 </div>
                 <div class="modal-footer">
                 <div class="modal-footer">
                     <button class="btn btn-secondary" type="button" data-dismiss="modal">Cancel</button>
                     <button class="btn btn-secondary" type="button" data-dismiss="modal">Cancel</button>
@@ -523,6 +523,25 @@
             enabled: false
             enabled: false
         };
         };
 
 
+        $.fn.dataTable.ext.buttons.share = {
+            text: '<i class="fas fa-share-alt"></i>',
+            name: 'share',
+            titleAttr: "Share",
+            action: function (e, dt, node, config) {
+                var filesArray = [];
+                var selected = dt.column(0).checkboxes.selected();
+                for (i = 0; i < selected.length; i++) {
+                    filesArray.push(getNameFromMeta(selected[i]));
+                }
+                var files = fixedEncodeURIComponent(JSON.stringify(filesArray));
+                var shareURL = '{{.ShareURL}}';
+                var currentDir = '{{.CurrentDir}}';
+                var ts = new Date().getTime().toString();
+                window.location = `${shareURL}?path=${currentDir}&files=${files}&_=${ts}`;
+            },
+            enabled: false
+        };
+
         var table = $('#dataTable').DataTable({
         var table = $('#dataTable').DataTable({
             "ajax": {
             "ajax": {
                 "url": "{{.DirsURL}}?path={{.CurrentDir}}",
                 "url": "{{.DirsURL}}?path={{.CurrentDir}}",
@@ -601,13 +620,18 @@
                             } else if (selectedItems > 1) {
                             } else if (selectedItems > 1) {
                                 selectedText = `${selectedItems} items selected`;
                                 selectedText = `${selectedItems} items selected`;
                             }
                             }
+                            {{if .CanDownload}}
                             table.button('download:name').enable(selectedItems > 0);
                             table.button('download:name').enable(selectedItems > 0);
+                            {{end}}
                             {{if .CanRename}}
                             {{if .CanRename}}
                             table.button('rename:name').enable(selectedItems == 1);
                             table.button('rename:name').enable(selectedItems == 1);
                             {{end}}
                             {{end}}
                             {{if .CanDelete}}
                             {{if .CanDelete}}
                             table.button('delete:name').enable(selectedItems == 1);
                             table.button('delete:name').enable(selectedItems == 1);
                             {{end}}
                             {{end}}
+                            {{if .CanShare}}
+                            table.button('share:name').enable(selectedItems > 0);
+                            {{end}}
                             $('#dataTable_info').find('span').remove();
                             $('#dataTable_info').find('span').remove();
                             $("#dataTable_info").append('<span class="selected-info"><span class="selected-item">' + selectedText + '</span></span>');
                             $("#dataTable_info").append('<span class="selected-info"><span class="selected-item">' + selectedText + '</span></span>');
                         }
                         }
@@ -641,6 +665,9 @@
             "initComplete": function (settings, json) {
             "initComplete": function (settings, json) {
                 table.button().add(0, 'refresh');
                 table.button().add(0, 'refresh');
                 table.button().add(0, 'pageLength');
                 table.button().add(0, 'pageLength');
+                {{if .CanShare}}
+                table.button().add(0, 'share');
+                {{end}}
                 {{if .CanDownload}}
                 {{if .CanDownload}}
                 table.button().add(0, 'download');
                 table.button().add(0, 'download');
                 {{end}}
                 {{end}}

+ 209 - 0
templates/webclient/share.html

@@ -0,0 +1,209 @@
+{{template "base" .}}
+
+{{define "title"}}{{.Title}}{{end}}
+
+{{define "extra_css"}}
+<link href="{{.StaticURL}}/vendor/tempusdominus/css/tempusdominus-bootstrap-4.min.css" rel="stylesheet">
+{{end}}
+
+{{define "page_body"}}
+<div class="card shadow mb-4">
+    <div class="card-header py-3">
+        <h6 class="m-0 font-weight-bold text-primary">{{if .IsAdd}}Add a new share{{else}}Edit share{{end}}</h6>
+    </div>
+    <div class="card-body">
+        {{if .Error}}
+        <div class="card mb-4 border-left-warning">
+            <div class="card-body text-form-error">{{.Error}}</div>
+        </div>
+        {{end}}
+        <form id="share_form" action="{{.CurrentURL}}" method="POST" autocomplete="off">
+            <div class="form-group row">
+                <label for="idName" class="col-sm-2 col-form-label">Name</label>
+                <div class="col-sm-10">
+                    <input type="text" class="form-control" id="idName" name="name" placeholder=""
+                        value="{{.Share.Name}}" maxlength="255" autocomplete="nope" required {{if not .IsAdd}}readonly{{end}}>
+                </div>
+            </div>
+
+            <div class="form-group row">
+                <label for="idScope" class="col-sm-2 col-form-label">Scope</label>
+                <div class="col-sm-10">
+                    <select class="form-control" id="idScope" name="scope" aria-describedby="scopeHelpBlock">
+                        <option value="1" {{if eq .Share.Scope 1 }}selected{{end}}>Read</option>
+                        <option value="2" {{if eq .Share.Scope 2 }}selected{{end}}>Write</option>
+                    </select>
+                    <small id="scopeHelpBlock" class="form-text text-muted">
+                        For scope "Write" you have to define one path and it must be a directory
+                    </small>
+                </div>
+            </div>
+
+            <div class="card bg-light mb-3">
+                <div class="card-header">
+                    Paths
+                </div>
+                <div class="card-body">
+                    <div class="form-group row">
+                        <div class="col-md-12 form_field_path_outer">
+                            {{range $idx, $val := .Share.Paths}}
+                            <div class="row form_field_path_outer_row">
+                                <div class="form-group col-md-11">
+                                    <input type="text" class="form-control" id="idPath{{$idx}}" name="paths"
+                                        placeholder="file or directory path, i.e. /dir or /dir/file.txt" value="{{$val}}" maxlength="255">
+                                </div>
+                                <div class="form-group col-md-1">
+                                    <button class="btn btn-circle btn-danger remove_path_btn_frm_field">
+                                        <i class="fas fa-trash"></i>
+                                    </button>
+                                </div>
+                            </div>
+                            {{else}}
+                            <div class="row form_field_path_outer_row">
+                                <div class="form-group col-md-11">
+                                    <input type="text" class="form-control" id="idPath0" name="paths"
+                                        placeholder="file or directory path, i.e. /dir or /dir/file.txt" value="" maxlength="512">
+                                </div>
+                                <div class="form-group col-md-1">
+                                    <button class="btn btn-circle btn-danger remove_path_btn_frm_field">
+                                        <i class="fas fa-trash"></i>
+                                    </button>
+                                </div>
+                            </div>
+                            {{end}}
+                        </div>
+                    </div>
+
+                    <div class="row mx-1">
+                        <button type="button" class="btn btn-secondary add_new_path_field_btn">
+                            <i class="fas fa-plus"></i> Add a path
+                        </button>
+                    </div>
+                </div>
+            </div>
+
+            <div class="form-group row">
+                <label for="idPassword" class="col-sm-2 col-form-label">Password</label>
+                <div class="col-sm-10">
+                    <input type="password" class="form-control" id="idPassword" name="password" placeholder=""
+                        value="{{.Share.Password}}" aria-describedby="passwordHelpBlock">
+                    <small id="passwordHelpBlock" class="form-text text-muted">
+                        If set the share will be password-protected
+                    </small>
+                </div>
+            </div>
+
+            <div class="form-group row">
+                <label for="idExpiration" class="col-sm-2 col-form-label">Expiration</label>
+                <div class="col-sm-10 input-group date" id="expirationDateTimePicker" data-target-input="nearest">
+                    <input type="text" class="form-control datetimepicker-input" id="idExpiration"
+                        data-target="#expirationDateTimePicker" placeholder="none">
+                    <div class="input-group-append" data-target="#expirationDateTimePicker" data-toggle="datetimepicker">
+                        <div class="input-group-text"><i class="fas fa-calendar"></i></div>
+                    </div>
+                </div>
+            </div>
+
+            <div class="form-group row">
+                <label for="idMaxTokens" class="col-sm-2 col-form-label">Max tokens</label>
+                <div class="col-sm-10">
+                    <input type="number" min="0" class="form-control" id="idMaxTokens" name="max_tokens" placeholder=""
+                        value="{{.Share.MaxTokens}}" aria-describedby="maxTokensHelpBlock">
+                    <small id="maxTokensHelpBlock" class="form-text text-muted">
+                        Maximum number of times this share can be accessed. 0 means no limit
+                    </small>
+                </div>
+            </div>
+
+            <div class="form-group row">
+                <label for="idAllowedIP" class="col-sm-2 col-form-label">Allowed IP/Mask</label>
+                <div class="col-sm-10">
+                    <input type="text" class="form-control" id="idAllowedIP" name="allowed_ip" placeholder=""
+                        value="{{.Share.GetAllowedFromAsString}}" maxlength="255" aria-describedby="allowedIPHelpBlock">
+                    <small id="allowedIPHelpBlock" class="form-text text-muted">
+                        Comma separated IP/Mask in CIDR format, for example "192.168.1.0/24,10.8.0.100/32"
+                    </small>
+                </div>
+            </div>
+
+            <div class="form-group row">
+                <label for="idDescription" class="col-sm-2 col-form-label">Description</label>
+                <div class="col-sm-10">
+                    <textarea class="form-control" id="idDescription" name="description" rows="3">{{.Share.Description}}</textarea>
+                </div>
+            </div>
+
+            <input type="hidden" name="expiration_date" id="hidden_start_datetime" value="">
+            <input type="hidden" name="_form_token" value="{{.CSRFToken}}">
+            <button type="submit" class="btn btn-primary float-right mt-3 px-5 px-3">Submit</button>
+        </form>
+    </div>
+</div>
+{{end}}
+
+{{define "extra_js"}}
+<script src="{{.StaticURL}}/vendor/moment/js/moment.min.js"></script>
+<script src="{{.StaticURL}}/vendor/tempusdominus/js/tempusdominus-bootstrap-4.min.js"></script>
+<script type="text/javascript">
+    $(document).ready(function () {
+
+        $('#expirationDateTimePicker').datetimepicker({
+            format: 'YYYY-MM-DD HH:mm',
+            sideBySide: true,
+            minDate: moment(),
+            buttons: {
+                showClear: false,
+                showClose: true,
+                showToday: false
+            }
+        });
+
+        {{ if gt .Share.ExpiresAt 0 }}
+        var input_dt = moment({{.Share.ExpiresAt }}).format('YYYY-MM-DD HH:mm');
+        $('#idExpiration').val(input_dt);
+        $('#expirationDateTimePicker').datetimepicker('viewDate', input_dt);
+        {{ end }}
+
+        $("#share_form").submit(function (event) {
+            var dt = $('#idExpiration').val();
+            if (dt) {
+                var d = $('#expirationDateTimePicker').datetimepicker('viewDate');
+                if (d) {
+                    var dateString = moment.utc(d).format('YYYY-MM-DD HH:mm:ss');
+                    $('#hidden_start_datetime').val(dateString);
+                } else {
+                    $('#hidden_start_datetime').val("");
+                }
+            } else {
+                $('#hidden_start_datetime').val("");
+            }
+            return true;
+        });
+
+        $("body").on("click", ".add_new_path_field_btn", function () {
+            var index = $(".form_field_path_outer").find(".form_field_path_outer_row").length;
+            while (document.getElementById("idPath"+index) != null){
+                index++;
+            }
+            $(".form_field_path_outer").append(`
+                    <div class="row form_field_path_outer_row">
+                        <div class="form-group col-md-11">
+                            <input type="text" class="form-control" id="idPath${index}" name="paths"
+                                        placeholder="file or directory path, i.e. /dir or /dir/file.txt" value="" maxlength="512">
+                        </div>
+                        <div class="form-group col-md-1">
+                            <button class="btn btn-circle btn-danger remove_path_btn_frm_field">
+                                <i class="fas fa-trash"></i>
+                            </button>
+                        </div>
+                    </div>
+                `);
+        });
+
+        $("body").on("click", ".remove_path_btn_frm_field", function () {
+            $(this).closest(".form_field_path_outer_row").remove();
+        });
+
+    });
+</script>
+{{end}}

+ 257 - 0
templates/webclient/shares.html

@@ -0,0 +1,257 @@
+{{template "base" .}}
+
+{{define "title"}}{{.Title}}{{end}}
+
+{{define "extra_css"}}
+<link href="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.css" rel="stylesheet">
+<link href="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.css" rel="stylesheet">
+<link href="{{.StaticURL}}/vendor/datatables/fixedHeader.bootstrap4.min.css" rel="stylesheet">
+<link href="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.css" rel="stylesheet">
+<link href="{{.StaticURL}}/vendor/datatables/select.bootstrap4.min.css" rel="stylesheet">
+{{end}}
+
+{{define "page_body"}}
+<div id="errorMsg" class="card mb-4 border-left-warning" style="display: none;">
+    <div id="errorTxt" class="card-body text-form-error"></div>
+</div>
+
+<div id="successMsg" class="card mb-4 border-left-success" style="display: none;">
+    <div id="successTxt" class="card-body"></div>
+</div>
+
+<div class="card shadow mb-4">
+    <div class="card-header py-3">
+        <h6 class="m-0 font-weight-bold text-primary">View and manage shares</h6>
+    </div>
+    <div class="card-body">
+        <div class="table-responsive">
+            <table class="table table-hover nowrap" id="dataTable" width="100%" cellspacing="0">
+                <thead>
+                    <tr>
+                        <th>ID</th>
+                        <th>Name</th>
+                        <th>Scope</th>
+                        <th>Info</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    {{range .Shares}}
+                    <tr>
+                        <td>{{.ShareID}}</td>
+                        <td>{{.Name}}</td>
+                        <td>{{.GetScopeAsString}}</td>
+                        <td>{{.GetInfoString}}</td>
+                    </tr>
+                    {{end}}
+                </tbody>
+            </table>
+        </div>
+    </div>
+</div>
+{{end}}
+
+{{define "dialog"}}
+<div class="modal fade" id="deleteModal" tabindex="-1" role="dialog" aria-labelledby="deleteModalLabel"
+    aria-hidden="true">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title" id="deleteModalLabel">
+                    Confirmation required
+                </h5>
+                <button class="close" type="button" data-dismiss="modal" aria-label="Close">
+                    <span aria-hidden="true">&times;</span>
+                </button>
+            </div>
+            <div class="modal-body">Do you want to delete the selected share?</div>
+            <div class="modal-footer">
+                <button class="btn btn-secondary" type="button" data-dismiss="modal">
+                    Cancel
+                </button>
+                <a class="btn btn-warning" href="#" onclick="deleteAction()">
+                    Delete
+                </a>
+            </div>
+        </div>
+    </div>
+</div>
+
+<div class="modal fade" id="linkModal" tabindex="-1" role="dialog" aria-labelledby="linkModalLabel"
+    aria-hidden="true">
+    <div class="modal-dialog" role="document">
+        <div class="modal-content">
+            <div class="modal-header">
+                <h5 class="modal-title" id="linkModalLabel">
+                    Share access link
+                </h5>
+                <button class="close" type="button" data-dismiss="modal" aria-label="Close">
+                    <span aria-hidden="true">&times;</span>
+                </button>
+            </div>
+            <div class="modal-body">
+                <div id="readShare">
+                    You can download the shared contents, as single zip file, using this <a id="readLink" href="#" target="_blank">link</a>
+                </div>
+                <div id="writeShare">
+                    <p>You can upload one or more files to the shared directory by sending a multipart/form-data request to this <a id="writeLink" href="#" target="_blank">link</a>. The form field name for the file(s) is <b><code>filenames</code></b>.</p>
+                    <p>For example:</p>
+                    <p><code>curl -F filenames=@file1.txt -F filenames=@file2.txt "share link"</code></p>
+                </div>
+            </div>
+            <div class="modal-footer">
+                <button class="btn btn-primary" type="button" data-dismiss="modal">
+                    OK
+                </button>
+            </div>
+        </div>
+    </div>
+</div>
+{{end}}
+
+{{define "extra_js"}}
+<script src="{{.StaticURL}}/vendor/datatables/jquery.dataTables.min.js"></script>
+<script src="{{.StaticURL}}/vendor/datatables/dataTables.bootstrap4.min.js"></script>
+<script src="{{.StaticURL}}/vendor/datatables/dataTables.buttons.min.js"></script>
+<script src="{{.StaticURL}}/vendor/datatables/buttons.bootstrap4.min.js"></script>
+<script src="{{.StaticURL}}/vendor/datatables/dataTables.fixedHeader.min.js"></script>
+<script src="{{.StaticURL}}/vendor/datatables/dataTables.responsive.min.js"></script>
+<script src="{{.StaticURL}}/vendor/datatables/responsive.bootstrap4.min.js"></script>
+<script src="{{.StaticURL}}/vendor/datatables/dataTables.select.min.js"></script>
+<script type="text/javascript">
+
+    function deleteAction() {
+        var table = $('#dataTable').DataTable();
+        table.button('delete:name').enable(false);
+        var shareID = table.row({ selected: true }).data()[0];
+        var path = '{{.ShareURL}}' + "/" + fixedEncodeURIComponent(shareID);
+        $('#deleteModal').modal('hide');
+        $.ajax({
+            url: path,
+            type: 'DELETE',
+            dataType: 'json',
+            headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
+            timeout: 15000,
+            success: function (result) {
+                window.location.href = '{{.SharesURL}}';
+            },
+            error: function ($xhr, textStatus, errorThrown) {
+                var txt = "Unable to delete the selected share";
+                if ($xhr) {
+                    var json = $xhr.responseJSON;
+                    if (json) {
+                        if (json.message){
+                            txt += ": " + json.message;
+                        } else {
+                            txt += ": " + json.error;
+                        }
+                    }
+                }
+                $('#errorTxt').text(txt);
+                $('#errorMsg').show();
+                setTimeout(function () {
+                    $('#errorMsg').hide();
+                }, 5000);
+            }
+        });
+    }
+
+    $(document).ready(function () {
+        $.fn.dataTable.ext.buttons.add = {
+            text: '<i class="fas fa-plus"></i>',
+            name: 'add',
+            titleAttr: "Add",
+            action: function (e, dt, node, config) {
+                window.location.href = '{{.ShareURL}}';
+            }
+        };
+
+        $.fn.dataTable.ext.buttons.edit = {
+            text: '<i class="fas fa-pen"></i>',
+            name: 'edit',
+            titleAttr: "Edit",
+            action: function (e, dt, node, config) {
+                var shareID = dt.row({ selected: true }).data()[0];
+                var path = '{{.ShareURL}}' + "/" + fixedEncodeURIComponent(shareID);
+                window.location.href = path;
+            },
+            enabled: false
+        };
+
+        $.fn.dataTable.ext.buttons.delete = {
+            text: '<i class="fas fa-trash"></i>',
+            name: 'delete',
+            titleAttr: "Delete",
+            action: function (e, dt, node, config) {
+                $('#deleteModal').modal('show');
+            },
+            enabled: false
+        };
+
+        $.fn.dataTable.ext.buttons.link = {
+            text: '<i class="fas fa-link"></i>',
+            name: 'link',
+            titleAttr: "Link",
+            action: function (e, dt, node, config) {
+                var shareData = dt.row({ selected: true }).data();
+                var shareID = shareData[0];
+                var shareScope = shareData[2];
+                var shareURL = '{{.BasePublicSharesURL}}' + "/" + fixedEncodeURIComponent(shareID);
+                if (shareScope == 'Read'){
+                    $('#writeShare').hide();
+                    $('#readShare').show();
+                    $('#readLink').attr("href", shareURL);
+                    $('#readLink').attr("title", shareURL);
+                } else {
+                    $('#writeShare').show();
+                    $('#readShare').hide();
+                    $('#writeLink').attr("href", shareURL);
+                    $('#writeLink').attr("title", shareURL);
+                }
+                $('#linkModal').modal('show');
+            },
+            enabled: false
+        };
+
+        var table = $('#dataTable').DataTable({
+            "select": {
+                "style": "single",
+                "blurable": true
+            },
+            "stateSave": true,
+            "stateDuration": 3600,
+            "buttons": [],
+            "columnDefs": [
+                {
+                    "targets": [0],
+                    "visible": false,
+                    "searchable": false
+                }
+            ],
+            "scrollX": false,
+            "scrollY": false,
+            "responsive": true,
+            "language": {
+                "emptyTable": "No share defined"
+            },
+            "order": [[1, 'asc']]
+        });
+
+        new $.fn.dataTable.FixedHeader( table );
+
+        table.button().add(0,'link');
+        table.button().add(0,'delete');
+        table.button().add(0,'edit');
+        table.button().add(0,'add');
+
+        table.buttons().container().appendTo('.col-md-6:eq(0)', table.table().container());
+
+        table.on('select deselect', function () {
+            var selectedRows = table.rows({ selected: true }).count();
+            table.button('edit:name').enable(selectedRows == 1);
+            table.button('clone:name').enable(selectedRows == 1);
+            table.button('delete:name').enable(selectedRows == 1);
+            table.button('link:name').enable(selectedRows == 1);
+        });
+    });
+</script>
+{{end}}

+ 3 - 3
tests/eventsearcher/go.mod

@@ -11,15 +11,15 @@ require (
 	github.com/fatih/color v1.13.0 // indirect
 	github.com/fatih/color v1.13.0 // indirect
 	github.com/golang/protobuf v1.5.2 // indirect
 	github.com/golang/protobuf v1.5.2 // indirect
 	github.com/hashicorp/go-hclog v1.0.0 // indirect
 	github.com/hashicorp/go-hclog v1.0.0 // indirect
-	github.com/hashicorp/yamux v0.0.0-20210826001029-26ff87cf9493 // indirect
+	github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87 // indirect
 	github.com/mattn/go-colorable v0.1.11 // indirect
 	github.com/mattn/go-colorable v0.1.11 // indirect
 	github.com/mattn/go-isatty v0.0.14 // indirect
 	github.com/mattn/go-isatty v0.0.14 // indirect
 	github.com/mitchellh/go-testing-interface v1.14.1 // indirect
 	github.com/mitchellh/go-testing-interface v1.14.1 // indirect
 	github.com/oklog/run v1.1.0 // indirect
 	github.com/oklog/run v1.1.0 // indirect
 	golang.org/x/net v0.0.0-20211020060615-d418f374d309 // indirect
 	golang.org/x/net v0.0.0-20211020060615-d418f374d309 // indirect
-	golang.org/x/sys v0.0.0-20211023085530-d6a326fbbf70 // indirect
+	golang.org/x/sys v0.0.0-20211029165221-6e7872819dc8 // indirect
 	golang.org/x/text v0.3.7 // indirect
 	golang.org/x/text v0.3.7 // indirect
-	google.golang.org/genproto v0.0.0-20211021150943-2b146023228c // indirect
+	google.golang.org/genproto v0.0.0-20211029142109-e255c875f7c7 // indirect
 	google.golang.org/grpc v1.41.0 // indirect
 	google.golang.org/grpc v1.41.0 // indirect
 	google.golang.org/protobuf v1.27.1 // indirect
 	google.golang.org/protobuf v1.27.1 // indirect
 )
 )

+ 23 - 10
tests/eventsearcher/go.sum

@@ -122,7 +122,7 @@ github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZo
 github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go v1.38.68/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go v1.38.68/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go v1.40.34/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
 github.com/aws/aws-sdk-go v1.40.34/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
-github.com/aws/aws-sdk-go v1.41.9/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
+github.com/aws/aws-sdk-go v1.41.14/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
 github.com/aws/aws-sdk-go-v2 v1.7.0/go.mod h1:tb9wi5s61kTDA5qCkcDbt3KRVV74GGslQkl/DRdX/P4=
 github.com/aws/aws-sdk-go-v2 v1.7.0/go.mod h1:tb9wi5s61kTDA5qCkcDbt3KRVV74GGslQkl/DRdX/P4=
 github.com/aws/aws-sdk-go-v2 v1.9.0/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
 github.com/aws/aws-sdk-go-v2 v1.9.0/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
 github.com/aws/aws-sdk-go-v2/config v1.7.0/go.mod h1:w9+nMZ7soXCe5nT46Ri354SNhXDQ6v+V5wqDjnZE+GY=
 github.com/aws/aws-sdk-go-v2/config v1.7.0/go.mod h1:w9+nMZ7soXCe5nT46Ri354SNhXDQ6v+V5wqDjnZE+GY=
@@ -161,8 +161,12 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk
 github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
 github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
 github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
 github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
 github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
 github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
+github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
 github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
 github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
 github.com/cockroachdb/cockroach-go/v2 v2.2.1/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI=
 github.com/cockroachdb/cockroach-go/v2 v2.2.1/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI=
 github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI=
 github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI=
@@ -199,7 +203,9 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.m
 github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
 github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
 github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
 github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
 github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
 github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
+github.com/envoyproxy/go-control-plane v0.10.0/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
 github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
 github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
 github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
@@ -218,6 +224,7 @@ github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm
 github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do=
 github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do=
 github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
 github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
 github.com/go-chi/chi/v5 v5.0.4/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
 github.com/go-chi/chi/v5 v5.0.4/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
+github.com/go-chi/chi/v5 v5.0.5/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
 github.com/go-chi/jwtauth/v5 v5.0.2/go.mod h1:TeA7vmPe3uYThvHw8O8W13HOOpOd4MTgToxL41gZyjs=
 github.com/go-chi/jwtauth/v5 v5.0.2/go.mod h1:TeA7vmPe3uYThvHw8O8W13HOOpOd4MTgToxL41gZyjs=
 github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns=
 github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns=
 github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
 github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
@@ -386,10 +393,11 @@ github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOn
 github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
 github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
 github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk=
 github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk=
 github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM=
 github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM=
-github.com/hashicorp/yamux v0.0.0-20210826001029-26ff87cf9493 h1:brI5vBRUlAlM34VFmnLPwjnCL/FxAJp9XvOdX6Zt+XE=
-github.com/hashicorp/yamux v0.0.0-20210826001029-26ff87cf9493/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
+github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87 h1:xixZ2bWeofWV68J+x6AzmKuVM/JWCQwkWm6GW/MUR6I=
+github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
 github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
 github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
+github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
 github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
@@ -484,7 +492,7 @@ github.com/lestrrat-go/codegen v1.0.1/go.mod h1:JhJw6OQAuPEfVKUCLItpaVLumDGWQznd
 github.com/lestrrat-go/httpcc v1.0.0/go.mod h1:tGS/u00Vh5N6FHNkExqGGNId8e0Big+++0Gf8MBnAvE=
 github.com/lestrrat-go/httpcc v1.0.0/go.mod h1:tGS/u00Vh5N6FHNkExqGGNId8e0Big+++0Gf8MBnAvE=
 github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc=
 github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc=
 github.com/lestrrat-go/jwx v1.2.6/go.mod h1:tJuGuAI3LC71IicTx82Mz1n3w9woAs2bYJZpkjJQ5aU=
 github.com/lestrrat-go/jwx v1.2.6/go.mod h1:tJuGuAI3LC71IicTx82Mz1n3w9woAs2bYJZpkjJQ5aU=
-github.com/lestrrat-go/jwx v1.2.8/go.mod h1:25DcLbNWArPA/Ew5CcBmewl32cJKxOk5cbepBsIJFzw=
+github.com/lestrrat-go/jwx v1.2.9/go.mod h1:25DcLbNWArPA/Ew5CcBmewl32cJKxOk5cbepBsIJFzw=
 github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
 github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
 github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
@@ -494,6 +502,7 @@ github.com/lib/pq v1.10.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts=
 github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts=
+github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=
 github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
 github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
 github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
 github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
 github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
 github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
@@ -631,6 +640,7 @@ github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1
 github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
 github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
 github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
 github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
 github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
 github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
+github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=
 github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
 github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
 github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
 github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
 github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
 github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
@@ -808,6 +818,7 @@ golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qx
 golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20211020060615-d418f374d309 h1:A0lJIi+hcTR6aajJH4YqKWwohY4aW9RO7oRMcdv+HKI=
 golang.org/x/net v0.0.0-20211020060615-d418f374d309 h1:A0lJIi+hcTR6aajJH4YqKWwohY4aW9RO7oRMcdv+HKI=
 golang.org/x/net v0.0.0-20211020060615-d418f374d309/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20211020060615-d418f374d309/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
@@ -830,6 +841,7 @@ golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ
 golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20211028175245-ba495a64dcb5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -919,14 +931,15 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210816074244-15123e1e1f71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210816074244-15123e1e1f71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20211023085530-d6a326fbbf70 h1:SeSEfdIxyvwGJliREIJhRPPXvW6sDlLT+UQ3B0hD0NA=
-golang.org/x/sys v0.0.0-20211023085530-d6a326fbbf70/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211029165221-6e7872819dc8 h1:M69LAlWZCshgp0QSzyDcSsSIejIEeuaCVpmwcKwyLMk=
+golang.org/x/sys v0.0.0-20211029165221-6e7872819dc8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1051,7 +1064,7 @@ google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqiv
 google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
 google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
 google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI=
 google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI=
 google.golang.org/api v0.58.0/go.mod h1:cAbP2FsxoGVNwtgNAmmn3y5G1TWAiVYRmg4yku3lv+E=
 google.golang.org/api v0.58.0/go.mod h1:cAbP2FsxoGVNwtgNAmmn3y5G1TWAiVYRmg4yku3lv+E=
-google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU=
+google.golang.org/api v0.60.0/go.mod h1:d7rl65NZAkEQ90JFzqBjcRq1TVeG5ZoGV3sSpEnnVb4=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -1124,10 +1137,10 @@ google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEc
 google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
 google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
 google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
 google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
 google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211016002631-37fc39342514/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211016002631-37fc39342514/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
-google.golang.org/genproto v0.0.0-20211021150943-2b146023228c h1:FqrtZMB5Wr+/RecOM3uPJNPfWR8Upb5hAPnt7PU6i4k=
 google.golang.org/genproto v0.0.0-20211021150943-2b146023228c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/genproto v0.0.0-20211021150943-2b146023228c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
+google.golang.org/genproto v0.0.0-20211029142109-e255c875f7c7 h1:aaSaYY/DIDJy3f/JLXWv6xJ1mBQSRnQ1s5JhAFTnzO4=
+google.golang.org/genproto v0.0.0-20211029142109-e255c875f7c7/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
 google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=

+ 11 - 0
util/util.go

@@ -28,6 +28,8 @@ import (
 	"strings"
 	"strings"
 	"time"
 	"time"
 
 
+	"github.com/google/uuid"
+	"github.com/lithammer/shortuuid/v3"
 	"github.com/rs/xid"
 	"github.com/rs/xid"
 	"golang.org/x/crypto/ssh"
 	"golang.org/x/crypto/ssh"
 
 
@@ -431,6 +433,15 @@ func GenerateRandomBytes(length int) []byte {
 	return b[:length]
 	return b[:length]
 }
 }
 
 
+// GenerateUniqueID retuens an unique ID
+func GenerateUniqueID() string {
+	u, err := uuid.NewRandom()
+	if err != nil {
+		return xid.New().String()
+	}
+	return shortuuid.DefaultEncoder.Encode(u)
+}
+
 // HTTPListenAndServe is a wrapper for ListenAndServe that support both tcp
 // HTTPListenAndServe is a wrapper for ListenAndServe that support both tcp
 // and Unix-domain sockets
 // and Unix-domain sockets
 func HTTPListenAndServe(srv *http.Server, address string, port int, isTLS bool, logSender string) error {
 func HTTPListenAndServe(srv *http.Server, address string, port int, isTLS bool, logSender string) error {

Some files were not shown because too many files changed in this diff