WebClient/REST API: add sharing support
This commit is contained in:
parent
f6938e76dc
commit
3bc58f5988
48 changed files with 4038 additions and 258 deletions
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 != "" {
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
274
dataprovider/share.go
Normal 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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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])
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
55
go.mod
|
@ -7,10 +7,9 @@ require (
|
|||
github.com/Azure/azure-storage-blob-go v0.14.0
|
||||
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962
|
||||
github.com/alexedwards/argon2id v0.0.0-20210511081203-7d35d68092b8
|
||||
github.com/aws/aws-sdk-go v1.41.13
|
||||
github.com/aws/aws-sdk-go v1.41.19
|
||||
github.com/cockroachdb/cockroach-go/v2 v2.2.1
|
||||
github.com/eikenb/pipeat v0.0.0-20210603033007-44fc3ffce52b
|
||||
github.com/fatih/color v1.13.0 // indirect
|
||||
github.com/fclairamb/ftpserverlib v0.16.0
|
||||
github.com/fclairamb/go-log v0.1.0
|
||||
github.com/go-chi/chi/v5 v5.0.5
|
||||
|
@ -19,42 +18,33 @@ require (
|
|||
github.com/go-sql-driver/mysql v1.6.0
|
||||
github.com/golang/mock v1.6.0
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/grandcat/zeroconf v1.0.0
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-hclog v1.0.0
|
||||
github.com/hashicorp/go-plugin v1.4.3
|
||||
github.com/hashicorp/go-retryablehttp v0.7.0
|
||||
github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87 // indirect
|
||||
github.com/jlaffaye/ftp v0.0.0-20201112195030-9aae4d151126
|
||||
github.com/klauspost/compress v1.13.6
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect
|
||||
github.com/lestrrat-go/jwx v1.2.9
|
||||
github.com/lib/pq v1.10.3
|
||||
github.com/lithammer/shortuuid/v3 v3.0.7
|
||||
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.9
|
||||
github.com/mhale/smtpd v0.8.0
|
||||
github.com/miekg/dns v1.1.43 // indirect
|
||||
github.com/minio/sio v0.3.0
|
||||
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
|
||||
github.com/oklog/run v1.1.0 // indirect
|
||||
github.com/otiai10/copy v1.6.0
|
||||
github.com/pires/go-proxyproto v0.6.1
|
||||
github.com/pkg/sftp v1.13.4
|
||||
github.com/pquerna/otp v1.3.0
|
||||
github.com/prometheus/client_golang v1.11.0
|
||||
github.com/prometheus/common v0.32.1 // indirect
|
||||
github.com/rs/cors v1.8.0
|
||||
github.com/rs/xid v1.3.0
|
||||
github.com/rs/zerolog v1.25.0
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/shirou/gopsutil/v3 v3.21.9
|
||||
github.com/rs/zerolog v1.26.0
|
||||
github.com/shirou/gopsutil/v3 v3.21.10
|
||||
github.com/spf13/afero v1.6.0
|
||||
github.com/spf13/cobra v1.2.1
|
||||
github.com/spf13/viper v1.9.0
|
||||
github.com/stretchr/testify v1.7.0
|
||||
github.com/studio-b12/gowebdav v0.0.0-20210917133250-a3a86976a1df
|
||||
github.com/studio-b12/gowebdav v0.0.0-20211106090535-29e74efa701f
|
||||
github.com/wagslane/go-password-validator v0.3.0
|
||||
github.com/xhit/go-simple-mail/v2 v2.10.0
|
||||
github.com/yl2chen/cidranger v1.0.2
|
||||
|
@ -62,12 +52,11 @@ require (
|
|||
go.uber.org/automaxprocs v1.4.0
|
||||
gocloud.dev v0.24.0
|
||||
golang.org/x/crypto v0.0.0-20210915214749-c084706c2272
|
||||
golang.org/x/net v0.0.0-20211020060615-d418f374d309
|
||||
golang.org/x/sys v0.0.0-20211029165221-6e7872819dc8
|
||||
golang.org/x/net v0.0.0-20211105192438-b53810dc28af
|
||||
golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c
|
||||
golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac
|
||||
google.golang.org/api v0.60.0
|
||||
google.golang.org/genproto v0.0.0-20211029142109-e255c875f7c7 // indirect
|
||||
google.golang.org/grpc v1.41.0
|
||||
google.golang.org/grpc v1.42.0
|
||||
google.golang.org/protobuf v1.27.1
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0
|
||||
)
|
||||
|
@ -81,42 +70,53 @@ require (
|
|||
github.com/cenkalti/backoff v2.2.1+incompatible // indirect
|
||||
github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.1.2 // indirect
|
||||
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158 // indirect
|
||||
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1 // indirect
|
||||
github.com/coreos/go-systemd/v22 v22.3.2 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0 // indirect
|
||||
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
|
||||
github.com/envoyproxy/go-control-plane v0.10.0 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect
|
||||
github.com/fatih/color v1.13.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.5.1 // indirect
|
||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||
github.com/goccy/go-json v0.7.10 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/google/go-cmp v0.5.6 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.1.1 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.0.0 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.0.9 // indirect
|
||||
github.com/kr/fs v0.1.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.0 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.0 // indirect
|
||||
github.com/lestrrat-go/iter v1.0.1 // indirect
|
||||
github.com/lestrrat-go/option v1.0.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.5 // indirect
|
||||
github.com/mattn/go-colorable v0.1.11 // indirect
|
||||
github.com/mattn/go-ieproxy v0.0.1 // indirect
|
||||
github.com/mattn/go-isatty v0.0.14 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
|
||||
github.com/miekg/dns v1.1.43 // indirect
|
||||
github.com/minio/sha256-simd v1.0.0 // indirect
|
||||
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
|
||||
github.com/mitchellh/mapstructure v1.4.2 // indirect
|
||||
github.com/oklog/run v1.1.0 // indirect
|
||||
github.com/pelletier/go-toml v1.9.4 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.2.0 // indirect
|
||||
github.com/prometheus/common v0.32.1 // indirect
|
||||
github.com/prometheus/procfs v0.7.3 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/spf13/cast v1.4.1 // indirect
|
||||
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
|
@ -124,10 +124,11 @@ require (
|
|||
github.com/tklauser/go-sysconf v0.3.9 // indirect
|
||||
github.com/tklauser/numcpus v0.3.0 // indirect
|
||||
go.opencensus.io v0.23.0 // indirect
|
||||
golang.org/x/oauth2 v0.0.0-20211028175245-ba495a64dcb5 // indirect
|
||||
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/genproto v0.0.0-20211104193956-4c6863e31247 // indirect
|
||||
gopkg.in/ini.v1 v1.63.2 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
|
||||
|
@ -137,5 +138,5 @@ replace (
|
|||
github.com/eikenb/pipeat => github.com/drakkan/pipeat v0.0.0-20210805162858-70e57fa8a639
|
||||
github.com/jlaffaye/ftp => github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9
|
||||
golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20210918082254-e7eb8487714b
|
||||
golang.org/x/net => github.com/drakkan/net v0.0.0-20211023135414-8d45d13382c8
|
||||
golang.org/x/net => github.com/drakkan/net v0.0.0-20211106121348-90772e49e64e
|
||||
)
|
||||
|
|
64
go.sum
64
go.sum
|
@ -137,8 +137,8 @@ github.com/aws/aws-sdk-go v1.15.27/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZo
|
|||
github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
|
||||
github.com/aws/aws-sdk-go v1.38.68/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
|
||||
github.com/aws/aws-sdk-go v1.40.34/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
|
||||
github.com/aws/aws-sdk-go v1.41.13 h1:wGgr6jkHdGExF33phfOqijFq7ZF+h7a6FXvJc77GpTc=
|
||||
github.com/aws/aws-sdk-go v1.41.13/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
|
||||
github.com/aws/aws-sdk-go v1.41.19 h1:9QR2WTNj5bFdrNjRY9SeoG+3hwQmKXGX16851vdh+N8=
|
||||
github.com/aws/aws-sdk-go v1.41.19/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q=
|
||||
github.com/aws/aws-sdk-go-v2 v1.7.0/go.mod h1:tb9wi5s61kTDA5qCkcDbt3KRVV74GGslQkl/DRdX/P4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.9.0/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.7.0/go.mod h1:w9+nMZ7soXCe5nT46Ri354SNhXDQ6v+V5wqDjnZE+GY=
|
||||
|
@ -182,11 +182,15 @@ github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4
|
|||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403 h1:cqQfy1jclcSy/FwLjemeg3SR1yaINm74aQyupQ0Bl8M=
|
||||
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 h1:hzAQntlaYRkVSFEfj9OTWlVV1H155FMD8BTKktLv0QI=
|
||||
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
|
||||
github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158 h1:CevA8fI91PAnP8vpnXuB8ZYAZ5wqY86nAbxfgK8tWO4=
|
||||
github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1 h1:zH8ljVhhq7yC0MIeUL/IviMtY8hx2mK8cN9wEYb8ggw=
|
||||
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
||||
github.com/cockroachdb/cockroach-go/v2 v2.2.1 h1:nZte1DDdL9iu8IV0YPmX8l9Lg2+HRJ3CMvkT3iG52rc=
|
||||
github.com/cockroachdb/cockroach-go/v2 v2.2.1/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI=
|
||||
|
@ -207,8 +211,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210816181553-5444fa50b93d/go.mod h1:tmAIfUFEirG/Y8jhZ9M+h36obRZAk/1fcSpXwAVlfqE=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0 h1:Fe5DW39aaoS/fqZiYlylEqQWIKznnbatWSHpWdFA3oQ=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
|
||||
github.com/denisenkom/go-mssqldb v0.9.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
|
||||
github.com/devigned/tab v0.1.1/go.mod h1:XG9mPq0dFghrYvoBF3xdRrJzSTX1b7IQrvaL9mzjeJY=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
|
@ -218,8 +222,8 @@ github.com/drakkan/crypto v0.0.0-20210918082254-e7eb8487714b h1:MZY6RAQFVhJous68
|
|||
github.com/drakkan/crypto v0.0.0-20210918082254-e7eb8487714b/go.mod h1:0hNoheD1tVu/m8WMkw/chBXf5VpwzL5fHQU25k79NKo=
|
||||
github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9 h1:LPH1dEblAOO/LoG7yHPMtBLXhQmjaga91/DDjWk9jWA=
|
||||
github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9/go.mod h1:2lmrmq866uF2tnje75wQHzmPXhmSWUt7Gyx2vgK1RCU=
|
||||
github.com/drakkan/net v0.0.0-20211023135414-8d45d13382c8 h1:xjuGl7Do3QtkkIaEOHME5EAG/dSi03ahxuqkmh9tx9A=
|
||||
github.com/drakkan/net v0.0.0-20211023135414-8d45d13382c8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
github.com/drakkan/net v0.0.0-20211106121348-90772e49e64e h1:om9H3anUwjKmPDdAdNiVB96Fcwnt7t8B4C1f8ivrm0U=
|
||||
github.com/drakkan/net v0.0.0-20211106121348-90772e49e64e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
github.com/drakkan/pipeat v0.0.0-20210805162858-70e57fa8a639 h1:8tfGdb4kg/YCvAbIrsMazgoNtnqdOqQVDKW12uUCuuU=
|
||||
github.com/drakkan/pipeat v0.0.0-20210805162858-70e57fa8a639/go.mod h1:kltMsfRMTHSFdMbK66XdS8mfMW77+FZA1fGY1xYMF84=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
|
@ -234,10 +238,12 @@ github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5y
|
|||
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
|
||||
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021 h1:fP+fF0up6oPY49OrjPrhIJ8yQfdIM85NXMLkMg1EXVs=
|
||||
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0 h1:EQciDnbrYxy13PgWoY8AqoxGiPrpgBZ1R8UNe3ddc+A=
|
||||
github.com/envoyproxy/go-control-plane v0.10.0 h1:WVt4HEPbdRbRD/PKKPbPnIVavO6gk/h673jWyIJ016k=
|
||||
github.com/envoyproxy/go-control-plane v0.10.0/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.6.2 h1:JiO+kJTpmYGjEodY7O1Zk8oZcNz1+f30UtwtXoFUPzE=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws=
|
||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
|
||||
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
|
||||
|
@ -454,6 +460,7 @@ github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87 h1:xixZ2bWeofWV68J
|
|||
github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
|
||||
github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
|
||||
|
@ -573,6 +580,9 @@ github.com/lib/pq v1.10.3 h1:v9QZf2Sn6AmjXtQeFpdoq/eaNtYP6IN+7lcrygsIAtg=
|
|||
github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lithammer/shortuuid/v3 v3.0.7 h1:trX0KTHy4Pbwo/6ia8fscyHoGA+mf1jWbPJVuvyJQQ8=
|
||||
github.com/lithammer/shortuuid/v3 v3.0.7/go.mod h1:vMk8ke37EmiewwolSO1NLW8vP4ZaKlRuDIi8tWWmAts=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=
|
||||
github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls=
|
||||
github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
|
||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
|
@ -710,8 +720,8 @@ github.com/rs/xid v1.3.0 h1:6NjYksEUlhurdVehpc7S7dk6DAmcKv8V9gG0FsVN2U4=
|
|||
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
||||
github.com/rs/zerolog v1.25.0 h1:Rj7XygbUHKUlDPcVdoLyR91fJBsduXj5fRxyqIQj/II=
|
||||
github.com/rs/zerolog v1.25.0/go.mod h1:7KHcEGe0QZPOm2IE4Kpb5rTh6n1h2hIgS5OOnu1rUaI=
|
||||
github.com/rs/zerolog v1.26.0 h1:ORM4ibhEZeTeQlCojCK2kPz1ogAY4bGs4tD+SaAdGaE=
|
||||
github.com/rs/zerolog v1.26.0/go.mod h1:yBiM87lvSqX8h0Ww4sdzNSkVYZ8dL2xjZJG1lAuGZEo=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
|
@ -721,8 +731,8 @@ github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdh
|
|||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||
github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
|
||||
github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4/go.mod h1:MnkX001NG75g3p8bhFycnyIjeQoOjGL6CEIsdE/nKSY=
|
||||
github.com/shirou/gopsutil/v3 v3.21.9 h1:Vn4MUz2uXhqLSiCbGFRc0DILbMVLAY92DSkT8bsYrHg=
|
||||
github.com/shirou/gopsutil/v3 v3.21.9/go.mod h1:YWp/H8Qs5fVmf17v7JNZzA0mPJ+mS2e9JdiUF9LlKzQ=
|
||||
github.com/shirou/gopsutil/v3 v3.21.10 h1:flTg1DrnV/UVrBqjLgVgDJzx6lf+91rC64/dBHmO2IA=
|
||||
github.com/shirou/gopsutil/v3 v3.21.10/go.mod h1:t75NhzCZ/dYyPQjyQmrAYP6c8+LCdFANeBMdLPCNnew=
|
||||
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
|
||||
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
|
@ -736,6 +746,7 @@ github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1
|
|||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
|
||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=
|
||||
github.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY=
|
||||
github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
|
||||
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
|
@ -763,8 +774,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
|
|||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/studio-b12/gowebdav v0.0.0-20210917133250-a3a86976a1df h1:C+J/LwTqP8gRPt1MdSzBNZP0OYuDm5wsmDKgwpLjYzo=
|
||||
github.com/studio-b12/gowebdav v0.0.0-20210917133250-a3a86976a1df/go.mod h1:gCcfDlA1Y7GqOaeEKw5l9dOGx1VLdc/HuQSlQAaZ30s=
|
||||
github.com/studio-b12/gowebdav v0.0.0-20211106090535-29e74efa701f h1:SLJx0nHhb2ZLlYNMAbrYsjwmVwXx4yRT48lNIxOp7ts=
|
||||
github.com/studio-b12/gowebdav v0.0.0-20211106090535-29e74efa701f/go.mod h1:gCcfDlA1Y7GqOaeEKw5l9dOGx1VLdc/HuQSlQAaZ30s=
|
||||
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
|
||||
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
|
||||
github.com/tklauser/go-sysconf v0.3.9 h1:JeUVdAOWhhxVcU6Eqr/ATFHgXk/mmiItdKeJPev3vTo=
|
||||
|
@ -784,6 +795,7 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
|||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||
go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU=
|
||||
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
|
||||
|
@ -877,8 +889,8 @@ golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ
|
|||
golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20211028175245-ba495a64dcb5 h1:v79phzBz03tsVCUTbvTBmmC3CUXF5mKYt7DA4ZVldpM=
|
||||
golang.org/x/oauth2 v0.0.0-20211028175245-ba495a64dcb5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 h1:RerP+noqYHUQ8CMRcPlC2nvTa4dcBIjegkuWdcUDuqg=
|
||||
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
@ -963,15 +975,18 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210816074244-15123e1e1f71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211029165221-6e7872819dc8 h1:M69LAlWZCshgp0QSzyDcSsSIejIEeuaCVpmwcKwyLMk=
|
||||
golang.org/x/sys v0.0.0-20211029165221-6e7872819dc8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c h1:+8miTPjMCTXwih7BQmvWwd0PjdBZq2MKp/qQaahSzEM=
|
||||
golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
|
@ -1056,6 +1071,7 @@ golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
|||
golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
|
||||
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
@ -1174,8 +1190,8 @@ google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEc
|
|||
google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20211016002631-37fc39342514/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20211021150943-2b146023228c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20211029142109-e255c875f7c7 h1:aaSaYY/DIDJy3f/JLXWv6xJ1mBQSRnQ1s5JhAFTnzO4=
|
||||
google.golang.org/genproto v0.0.0-20211029142109-e255c875f7c7/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/genproto v0.0.0-20211104193956-4c6863e31247 h1:ZONpjmFT5e+I/0/xE3XXbG5OIvX2hRYzol04MhKBl2E=
|
||||
google.golang.org/genproto v0.0.0-20211104193956-4c6863e31247/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||
google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
|
@ -1202,8 +1218,8 @@ google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQ
|
|||
google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
|
||||
google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
|
||||
google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
|
||||
google.golang.org/grpc v1.41.0 h1:f+PlOh7QV4iIJkPrx5NQ7qaNGFQ3OTse67yaDHfju4E=
|
||||
google.golang.org/grpc v1.41.0/go.mod h1:U3l9uK9J0sini8mHphKoXyaqDA/8VyGnDee1zzIUK6k=
|
||||
google.golang.org/grpc v1.42.0 h1:XT2/MFpuPFsEX2fWh3YQtHkZ+WYZFQRfaUgLZYj/p6A=
|
||||
google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
232
httpd/api_shares.go
Normal 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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
@ -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) {
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)}
|
||||
)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}()
|
||||
|
|
|
@ -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"}}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}}">
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}}
|
||||
|
|
209
templates/webclient/share.html
Normal file
209
templates/webclient/share.html
Normal 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}}
|
257
templates/webclient/shares.html
Normal file
257
templates/webclient/shares.html
Normal 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">×</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">×</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}}
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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=
|
||||
|
|
11
util/util.go
11
util/util.go
|
@ -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 {
|
||||
|
|
Loading…
Reference in a new issue