mirror of
https://github.com/drakkan/sftpgo.git
synced 2024-11-21 23:20:24 +00:00
add support for virtual folders
directories outside the user home directory can be exposed as virtual folders
This commit is contained in:
parent
382c6fda89
commit
45b9366dd0
27 changed files with 973 additions and 136 deletions
15
README.md
15
README.md
|
@ -18,6 +18,7 @@ Full featured and highly configurable SFTP server
|
|||
- Per user and per directory permissions: list directories content, upload, overwrite, download, delete, rename, create directories, create symlinks, changing owner/group and mode, changing access and modification times can be enabled or disabled.
|
||||
- Per user files/folders ownership: you can map all the users to the system account that runs SFTPGo (all platforms are supported) or you can run SFTPGo as root user and map each user or group of users to a different system account (*NIX only).
|
||||
- Per user IP filters are supported: login can be restricted to specific ranges of IP addresses or to a specific IP address.
|
||||
- Virtual folders are supported: directories outside the user home directory can be exposed as virtual folders.
|
||||
- Configurable custom commands and/or HTTP notifications on file upload, download, delete, rename, on SSH commands and on user add, update and delete.
|
||||
- Automatically terminating idle connections.
|
||||
- Atomic uploads are configurable.
|
||||
|
@ -666,7 +667,8 @@ For each account the following properties can be configured:
|
|||
- `public_keys` array of public keys. At least one public key or the password is mandatory.
|
||||
- `status` 1 means "active", 0 "inactive". An inactive account cannot login.
|
||||
- `expiration_date` expiration date as unix timestamp in milliseconds. An expired account cannot login. 0 means no expiration.
|
||||
- `home_dir` The user cannot upload or download files outside this directory. Must be an absolute path.
|
||||
- `home_dir` the user cannot upload or download files outside this directory. Must be an absolute path.
|
||||
- `virtual_folders` list of mappings between virtual SFTP/SCP paths and local filesystem paths outside the user home directory. The specified paths must be absolute and the virtual path cannot be "/", it must be a sub directory. The parent directory for the specified virtual path must exist. SFTPGo will try to automatically create any missing parent directory for the configured virtual folders at user login
|
||||
- `uid`, `gid`. If sftpgo runs as root system user then the created files and directories will be assigned to this system uid/gid. Ignored on windows and if sftpgo runs as non root user: in this case files and directories for all SFTP users will be owned by the system user that runs sftpgo.
|
||||
- `max_sessions` maximum concurrent sessions. 0 means unlimited.
|
||||
- `quota_size` maximum size allowed as bytes. 0 means unlimited.
|
||||
|
@ -833,10 +835,19 @@ The logs can be divided into the following categories:
|
|||
- `login_type` string. Can be `publickey`, `password`, `keyboard-interactive` or `no_auth_tryed`
|
||||
- `error` string. Optional error description
|
||||
|
||||
### Brute force protection
|
||||
## Brute force protection
|
||||
|
||||
The **connection failed logs** can be used for integration in tools such as [Fail2ban](http://www.fail2ban.org/). Example of [jails](./fail2ban/jails) and [filters](./fail2ban/filters) working with `systemd`/`journald` are available in fail2ban directory.
|
||||
|
||||
## Performance
|
||||
|
||||
SFTPGo can easily saturate a Gigabit connection, on low end hardware, with no special configurations and this is generally enough for most use cases.
|
||||
|
||||
The main bootlenecks are the encryption and the messages authentication, so if you can use a fast cipher with implicit message authentication, for example `aes128-gcm@openssh.com`, you will get a big performace boost.
|
||||
|
||||
There is an open [issue](https://github.com/drakkan/sftpgo/issues/69) with some other suggestions to improve performance and some comparisons against OpenSSH.
|
||||
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
- [pkg/sftp](https://github.com/pkg/sftp)
|
||||
|
|
|
@ -500,6 +500,80 @@ func buildUserHomeDir(user *User) {
|
|||
}
|
||||
}
|
||||
|
||||
func isVirtualDirOverlapped(dir1, dir2 string) bool {
|
||||
if dir1 == dir2 {
|
||||
return true
|
||||
}
|
||||
if len(dir1) > len(dir2) {
|
||||
if strings.HasPrefix(dir1, dir2+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(dir2) > len(dir1) {
|
||||
if strings.HasPrefix(dir2, dir1+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isMappedDirOverlapped(dir1, dir2 string) bool {
|
||||
if dir1 == dir2 {
|
||||
return true
|
||||
}
|
||||
if len(dir1) > len(dir2) {
|
||||
if strings.HasPrefix(dir1, dir2+string(os.PathSeparator)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(dir2) > len(dir1) {
|
||||
if strings.HasPrefix(dir2, dir1+string(os.PathSeparator)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateVirtualFolders(user *User) error {
|
||||
if len(user.VirtualFolders) == 0 || user.FsConfig.Provider != 0 {
|
||||
user.VirtualFolders = []vfs.VirtualFolder{}
|
||||
return nil
|
||||
}
|
||||
var virtualFolders []vfs.VirtualFolder
|
||||
mappedPaths := make(map[string]string)
|
||||
for _, v := range user.VirtualFolders {
|
||||
cleanedVPath := filepath.ToSlash(path.Clean(v.VirtualPath))
|
||||
if !path.IsAbs(cleanedVPath) || cleanedVPath == "/" {
|
||||
return &ValidationError{err: fmt.Sprintf("invalid virtual folder %#v", v.VirtualPath)}
|
||||
}
|
||||
cleanedMPath := filepath.Clean(v.MappedPath)
|
||||
if !filepath.IsAbs(cleanedMPath) {
|
||||
return &ValidationError{err: fmt.Sprintf("invalid mapped folder %#v", v.MappedPath)}
|
||||
}
|
||||
if isMappedDirOverlapped(cleanedMPath, user.GetHomeDir()) {
|
||||
return &ValidationError{err: fmt.Sprintf("invalid mapped folder %#v cannot be inside or contain the user home dir %#v",
|
||||
v.MappedPath, user.GetHomeDir())}
|
||||
}
|
||||
virtualFolders = append(virtualFolders, vfs.VirtualFolder{
|
||||
VirtualPath: cleanedVPath,
|
||||
MappedPath: cleanedMPath,
|
||||
})
|
||||
for k, virtual := range mappedPaths {
|
||||
if isMappedDirOverlapped(k, cleanedMPath) {
|
||||
return &ValidationError{err: fmt.Sprintf("invalid mapped folder %#v overlaps with mapped folder %#v",
|
||||
v.MappedPath, k)}
|
||||
}
|
||||
if isVirtualDirOverlapped(virtual, cleanedVPath) {
|
||||
return &ValidationError{err: fmt.Sprintf("invalid virtual folder %#v overlaps with virtual folder %#v",
|
||||
v.VirtualPath, virtual)}
|
||||
}
|
||||
}
|
||||
mappedPaths[cleanedMPath] = cleanedVPath
|
||||
}
|
||||
user.VirtualFolders = virtualFolders
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePermissions(user *User) error {
|
||||
if len(user.Permissions) == 0 {
|
||||
return &ValidationError{err: "please grant some permissions to this user"}
|
||||
|
@ -659,6 +733,9 @@ func validateUser(user *User) error {
|
|||
if err := validateFilesystemConfig(user); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateVirtualFolders(user); err != nil {
|
||||
return err
|
||||
}
|
||||
if user.Status < 0 || user.Status > 1 {
|
||||
return &ValidationError{err: fmt.Sprintf("invalid user status: %v", user.Status)}
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ const (
|
|||
"`last_login` bigint(20) NOT NULL, `status` int(11) NOT NULL, `filters` longtext DEFAULT NULL, " +
|
||||
"`filesystem` longtext DEFAULT NULL);"
|
||||
mysqlSchemaTableSQL = "CREATE TABLE `schema_version` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, `version` integer NOT NULL);"
|
||||
mysqlUsersV2SQL = "ALTER TABLE `{{users}}` ADD COLUMN `virtual_folders` longtext NULL;"
|
||||
)
|
||||
|
||||
// MySQLProvider auth provider for MySQL/MariaDB database
|
||||
|
@ -143,5 +144,36 @@ func (p MySQLProvider) initializeDatabase() error {
|
|||
}
|
||||
|
||||
func (p MySQLProvider) migrateDatabase() error {
|
||||
return sqlCommonMigrateDatabase(p.dbHandle)
|
||||
dbVersion, err := sqlCommonGetDatabaseVersion(p.dbHandle)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dbVersion.Version == sqlDatabaseVersion {
|
||||
providerLog(logger.LevelDebug, "sql database is updated, current version: %v", dbVersion.Version)
|
||||
return nil
|
||||
}
|
||||
if dbVersion.Version == 1 {
|
||||
return updateMySQLDatabaseFrom1To2(p.dbHandle)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateMySQLDatabaseFrom1To2(dbHandle *sql.DB) error {
|
||||
providerLog(logger.LevelInfo, "updating database version: 1 -> 2")
|
||||
sql := strings.Replace(mysqlUsersV2SQL, "{{users}}", config.UsersTable, 1)
|
||||
tx, err := dbHandle.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(sql)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
err = sqlCommonUpdateDatabaseVersionWithTX(tx, 2)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ const (
|
|||
"expiration_date" bigint NOT NULL, "last_login" bigint NOT NULL, "status" integer NOT NULL, "filters" text NULL,
|
||||
"filesystem" text NULL);`
|
||||
pgsqlSchemaTableSQL = `CREATE TABLE "schema_version" ("id" serial NOT NULL PRIMARY KEY, "version" integer NOT NULL);`
|
||||
pgsqlUsersV2SQL = `ALTER TABLE "{{users}}" ADD COLUMN "virtual_folders" text NULL;`
|
||||
)
|
||||
|
||||
// PGSQLProvider auth provider for PostgreSQL database
|
||||
|
@ -141,5 +142,36 @@ func (p PGSQLProvider) initializeDatabase() error {
|
|||
}
|
||||
|
||||
func (p PGSQLProvider) migrateDatabase() error {
|
||||
return sqlCommonMigrateDatabase(p.dbHandle)
|
||||
dbVersion, err := sqlCommonGetDatabaseVersion(p.dbHandle)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dbVersion.Version == sqlDatabaseVersion {
|
||||
providerLog(logger.LevelDebug, "sql database is updated, current version: %v", dbVersion.Version)
|
||||
return nil
|
||||
}
|
||||
if dbVersion.Version == 1 {
|
||||
return updatePGSQLDatabaseFrom1To2(p.dbHandle)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updatePGSQLDatabaseFrom1To2(dbHandle *sql.DB) error {
|
||||
providerLog(logger.LevelInfo, "updating database version: 1 -> 2")
|
||||
sql := strings.Replace(pgsqlUsersV2SQL, "{{users}}", config.UsersTable, 1)
|
||||
tx, err := dbHandle.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(sql)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
err = sqlCommonUpdateDatabaseVersionWithTX(tx, 2)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
|
|
@ -9,10 +9,11 @@ import (
|
|||
|
||||
"github.com/drakkan/sftpgo/logger"
|
||||
"github.com/drakkan/sftpgo/utils"
|
||||
"github.com/drakkan/sftpgo/vfs"
|
||||
)
|
||||
|
||||
const (
|
||||
sqlDatabaseVersion = 1
|
||||
sqlDatabaseVersion = 2
|
||||
initialDBVersionSQL = "INSERT INTO schema_version (version) VALUES (1);"
|
||||
)
|
||||
|
||||
|
@ -171,9 +172,13 @@ func sqlCommonAddUser(user User, dbHandle *sql.DB) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
virtualFolders, err := user.GetVirtualFoldersAsJSON()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = stmt.Exec(user.Username, user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions, user.QuotaSize,
|
||||
user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate, string(filters),
|
||||
string(fsConfig))
|
||||
string(fsConfig), string(virtualFolders))
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -205,9 +210,13 @@ func sqlCommonUpdateUser(user User, dbHandle *sql.DB) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
virtualFolders, err := user.GetVirtualFoldersAsJSON()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = stmt.Exec(user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions, user.QuotaSize,
|
||||
user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate,
|
||||
string(filters), string(fsConfig), user.ID)
|
||||
string(filters), string(fsConfig), string(virtualFolders), user.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -281,6 +290,25 @@ func sqlCommonGetUsers(limit int, offset int, order string, username string, dbH
|
|||
return users, err
|
||||
}
|
||||
|
||||
func updateUserPermissionsFromDb(user *User, permissions string) error {
|
||||
var err error
|
||||
perms := make(map[string][]string)
|
||||
err = json.Unmarshal([]byte(permissions), &perms)
|
||||
if err == nil {
|
||||
user.Permissions = perms
|
||||
} else {
|
||||
// compatibility layer: until version 0.9.4 permissions were a string list
|
||||
var list []string
|
||||
err = json.Unmarshal([]byte(permissions), &list)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
perms["/"] = list
|
||||
user.Permissions = perms
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) {
|
||||
var user User
|
||||
var permissions sql.NullString
|
||||
|
@ -288,16 +316,19 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) {
|
|||
var publicKey sql.NullString
|
||||
var filters sql.NullString
|
||||
var fsConfig sql.NullString
|
||||
var virtualFolders sql.NullString
|
||||
var err error
|
||||
if row != nil {
|
||||
err = row.Scan(&user.ID, &user.Username, &password, &publicKey, &user.HomeDir, &user.UID, &user.GID, &user.MaxSessions,
|
||||
&user.QuotaSize, &user.QuotaFiles, &permissions, &user.UsedQuotaSize, &user.UsedQuotaFiles, &user.LastQuotaUpdate,
|
||||
&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters, &fsConfig)
|
||||
&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters, &fsConfig,
|
||||
&virtualFolders)
|
||||
|
||||
} else {
|
||||
err = rows.Scan(&user.ID, &user.Username, &password, &publicKey, &user.HomeDir, &user.UID, &user.GID, &user.MaxSessions,
|
||||
&user.QuotaSize, &user.QuotaFiles, &permissions, &user.UsedQuotaSize, &user.UsedQuotaFiles, &user.LastQuotaUpdate,
|
||||
&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters, &fsConfig)
|
||||
&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters, &fsConfig,
|
||||
&virtualFolders)
|
||||
}
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
|
@ -308,6 +339,9 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) {
|
|||
if password.Valid {
|
||||
user.Password = password.String
|
||||
}
|
||||
// we can have a empty string or an invalid json in null string
|
||||
// so we do a relaxed test if the field is optional, for example we
|
||||
// populate public keys only if unmarshal does not return an error
|
||||
if publicKey.Valid {
|
||||
var list []string
|
||||
err = json.Unmarshal([]byte(publicKey.String), &list)
|
||||
|
@ -316,18 +350,9 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) {
|
|||
}
|
||||
}
|
||||
if permissions.Valid {
|
||||
perms := make(map[string][]string)
|
||||
err = json.Unmarshal([]byte(permissions.String), &perms)
|
||||
if err == nil {
|
||||
user.Permissions = perms
|
||||
} else {
|
||||
// compatibility layer: until version 0.9.4 permissions were a string list
|
||||
var list []string
|
||||
err = json.Unmarshal([]byte(permissions.String), &list)
|
||||
if err == nil {
|
||||
perms["/"] = list
|
||||
user.Permissions = perms
|
||||
}
|
||||
err = updateUserPermissionsFromDb(&user, permissions.String)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
}
|
||||
if filters.Valid {
|
||||
|
@ -336,11 +361,6 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) {
|
|||
if err == nil {
|
||||
user.Filters = userFilters
|
||||
}
|
||||
} else {
|
||||
user.Filters = UserFilters{
|
||||
AllowedIP: []string{},
|
||||
DeniedIP: []string{},
|
||||
}
|
||||
}
|
||||
if fsConfig.Valid {
|
||||
var fs Filesystem
|
||||
|
@ -348,26 +368,17 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) {
|
|||
if err == nil {
|
||||
user.FsConfig = fs
|
||||
}
|
||||
} else {
|
||||
user.FsConfig = Filesystem{
|
||||
Provider: 0,
|
||||
}
|
||||
if virtualFolders.Valid {
|
||||
var list []vfs.VirtualFolder
|
||||
err = json.Unmarshal([]byte(virtualFolders.String), &list)
|
||||
if err == nil {
|
||||
user.VirtualFolders = list
|
||||
}
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
|
||||
func sqlCommonMigrateDatabase(dbHandle *sql.DB) error {
|
||||
dbVersion, err := sqlCommonGetDatabaseVersion(dbHandle)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dbVersion.Version == sqlDatabaseVersion {
|
||||
providerLog(logger.LevelDebug, "sql database is updated, current version: %v", dbVersion.Version)
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sqlCommonGetDatabaseVersion(dbHandle *sql.DB) (schemaVersion, error) {
|
||||
var result schemaVersion
|
||||
q := getDatabaseVersionQuery()
|
||||
|
@ -382,7 +393,7 @@ func sqlCommonGetDatabaseVersion(dbHandle *sql.DB) (schemaVersion, error) {
|
|||
return result, err
|
||||
}
|
||||
|
||||
func sqlCommonUpdateDatabaseVersion(dbHandle *sql.DB) error {
|
||||
func sqlCommonUpdateDatabaseVersion(dbHandle *sql.DB, version int) error {
|
||||
q := getUpdateDBVersionQuery()
|
||||
stmt, err := dbHandle.Prepare(q)
|
||||
if err != nil {
|
||||
|
@ -390,7 +401,18 @@ func sqlCommonUpdateDatabaseVersion(dbHandle *sql.DB) error {
|
|||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
_, err = stmt.Exec(sqlDatabaseVersion)
|
||||
_, err = stmt.Exec(version)
|
||||
return err
|
||||
}
|
||||
|
||||
func sqlCommonUpdateDatabaseVersionWithTX(tx *sql.Tx, version int) error {
|
||||
q := getUpdateDBVersionQuery()
|
||||
stmt, err := tx.Prepare(q)
|
||||
if err != nil {
|
||||
providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err)
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
_, err = stmt.Exec(version)
|
||||
return err
|
||||
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ NOT NULL UNIQUE, "password" varchar(255) NULL, "public_keys" text NULL, "home_di
|
|||
"expiration_date" bigint NOT NULL, "last_login" bigint NOT NULL, "status" integer NOT NULL, "filters" text NULL,
|
||||
"filesystem" text NULL);`
|
||||
sqliteSchemaTableSQL = `CREATE TABLE "schema_version" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "version" integer NOT NULL);`
|
||||
sqliteUsersV2SQL = `ALTER TABLE "{{users}}" ADD COLUMN "virtual_folders" text NULL;`
|
||||
)
|
||||
|
||||
// SQLiteProvider auth provider for SQLite database
|
||||
|
@ -119,5 +120,26 @@ func (p SQLiteProvider) initializeDatabase() error {
|
|||
}
|
||||
|
||||
func (p SQLiteProvider) migrateDatabase() error {
|
||||
return sqlCommonMigrateDatabase(p.dbHandle)
|
||||
dbVersion, err := sqlCommonGetDatabaseVersion(p.dbHandle)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dbVersion.Version == sqlDatabaseVersion {
|
||||
providerLog(logger.LevelDebug, "sql database is updated, current version: %v", dbVersion.Version)
|
||||
return nil
|
||||
}
|
||||
if dbVersion.Version == 1 {
|
||||
return updateSQLiteDatabaseFrom1To2(p.dbHandle)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateSQLiteDatabaseFrom1To2(dbHandle *sql.DB) error {
|
||||
providerLog(logger.LevelInfo, "updating database version: 1 -> 2")
|
||||
sql := strings.Replace(sqliteUsersV2SQL, "{{users}}", config.UsersTable, 1)
|
||||
_, err := dbHandle.Exec(sql)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return sqlCommonUpdateDatabaseVersion(dbHandle, 2)
|
||||
}
|
||||
|
|
|
@ -4,7 +4,8 @@ import "fmt"
|
|||
|
||||
const (
|
||||
selectUserFields = "id,username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions,used_quota_size," +
|
||||
"used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,expiration_date,last_login,status,filters,filesystem"
|
||||
"used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,expiration_date,last_login,status,filters,filesystem," +
|
||||
"virtual_folders"
|
||||
)
|
||||
|
||||
func getSQLPlaceholders() []string {
|
||||
|
@ -61,19 +62,20 @@ func getQuotaQuery() string {
|
|||
func getAddUserQuery() string {
|
||||
return fmt.Sprintf(`INSERT INTO %v (username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions,
|
||||
used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,status,last_login,expiration_date,filters,
|
||||
filesystem)
|
||||
VALUES (%v,%v,%v,%v,%v,%v,%v,%v,%v,%v,0,0,0,%v,%v,%v,0,%v,%v,%v)`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1],
|
||||
filesystem,virtual_folders)
|
||||
VALUES (%v,%v,%v,%v,%v,%v,%v,%v,%v,%v,0,0,0,%v,%v,%v,0,%v,%v,%v,%v)`, config.UsersTable, 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], sqlPlaceholders[13],
|
||||
sqlPlaceholders[14], sqlPlaceholders[15])
|
||||
sqlPlaceholders[14], sqlPlaceholders[15], sqlPlaceholders[16])
|
||||
}
|
||||
|
||||
func getUpdateUserQuery() string {
|
||||
return fmt.Sprintf(`UPDATE %v SET password=%v,public_keys=%v,home_dir=%v,uid=%v,gid=%v,max_sessions=%v,quota_size=%v,
|
||||
quota_files=%v,permissions=%v,upload_bandwidth=%v,download_bandwidth=%v,status=%v,expiration_date=%v,filters=%v,filesystem=%v
|
||||
WHERE id = %v`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3],
|
||||
quota_files=%v,permissions=%v,upload_bandwidth=%v,download_bandwidth=%v,status=%v,expiration_date=%v,filters=%v,filesystem=%v,
|
||||
virtual_folders=%v WHERE id = %v`, config.UsersTable, 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], sqlPlaceholders[13], sqlPlaceholders[14], sqlPlaceholders[15])
|
||||
sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13], sqlPlaceholders[14], sqlPlaceholders[15],
|
||||
sqlPlaceholders[16])
|
||||
}
|
||||
|
||||
func getDeleteUserQuery() string {
|
||||
|
|
|
@ -4,9 +4,11 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/drakkan/sftpgo/logger"
|
||||
"github.com/drakkan/sftpgo/utils"
|
||||
|
@ -90,6 +92,8 @@ type User struct {
|
|||
PublicKeys []string `json:"public_keys,omitempty"`
|
||||
// The user cannot upload or download files outside this directory. Must be an absolute path
|
||||
HomeDir string `json:"home_dir"`
|
||||
// Mapping between virtual paths and filesystem paths outside the home directory. Supported for local filesystem only
|
||||
VirtualFolders []vfs.VirtualFolder `json:"virtual_folders,omitempty"`
|
||||
// If sftpgo runs as root system user then the created files and directories will be assigned to this system UID
|
||||
UID int `json:"uid"`
|
||||
// If sftpgo runs as root system user then the created files and directories will be assigned to this system GID
|
||||
|
@ -129,7 +133,7 @@ func (u *User) GetFilesystem(connectionID string) (vfs.Fs, error) {
|
|||
config.CredentialFile = u.getGCSCredentialsFilePath()
|
||||
return vfs.NewGCSFs(connectionID, u.GetHomeDir(), config)
|
||||
}
|
||||
return vfs.NewOsFs(connectionID, u.GetHomeDir()), nil
|
||||
return vfs.NewOsFs(connectionID, u.GetHomeDir(), u.VirtualFolders), nil
|
||||
}
|
||||
|
||||
// GetPermissionsForPath returns the permissions for the given path.
|
||||
|
@ -144,19 +148,7 @@ func (u *User) GetPermissionsForPath(p string) []string {
|
|||
// fallback permissions
|
||||
permissions = perms
|
||||
}
|
||||
sftpPath := filepath.ToSlash(p)
|
||||
if !path.IsAbs(p) {
|
||||
sftpPath = "/" + sftpPath
|
||||
}
|
||||
sftpPath = path.Clean(sftpPath)
|
||||
dirsForPath := []string{sftpPath}
|
||||
for {
|
||||
if sftpPath == "/" {
|
||||
break
|
||||
}
|
||||
sftpPath = path.Dir(sftpPath)
|
||||
dirsForPath = append(dirsForPath, sftpPath)
|
||||
}
|
||||
dirsForPath := utils.GetDirsForSFTPPath(p)
|
||||
// dirsForPath contains all the dirs for a given path in reverse order
|
||||
// for example if the path is: /1/2/3/4 it contains:
|
||||
// [ "/1/2/3/4", "/1/2/3", "/1/2", "/1", "/" ]
|
||||
|
@ -170,6 +162,40 @@ func (u *User) GetPermissionsForPath(p string) []string {
|
|||
return permissions
|
||||
}
|
||||
|
||||
// AddVirtualDirs adds virtual folders, if defined, to the given files list
|
||||
func (u *User) AddVirtualDirs(list []os.FileInfo, sftpPath string) []os.FileInfo {
|
||||
if len(u.VirtualFolders) == 0 {
|
||||
return list
|
||||
}
|
||||
for _, v := range u.VirtualFolders {
|
||||
if path.Dir(v.VirtualPath) == sftpPath {
|
||||
fi := vfs.NewFileInfo(path.Base(v.VirtualPath), true, 0, time.Time{})
|
||||
found := false
|
||||
for index, f := range list {
|
||||
if f.Name() == fi.Name() {
|
||||
list[index] = fi
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
list = append(list, fi)
|
||||
}
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// IsVirtualFolder returns true if the specified sftp path is a virtual folder
|
||||
func (u *User) IsVirtualFolder(sftpPath string) bool {
|
||||
for _, v := range u.VirtualFolders {
|
||||
if sftpPath == v.VirtualPath {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasPerm returns true if the user has the given permission or any permission
|
||||
func (u *User) HasPerm(permission, path string) bool {
|
||||
perms := u.GetPermissionsForPath(path)
|
||||
|
@ -259,6 +285,11 @@ func (u *User) GetFsConfigAsJSON() ([]byte, error) {
|
|||
return json.Marshal(u.FsConfig)
|
||||
}
|
||||
|
||||
// GetVirtualFoldersAsJSON returns the virtual folders as json byte array
|
||||
func (u *User) GetVirtualFoldersAsJSON() ([]byte, error) {
|
||||
return json.Marshal(u.VirtualFolders)
|
||||
}
|
||||
|
||||
// GetUID returns a validate uid, suitable for use with os.Chown
|
||||
func (u *User) GetUID() int {
|
||||
if u.UID <= 0 || u.UID > 65535 {
|
||||
|
@ -417,6 +448,8 @@ func (u User) GetDeniedIPAsString() string {
|
|||
func (u *User) getACopy() User {
|
||||
pubKeys := make([]string, len(u.PublicKeys))
|
||||
copy(pubKeys, u.PublicKeys)
|
||||
virtualFolders := make([]vfs.VirtualFolder, len(u.VirtualFolders))
|
||||
copy(virtualFolders, u.VirtualFolders)
|
||||
permissions := make(map[string][]string)
|
||||
for k, v := range u.Permissions {
|
||||
perms := make([]string, len(v))
|
||||
|
@ -456,6 +489,7 @@ func (u *User) getACopy() User {
|
|||
Password: u.Password,
|
||||
PublicKeys: pubKeys,
|
||||
HomeDir: u.HomeDir,
|
||||
VirtualFolders: virtualFolders,
|
||||
UID: u.UID,
|
||||
GID: u.GID,
|
||||
MaxSessions: u.MaxSessions,
|
||||
|
|
|
@ -12,6 +12,7 @@ import (
|
|||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
@ -422,10 +423,32 @@ func checkUser(expected *dataprovider.User, actual *dataprovider.User) error {
|
|||
if err := compareUserFsConfig(expected, actual); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := compareUserVirtualFolders(expected, actual); err != nil {
|
||||
return err
|
||||
}
|
||||
return compareEqualsUserFields(expected, actual)
|
||||
}
|
||||
|
||||
func compareUserVirtualFolders(expected *dataprovider.User, actual *dataprovider.User) error {
|
||||
if len(actual.VirtualFolders) != len(expected.VirtualFolders) {
|
||||
return errors.New("Virtual folders mismatch")
|
||||
}
|
||||
for _, v := range actual.VirtualFolders {
|
||||
found := false
|
||||
for _, v1 := range expected.VirtualFolders {
|
||||
if path.Clean(v.VirtualPath) == path.Clean(v1.VirtualPath) &&
|
||||
filepath.Clean(v.MappedPath) == filepath.Clean(v1.MappedPath) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return errors.New("Virtual folders mismatch")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func compareUserFsConfig(expected *dataprovider.User, actual *dataprovider.User) error {
|
||||
if expected.FsConfig.Provider != actual.FsConfig.Provider {
|
||||
return errors.New("Fs provider mismatch")
|
||||
|
|
|
@ -104,6 +104,7 @@ func (c Conf) Initialize(configDir string) error {
|
|||
Handler: router,
|
||||
ReadTimeout: 60 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
MaxHeaderBytes: 1 << 16, // 64KB
|
||||
}
|
||||
if len(certificateFile) > 0 && len(certificateKeyFile) > 0 {
|
||||
|
|
|
@ -33,6 +33,7 @@ import (
|
|||
"github.com/drakkan/sftpgo/logger"
|
||||
"github.com/drakkan/sftpgo/sftpd"
|
||||
"github.com/drakkan/sftpgo/utils"
|
||||
"github.com/drakkan/sftpgo/vfs"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -376,6 +377,132 @@ func TestAddUserInvalidFsConfig(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAddUserInvalidVirtualFolders(t *testing.T) {
|
||||
u := getTestUser()
|
||||
u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
|
||||
VirtualPath: "vdir",
|
||||
MappedPath: filepath.Join(os.TempDir(), "mapped_dir"),
|
||||
})
|
||||
_, _, err := httpd.AddUser(u, http.StatusBadRequest)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error adding user with invalid virtual folder: %v", err)
|
||||
}
|
||||
u.VirtualFolders = nil
|
||||
u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
|
||||
VirtualPath: "/",
|
||||
MappedPath: filepath.Join(os.TempDir(), "mapped_dir"),
|
||||
})
|
||||
_, _, err = httpd.AddUser(u, http.StatusBadRequest)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error adding user with invalid virtual folder: %v", err)
|
||||
}
|
||||
u.VirtualFolders = nil
|
||||
u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
|
||||
VirtualPath: "/vdir",
|
||||
MappedPath: filepath.Join(u.GetHomeDir(), "mapped_dir"),
|
||||
})
|
||||
_, _, err = httpd.AddUser(u, http.StatusBadRequest)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error adding user with invalid virtual folder: %v", err)
|
||||
}
|
||||
u.VirtualFolders = nil
|
||||
u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
|
||||
VirtualPath: "/vdir",
|
||||
MappedPath: u.GetHomeDir(),
|
||||
})
|
||||
_, _, err = httpd.AddUser(u, http.StatusBadRequest)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error adding user with invalid virtual folder: %v", err)
|
||||
}
|
||||
u.VirtualFolders = nil
|
||||
u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
|
||||
VirtualPath: "/vdir",
|
||||
MappedPath: filepath.Join(u.GetHomeDir(), ".."),
|
||||
})
|
||||
_, _, err = httpd.AddUser(u, http.StatusBadRequest)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error adding user with invalid virtual folder: %v", err)
|
||||
}
|
||||
u.VirtualFolders = nil
|
||||
u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
|
||||
VirtualPath: "/vdir",
|
||||
MappedPath: filepath.Join(os.TempDir(), "mapped_dir"),
|
||||
})
|
||||
u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
|
||||
VirtualPath: "/vdir",
|
||||
MappedPath: filepath.Join(os.TempDir(), "mapped_dir1"),
|
||||
})
|
||||
_, _, err = httpd.AddUser(u, http.StatusBadRequest)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error adding user with invalid virtual folder: %v", err)
|
||||
}
|
||||
u.VirtualFolders = nil
|
||||
u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
|
||||
VirtualPath: "/vdir1",
|
||||
MappedPath: filepath.Join(os.TempDir(), "mapped_dir"),
|
||||
})
|
||||
u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
|
||||
VirtualPath: "/vdir2",
|
||||
MappedPath: filepath.Join(os.TempDir(), "mapped_dir"),
|
||||
})
|
||||
_, _, err = httpd.AddUser(u, http.StatusBadRequest)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error adding user with invalid virtual folder: %v", err)
|
||||
}
|
||||
u.VirtualFolders = nil
|
||||
u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
|
||||
VirtualPath: "/vdir1",
|
||||
MappedPath: filepath.Join(os.TempDir(), "mapped_dir", "subdir"),
|
||||
})
|
||||
u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
|
||||
VirtualPath: "/vdir2",
|
||||
MappedPath: filepath.Join(os.TempDir(), "mapped_dir"),
|
||||
})
|
||||
_, _, err = httpd.AddUser(u, http.StatusBadRequest)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error adding user with invalid virtual folder: %v", err)
|
||||
}
|
||||
u.VirtualFolders = nil
|
||||
u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
|
||||
VirtualPath: "/vdir1",
|
||||
MappedPath: filepath.Join(os.TempDir(), "mapped_dir"),
|
||||
})
|
||||
u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
|
||||
VirtualPath: "/vdir2",
|
||||
MappedPath: filepath.Join(os.TempDir(), "mapped_dir", "subdir"),
|
||||
})
|
||||
_, _, err = httpd.AddUser(u, http.StatusBadRequest)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error adding user with invalid virtual folder: %v", err)
|
||||
}
|
||||
u.VirtualFolders = nil
|
||||
u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
|
||||
VirtualPath: "/vdir1/subdir",
|
||||
MappedPath: filepath.Join(os.TempDir(), "mapped_dir1"),
|
||||
})
|
||||
u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
|
||||
VirtualPath: "/vdir1/../vdir1",
|
||||
MappedPath: filepath.Join(os.TempDir(), "mapped_dir2"),
|
||||
})
|
||||
_, _, err = httpd.AddUser(u, http.StatusBadRequest)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error adding user with invalid virtual folder: %v", err)
|
||||
}
|
||||
u.VirtualFolders = nil
|
||||
u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
|
||||
VirtualPath: "/vdir1/",
|
||||
MappedPath: filepath.Join(os.TempDir(), "mapped_dir1"),
|
||||
})
|
||||
u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
|
||||
VirtualPath: "/vdir1/subdir",
|
||||
MappedPath: filepath.Join(os.TempDir(), "mapped_dir2"),
|
||||
})
|
||||
_, _, err = httpd.AddUser(u, http.StatusBadRequest)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error adding user with invalid virtual folder: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserPublicKey(t *testing.T) {
|
||||
u := getTestUser()
|
||||
invalidPubKey := "invalid"
|
||||
|
@ -424,6 +551,15 @@ func TestUpdateUser(t *testing.T) {
|
|||
user.Filters.DeniedLoginMethods = []string{dataprovider.SSHLoginMethodPassword}
|
||||
user.UploadBandwidth = 1024
|
||||
user.DownloadBandwidth = 512
|
||||
user.VirtualFolders = nil
|
||||
user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{
|
||||
VirtualPath: "/vdir1",
|
||||
MappedPath: filepath.Join(os.TempDir(), "mapped_dir1"),
|
||||
})
|
||||
user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{
|
||||
VirtualPath: "/vdir12/subdir",
|
||||
MappedPath: filepath.Join(os.TempDir(), "mapped_dir2"),
|
||||
})
|
||||
user, _, err = httpd.UpdateUser(user, http.StatusOK)
|
||||
if err != nil {
|
||||
t.Errorf("unable to update user: %v", err)
|
||||
|
@ -1560,6 +1696,7 @@ func TestWebUserAddMock(t *testing.T) {
|
|||
user.UploadBandwidth = 32
|
||||
user.DownloadBandwidth = 64
|
||||
user.UID = 1000
|
||||
mappedDir := filepath.Join(os.TempDir(), "mapped")
|
||||
form := make(url.Values)
|
||||
form.Set("username", user.Username)
|
||||
form.Set("home_dir", user.HomeDir)
|
||||
|
@ -1567,7 +1704,8 @@ func TestWebUserAddMock(t *testing.T) {
|
|||
form.Set("status", strconv.Itoa(user.Status))
|
||||
form.Set("expiration_date", "")
|
||||
form.Set("permissions", "*")
|
||||
form.Set("sub_dirs_permissions", " /subdir:list ,download ")
|
||||
form.Set("sub_dirs_permissions", " /subdir::list ,download ")
|
||||
form.Set("virtual_folders", fmt.Sprintf(" /vdir:: %v ", mappedDir))
|
||||
b, contentType, _ := getMultipartFormData(form, "", "")
|
||||
// test invalid url escape
|
||||
req, _ := http.NewRequest(http.MethodPost, webUserPath+"?a=%2", &b)
|
||||
|
@ -1696,7 +1834,16 @@ func TestWebUserAddMock(t *testing.T) {
|
|||
t.Error("permssions for /subdir does not match")
|
||||
}
|
||||
} else {
|
||||
t.Errorf("user permissions must contains /somedir, actual: %v", newUser.Permissions)
|
||||
t.Errorf("user permissions must contain /somedir, actual: %v", newUser.Permissions)
|
||||
}
|
||||
vfolderFoumd := false
|
||||
for _, v := range newUser.VirtualFolders {
|
||||
if v.VirtualPath == "/vdir" && v.MappedPath == mappedDir {
|
||||
vfolderFoumd = true
|
||||
}
|
||||
}
|
||||
if !vfolderFoumd {
|
||||
t.Errorf("virtual folders must contain /vdir, actual: %+v", newUser.VirtualFolders)
|
||||
}
|
||||
req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(newUser.ID, 10), nil)
|
||||
rr = executeRequest(req)
|
||||
|
@ -1728,7 +1875,7 @@ func TestWebUserUpdateMock(t *testing.T) {
|
|||
form.Set("upload_bandwidth", "0")
|
||||
form.Set("download_bandwidth", "0")
|
||||
form.Set("permissions", "*")
|
||||
form.Set("sub_dirs_permissions", "/otherdir : list ,upload ")
|
||||
form.Set("sub_dirs_permissions", "/otherdir :: list ,upload ")
|
||||
form.Set("status", strconv.Itoa(user.Status))
|
||||
form.Set("expiration_date", "2020-01-01 00:00:00")
|
||||
form.Set("allowed_ip", " 192.168.1.3/32, 192.168.2.0/24 ")
|
||||
|
|
|
@ -17,6 +17,7 @@ import (
|
|||
"github.com/drakkan/sftpgo/dataprovider"
|
||||
"github.com/drakkan/sftpgo/sftpd"
|
||||
"github.com/drakkan/sftpgo/utils"
|
||||
"github.com/drakkan/sftpgo/vfs"
|
||||
"github.com/go-chi/chi"
|
||||
)
|
||||
|
||||
|
@ -103,6 +104,23 @@ func TestCheckUser(t *testing.T) {
|
|||
if err == nil {
|
||||
t.Errorf("Fs providers are not equal")
|
||||
}
|
||||
actual.FsConfig.Provider = 0
|
||||
expected.VirtualFolders = append(expected.VirtualFolders, vfs.VirtualFolder{
|
||||
VirtualPath: "/vdir",
|
||||
MappedPath: os.TempDir(),
|
||||
})
|
||||
err = checkUser(expected, actual)
|
||||
if err == nil {
|
||||
t.Errorf("Virtual folders are not equal")
|
||||
}
|
||||
actual.VirtualFolders = append(actual.VirtualFolders, vfs.VirtualFolder{
|
||||
VirtualPath: "/vdir1",
|
||||
MappedPath: os.TempDir(),
|
||||
})
|
||||
err = checkUser(expected, actual)
|
||||
if err == nil {
|
||||
t.Errorf("Virtual folders are not equal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareUserFilters(t *testing.T) {
|
||||
|
|
|
@ -2,7 +2,7 @@ openapi: 3.0.1
|
|||
info:
|
||||
title: SFTPGo
|
||||
description: 'SFTPGo REST API'
|
||||
version: 1.8.1
|
||||
version: 1.8.2
|
||||
|
||||
servers:
|
||||
- url: /api/v1
|
||||
|
@ -1036,6 +1036,17 @@ components:
|
|||
gcsconfig:
|
||||
$ref: '#/components/schemas/GCSConfig'
|
||||
description: Storage filesystem details
|
||||
VirtualFolder:
|
||||
type: object
|
||||
properties:
|
||||
virtual_path:
|
||||
type: string
|
||||
mapped_path:
|
||||
type: string
|
||||
required:
|
||||
- virtual_path
|
||||
- mapped_path
|
||||
description: A virtual folder is a mapping between a SFTP/SCP virtual path and a filesystem path outside the user home directory. The specified paths must be absolute and the virtual path cannot be "/", it must be a sub directory. The parent directory for the specified virtual path must exist. SFTPGo will try to automatically create any missing parent directory for the configured virtual folders at user login.
|
||||
User:
|
||||
type: object
|
||||
properties:
|
||||
|
@ -1071,6 +1082,12 @@ components:
|
|||
home_dir:
|
||||
type: string
|
||||
description: path to the user home directory. The user cannot upload or download files outside this directory. SFTPGo tries to automatically create this folder if missing. Must be an absolute path
|
||||
virtual_folders:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/VirtualFolder'
|
||||
nullable: true
|
||||
description: mapping between virtual SFTP/SCP paths and filesystem paths outside the user home directory. Supported for local filesystem only
|
||||
uid:
|
||||
type: integer
|
||||
format: int32
|
||||
|
|
23
httpd/web.go
23
httpd/web.go
|
@ -15,6 +15,7 @@ import (
|
|||
"github.com/drakkan/sftpgo/dataprovider"
|
||||
"github.com/drakkan/sftpgo/sftpd"
|
||||
"github.com/drakkan/sftpgo/utils"
|
||||
"github.com/drakkan/sftpgo/vfs"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -186,13 +187,30 @@ func renderUpdateUserPage(w http.ResponseWriter, user dataprovider.User, error s
|
|||
renderTemplate(w, templateUser, data)
|
||||
}
|
||||
|
||||
func getVirtualFoldersFromPostFields(r *http.Request) []vfs.VirtualFolder {
|
||||
var virtualFolders []vfs.VirtualFolder
|
||||
formValue := r.Form.Get("virtual_folders")
|
||||
for _, cleaned := range getSliceFromDelimitedValues(formValue, "\n") {
|
||||
if strings.Contains(cleaned, "::") {
|
||||
mapping := strings.Split(cleaned, "::")
|
||||
if len(mapping) > 1 {
|
||||
virtualFolders = append(virtualFolders, vfs.VirtualFolder{
|
||||
VirtualPath: strings.TrimSpace(mapping[0]),
|
||||
MappedPath: strings.TrimSpace(mapping[1]),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return virtualFolders
|
||||
}
|
||||
|
||||
func getUserPermissionsFromPostFields(r *http.Request) map[string][]string {
|
||||
permissions := make(map[string][]string)
|
||||
permissions["/"] = r.Form["permissions"]
|
||||
subDirsPermsValue := r.Form.Get("sub_dirs_permissions")
|
||||
for _, cleaned := range getSliceFromDelimitedValues(subDirsPermsValue, "\n") {
|
||||
if strings.ContainsRune(cleaned, ':') {
|
||||
dirPerms := strings.Split(cleaned, ":")
|
||||
if strings.Contains(cleaned, "::") {
|
||||
dirPerms := strings.Split(cleaned, "::")
|
||||
if len(dirPerms) > 1 {
|
||||
dir := dirPerms[0]
|
||||
dir = strings.TrimSpace(dir)
|
||||
|
@ -335,6 +353,7 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
|
|||
Password: r.Form.Get("password"),
|
||||
PublicKeys: publicKeys,
|
||||
HomeDir: r.Form.Get("home_dir"),
|
||||
VirtualFolders: getVirtualFoldersFromPostFields(r),
|
||||
UID: uid,
|
||||
GID: gid,
|
||||
Permissions: getUserPermissionsFromPostFields(r),
|
||||
|
|
|
@ -44,7 +44,7 @@ Let's see a sample usage for each REST API.
|
|||
Command:
|
||||
|
||||
```
|
||||
python sftpgo_api_cli.py add-user test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 33 --gid 1000 --max-sessions 2 --quota-size 0 --quota-files 3 --permissions "list" "download" "upload" "delete" "rename" "create_dirs" "overwrite" --subdirs-permissions "/dir1:list,download" "/dir2:*" --upload-bandwidth 100 --download-bandwidth 60 --status 0 --expiration-date 2019-01-01 --allowed-ip "192.168.1.1/32" --fs S3 --s3-bucket test --s3-region eu-west-1 --s3-access-key accesskey --s3-access-secret secret --s3-endpoint "http://127.0.0.1:9000" --s3-storage-class Standard --s3-key-prefix "vfolder/" --denied-login-methods "password" "keyboard-interactive"
|
||||
python sftpgo_api_cli.py add-user test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 33 --gid 1000 --max-sessions 2 --quota-size 0 --quota-files 3 --permissions "list" "download" "upload" "delete" "rename" "create_dirs" "overwrite" --subdirs-permissions "/dir1::list,download" "/dir2::*" --upload-bandwidth 100 --download-bandwidth 60 --status 0 --expiration-date 2019-01-01 --allowed-ip "192.168.1.1/32" --fs S3 --s3-bucket test --s3-region eu-west-1 --s3-access-key accesskey --s3-access-secret secret --s3-endpoint "http://127.0.0.1:9000" --s3-storage-class Standard --s3-key-prefix "vfolder/" --denied-login-methods "password" "keyboard-interactive"
|
||||
```
|
||||
|
||||
Output:
|
||||
|
@ -115,7 +115,7 @@ Output:
|
|||
Command:
|
||||
|
||||
```
|
||||
python sftpgo_api_cli.py update-user 9576 test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 0 --gid 33 --max-sessions 3 --quota-size 0 --quota-files 4 --permissions "*" --subdirs-permissions "/dir1:list,download,create_symlinks" --upload-bandwidth 90 --download-bandwidth 80 --status 1 --expiration-date "" --allowed-ip "" --denied-ip "192.168.1.0/24" --denied-login-methods "" --fs local
|
||||
python sftpgo_api_cli.py update-user 9576 test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 0 --gid 33 --max-sessions 3 --quota-size 0 --quota-files 4 --permissions "*" --subdirs-permissions "/dir1::list,download,create_symlinks" --upload-bandwidth 90 --download-bandwidth 80 --status 1 --expiration-date "" --allowed-ip "" --denied-ip "192.168.1.0/24" --denied-login-methods "" --fs local --virtual-folders "/vdir1::/tmp/mapped1" "/vdir2::/tmp/mapped2"
|
||||
```
|
||||
|
||||
Output:
|
||||
|
@ -175,7 +175,17 @@ Output:
|
|||
"upload_bandwidth": 90,
|
||||
"used_quota_files": 0,
|
||||
"used_quota_size": 0,
|
||||
"username": "test_username"
|
||||
"username": "test_username",
|
||||
"virtual_folders": [
|
||||
{
|
||||
"mapped_path": "/tmp/mapped1",
|
||||
"virtual_path": "/vdir1"
|
||||
},
|
||||
{
|
||||
"mapped_path": "/tmp/mapped2",
|
||||
"virtual_path": "/vdir2"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
@ -227,7 +237,17 @@ Output:
|
|||
"upload_bandwidth": 90,
|
||||
"used_quota_files": 0,
|
||||
"used_quota_size": 0,
|
||||
"username": "test_username"
|
||||
"username": "test_username",
|
||||
"virtual_folders": [
|
||||
{
|
||||
"mapped_path": "/tmp/mapped1",
|
||||
"virtual_path": "/vdir1"
|
||||
},
|
||||
{
|
||||
"mapped_path": "/tmp/mapped2",
|
||||
"virtual_path": "/vdir2"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
|
|
@ -76,7 +76,7 @@ class SFTPGoApiRequests:
|
|||
status=1, expiration_date=0, allowed_ip=[], denied_ip=[], fs_provider='local', s3_bucket='',
|
||||
s3_region='', s3_access_key='', s3_access_secret='', s3_endpoint='', s3_storage_class='',
|
||||
s3_key_prefix='', gcs_bucket='', gcs_key_prefix='', gcs_storage_class='', gcs_credentials_file='',
|
||||
gcs_automatic_credentials='automatic', denied_login_methods=[]):
|
||||
gcs_automatic_credentials='automatic', denied_login_methods=[], virtual_folders=[]):
|
||||
user = {'id':user_id, 'username':username, 'uid':uid, 'gid':gid,
|
||||
'max_sessions':max_sessions, 'quota_size':quota_size, 'quota_files':quota_files,
|
||||
'upload_bandwidth':upload_bandwidth, 'download_bandwidth':download_bandwidth,
|
||||
|
@ -92,6 +92,8 @@ class SFTPGoApiRequests:
|
|||
user.update({'home_dir':home_dir})
|
||||
if permissions:
|
||||
user.update({'permissions':permissions})
|
||||
if virtual_folders:
|
||||
user.update({'virtual_folders':self.buildVirtualFolders(virtual_folders)})
|
||||
if allowed_ip or denied_ip or denied_login_methods:
|
||||
user.update({'filters':self.buildFilters(allowed_ip, denied_ip, denied_login_methods)})
|
||||
user.update({'filesystem':self.buildFsConfig(fs_provider, s3_bucket, s3_region, s3_access_key, s3_access_secret,
|
||||
|
@ -100,15 +102,29 @@ class SFTPGoApiRequests:
|
|||
gcs_automatic_credentials)})
|
||||
return user
|
||||
|
||||
def buildVirtualFolders(self, vfolders):
|
||||
result = []
|
||||
for f in vfolders:
|
||||
if '::' in f:
|
||||
vpath = ''
|
||||
mapped_path = ''
|
||||
values = f.split('::')
|
||||
if len(values) > 1:
|
||||
vpath = values[0]
|
||||
mapped_path = values[1]
|
||||
if vpath and mapped_path:
|
||||
result.append({"virtual_path":vpath, "mapped_path":mapped_path})
|
||||
return result
|
||||
|
||||
def buildPermissions(self, root_perms, subdirs_perms):
|
||||
permissions = {}
|
||||
if root_perms:
|
||||
permissions.update({'/':root_perms})
|
||||
for p in subdirs_perms:
|
||||
if ':' in p:
|
||||
if '::' in p:
|
||||
directory = None
|
||||
values = []
|
||||
for value in p.split(':'):
|
||||
for value in p.split('::'):
|
||||
if directory is None:
|
||||
directory = value
|
||||
else:
|
||||
|
@ -172,12 +188,12 @@ class SFTPGoApiRequests:
|
|||
subdirs_permissions=[], allowed_ip=[], denied_ip=[], fs_provider='local', s3_bucket='', s3_region='',
|
||||
s3_access_key='', s3_access_secret='', s3_endpoint='', s3_storage_class='', s3_key_prefix='', gcs_bucket='',
|
||||
gcs_key_prefix='', gcs_storage_class='', gcs_credentials_file='', gcs_automatic_credentials='automatic',
|
||||
denied_login_methods=[]):
|
||||
denied_login_methods=[], virtual_folders=[]):
|
||||
u = self.buildUserObject(0, username, password, public_keys, home_dir, uid, gid, max_sessions,
|
||||
quota_size, quota_files, self.buildPermissions(perms, subdirs_permissions), upload_bandwidth, download_bandwidth,
|
||||
status, expiration_date, allowed_ip, denied_ip, fs_provider, s3_bucket, s3_region, s3_access_key,
|
||||
s3_access_secret, s3_endpoint, s3_storage_class, s3_key_prefix, gcs_bucket, gcs_key_prefix, gcs_storage_class,
|
||||
gcs_credentials_file, gcs_automatic_credentials, denied_login_methods)
|
||||
gcs_credentials_file, gcs_automatic_credentials, denied_login_methods, virtual_folders)
|
||||
r = requests.post(self.userPath, json=u, auth=self.auth, verify=self.verify)
|
||||
self.printResponse(r)
|
||||
|
||||
|
@ -186,12 +202,12 @@ class SFTPGoApiRequests:
|
|||
expiration_date=0, subdirs_permissions=[], allowed_ip=[], denied_ip=[], fs_provider='local',
|
||||
s3_bucket='', s3_region='', s3_access_key='', s3_access_secret='', s3_endpoint='', s3_storage_class='',
|
||||
s3_key_prefix='', gcs_bucket='', gcs_key_prefix='', gcs_storage_class='', gcs_credentials_file='',
|
||||
gcs_automatic_credentials='automatic', denied_login_methods=[]):
|
||||
gcs_automatic_credentials='automatic', denied_login_methods=[], virtual_folders=[]):
|
||||
u = self.buildUserObject(user_id, username, password, public_keys, home_dir, uid, gid, max_sessions,
|
||||
quota_size, quota_files, self.buildPermissions(perms, subdirs_permissions), upload_bandwidth, download_bandwidth,
|
||||
status, expiration_date, allowed_ip, denied_ip, fs_provider, s3_bucket, s3_region, s3_access_key,
|
||||
s3_access_secret, s3_endpoint, s3_storage_class, s3_key_prefix, gcs_bucket, gcs_key_prefix, gcs_storage_class,
|
||||
gcs_credentials_file, gcs_automatic_credentials, denied_login_methods)
|
||||
gcs_credentials_file, gcs_automatic_credentials, denied_login_methods, virtual_folders)
|
||||
r = requests.put(urlparse.urljoin(self.userPath, 'user/' + str(user_id)), json=u, auth=self.auth, verify=self.verify)
|
||||
self.printResponse(r)
|
||||
|
||||
|
@ -435,7 +451,9 @@ def addCommonUserArguments(parser):
|
|||
parser.add_argument('-L', '--denied-login-methods', type=str, nargs='+', default=[],
|
||||
choices=['', 'publickey', 'password', 'keyboard-interactive'], help='Default: %(default)s')
|
||||
parser.add_argument('--subdirs-permissions', type=str, nargs='*', default=[], help='Permissions for subdirs. '
|
||||
+'For example: "/somedir:list,download" "/otherdir/subdir:*" Default: %(default)s')
|
||||
+'For example: "/somedir::list,download" "/otherdir/subdir::*" Default: %(default)s')
|
||||
parser.add_argument('--virtual-folders', type=str, nargs='*', default=[], help='Virtual folder mapping. For example: '
|
||||
+'"/vpath::/home/adir" "/vpath::C:\adir", ignored for non local filesystems. Default: %(default)s')
|
||||
parser.add_argument('-U', '--upload-bandwidth', type=int, default=0,
|
||||
help='Maximum upload bandwidth as KB/s, 0 means unlimited. Default: %(default)s')
|
||||
parser.add_argument('-D', '--download-bandwidth', type=int, default=0,
|
||||
|
@ -578,7 +596,7 @@ if __name__ == '__main__':
|
|||
args.denied_ip, args.fs, args.s3_bucket, args.s3_region, args.s3_access_key, args.s3_access_secret,
|
||||
args.s3_endpoint, args.s3_storage_class, args.s3_key_prefix, args.gcs_bucket, args.gcs_key_prefix,
|
||||
args.gcs_storage_class, args.gcs_credentials_file, args.gcs_automatic_credentials,
|
||||
args.denied_login_methods)
|
||||
args.denied_login_methods, args.virtual_folders)
|
||||
elif args.command == 'update-user':
|
||||
api.updateUser(args.id, args.username, args.password, args.public_keys, args.home_dir, args.uid, args.gid,
|
||||
args.max_sessions, args.quota_size, args.quota_files, args.permissions, args.upload_bandwidth,
|
||||
|
@ -586,7 +604,8 @@ if __name__ == '__main__':
|
|||
args.subdirs_permissions, args.allowed_ip, args.denied_ip, args.fs, args.s3_bucket, args.s3_region,
|
||||
args.s3_access_key, args.s3_access_secret, args.s3_endpoint, args.s3_storage_class,
|
||||
args.s3_key_prefix, args.gcs_bucket, args.gcs_key_prefix, args.gcs_storage_class,
|
||||
args.gcs_credentials_file, args.gcs_automatic_credentials, args.denied_login_methods)
|
||||
args.gcs_credentials_file, args.gcs_automatic_credentials, args.denied_login_methods,
|
||||
args.virtual_folders)
|
||||
elif args.command == 'delete-user':
|
||||
api.deleteUser(args.id)
|
||||
elif args.command == 'get-users':
|
||||
|
|
|
@ -206,14 +206,13 @@ func (c Connection) Filelist(request *sftp.Request) (sftp.ListerAt, error) {
|
|||
}
|
||||
|
||||
c.Log(logger.LevelDebug, logSender, "requested list file for dir: %#v", p)
|
||||
|
||||
files, err := c.fs.ReadDir(p)
|
||||
if err != nil {
|
||||
c.Log(logger.LevelWarn, logSender, "error listing directory: %+v", err)
|
||||
return nil, vfs.GetSFTPError(c.fs, err)
|
||||
}
|
||||
|
||||
return listerAt(files), nil
|
||||
return listerAt(c.User.AddVirtualDirs(files, request.Filepath)), nil
|
||||
case "Stat":
|
||||
if !c.User.HasPerm(dataprovider.PermListItems, path.Dir(request.Filepath)) {
|
||||
return nil, sftp.ErrSSHFxPermissionDenied
|
||||
|
@ -306,6 +305,10 @@ func (c Connection) handleSFTPRename(sourcePath string, targetPath string, reque
|
|||
c.Log(logger.LevelWarn, logSender, "renaming root dir is not allowed")
|
||||
return sftp.ErrSSHFxPermissionDenied
|
||||
}
|
||||
if c.User.IsVirtualFolder(request.Filepath) || c.User.IsVirtualFolder(request.Target) {
|
||||
c.Log(logger.LevelWarn, logSender, "renaming a virtual folder is not allowed")
|
||||
return sftp.ErrSSHFxPermissionDenied
|
||||
}
|
||||
if !c.User.HasPerm(dataprovider.PermRename, path.Dir(request.Target)) {
|
||||
return sftp.ErrSSHFxPermissionDenied
|
||||
}
|
||||
|
@ -323,6 +326,10 @@ func (c Connection) handleSFTPRmdir(dirPath string, request *sftp.Request) error
|
|||
c.Log(logger.LevelWarn, logSender, "removing root dir is not allowed")
|
||||
return sftp.ErrSSHFxPermissionDenied
|
||||
}
|
||||
if c.User.IsVirtualFolder(request.Filepath) {
|
||||
c.Log(logger.LevelWarn, logSender, "removing a virtual folder is not allowed: %#v", request.Filepath)
|
||||
return sftp.ErrSSHFxPermissionDenied
|
||||
}
|
||||
if !c.User.HasPerm(dataprovider.PermDelete, path.Dir(request.Filepath)) {
|
||||
return sftp.ErrSSHFxPermissionDenied
|
||||
}
|
||||
|
@ -352,6 +359,10 @@ func (c Connection) handleSFTPSymlink(sourcePath string, targetPath string, requ
|
|||
c.Log(logger.LevelWarn, logSender, "symlinking root dir is not allowed")
|
||||
return sftp.ErrSSHFxPermissionDenied
|
||||
}
|
||||
if c.User.IsVirtualFolder(request.Target) {
|
||||
c.Log(logger.LevelWarn, logSender, "symlinking a virtual folder is not allowed")
|
||||
return sftp.ErrSSHFxPermissionDenied
|
||||
}
|
||||
if !c.User.HasPerm(dataprovider.PermCreateSymlinks, path.Dir(request.Target)) {
|
||||
return sftp.ErrSSHFxPermissionDenied
|
||||
}
|
||||
|
@ -368,6 +379,10 @@ func (c Connection) handleSFTPMkdir(dirPath string, request *sftp.Request) error
|
|||
if !c.User.HasPerm(dataprovider.PermCreateDirs, path.Dir(request.Filepath)) {
|
||||
return sftp.ErrSSHFxPermissionDenied
|
||||
}
|
||||
if c.User.IsVirtualFolder(request.Filepath) {
|
||||
c.Log(logger.LevelWarn, logSender, "mkdir not allowed %#v is virtual folder is not allowed", request.Filepath)
|
||||
return sftp.ErrSSHFxPermissionDenied
|
||||
}
|
||||
if err := c.fs.Mkdir(dirPath); err != nil {
|
||||
c.Log(logger.LevelWarn, logSender, "error creating missing dir: %#v error: %+v", dirPath, err)
|
||||
return vfs.GetSFTPError(c.fs, err)
|
||||
|
|
|
@ -112,7 +112,7 @@ func (fs MockOsFs) Rename(source, target string) error {
|
|||
|
||||
func newMockOsFs(err, statErr error, atomicUpload bool, connectionID, rootDir string) vfs.Fs {
|
||||
return &MockOsFs{
|
||||
Fs: vfs.NewOsFs(connectionID, rootDir),
|
||||
Fs: vfs.NewOsFs(connectionID, rootDir, nil),
|
||||
err: err,
|
||||
statErr: statErr,
|
||||
isAtomicUploadSupported: atomicUpload,
|
||||
|
@ -388,7 +388,7 @@ func TestUploadFiles(t *testing.T) {
|
|||
oldUploadMode := uploadMode
|
||||
uploadMode = uploadModeAtomic
|
||||
c := Connection{
|
||||
fs: vfs.NewOsFs("123", os.TempDir()),
|
||||
fs: vfs.NewOsFs("123", os.TempDir(), nil),
|
||||
}
|
||||
var flags sftp.FileOpenFlags
|
||||
flags.Write = true
|
||||
|
@ -477,7 +477,7 @@ func TestSFTPCmdTargetPath(t *testing.T) {
|
|||
|
||||
func TestGetSFTPErrorFromOSError(t *testing.T) {
|
||||
err := os.ErrNotExist
|
||||
fs := vfs.NewOsFs("", os.TempDir())
|
||||
fs := vfs.NewOsFs("", os.TempDir(), nil)
|
||||
err = vfs.GetSFTPError(fs, err)
|
||||
if err != sftp.ErrSSHFxNoSuchFile {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
|
@ -1287,7 +1287,7 @@ func TestSCPRecursiveDownloadErrors(t *testing.T) {
|
|||
connection := Connection{
|
||||
channel: &mockSSHChannel,
|
||||
netConn: client,
|
||||
fs: vfs.NewOsFs("123", os.TempDir()),
|
||||
fs: vfs.NewOsFs("123", os.TempDir(), nil),
|
||||
}
|
||||
scpCommand := scpCommand{
|
||||
sshCommand: sshCommand{
|
||||
|
|
|
@ -652,7 +652,7 @@ func (c *scpCommand) parseUploadMessage(command string) (int64, string, error) {
|
|||
c.sendErrorMessage(err.Error())
|
||||
return size, name, err
|
||||
}
|
||||
parts := strings.Split(command, " ")
|
||||
parts := strings.SplitN(command, " ", 3)
|
||||
if len(parts) == 3 {
|
||||
size, err = strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil {
|
||||
|
@ -668,7 +668,7 @@ func (c *scpCommand) parseUploadMessage(command string) (int64, string, error) {
|
|||
return size, name, err
|
||||
}
|
||||
} else {
|
||||
err = fmt.Errorf("Error splitting upload message: %v", command)
|
||||
err = fmt.Errorf("Error splitting upload message: %#v", command)
|
||||
c.connection.Log(logger.LevelWarn, logSenderSCP, "error: %v", err)
|
||||
c.sendErrorMessage(err.Error())
|
||||
return size, name, err
|
||||
|
|
|
@ -1988,6 +1988,68 @@ func TestBandwidthAndConnections(t *testing.T) {
|
|||
os.RemoveAll(user.GetHomeDir())
|
||||
}
|
||||
|
||||
func TestVirtualFolders(t *testing.T) {
|
||||
usePubKey := true
|
||||
u := getTestUser(usePubKey)
|
||||
mappedPath := filepath.Join(os.TempDir(), "vdir")
|
||||
vdirPath := "/vdir"
|
||||
u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
|
||||
VirtualPath: vdirPath,
|
||||
MappedPath: mappedPath,
|
||||
})
|
||||
os.MkdirAll(mappedPath, 0777)
|
||||
user, _, err := httpd.AddUser(u, http.StatusOK)
|
||||
if err != nil {
|
||||
t.Errorf("unable to add user: %v", err)
|
||||
}
|
||||
client, err := getSftpClient(user, usePubKey)
|
||||
if err != nil {
|
||||
t.Errorf("unable to create sftp client: %v", err)
|
||||
} else {
|
||||
defer client.Close()
|
||||
testFileSize := int64(131072)
|
||||
testFileName := "test_file.dat"
|
||||
testFilePath := filepath.Join(homeBasePath, testFileName)
|
||||
err = createTestFile(testFilePath, testFileSize)
|
||||
if err != nil {
|
||||
t.Errorf("unable to create test file: %v", err)
|
||||
}
|
||||
localDownloadPath := filepath.Join(homeBasePath, "test_download.dat")
|
||||
err = sftpUploadFile(testFilePath, path.Join(vdirPath, testFileName), testFileSize, client)
|
||||
if err != nil {
|
||||
t.Errorf("file upload error: %v", err)
|
||||
}
|
||||
err = sftpDownloadFile(path.Join(vdirPath, testFileName), localDownloadPath, testFileSize, client)
|
||||
if err != nil {
|
||||
t.Errorf("file download error: %v", err)
|
||||
}
|
||||
err = client.Rename(vdirPath, "new_name")
|
||||
if err == nil {
|
||||
t.Error("renaming a virtual folder must fail")
|
||||
}
|
||||
err = client.RemoveDirectory(vdirPath)
|
||||
if err == nil {
|
||||
t.Error("removing a virtual folder must fail")
|
||||
}
|
||||
err = client.Mkdir(vdirPath)
|
||||
if err == nil {
|
||||
t.Error("creating a virtual folder must fail")
|
||||
}
|
||||
err = client.Symlink(path.Join(vdirPath, testFileName), vdirPath)
|
||||
if err == nil {
|
||||
t.Error("symlink to a virtual folder must fail")
|
||||
}
|
||||
os.Remove(testFilePath)
|
||||
os.Remove(localDownloadPath)
|
||||
}
|
||||
_, err = httpd.RemoveUser(user, http.StatusOK)
|
||||
if err != nil {
|
||||
t.Errorf("unable to remove user: %v", err)
|
||||
}
|
||||
os.RemoveAll(user.GetHomeDir())
|
||||
os.RemoveAll(mappedPath)
|
||||
}
|
||||
|
||||
func TestMissingFile(t *testing.T) {
|
||||
usePubKey := false
|
||||
u := getTestUser(usePubKey)
|
||||
|
@ -3116,7 +3178,7 @@ func TestRootDirCommands(t *testing.T) {
|
|||
func TestRelativePaths(t *testing.T) {
|
||||
user := getTestUser(true)
|
||||
var path, rel string
|
||||
filesystems := []vfs.Fs{vfs.NewOsFs("", user.GetHomeDir())}
|
||||
filesystems := []vfs.Fs{vfs.NewOsFs("", user.GetHomeDir(), user.VirtualFolders)}
|
||||
keyPrefix := strings.TrimPrefix(user.GetHomeDir(), "/") + "/"
|
||||
s3config := vfs.S3FsConfig{
|
||||
KeyPrefix: keyPrefix,
|
||||
|
@ -3187,7 +3249,7 @@ func TestResolvePaths(t *testing.T) {
|
|||
user := getTestUser(true)
|
||||
var path, resolved string
|
||||
var err error
|
||||
filesystems := []vfs.Fs{vfs.NewOsFs("", user.GetHomeDir())}
|
||||
filesystems := []vfs.Fs{vfs.NewOsFs("", user.GetHomeDir(), user.VirtualFolders)}
|
||||
keyPrefix := strings.TrimPrefix(user.GetHomeDir(), "/") + "/"
|
||||
s3config := vfs.S3FsConfig{
|
||||
KeyPrefix: keyPrefix,
|
||||
|
@ -3243,6 +3305,80 @@ func TestResolvePaths(t *testing.T) {
|
|||
os.RemoveAll(user.GetHomeDir())
|
||||
}
|
||||
|
||||
func TestVirtualRelativePaths(t *testing.T) {
|
||||
user := getTestUser(true)
|
||||
mappedPath := filepath.Join(os.TempDir(), "vdir")
|
||||
vdirPath := "/vdir"
|
||||
user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{
|
||||
VirtualPath: vdirPath,
|
||||
MappedPath: mappedPath,
|
||||
})
|
||||
os.MkdirAll(mappedPath, 0777)
|
||||
fs := vfs.NewOsFs("", user.GetHomeDir(), user.VirtualFolders)
|
||||
rel := fs.GetRelativePath(mappedPath)
|
||||
if rel != vdirPath {
|
||||
t.Errorf("Unexpected relative path: %v", rel)
|
||||
}
|
||||
rel = fs.GetRelativePath(filepath.Join(mappedPath, ".."))
|
||||
if rel != "/" {
|
||||
t.Errorf("Unexpected relative path: %v", rel)
|
||||
}
|
||||
// path outside home and virtual dir
|
||||
rel = fs.GetRelativePath(filepath.Join(mappedPath, "../vdir1"))
|
||||
if rel != "/" {
|
||||
t.Errorf("Unexpected relative path: %v", rel)
|
||||
}
|
||||
rel = fs.GetRelativePath(filepath.Join(mappedPath, "../vdir/file.txt"))
|
||||
if rel != "/vdir/file.txt" {
|
||||
t.Errorf("Unexpected relative path: %v", rel)
|
||||
}
|
||||
rel = fs.GetRelativePath(filepath.Join(user.HomeDir, "vdir1/file.txt"))
|
||||
if rel != "/vdir1/file.txt" {
|
||||
t.Errorf("Unexpected relative path: %v", rel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveVirtualPaths(t *testing.T) {
|
||||
user := getTestUser(true)
|
||||
mappedPath := filepath.Join(os.TempDir(), "vdir")
|
||||
vdirPath := "/vdir"
|
||||
user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{
|
||||
VirtualPath: vdirPath,
|
||||
MappedPath: mappedPath,
|
||||
})
|
||||
os.MkdirAll(mappedPath, 0777)
|
||||
fs := vfs.NewOsFs("", user.GetHomeDir(), user.VirtualFolders)
|
||||
osFs := fs.(*vfs.OsFs)
|
||||
b, f := osFs.GetFsPaths("/vdir/a.txt")
|
||||
if b != mappedPath {
|
||||
t.Errorf("unexpected base path: %#v expected: %#v", b, mappedPath)
|
||||
}
|
||||
if f != filepath.Join(mappedPath, "a.txt") {
|
||||
t.Errorf("unexpected fs path: %#v expected: %#v", f, filepath.Join(mappedPath, "a.txt"))
|
||||
}
|
||||
b, f = osFs.GetFsPaths("/vdir/sub with space & spécial chars/a.txt")
|
||||
if b != mappedPath {
|
||||
t.Errorf("unexpected base path: %#v expected: %#v", b, mappedPath)
|
||||
}
|
||||
if f != filepath.Join(mappedPath, "sub with space & spécial chars/a.txt") {
|
||||
t.Errorf("unexpected fs path: %#v expected: %#v", f, filepath.Join(mappedPath, "sub with space & spécial chars/a.txt"))
|
||||
}
|
||||
b, f = osFs.GetFsPaths("/vdir/../a.txt")
|
||||
if b != user.GetHomeDir() {
|
||||
t.Errorf("unexpected base path: %#v expected: %#v", b, user.GetHomeDir())
|
||||
}
|
||||
if f != filepath.Join(user.GetHomeDir(), "a.txt") {
|
||||
t.Errorf("unexpected fs path: %#v expected: %#v", f, filepath.Join(user.GetHomeDir(), "a.txt"))
|
||||
}
|
||||
b, f = osFs.GetFsPaths("/vdir1/a.txt")
|
||||
if b != user.GetHomeDir() {
|
||||
t.Errorf("unexpected base path: %#v expected: %#v", b, user.GetHomeDir())
|
||||
}
|
||||
if f != filepath.Join(user.GetHomeDir(), "/vdir1/a.txt") {
|
||||
t.Errorf("unexpected fs path: %#v expected: %#v", f, filepath.Join(user.GetHomeDir(), "/vdir1/a.txt"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserPerms(t *testing.T) {
|
||||
user := getTestUser(true)
|
||||
user.Permissions = make(map[string][]string)
|
||||
|
@ -3780,6 +3916,54 @@ func TestSCPRecursive(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSCPVirtualFolders(t *testing.T) {
|
||||
if len(scpPath) == 0 {
|
||||
t.Skip("scp command not found, unable to execute this test")
|
||||
}
|
||||
usePubKey := true
|
||||
u := getTestUser(usePubKey)
|
||||
mappedPath := filepath.Join(os.TempDir(), "vdir")
|
||||
vdirPath := "/vdir"
|
||||
u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{
|
||||
VirtualPath: vdirPath,
|
||||
MappedPath: mappedPath,
|
||||
})
|
||||
os.MkdirAll(mappedPath, 0777)
|
||||
user, _, err := httpd.AddUser(u, http.StatusOK)
|
||||
if err != nil {
|
||||
t.Errorf("unable to add user: %v", err)
|
||||
}
|
||||
testFileName := "test_file.dat"
|
||||
testBaseDirName := "test_dir"
|
||||
testBaseDirPath := filepath.Join(homeBasePath, testBaseDirName)
|
||||
testBaseDirDownName := "test_dir_down"
|
||||
testBaseDirDownPath := filepath.Join(homeBasePath, testBaseDirDownName)
|
||||
testFilePath := filepath.Join(homeBasePath, testBaseDirName, testFileName)
|
||||
testFilePath1 := filepath.Join(homeBasePath, testBaseDirName, testBaseDirName, testFileName)
|
||||
testFileSize := int64(131074)
|
||||
createTestFile(testFilePath, testFileSize)
|
||||
createTestFile(testFilePath1, testFileSize)
|
||||
remoteDownPath := fmt.Sprintf("%v@127.0.0.1:%v", user.Username, path.Join("/", vdirPath))
|
||||
remoteUpPath := fmt.Sprintf("%v@127.0.0.1:%v", user.Username, vdirPath)
|
||||
err = scpUpload(testBaseDirPath, remoteUpPath, true, false)
|
||||
if err != nil {
|
||||
t.Errorf("error uploading dir via scp: %v", err)
|
||||
}
|
||||
err = scpDownload(testBaseDirDownPath, remoteDownPath, true, true)
|
||||
if err != nil {
|
||||
t.Errorf("error downloading dir via scp: %v", err)
|
||||
}
|
||||
|
||||
_, err = httpd.RemoveUser(user, http.StatusOK)
|
||||
if err != nil {
|
||||
t.Errorf("unable to remove user: %v", err)
|
||||
}
|
||||
os.RemoveAll(testBaseDirPath)
|
||||
os.RemoveAll(testBaseDirDownPath)
|
||||
os.RemoveAll(user.GetHomeDir())
|
||||
os.RemoveAll(mappedPath)
|
||||
}
|
||||
|
||||
func TestSCPPermsSubDirs(t *testing.T) {
|
||||
if len(scpPath) == 0 {
|
||||
t.Skip("scp command not found, unable to execute this test")
|
||||
|
|
|
@ -11,8 +11,6 @@ import (
|
|||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
@ -360,11 +358,7 @@ func (c *sshCommand) getDestPath() string {
|
|||
}
|
||||
destPath := strings.Trim(c.args[len(c.args)-1], "'")
|
||||
destPath = strings.Trim(destPath, "\"")
|
||||
destPath = filepath.ToSlash(destPath)
|
||||
if !path.IsAbs(destPath) {
|
||||
destPath = "/" + destPath
|
||||
}
|
||||
result := path.Clean(destPath)
|
||||
result := utils.CleanSFTPPath(destPath)
|
||||
if strings.HasSuffix(destPath, "/") && !strings.HasSuffix(result, "/") {
|
||||
result += "/"
|
||||
}
|
||||
|
|
|
@ -102,11 +102,11 @@
|
|||
<textarea class="form-control" id="idSubDirsPermissions" name="sub_dirs_permissions" rows="3"
|
||||
aria-describedby="subDirsHelpBlock">{{range $dir, $perms := .User.Permissions -}}
|
||||
{{if ne $dir "/" -}}
|
||||
{{$dir}}:{{range $index, $p := $perms}}{{if $index}},{{end}}{{$p}}{{end}}
|
||||
{{$dir}}::{{range $index, $p := $perms}}{{if $index}},{{end}}{{$p}}{{end}}
|
||||
{{- end}}
|
||||
{{- end}}</textarea>
|
||||
<small id="subDirsHelpBlock" class="form-text text-muted">
|
||||
One directory per line as dir:perms, for example /somedir:list,download
|
||||
One directory per line as dir::perms, for example /somedir::list,download
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -119,6 +119,19 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="idVirtualFolders" class="col-sm-2 col-form-label">Virtual folders</label>
|
||||
<div class="col-sm-10">
|
||||
<textarea class="form-control" id="idVirtualFolders" name="virtual_folders" rows="3"
|
||||
aria-describedby="vfHelpBlock">{{range $index, $mapping := .User.VirtualFolders -}}
|
||||
{{$mapping.VirtualPath}}::{{$mapping.MappedPath}}
|
||||
{{- end}}</textarea>
|
||||
<small id="vfHelpBlock" class="form-text text-muted">
|
||||
One mapping per line as vpath::path, for example /vdir::/home/adir or /vdir::C:\adir, ignored for non local filesystems
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="idQuotaFiles" class="col-sm-2 col-form-label">Quota files</label>
|
||||
<div class="col-sm-3">
|
||||
|
|
|
@ -17,6 +17,8 @@ import (
|
|||
"io/ioutil"
|
||||
"net"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
@ -248,3 +250,28 @@ func GenerateECDSAKeys(file string) error {
|
|||
}
|
||||
return ioutil.WriteFile(file+".pub", ssh.MarshalAuthorizedKey(pub), 0600)
|
||||
}
|
||||
|
||||
// GetDirsForSFTPPath returns all the directory for the given path in reverse order
|
||||
// for example if the path is: /1/2/3/4 it returns:
|
||||
// [ "/1/2/3/4", "/1/2/3", "/1/2", "/1", "/" ]
|
||||
func GetDirsForSFTPPath(p string) []string {
|
||||
sftpPath := CleanSFTPPath(p)
|
||||
dirsForPath := []string{sftpPath}
|
||||
for {
|
||||
if sftpPath == "/" {
|
||||
break
|
||||
}
|
||||
sftpPath = path.Dir(sftpPath)
|
||||
dirsForPath = append(dirsForPath, sftpPath)
|
||||
}
|
||||
return dirsForPath
|
||||
}
|
||||
|
||||
// CleanSFTPPath returns a clean sftp path
|
||||
func CleanSFTPPath(p string) string {
|
||||
sftpPath := filepath.ToSlash(p)
|
||||
if !path.IsAbs(p) {
|
||||
sftpPath = "/" + sftpPath
|
||||
}
|
||||
return path.Clean(sftpPath)
|
||||
}
|
||||
|
|
|
@ -385,7 +385,7 @@ func (GCSFs) IsPermission(err error) bool {
|
|||
// CheckRootPath creates the specified root directory if it does not exists
|
||||
func (fs GCSFs) CheckRootPath(username string, uid int, gid int) bool {
|
||||
// we need a local directory for temporary files
|
||||
osFs := NewOsFs(fs.ConnectionID(), fs.localTempDir)
|
||||
osFs := NewOsFs(fs.ConnectionID(), fs.localTempDir, nil)
|
||||
osFs.CheckRootPath(username, uid, gid)
|
||||
return fs.checkIfBucketExists() != nil
|
||||
}
|
||||
|
|
137
vfs/osfs.go
137
vfs/osfs.go
|
@ -2,13 +2,14 @@ package vfs
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/drakkan/sftpgo/logger"
|
||||
"github.com/drakkan/sftpgo/utils"
|
||||
"github.com/eikenb/pipeat"
|
||||
"github.com/rs/xid"
|
||||
)
|
||||
|
@ -23,14 +24,16 @@ type OsFs struct {
|
|||
name string
|
||||
connectionID string
|
||||
rootDir string
|
||||
virtualFolders []VirtualFolder
|
||||
}
|
||||
|
||||
// NewOsFs returns an OsFs object that allows to interact with local Os filesystem
|
||||
func NewOsFs(connectionID, rootDir string) Fs {
|
||||
func NewOsFs(connectionID, rootDir string, virtualFolders []VirtualFolder) Fs {
|
||||
return &OsFs{
|
||||
name: osFsName,
|
||||
connectionID: connectionID,
|
||||
rootDir: rootDir,
|
||||
virtualFolders: virtualFolders,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -111,7 +114,16 @@ func (OsFs) Chtimes(name string, atime, mtime time.Time) error {
|
|||
// ReadDir reads the directory named by dirname and returns
|
||||
// a list of directory entries.
|
||||
func (OsFs) ReadDir(dirname string) ([]os.FileInfo, error) {
|
||||
return ioutil.ReadDir(dirname)
|
||||
f, err := os.Open(dirname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list, err := f.Readdir(-1)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// IsUploadResumeSupported returns true if upload resume is supported
|
||||
|
@ -147,26 +159,32 @@ func (fs OsFs) CheckRootPath(username string, uid int, gid int) bool {
|
|||
SetPathPermissions(fs, fs.rootDir, uid, gid)
|
||||
}
|
||||
}
|
||||
// create any missing dirs to the defined virtual dirs
|
||||
for _, v := range fs.virtualFolders {
|
||||
p := filepath.Clean(filepath.Join(fs.rootDir, v.VirtualPath))
|
||||
err = fs.createMissingDirs(p, uid, gid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return (err == nil)
|
||||
}
|
||||
|
||||
// ScanRootDirContents returns the number of files contained in a directory and
|
||||
// their size
|
||||
func (fs OsFs) ScanRootDirContents() (int, int64, error) {
|
||||
numFiles := 0
|
||||
size := int64(0)
|
||||
isDir, err := IsDirectory(fs, fs.rootDir)
|
||||
if err == nil && isDir {
|
||||
err = filepath.Walk(fs.rootDir, func(path string, info os.FileInfo, err error) error {
|
||||
numFiles, size, err := fs.getDirSize(fs.rootDir)
|
||||
for _, v := range fs.virtualFolders {
|
||||
num, s, err := fs.getDirSize(v.MappedPath)
|
||||
if err != nil {
|
||||
return err
|
||||
if fs.IsNotExist(err) {
|
||||
fsLog(fs, logger.LevelWarn, "unable to scan contents for not existent mapped path: %#v", v.MappedPath)
|
||||
continue
|
||||
}
|
||||
if info != nil && info.Mode().IsRegular() {
|
||||
size += info.Size()
|
||||
numFiles++
|
||||
return numFiles, size, err
|
||||
}
|
||||
return err
|
||||
})
|
||||
numFiles += num
|
||||
size += s
|
||||
}
|
||||
return numFiles, size, err
|
||||
}
|
||||
|
@ -181,14 +199,23 @@ func (OsFs) GetAtomicUploadPath(name string) string {
|
|||
// GetRelativePath returns the path for a file relative to the user's home dir.
|
||||
// This is the path as seen by SFTP users
|
||||
func (fs OsFs) GetRelativePath(name string) string {
|
||||
rel, err := filepath.Rel(fs.rootDir, filepath.Clean(name))
|
||||
basePath := fs.rootDir
|
||||
virtualPath := "/"
|
||||
for _, v := range fs.virtualFolders {
|
||||
if strings.HasPrefix(name, v.MappedPath+string(os.PathSeparator)) ||
|
||||
filepath.Clean(name) == v.MappedPath {
|
||||
basePath = v.MappedPath
|
||||
virtualPath = v.VirtualPath
|
||||
}
|
||||
}
|
||||
rel, err := filepath.Rel(basePath, filepath.Clean(name))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if rel == "." || strings.HasPrefix(rel, "..") {
|
||||
rel = ""
|
||||
}
|
||||
return "/" + filepath.ToSlash(rel)
|
||||
return path.Join(virtualPath, filepath.ToSlash(rel))
|
||||
}
|
||||
|
||||
// Join joins any number of path elements into a single path
|
||||
|
@ -201,27 +228,61 @@ func (fs OsFs) ResolvePath(sftpPath string) (string, error) {
|
|||
if !filepath.IsAbs(fs.rootDir) {
|
||||
return "", fmt.Errorf("Invalid root path: %v", fs.rootDir)
|
||||
}
|
||||
r := filepath.Clean(filepath.Join(fs.rootDir, sftpPath))
|
||||
basePath, r := fs.GetFsPaths(sftpPath)
|
||||
p, err := filepath.EvalSymlinks(r)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return "", err
|
||||
} else if os.IsNotExist(err) {
|
||||
// The requested path doesn't exist, so at this point we need to iterate up the
|
||||
// path chain until we hit a directory that _does_ exist and can be validated.
|
||||
_, err = fs.findFirstExistingDir(r, fs.rootDir)
|
||||
_, err = fs.findFirstExistingDir(r, basePath)
|
||||
if err != nil {
|
||||
fsLog(fs, logger.LevelWarn, "error resolving not existent path: %#v", err)
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
err = fs.isSubDir(p, fs.rootDir)
|
||||
err = fs.isSubDir(p, basePath)
|
||||
if err != nil {
|
||||
fsLog(fs, logger.LevelWarn, "Invalid path resolution, dir: %#v outside user home: %#v err: %v", p, fs.rootDir, err)
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
// GetFsPaths returns the base path and filesystem path for the given sftpPath.
|
||||
// base path is the root dir or matching the virtual folder dir for the sftpPath.
|
||||
// file path is the filesystem path matching the sftpPath
|
||||
func (fs *OsFs) GetFsPaths(sftpPath string) (string, string) {
|
||||
basePath := fs.rootDir
|
||||
virtualPath, mappedPath := fs.getMappedFolderForPath(sftpPath)
|
||||
if len(mappedPath) > 0 {
|
||||
basePath = mappedPath
|
||||
sftpPath = strings.TrimPrefix(utils.CleanSFTPPath(sftpPath), virtualPath)
|
||||
}
|
||||
r := filepath.Clean(filepath.Join(basePath, sftpPath))
|
||||
return basePath, r
|
||||
}
|
||||
|
||||
// returns the path for the mapped folders or an empty string
|
||||
func (fs *OsFs) getMappedFolderForPath(p string) (virtualPath, mappedPath string) {
|
||||
if len(fs.virtualFolders) == 0 {
|
||||
return
|
||||
}
|
||||
dirsForPath := utils.GetDirsForSFTPPath(p)
|
||||
// dirsForPath contains all the dirs for a given path in reverse order
|
||||
// for example if the path is: /1/2/3/4 it contains:
|
||||
// [ "/1/2/3/4", "/1/2/3", "/1/2", "/1", "/" ]
|
||||
// so the first match is the one we are interested to
|
||||
for _, val := range dirsForPath {
|
||||
for _, v := range fs.virtualFolders {
|
||||
if val == v.VirtualPath {
|
||||
return v.VirtualPath, v.MappedPath
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (fs *OsFs) findNonexistentDirs(path, rootPath string) ([]string, error) {
|
||||
results := []string{}
|
||||
cleanPath := filepath.Clean(path)
|
||||
|
@ -279,7 +340,7 @@ func (fs *OsFs) isSubDir(sub, rootPath string) error {
|
|||
// rootPath must exist and it is already a validated absolute path
|
||||
parent, err := filepath.EvalSymlinks(rootPath)
|
||||
if err != nil {
|
||||
fsLog(fs, logger.LevelWarn, "invalid home dir %#v: %v", rootPath, err)
|
||||
fsLog(fs, logger.LevelWarn, "invalid root path %#v: %v", rootPath, err)
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(sub, parent) {
|
||||
|
@ -289,3 +350,39 @@ func (fs *OsFs) isSubDir(sub, rootPath string) error {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fs *OsFs) createMissingDirs(filePath string, uid, gid int) error {
|
||||
dirsToCreate, err := fs.findNonexistentDirs(filePath, fs.rootDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
last := len(dirsToCreate) - 1
|
||||
for i := range dirsToCreate {
|
||||
d := dirsToCreate[last-i]
|
||||
if err := os.Mkdir(d, 0777); err != nil {
|
||||
fsLog(fs, logger.LevelError, "error creating missing dir: %#v", d)
|
||||
return err
|
||||
}
|
||||
SetPathPermissions(fs, d, uid, gid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (fs *OsFs) getDirSize(dirname string) (int, int64, error) {
|
||||
numFiles := 0
|
||||
size := int64(0)
|
||||
isDir, err := IsDirectory(fs, dirname)
|
||||
if err == nil && isDir {
|
||||
err = filepath.Walk(dirname, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info != nil && info.Mode().IsRegular() {
|
||||
size += info.Size()
|
||||
numFiles++
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
return numFiles, size, err
|
||||
}
|
||||
|
|
|
@ -409,7 +409,7 @@ func (S3Fs) IsPermission(err error) bool {
|
|||
// CheckRootPath creates the specified root directory if it does not exists
|
||||
func (fs S3Fs) CheckRootPath(username string, uid int, gid int) bool {
|
||||
// we need a local directory for temporary files
|
||||
osFs := NewOsFs(fs.ConnectionID(), fs.localTempDir)
|
||||
osFs := NewOsFs(fs.ConnectionID(), fs.localTempDir, nil)
|
||||
osFs.CheckRootPath(username, uid, gid)
|
||||
return fs.checkIfBucketExists() != nil
|
||||
}
|
||||
|
|
11
vfs/vfs.go
11
vfs/vfs.go
|
@ -43,6 +43,17 @@ type Fs interface {
|
|||
Join(elem ...string) string
|
||||
}
|
||||
|
||||
// VirtualFolder defines a mapping between a SFTP/SCP virtual path and a
|
||||
// filesystem path outside the user home directory.
|
||||
// The specified paths must be absolute and the virtual path cannot be "/",
|
||||
// it must be a sub directory. The parent directory for the specified virtual
|
||||
// path must exist. SFTPGo will try to automatically create any missing
|
||||
// parent directory for the configured virtual folders at user login.
|
||||
type VirtualFolder struct {
|
||||
VirtualPath string `json:"virtual_path"`
|
||||
MappedPath string `json:"mapped_path"`
|
||||
}
|
||||
|
||||
// IsDirectory checks if a path exists and is a directory
|
||||
func IsDirectory(fs Fs, path string) (bool, error) {
|
||||
fileInfo, err := fs.Stat(path)
|
||||
|
|
Loading…
Reference in a new issue