WebClient/REST API: add sharing support

This commit is contained in:
Nicola Murino 2021-11-06 14:13:20 +01:00
parent f6938e76dc
commit 3bc58f5988
No known key found for this signature in database
GPG key ID: 2F1FB59433D5A8CB
48 changed files with 4038 additions and 258 deletions

View file

@ -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.

View file

@ -80,6 +80,7 @@ const (
ProtocolFTP = "FTP"
ProtocolWebDAV = "DAV"
ProtocolHTTP = "HTTP"
ProtocolHTTPShare = "HTTPShare"
ProtocolDataRetention = "DataRetention"
)
@ -122,7 +123,8 @@ var (
QuotaScans ActiveScans
idleTimeoutTicker *time.Ticker
idleTimeoutTickerDone chan bool
supportedProtocols = []string{ProtocolSFTP, ProtocolSCP, ProtocolSSH, ProtocolFTP, ProtocolWebDAV, ProtocolHTTP}
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

View file

@ -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) {

View file

@ -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) {

View file

@ -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

View file

@ -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 != "" {

View file

@ -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)
for _, bucket := range boltBuckets {
if err := dbHandle.Update(func(tx *bolt.Tx) error {
_, e := tx.CreateBucketIfNotExists(bucket)
return e
})
if err != nil {
providerLog(logger.LevelWarn, "error creating users bucket: %v", err)
return err
}); err != nil {
providerLog(logger.LevelWarn, "error creating bucket %#v: %v", string(bucket), 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
}
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

View file

@ -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$"
@ -132,6 +132,7 @@ var (
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")
ErrLoginNotAllowedFromIP = errors.New("login is not allowed from this IP")
isAdminCreated = int32(0)
validTLSUsernames = []string{string(sdk.TLSUsernameNone), string(sdk.TLSUsernameCN)}
config Config
@ -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
}

View file

@ -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)

View file

@ -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")

View file

@ -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")

274
dataprovider/share.go Normal file
View file

@ -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
}

View file

@ -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

View file

@ -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")

View file

@ -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])

View file

@ -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

View file

@ -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.

55
go.mod
View file

@ -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
)

64
go.sum
View file

@ -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=

View file

@ -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) {

View file

@ -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))

View file

@ -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 {

View file

@ -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 {

232
httpd/api_shares.go Normal file
View file

@ -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
}

View file

@ -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)
}
}

View file

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

View file

@ -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)

File diff suppressed because it is too large Load diff

View file

@ -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) {

View file

@ -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:
type: array
items:
$ref: '#/components/schemas/APIKey'
shares:
type: array
items:
$ref: '#/components/schemas/Share'
version:
type: integer
PwdChange:

View file

@ -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)
})
}

View file

@ -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

View file

@ -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
}

View file

@ -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()

View file

@ -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)}
)

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}
}()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -122,7 +122,7 @@ github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZo
github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
github.com/aws/aws-sdk-go v1.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.9/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
github.com/aws/aws-sdk-go v1.41.14/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
github.com/aws/aws-sdk-go-v2 v1.7.0/go.mod h1:tb9wi5s61kTDA5qCkcDbt3KRVV74GGslQkl/DRdX/P4=
github.com/aws/aws-sdk-go-v2 v1.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=
@ -161,8 +161,12 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-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/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/cockroachdb/cockroach-go/v2 v2.2.1/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI=
github.com/codahale/hdrhistogram v0.0.0-20161010025455-3a0bb77429bd/go.mod h1:sE/e/2PUdi/liOCUjSTXgM1o87ZssimdTWN964YiIeI=
@ -199,7 +203,9 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.m
github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
github.com/envoyproxy/go-control-plane v0.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/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=
@ -218,6 +224,7 @@ github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm
github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/go-chi/chi/v5 v5.0.4/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/chi/v5 v5.0.5/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/jwtauth/v5 v5.0.2/go.mod h1:TeA7vmPe3uYThvHw8O8W13HOOpOd4MTgToxL41gZyjs=
github.com/go-chi/render v1.0.1/go.mod h1:pq4Rr7HbnsdaeHagklXub+p6Wd16Af5l9koip1OvJns=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
@ -386,10 +393,11 @@ github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOn
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk=
github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM=
github.com/hashicorp/yamux v0.0.0-20210826001029-26ff87cf9493 h1:brI5vBRUlAlM34VFmnLPwjnCL/FxAJp9XvOdX6Zt+XE=
github.com/hashicorp/yamux v0.0.0-20210826001029-26ff87cf9493/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87 h1:xixZ2bWeofWV68J+x6AzmKuVM/JWCQwkWm6GW/MUR6I=
github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/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/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
@ -484,7 +492,7 @@ github.com/lestrrat-go/codegen v1.0.1/go.mod h1:JhJw6OQAuPEfVKUCLItpaVLumDGWQznd
github.com/lestrrat-go/httpcc v1.0.0/go.mod h1:tGS/u00Vh5N6FHNkExqGGNId8e0Big+++0Gf8MBnAvE=
github.com/lestrrat-go/iter v1.0.1/go.mod h1:zIdgO1mRKhn8l9vrZJZz9TUMMFbQbLeTsbqPDrJ/OJc=
github.com/lestrrat-go/jwx v1.2.6/go.mod h1:tJuGuAI3LC71IicTx82Mz1n3w9woAs2bYJZpkjJQ5aU=
github.com/lestrrat-go/jwx v1.2.8/go.mod h1:25DcLbNWArPA/Ew5CcBmewl32cJKxOk5cbepBsIJFzw=
github.com/lestrrat-go/jwx v1.2.9/go.mod h1:25DcLbNWArPA/Ew5CcBmewl32cJKxOk5cbepBsIJFzw=
github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
@ -494,6 +502,7 @@ github.com/lib/pq v1.10.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts=
github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=
github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
@ -631,6 +640,7 @@ github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/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/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
@ -808,6 +818,7 @@ golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211020060615-d418f374d309 h1:A0lJIi+hcTR6aajJH4YqKWwohY4aW9RO7oRMcdv+HKI=
golang.org/x/net v0.0.0-20211020060615-d418f374d309/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
@ -830,6 +841,7 @@ golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ
golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-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/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=
@ -919,14 +931,15 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210816074244-15123e1e1f71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-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-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211023085530-d6a326fbbf70 h1:SeSEfdIxyvwGJliREIJhRPPXvW6sDlLT+UQ3B0hD0NA=
golang.org/x/sys v0.0.0-20211023085530-d6a326fbbf70/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211029165221-6e7872819dc8 h1:M69LAlWZCshgp0QSzyDcSsSIejIEeuaCVpmwcKwyLMk=
golang.org/x/sys v0.0.0-20211029165221-6e7872819dc8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@ -1051,7 +1064,7 @@ google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqiv
google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI=
google.golang.org/api v0.58.0/go.mod h1:cAbP2FsxoGVNwtgNAmmn3y5G1TWAiVYRmg4yku3lv+E=
google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU=
google.golang.org/api v0.60.0/go.mod h1:d7rl65NZAkEQ90JFzqBjcRq1TVeG5ZoGV3sSpEnnVb4=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@ -1124,10 +1137,10 @@ google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEc
google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211016002631-37fc39342514/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211021150943-2b146023228c h1:FqrtZMB5Wr+/RecOM3uPJNPfWR8Upb5hAPnt7PU6i4k=
google.golang.org/genproto v0.0.0-20211021150943-2b146023228c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211029142109-e255c875f7c7 h1:aaSaYY/DIDJy3f/JLXWv6xJ1mBQSRnQ1s5JhAFTnzO4=
google.golang.org/genproto v0.0.0-20211029142109-e255c875f7c7/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=

View file

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