From 3bc58f5988fcfef970fd080a7d73716488f253ab Mon Sep 17 00:00:00 2001 From: Nicola Murino Date: Sat, 6 Nov 2021 14:13:20 +0100 Subject: [PATCH] WebClient/REST API: add sharing support --- README.md | 4 +- common/common.go | 6 +- common/connection.go | 3 +- dataprovider/actions.go | 1 + dataprovider/admin.go | 18 +- dataprovider/apikey.go | 11 +- dataprovider/bolt.go | 371 ++++++++++-- dataprovider/dataprovider.go | 91 ++- dataprovider/memory.go | 285 ++++++++- dataprovider/mysql.go | 76 ++- dataprovider/pgsql.go | 78 ++- dataprovider/share.go | 274 +++++++++ dataprovider/sqlcommon.go | 240 +++++++- dataprovider/sqlite.go | 76 ++- dataprovider/sqlqueries.go | 47 ++ dataprovider/user.go | 25 +- docs/web-client.md | 4 +- go.mod | 55 +- go.sum | 64 +- httpd/api_http_user.go | 25 +- httpd/api_keys.go | 1 + httpd/api_maintenance.go | 32 + httpd/api_mfa.go | 3 +- httpd/api_shares.go | 232 ++++++++ httpd/api_utils.go | 10 +- httpd/auth_utils.go | 4 +- httpd/httpd.go | 11 + httpd/httpd_test.go | 952 ++++++++++++++++++++++++++++-- httpd/internal_test.go | 69 ++- httpd/schema/openapi.yaml | 387 +++++++++++- httpd/server.go | 26 +- httpd/webadmin.go | 6 +- httpd/webclient.go | 238 +++++++- sdk/plugin/plugin.go | 2 + sdk/user.go | 3 +- service/service.go | 4 + service/service_windows.go | 2 +- service/signals_windows.go | 2 + templates/webadmin/folders.html | 2 - templates/webadmin/fsconfig.html | 2 +- templates/webclient/base.html | 7 + templates/webclient/editfile.html | 2 +- templates/webclient/files.html | 29 +- templates/webclient/share.html | 209 +++++++ templates/webclient/shares.html | 257 ++++++++ tests/eventsearcher/go.mod | 6 +- tests/eventsearcher/go.sum | 33 +- util/util.go | 11 + 48 files changed, 4038 insertions(+), 258 deletions(-) create mode 100644 dataprovider/share.go create mode 100644 httpd/api_shares.go create mode 100644 templates/webclient/share.html create mode 100644 templates/webclient/shares.html diff --git a/README.md b/README.md index aaab1cb7..b48f8aa4 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![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) -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. ## 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. - [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 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. - 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. diff --git a/common/common.go b/common/common.go index 76756cd8..c1bb1cab 100644 --- a/common/common.go +++ b/common/common.go @@ -80,6 +80,7 @@ const ( ProtocolFTP = "FTP" ProtocolWebDAV = "DAV" ProtocolHTTP = "HTTP" + ProtocolHTTPShare = "HTTPShare" ProtocolDataRetention = "DataRetention" ) @@ -122,8 +123,9 @@ var ( QuotaScans ActiveScans idleTimeoutTicker *time.Ticker 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 rateLimiters map[string][]*rateLimiter ) diff --git a/common/connection.go b/common/connection.go index 039ad2ed..ef4290d0 100644 --- a/common/connection.go +++ b/common/connection.go @@ -226,7 +226,7 @@ func (c *BaseConnection) ListDir(virtualPath string) ([]os.FileInfo, error) { } files, err := fs.ReadDir(fsPath) 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 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)) } if err != nil { + c.Log(logger.LevelDebug, "stat error for path %#v: %+v", virtualPath, err) return info, c.GetFsError(fs, err) } if vfs.IsCryptOsFs(fs) { diff --git a/dataprovider/actions.go b/dataprovider/actions.go index e03d651b..9c3d54f7 100644 --- a/dataprovider/actions.go +++ b/dataprovider/actions.go @@ -29,6 +29,7 @@ const ( actionObjectUser = "user" actionObjectAdmin = "admin" actionObjectAPIKey = "api_key" + actionObjectShare = "share" ) func executeAction(operation, executor, ip, objectType, objectName string, object plugin.Renderer) { diff --git a/dataprovider/admin.go b/dataprovider/admin.go index b631346f..b685d520 100644 --- a/dataprovider/admin.go +++ b/dataprovider/admin.go @@ -131,7 +131,7 @@ func (a *Admin) CountUnusedRecoveryCodes() int { return unused } -func (a *Admin) checkPassword() error { +func (a *Admin) hashPassword() error { if a.Password != "" && !util.IsStringPrefixInSlice(a.Password, internalHashPwdPrefixes) { if config.PasswordValidation.Admins.MinEntropy > 0 { 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) { 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 } if err := a.validatePermissions(); err != nil { @@ -238,7 +238,11 @@ func (a *Admin) CheckPassword(password string) (bool, error) { } 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 @@ -361,14 +365,14 @@ func (a *Admin) GetValidPerms() []string { // GetInfoString returns admin's info as string. func (a *Admin) GetInfoString() string { - var result string + var result strings.Builder if a.Email != "" { - result = fmt.Sprintf("Email: %v. ", a.Email) + result.WriteString(fmt.Sprintf("Email: %v. ", a.Email)) } 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 diff --git a/dataprovider/apikey.go b/dataprovider/apikey.go index 2b08049f..c1bd93c4 100644 --- a/dataprovider/apikey.go +++ b/dataprovider/apikey.go @@ -7,7 +7,6 @@ import ( "time" "github.com/alexedwards/argon2id" - "github.com/lithammer/shortuuid/v3" "golang.org/x/crypto/bcrypt" "github.com/drakkan/sftpgo/v2/logger" @@ -93,12 +92,12 @@ func (k *APIKey) RenderAsJSON(reload bool) ([]byte, error) { return json.Marshal(k) } -// HideConfidentialData hides admin confidential data +// HideConfidentialData hides API key confidential data func (k *APIKey) HideConfidentialData() { k.Key = "" } -func (k *APIKey) checkKey() error { +func (k *APIKey) hashKey() error { if k.Key != "" && !util.IsStringPrefixInSlice(k.Key, internalHashPwdPrefixes) { if config.PasswordHashing.Algo == HashingAlgoBcrypt { hashed, err := bcrypt.GenerateFromPassword([]byte(k.Key), config.PasswordHashing.BcryptOptions.Cost) @@ -121,8 +120,8 @@ func (k *APIKey) generateKey() { if k.KeyID != "" || k.Key != "" { return } - k.KeyID = shortuuid.New() - k.Key = shortuuid.New() + k.KeyID = util.GenerateUniqueID() + k.Key = util.GenerateUniqueID() k.plainKey = k.Key } @@ -139,7 +138,7 @@ func (k *APIKey) validate() error { return util.NewValidationError(fmt.Sprintf("invalid scope: %v", k.Scope)) } k.generateKey() - if err := k.checkKey(); err != nil { + if err := k.hashKey(); err != nil { return err } if k.User != "" && k.Admin != "" { diff --git a/dataprovider/bolt.go b/dataprovider/bolt.go index eec999b1..4191c42b 100644 --- a/dataprovider/bolt.go +++ b/dataprovider/bolt.go @@ -20,7 +20,7 @@ import ( ) const ( - boltDatabaseVersion = 13 + boltDatabaseVersion = 14 ) var ( @@ -28,8 +28,11 @@ var ( foldersBucket = []byte("folders") adminsBucket = []byte("admins") apiKeysBucket = []byte("api_keys") + sharesBucket = []byte("shares") dbVersionBucket = []byte("db_version") dbVersionKey = []byte("version") + boltBuckets = [][]byte{usersBucket, foldersBucket, adminsBucket, apiKeysBucket, + sharesBucket, dbVersionBucket} ) // BoltProvider auth provider for bolt key/value store @@ -57,50 +60,16 @@ func initializeBoltProvider(basePath string) error { Timeout: 5 * time.Second}) if err == nil { 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} } else { 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 { return err } + if err := deleteRelatedShares(tx, user.Username); err != nil { + return err + } return bucket.Delete([]byte(user.Username)) }) } @@ -995,6 +967,16 @@ func (p *BoltProvider) addAPIKey(apiKey *APIKey) error { apiKey.CreatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) apiKey.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) 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) if err != nil { return err @@ -1030,6 +1012,16 @@ func (p *BoltProvider) updateAPIKey(apiKey *APIKey) error { apiKey.CreatedAt = oldAPIKey.CreatedAt apiKey.LastUseAt = oldAPIKey.LastUseAt 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) if err != nil { 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 { bucket, err := getAPIKeysBucket(tx) if err != nil { @@ -1127,6 +1119,224 @@ func (p *BoltProvider) dumpAPIKeys() ([]APIKey, error) { 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 { return p.dbHandle.Close() } @@ -1155,11 +1365,13 @@ func (p *BoltProvider) migrateDatabase() error { logger.ErrorToConsole("%v", err) return err case version == 10: - return updateBoltDatabaseVersion(p.dbHandle, 13) + return updateBoltDatabaseVersion(p.dbHandle, 14) case version == 11: - return updateBoltDatabaseVersion(p.dbHandle, 13) + return updateBoltDatabaseVersion(p.dbHandle, 14) case version == 12: - return updateBoltDatabaseVersion(p.dbHandle, 13) + return updateBoltDatabaseVersion(p.dbHandle, 14) + case version == 13: + return updateBoltDatabaseVersion(p.dbHandle, 14) default: if version > boltDatabaseVersion { 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") } switch dbVersion.Version { + case 14: + return updateBoltDatabaseVersion(p.dbHandle, 10) case 13: return updateBoltDatabaseVersion(p.dbHandle, 10) case 12: @@ -1297,6 +1511,57 @@ func removeUserFromFolderMapping(folder *vfs.VirtualFolder, user *User, bucket * 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 { bucket, err := getAPIKeysBucket(tx) if err != nil { @@ -1330,6 +1595,16 @@ func deleteRelatedAPIKey(tx *bolt.Tx, username string, scope APIKeyScope) error 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) { var err error diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index 975c94af..46e899d0 100644 --- a/dataprovider/dataprovider.go +++ b/dataprovider/dataprovider.go @@ -70,7 +70,7 @@ const ( CockroachDataProviderName = "cockroachdb" // DumpVersion defines the version for the dump. // For restore/load we support the current version and the previous one - DumpVersion = 9 + DumpVersion = 10 argonPwdPrefix = "$argon2id$" bcryptPwdPrefix = "$2a$" @@ -131,14 +131,15 @@ var ( // ErrNoInitRequired defines the error returned by InitProvider if no inizialization/update is required ErrNoInitRequired = errors.New("the data provider is up to date") // 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} pbkdfPwdPrefixes = []string{pbkdf2SHA1Prefix, pbkdf2SHA256Prefix, pbkdf2SHA512Prefix, pbkdf2SHA256B64SaltPrefix} pbkdfPwdB64SaltPrefixes = []string{pbkdf2SHA256B64SaltPrefix} @@ -156,6 +157,7 @@ var ( sqlTableFoldersMapping = "folders_mapping" sqlTableAdmins = "admins" sqlTableAPIKeys = "api_keys" + sqlTableShares = "shares" sqlTableSchemaVersion = "schema_version" argon2Params *argon2id.Params lastLoginMinDelay = 10 * time.Minute @@ -368,6 +370,7 @@ type BackupData struct { Folders []vfs.BaseVirtualFolder `json:"folders"` Admins []Admin `json:"admins"` APIKeys []APIKey `json:"api_keys"` + Shares []Share `json:"shares"` Version int `json:"version"` } @@ -436,10 +439,17 @@ type Provider interface { apiKeyExists(keyID string) (APIKey, error) addAPIKey(apiKey *APIKey) error updateAPIKey(apiKey *APIKey) error - deleteAPIKeys(apiKey *APIKey) error + deleteAPIKey(apiKey *APIKey) error getAPIKeys(limit int, offset int, order string) ([]APIKey, error) dumpAPIKeys() ([]APIKey, 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 close() error reloadConfig() error @@ -574,10 +584,11 @@ func validateSQLTablesPrefix() error { sqlTableFoldersMapping = config.SQLTablesPrefix + sqlTableFoldersMapping sqlTableAdmins = config.SQLTablesPrefix + sqlTableAdmins sqlTableAPIKeys = config.SQLTablesPrefix + sqlTableAPIKeys + sqlTableShares = config.SQLTablesPrefix + sqlTableShares sqlTableSchemaVersion = config.SQLTablesPrefix + sqlTableSchemaVersion 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 } @@ -831,6 +842,11 @@ func CheckKeyboardInteractiveAuth(username, authHook string, client ssh.Keyboard 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 func UpdateAPIKeyLastUse(apiKey *APIKey) error { lastUse := util.GetTimeFromMsecSinceEpoch(apiKey.LastUseAt) @@ -928,6 +944,45 @@ func GetUsedVirtualFolderQuota(name string) (int, int64, error) { 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 func AddAPIKey(apiKey *APIKey, executor, ipAddress string) error { err := provider.addAPIKey(apiKey) @@ -952,7 +1007,7 @@ func DeleteAPIKey(keyID string, executor, ipAddress string) error { if err != nil { return err } - err = provider.deleteAPIKeys(&apiKey) + err = provider.deleteAPIKey(&apiKey) if err == nil { executeAction(operationDelete, executor, ipAddress, actionObjectAPIKey, apiKey.KeyID, &apiKey) } @@ -1066,6 +1121,11 @@ func ReloadConfig() error { 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 func GetAPIKeys(limit, offset int, order string) ([]APIKey, error) { return provider.getAPIKeys(limit, offset, order) @@ -1154,10 +1214,15 @@ func DumpData() (BackupData, error) { if err != nil { return data, err } + shares, err := provider.dumpShares() + if err != nil { + return data, err + } data.Users = users data.Folders = folders data.Admins = admins data.APIKeys = apiKeys + data.Shares = shares data.Version = DumpVersion return data, err } diff --git a/dataprovider/memory.go b/dataprovider/memory.go index 6e50c95e..7b0e38e1 100644 --- a/dataprovider/memory.go +++ b/dataprovider/memory.go @@ -40,6 +40,10 @@ type memoryProviderHandle struct { apiKeys map[string]APIKey // slice with ordered API keys KeyID 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 @@ -66,6 +70,8 @@ func initializeMemoryProvider(basePath string) { adminsUsernames: []string{}, apiKeys: make(map[string]APIKey), apiKeysIDs: []string{}, + shares: make(map[string]Share), + sharesIDs: []string{}, configFile: configFile, }, } @@ -328,6 +334,7 @@ func (p *MemoryProvider) deleteUser(user *User) error { } sort.Strings(p.dbHandle.usernames) p.deleteAPIKeysWithUser(user.Username) + p.deleteSharesWithUser(user.Username) return nil } @@ -869,6 +876,16 @@ func (p *MemoryProvider) addAPIKey(apiKey *APIKey) error { if err == nil { 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.UpdatedAt = util.GetTimeAsMsSinceEpoch(time.Now()) apiKey.LastUseAt = 0 @@ -893,6 +910,16 @@ func (p *MemoryProvider) updateAPIKey(apiKey *APIKey) error { if err != nil { 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.KeyID = k.KeyID apiKey.Key = k.Key @@ -903,7 +930,7 @@ func (p *MemoryProvider) updateAPIKey(apiKey *APIKey) error { return nil } -func (p *MemoryProvider) deleteAPIKeys(apiKey *APIKey) error { +func (p *MemoryProvider) deleteAPIKey(apiKey *APIKey) error { p.dbHandle.Lock() defer p.dbHandle.Unlock() if p.dbHandle.isClosed { @@ -915,12 +942,8 @@ func (p *MemoryProvider) deleteAPIKeys(apiKey *APIKey) error { } 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 } @@ -986,19 +1009,235 @@ func (p *MemoryProvider) dumpAPIKeys() ([]APIKey, error) { } func (p *MemoryProvider) deleteAPIKeysWithUser(username string) { + found := false for k, v := range p.dbHandle.apiKeys { if v.User == username { delete(p.dbHandle.apiKeys, k) + found = true } } + if found { + p.updateAPIKeysOrdering() + } } func (p *MemoryProvider) deleteAPIKeysWithAdmin(username string) { + found := false for k, v := range p.dbHandle.apiKeys { if v.Admin == username { 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 { @@ -1040,6 +1279,10 @@ func (p *MemoryProvider) clear() { p.dbHandle.vfolders = make(map[string]vfs.BaseVirtualFolder) p.dbHandle.admins = make(map[string]Admin) 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 { @@ -1091,13 +1334,39 @@ func (p *MemoryProvider) reloadConfig() error { return err } + if err := p.restoreShares(&dump); err != nil { + return err + } + providerLog(logger.LevelDebug, "config loaded from file: %#v", p.dbHandle.configFile) 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 { for _, apiKey := range dump.APIKeys { - if apiKey.KeyID == "" { + if apiKey.Key == "" { return fmt.Errorf("cannot restore an empty API key: %+v", apiKey) } k, err := p.apiKeyExists(apiKey.KeyID) diff --git a/dataprovider/mysql.go b/dataprovider/mysql.go index 416e9f1e..d944403c 100644 --- a/dataprovider/mysql.go +++ b/dataprovider/mysql.go @@ -66,6 +66,15 @@ const ( mysqlV13SQL = "ALTER TABLE `{{users}}` ADD COLUMN `email` varchar(255) NULL;" 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 @@ -251,7 +260,7 @@ func (p *MySQLProvider) updateAPIKey(apiKey *APIKey) error { return sqlCommonUpdateAPIKey(apiKey, p.dbHandle) } -func (p *MySQLProvider) deleteAPIKeys(apiKey *APIKey) error { +func (p *MySQLProvider) deleteAPIKey(apiKey *APIKey) error { return sqlCommonDeleteAPIKey(apiKey, p.dbHandle) } @@ -267,6 +276,34 @@ func (p *MySQLProvider) updateAPIKeyLastUse(keyID string) error { 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 { return p.dbHandle.Close() } @@ -291,6 +328,7 @@ func (p *MySQLProvider) initializeDatabase() error { return sqlCommonExecSQLAndUpdateDBVersion(p.dbHandle, strings.Split(initialSQL, ";"), 10) } +//nolint:dupl func (p *MySQLProvider) migrateDatabase() error { dbVersion, err := sqlCommonGetDatabaseVersion(p.dbHandle, true) if err != nil { @@ -312,6 +350,8 @@ func (p *MySQLProvider) migrateDatabase() error { return updateMySQLDatabaseFromV11(p.dbHandle) case version == 12: return updateMySQLDatabaseFromV12(p.dbHandle) + case version == 13: + return updateMySQLDatabaseFromV13(p.dbHandle) default: if version > sqlDatabaseVersion { 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 { + case 14: + return downgradeMySQLDatabaseFromV14(p.dbHandle) case 13: return downgradeMySQLDatabaseFromV13(p.dbHandle) case 12: @@ -360,7 +402,21 @@ func updateMySQLDatabaseFromV11(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 { @@ -381,6 +437,22 @@ func downgradeMySQLDatabaseFromV11(dbHandle *sql.DB) error { 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 { logger.InfoToConsole("updating database version: 12 -> 13") providerLog(logger.LevelInfo, "updating database version: 12 -> 13") diff --git a/dataprovider/pgsql.go b/dataprovider/pgsql.go index 13b0ede5..ea762648 100644 --- a/dataprovider/pgsql.go +++ b/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;` 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 @@ -263,7 +274,7 @@ func (p *PGSQLProvider) updateAPIKey(apiKey *APIKey) error { return sqlCommonUpdateAPIKey(apiKey, p.dbHandle) } -func (p *PGSQLProvider) deleteAPIKeys(apiKey *APIKey) error { +func (p *PGSQLProvider) deleteAPIKey(apiKey *APIKey) error { return sqlCommonDeleteAPIKey(apiKey, p.dbHandle) } @@ -279,6 +290,34 @@ func (p *PGSQLProvider) updateAPIKeyLastUse(keyID string) error { 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 { return p.dbHandle.Close() } @@ -309,6 +348,7 @@ func (p *PGSQLProvider) initializeDatabase() error { return sqlCommonExecSQLAndUpdateDBVersion(p.dbHandle, []string{initialSQL}, 10) } +//nolint:dupl func (p *PGSQLProvider) migrateDatabase() error { dbVersion, err := sqlCommonGetDatabaseVersion(p.dbHandle, true) if err != nil { @@ -330,6 +370,8 @@ func (p *PGSQLProvider) migrateDatabase() error { return updatePGSQLDatabaseFromV11(p.dbHandle) case version == 12: return updatePGSQLDatabaseFromV12(p.dbHandle) + case version == 13: + return updatePGSQLDatabaseFromV13(p.dbHandle) default: if version > sqlDatabaseVersion { 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 { + case 14: + return downgradePGSQLDatabaseFromV14(p.dbHandle) case 13: return downgradePGSQLDatabaseFromV13(p.dbHandle) case 12: @@ -378,7 +422,21 @@ func updatePGSQLDatabaseFromV11(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 { @@ -399,6 +457,22 @@ func downgradePGSQLDatabaseFromV11(dbHandle *sql.DB) error { 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 { logger.InfoToConsole("updating database version: 12 -> 13") providerLog(logger.LevelInfo, "updating database version: 12 -> 13") diff --git a/dataprovider/share.go b/dataprovider/share.go new file mode 100644 index 00000000..cf6592b3 --- /dev/null +++ b/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 +} diff --git a/dataprovider/sqlcommon.go b/dataprovider/sqlcommon.go index 977b4a57..84bef721 100644 --- a/dataprovider/sqlcommon.go +++ b/dataprovider/sqlcommon.go @@ -19,7 +19,7 @@ import ( ) const ( - sqlDatabaseVersion = 13 + sqlDatabaseVersion = 14 defaultSQLQueryTimeout = 10 * time.Second longSQLQueryTimeout = 60 * time.Second ) @@ -34,10 +34,189 @@ type sqlScanner interface { 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) { var apiKey APIKey ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) defer cancel() + q := getAPIKeyByIDQuery() stmt, err := dbHandle.PrepareContext(ctx, q) if err != nil { @@ -468,6 +647,25 @@ func sqlCommonGetUsedQuota(username string, dbHandle *sql.DB) (int, int64, error 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 { ctx, cancel := context.WithTimeout(context.Background(), defaultSQLQueryTimeout) defer cancel() @@ -739,6 +937,46 @@ func sqlCommonGetUsers(limit int, offset int, order string, dbHandle sqlQuerier) 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) { var apiKey APIKey var userID, adminID sql.NullInt64 diff --git a/dataprovider/sqlite.go b/dataprovider/sqlite.go index 71affacd..f287dbf3 100644 --- a/dataprovider/sqlite.go +++ b/dataprovider/sqlite.go @@ -69,6 +69,15 @@ ALTER TABLE "{{admins}}" DROP COLUMN "last_login"; ` sqliteV13SQL = `ALTER TABLE "{{users}}" ADD COLUMN "email" varchar(255) NULL;` 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 @@ -247,7 +256,7 @@ func (p *SQLiteProvider) updateAPIKey(apiKey *APIKey) error { return sqlCommonUpdateAPIKey(apiKey, p.dbHandle) } -func (p *SQLiteProvider) deleteAPIKeys(apiKey *APIKey) error { +func (p *SQLiteProvider) deleteAPIKey(apiKey *APIKey) error { return sqlCommonDeleteAPIKey(apiKey, p.dbHandle) } @@ -263,6 +272,34 @@ func (p *SQLiteProvider) updateAPIKeyLastUse(keyID string) error { 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 { return p.dbHandle.Close() } @@ -287,6 +324,7 @@ func (p *SQLiteProvider) initializeDatabase() error { return sqlCommonExecSQLAndUpdateDBVersion(p.dbHandle, []string{initialSQL}, 10) } +//nolint:dupl func (p *SQLiteProvider) migrateDatabase() error { dbVersion, err := sqlCommonGetDatabaseVersion(p.dbHandle, true) if err != nil { @@ -308,6 +346,8 @@ func (p *SQLiteProvider) migrateDatabase() error { return updateSQLiteDatabaseFromV11(p.dbHandle) case version == 12: return updateSQLiteDatabaseFromV12(p.dbHandle) + case version == 13: + return updateSQLiteDatabaseFromV13(p.dbHandle) default: if version > sqlDatabaseVersion { 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 { + case 14: + return downgradeSQLiteDatabaseFromV14(p.dbHandle) case 13: return downgradeSQLiteDatabaseFromV13(p.dbHandle) case 12: @@ -356,7 +398,21 @@ func updateSQLiteDatabaseFromV11(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 { @@ -377,6 +433,22 @@ func downgradeSQLiteDatabaseFromV11(dbHandle *sql.DB) error { 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 { logger.InfoToConsole("updating database version: 12 -> 13") providerLog(logger.LevelInfo, "updating database version: 12 -> 13") diff --git a/dataprovider/sqlqueries.go b/dataprovider/sqlqueries.go index 21ee6cb3..ce186352 100644 --- a/dataprovider/sqlqueries.go +++ b/dataprovider/sqlqueries.go @@ -15,6 +15,8 @@ const ( 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" 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 { @@ -59,6 +61,46 @@ func getDeleteAdminQuery() string { 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 { 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]) } +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 { return fmt.Sprintf(`SELECT used_quota_size,used_quota_files FROM %v WHERE username = %v`, sqlTableUsers, sqlPlaceholders[0]) diff --git a/dataprovider/user.go b/dataprovider/user.go index b5fb9855..46479f5b 100644 --- a/dataprovider/user.go +++ b/dataprovider/user.go @@ -727,6 +727,11 @@ func (u *User) CanManageMFA() bool { 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 func (u *User) CanChangePassword() bool { 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, // gid, denied and allowed IP/Mask are returned func (u *User) GetInfoString() string { - var result string + var result strings.Builder if u.LastLogin > 0 { 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 { - result += fmt.Sprintf("Storage: %s ", u.FsConfig.Provider.ShortInfo()) + result.WriteString(fmt.Sprintf("Storage: %s. ", u.FsConfig.Provider.ShortInfo())) } 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 { - result += fmt.Sprintf("Max sessions: %v ", u.MaxSessions) + result.WriteString(fmt.Sprintf("Max sessions: %v. ", u.MaxSessions)) } if u.UID > 0 { - result += fmt.Sprintf("UID: %v ", u.UID) + result.WriteString(fmt.Sprintf("UID: %v. ", u.UID)) } 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 { - 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 { - 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 diff --git a/docs/web-client.md b/docs/web-client.md index cfd44507..e25c2452 100644 --- a/docs/web-client.md +++ b/docs/web-client.md @@ -1,6 +1,8 @@ # 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. diff --git a/go.mod b/go.mod index f0b690c5..b21389b2 100644 --- a/go.mod +++ b/go.mod @@ -7,10 +7,9 @@ require ( github.com/Azure/azure-storage-blob-go v0.14.0 github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 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/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/go-log v0.1.0 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/golang/mock v1.6.0 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/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-hclog v1.0.0 github.com/hashicorp/go-plugin v1.4.3 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/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/lib/pq v1.10.3 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/mhale/smtpd v0.8.0 - github.com/miekg/dns v1.1.43 // indirect 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/pires/go-proxyproto v0.6.1 github.com/pkg/sftp v1.13.4 github.com/pquerna/otp v1.3.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/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/cobra v1.2.1 github.com/spf13/viper v1.9.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/xhit/go-simple-mail/v2 v2.10.0 github.com/yl2chen/cidranger v1.0.2 @@ -62,12 +52,11 @@ require ( go.uber.org/automaxprocs v1.4.0 gocloud.dev v0.24.0 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 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 gopkg.in/natefinch/lumberjack.v2 v2.0.0 ) @@ -81,42 +70,53 @@ require ( github.com/cenkalti/backoff v2.2.1+incompatible // indirect github.com/census-instrumentation/opencensus-proto v0.3.0 // 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/cpuguy83/go-md2man/v2 v2.0.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/go-ole/go-ole v1.2.6 // indirect github.com/goccy/go-json v0.7.10 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.2 // 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/hashicorp/go-cleanhttp v0.5.2 // 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/jmespath/go-jmespath v0.4.0 // indirect github.com/klauspost/cpuid/v2 v2.0.9 // 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/httpcc v1.0.0 // indirect github.com/lestrrat-go/iter v1.0.1 // 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/mattn/go-colorable v0.1.11 // 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/miekg/dns v1.1.43 // 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/oklog/run v1.1.0 // indirect github.com/pelletier/go-toml v1.9.4 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.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/russross/blackfriday/v2 v2.1.0 // indirect github.com/spf13/cast v1.4.1 // indirect github.com/spf13/jwalterweatherman v1.1.0 // 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/numcpus v0.3.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/xerrors v0.0.0-20200804184101-5ec99f83aff1 // 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/yaml.v2 v2.4.0 // 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/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/net => github.com/drakkan/net v0.0.0-20211023135414-8d45d13382c8 + golang.org/x/net => github.com/drakkan/net v0.0.0-20211106121348-90772e49e64e ) diff --git a/go.sum b/go.sum index 47fcb867..69b627ef 100644 --- a/go.sum +++ b/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.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.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.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= @@ -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/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-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-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-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-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/cockroach-go/v2 v2.2.1 h1:nZte1DDdL9iu8IV0YPmX8l9Lg2+HRJ3CMvkT3iG52rc= 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/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 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/devigned/tab v0.1.1/go.mod h1:XG9mPq0dFghrYvoBF3xdRrJzSTX1b7IQrvaL9mzjeJY= 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/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/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/go.mod h1:kltMsfRMTHSFdMbK66XdS8mfMW77+FZA1fGY1xYMF84= 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.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.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/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.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.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= 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/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 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-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 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/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8= 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/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= 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/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.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.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 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/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/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-20200227202807-02e2044944cc/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/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/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/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= 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.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 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/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= 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.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 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= go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= 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-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-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-20181108010431-42b317875d0f/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-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-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-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-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-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-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-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/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 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.4/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-20190513163551-3ee3066db522/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-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-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.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 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.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.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/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= diff --git a/httpd/api_http_user.go b/httpd/api_http_user.go index 3c0cb912..d574b6ef 100644 --- a/httpd/api_http_user.go +++ b/httpd/api_http_user.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "mime/multipart" "net/http" "os" "path" @@ -187,17 +188,23 @@ func uploadUserFiles(w http.ResponseWriter, r *http.Request) { defer r.MultipartForm.RemoveAll() //nolint:errcheck parentDir := util.CleanPath(r.URL.Query().Get("path")) - files := r.MultipartForm.File["filename"] + files := r.MultipartForm.File["filenames"] if len(files) == 0 { - sendAPIResponse(w, r, err, "No files uploaded!", http.StatusBadRequest) + sendAPIResponse(w, r, nil, "No files uploaded!", http.StatusBadRequest) 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 { file, err := f.Open() if err != nil { sendAPIResponse(w, r, err, fmt.Sprintf("Unable to read uploaded file %#v", f.Filename), getMappedStatusCode(err)) - return + return uploaded } defer file.Close() @@ -205,21 +212,23 @@ func uploadUserFiles(w http.ResponseWriter, r *http.Request) { writer, err := connection.getFileWriter(filePath) if err != nil { sendAPIResponse(w, r, err, fmt.Sprintf("Unable to write file %#v", f.Filename), getMappedStatusCode(err)) - return + return uploaded } _, err = io.Copy(writer, file) if err != nil { writer.Close() //nolint:errcheck sendAPIResponse(w, r, err, fmt.Sprintf("Error saving file %#v", f.Filename), getMappedStatusCode(err)) - return + return uploaded } err = writer.Close() if err != nil { 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) + return uploaded } 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 = util.RemoveDuplicates(filesList) + 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) { diff --git a/httpd/api_keys.go b/httpd/api_keys.go index 3e5dc0f5..cead8ac5 100644 --- a/httpd/api_keys.go +++ b/httpd/api_keys.go @@ -55,6 +55,7 @@ func addAPIKey(w http.ResponseWriter, r *http.Request) { apiKey.ID = 0 apiKey.KeyID = "" apiKey.Key = "" + apiKey.LastUseAt = 0 err = dataprovider.AddAPIKey(&apiKey, claims.Username, util.GetIPFromRemoteAddress(r.RemoteAddr)) if err != nil { sendAPIResponse(w, r, err, "", getRespStatus(err)) diff --git a/httpd/api_maintenance.go b/httpd/api_maintenance.go index e9188ce9..17fe844b 100644 --- a/httpd/api_maintenance.go +++ b/httpd/api_maintenance.go @@ -182,6 +182,10 @@ func restoreBackup(content []byte, inputFile string, scanQuota, mode int, execut 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", len(dump.Users), len(dump.Folders), len(dump.Admins)) @@ -244,6 +248,34 @@ func RestoreFolders(folders []vfs.BaseVirtualFolder, inputFile string, mode, sca 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 func RestoreAPIKeys(apiKeys []dataprovider.APIKey, inputFile string, mode int, executor, ipAddress string) error { for _, apiKey := range apiKeys { diff --git a/httpd/api_mfa.go b/httpd/api_mfa.go index 6d822a25..03ca8472 100644 --- a/httpd/api_mfa.go +++ b/httpd/api_mfa.go @@ -6,7 +6,6 @@ import ( "strings" "github.com/go-chi/render" - "github.com/lithammer/shortuuid/v3" "github.com/drakkan/sftpgo/v2/dataprovider" "github.com/drakkan/sftpgo/v2/kms" @@ -198,7 +197,7 @@ func generateRecoveryCodes(w http.ResponseWriter, r *http.Request) { } 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 { diff --git a/httpd/api_shares.go b/httpd/api_shares.go new file mode 100644 index 00000000..fd7287ac --- /dev/null +++ b/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 +} diff --git a/httpd/api_utils.go b/httpd/api_utils.go index ad4078cc..abfe0ad9 100644 --- a/httpd/api_utils.go +++ b/httpd/api_utils.go @@ -168,7 +168,9 @@ func getSearchFilters(w http.ResponseWriter, r *http.Request) (int, int, string, 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("Accept-Ranges", "none") w.Header().Set("Content-Transfer-Encoding", "binary") @@ -179,11 +181,17 @@ func renderCompressedFiles(w http.ResponseWriter, conn *Connection, baseDir stri for _, file := range files { fullPath := path.Join(baseDir, file) if err := addZipEntry(wr, conn, fullPath, baseDir); err != nil { + if share != nil { + dataprovider.UpdateShareLastUse(share, -1) //nolint:errcheck + } panic(http.ErrAbortHandler) } } if err := wr.Close(); err != nil { conn.Log(logger.LevelWarn, "unable to close zip file: %v", err) + if share != nil { + dataprovider.UpdateShareLastUse(share, -1) //nolint:errcheck + } panic(http.ErrAbortHandler) } } diff --git a/httpd/auth_utils.go b/httpd/auth_utils.go index f67dc163..c1c923c2 100644 --- a/httpd/auth_utils.go +++ b/httpd/auth_utils.go @@ -38,8 +38,8 @@ var ( tokenDuration = 20 * time.Minute // csrf token duration is greater than normal token duration to reduce issues // with the login form - csrfTokenDuration = 6 * time.Hour - tokenRefreshMin = 10 * time.Minute + csrfTokenDuration = 6 * time.Hour + tokenRefreshThreshold = 10 * time.Minute ) type jwtTokenClaims struct { diff --git a/httpd/httpd.go b/httpd/httpd.go index f7f74b06..0354d171 100644 --- a/httpd/httpd.go +++ b/httpd/httpd.go @@ -74,10 +74,12 @@ const ( userTOTPSavePath = "/api/v2/user/totp/save" user2FARecoveryCodesPath = "/api/v2/user/2fa/recoverycodes" userProfilePath = "/api/v2/user/profile" + userSharesPath = "/api/v2/user/shares" retentionBasePath = "/api/v2/retention/users" retentionChecksPath = "/api/v2/retention/users/checks" fsEventsPath = "/api/v2/events/fs" providerEventsPath = "/api/v2/events/provider" + sharesPath = "/api/v2/shares" healthzPath = "/healthz" webRootPathDefault = "/" webBasePathDefault = "/web" @@ -116,6 +118,8 @@ const ( webClientTwoFactorPathDefault = "/web/client/twofactor" webClientTwoFactorRecoveryPathDefault = "/web/client/twofactor-recovery" webClientFilesPathDefault = "/web/client/files" + webClientSharesPathDefault = "/web/client/shares" + webClientSharePathDefault = "/web/client/share" webClientEditFilePathDefault = "/web/client/editfile" webClientDirsPathDefault = "/web/client/dirs" webClientDownloadZipPathDefault = "/web/client/downloadzip" @@ -127,6 +131,7 @@ const ( webClientRecoveryCodesPathDefault = "/web/client/recoverycodes" webChangeClientPwdPathDefault = "/web/client/changepwd" webClientLogoutPathDefault = "/web/client/logout" + webClientPubSharesPathDefault = "/web/client/pubshares" webStaticFilesPathDefault = "/static" // MaxRestoreSize defines the max size for the loaddata input file MaxRestoreSize = 10485760 // 10 MB @@ -182,6 +187,8 @@ var ( webClientTwoFactorPath string webClientTwoFactorRecoveryPath string webClientFilesPath string + webClientSharesPath string + webClientSharePath string webClientEditFilePath string webClientDirsPath string webClientDownloadZipPath string @@ -192,6 +199,7 @@ var ( webClientTOTPValidatePath string webClientTOTPSavePath string webClientRecoveryCodesPath string + webClientPubSharesPath string webClientLogoutPath string webStaticFilesPath string // max upload size for http clients, 1GB by default @@ -517,6 +525,9 @@ func updateWebClientURLs(baseURL string) { webClientTwoFactorPath = path.Join(baseURL, webClientTwoFactorPathDefault) webClientTwoFactorRecoveryPath = path.Join(baseURL, webClientTwoFactorRecoveryPathDefault) 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) webClientDirsPath = path.Join(baseURL, webClientDirsPathDefault) webClientDownloadZipPath = path.Join(baseURL, webClientDownloadZipPathDefault) diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index 57f82090..6c2cb01d 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -26,7 +26,6 @@ import ( "github.com/go-chi/render" _ "github.com/go-sql-driver/mysql" _ "github.com/lib/pq" - "github.com/lithammer/shortuuid/v3" _ "github.com/mattn/go-sqlite3" "github.com/pquerna/otp" "github.com/pquerna/otp/totp" @@ -100,9 +99,11 @@ const ( userTOTPSavePath = "/api/v2/user/totp/save" user2FARecoveryCodesPath = "/api/v2/user/2fa/recoverycodes" userProfilePath = "/api/v2/user/profile" + userSharesPath = "/api/v2/user/shares" retentionBasePath = "/api/v2/retention/users" fsEventsPath = "/api/v2/events/fs" providerEventsPath = "/api/v2/events/provider" + sharesPath = "/api/v2/shares" healthzPath = "/healthz" webBasePath = "/web" webBasePathAdmin = "/web/admin" @@ -141,6 +142,9 @@ const ( webClientLogoutPath = "/web/client/logout" webClientMFAPath = "/web/client/mfa" webClientTOTPSavePath = "/web/client/totp/save" + webClientSharesPath = "/web/client/shares" + webClientSharePath = "/web/client/share" + webClientPubSharesPath = "/web/client/pubshares" httpBaseURL = "http://127.0.0.1:8081" sftpServerAddr = "127.0.0.1:8022" configDir = ".." @@ -379,7 +383,7 @@ func TestMain(m *testing.M) { defer testServer.Close() exitCode := m.Run() - os.Remove(logfilePath) + //os.Remove(logfilePath) os.RemoveAll(backupsPath) os.RemoveAll(credentialsPath) os.Remove(certPath) @@ -749,7 +753,7 @@ func TestPermMFADisabled(t *testing.T) { user.Filters.RecoveryCodes = []sdk.RecoveryCode{ { - Secret: kms.NewPlainSecret(shortuuid.New()), + Secret: kms.NewPlainSecret(util.GenerateUniqueID()), }, } user, resp, err = httpdtest.UpdateUser(user, http.StatusOK, "") @@ -3742,6 +3746,14 @@ func TestQuotaTrackingDisabled(t *testing.T) { } func TestProviderErrors(t *testing.T) { + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + userAPIToken, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + userWebToken, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) token, _, err := httpdtest.GetToken(defaultTokenAuthUser, defaultTokenAuthPass) assert.NoError(t, err) testServerToken, err := getJWTWebTokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass) @@ -3757,6 +3769,30 @@ func TestProviderErrors(t *testing.T) { assert.NoError(t, err) _, _, err = httpdtest.GetAPIKeys(1, 0, http.StatusInternalServerError) assert.NoError(t, err) + req, err := http.NewRequest(http.MethodGet, userSharesPath, nil) + assert.NoError(t, err) + setBearerForReq(req, userAPIToken) + rr := executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) + + req, err = http.NewRequest(http.MethodGet, webClientSharesPath, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, userWebToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) + + req, err = http.NewRequest(http.MethodGet, webClientSharePath+"/shareID", nil) + assert.NoError(t, err) + setJWTCookieForReq(req, userWebToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) + + req, err = http.NewRequest(http.MethodPost, webClientSharePath+"/shareID", nil) + assert.NoError(t, err) + setJWTCookieForReq(req, userWebToken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) + _, _, err = httpdtest.UpdateUser(dataprovider.User{BaseUser: sdk.BaseUser{Username: "auser"}}, http.StatusInternalServerError, "") assert.NoError(t, err) _, err = httpdtest.RemoveUser(dataprovider.User{BaseUser: sdk.BaseUser{Username: "auser"}}, http.StatusInternalServerError) @@ -3771,7 +3807,7 @@ func TestProviderErrors(t *testing.T) { assert.NoError(t, err) _, _, err = httpdtest.GetFolders(0, 0, http.StatusInternalServerError) assert.NoError(t, err) - user := getTestUser() + user = getTestUser() user.ID = 1 backupData := dataprovider.BackupData{} backupData.Users = append(backupData.Users, user) @@ -3803,8 +3839,8 @@ func TestProviderErrors(t *testing.T) { backupData.Admins = nil backupData.APIKeys = append(backupData.APIKeys, dataprovider.APIKey{ Name: "name", - KeyID: shortuuid.New(), - Key: fmt.Sprintf("%v.%v", shortuuid.New(), shortuuid.New()), + KeyID: util.GenerateUniqueID(), + Key: fmt.Sprintf("%v.%v", util.GenerateUniqueID(), util.GenerateUniqueID()), Scope: dataprovider.APIKeyScopeUser, }) backupContent, err = json.Marshal(backupData) @@ -3813,12 +3849,26 @@ func TestProviderErrors(t *testing.T) { assert.NoError(t, err) _, _, err = httpdtest.Loaddata(backupFilePath, "", "", http.StatusInternalServerError) assert.NoError(t, err) + backupData.APIKeys = nil + backupData.Shares = append(backupData.Shares, dataprovider.Share{ + Name: util.GenerateUniqueID(), + ShareID: util.GenerateUniqueID(), + Scope: dataprovider.ShareScopeRead, + Paths: []string{"/"}, + Username: defaultUsername, + }) + backupContent, err = json.Marshal(backupData) + assert.NoError(t, err) + err = os.WriteFile(backupFilePath, backupContent, os.ModePerm) + assert.NoError(t, err) + _, _, err = httpdtest.Loaddata(backupFilePath, "", "", http.StatusInternalServerError) + assert.NoError(t, err) err = os.Remove(backupFilePath) assert.NoError(t, err) - req, err := http.NewRequest(http.MethodGet, webUserPath, nil) + req, err = http.NewRequest(http.MethodGet, webUserPath, nil) assert.NoError(t, err) setJWTCookieForReq(req, testServerToken) - rr := executeRequest(req) + rr = executeRequest(req) checkResponseCode(t, http.StatusInternalServerError, rr) req, err = http.NewRequest(http.MethodGet, webUserPath+"?clone-from=user", nil) assert.NoError(t, err) @@ -3971,6 +4021,10 @@ func TestDumpdata(t *testing.T) { assert.True(t, ok) _, ok = response["folders"] assert.True(t, ok) + _, ok = response["api_keys"] + assert.True(t, ok) + _, ok = response["shares"] + assert.True(t, ok) _, ok = response["version"] assert.True(t, ok) _, rawResp, err = httpdtest.Dumpdata("backup.json", "", "1", http.StatusOK) @@ -4147,6 +4201,7 @@ func TestLoaddataFromPostBody(t *testing.T) { }, } backupData.APIKeys = append(backupData.APIKeys, dataprovider.APIKey{}) + backupData.Shares = append(backupData.Shares, dataprovider.Share{}) backupContent, err := json.Marshal(backupData) assert.NoError(t, err) _, _, err = httpdtest.LoaddataFromPostBody(nil, "0", "0", http.StatusBadRequest) @@ -4158,13 +4213,22 @@ func TestLoaddataFromPostBody(t *testing.T) { _, _, err = httpdtest.LoaddataFromPostBody(backupContent, "0", "0", http.StatusInternalServerError) assert.NoError(t, err) - keyID := shortuuid.New() + keyID := util.GenerateUniqueID() backupData.APIKeys = []dataprovider.APIKey{ { Name: "test key", Scope: dataprovider.APIKeyScopeAdmin, KeyID: keyID, - Key: fmt.Sprintf("%v.%v", shortuuid.New(), shortuuid.New()), + Key: fmt.Sprintf("%v.%v", util.GenerateUniqueID(), util.GenerateUniqueID()), + }, + } + backupData.Shares = []dataprovider.Share{ + { + ShareID: keyID, + Name: keyID, + Scope: dataprovider.ShareScopeWrite, + Paths: []string{"/"}, + Username: user.Username, }, } backupContent, err = json.Marshal(backupData) @@ -4173,6 +4237,8 @@ func TestLoaddataFromPostBody(t *testing.T) { assert.NoError(t, err) user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK) assert.NoError(t, err) + _, err = dataprovider.ShareExists(keyID, user.Username) + assert.NoError(t, err) _, err = httpdtest.RemoveUser(user, http.StatusOK) assert.NoError(t, err) @@ -4207,10 +4273,17 @@ func TestLoaddata(t *testing.T) { admin.ID = 1 admin.Username = "test_admin_restore" apiKey := dataprovider.APIKey{ - Name: shortuuid.New(), + Name: util.GenerateUniqueID(), Scope: dataprovider.APIKeyScopeAdmin, - KeyID: shortuuid.New(), - Key: fmt.Sprintf("%v.%v", shortuuid.New(), shortuuid.New()), + KeyID: util.GenerateUniqueID(), + Key: fmt.Sprintf("%v.%v", util.GenerateUniqueID(), util.GenerateUniqueID()), + } + share := dataprovider.Share{ + ShareID: util.GenerateUniqueID(), + Name: util.GenerateUniqueID(), + Scope: dataprovider.ShareScopeRead, + Paths: []string{"/"}, + Username: user.Username, } backupData := dataprovider.BackupData{} backupData.Users = append(backupData.Users, user) @@ -4231,6 +4304,7 @@ func TestLoaddata(t *testing.T) { }, } backupData.APIKeys = append(backupData.APIKeys, apiKey) + backupData.Shares = append(backupData.Shares, share) backupContent, err := json.Marshal(backupData) assert.NoError(t, err) backupFilePath := filepath.Join(backupsPath, "backup.json") @@ -4252,7 +4326,7 @@ func TestLoaddata(t *testing.T) { err = os.Chmod(backupFilePath, 0644) assert.NoError(t, err) } - // add user, folder, admin, API key from backup + // add user, folder, admin, API key, share from backup _, _, err = httpdtest.Loaddata(backupFilePath, "1", "", http.StatusOK) assert.NoError(t, err) // update from backup @@ -4260,6 +4334,8 @@ func TestLoaddata(t *testing.T) { assert.NoError(t, err) user, _, err = httpdtest.GetUserByUsername(user.Username, http.StatusOK) assert.NoError(t, err) + _, err = dataprovider.ShareExists(share.ShareID, user.Username) + assert.NoError(t, err) _, err = httpdtest.RemoveUser(user, http.StatusOK) assert.NoError(t, err) @@ -4309,12 +4385,19 @@ func TestLoaddataMode(t *testing.T) { admin.ID = 1 admin.Username = "test_admin_restore" apiKey := dataprovider.APIKey{ - Name: shortuuid.New(), + Name: util.GenerateUniqueID(), Scope: dataprovider.APIKeyScopeAdmin, - KeyID: shortuuid.New(), - Key: fmt.Sprintf("%v.%v", shortuuid.New(), shortuuid.New()), + KeyID: util.GenerateUniqueID(), + Key: fmt.Sprintf("%v.%v", util.GenerateUniqueID(), util.GenerateUniqueID()), Description: "desc", } + share := dataprovider.Share{ + ShareID: util.GenerateUniqueID(), + Name: util.GenerateUniqueID(), + Scope: dataprovider.ShareScopeRead, + Paths: []string{"/"}, + Username: user.Username, + } backupData := dataprovider.BackupData{} backupData.Users = append(backupData.Users, user) backupData.Admins = append(backupData.Admins, admin) @@ -4333,6 +4416,7 @@ func TestLoaddataMode(t *testing.T) { }, } backupData.APIKeys = append(backupData.APIKeys, apiKey) + backupData.Shares = append(backupData.Shares, share) backupContent, _ := json.Marshal(backupData) backupFilePath := filepath.Join(backupsPath, "backup.json") err := os.WriteFile(backupFilePath, backupContent, os.ModePerm) @@ -4367,6 +4451,9 @@ func TestLoaddataMode(t *testing.T) { apiKey.Description = "new desc" apiKey, _, err = httpdtest.UpdateAPIKey(apiKey, http.StatusOK) assert.NoError(t, err) + share.Description = "test desc" + err = dataprovider.UpdateShare(&share, "", "") + assert.NoError(t, err) backupData.Folders = []vfs.BaseVirtualFolder{ { @@ -4403,6 +4490,10 @@ func TestLoaddataMode(t *testing.T) { assert.NotEqual(t, int64(0), apiKey.ExpiresAt) assert.NotEqual(t, oldAPIKeyDesc, apiKey.Description) + share, err = dataprovider.ShareExists(share.ShareID, user.Username) + assert.NoError(t, err) + assert.NotEmpty(t, share.Description) + _, _, err = httpdtest.Loaddata(backupFilePath, "0", "2", http.StatusOK) assert.NoError(t, err) // mode 2 will update the user and close the previous connection @@ -5065,7 +5156,7 @@ func TestAdminTOTP(t *testing.T) { assert.Equal(t, kms.SecretStatusSecretBox, admin.Filters.TOTPConfig.Secret.GetStatus()) admin.Filters.TOTPConfig = dataprovider.TOTPConfig{ Enabled: false, - ConfigName: shortuuid.New(), + ConfigName: util.GenerateUniqueID(), Secret: kms.NewEmptySecret(), } admin.Filters.RecoveryCodes = []sdk.RecoveryCode{ @@ -6544,13 +6635,15 @@ func TestAdminLastLoginWithAPIKey(t *testing.T) { assert.Equal(t, int64(0), admin.LastLogin) apiKey := dataprovider.APIKey{ - Name: "admin API key", - Scope: dataprovider.APIKeyScopeAdmin, - Admin: altAdminUsername, + Name: "admin API key", + Scope: dataprovider.APIKeyScopeAdmin, + Admin: altAdminUsername, + LastUseAt: 123, } apiKey, resp, err = httpdtest.AddAPIKey(apiKey, http.StatusCreated) assert.NoError(t, err, string(resp)) + assert.Equal(t, int64(0), apiKey.LastUseAt) req, err := http.NewRequest(http.MethodGet, versionPath, nil) assert.NoError(t, err) @@ -8293,7 +8386,7 @@ func TestPreUploadHook(t *testing.T) { body := new(bytes.Buffer) writer := multipart.NewWriter(body) - part, err := writer.CreateFormFile("filename", "filepre") + part, err := writer.CreateFormFile("filenames", "filepre") assert.NoError(t, err) _, err = part.Write([]byte("file content")) assert.NoError(t, err) @@ -8329,6 +8422,573 @@ func TestPreUploadHook(t *testing.T) { common.Config.Actions.Hook = oldHook } +func TestShareUsage(t *testing.T) { + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + + testFileName := "testfile.dat" + testFileSize := int64(65536) + testFilePath := filepath.Join(user.GetHomeDir(), testFileName) + err = createTestFile(testFilePath, testFileSize) + assert.NoError(t, err) + + token, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + + share := dataprovider.Share{ + Name: "test share", + Scope: dataprovider.ShareScopeRead, + Paths: []string{"/"}, + Password: defaultPassword, + MaxTokens: 2, + ExpiresAt: util.GetTimeAsMsSinceEpoch(time.Now().Add(1 * time.Second)), + } + asJSON, err := json.Marshal(share) + assert.NoError(t, err) + req, err := http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr := executeRequest(req) + checkResponseCode(t, http.StatusCreated, rr) + objectID := rr.Header().Get("X-Object-ID") + assert.NotEmpty(t, objectID) + + req, err = http.NewRequest(http.MethodGet, sharesPath+"/unknownid", nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodGet, sharesPath+"/"+objectID, nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusUnauthorized, rr) + + req.SetBasicAuth(defaultUsername, "wrong password") + rr = executeRequest(req) + checkResponseCode(t, http.StatusUnauthorized, rr) + + req.SetBasicAuth(defaultUsername, defaultPassword) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + time.Sleep(2 * time.Second) + + req, err = http.NewRequest(http.MethodGet, webClientPubSharesPath+"/"+objectID, nil) + assert.NoError(t, err) + req.SetBasicAuth(defaultUsername, defaultPassword) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + share.ExpiresAt = 0 + jsonReq := make(map[string]interface{}) + jsonReq["name"] = share.Name + jsonReq["scope"] = share.Scope + jsonReq["paths"] = share.Paths + jsonReq["password"] = share.Password + jsonReq["max_tokens"] = share.MaxTokens + jsonReq["expires_at"] = share.ExpiresAt + asJSON, err = json.Marshal(jsonReq) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPut, userSharesPath+"/"+objectID, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + req, err = http.NewRequest(http.MethodGet, webClientPubSharesPath+"/"+objectID, nil) + assert.NoError(t, err) + req.SetBasicAuth(defaultUsername, defaultPassword) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodPost, sharesPath+"/"+objectID, nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "Invalid share scope") + + share.MaxTokens = 3 + share.Scope = dataprovider.ShareScopeWrite + asJSON, err = json.Marshal(share) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPut, userSharesPath+"/"+objectID, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + body := new(bytes.Buffer) + writer := multipart.NewWriter(body) + part1, err := writer.CreateFormFile("filenames", "file1.txt") + assert.NoError(t, err) + _, err = part1.Write([]byte("file1 content")) + assert.NoError(t, err) + part2, err := writer.CreateFormFile("filenames", "file2.txt") + assert.NoError(t, err) + _, err = part2.Write([]byte("file2 content")) + assert.NoError(t, err) + err = writer.Close() + assert.NoError(t, err) + reader := bytes.NewReader(body.Bytes()) + + req, err = http.NewRequest(http.MethodPost, sharesPath+"/"+objectID, reader) + assert.NoError(t, err) + req.SetBasicAuth(defaultUsername, defaultPassword) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "Unable to parse multipart form") + + _, err = reader.Seek(0, io.SeekStart) + assert.NoError(t, err) + // set the proper content type + req, err = http.NewRequest(http.MethodPost, sharesPath+"/"+objectID, reader) + assert.NoError(t, err) + req.Header.Add("Content-Type", writer.FormDataContentType()) + req.SetBasicAuth(defaultUsername, defaultPassword) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "Allowed usage exceeded") + + share.MaxTokens = 6 + share.Scope = dataprovider.ShareScopeWrite + asJSON, err = json.Marshal(share) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPut, userSharesPath+"/"+objectID, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + _, err = reader.Seek(0, io.SeekStart) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, sharesPath+"/"+objectID, reader) + assert.NoError(t, err) + req.Header.Add("Content-Type", writer.FormDataContentType()) + req.SetBasicAuth(defaultUsername, defaultPassword) + rr = executeRequest(req) + checkResponseCode(t, http.StatusCreated, rr) + + _, err = reader.Seek(0, io.SeekStart) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, webClientPubSharesPath+"/"+objectID, reader) + assert.NoError(t, err) + req.Header.Add("Content-Type", writer.FormDataContentType()) + req.SetBasicAuth(defaultUsername, defaultPassword) + rr = executeRequest(req) + checkResponseCode(t, http.StatusCreated, rr) + + share, err = dataprovider.ShareExists(objectID, user.Username) + assert.NoError(t, err) + assert.Equal(t, 6, share.UsedTokens) + + _, err = reader.Seek(0, io.SeekStart) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, sharesPath+"/"+objectID, reader) + assert.NoError(t, err) + req.Header.Add("Content-Type", writer.FormDataContentType()) + req.SetBasicAuth(defaultUsername, defaultPassword) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + share.MaxTokens = 0 + err = dataprovider.UpdateShare(&share, user.Username, "") + assert.NoError(t, err) + + user.Permissions["/"] = []string{dataprovider.PermListItems, dataprovider.PermDownload} + user, _, err = httpdtest.UpdateUser(user, http.StatusOK, "") + assert.NoError(t, err) + + _, err = reader.Seek(0, io.SeekStart) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, sharesPath+"/"+objectID, reader) + assert.NoError(t, err) + req.Header.Add("Content-Type", writer.FormDataContentType()) + req.SetBasicAuth(defaultUsername, defaultPassword) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr) + assert.Contains(t, rr.Body.String(), "permission denied") + + body = new(bytes.Buffer) + writer = multipart.NewWriter(body) + part, err := writer.CreateFormFile("filename", "file1.txt") + assert.NoError(t, err) + _, err = part.Write([]byte("file content")) + assert.NoError(t, err) + err = writer.Close() + assert.NoError(t, err) + reader = bytes.NewReader(body.Bytes()) + + req, err = http.NewRequest(http.MethodPost, sharesPath+"/"+objectID, reader) + assert.NoError(t, err) + req.Header.Add("Content-Type", writer.FormDataContentType()) + req.SetBasicAuth(defaultUsername, defaultPassword) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "No files uploaded!") + + share.Scope = dataprovider.ShareScopeRead + share.Paths = []string{"/missing"} + err = dataprovider.UpdateShare(&share, user.Username, "") + assert.NoError(t, err) + + defer func() { + rcv := recover() + assert.Equal(t, http.ErrAbortHandler, rcv) + + share, err = dataprovider.ShareExists(objectID, user.Username) + assert.NoError(t, err) + assert.Equal(t, 6, share.UsedTokens) + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + }() + + req, err = http.NewRequest(http.MethodGet, sharesPath+"/"+objectID, nil) + assert.NoError(t, err) + req.SetBasicAuth(defaultUsername, defaultPassword) + executeRequest(req) +} + +func TestUserAPIShareErrors(t *testing.T) { + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + token, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + + share := dataprovider.Share{ + Scope: 1000, + } + asJSON, err := json.Marshal(share) + assert.NoError(t, err) + + req, err := http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr := executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "invalid scope") + // invalid json + req, err = http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer([]byte("{"))) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + + share.Scope = dataprovider.ShareScopeWrite + asJSON, err = json.Marshal(share) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "at least a shared path is required") + + share.Paths = []string{"path1", "../path1", "/path2"} + asJSON, err = json.Marshal(share) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "the write share scope requires exactly one path") + + share.Paths = []string{"", ""} + asJSON, err = json.Marshal(share) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "at least a shared path is required") + + share.Paths = []string{"path1", "../path1", "/path1"} + share.Password = redactedSecret + asJSON, err = json.Marshal(share) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "cannot save a share with a redacted password") + + share.Password = "newpass" + share.AllowFrom = []string{"not valid"} + asJSON, err = json.Marshal(share) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "could not parse allow from entry") + + share.AllowFrom = []string{"127.0.0.1/8"} + share.ExpiresAt = util.GetTimeAsMsSinceEpoch(time.Now().Add(-12 * time.Hour)) + asJSON, err = json.Marshal(share) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "expiration must be in the future") + + share.ExpiresAt = util.GetTimeAsMsSinceEpoch(time.Now().Add(12 * time.Hour)) + asJSON, err = json.Marshal(share) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusCreated, rr) + location := rr.Header().Get("Location") + + asJSON, err = json.Marshal(share) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPut, location, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "name is mandatory") + // invalid json + req, err = http.NewRequest(http.MethodPut, location, bytes.NewBuffer([]byte("}"))) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + + req, err = http.NewRequest(http.MethodGet, userSharesPath+"?limit=a", nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + +func TestUserAPIShares(t *testing.T) { + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + token, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + + u := getTestUser() + u.Username = altAdminUsername + user1, _, err := httpdtest.AddUser(u, http.StatusCreated) + assert.NoError(t, err) + token1, err := getJWTAPIUserTokenFromTestServer(user1.Username, defaultPassword) + assert.NoError(t, err) + + // the share username will be set from + share := dataprovider.Share{ + Name: "share1", + Description: "description1", + Scope: dataprovider.ShareScopeRead, + Paths: []string{"/"}, + CreatedAt: 1, + UpdatedAt: 2, + LastUseAt: 3, + ExpiresAt: util.GetTimeAsMsSinceEpoch(time.Now().Add(2 * time.Hour)), + Password: defaultPassword, + MaxTokens: 10, + UsedTokens: 2, + AllowFrom: []string{"192.168.1.0/24"}, + } + asJSON, err := json.Marshal(share) + assert.NoError(t, err) + + req, err := http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr := executeRequest(req) + checkResponseCode(t, http.StatusCreated, rr) + location := rr.Header().Get("Location") + assert.NotEmpty(t, location) + objectID := rr.Header().Get("X-Object-ID") + assert.NotEmpty(t, objectID) + assert.Equal(t, fmt.Sprintf("%v/%v", userSharesPath, objectID), location) + + req, err = http.NewRequest(http.MethodGet, location, nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + var shareGet dataprovider.Share + err = json.Unmarshal(rr.Body.Bytes(), &shareGet) + assert.NoError(t, err) + assert.Equal(t, objectID, shareGet.ShareID) + assert.Equal(t, share.Name, shareGet.Name) + assert.Equal(t, share.Description, shareGet.Description) + assert.Equal(t, share.Scope, shareGet.Scope) + assert.Equal(t, share.Paths, shareGet.Paths) + assert.Equal(t, int64(0), shareGet.LastUseAt) + assert.Greater(t, shareGet.CreatedAt, share.CreatedAt) + assert.Greater(t, shareGet.UpdatedAt, share.UpdatedAt) + assert.Equal(t, share.ExpiresAt, shareGet.ExpiresAt) + assert.Equal(t, share.MaxTokens, shareGet.MaxTokens) + assert.Equal(t, 0, shareGet.UsedTokens) + assert.Equal(t, share.Paths, shareGet.Paths) + assert.Equal(t, redactedSecret, shareGet.Password) + + req, err = http.NewRequest(http.MethodGet, location, nil) + assert.NoError(t, err) + setBearerForReq(req, token1) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + s, err := dataprovider.ShareExists(objectID, defaultUsername) + assert.NoError(t, err) + match, err := s.CheckPassword(defaultPassword) + assert.True(t, match) + assert.NoError(t, err) + match, err = s.CheckPassword(defaultPassword + "mod") + assert.False(t, match) + assert.Error(t, err) + + shareGet.ExpiresAt = util.GetTimeAsMsSinceEpoch(time.Now().Add(3 * time.Hour)) + asJSON, err = json.Marshal(shareGet) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPut, location, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + s, err = dataprovider.ShareExists(objectID, defaultUsername) + assert.NoError(t, err) + match, err = s.CheckPassword(defaultPassword) + assert.True(t, match) + assert.NoError(t, err) + match, err = s.CheckPassword(defaultPassword + "mod") + assert.False(t, match) + assert.Error(t, err) + + req, err = http.NewRequest(http.MethodGet, location, nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + var shareGetNew dataprovider.Share + err = json.Unmarshal(rr.Body.Bytes(), &shareGetNew) + assert.NoError(t, err) + assert.NotEqual(t, shareGet.UpdatedAt, shareGetNew.UpdatedAt) + shareGet.UpdatedAt = shareGetNew.UpdatedAt + assert.Equal(t, shareGet, shareGetNew) + + req, err = http.NewRequest(http.MethodGet, userSharesPath, nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + var shares []dataprovider.Share + err = json.Unmarshal(rr.Body.Bytes(), &shares) + assert.NoError(t, err) + if assert.Len(t, shares, 1) { + assert.Equal(t, shareGetNew, shares[0]) + } + + err = dataprovider.UpdateShareLastUse(&shareGetNew, 2) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodGet, location, nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + shareGetNew = dataprovider.Share{} + err = json.Unmarshal(rr.Body.Bytes(), &shareGetNew) + assert.NoError(t, err) + assert.Equal(t, 2, shareGetNew.UsedTokens, "share: %v", shareGetNew) + assert.Greater(t, shareGetNew.LastUseAt, int64(0), "share: %v", shareGetNew) + + req, err = http.NewRequest(http.MethodGet, userSharesPath, nil) + assert.NoError(t, err) + setBearerForReq(req, token1) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + shares = nil + err = json.Unmarshal(rr.Body.Bytes(), &shares) + assert.NoError(t, err) + assert.Len(t, shares, 0) + + // set an empty password + shareGet.Password = "" + asJSON, err = json.Marshal(shareGet) + assert.NoError(t, err) + req, err = http.NewRequest(http.MethodPut, location, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + req, err = http.NewRequest(http.MethodGet, location, nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + shareGetNew = dataprovider.Share{} + err = json.Unmarshal(rr.Body.Bytes(), &shareGetNew) + assert.NoError(t, err) + assert.Empty(t, shareGetNew.Password) + + req, err = http.NewRequest(http.MethodDelete, location, nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + share.Name = "" + asJSON, err = json.Marshal(share) + assert.NoError(t, err) + + req, err = http.NewRequest(http.MethodPost, userSharesPath, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusCreated, rr) + location = rr.Header().Get("Location") + + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + // the share should be deleted with the associated user + req, err = http.NewRequest(http.MethodGet, location, nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodPut, location, bytes.NewBuffer(asJSON)) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodDelete, location, nil) + assert.NoError(t, err) + setBearerForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + _, err = httpdtest.RemoveUser(user1, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user1.GetHomeDir()) + assert.NoError(t, err) +} + func TestUserAPIKey(t *testing.T) { u := getTestUser() u.Filters.AllowAPIKeyAuth = true @@ -8336,15 +8996,18 @@ func TestUserAPIKey(t *testing.T) { assert.NoError(t, err) apiKey := dataprovider.APIKey{ Name: "testkey", - User: user.Username, + User: user.Username + "1", Scope: dataprovider.APIKeyScopeUser, } + _, _, err = httpdtest.AddAPIKey(apiKey, http.StatusBadRequest) + assert.NoError(t, err) + apiKey.User = user.Username apiKey, _, err = httpdtest.AddAPIKey(apiKey, http.StatusCreated) assert.NoError(t, err) body := new(bytes.Buffer) writer := multipart.NewWriter(body) - part, err := writer.CreateFormFile("filename", "filenametest") + part, err := writer.CreateFormFile("filenames", "filenametest") assert.NoError(t, err) _, err = part.Write([]byte("test file content")) assert.NoError(t, err) @@ -8918,11 +9581,11 @@ func TestWebFilesAPI(t *testing.T) { body := new(bytes.Buffer) writer := multipart.NewWriter(body) - part1, err := writer.CreateFormFile("filename", "file1.txt") + part1, err := writer.CreateFormFile("filenames", "file1.txt") assert.NoError(t, err) _, err = part1.Write([]byte("file1 content")) assert.NoError(t, err) - part2, err := writer.CreateFormFile("filename", "file2.txt") + part2, err := writer.CreateFormFile("filenames", "file2.txt") assert.NoError(t, err) _, err = part2.Write([]byte("file2 content")) assert.NoError(t, err) @@ -9117,7 +9780,7 @@ func TestWebUploadErrors(t *testing.T) { body := new(bytes.Buffer) writer := multipart.NewWriter(body) - part, err := writer.CreateFormFile("filename", "file.zip") + part, err := writer.CreateFormFile("filenames", "file.zip") assert.NoError(t, err) _, err = part.Write([]byte("file content")) assert.NoError(t, err) @@ -9279,7 +9942,7 @@ func TestWebAPIVFolder(t *testing.T) { body := new(bytes.Buffer) writer := multipart.NewWriter(body) - part, err := writer.CreateFormFile("filename", "file.txt") + part, err := writer.CreateFormFile("filenames", "file.txt") assert.NoError(t, err) _, err = part.Write(fileContents) assert.NoError(t, err) @@ -9339,7 +10002,7 @@ func TestWebAPIWritePermission(t *testing.T) { body := new(bytes.Buffer) writer := multipart.NewWriter(body) - part, err := writer.CreateFormFile("filename", "file.txt") + part, err := writer.CreateFormFile("filenames", "file.txt") assert.NoError(t, err) _, err = part.Write([]byte("")) assert.NoError(t, err) @@ -9421,7 +10084,7 @@ func TestWebAPICryptFs(t *testing.T) { body := new(bytes.Buffer) writer := multipart.NewWriter(body) - part, err := writer.CreateFormFile("filename", "file.txt") + part, err := writer.CreateFormFile("filenames", "file.txt") assert.NoError(t, err) _, err = part.Write([]byte("content")) assert.NoError(t, err) @@ -9467,7 +10130,7 @@ func TestWebUploadSFTP(t *testing.T) { body := new(bytes.Buffer) writer := multipart.NewWriter(body) - part, err := writer.CreateFormFile("filename", "file.txt") + part, err := writer.CreateFormFile("filenames", "file.txt") assert.NoError(t, err) _, err = part.Write([]byte("test file content")) assert.NoError(t, err) @@ -9541,7 +10204,7 @@ func TestWebUploadMultipartFormReadError(t *testing.T) { mpartForm := &multipart.Form{ File: make(map[string][]*multipart.FileHeader), } - mpartForm.File["filename"] = append(mpartForm.File["filename"], &multipart.FileHeader{Filename: "missing"}) + mpartForm.File["filenames"] = append(mpartForm.File["filenames"], &multipart.FileHeader{Filename: "missing"}) req.MultipartForm = mpartForm req.Header.Add("Content-Type", "multipart/form-data") setBearerForReq(req, webAPIToken) @@ -9708,7 +10371,7 @@ func TestClientUserClose(t *testing.T) { defer wg.Done() body := new(bytes.Buffer) writer := multipart.NewWriter(body) - part, err := writer.CreateFormFile("filename", "upload.dat") + part, err := writer.CreateFormFile("filenames", "upload.dat") assert.NoError(t, err) n, err := part.Write(uploadContent) assert.NoError(t, err) @@ -9996,6 +10659,225 @@ func TestAdminNoToken(t *testing.T) { checkResponseCode(t, http.StatusUnauthorized, rr) } +func TestWebUserShare(t *testing.T) { + user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) + assert.NoError(t, err) + + csrfToken, err := getCSRFToken(httpBaseURL + webClientLoginPath) + assert.NoError(t, err) + token, err := getJWTWebClientTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + userAPItoken, err := getJWTAPIUserTokenFromTestServer(defaultUsername, defaultPassword) + assert.NoError(t, err) + + share := dataprovider.Share{ + Name: "test share", + Description: "test share desc", + Scope: dataprovider.ShareScopeRead, + Paths: []string{"/"}, + ExpiresAt: util.GetTimeAsMsSinceEpoch(time.Now().Add(24 * time.Hour)), + MaxTokens: 100, + AllowFrom: []string{"127.0.0.0/8", "172.16.0.0/16"}, + Password: defaultPassword, + } + form := make(url.Values) + form.Set("name", share.Name) + form.Set("scope", strconv.Itoa(int(share.Scope))) + form.Set("paths", "/") + form.Set("max_tokens", strconv.Itoa(share.MaxTokens)) + form.Set("allowed_ip", strings.Join(share.AllowFrom, ",")) + form.Set("description", share.Description) + form.Set("password", share.Password) + form.Set("expiration_date", "123") + // invalid expiration date + req, err := http.NewRequest(http.MethodPost, webClientSharePath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, token) + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "cannot parse") + form.Set("expiration_date", util.GetTimeFromMsecSinceEpoch(share.ExpiresAt).UTC().Format("2006-01-02 15:04:05")) + form.Set("scope", "") + // invalid scope + req, err = http.NewRequest(http.MethodPost, webClientSharePath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "invalid syntax") + form.Set("scope", strconv.Itoa(int(share.Scope))) + // invalid max tokens + form.Set("max_tokens", "t") + req, err = http.NewRequest(http.MethodPost, webClientSharePath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "invalid syntax") + form.Set("max_tokens", strconv.Itoa(share.MaxTokens)) + // no csrf token + req, err = http.NewRequest(http.MethodPost, webClientSharePath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "unable to verify form token") + + form.Set(csrfFormToken, csrfToken) + form.Set("scope", "100") + req, err = http.NewRequest(http.MethodPost, webClientSharePath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "Validation error: invalid scope") + + form.Set("scope", strconv.Itoa(int(share.Scope))) + req, err = http.NewRequest(http.MethodPost, webClientSharePath, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusSeeOther, rr) + + req, err = http.NewRequest(http.MethodGet, userSharesPath, nil) + assert.NoError(t, err) + setBearerForReq(req, userAPItoken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + var shares []dataprovider.Share + err = json.Unmarshal(rr.Body.Bytes(), &shares) + assert.NoError(t, err) + if assert.Len(t, shares, 1) { + s := shares[0] + assert.Equal(t, share.Name, s.Name) + assert.Equal(t, share.Description, s.Description) + assert.Equal(t, share.Scope, s.Scope) + assert.Equal(t, share.Paths, s.Paths) + assert.InDelta(t, share.ExpiresAt, s.ExpiresAt, 999) + assert.Equal(t, share.MaxTokens, s.MaxTokens) + assert.Equal(t, share.AllowFrom, s.AllowFrom) + assert.Equal(t, redactedSecret, s.Password) + share.ShareID = s.ShareID + } + form.Set("password", redactedSecret) + form.Set("expiration_date", "123") + req, err = http.NewRequest(http.MethodPost, webClientSharePath+"/unknowid", bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodPost, webClientSharePath+"/"+share.ShareID, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "cannot parse") + + form.Set("expiration_date", "") + form.Set(csrfFormToken, "") + req, err = http.NewRequest(http.MethodPost, webClientSharePath+"/"+share.ShareID, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusForbidden, rr) + assert.Contains(t, rr.Body.String(), "unable to verify form token") + + form.Set(csrfFormToken, csrfToken) + form.Set("allowed_ip", "1.1.1") + req, err = http.NewRequest(http.MethodPost, webClientSharePath+"/"+share.ShareID, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + assert.Contains(t, rr.Body.String(), "Validation error: could not parse allow from entry") + + form.Set("allowed_ip", "") + req, err = http.NewRequest(http.MethodPost, webClientSharePath+"/"+share.ShareID, bytes.NewBuffer([]byte(form.Encode()))) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + setJWTCookieForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusSeeOther, rr) + + req, err = http.NewRequest(http.MethodGet, userSharesPath, nil) + assert.NoError(t, err) + setBearerForReq(req, userAPItoken) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + shares = nil + err = json.Unmarshal(rr.Body.Bytes(), &shares) + assert.NoError(t, err) + if assert.Len(t, shares, 1) { + s := shares[0] + assert.Equal(t, share.Name, s.Name) + assert.Equal(t, share.Description, s.Description) + assert.Equal(t, share.Scope, s.Scope) + assert.Equal(t, share.Paths, s.Paths) + assert.Equal(t, int64(0), s.ExpiresAt) + assert.Equal(t, share.MaxTokens, s.MaxTokens) + assert.Empty(t, s.AllowFrom) + } + // check the password + s, err := dataprovider.ShareExists(share.ShareID, user.Username) + assert.NoError(t, err) + match, err := s.CheckPassword(defaultPassword) + assert.NoError(t, err) + assert.True(t, match) + + req, err = http.NewRequest(http.MethodGet, webClientSharePath+"?path=%2F&files=a", nil) + assert.NoError(t, err) + setJWTCookieForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr) + assert.Contains(t, rr.Body.String(), "Invalid share list") + + req, err = http.NewRequest(http.MethodGet, webClientSharePath+"?path=%2F&files=%5B\"adir\"%5D", nil) + assert.NoError(t, err) + setJWTCookieForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + req, err = http.NewRequest(http.MethodGet, webClientSharePath+"/unknown", nil) + assert.NoError(t, err) + setJWTCookieForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr) + + req, err = http.NewRequest(http.MethodGet, webClientSharePath+"/"+share.ShareID, nil) + assert.NoError(t, err) + setJWTCookieForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + req, err = http.NewRequest(http.MethodGet, webClientSharesPath+"?qlimit=a", nil) + assert.NoError(t, err) + setJWTCookieForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + req, err = http.NewRequest(http.MethodGet, webClientSharesPath+"?qlimit=1", nil) + assert.NoError(t, err) + setJWTCookieForReq(req, token) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr) + + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + _, err = httpdtest.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) +} + func TestWebUserProfile(t *testing.T) { user, _, err := httpdtest.AddUser(getTestUser(), http.StatusCreated) assert.NoError(t, err) @@ -11137,8 +12019,8 @@ func TestWebMaintenanceMock(t *testing.T) { apiKey := dataprovider.APIKey{ Name: "key name", - KeyID: shortuuid.New(), - Key: fmt.Sprintf("%v.%v", shortuuid.New(), shortuuid.New()), + KeyID: util.GenerateUniqueID(), + Key: fmt.Sprintf("%v.%v", util.GenerateUniqueID(), util.GenerateUniqueID()), Scope: dataprovider.APIKeyScopeAdmin, } backupData := dataprovider.BackupData{} diff --git a/httpd/internal_test.go b/httpd/internal_test.go index f5f0786d..14221fb8 100644 --- a/httpd/internal_test.go +++ b/httpd/internal_test.go @@ -383,6 +383,31 @@ func TestInvalidToken(t *testing.T) { assert.Equal(t, http.StatusBadRequest, rr.Code) 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() getUserPublicKeys(rr, req) 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.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" user = dataprovider.User{ BaseUser: sdk.BaseUser{ @@ -1471,7 +1503,8 @@ func TestCompressorAbortHandler(t *testing.T) { BaseConnection: common.NewBaseConnection(xid.New().String(), common.ProtocolHTTP, "", "", dataprovider.User{}), request: nil, } - renderCompressedFiles(&failingWriter{}, connection, "", nil) + share := &dataprovider.Share{} + renderCompressedFiles(&failingWriter{}, connection, "", nil, share) } 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.initializeRouter() @@ -1856,6 +1889,34 @@ func TestGetFilesInvalidClaims(t *testing.T) { handleClientEditFile(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, 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) { @@ -1883,7 +1944,7 @@ func TestInvalidClaims(t *testing.T) { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"])) handleWebClientProfilePost(rr, req) - assert.Equal(t, http.StatusInternalServerError, rr.Code) + assert.Equal(t, http.StatusForbidden, rr.Code) admin := dataprovider.Admin{ Username: "", @@ -1903,7 +1964,7 @@ func TestInvalidClaims(t *testing.T) { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Cookie", fmt.Sprintf("jwt=%v", token["access_token"])) handleWebAdminProfilePost(rr, req) - assert.Equal(t, http.StatusInternalServerError, rr.Code) + assert.Equal(t, http.StatusForbidden, rr.Code) } func TestTLSReq(t *testing.T) { diff --git a/httpd/schema/openapi.yaml b/httpd/schema/openapi.yaml index d94b9c48..80524257 100644 --- a/httpd/schema/openapi.yaml +++ b/httpd/schema/openapi.yaml @@ -12,14 +12,16 @@ tags: - name: users - name: data retention - name: events - - name: users API + - name: user APIs + - name: public shares info: title: SFTPGo 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. 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. + 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 contact: name: API support @@ -51,6 +53,81 @@ paths: schema: type: string 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: get: security: @@ -2555,7 +2632,7 @@ paths: security: - BearerAuth: [] tags: - - users API + - user APIs summary: Change user password description: Changes the password for the logged in user operationId: change_user_password @@ -2585,7 +2662,7 @@ paths: security: - BearerAuth: [] tags: - - users API + - user APIs deprecated: true summary: Get the user's public keys description: 'Returns the public keys for the logged in user. Deprecated please use "/user/profile" instead' @@ -2613,7 +2690,7 @@ paths: security: - BearerAuth: [] tags: - - users API + - user APIs deprecated: true 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' @@ -2650,7 +2727,7 @@ paths: security: - BearerAuth: [] tags: - - users API + - user APIs summary: Get user profile description: 'Returns the profile for the logged in user' operationId: get_user_profile @@ -2673,7 +2750,7 @@ paths: security: - BearerAuth: [] tags: - - users API + - user APIs summary: Update user profile description: 'Allows to update the profile for the logged in user' operationId: update_user_profile @@ -2705,7 +2782,7 @@ paths: security: - BearerAuth: [] tags: - - users API + - user APIs 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' operationId: get_user_recovery_codes @@ -2730,7 +2807,7 @@ paths: security: - BearerAuth: [] tags: - - users API + - user APIs summary: Generate recovery codes description: 'Generates new recovery codes for the logged in user. Generating new recovery codes you automatically invalidate old ones' operationId: generate_user_recovery_codes @@ -2758,7 +2835,7 @@ paths: security: - BearerAuth: [] tags: - - users API + - user APIs summary: Get available TOTP configuration description: Returns the available TOTP configurations for the logged in user operationId: get_user_totp_configs @@ -2784,7 +2861,7 @@ paths: security: - BearerAuth: [] tags: - - users API + - user APIs 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' operationId: generate_user_totp_secret @@ -2831,7 +2908,7 @@ paths: security: - BearerAuth: [] tags: - - users API + - user APIs summary: Validate a one time authentication code description: 'Checks if the given authentication code can be validated using the specified secret and config name' operationId: validate_user_totp_secret @@ -2875,7 +2952,7 @@ paths: security: - BearerAuth: [] tags: - - users API + - user APIs summary: Save a TOTP config description: 'Saves the specified TOTP config for the logged in user' operationId: save_user_totp_config @@ -2904,10 +2981,194 @@ paths: $ref: '#/components/responses/InternalServerError' default: $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: get: tags: - - users API + - user APIs summary: Read folders contents description: Returns the contents of the specified folder for the logged in user. Please use '/user/dirs' instead operationId: get_user_folder_contents @@ -2940,7 +3201,7 @@ paths: /user/dirs: get: tags: - - users API + - user APIs summary: Read directory contents description: Returns the contents of the specified directory for the logged in user operationId: get_user_dir_contents @@ -2971,7 +3232,7 @@ paths: $ref: '#/components/responses/DefaultResponse' post: tags: - - users API + - user APIs summary: Create a directory description: Create a directory for the logged in user operationId: create_user_dir @@ -3003,7 +3264,7 @@ paths: $ref: '#/components/responses/DefaultResponse' patch: tags: - - users API + - user APIs 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 operationId: rename_user_dir @@ -3041,7 +3302,7 @@ paths: $ref: '#/components/responses/DefaultResponse' delete: tags: - - users API + - user APIs summary: Delete a directory description: Delete a directory for the logged in user. Only empty directories can be deleted operationId: delete_user_dir @@ -3074,7 +3335,7 @@ paths: /user/file: get: tags: - - users API + - user APIs summary: Download a single file description: Returns the file contents as response body. Please use '/user/files' instead operationId: get_user_file @@ -3114,7 +3375,7 @@ paths: /user/files: get: tags: - - users API + - user APIs summary: Download a single file description: Returns the file contents as response body operationId: download_user_file @@ -3152,7 +3413,7 @@ paths: $ref: '#/components/responses/DefaultResponse' post: tags: - - users API + - user APIs summary: Upload files description: Upload one or more files for the logged in user operationId: create_user_files @@ -3168,7 +3429,7 @@ paths: schema: type: object properties: - filename: + filenames: type: array items: type: string @@ -3197,7 +3458,7 @@ paths: $ref: '#/components/responses/DefaultResponse' patch: tags: - - users API + - user APIs summary: Rename a file description: Rename a file for the logged in user operationId: rename_user_file @@ -3235,7 +3496,7 @@ paths: $ref: '#/components/responses/DefaultResponse' delete: tags: - - users API + - user APIs summary: Delete a file description: Delete a file for the logged in user. operationId: delete_user_file @@ -3268,7 +3529,7 @@ paths: /user/streamzip: post: tags: - - users API + - user APIs 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 operationId: streamzip @@ -3489,6 +3750,7 @@ components: - password-change-disabled - api-key-auth-change-disabled - info-change-disabled + - shares-disabled description: | Options: * `publickey-change-disabled` - changing SSH public keys is not allowed @@ -3497,6 +3759,7 @@ components: * `password-change-disabled` - changing password 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 + * `shares-disabled` - sharing files and directories with external users is disabled RetentionCheckNotification: type: string enum: @@ -3515,6 +3778,15 @@ components: Options: * `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 + ShareScope: + type: integer + enum: + - 1 + - 2 + description: | + Options: + * `1` - read scope + * `2` - write scope TOTPHMacAlgo: type: string enum: @@ -3562,6 +3834,7 @@ components: - user - admin - api_key + - share TOTPConfig: type: object properties: @@ -4506,6 +4779,62 @@ components: score: type: integer 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: type: object properties: @@ -4522,7 +4851,13 @@ components: items: $ref: '#/components/schemas/Admin' api_keys: - $ref: '#/components/schemas/APIKey' + type: array + items: + $ref: '#/components/schemas/APIKey' + shares: + type: array + items: + $ref: '#/components/schemas/Share' version: type: integer PwdChange: diff --git a/httpd/server.go b/httpd/server.go index 776669c2..14e53a9e 100644 --- a/httpd/server.go +++ b/httpd/server.go @@ -721,7 +721,7 @@ func (s *httpdServer) checkCookieExpiration(w http.ResponseWriter, r *http.Reque if tokenClaims.Username == "" || tokenClaims.Signature == "" { return } - if time.Until(token.Expiration()) > tokenRefreshMin { + if time.Until(token.Expiration()) > tokenRefreshThreshold { return } if util.IsStringInSlice(tokenAudienceWebClient, token.Audience()) { @@ -896,6 +896,10 @@ func (s *httpdServer) initializeRouter() { 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.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)).Delete(userFilesPath, deleteUserFile) 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 { @@ -1083,6 +1092,9 @@ func (s *httpdServer) initializeRouter() { s.router.With(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie), jwtAuthenticatorPartial(tokenAudienceWebClientPartial)). 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) { router.Use(jwtauth.Verify(s.tokenAuth, jwtauth.TokenFromCookie)) @@ -1124,6 +1136,18 @@ func (s *httpdServer) initializeRouter() { Get(webClientRecoveryCodesPath, getRecoveryCodes) router.With(checkHTTPUserPerm(sdk.WebClientMFADisabled), verifyCSRFHeader). 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) }) } diff --git a/httpd/webadmin.go b/httpd/webadmin.go index c0823157..69938f15 100644 --- a/httpd/webadmin.go +++ b/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, error string, isAdd bool) { currentURL := webAdminPath + title := "Add a new admin" if !isAdd { currentURL = fmt.Sprintf("%v/%v", webAdminPath, url.PathEscape(admin.Username)) + title = "Update admin" } data := adminPage{ - basePage: getBasePageData("Add a new user", currentURL, r), + basePage: getBasePageData(title, currentURL, r), Admin: admin, Error: error, IsAdd: isAdd, @@ -1093,7 +1095,7 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) { } expirationDateMillis := int64(0) expirationDateString := r.Form.Get("expiration_date") - if len(strings.TrimSpace(expirationDateString)) > 0 { + if strings.TrimSpace(expirationDateString) != "" { expirationDate, err := time.Parse(webDateTimeFormat, expirationDateString) if err != nil { return user, err diff --git a/httpd/webclient.go b/httpd/webclient.go index bf948ab6..74e2e5c7 100644 --- a/httpd/webclient.go +++ b/httpd/webclient.go @@ -11,6 +11,7 @@ import ( "os" "path" "path/filepath" + "strconv" "strings" "time" @@ -39,7 +40,10 @@ const ( templateClientTwoFactorRecovery = "twofactor-recovery.html" templateClientMFA = "mfa.html" templateClientEditFile = "editfile.html" + templateClientShare = "share.html" + templateClientShares = "shares.html" pageClientFilesTitle = "My Files" + pageClientSharesTitle = "Shares" pageClientProfileTitle = "My Profile" pageClientChangePwdTitle = "Change password" pageClient2FATitle = "Two-factor auth" @@ -70,6 +74,8 @@ type baseClientPage struct { Title string CurrentURL string FilesURL string + SharesURL string + ShareURL string ProfileURL string ChangePwdURL string StaticURL string @@ -77,6 +83,7 @@ type baseClientPage struct { MFAURL string MFATitle string FilesTitle string + SharesTitle string ProfileTitle string Version string CSRFToken string @@ -106,6 +113,7 @@ type filesPage struct { CanRename bool CanDelete bool CanDownload bool + CanShare bool Error string Paths []dirMapping } @@ -142,6 +150,19 @@ type clientMFAPage struct { 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 { 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, 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{ filepath.Join(templatesPath, templateClientDir, templateClientBase), filepath.Join(templatesPath, templateClientDir, templateClientProfile), @@ -200,6 +229,8 @@ func loadClientTemplates(templatesPath string) { twoFactorTmpl := util.LoadTemplate(nil, twoFactorPath...) twoFactorRecoveryTmpl := util.LoadTemplate(nil, twoFactorRecoveryPath...) editFileTmpl := util.LoadTemplate(nil, editFilePath...) + sharesTmpl := util.LoadTemplate(nil, sharesPaths...) + shareTmpl := util.LoadTemplate(nil, sharePaths...) clientTemplates[templateClientFiles] = filesTmpl clientTemplates[templateClientProfile] = profileTmpl @@ -210,6 +241,8 @@ func loadClientTemplates(templatesPath string) { clientTemplates[templateClientTwoFactor] = twoFactorTmpl clientTemplates[templateClientTwoFactorRecovery] = twoFactorRecoveryTmpl clientTemplates[templateClientEditFile] = editFileTmpl + clientTemplates[templateClientShares] = sharesTmpl + clientTemplates[templateClientShare] = shareTmpl } func getBaseClientPageData(title, currentURL string, r *http.Request) baseClientPage { @@ -223,6 +256,8 @@ func getBaseClientPageData(title, currentURL string, r *http.Request) baseClient Title: title, CurrentURL: currentURL, FilesURL: webClientFilesPath, + SharesURL: webClientSharesPath, + ShareURL: webClientSharePath, ProfileURL: webClientProfilePath, ChangePwdURL: webChangeClientPwdPath, StaticURL: webStaticFilesPath, @@ -230,6 +265,7 @@ func getBaseClientPageData(title, currentURL string, r *http.Request) baseClient MFAURL: webClientMFAPath, MFATitle: pageClient2FATitle, FilesTitle: pageClientFilesTitle, + SharesTitle: pageClientSharesTitle, ProfileTitle: pageClientProfileTitle, Version: fmt.Sprintf("%v-%v", v.Version, v.CommitHash), CSRFToken: csrfToken, @@ -331,6 +367,24 @@ func renderEditFilePage(w http.ResponseWriter, r *http.Request, fileName, fileDa 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) { data := filesPage{ 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), CanDelete: user.CanDeleteFromWeb(dirName), CanDownload: user.HasPerm(dataprovider.PermDownload, dirName), + CanShare: user.CanManageShares(), } paths := []dirMapping{} if dirName != "/" { @@ -442,7 +497,7 @@ func handleWebClientDownloadZip(w http.ResponseWriter, r *http.Request) { } 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) { @@ -635,6 +690,152 @@ func handleClientEditFile(w http.ResponseWriter, r *http.Request) { 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) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) renderClientProfilePage(w, r, "") @@ -678,7 +879,7 @@ func handleWebClientProfilePost(w http.ResponseWriter, r *http.Request) { } claims, err := getTokenClaims(r) if err != nil || claims.Username == "" { - renderClientProfilePage(w, r, "Invalid token claims") + renderClientForbiddenPage(w, r, "Invalid token claims") return } 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) 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 +} diff --git a/sdk/plugin/plugin.go b/sdk/plugin/plugin.go index 7fd70d31..a8739c02 100644 --- a/sdk/plugin/plugin.go +++ b/sdk/plugin/plugin.go @@ -93,6 +93,7 @@ type Manager struct { // Initialize initializes the configured plugins func Initialize(configs []Config, logVerbose bool) error { + logger.Debug(logSender, "", "initialize") Handler = Manager{ Configs: configs, done: make(chan bool), @@ -495,6 +496,7 @@ func (m *Manager) restartSearcherPlugin(config Config) { // Cleanup releases all the active plugins func (m *Manager) Cleanup() { + logger.Debug(logSender, "", "cleanup") atomic.StoreInt32(&m.closed, 1) close(m.done) m.notifLock.Lock() diff --git a/sdk/user.go b/sdk/user.go index f60cd6c5..2d201f62 100644 --- a/sdk/user.go +++ b/sdk/user.go @@ -15,12 +15,13 @@ const ( WebClientPasswordChangeDisabled = "password-change-disabled" WebClientAPIKeyAuthChangeDisabled = "api-key-auth-change-disabled" WebClientInfoChangeDisabled = "info-change-disabled" + WebClientSharesDisabled = "shares-disabled" ) var ( // WebClientOptions defines the available options for the web client interface/user REST API WebClientOptions = []string{WebClientWriteDisabled, WebClientPasswordChangeDisabled, WebClientPubKeyChangeDisabled, - WebClientMFADisabled, WebClientAPIKeyAuthChangeDisabled, WebClientInfoChangeDisabled} + WebClientMFADisabled, WebClientAPIKeyAuthChangeDisabled, WebClientInfoChangeDisabled, WebClientSharesDisabled} // UserTypes defines the supported user type hints for auth plugins UserTypes = []string{string(UserTypeLDAP), string(UserTypeOS)} ) diff --git a/service/service.go b/service/service.go index 6e9680fc..2936813b 100644 --- a/service/service.go +++ b/service/service.go @@ -316,5 +316,9 @@ func (s *Service) restoreDump(dump *dataprovider.BackupData) error { if err != nil { 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 } diff --git a/service/service_windows.go b/service/service_windows.go index 6735507d..310de562 100644 --- a/service/service_windows.go +++ b/service/service_windows.go @@ -113,6 +113,7 @@ loop: changes <- svc.Status{State: svc.StopPending} wasStopped <- true s.Service.Stop() + plugin.Handler.Cleanup() break loop case svc.ParamChange: 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) } } - plugin.Handler.Cleanup() return nil } diff --git a/service/signals_windows.go b/service/signals_windows.go index f3e13af3..154a1075 100644 --- a/service/signals_windows.go +++ b/service/signals_windows.go @@ -5,6 +5,7 @@ import ( "os/signal" "github.com/drakkan/sftpgo/v2/logger" + "github.com/drakkan/sftpgo/v2/sdk/plugin" ) func registerSignals() { @@ -13,6 +14,7 @@ func registerSignals() { go func() { for range c { logger.Debug(logSender, "", "Received interrupt request") + plugin.Handler.Cleanup() os.Exit(0) } }() diff --git a/templates/webadmin/folders.html b/templates/webadmin/folders.html index 52c9bee9..f91f6301 100644 --- a/templates/webadmin/folders.html +++ b/templates/webadmin/folders.html @@ -11,7 +11,6 @@ {{end}} {{define "page_body"}} - @@ -50,7 +49,6 @@ - {{end}} {{define "dialog"}} diff --git a/templates/webadmin/fsconfig.html b/templates/webadmin/fsconfig.html index 1dc59bde..b290130e 100644 --- a/templates/webadmin/fsconfig.html +++ b/templates/webadmin/fsconfig.html @@ -105,7 +105,7 @@ placeholder="" value="{{.S3Config.DownloadPartMaxTime}}" min="0" aria-describedby="S3DownloadTimeoutHelpBlock"> - 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
diff --git a/templates/webclient/base.html b/templates/webclient/base.html index f4fe0875..c6fcc50b 100644 --- a/templates/webclient/base.html +++ b/templates/webclient/base.html @@ -78,6 +78,13 @@ {{.FilesTitle}} + {{if .LoggedUser.CanManageShares}} + + {{end}}