diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 5781711c..d6ca2526 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v2 - name: Install golangci-lint - run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.26.0 + run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.27.0 - name: Run golangci-lint run: golangci-lint run diff --git a/README.md b/README.md index d21638ab..6328d63e 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Fully featured and highly configurable SFTP server, written in Go - Per user IP filters are supported: login can be restricted to specific ranges of IP addresses or to a specific IP address. - Per user and per directory file extensions filters are supported: files can be allowed or denied based on their extensions. - 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. +- Configurable custom commands and/or HTTP notifications on file upload, download, pre-delete, delete, rename, on SSH commands and on user add, update and delete. - Automatically terminating idle connections. - Atomic uploads are configurable. - Support for Git repositories over SSH. @@ -132,6 +132,10 @@ SFTPGo allows to configure custom commands and/or HTTP notifications on file upl More information about custom actions can be found [here](./docs/custom-actions.md). +## Virtual folders + +Directories outside the user home directory can be exposed as virtual folders, more information [here](./docs/virtual-folders.md). + ## Storage backends ### S3 Compabible Object Storage backends diff --git a/config/config.go b/config/config.go index 67a89d4d..20148ca7 100644 --- a/config/config.go +++ b/config/config.go @@ -75,7 +75,7 @@ func init() { Username: "", Password: "", ConnectionString: "", - UsersTable: "users", + SQLTablesPrefix: "", ManageUsers: 1, SSLMode: 0, TrackQuota: 1, diff --git a/dataprovider/bolt.go b/dataprovider/bolt.go index 8097c4aa..483be3ef 100644 --- a/dataprovider/bolt.go +++ b/dataprovider/bolt.go @@ -14,15 +14,17 @@ import ( "github.com/drakkan/sftpgo/logger" "github.com/drakkan/sftpgo/utils" + "github.com/drakkan/sftpgo/vfs" ) const ( - boltDatabaseVersion = 3 + boltDatabaseVersion = 4 ) var ( usersBucket = []byte("users") usersIDIdxBucket = []byte("users_id_idx") + foldersBucket = []byte("folders") dbVersionBucket = []byte("db_version") dbVersionKey = []byte("version") ) @@ -90,6 +92,14 @@ func initializeBoltProvider(basePath string) error { 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 username idx bucket: %v", err) + return err + } err = dbHandle.Update(func(tx *bolt.Tx) error { _, e := tx.CreateBucketIfNotExists(dbVersionBucket) return e @@ -106,7 +116,7 @@ func initializeBoltProvider(basePath string) error { } func (p BoltProvider) checkAvailability() error { - _, err := p.getUsers(1, 0, "ASC", "") + _, err := getBoltDatabaseVersion(p.dbHandle) return err } @@ -152,7 +162,12 @@ func (p BoltProvider) getUserByID(ID int64) (User, error) { if u == nil { return &RecordNotFoundError{err: fmt.Sprintf("username %#v and ID: %v does not exist", string(username), ID)} } - return json.Unmarshal(u, &user) + folderBucket, err := getFolderBucket(tx) + if err != nil { + return err + } + user, err = joinUserAndFolders(u, folderBucket) + return err }) return user, err @@ -215,7 +230,10 @@ func (p BoltProvider) updateQuota(username string, filesAdd int, sizeAdd int64, if err != nil { return err } - return bucket.Put([]byte(username), buf) + err = bucket.Put([]byte(username), buf) + providerLog(logger.LevelDebug, "quota updated for user %#v, files increment: %v size increment: %v is reset? %v", + username, filesAdd, sizeAdd, reset) + return err }) } @@ -239,7 +257,12 @@ func (p BoltProvider) userExists(username string) (User, error) { if u == nil { return &RecordNotFoundError{err: fmt.Sprintf("username %v does not exist", username)} } - return json.Unmarshal(u, &user) + folderBucket, err := getFolderBucket(tx) + if err != nil { + return err + } + user, err = joinUserAndFolders(u, folderBucket) + return err }) return user, err } @@ -254,6 +277,10 @@ func (p BoltProvider) addUser(user User) error { if err != nil { return err } + folderBucket, err := getFolderBucket(tx) + if err != nil { + return err + } if u := bucket.Get([]byte(user.Username)); u != nil { return fmt.Errorf("username %v already exists", user.Username) } @@ -262,15 +289,21 @@ func (p BoltProvider) addUser(user User) error { return err } user.ID = int64(id) + for _, folder := range user.VirtualFolders { + err = addUserToFolderMapping(folder, user, folderBucket) + if err != nil { + return err + } + } buf, err := json.Marshal(user) if err != nil { return err } - userIDAsBytes := itob(user.ID) err = bucket.Put([]byte(user.Username), buf) if err != nil { return err } + userIDAsBytes := itob(user.ID) return idxBucket.Put(userIDAsBytes, []byte(user.Username)) }) } @@ -285,9 +318,35 @@ func (p BoltProvider) updateUser(user User) error { if err != nil { return err } - if u := bucket.Get([]byte(user.Username)); u == nil { + folderBucket, err := getFolderBucket(tx) + if err != nil { + return err + } + var u []byte + if u = bucket.Get([]byte(user.Username)); u == nil { return &RecordNotFoundError{err: fmt.Sprintf("username %v does not exist", user.Username)} } + var oldUser User + err = json.Unmarshal(u, &oldUser) + if err != nil { + return err + } + for _, folder := range oldUser.VirtualFolders { + err = removeUserFromFolderMapping(folder, oldUser, folderBucket) + if err != nil { + return err + } + } + for _, folder := range user.VirtualFolders { + err = addUserToFolderMapping(folder, user, folderBucket) + if err != nil { + return err + } + } + user.LastQuotaUpdate = oldUser.LastQuotaUpdate + user.UsedQuotaSize = oldUser.UsedQuotaSize + user.UsedQuotaFiles = oldUser.UsedQuotaFiles + user.LastLogin = oldUser.LastLogin buf, err := json.Marshal(user) if err != nil { return err @@ -302,6 +361,18 @@ func (p BoltProvider) deleteUser(user User) error { if err != nil { return err } + if len(user.VirtualFolders) > 0 { + folderBucket, err := getFolderBucket(tx) + if err != nil { + return err + } + for _, folder := range user.VirtualFolders { + err = removeUserFromFolderMapping(folder, user, folderBucket) + if err != nil { + return err + } + } + } userIDAsBytes := itob(user.ID) userName := idxBucket.Get(userIDAsBytes) if userName == nil { @@ -316,16 +387,19 @@ func (p BoltProvider) deleteUser(user User) error { } func (p BoltProvider) dumpUsers() ([]User, error) { - users := []User{} + users := make([]User, 0, 100) err := p.dbHandle.View(func(tx *bolt.Tx) error { bucket, _, err := getBuckets(tx) if err != nil { return err } + folderBucket, err := getFolderBucket(tx) + if err != nil { + return err + } cursor := bucket.Cursor() for k, v := cursor.First(); k != nil; k, v = cursor.Next() { - var user User - err = json.Unmarshal(v, &user) + user, err := joinUserAndFolders(v, folderBucket) if err != nil { return err } @@ -355,7 +429,7 @@ func (p BoltProvider) getUserWithUsername(username string) ([]User, error) { } func (p BoltProvider) getUsers(limit int, offset int, order string, username string) ([]User, error) { - users := []User{} + users := make([]User, 0, limit) var err error if limit <= 0 { return users, err @@ -371,16 +445,19 @@ func (p BoltProvider) getUsers(limit int, offset int, order string, username str if err != nil { return err } + folderBucket, err := getFolderBucket(tx) + if err != nil { + return err + } cursor := bucket.Cursor() itNum := 0 - if order == "ASC" { + if order == OrderASC { for k, v := cursor.First(); k != nil; k, v = cursor.Next() { itNum++ if itNum <= offset { continue } - var user User - err = json.Unmarshal(v, &user) + user, err := joinUserAndFolders(v, folderBucket) if err == nil { users = append(users, HideUserSensitiveData(&user)) } @@ -394,8 +471,7 @@ func (p BoltProvider) getUsers(limit int, offset int, order string, username str if itNum <= offset { continue } - var user User - err = json.Unmarshal(v, &user) + user, err := joinUserAndFolders(v, folderBucket) if err == nil { users = append(users, HideUserSensitiveData(&user)) } @@ -409,6 +485,209 @@ func (p BoltProvider) getUsers(limit int, offset int, order string, username str return users, err } +func (p BoltProvider) dumpFolders() ([]vfs.BaseVirtualFolder, error) { + folders := make([]vfs.BaseVirtualFolder, 0, 50) + err := p.dbHandle.View(func(tx *bolt.Tx) error { + bucket, err := getFolderBucket(tx) + if err != nil { + return err + } + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var folder vfs.BaseVirtualFolder + err = json.Unmarshal(v, &folder) + if err != nil { + return err + } + folders = append(folders, folder) + } + return err + }) + return folders, err +} + +func (p BoltProvider) getFolders(limit, offset int, order, folderPath string) ([]vfs.BaseVirtualFolder, error) { + folders := make([]vfs.BaseVirtualFolder, 0, limit) + var err error + if limit <= 0 { + return folders, err + } + if len(folderPath) > 0 { + if offset == 0 { + var folder vfs.BaseVirtualFolder + folder, err = p.getFolderByPath(folderPath) + if err == nil { + folders = append(folders, folder) + } + } + return folders, err + } + err = p.dbHandle.View(func(tx *bolt.Tx) error { + bucket, err := getFolderBucket(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() { + itNum++ + if itNum <= offset { + continue + } + var folder vfs.BaseVirtualFolder + err = json.Unmarshal(v, &folder) + if err != nil { + return err + } + folders = append(folders, folder) + if len(folders) >= limit { + break + } + } + } else { + for k, v := cursor.Last(); k != nil; k, v = cursor.Prev() { + itNum++ + if itNum <= offset { + continue + } + var folder vfs.BaseVirtualFolder + err = json.Unmarshal(v, &folder) + if err != nil { + return err + } + folders = append(folders, folder) + if len(folders) >= limit { + break + } + } + } + return err + }) + return folders, err +} + +func (p BoltProvider) getFolderByPath(name string) (vfs.BaseVirtualFolder, error) { + var folder vfs.BaseVirtualFolder + err := p.dbHandle.View(func(tx *bolt.Tx) error { + bucket, err := getFolderBucket(tx) + if err != nil { + return err + } + folder, err = folderExistsInternal(name, bucket) + return err + }) + return folder, err +} + +func (p BoltProvider) addFolder(folder vfs.BaseVirtualFolder) error { + err := validateFolder(&folder) + if err != nil { + return err + } + return p.dbHandle.Update(func(tx *bolt.Tx) error { + bucket, err := getFolderBucket(tx) + if err != nil { + return err + } + if f := bucket.Get([]byte(folder.MappedPath)); f != nil { + return fmt.Errorf("folder %v already exists", folder.MappedPath) + } + _, err = addFolderInternal(folder, bucket) + return err + }) +} + +func (p BoltProvider) deleteFolder(folder vfs.BaseVirtualFolder) error { + return p.dbHandle.Update(func(tx *bolt.Tx) error { + bucket, err := getFolderBucket(tx) + if err != nil { + return err + } + usersBucket, _, err := getBuckets(tx) + if err != nil { + return err + } + var f []byte + if f = bucket.Get([]byte(folder.MappedPath)); f == nil { + return &RecordNotFoundError{err: fmt.Sprintf("folder %v does not exist", folder.MappedPath)} + } + var folder vfs.BaseVirtualFolder + err = json.Unmarshal(f, &folder) + if err != nil { + return err + } + for _, username := range folder.Users { + var u []byte + if u = usersBucket.Get([]byte(username)); u == nil { + continue + } + var user User + err = json.Unmarshal(u, &user) + if err != nil { + return err + } + var folders []vfs.VirtualFolder + for _, userFolder := range user.VirtualFolders { + if folder.MappedPath != userFolder.MappedPath { + folders = append(folders, userFolder) + } + } + user.VirtualFolders = folders + buf, err := json.Marshal(user) + if err != nil { + return err + } + err = usersBucket.Put([]byte(user.Username), buf) + if err != nil { + return err + } + } + + return bucket.Delete([]byte(folder.MappedPath)) + }) +} + +func (p BoltProvider) updateFolderQuota(mappedPath string, filesAdd int, sizeAdd int64, reset bool) error { + return p.dbHandle.Update(func(tx *bolt.Tx) error { + bucket, err := getFolderBucket(tx) + if err != nil { + return err + } + var f []byte + if f = bucket.Get([]byte(mappedPath)); f == nil { + return &RecordNotFoundError{err: fmt.Sprintf("folder %v does not exist, unable to update quota", mappedPath)} + } + var folder vfs.BaseVirtualFolder + err = json.Unmarshal(f, &folder) + if err != nil { + return err + } + if reset { + folder.UsedQuotaSize = sizeAdd + folder.UsedQuotaFiles = filesAdd + } else { + folder.UsedQuotaSize += sizeAdd + folder.UsedQuotaFiles += filesAdd + } + folder.LastQuotaUpdate = utils.GetTimeAsMsSinceEpoch(time.Now()) + buf, err := json.Marshal(folder) + if err != nil { + return err + } + return bucket.Put([]byte(folder.MappedPath), buf) + }) +} + +func (p BoltProvider) getUsedFolderQuota(mappedPath string) (int, int64, error) { + folder, err := p.getFolderByPath(mappedPath) + if err != nil { + providerLog(logger.LevelWarn, "unable to get quota for folder %#v error: %v", mappedPath, err) + return 0, 0, err + } + return folder.UsedQuotaFiles, folder.UsedQuotaSize, err +} + func (p BoltProvider) close() error { return p.dbHandle.Close() } @@ -437,9 +716,19 @@ func (p BoltProvider) migrateDatabase() error { if err != nil { return err } - return updateDatabaseFrom2To3(p.dbHandle) + err = updateDatabaseFrom2To3(p.dbHandle) + if err != nil { + return err + } + return updateDatabaseFrom3To4(p.dbHandle) case 2: - return updateDatabaseFrom2To3(p.dbHandle) + err = updateDatabaseFrom2To3(p.dbHandle) + if err != nil { + return err + } + return updateDatabaseFrom3To4(p.dbHandle) + case 3: + return updateDatabaseFrom3To4(p.dbHandle) default: return fmt.Errorf("Database version not handled: %v", dbVersion.Version) } @@ -452,6 +741,106 @@ func itob(v int64) []byte { return b } +func joinUserAndFolders(u []byte, foldersBucket *bolt.Bucket) (User, error) { + var user User + err := json.Unmarshal(u, &user) + if len(user.VirtualFolders) > 0 { + var folders []vfs.VirtualFolder + for _, folder := range user.VirtualFolders { + baseFolder, err := folderExistsInternal(folder.MappedPath, foldersBucket) + if err != nil { + continue + } + folder.UsedQuotaFiles = baseFolder.UsedQuotaFiles + folder.UsedQuotaSize = baseFolder.UsedQuotaSize + folder.LastQuotaUpdate = baseFolder.LastQuotaUpdate + folder.ID = baseFolder.ID + folders = append(folders, folder) + } + user.VirtualFolders = folders + } + return user, err +} + +func folderExistsInternal(name string, bucket *bolt.Bucket) (vfs.BaseVirtualFolder, error) { + var folder vfs.BaseVirtualFolder + f := bucket.Get([]byte(name)) + if f == nil { + err := &RecordNotFoundError{err: fmt.Sprintf("folder %v does not exist", name)} + return folder, err + } + err := json.Unmarshal(f, &folder) + return folder, err +} + +func addFolderInternal(folder vfs.BaseVirtualFolder, bucket *bolt.Bucket) (vfs.BaseVirtualFolder, error) { + id, err := bucket.NextSequence() + if err != nil { + return folder, err + } + folder.ID = int64(id) + buf, err := json.Marshal(folder) + if err != nil { + return folder, err + } + err = bucket.Put([]byte(folder.MappedPath), buf) + return folder, err +} + +func addUserToFolderMapping(folder vfs.VirtualFolder, user User, bucket *bolt.Bucket) error { + var baseFolder vfs.BaseVirtualFolder + var err error + if f := bucket.Get([]byte(folder.MappedPath)); f == nil { + // folder does not exists, try to create + baseFolder, err = addFolderInternal(folder.BaseVirtualFolder, bucket) + } else { + err = json.Unmarshal(f, &baseFolder) + } + if err != nil { + return err + } + if !utils.IsStringInSlice(user.Username, baseFolder.Users) { + baseFolder.Users = append(baseFolder.Users, user.Username) + buf, err := json.Marshal(baseFolder) + if err != nil { + return err + } + err = bucket.Put([]byte(folder.MappedPath), buf) + if err != nil { + return err + } + } + return err +} + +func removeUserFromFolderMapping(folder vfs.VirtualFolder, user User, bucket *bolt.Bucket) error { + var f []byte + if f = bucket.Get([]byte(folder.MappedPath)); f == nil { + // the folder does not exists so there is no associated user + return nil + } + var baseFolder vfs.BaseVirtualFolder + err := json.Unmarshal(f, &baseFolder) + if err != nil { + return err + } + if utils.IsStringInSlice(user.Username, baseFolder.Users) { + var newUserMapping []string + for _, u := range baseFolder.Users { + if u != user.Username { + newUserMapping = append(newUserMapping, u) + } + } + baseFolder.Users = newUserMapping + buf, err := json.Marshal(baseFolder) + if err != nil { + return err + } + return bucket.Put([]byte(folder.MappedPath), buf) + } + return err +} + func getBuckets(tx *bolt.Tx) (*bolt.Bucket, *bolt.Bucket, error) { var err error bucket := tx.Bucket(usersBucket) @@ -462,6 +851,15 @@ func getBuckets(tx *bolt.Tx) (*bolt.Bucket, *bolt.Bucket, error) { return bucket, idxBucket, err } +func getFolderBucket(tx *bolt.Tx) (*bolt.Bucket, error) { + var err error + bucket := tx.Bucket(foldersBucket) + if bucket == nil { + err = fmt.Errorf("unable to find required buckets, bolt database structure not correcly defined") + } + return bucket, err +} + func updateDatabaseFrom1To2(dbHandle *bolt.DB) error { providerLog(logger.LevelInfo, "updating bolt database version: 1 -> 2") usernames, err := getBoltAvailableUsernames(dbHandle) @@ -537,6 +935,69 @@ func updateDatabaseFrom2To3(dbHandle *bolt.DB) error { return updateBoltDatabaseVersion(dbHandle, 3) } +func updateDatabaseFrom3To4(dbHandle *bolt.DB) error { + providerLog(logger.LevelInfo, "updating bolt database version: 3 -> 4") + foldersToScan := []string{} + users := []userCompactVFolders{} + err := dbHandle.View(func(tx *bolt.Tx) error { + bucket, _, err := getBuckets(tx) + if err != nil { + return err + } + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var compatUser userCompactVFolders + err = json.Unmarshal(v, &compatUser) + if err == nil && len(compatUser.VirtualFolders) > 0 { + users = append(users, compatUser) + } + } + return err + }) + if err != nil { + return err + } + for _, u := range users { + user, err := provider.userExists(u.Username) + if err != nil { + return err + } + var folders []vfs.VirtualFolder + for _, f := range u.VirtualFolders { + providerLog(logger.LevelInfo, "restoring virtual folder: %+v for user %#v", f, user.Username) + quotaSize := int64(-1) + quotaFiles := -1 + if f.ExcludeFromQuota { + quotaSize = 0 + quotaFiles = 0 + } + folder := vfs.VirtualFolder{ + QuotaSize: quotaSize, + QuotaFiles: quotaFiles, + VirtualPath: f.VirtualPath, + } + folder.MappedPath = f.MappedPath + folders = append(folders, folder) + if !utils.IsStringInSlice(folder.MappedPath, foldersToScan) { + foldersToScan = append(foldersToScan, folder.MappedPath) + } + } + user.VirtualFolders = folders + err = provider.updateUser(user) + providerLog(logger.LevelInfo, "number of virtual folders to restore %v, user %#v, error: %v", len(user.VirtualFolders), + user.Username, err) + if err != nil { + return err + } + } + + err = updateBoltDatabaseVersion(dbHandle, 4) + if err == nil { + go updateVFoldersQuotaAfterRestore(foldersToScan) + } + return err +} + func getBoltAvailableUsernames(dbHandle *bolt.DB) ([]string, error) { usernames := []string{} err := dbHandle.View(func(tx *bolt.Tx) error { diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index b4031ad8..d3be252c 100644 --- a/dataprovider/dataprovider.go +++ b/dataprovider/dataprovider.go @@ -1,6 +1,5 @@ // Package dataprovider provides data access. -// It abstract different data providers and exposes a common API. -// Currently the supported data providers are: PostreSQL (9+), MySQL (4.1+) and SQLite 3.x +// It abstracts different data providers and exposes a common API. package dataprovider import ( @@ -72,6 +71,13 @@ const ( operationAdd = "add" operationUpdate = "update" operationDelete = "delete" + sqlPrefixValidChars = "abcdefghijklmnopqrstuvwxyz_" +) + +// ordering constants +const ( + OrderASC = "ASC" + OrderDESC = "DESC" ) var ( @@ -100,6 +106,10 @@ var ( errWrongPassword = errors.New("password does not match") errNoInitRequired = errors.New("initialization is not required for this data provider") credentialsDirPath string + sqlTableUsers = "users" + sqlTableFolders = "folders" + sqlTableFoldersMapping = "folders_mapping" + sqlTableSchemaVersion = "schema_version" ) type schemaVersion struct { @@ -143,14 +153,15 @@ type Config struct { // Custom database connection string. // If not empty this connection string will be used instead of build one using the previous parameters ConnectionString string `json:"connection_string" mapstructure:"connection_string"` - // Database table for SFTP users - UsersTable string `json:"users_table" mapstructure:"users_table"` + // prefix for SQL tables + SQLTablesPrefix string `json:"sql_tables_prefix" mapstructure:"sql_tables_prefix"` // Set to 0 to disable users management, 1 to enable ManageUsers int `json:"manage_users" mapstructure:"manage_users"` // Set the preferred way to track users quota between the following choices: // 0, disable quota tracking. REST API to scan user dir and update quota will do nothing // 1, quota is updated each time a user upload or delete a file even if the user has no quota restrictions - // 2, quota is updated each time a user upload or delete a file but only for users with quota restrictions. + // 2, quota is updated each time a user upload or delete a file but only for users with quota restrictions + // and for virtual folders. // With this configuration the "quota scan" REST API can still be used to periodically update space usage // for users without quota restrictions TrackQuota int `json:"track_quota" mapstructure:"track_quota"` @@ -253,7 +264,8 @@ type Config struct { // BackupData defines the structure for the backup/restore files type BackupData struct { - Users []User `json:"users"` + Users []User `json:"users"` + Folders []vfs.BaseVirtualFolder `json:"folders"` } type keyboardAuthHookRequest struct { @@ -272,6 +284,18 @@ type keyboardAuthHookResponse struct { CheckPwd int `json:"check_password"` } +type virtualFoldersCompact struct { + VirtualPath string `json:"virtual_path"` + MappedPath string `json:"mapped_path"` + ExcludeFromQuota bool `json:"exclude_from_quota"` +} + +type userCompactVFolders struct { + ID int64 `json:"id"` + Username string `json:"username"` + VirtualFolders []virtualFoldersCompact `json:"virtual_folders"` +} + // ValidationError raised if input data is not valid type ValidationError struct { err string @@ -313,7 +337,7 @@ func GetQuotaTracking() int { return config.TrackQuota } -// Provider interface that data providers must implement. +// Provider defines the interface that data providers must implement. type Provider interface { validateUserAndPass(username string, password string) (User, error) validateUserAndPubKey(username string, pubKey []byte) (User, string, error) @@ -327,6 +351,13 @@ type Provider interface { dumpUsers() ([]User, error) getUserByID(ID int64) (User, error) updateLastLogin(username string) error + getFolders(limit, offset int, order, folderPath string) ([]vfs.BaseVirtualFolder, error) + getFolderByPath(mappedPath string) (vfs.BaseVirtualFolder, error) + addFolder(folder vfs.BaseVirtualFolder) error + deleteFolder(folder vfs.BaseVirtualFolder) error + updateFolderQuota(mappedPath string, filesAdd int, sizeAdd int64, reset bool) error + getUsedFolderQuota(mappedPath string) (int, int64, error) + dumpFolders() ([]vfs.BaseVirtualFolder, error) checkAvailability() error close() error reloadConfig() error @@ -343,7 +374,6 @@ func init() { func Initialize(cnf Config, basePath string) error { var err error config = cnf - sqlPlaceholders = getSQLPlaceholders() if err = validateHooks(); err != nil { return err @@ -388,10 +418,26 @@ func validateHooks() error { return nil } +func validateSQLTablesPrefix() error { + if len(config.SQLTablesPrefix) > 0 { + for _, char := range config.SQLTablesPrefix { + if !strings.Contains(sqlPrefixValidChars, strings.ToLower(string(char))) { + return errors.New("Invalid sql_tables_prefix only chars in range 'a..z', 'A..Z' and '_' are allowed") + } + } + sqlTableUsers = config.SQLTablesPrefix + sqlTableUsers + sqlTableFolders = config.SQLTablesPrefix + sqlTableFolders + sqlTableFoldersMapping = config.SQLTablesPrefix + sqlTableFoldersMapping + sqlTableSchemaVersion = config.SQLTablesPrefix + sqlTableSchemaVersion + providerLog(logger.LevelDebug, "sql table for users %#v, folders %#v folders mapping %#v schema version %#v", + sqlTableUsers, sqlTableFolders, sqlTableFoldersMapping, sqlTableSchemaVersion) + } + return nil +} + // InitializeDatabase creates the initial database structure func InitializeDatabase(cnf Config, basePath string) error { config = cnf - sqlPlaceholders = getSQLPlaceholders() if config.Driver == BoltDataProviderName || config.Driver == MemoryDataProviderName { return errNoInitRequired @@ -481,8 +527,19 @@ func UpdateUserQuota(p Provider, user User, filesAdd int, sizeAdd int64, reset b return p.updateQuota(user.Username, filesAdd, sizeAdd, reset) } +// UpdateVirtualFolderQuota updates the quota for the given virtual folder adding filesAdd and sizeAdd. +// If reset is true filesAdd and sizeAdd indicates the total files and the total size instead of the difference. +func UpdateVirtualFolderQuota(p Provider, vfolder vfs.BaseVirtualFolder, filesAdd int, sizeAdd int64, reset bool) error { + if config.TrackQuota == 0 { + return &MethodDisabledError{err: trackQuotaDisabledError} + } + if config.ManageUsers == 0 { + return &MethodDisabledError{err: manageUsersDisabledError} + } + return p.updateFolderQuota(vfolder.MappedPath, filesAdd, sizeAdd, reset) +} + // GetUsedQuota returns the used quota for the given SFTP user. -// TrackQuota must be >=1 to enable this method func GetUsedQuota(p Provider, username string) (int, int64, error) { if config.TrackQuota == 0 { return 0, 0, &MethodDisabledError{err: trackQuotaDisabledError} @@ -490,6 +547,14 @@ func GetUsedQuota(p Provider, username string) (int, int64, error) { return p.getUsedQuota(username) } +// GetUsedVirtualFolderQuota returns the used quota for the given virtual folder. +func GetUsedVirtualFolderQuota(p Provider, mappedPath string) (int, int64, error) { + if config.TrackQuota == 0 { + return 0, 0, &MethodDisabledError{err: trackQuotaDisabledError} + } + return p.getUsedFolderQuota(mappedPath) +} + // UserExists checks if the given SFTP username exists, returns an error if no match is found func UserExists(p Provider, username string) (User, error) { return p.userExists(username) @@ -534,11 +599,6 @@ func DeleteUser(p Provider, user User) error { return err } -// DumpUsers returns an array with all users including their hashed password -func DumpUsers(p Provider) ([]User, error) { - return p.dumpUsers() -} - // ReloadConfig reloads provider configuration. // Currently only implemented for memory provider, allows to reload the users // from the configured file, if defined @@ -547,7 +607,7 @@ func ReloadConfig() error { } // GetUsers returns an array of users respecting limit and offset and filtered by username exact match if not empty -func GetUsers(p Provider, limit int, offset int, order string, username string) ([]User, error) { +func GetUsers(p Provider, limit, offset int, order string, username string) ([]User, error) { return p.getUsers(limit, offset, order, username) } @@ -556,6 +616,50 @@ func GetUserByID(p Provider, ID int64) (User, error) { return p.getUserByID(ID) } +// AddFolder adds a new virtual folder. +// ManageUsers configuration must be set to 1 to enable this method +func AddFolder(p Provider, folder vfs.BaseVirtualFolder) error { + if config.ManageUsers == 0 { + return &MethodDisabledError{err: manageUsersDisabledError} + } + return p.addFolder(folder) +} + +// DeleteFolder deletes an existing folder. +// ManageUsers configuration must be set to 1 to enable this method +func DeleteFolder(p Provider, folder vfs.BaseVirtualFolder) error { + if config.ManageUsers == 0 { + return &MethodDisabledError{err: manageUsersDisabledError} + } + return p.deleteFolder(folder) +} + +// GetFolderByPath returns the folder with the specified path if any +func GetFolderByPath(p Provider, mappedPath string) (vfs.BaseVirtualFolder, error) { + return p.getFolderByPath(mappedPath) +} + +// GetFolders returns an array of folders respecting limit and offset +func GetFolders(p Provider, limit, offset int, order, folderPath string) ([]vfs.BaseVirtualFolder, error) { + return p.getFolders(limit, offset, order, folderPath) +} + +// DumpData returns all users and folders +func DumpData(p Provider) (BackupData, error) { + var data BackupData + users, err := p.dumpUsers() + if err != nil { + return data, err + } + folders, err := p.dumpFolders() + if err != nil { + return data, err + } + data.Users = users + data.Folders = folders + return data, err +} + // GetProviderStatus returns an error if the provider is not available func GetProviderStatus(p Provider) error { return p.checkAvailability() @@ -572,6 +676,10 @@ func Close(p Provider) error { func createProvider(basePath string) error { var err error + sqlPlaceholders = getSQLPlaceholders() + if err = validateSQLTablesPrefix(); err != nil { + return err + } if config.Driver == SQLiteDataProviderName { err = initializeSQLiteProvider(basePath) } else if config.Driver == PGSQLDataProviderName { @@ -630,7 +738,21 @@ func isMappedDirOverlapped(dir1, dir2 string) bool { return false } -func validateVirtualFolders(user *User) error { +func validateFolderQuotaLimits(folder vfs.VirtualFolder) error { + if folder.QuotaSize < -1 { + return &ValidationError{err: fmt.Sprintf("invalid quota_size: %v folder path %#v", folder.QuotaSize, folder.MappedPath)} + } + if folder.QuotaFiles < -1 { + return &ValidationError{err: fmt.Sprintf("invalid quota_file: %v folder path %#v", folder.QuotaSize, folder.MappedPath)} + } + if (folder.QuotaSize == -1 && folder.QuotaFiles != -1) || (folder.QuotaFiles == -1 && folder.QuotaSize != -1) { + return &ValidationError{err: fmt.Sprintf("virtual folder quota_size and quota_files must be both -1 or >= 0, quota_size: %v quota_files: %v", + folder.QuotaFiles, folder.QuotaSize)} + } + return nil +} + +func validateUserVirtualFolders(user *User) error { if len(user.VirtualFolders) == 0 || user.FsConfig.Provider != 0 { user.VirtualFolders = []vfs.VirtualFolder{} return nil @@ -642,6 +764,9 @@ func validateVirtualFolders(user *User) error { if !path.IsAbs(cleanedVPath) || cleanedVPath == "/" { return &ValidationError{err: fmt.Sprintf("invalid virtual folder %#v", v.VirtualPath)} } + if err := validateFolderQuotaLimits(v); err != nil { + return err + } cleanedMPath := filepath.Clean(v.MappedPath) if !filepath.IsAbs(cleanedMPath) { return &ValidationError{err: fmt.Sprintf("invalid mapped folder %#v", v.MappedPath)} @@ -651,9 +776,12 @@ func validateVirtualFolders(user *User) error { v.MappedPath, user.GetHomeDir())} } virtualFolders = append(virtualFolders, vfs.VirtualFolder{ - VirtualPath: cleanedVPath, - MappedPath: cleanedMPath, - ExcludeFromQuota: v.ExcludeFromQuota, + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: cleanedMPath, + }, + VirtualPath: cleanedVPath, + QuotaSize: v.QuotaSize, + QuotaFiles: v.QuotaFiles, }) for k, virtual := range mappedPaths { if isMappedDirOverlapped(k, cleanedMPath) { @@ -859,6 +987,15 @@ func createUserPasswordHash(user *User) error { return nil } +func validateFolder(folder *vfs.BaseVirtualFolder) error { + cleanedMPath := filepath.Clean(folder.MappedPath) + if !filepath.IsAbs(cleanedMPath) { + return &ValidationError{err: fmt.Sprintf("invalid mapped folder %#v", folder.MappedPath)} + } + folder.MappedPath = cleanedMPath + return nil +} + func validateUser(user *User) error { buildUserHomeDir(user) if err := validateBaseParams(user); err != nil { @@ -870,7 +1007,7 @@ func validateUser(user *User) error { if err := validateFilesystemConfig(user); err != nil { return err } - if err := validateVirtualFolders(user); err != nil { + if err := validateUserVirtualFolders(user); err != nil { return err } if user.Status < 0 || user.Status > 1 { @@ -1581,3 +1718,23 @@ func executeAction(operation string, user User) { executeNotificationCommand(operation, user) //nolint:errcheck // the error is used in test cases only } } + +// after migrating database to v4 we have to update the quota for the imported folders +func updateVFoldersQuotaAfterRestore(foldersToScan []string) { + fs := vfs.NewOsFs("", "", nil).(vfs.OsFs) + for _, folder := range foldersToScan { + providerLog(logger.LevelDebug, "starting quota scan after migration for folder %#v", folder) + vfolder, err := provider.getFolderByPath(folder) + if err != nil { + providerLog(logger.LevelWarn, "error getting folder to scan %#v: %v", folder, err) + continue + } + numFiles, size, err := fs.GetDirSize(folder) + if err != nil { + providerLog(logger.LevelWarn, "error scanning folder %#v: %v", folder, err) + continue + } + err = UpdateVirtualFolderQuota(provider, vfolder, numFiles, size, true) + providerLog(logger.LevelDebug, "quota updated for virtual folder %#v, error: %v", vfolder.MappedPath, err) + } +} diff --git a/dataprovider/memory.go b/dataprovider/memory.go index 41b7b0c1..d825793c 100644 --- a/dataprovider/memory.go +++ b/dataprovider/memory.go @@ -13,6 +13,7 @@ import ( "github.com/drakkan/sftpgo/logger" "github.com/drakkan/sftpgo/utils" + "github.com/drakkan/sftpgo/vfs" ) var ( @@ -27,6 +28,10 @@ type memoryProviderHandle struct { usersIdx map[int64]string // map for users, username is the key users map[string]User + // map for virtual folders, MappedPath is the key + vfolders map[string]vfs.BaseVirtualFolder + // slice with ordered folders mapped path + vfoldersPaths []string // configuration file to use for loading users configFile string lock *sync.Mutex @@ -48,12 +53,14 @@ func initializeMemoryProvider(basePath string) error { } provider = MemoryProvider{ dbHandle: &memoryProviderHandle{ - isClosed: false, - usernames: []string{}, - usersIdx: make(map[int64]string), - users: make(map[string]User), - configFile: configFile, - lock: new(sync.Mutex), + isClosed: false, + usernames: []string{}, + usersIdx: make(map[int64]string), + users: make(map[string]User), + vfolders: make(map[string]vfs.BaseVirtualFolder), + vfoldersPaths: []string{}, + configFile: configFile, + lock: new(sync.Mutex), }, } return provider.reloadConfig() @@ -85,7 +92,7 @@ func (p MemoryProvider) validateUserAndPass(username string, password string) (U } user, err := p.userExists(username) if err != nil { - providerLog(logger.LevelWarn, "error authenticating user: %v, error: %v", username, err) + providerLog(logger.LevelWarn, "error authenticating user %#v, error: %v", username, err) return user, err } return checkUserAndPass(user, password) @@ -98,7 +105,7 @@ func (p MemoryProvider) validateUserAndPubKey(username string, pubKey []byte) (U } user, err := p.userExists(username) if err != nil { - providerLog(logger.LevelWarn, "error authenticating user: %v, error: %v", username, err) + providerLog(logger.LevelWarn, "error authenticating user %#v, error: %v", username, err) return user, "", err } return checkUserAndPubKey(user, pubKey) @@ -139,7 +146,7 @@ func (p MemoryProvider) updateQuota(username string, filesAdd int, sizeAdd int64 } user, err := p.userExistsInternal(username) if err != nil { - providerLog(logger.LevelWarn, "unable to update quota for user %v error: %v", username, err) + providerLog(logger.LevelWarn, "unable to update quota for user %#v error: %v", username, err) return err } if reset { @@ -150,6 +157,8 @@ func (p MemoryProvider) updateQuota(username string, filesAdd int, sizeAdd int64 user.UsedQuotaFiles += filesAdd } user.LastQuotaUpdate = utils.GetTimeAsMsSinceEpoch(time.Now()) + providerLog(logger.LevelDebug, "quota updated for user %#v, files increment: %v size increment: %v is reset? %v", + username, filesAdd, sizeAdd, reset) p.dbHandle.users[user.Username] = user return nil } @@ -162,7 +171,7 @@ func (p MemoryProvider) getUsedQuota(username string) (int, int64, error) { } user, err := p.userExistsInternal(username) if err != nil { - providerLog(logger.LevelWarn, "unable to get quota for user %v error: %v", username, err) + providerLog(logger.LevelWarn, "unable to get quota for user %#v error: %v", username, err) return 0, 0, err } return user.UsedQuotaFiles, user.UsedQuotaSize, err @@ -180,9 +189,10 @@ func (p MemoryProvider) addUser(user User) error { } _, err = p.userExistsInternal(user.Username) if err == nil { - return fmt.Errorf("username %v already exists", user.Username) + return fmt.Errorf("username %#v already exists", user.Username) } user.ID = p.getNextID() + user.VirtualFolders = p.joinVirtualFoldersFields(user) p.dbHandle.users[user.Username] = user p.dbHandle.usersIdx[user.ID] = user.Username p.dbHandle.usernames = append(p.dbHandle.usernames, user.Username) @@ -200,10 +210,18 @@ func (p MemoryProvider) updateUser(user User) error { if err != nil { return err } - _, err = p.userExistsInternal(user.Username) + u, err := p.userExistsInternal(user.Username) if err != nil { return err } + for _, oldFolder := range u.VirtualFolders { + p.removeUserFromFolderMapping(oldFolder.MappedPath, u.Username) + } + user.VirtualFolders = p.joinVirtualFoldersFields(user) + user.LastQuotaUpdate = u.LastQuotaUpdate + user.UsedQuotaSize = u.UsedQuotaSize + user.UsedQuotaFiles = u.UsedQuotaFiles + user.LastLogin = u.LastLogin p.dbHandle.users[user.Username] = user return nil } @@ -214,10 +232,13 @@ func (p MemoryProvider) deleteUser(user User) error { if p.dbHandle.isClosed { return errMemoryProviderClosed } - _, err := p.userExistsInternal(user.Username) + u, err := p.userExistsInternal(user.Username) if err != nil { return err } + for _, oldFolder := range u.VirtualFolders { + p.removeUserFromFolderMapping(oldFolder.MappedPath, u.Username) + } delete(p.dbHandle.users, user.Username) delete(p.dbHandle.usersIdx, user.ID) // this could be more efficient @@ -230,10 +251,10 @@ func (p MemoryProvider) deleteUser(user User) error { } func (p MemoryProvider) dumpUsers() ([]User, error) { - users := []User{} - var err error p.dbHandle.lock.Lock() defer p.dbHandle.lock.Unlock() + users := make([]User, 0, len(p.dbHandle.usernames)) + var err error if p.dbHandle.isClosed { return users, errMemoryProviderClosed } @@ -248,8 +269,21 @@ func (p MemoryProvider) dumpUsers() ([]User, error) { return users, err } +func (p MemoryProvider) dumpFolders() ([]vfs.BaseVirtualFolder, error) { + p.dbHandle.lock.Lock() + defer p.dbHandle.lock.Unlock() + folders := make([]vfs.BaseVirtualFolder, 0, len(p.dbHandle.vfoldersPaths)) + if p.dbHandle.isClosed { + return folders, errMemoryProviderClosed + } + for _, f := range p.dbHandle.vfolders { + folders = append(folders, f) + } + return folders, nil +} + func (p MemoryProvider) getUsers(limit int, offset int, order string, username string) ([]User, error) { - users := []User{} + users := make([]User, 0, limit) var err error p.dbHandle.lock.Lock() defer p.dbHandle.lock.Unlock() @@ -269,7 +303,7 @@ func (p MemoryProvider) getUsers(limit int, offset int, order string, username s return users, err } itNum := 0 - if order == "ASC" { + if order == OrderASC { for _, username := range p.dbHandle.usernames { itNum++ if itNum <= offset { @@ -311,7 +345,224 @@ func (p MemoryProvider) userExistsInternal(username string) (User, error) { if val, ok := p.dbHandle.users[username]; ok { return val.getACopy(), nil } - return User{}, &RecordNotFoundError{err: fmt.Sprintf("username %v does not exist", username)} + return User{}, &RecordNotFoundError{err: fmt.Sprintf("username %#v does not exist", username)} +} + +func (p MemoryProvider) updateFolderQuota(mappedPath string, filesAdd int, sizeAdd int64, reset bool) error { + p.dbHandle.lock.Lock() + defer p.dbHandle.lock.Unlock() + if p.dbHandle.isClosed { + return errMemoryProviderClosed + } + folder, err := p.folderExistsInternal(mappedPath) + if err != nil { + providerLog(logger.LevelWarn, "unable to update quota for folder %#v error: %v", mappedPath, err) + return err + } + if reset { + folder.UsedQuotaSize = sizeAdd + folder.UsedQuotaFiles = filesAdd + } else { + folder.UsedQuotaSize += sizeAdd + folder.UsedQuotaFiles += filesAdd + } + folder.LastQuotaUpdate = utils.GetTimeAsMsSinceEpoch(time.Now()) + p.dbHandle.vfolders[mappedPath] = folder + return nil +} + +func (p MemoryProvider) getUsedFolderQuota(mappedPath string) (int, int64, error) { + p.dbHandle.lock.Lock() + defer p.dbHandle.lock.Unlock() + if p.dbHandle.isClosed { + return 0, 0, errMemoryProviderClosed + } + folder, err := p.folderExistsInternal(mappedPath) + if err != nil { + providerLog(logger.LevelWarn, "unable to get quota for folder %#v error: %v", mappedPath, err) + return 0, 0, err + } + return folder.UsedQuotaFiles, folder.UsedQuotaSize, err +} + +func (p MemoryProvider) joinVirtualFoldersFields(user User) []vfs.VirtualFolder { + var folders []vfs.VirtualFolder + for _, folder := range user.VirtualFolders { + f, err := p.addOrGetFolderInternal(folder.MappedPath, user.Username, folder.UsedQuotaSize, folder.UsedQuotaFiles, + folder.LastQuotaUpdate) + if err == nil { + folder.UsedQuotaFiles = f.UsedQuotaFiles + folder.UsedQuotaSize = f.UsedQuotaSize + folder.LastQuotaUpdate = f.LastQuotaUpdate + folder.ID = f.ID + folders = append(folders, folder) + } + } + return folders +} + +func (p MemoryProvider) removeUserFromFolderMapping(mappedPath, username string) { + folder, err := p.folderExistsInternal(mappedPath) + if err == nil { + var usernames []string + for _, user := range folder.Users { + if user != username { + usernames = append(usernames, user) + } + } + folder.Users = usernames + p.dbHandle.vfolders[folder.MappedPath] = folder + } +} + +func (p MemoryProvider) updateFoldersMappingInternal(folder vfs.BaseVirtualFolder) { + p.dbHandle.vfolders[folder.MappedPath] = folder + if !utils.IsStringInSlice(folder.MappedPath, p.dbHandle.vfoldersPaths) { + p.dbHandle.vfoldersPaths = append(p.dbHandle.vfoldersPaths, folder.MappedPath) + sort.Strings(p.dbHandle.vfoldersPaths) + } +} + +func (p MemoryProvider) addOrGetFolderInternal(mappedPath, username string, usedQuotaSize int64, usedQuotaFiles int, lastQuotaUpdate int64) (vfs.BaseVirtualFolder, error) { + folder, err := p.folderExistsInternal(mappedPath) + if _, ok := err.(*RecordNotFoundError); ok { + folder := vfs.BaseVirtualFolder{ + ID: p.getNextFolderID(), + MappedPath: mappedPath, + UsedQuotaSize: usedQuotaSize, + UsedQuotaFiles: usedQuotaFiles, + LastQuotaUpdate: lastQuotaUpdate, + Users: []string{username}, + } + p.updateFoldersMappingInternal(folder) + return folder, nil + } + if err == nil && !utils.IsStringInSlice(username, folder.Users) { + folder.Users = append(folder.Users, username) + p.updateFoldersMappingInternal(folder) + } + return folder, err +} + +func (p MemoryProvider) folderExistsInternal(mappedPath string) (vfs.BaseVirtualFolder, error) { + if val, ok := p.dbHandle.vfolders[mappedPath]; ok { + return val, nil + } + return vfs.BaseVirtualFolder{}, &RecordNotFoundError{err: fmt.Sprintf("folder %#v does not exist", mappedPath)} +} + +func (p MemoryProvider) getFolders(limit, offset int, order, folderPath string) ([]vfs.BaseVirtualFolder, error) { + folders := make([]vfs.BaseVirtualFolder, 0, limit) + var err error + p.dbHandle.lock.Lock() + defer p.dbHandle.lock.Unlock() + if p.dbHandle.isClosed { + return folders, errMemoryProviderClosed + } + if limit <= 0 { + return folders, err + } + if len(folderPath) > 0 { + if offset == 0 { + var folder vfs.BaseVirtualFolder + folder, err = p.folderExistsInternal(folderPath) + if err == nil { + folders = append(folders, folder) + } + } + return folders, err + } + itNum := 0 + if order == OrderASC { + for _, mappedPath := range p.dbHandle.vfoldersPaths { + itNum++ + if itNum <= offset { + continue + } + folder := p.dbHandle.vfolders[mappedPath] + folders = append(folders, folder) + if len(folders) >= limit { + break + } + } + } else { + for i := len(p.dbHandle.vfoldersPaths) - 1; i >= 0; i-- { + itNum++ + if itNum <= offset { + continue + } + mappedPath := p.dbHandle.vfoldersPaths[i] + folder := p.dbHandle.vfolders[mappedPath] + folders = append(folders, folder) + if len(folders) >= limit { + break + } + } + } + return folders, err +} + +func (p MemoryProvider) getFolderByPath(mappedPath string) (vfs.BaseVirtualFolder, error) { + p.dbHandle.lock.Lock() + defer p.dbHandle.lock.Unlock() + if p.dbHandle.isClosed { + return vfs.BaseVirtualFolder{}, errMemoryProviderClosed + } + return p.folderExistsInternal(mappedPath) +} + +func (p MemoryProvider) addFolder(folder vfs.BaseVirtualFolder) error { + p.dbHandle.lock.Lock() + defer p.dbHandle.lock.Unlock() + if p.dbHandle.isClosed { + return errMemoryProviderClosed + } + err := validateFolder(&folder) + if err != nil { + return err + } + _, err = p.folderExistsInternal(folder.MappedPath) + if err == nil { + return fmt.Errorf("folder %#v already exists", folder.MappedPath) + } + folder.ID = p.getNextFolderID() + p.dbHandle.vfolders[folder.MappedPath] = folder + p.dbHandle.vfoldersPaths = append(p.dbHandle.vfoldersPaths, folder.MappedPath) + sort.Strings(p.dbHandle.vfoldersPaths) + return nil +} + +func (p MemoryProvider) deleteFolder(folder vfs.BaseVirtualFolder) error { + p.dbHandle.lock.Lock() + defer p.dbHandle.lock.Unlock() + if p.dbHandle.isClosed { + return errMemoryProviderClosed + } + + _, err := p.folderExistsInternal(folder.MappedPath) + if err != nil { + return err + } + for _, username := range folder.Users { + user, err := p.userExistsInternal(username) + if err == nil { + var folders []vfs.VirtualFolder + for _, userFolder := range user.VirtualFolders { + if folder.MappedPath != userFolder.MappedPath { + folders = append(folders, userFolder) + } + } + user.VirtualFolders = folders + p.dbHandle.users[user.Username] = user + } + } + delete(p.dbHandle.vfolders, folder.MappedPath) + p.dbHandle.vfoldersPaths = []string{} + for mappedPath := range p.dbHandle.vfolders { + p.dbHandle.vfoldersPaths = append(p.dbHandle.vfoldersPaths, mappedPath) + } + sort.Strings(p.dbHandle.vfoldersPaths) + return nil } func (p MemoryProvider) getNextID() int64 { @@ -324,12 +575,24 @@ func (p MemoryProvider) getNextID() int64 { return nextID } -func (p MemoryProvider) clearUsers() { +func (p MemoryProvider) getNextFolderID() int64 { + nextID := int64(1) + for _, v := range p.dbHandle.vfolders { + if v.ID >= nextID { + nextID = v.ID + 1 + } + } + return nextID +} + +func (p MemoryProvider) clear() { p.dbHandle.lock.Lock() defer p.dbHandle.lock.Unlock() p.dbHandle.usernames = []string{} p.dbHandle.usersIdx = make(map[int64]string) p.dbHandle.users = make(map[string]User) + p.dbHandle.vfoldersPaths = []string{} + p.dbHandle.vfolders = make(map[string]vfs.BaseVirtualFolder) } func (p MemoryProvider) reloadConfig() error { @@ -364,23 +627,30 @@ func (p MemoryProvider) reloadConfig() error { providerLog(logger.LevelWarn, "error loading users: %v", err) return err } - p.clearUsers() + p.clear() + for _, folder := range dump.Folders { + _, err := p.getFolderByPath(folder.MappedPath) + if err == nil { + logger.Debug(logSender, "", "folder %#v already exists, restore not needed", folder.MappedPath) + continue + } + folder.Users = nil + err = p.addFolder(folder) + if err != nil { + providerLog(logger.LevelWarn, "error adding folder %#v: %v", folder.MappedPath, err) + return err + } + } for _, user := range dump.Users { u, err := p.userExists(user.Username) if err == nil { user.ID = u.ID - user.LastLogin = u.LastLogin - user.UsedQuotaSize = u.UsedQuotaSize - user.UsedQuotaFiles = u.UsedQuotaFiles err = p.updateUser(user) if err != nil { providerLog(logger.LevelWarn, "error updating user %#v: %v", user.Username, err) return err } } else { - user.LastLogin = 0 - user.UsedQuotaSize = 0 - user.UsedQuotaFiles = 0 err = p.addUser(user) if err != nil { providerLog(logger.LevelWarn, "error adding user %#v: %v", user.Username, err) @@ -388,7 +658,7 @@ func (p MemoryProvider) reloadConfig() error { } } } - providerLog(logger.LevelDebug, "users loaded from file: %#v", p.dbHandle.configFile) + providerLog(logger.LevelDebug, "user and folders loaded from file: %#v", p.dbHandle.configFile) return nil } diff --git a/dataprovider/mysql.go b/dataprovider/mysql.go index edfb2bf1..80f211ea 100644 --- a/dataprovider/mysql.go +++ b/dataprovider/mysql.go @@ -13,6 +13,7 @@ import ( "github.com/drakkan/sftpgo/logger" "github.com/drakkan/sftpgo/utils" + "github.com/drakkan/sftpgo/vfs" ) const ( @@ -24,9 +25,18 @@ const ( "`upload_bandwidth` integer NOT NULL, `download_bandwidth` integer NOT NULL, `expiration_date` bigint(20) NOT NULL, " + "`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;" - mysqlUsersV3SQL = "ALTER TABLE `{{users}}` MODIFY `password` longtext NULL;" + mysqlSchemaTableSQL = "CREATE TABLE `{{schema_version}}` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, `version` integer NOT NULL);" + mysqlV2SQL = "ALTER TABLE `{{users}}` ADD COLUMN `virtual_folders` longtext NULL;" + mysqlV3SQL = "ALTER TABLE `{{users}}` MODIFY `password` longtext NULL;" + mysqlV4SQL = "CREATE TABLE `{{folders}}` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, `path` varchar(512) NOT NULL UNIQUE," + + "`used_quota_size` bigint NOT NULL, `used_quota_files` integer NOT NULL, `last_quota_update` bigint NOT NULL);" + + "ALTER TABLE `{{users}}` MODIFY `home_dir` varchar(512) NOT NULL;" + + "ALTER TABLE `{{users}}` DROP COLUMN `virtual_folders`;" + + "CREATE TABLE `{{folders_mapping}}` (`id` integer AUTO_INCREMENT NOT NULL PRIMARY KEY, `virtual_path` varchar(512) NOT NULL, " + + "`quota_size` bigint NOT NULL, `quota_files` integer NOT NULL, `folder_id` integer NOT NULL, `user_id` integer NOT NULL);" + + "ALTER TABLE `{{folders_mapping}}` ADD CONSTRAINT `unique_mapping` UNIQUE (`user_id`, `folder_id`);" + + "ALTER TABLE `{{folders_mapping}}` ADD CONSTRAINT `folders_mapping_folder_id_fk_folders_id` FOREIGN KEY (`folder_id`) REFERENCES `{{folders}}` (`id`) ON DELETE CASCADE;" + + "ALTER TABLE `{{folders_mapping}}` ADD CONSTRAINT `folders_mapping_user_id_fk_users_id` FOREIGN KEY (`user_id`) REFERENCES `{{users}}` (`id`) ON DELETE CASCADE;" ) // MySQLProvider auth provider for MySQL/MariaDB database @@ -89,14 +99,14 @@ func (p MySQLProvider) updateQuota(username string, filesAdd int, sizeAdd int64, return sqlCommonUpdateQuota(username, filesAdd, sizeAdd, reset, p.dbHandle) } -func (p MySQLProvider) updateLastLogin(username string) error { - return sqlCommonUpdateLastLogin(username, p.dbHandle) -} - func (p MySQLProvider) getUsedQuota(username string) (int, int64, error) { return sqlCommonGetUsedQuota(username, p.dbHandle) } +func (p MySQLProvider) updateLastLogin(username string) error { + return sqlCommonUpdateLastLogin(username, p.dbHandle) +} + func (p MySQLProvider) userExists(username string) (User, error) { return sqlCommonCheckUserExists(username, p.dbHandle) } @@ -121,6 +131,34 @@ func (p MySQLProvider) getUsers(limit int, offset int, order string, username st return sqlCommonGetUsers(limit, offset, order, username, p.dbHandle) } +func (p MySQLProvider) dumpFolders() ([]vfs.BaseVirtualFolder, error) { + return sqlCommonDumpFolders(p.dbHandle) +} + +func (p MySQLProvider) getFolders(limit, offset int, order, folderPath string) ([]vfs.BaseVirtualFolder, error) { + return sqlCommonGetFolders(limit, offset, order, folderPath, p.dbHandle) +} + +func (p MySQLProvider) getFolderByPath(mappedPath string) (vfs.BaseVirtualFolder, error) { + return sqlCommonCheckFolderExists(mappedPath, p.dbHandle) +} + +func (p MySQLProvider) addFolder(folder vfs.BaseVirtualFolder) error { + return sqlCommonAddFolder(folder, p.dbHandle) +} + +func (p MySQLProvider) deleteFolder(folder vfs.BaseVirtualFolder) error { + return sqlCommonDeleteFolder(folder, p.dbHandle) +} + +func (p MySQLProvider) updateFolderQuota(mappedPath string, filesAdd int, sizeAdd int64, reset bool) error { + return sqlCommonUpdateFolderQuota(mappedPath, filesAdd, sizeAdd, reset, p.dbHandle) +} + +func (p MySQLProvider) getUsedFolderQuota(mappedPath string) (int, int64, error) { + return sqlCommonGetFolderUsedQuota(mappedPath, p.dbHandle) +} + func (p MySQLProvider) close() error { return p.dbHandle.Close() } @@ -131,7 +169,7 @@ func (p MySQLProvider) reloadConfig() error { // initializeDatabase creates the initial database structure func (p MySQLProvider) initializeDatabase() error { - sqlUsers := strings.Replace(mysqlUsersTableSQL, "{{users}}", config.UsersTable, 1) + sqlUsers := strings.Replace(mysqlUsersTableSQL, "{{users}}", sqlTableUsers, 1) tx, err := p.dbHandle.Begin() if err != nil { return err @@ -141,12 +179,12 @@ func (p MySQLProvider) initializeDatabase() error { sqlCommonRollbackTransaction(tx) return err } - _, err = tx.Exec(mysqlSchemaTableSQL) + _, err = tx.Exec(strings.Replace(mysqlSchemaTableSQL, "{{schema_version}}", sqlTableSchemaVersion, 1)) if err != nil { sqlCommonRollbackTransaction(tx) return err } - _, err = tx.Exec(initialDBVersionSQL) + _, err = tx.Exec(strings.Replace(initialDBVersionSQL, "{{schema_version}}", sqlTableSchemaVersion, 1)) if err != nil { sqlCommonRollbackTransaction(tx) return err @@ -169,9 +207,19 @@ func (p MySQLProvider) migrateDatabase() error { if err != nil { return err } - return updateMySQLDatabaseFrom2To3(p.dbHandle) + err = updateMySQLDatabaseFrom2To3(p.dbHandle) + if err != nil { + return err + } + return updateMySQLDatabaseFrom3To4(p.dbHandle) case 2: - return updateMySQLDatabaseFrom2To3(p.dbHandle) + err = updateMySQLDatabaseFrom2To3(p.dbHandle) + if err != nil { + return err + } + return updateMySQLDatabaseFrom3To4(p.dbHandle) + case 3: + return updateMySQLDatabaseFrom3To4(p.dbHandle) default: return fmt.Errorf("Database version not handled: %v", dbVersion.Version) } @@ -179,30 +227,16 @@ func (p MySQLProvider) migrateDatabase() error { func updateMySQLDatabaseFrom1To2(dbHandle *sql.DB) error { providerLog(logger.LevelInfo, "updating database version: 1 -> 2") - sql := strings.Replace(mysqlUsersV2SQL, "{{users}}", config.UsersTable, 1) - return updateMySQLDatabase(dbHandle, sql, 2) + sql := strings.Replace(mysqlV2SQL, "{{users}}", sqlTableUsers, 1) + return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 2) } func updateMySQLDatabaseFrom2To3(dbHandle *sql.DB) error { providerLog(logger.LevelInfo, "updating database version: 2 -> 3") - sql := strings.Replace(mysqlUsersV3SQL, "{{users}}", config.UsersTable, 1) - return updateMySQLDatabase(dbHandle, sql, 3) + sql := strings.Replace(mysqlV3SQL, "{{users}}", sqlTableUsers, 1) + return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 3) } -func updateMySQLDatabase(dbHandle *sql.DB, sql string, newVersion int) error { - tx, err := dbHandle.Begin() - if err != nil { - return err - } - _, err = tx.Exec(sql) - if err != nil { - sqlCommonRollbackTransaction(tx) - return err - } - err = sqlCommonUpdateDatabaseVersionWithTX(tx, newVersion) - if err != nil { - sqlCommonRollbackTransaction(tx) - return err - } - return tx.Commit() +func updateMySQLDatabaseFrom3To4(dbHandle *sql.DB) error { + return sqlCommonUpdateDatabaseFrom3To4(mysqlV4SQL, dbHandle) } diff --git a/dataprovider/pgsql.go b/dataprovider/pgsql.go index b5ef445e..d8c2ba29 100644 --- a/dataprovider/pgsql.go +++ b/dataprovider/pgsql.go @@ -12,6 +12,7 @@ import ( "github.com/drakkan/sftpgo/logger" "github.com/drakkan/sftpgo/utils" + "github.com/drakkan/sftpgo/vfs" ) const ( @@ -22,9 +23,19 @@ const ( "last_quota_update" bigint NOT NULL, "upload_bandwidth" integer NOT NULL, "download_bandwidth" integer NOT NULL, "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;` - pgsqlUsersV3SQL = `ALTER TABLE "{{users}}" ALTER COLUMN "password" TYPE text USING "password"::text;` + pgsqlSchemaTableSQL = `CREATE TABLE "{{schema_version}}" ("id" serial NOT NULL PRIMARY KEY, "version" integer NOT NULL);` + pgsqlV2SQL = `ALTER TABLE "{{users}}" ADD COLUMN "virtual_folders" text NULL;` + pgsqlV3SQL = `ALTER TABLE "{{users}}" ALTER COLUMN "password" TYPE text USING "password"::text;` + pgsqlV4SQL = `CREATE TABLE "{{folders}}" ("id" serial NOT NULL PRIMARY KEY, "path" varchar(512) NOT NULL UNIQUE, "used_quota_size" bigint NOT NULL, "used_quota_files" integer NOT NULL, "last_quota_update" bigint NOT NULL); +ALTER TABLE "{{users}}" ALTER COLUMN "home_dir" TYPE varchar(512) USING "home_dir"::varchar(512); +ALTER TABLE "{{users}}" DROP COLUMN "virtual_folders" CASCADE; +CREATE TABLE "{{folders_mapping}}" ("id" serial NOT NULL PRIMARY KEY, "virtual_path" varchar(512) NOT NULL, "quota_size" bigint NOT NULL, "quota_files" integer NOT NULL, "folder_id" integer NOT NULL, "user_id" integer NOT NULL); +ALTER TABLE "{{folders_mapping}}" ADD CONSTRAINT "unique_mapping" UNIQUE ("user_id", "folder_id"); +ALTER TABLE "{{folders_mapping}}" ADD CONSTRAINT "folders_mapping_folder_id_fk_folders_id" FOREIGN KEY ("folder_id") REFERENCES "{{folders}}" ("id") MATCH SIMPLE ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED; +ALTER TABLE "{{folders_mapping}}" ADD CONSTRAINT "folders_mapping_user_id_fk_users_id" FOREIGN KEY ("user_id") REFERENCES "{{users}}" ("id") MATCH SIMPLE ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED; +CREATE INDEX "folders_mapping_folder_id_idx" ON "{{folders_mapping}}" ("folder_id"); +CREATE INDEX "folders_mapping_user_id_idx" ON "{{folders_mapping}}" ("user_id"); +` ) // PGSQLProvider auth provider for PostgreSQL database @@ -87,14 +98,14 @@ func (p PGSQLProvider) updateQuota(username string, filesAdd int, sizeAdd int64, return sqlCommonUpdateQuota(username, filesAdd, sizeAdd, reset, p.dbHandle) } -func (p PGSQLProvider) updateLastLogin(username string) error { - return sqlCommonUpdateLastLogin(username, p.dbHandle) -} - func (p PGSQLProvider) getUsedQuota(username string) (int, int64, error) { return sqlCommonGetUsedQuota(username, p.dbHandle) } +func (p PGSQLProvider) updateLastLogin(username string) error { + return sqlCommonUpdateLastLogin(username, p.dbHandle) +} + func (p PGSQLProvider) userExists(username string) (User, error) { return sqlCommonCheckUserExists(username, p.dbHandle) } @@ -119,6 +130,34 @@ func (p PGSQLProvider) getUsers(limit int, offset int, order string, username st return sqlCommonGetUsers(limit, offset, order, username, p.dbHandle) } +func (p PGSQLProvider) dumpFolders() ([]vfs.BaseVirtualFolder, error) { + return sqlCommonDumpFolders(p.dbHandle) +} + +func (p PGSQLProvider) getFolders(limit, offset int, order, folderPath string) ([]vfs.BaseVirtualFolder, error) { + return sqlCommonGetFolders(limit, offset, order, folderPath, p.dbHandle) +} + +func (p PGSQLProvider) getFolderByPath(mappedPath string) (vfs.BaseVirtualFolder, error) { + return sqlCommonCheckFolderExists(mappedPath, p.dbHandle) +} + +func (p PGSQLProvider) addFolder(folder vfs.BaseVirtualFolder) error { + return sqlCommonAddFolder(folder, p.dbHandle) +} + +func (p PGSQLProvider) deleteFolder(folder vfs.BaseVirtualFolder) error { + return sqlCommonDeleteFolder(folder, p.dbHandle) +} + +func (p PGSQLProvider) updateFolderQuota(mappedPath string, filesAdd int, sizeAdd int64, reset bool) error { + return sqlCommonUpdateFolderQuota(mappedPath, filesAdd, sizeAdd, reset, p.dbHandle) +} + +func (p PGSQLProvider) getUsedFolderQuota(mappedPath string) (int, int64, error) { + return sqlCommonGetFolderUsedQuota(mappedPath, p.dbHandle) +} + func (p PGSQLProvider) close() error { return p.dbHandle.Close() } @@ -129,7 +168,7 @@ func (p PGSQLProvider) reloadConfig() error { // initializeDatabase creates the initial database structure func (p PGSQLProvider) initializeDatabase() error { - sqlUsers := strings.Replace(pgsqlUsersTableSQL, "{{users}}", config.UsersTable, 1) + sqlUsers := strings.Replace(pgsqlUsersTableSQL, "{{users}}", sqlTableUsers, 1) tx, err := p.dbHandle.Begin() if err != nil { return err @@ -139,12 +178,12 @@ func (p PGSQLProvider) initializeDatabase() error { sqlCommonRollbackTransaction(tx) return err } - _, err = tx.Exec(pgsqlSchemaTableSQL) + _, err = tx.Exec(strings.Replace(pgsqlSchemaTableSQL, "{{schema_version}}", sqlTableSchemaVersion, 1)) if err != nil { sqlCommonRollbackTransaction(tx) return err } - _, err = tx.Exec(initialDBVersionSQL) + _, err = tx.Exec(strings.Replace(initialDBVersionSQL, "{{schema_version}}", sqlTableSchemaVersion, 1)) if err != nil { sqlCommonRollbackTransaction(tx) return err @@ -167,9 +206,19 @@ func (p PGSQLProvider) migrateDatabase() error { if err != nil { return err } - return updatePGSQLDatabaseFrom2To3(p.dbHandle) + err = updatePGSQLDatabaseFrom2To3(p.dbHandle) + if err != nil { + return err + } + return updatePGSQLDatabaseFrom3To4(p.dbHandle) case 2: - return updatePGSQLDatabaseFrom2To3(p.dbHandle) + err = updatePGSQLDatabaseFrom2To3(p.dbHandle) + if err != nil { + return err + } + return updatePGSQLDatabaseFrom3To4(p.dbHandle) + case 3: + return updatePGSQLDatabaseFrom3To4(p.dbHandle) default: return fmt.Errorf("Database version not handled: %v", dbVersion.Version) } @@ -177,30 +226,16 @@ func (p PGSQLProvider) migrateDatabase() error { func updatePGSQLDatabaseFrom1To2(dbHandle *sql.DB) error { providerLog(logger.LevelInfo, "updating database version: 1 -> 2") - sql := strings.Replace(pgsqlUsersV2SQL, "{{users}}", config.UsersTable, 1) - return updatePGSQLDatabase(dbHandle, sql, 2) + sql := strings.Replace(pgsqlV2SQL, "{{users}}", sqlTableUsers, 1) + return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 2) } func updatePGSQLDatabaseFrom2To3(dbHandle *sql.DB) error { providerLog(logger.LevelInfo, "updating database version: 2 -> 3") - sql := strings.Replace(pgsqlUsersV3SQL, "{{users}}", config.UsersTable, 1) - return updatePGSQLDatabase(dbHandle, sql, 3) + sql := strings.Replace(pgsqlV3SQL, "{{users}}", sqlTableUsers, 1) + return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 3) } -func updatePGSQLDatabase(dbHandle *sql.DB, sql string, newVersion int) error { - tx, err := dbHandle.Begin() - if err != nil { - return err - } - _, err = tx.Exec(sql) - if err != nil { - sqlCommonRollbackTransaction(tx) - return err - } - err = sqlCommonUpdateDatabaseVersionWithTX(tx, newVersion) - if err != nil { - sqlCommonRollbackTransaction(tx) - return err - } - return tx.Commit() +func updatePGSQLDatabaseFrom3To4(dbHandle *sql.DB) error { + return sqlCommonUpdateDatabaseFrom3To4(pgsqlV4SQL, dbHandle) } diff --git a/dataprovider/sqlcommon.go b/dataprovider/sqlcommon.go index dff934a1..7edc88d4 100644 --- a/dataprovider/sqlcommon.go +++ b/dataprovider/sqlcommon.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "errors" + "strings" "time" "github.com/drakkan/sftpgo/logger" @@ -13,11 +14,17 @@ import ( ) const ( - sqlDatabaseVersion = 3 - initialDBVersionSQL = "INSERT INTO schema_version (version) VALUES (1);" + sqlDatabaseVersion = 4 + initialDBVersionSQL = "INSERT INTO {{schema_version}} (version) VALUES (1);" ) -func getUserByUsername(username string, dbHandle *sql.DB) (User, error) { +var errSQLFoldersAssosaction = errors.New("unable to associate virtual folders to user") + +type sqlQuerier interface { + Prepare(query string) (*sql.Stmt, error) +} + +func getUserByUsername(username string, dbHandle sqlQuerier) (User, error) { var user User q := getUserByUsernameQuery() stmt, err := dbHandle.Prepare(q) @@ -28,7 +35,11 @@ func getUserByUsername(username string, dbHandle *sql.DB) (User, error) { defer stmt.Close() row := stmt.QueryRow(username) - return getUserFromDbRow(row, nil) + user, err = getUserFromDbRow(row, nil) + if err != nil { + return user, err + } + return getUserWithVirtualFolders(user, dbHandle) } func sqlCommonValidateUserAndPass(username string, password string, dbHandle *sql.DB) (User, error) { @@ -74,7 +85,11 @@ func sqlCommonGetUserByID(ID int64, dbHandle *sql.DB) (User, error) { defer stmt.Close() row := stmt.QueryRow(ID) - return getUserFromDbRow(row, nil) + user, err = getUserFromDbRow(row, nil) + if err != nil { + return user, err + } + return getUserWithVirtualFolders(user, dbHandle) } func sqlCommonUpdateQuota(username string, filesAdd int, sizeAdd int64, reset bool, dbHandle *sql.DB) error { @@ -95,23 +110,6 @@ func sqlCommonUpdateQuota(username string, filesAdd int, sizeAdd int64, reset bo return err } -func sqlCommonUpdateLastLogin(username string, dbHandle *sql.DB) error { - q := getUpdateLastLoginQuery() - stmt, err := dbHandle.Prepare(q) - if err != nil { - providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err) - return err - } - defer stmt.Close() - _, err = stmt.Exec(utils.GetTimeAsMsSinceEpoch(time.Now()), username) - if err == nil { - providerLog(logger.LevelDebug, "last login updated for user %#v", username) - } else { - providerLog(logger.LevelWarn, "error updating last login for user %#v: %v", username, err) - } - return err -} - func sqlCommonGetUsedQuota(username string, dbHandle *sql.DB) (int, int64, error) { q := getQuotaQuery() stmt, err := dbHandle.Prepare(q) @@ -131,6 +129,23 @@ func sqlCommonGetUsedQuota(username string, dbHandle *sql.DB) (int, int64, error return usedFiles, usedSize, err } +func sqlCommonUpdateLastLogin(username string, dbHandle *sql.DB) error { + q := getUpdateLastLoginQuery() + stmt, err := dbHandle.Prepare(q) + if err != nil { + providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err) + return err + } + defer stmt.Close() + _, err = stmt.Exec(utils.GetTimeAsMsSinceEpoch(time.Now()), username) + if err == nil { + providerLog(logger.LevelDebug, "last login updated for user %#v", username) + } else { + providerLog(logger.LevelWarn, "error updating last login for user %#v: %v", username, err) + } + return err +} + func sqlCommonCheckUserExists(username string, dbHandle *sql.DB) (User, error) { var user User q := getUserByUsernameQuery() @@ -141,7 +156,11 @@ func sqlCommonCheckUserExists(username string, dbHandle *sql.DB) (User, error) { } defer stmt.Close() row := stmt.QueryRow(username) - return getUserFromDbRow(row, nil) + user, err = getUserFromDbRow(row, nil) + if err != nil { + return user, err + } + return getUserWithVirtualFolders(user, dbHandle) } func sqlCommonAddUser(user User, dbHandle *sql.DB) error { @@ -149,37 +168,51 @@ func sqlCommonAddUser(user User, dbHandle *sql.DB) error { if err != nil { return err } + tx, err := dbHandle.Begin() + if err != nil { + return err + } q := getAddUserQuery() - stmt, err := dbHandle.Prepare(q) + stmt, err := tx.Prepare(q) if err != nil { providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err) + sqlCommonRollbackTransaction(tx) return err } defer stmt.Close() permissions, err := user.GetPermissionsAsJSON() if err != nil { + sqlCommonRollbackTransaction(tx) return err } publicKeys, err := user.GetPublicKeysAsJSON() if err != nil { + sqlCommonRollbackTransaction(tx) return err } filters, err := user.GetFiltersAsJSON() if err != nil { + sqlCommonRollbackTransaction(tx) return err } fsConfig, err := user.GetFsConfigAsJSON() if err != nil { - return err - } - virtualFolders, err := user.GetVirtualFoldersAsJSON() - if err != nil { + sqlCommonRollbackTransaction(tx) 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(virtualFolders)) - return err + string(fsConfig)) + if err != nil { + sqlCommonRollbackTransaction(tx) + return err + } + err = generateVirtualFoldersMapping(user, tx) + if err != nil { + sqlCommonRollbackTransaction(tx) + return err + } + return tx.Commit() } func sqlCommonUpdateUser(user User, dbHandle *sql.DB) error { @@ -187,37 +220,51 @@ func sqlCommonUpdateUser(user User, dbHandle *sql.DB) error { if err != nil { return err } + tx, err := dbHandle.Begin() + if err != nil { + return err + } q := getUpdateUserQuery() - stmt, err := dbHandle.Prepare(q) + stmt, err := tx.Prepare(q) if err != nil { providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err) + sqlCommonRollbackTransaction(tx) return err } defer stmt.Close() permissions, err := user.GetPermissionsAsJSON() if err != nil { + sqlCommonRollbackTransaction(tx) return err } publicKeys, err := user.GetPublicKeysAsJSON() if err != nil { + sqlCommonRollbackTransaction(tx) return err } filters, err := user.GetFiltersAsJSON() if err != nil { + sqlCommonRollbackTransaction(tx) return err } fsConfig, err := user.GetFsConfigAsJSON() if err != nil { - return err - } - virtualFolders, err := user.GetVirtualFoldersAsJSON() - if err != nil { + sqlCommonRollbackTransaction(tx) 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), string(virtualFolders), user.ID) - return err + string(filters), string(fsConfig), user.ID) + if err != nil { + sqlCommonRollbackTransaction(tx) + return err + } + err = generateVirtualFoldersMapping(user, tx) + if err != nil { + sqlCommonRollbackTransaction(tx) + return err + } + return tx.Commit() } func sqlCommonDeleteUser(user User, dbHandle *sql.DB) error { @@ -232,8 +279,8 @@ func sqlCommonDeleteUser(user User, dbHandle *sql.DB) error { return err } -func sqlCommonDumpUsers(dbHandle *sql.DB) ([]User, error) { - users := []User{} +func sqlCommonDumpUsers(dbHandle sqlQuerier) ([]User, error) { + users := make([]User, 0, 100) q := getDumpUsersQuery() stmt, err := dbHandle.Prepare(q) if err != nil { @@ -242,26 +289,27 @@ func sqlCommonDumpUsers(dbHandle *sql.DB) ([]User, error) { } defer stmt.Close() rows, err := stmt.Query() - if err == nil { - defer rows.Close() - for rows.Next() { - u, err := getUserFromDbRow(nil, rows) - if err != nil { - return users, err - } - err = addCredentialsToUser(&u) - if err != nil { - return users, err - } - users = append(users, u) - } + if err != nil { + return users, err } - return users, err + defer rows.Close() + for rows.Next() { + u, err := getUserFromDbRow(nil, rows) + if err != nil { + return users, err + } + err = addCredentialsToUser(&u) + if err != nil { + return users, err + } + users = append(users, u) + } + return getUsersWithVirtualFolders(users, dbHandle) } -func sqlCommonGetUsers(limit int, offset int, order string, username string, dbHandle *sql.DB) ([]User, error) { - users := []User{} +func sqlCommonGetUsers(limit int, offset int, order string, username string, dbHandle sqlQuerier) ([]User, error) { + users := make([]User, 0, limit) q := getUsersQuery(order, username) stmt, err := dbHandle.Prepare(q) if err != nil { @@ -271,23 +319,25 @@ func sqlCommonGetUsers(limit int, offset int, order string, username string, dbH defer stmt.Close() var rows *sql.Rows if len(username) > 0 { - rows, err = stmt.Query(username, limit, offset) //nolint:rowserrcheck // err is checked + rows, err = stmt.Query(username, limit, offset) //nolint:rowserrcheck // rows.Err() is checked } else { - rows, err = stmt.Query(limit, offset) //nolint:rowserrcheck // err is checked + rows, err = stmt.Query(limit, offset) //nolint:rowserrcheck // rows.Err() is checked } if err == nil { defer rows.Close() for rows.Next() { u, err := getUserFromDbRow(nil, rows) - if err == nil { - users = append(users, HideUserSensitiveData(&u)) - } else { - break + if err != nil { + return users, err } + users = append(users, HideUserSensitiveData(&u)) } } - - return users, err + err = rows.Err() + if err != nil { + return users, err + } + return getUsersWithVirtualFolders(users, dbHandle) } func updateUserPermissionsFromDb(user *User, permissions string) error { @@ -316,18 +366,15 @@ 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, - &virtualFolders) + &user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters, &fsConfig) } 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, - &virtualFolders) + &user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status, &filters, &fsConfig) } if err != nil { if err == sql.ErrNoRows { @@ -368,14 +415,308 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) { user.FsConfig = fs } } - if virtualFolders.Valid { - var list []vfs.VirtualFolder - err = json.Unmarshal([]byte(virtualFolders.String), &list) - if err == nil { - user.VirtualFolders = list + return user, err +} + +func sqlCommonCheckFolderExists(name string, dbHandle sqlQuerier) (vfs.BaseVirtualFolder, error) { + var folder vfs.BaseVirtualFolder + q := getFolderByPathQuery() + stmt, err := dbHandle.Prepare(q) + if err != nil { + providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err) + return folder, err + } + defer stmt.Close() + row := stmt.QueryRow(name) + err = row.Scan(&folder.ID, &folder.MappedPath, &folder.UsedQuotaSize, &folder.UsedQuotaFiles, &folder.LastQuotaUpdate) + if err == sql.ErrNoRows { + return folder, &RecordNotFoundError{err: err.Error()} + } + return folder, err +} + +func sqlCommonAddOrGetFolder(name string, usedQuotaSize int64, usedQuotaFiles int, lastQuotaUpdate int64, dbHandle sqlQuerier) (vfs.BaseVirtualFolder, error) { + folder, err := sqlCommonCheckFolderExists(name, dbHandle) + if _, ok := err.(*RecordNotFoundError); ok { + f := vfs.BaseVirtualFolder{ + MappedPath: name, + UsedQuotaSize: usedQuotaSize, + UsedQuotaFiles: usedQuotaFiles, + LastQuotaUpdate: lastQuotaUpdate, + } + err = sqlCommonAddFolder(f, dbHandle) + if err != nil { + return folder, err + } + return sqlCommonCheckFolderExists(name, dbHandle) + } + return folder, err +} + +func sqlCommonAddFolder(folder vfs.BaseVirtualFolder, dbHandle sqlQuerier) error { + err := validateFolder(&folder) + if err != nil { + return err + } + q := getAddFolderQuery() + stmt, err := dbHandle.Prepare(q) + if err != nil { + providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err) + return err + } + defer stmt.Close() + _, err = stmt.Exec(folder.MappedPath, folder.UsedQuotaSize, folder.UsedQuotaFiles, folder.LastQuotaUpdate) + return err +} + +func sqlCommonDeleteFolder(folder vfs.BaseVirtualFolder, dbHandle sqlQuerier) error { + q := getDeleteFolderQuery() + stmt, err := dbHandle.Prepare(q) + if err != nil { + providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err) + return err + } + defer stmt.Close() + _, err = stmt.Exec(folder.ID) + return err +} + +func sqlCommonDumpFolders(dbHandle sqlQuerier) ([]vfs.BaseVirtualFolder, error) { + folders := make([]vfs.BaseVirtualFolder, 0, 50) + q := getDumpFoldersQuery() + stmt, err := dbHandle.Prepare(q) + if err != nil { + providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err) + return nil, err + } + defer stmt.Close() + rows, err := stmt.Query() + if err != nil { + return folders, err + } + defer rows.Close() + for rows.Next() { + var folder vfs.BaseVirtualFolder + err = rows.Scan(&folder.ID, &folder.MappedPath, &folder.UsedQuotaSize, &folder.UsedQuotaFiles, &folder.LastQuotaUpdate) + if err != nil { + return folders, err + } + folders = append(folders, folder) + } + err = rows.Err() + if err != nil { + return folders, err + } + return getVirtualFoldersWithUsers(folders, dbHandle) +} + +func sqlCommonGetFolders(limit, offset int, order, folderPath string, dbHandle sqlQuerier) ([]vfs.BaseVirtualFolder, error) { + folders := make([]vfs.BaseVirtualFolder, 0, limit) + q := getFoldersQuery(order, folderPath) + stmt, err := dbHandle.Prepare(q) + if err != nil { + providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err) + return nil, err + } + defer stmt.Close() + var rows *sql.Rows + if len(folderPath) > 0 { + rows, err = stmt.Query(folderPath, limit, offset) //nolint:rowserrcheck // rows.Err() is checked + } else { + rows, err = stmt.Query(limit, offset) //nolint:rowserrcheck // rows.Err() is checked + } + if err != nil { + return folders, err + } + defer rows.Close() + for rows.Next() { + var folder vfs.BaseVirtualFolder + err = rows.Scan(&folder.ID, &folder.MappedPath, &folder.UsedQuotaSize, &folder.UsedQuotaFiles, &folder.LastQuotaUpdate) + if err != nil { + return folders, err + } + folders = append(folders, folder) + } + + err = rows.Err() + if err != nil { + return folders, err + } + return getVirtualFoldersWithUsers(folders, dbHandle) +} + +func sqlCommonClearFolderMapping(user User, dbHandle sqlQuerier) error { + q := getClearFolderMappingQuery() + stmt, err := dbHandle.Prepare(q) + if err != nil { + providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err) + return err + } + defer stmt.Close() + _, err = stmt.Exec(user.Username) + return err +} + +func sqlCommonAddFolderMapping(user User, folder vfs.VirtualFolder, dbHandle sqlQuerier) error { + q := getAddFolderMappingQuery() + stmt, err := dbHandle.Prepare(q) + if err != nil { + providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err) + return err + } + defer stmt.Close() + _, err = stmt.Exec(folder.VirtualPath, folder.QuotaSize, folder.QuotaFiles, folder.ID, user.Username) + return err +} + +func generateVirtualFoldersMapping(user User, dbHandle sqlQuerier) error { + err := sqlCommonClearFolderMapping(user, dbHandle) + if err != nil { + return err + } + for _, vfolder := range user.VirtualFolders { + f, err := sqlCommonAddOrGetFolder(vfolder.MappedPath, 0, 0, 0, dbHandle) + if err != nil { + return err + } + vfolder.BaseVirtualFolder = f + err = sqlCommonAddFolderMapping(user, vfolder, dbHandle) + if err != nil { + return err } } - return user, err + return err +} + +func getUserWithVirtualFolders(user User, dbHandle sqlQuerier) (User, error) { + users, err := getUsersWithVirtualFolders([]User{user}, dbHandle) + if err != nil { + return user, err + } + if len(users) == 0 { + return user, errSQLFoldersAssosaction + } + return users[0], err +} + +func getUsersWithVirtualFolders(users []User, dbHandle sqlQuerier) ([]User, error) { + var err error + usersVirtualFolders := make(map[int64][]vfs.VirtualFolder) + if len(users) == 0 { + return users, err + } + q := getRelatedFoldersForUsersQuery(users) + stmt, err := dbHandle.Prepare(q) + if err != nil { + providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err) + return nil, err + } + defer stmt.Close() + rows, err := stmt.Query() + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var folder vfs.VirtualFolder + var userID int64 + err = rows.Scan(&folder.ID, &folder.MappedPath, &folder.UsedQuotaSize, &folder.UsedQuotaFiles, + &folder.LastQuotaUpdate, &folder.VirtualPath, &folder.QuotaSize, &folder.QuotaFiles, &userID) + if err != nil { + return users, err + } + usersVirtualFolders[userID] = append(usersVirtualFolders[userID], folder) + } + err = rows.Err() + if err != nil { + return users, err + } + if len(usersVirtualFolders) == 0 { + return users, err + } + for idx := range users { + ref := &users[idx] + ref.VirtualFolders = usersVirtualFolders[ref.ID] + } + return users, err +} + +func getVirtualFoldersWithUsers(folders []vfs.BaseVirtualFolder, dbHandle sqlQuerier) ([]vfs.BaseVirtualFolder, error) { + var err error + vFoldersUsers := make(map[int64][]string) + if len(folders) == 0 { + return folders, err + } + q := getRelatedUsersForFoldersQuery(folders) + stmt, err := dbHandle.Prepare(q) + if err != nil { + providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err) + return nil, err + } + defer stmt.Close() + rows, err := stmt.Query() + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var username string + var folderID int64 + err = rows.Scan(&folderID, &username) + if err != nil { + return folders, err + } + vFoldersUsers[folderID] = append(vFoldersUsers[folderID], username) + } + err = rows.Err() + if err != nil { + return folders, err + } + if len(vFoldersUsers) == 0 { + return folders, err + } + for idx := range folders { + ref := &folders[idx] + ref.Users = vFoldersUsers[ref.ID] + } + return folders, err +} + +func sqlCommonUpdateFolderQuota(mappedPath string, filesAdd int, sizeAdd int64, reset bool, dbHandle *sql.DB) error { + q := getUpdateFolderQuotaQuery(reset) + stmt, err := dbHandle.Prepare(q) + if err != nil { + providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err) + return err + } + defer stmt.Close() + _, err = stmt.Exec(sizeAdd, filesAdd, utils.GetTimeAsMsSinceEpoch(time.Now()), mappedPath) + if err == nil { + providerLog(logger.LevelDebug, "quota updated for folder %#v, files increment: %v size increment: %v is reset? %v", + mappedPath, filesAdd, sizeAdd, reset) + } else { + providerLog(logger.LevelWarn, "error updating quota for folder %#v: %v", mappedPath, err) + } + return err +} + +func sqlCommonGetFolderUsedQuota(mappedPath string, dbHandle *sql.DB) (int, int64, error) { + q := getQuotaFolderQuery() + stmt, err := dbHandle.Prepare(q) + if err != nil { + providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err) + return 0, 0, err + } + defer stmt.Close() + + var usedFiles int + var usedSize int64 + err = stmt.QueryRow(mappedPath).Scan(&usedSize, &usedFiles) + if err != nil { + providerLog(logger.LevelWarn, "error getting quota for folder: %v, error: %v", mappedPath, err) + return 0, 0, err + } + return usedFiles, usedSize, err } func sqlCommonRollbackTransaction(tx *sql.Tx) { @@ -399,7 +740,7 @@ func sqlCommonGetDatabaseVersion(dbHandle *sql.DB) (schemaVersion, error) { return result, err } -func sqlCommonUpdateDatabaseVersion(dbHandle *sql.DB, version int) error { +func sqlCommonUpdateDatabaseVersion(dbHandle sqlQuerier, version int) error { q := getUpdateDBVersionQuery() stmt, err := dbHandle.Prepare(q) if err != nil { @@ -411,14 +752,139 @@ func sqlCommonUpdateDatabaseVersion(dbHandle *sql.DB, version int) error { return err } -func sqlCommonUpdateDatabaseVersionWithTX(tx *sql.Tx, version int) error { - q := getUpdateDBVersionQuery() - stmt, err := tx.Prepare(q) +func sqlCommonExecSQLAndUpdateDBVersion(dbHandle *sql.DB, sql []string, newVersion int) error { + tx, err := dbHandle.Begin() if err != nil { - providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err) return err } + for _, q := range sql { + if len(strings.TrimSpace(q)) == 0 { + continue + } + _, err = tx.Exec(q) + if err != nil { + sqlCommonRollbackTransaction(tx) + return err + } + } + err = sqlCommonUpdateDatabaseVersion(tx, newVersion) + if err != nil { + sqlCommonRollbackTransaction(tx) + return err + } + return tx.Commit() +} + +func sqlCommonGetCompatVirtualFolders(dbHandle *sql.DB) ([]userCompactVFolders, error) { + users := []userCompactVFolders{} + q := getCompatVirtualFoldersQuery() + stmt, err := dbHandle.Prepare(q) + if err != nil { + providerLog(logger.LevelWarn, "error preparing database query %#v: %v", q, err) + return nil, err + } defer stmt.Close() - _, err = stmt.Exec(version) + rows, err := stmt.Query() + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var user userCompactVFolders + var virtualFolders sql.NullString + err = rows.Scan(&user.ID, &user.Username, &virtualFolders) + if err != nil { + return nil, err + } + if virtualFolders.Valid { + var list []virtualFoldersCompact + err = json.Unmarshal([]byte(virtualFolders.String), &list) + if err == nil && len(list) > 0 { + user.VirtualFolders = list + users = append(users, user) + } + } + } + return users, rows.Err() +} + +func sqlCommonRestoreCompatVirtualFolders(users []userCompactVFolders, dbHandle sqlQuerier) ([]string, error) { + foldersToScan := []string{} + for _, user := range users { + for _, vfolder := range user.VirtualFolders { + providerLog(logger.LevelInfo, "restoring virtual folder: %+v for user %#v", vfolder, user.Username) + // -1 means included in user quota, 0 means unlimited + quotaSize := int64(-1) + quotaFiles := -1 + if vfolder.ExcludeFromQuota { + quotaFiles = 0 + quotaSize = 0 + } + b, err := sqlCommonAddOrGetFolder(vfolder.MappedPath, 0, 0, 0, dbHandle) + if err != nil { + providerLog(logger.LevelWarn, "error restoring virtual folder for user %#v: %v", user.Username, err) + return foldersToScan, err + } + u := User{ + ID: user.ID, + Username: user.Username, + } + f := vfs.VirtualFolder{ + BaseVirtualFolder: b, + VirtualPath: vfolder.VirtualPath, + QuotaSize: quotaSize, + QuotaFiles: quotaFiles, + } + err = sqlCommonAddFolderMapping(u, f, dbHandle) + if err != nil { + providerLog(logger.LevelWarn, "error adding virtual folder mapping for user %#v: %v", user.Username, err) + return foldersToScan, err + } + if !utils.IsStringInSlice(vfolder.MappedPath, foldersToScan) { + foldersToScan = append(foldersToScan, vfolder.MappedPath) + } + providerLog(logger.LevelInfo, "virtual folder: %+v for user %#v successfully restored", vfolder, user.Username) + } + } + return foldersToScan, nil +} + +func sqlCommonUpdateDatabaseFrom3To4(sqlV4 string, dbHandle *sql.DB) error { + providerLog(logger.LevelInfo, "updating database version: 3 -> 4") + users, err := sqlCommonGetCompatVirtualFolders(dbHandle) + if err != nil { + return err + } + sql := strings.ReplaceAll(sqlV4, "{{users}}", sqlTableUsers) + sql = strings.ReplaceAll(sql, "{{folders}}", sqlTableFolders) + sql = strings.ReplaceAll(sql, "{{folders_mapping}}", sqlTableFoldersMapping) + tx, err := dbHandle.Begin() + if err != nil { + return err + } + for _, q := range strings.Split(sql, ";") { + if len(strings.TrimSpace(q)) == 0 { + continue + } + _, err = tx.Exec(q) + if err != nil { + sqlCommonRollbackTransaction(tx) + return err + } + } + foldersToScan, err := sqlCommonRestoreCompatVirtualFolders(users, tx) + if err != nil { + sqlCommonRollbackTransaction(tx) + return err + } + err = sqlCommonUpdateDatabaseVersion(tx, 4) + if err != nil { + sqlCommonRollbackTransaction(tx) + return err + } + err = tx.Commit() + if err == nil { + go updateVFoldersQuotaAfterRestore(foldersToScan) + } return err } diff --git a/dataprovider/sqlite.go b/dataprovider/sqlite.go index 2556e08d..a8d721a0 100644 --- a/dataprovider/sqlite.go +++ b/dataprovider/sqlite.go @@ -13,6 +13,7 @@ import ( "github.com/drakkan/sftpgo/logger" "github.com/drakkan/sftpgo/utils" + "github.com/drakkan/sftpgo/vfs" ) const ( @@ -23,9 +24,9 @@ NOT NULL UNIQUE, "password" varchar(255) NULL, "public_keys" text NULL, "home_di "last_quota_update" bigint NOT NULL, "upload_bandwidth" integer NOT NULL, "download_bandwidth" integer NOT NULL, "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;` - sqliteUsersV3SQL = `CREATE TABLE "new__users" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "username" varchar(255) NOT NULL UNIQUE, + sqliteSchemaTableSQL = `CREATE TABLE "{{schema_version}}" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "version" integer NOT NULL);` + sqliteV2SQL = `ALTER TABLE "{{users}}" ADD COLUMN "virtual_folders" text NULL;` + sqliteV3SQL = `CREATE TABLE "new__users" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "username" varchar(255) NOT NULL UNIQUE, "password" text NULL, "public_keys" text NULL, "home_dir" varchar(255) NOT NULL, "uid" integer NOT NULL, "gid" integer NOT NULL, "max_sessions" integer NOT NULL, "quota_size" bigint NOT NULL, "quota_files" integer NOT NULL, "permissions" text NOT NULL, "used_quota_size" bigint NOT NULL, "used_quota_files" integer NOT NULL, "last_quota_update" bigint NOT NULL, @@ -39,6 +40,27 @@ INSERT INTO "new__users" ("id", "username", "public_keys", "home_dir", "uid", "g "password" FROM "{{users}}"; DROP TABLE "{{users}}"; ALTER TABLE "new__users" RENAME TO "{{users}}";` + sqliteV4SQL = `CREATE TABLE "{{folders}}" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "path" varchar(512) NOT NULL UNIQUE, +"used_quota_size" bigint NOT NULL, "used_quota_files" integer NOT NULL, "last_quota_update" bigint NOT NULL); +CREATE TABLE "{{folders_mapping}}" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "virtual_path" varchar(512) NOT NULL, +"quota_size" bigint NOT NULL, "quota_files" integer NOT NULL, "folder_id" integer NOT NULL REFERENCES "{{folders}}" ("id") +ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, "user_id" integer NOT NULL REFERENCES "{{users}}" ("id") ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, +CONSTRAINT "unique_mapping" UNIQUE ("user_id", "folder_id")); +CREATE TABLE "new__users" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "username" varchar(255) NOT NULL UNIQUE, "password" text NULL, +"public_keys" text NULL, "home_dir" varchar(512) NOT NULL, "uid" integer NOT NULL, "gid" integer NOT NULL, "max_sessions" integer NOT NULL, +"quota_size" bigint NOT NULL, "quota_files" integer NOT NULL, "permissions" text NOT NULL, "used_quota_size" bigint NOT NULL, +"used_quota_files" integer NOT NULL, "last_quota_update" bigint NOT NULL, "upload_bandwidth" integer NOT NULL, "download_bandwidth" integer NOT NULL, +"expiration_date" bigint NOT NULL, "last_login" bigint NOT NULL, "status" integer NOT NULL, "filters" text NULL, "filesystem" text NULL); +INSERT INTO "new__users" ("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") SELECT "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" FROM "{{users}}"; +DROP TABLE "{{users}}"; +ALTER TABLE "new__users" RENAME TO "{{users}}"; +CREATE INDEX "folders_mapping_folder_id_idx" ON "{{folders_mapping}}" ("folder_id"); +CREATE INDEX "folders_mapping_user_id_idx" ON "{{folders_mapping}}" ("user_id"); +` ) // SQLiteProvider auth provider for SQLite database @@ -62,7 +84,7 @@ func initializeSQLiteProvider(basePath string) error { if !filepath.IsAbs(dbPath) { dbPath = filepath.Join(basePath, dbPath) } - connectionString = fmt.Sprintf("file:%v?cache=shared", dbPath) + connectionString = fmt.Sprintf("file:%v?cache=shared&_foreign_keys=1", dbPath) } else { connectionString = config.ConnectionString } @@ -98,14 +120,14 @@ func (p SQLiteProvider) updateQuota(username string, filesAdd int, sizeAdd int64 return sqlCommonUpdateQuota(username, filesAdd, sizeAdd, reset, p.dbHandle) } -func (p SQLiteProvider) updateLastLogin(username string) error { - return sqlCommonUpdateLastLogin(username, p.dbHandle) -} - func (p SQLiteProvider) getUsedQuota(username string) (int, int64, error) { return sqlCommonGetUsedQuota(username, p.dbHandle) } +func (p SQLiteProvider) updateLastLogin(username string) error { + return sqlCommonUpdateLastLogin(username, p.dbHandle) +} + func (p SQLiteProvider) userExists(username string) (User, error) { return sqlCommonCheckUserExists(username, p.dbHandle) } @@ -130,6 +152,34 @@ func (p SQLiteProvider) getUsers(limit int, offset int, order string, username s return sqlCommonGetUsers(limit, offset, order, username, p.dbHandle) } +func (p SQLiteProvider) dumpFolders() ([]vfs.BaseVirtualFolder, error) { + return sqlCommonDumpFolders(p.dbHandle) +} + +func (p SQLiteProvider) getFolders(limit, offset int, order, folderPath string) ([]vfs.BaseVirtualFolder, error) { + return sqlCommonGetFolders(limit, offset, order, folderPath, p.dbHandle) +} + +func (p SQLiteProvider) getFolderByPath(mappedPath string) (vfs.BaseVirtualFolder, error) { + return sqlCommonCheckFolderExists(mappedPath, p.dbHandle) +} + +func (p SQLiteProvider) addFolder(folder vfs.BaseVirtualFolder) error { + return sqlCommonAddFolder(folder, p.dbHandle) +} + +func (p SQLiteProvider) deleteFolder(folder vfs.BaseVirtualFolder) error { + return sqlCommonDeleteFolder(folder, p.dbHandle) +} + +func (p SQLiteProvider) updateFolderQuota(mappedPath string, filesAdd int, sizeAdd int64, reset bool) error { + return sqlCommonUpdateFolderQuota(mappedPath, filesAdd, sizeAdd, reset, p.dbHandle) +} + +func (p SQLiteProvider) getUsedFolderQuota(mappedPath string) (int, int64, error) { + return sqlCommonGetFolderUsedQuota(mappedPath, p.dbHandle) +} + func (p SQLiteProvider) close() error { return p.dbHandle.Close() } @@ -140,10 +190,27 @@ func (p SQLiteProvider) reloadConfig() error { // initializeDatabase creates the initial database structure func (p SQLiteProvider) initializeDatabase() error { - sqlUsers := strings.Replace(sqliteUsersTableSQL, "{{users}}", config.UsersTable, 1) - sql := sqlUsers + " " + sqliteSchemaTableSQL + " " + initialDBVersionSQL - _, err := p.dbHandle.Exec(sql) - return err + sqlUsers := strings.Replace(sqliteUsersTableSQL, "{{users}}", sqlTableUsers, 1) + tx, err := p.dbHandle.Begin() + if err != nil { + return err + } + _, err = tx.Exec(sqlUsers) + if err != nil { + sqlCommonRollbackTransaction(tx) + return err + } + _, err = tx.Exec(strings.Replace(sqliteSchemaTableSQL, "{{schema_version}}", sqlTableSchemaVersion, 1)) + if err != nil { + sqlCommonRollbackTransaction(tx) + return err + } + _, err = tx.Exec(strings.Replace(initialDBVersionSQL, "{{schema_version}}", sqlTableSchemaVersion, 1)) + if err != nil { + sqlCommonRollbackTransaction(tx) + return err + } + return tx.Commit() } func (p SQLiteProvider) migrateDatabase() error { @@ -161,9 +228,19 @@ func (p SQLiteProvider) migrateDatabase() error { if err != nil { return err } - return updateSQLiteDatabaseFrom2To3(p.dbHandle) + err = updateSQLiteDatabaseFrom2To3(p.dbHandle) + if err != nil { + return err + } + return updateSQLiteDatabaseFrom3To4(p.dbHandle) case 2: - return updateSQLiteDatabaseFrom2To3(p.dbHandle) + err = updateSQLiteDatabaseFrom2To3(p.dbHandle) + if err != nil { + return err + } + return updateSQLiteDatabaseFrom3To4(p.dbHandle) + case 3: + return updateSQLiteDatabaseFrom3To4(p.dbHandle) default: return fmt.Errorf("Database version not handled: %v", dbVersion.Version) } @@ -171,20 +248,16 @@ func (p SQLiteProvider) migrateDatabase() error { 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) + sql := strings.Replace(sqliteV2SQL, "{{users}}", sqlTableUsers, 1) + return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 2) } func updateSQLiteDatabaseFrom2To3(dbHandle *sql.DB) error { providerLog(logger.LevelInfo, "updating database version: 2 -> 3") - sql := strings.ReplaceAll(sqliteUsersV3SQL, "{{users}}", config.UsersTable) - _, err := dbHandle.Exec(sql) - if err != nil { - return err - } - return sqlCommonUpdateDatabaseVersion(dbHandle, 3) + sql := strings.ReplaceAll(sqliteV3SQL, "{{users}}", sqlTableUsers) + return sqlCommonExecSQLAndUpdateDBVersion(dbHandle, []string{sql}, 3) +} + +func updateSQLiteDatabaseFrom3To4(dbHandle *sql.DB) error { + return sqlCommonUpdateDatabaseFrom3To4(sqliteV4SQL, dbHandle) } diff --git a/dataprovider/sqlqueries.go b/dataprovider/sqlqueries.go index 3580af14..193b3044 100644 --- a/dataprovider/sqlqueries.go +++ b/dataprovider/sqlqueries.go @@ -1,11 +1,17 @@ package dataprovider -import "fmt" +import ( + "fmt" + "strconv" + "strings" + + "github.com/drakkan/sftpgo/vfs" +) 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," + - "virtual_folders" + "used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,expiration_date,last_login,status,filters,filesystem" + selectFolderFields = "id,path,used_quota_size,used_quota_files,last_quota_update" ) func getSQLPlaceholders() []string { @@ -21,71 +27,160 @@ func getSQLPlaceholders() []string { } func getUserByUsernameQuery() string { - return fmt.Sprintf(`SELECT %v FROM %v WHERE username = %v`, selectUserFields, config.UsersTable, sqlPlaceholders[0]) + return fmt.Sprintf(`SELECT %v FROM %v WHERE username = %v`, selectUserFields, sqlTableUsers, sqlPlaceholders[0]) } func getUserByIDQuery() string { - return fmt.Sprintf(`SELECT %v FROM %v WHERE id = %v`, selectUserFields, config.UsersTable, sqlPlaceholders[0]) + return fmt.Sprintf(`SELECT %v FROM %v WHERE id = %v`, selectUserFields, sqlTableUsers, sqlPlaceholders[0]) } func getUsersQuery(order string, username string) string { if len(username) > 0 { return fmt.Sprintf(`SELECT %v FROM %v WHERE username = %v ORDER BY username %v LIMIT %v OFFSET %v`, - selectUserFields, config.UsersTable, sqlPlaceholders[0], order, sqlPlaceholders[1], sqlPlaceholders[2]) + selectUserFields, sqlTableUsers, sqlPlaceholders[0], order, sqlPlaceholders[1], sqlPlaceholders[2]) } - return fmt.Sprintf(`SELECT %v FROM %v ORDER BY username %v LIMIT %v OFFSET %v`, selectUserFields, config.UsersTable, + return fmt.Sprintf(`SELECT %v FROM %v ORDER BY username %v LIMIT %v OFFSET %v`, selectUserFields, sqlTableUsers, order, sqlPlaceholders[0], sqlPlaceholders[1]) } func getDumpUsersQuery() string { - return fmt.Sprintf(`SELECT %v FROM %v`, selectUserFields, config.UsersTable) + return fmt.Sprintf(`SELECT %v FROM %v`, selectUserFields, sqlTableUsers) +} + +func getDumpFoldersQuery() string { + return fmt.Sprintf(`SELECT %v FROM %v`, selectFolderFields, sqlTableFolders) } func getUpdateQuotaQuery(reset bool) string { if reset { return fmt.Sprintf(`UPDATE %v SET used_quota_size = %v,used_quota_files = %v,last_quota_update = %v - WHERE username = %v`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3]) + WHERE username = %v`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3]) } return fmt.Sprintf(`UPDATE %v SET used_quota_size = used_quota_size + %v,used_quota_files = used_quota_files + %v,last_quota_update = %v - WHERE username = %v`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3]) + WHERE username = %v`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3]) } func getUpdateLastLoginQuery() string { - return fmt.Sprintf(`UPDATE %v SET last_login = %v WHERE username = %v`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1]) + return fmt.Sprintf(`UPDATE %v SET last_login = %v WHERE username = %v`, sqlTableUsers, sqlPlaceholders[0], sqlPlaceholders[1]) } func getQuotaQuery() string { - return fmt.Sprintf(`SELECT used_quota_size,used_quota_files FROM %v WHERE username = %v`, config.UsersTable, + return fmt.Sprintf(`SELECT used_quota_size,used_quota_files FROM %v WHERE username = %v`, sqlTableUsers, sqlPlaceholders[0]) } 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,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], + filesystem) + VALUES (%v,%v,%v,%v,%v,%v,%v,%v,%v,%v,0,0,0,%v,%v,%v,0,%v,%v,%v)`, sqlTableUsers, 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[16]) + sqlPlaceholders[14], sqlPlaceholders[15]) } 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, - virtual_folders=%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 + WHERE id = %v`, sqlTableUsers, 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[16]) + sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13], sqlPlaceholders[14], sqlPlaceholders[15]) } func getDeleteUserQuery() string { - return fmt.Sprintf(`DELETE FROM %v WHERE id = %v`, config.UsersTable, sqlPlaceholders[0]) + return fmt.Sprintf(`DELETE FROM %v WHERE id = %v`, sqlTableUsers, sqlPlaceholders[0]) +} + +func getFolderByPathQuery() string { + return fmt.Sprintf(`SELECT %v FROM %v WHERE path = %v`, selectFolderFields, sqlTableFolders, sqlPlaceholders[0]) +} + +func getAddFolderQuery() string { + return fmt.Sprintf(`INSERT INTO %v (path,used_quota_size,used_quota_files,last_quota_update) VALUES (%v,%v,%v,%v)`, + sqlTableFolders, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3]) +} + +func getDeleteFolderQuery() string { + return fmt.Sprintf(`DELETE FROM %v WHERE id = %v`, sqlTableFolders, sqlPlaceholders[0]) +} + +func getClearFolderMappingQuery() string { + return fmt.Sprintf(`DELETE FROM %v WHERE user_id = (SELECT id FROM %v WHERE username = %v)`, sqlTableFoldersMapping, + sqlTableUsers, sqlPlaceholders[0]) +} + +func getAddFolderMappingQuery() string { + return fmt.Sprintf(`INSERT INTO %v (virtual_path,quota_size,quota_files,folder_id,user_id) + VALUES (%v,%v,%v,%v,(SELECT id FROM %v WHERE username = %v))`, sqlTableFoldersMapping, sqlPlaceholders[0], + sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3], sqlTableUsers, sqlPlaceholders[4]) +} + +func getFoldersQuery(order, folderPath string) string { + if len(folderPath) > 0 { + return fmt.Sprintf(`SELECT %v FROM %v WHERE path = %v ORDER BY path %v LIMIT %v OFFSET %v`, + selectFolderFields, sqlTableFolders, sqlPlaceholders[0], order, sqlPlaceholders[1], sqlPlaceholders[2]) + } + return fmt.Sprintf(`SELECT %v FROM %v ORDER BY path %v LIMIT %v OFFSET %v`, selectFolderFields, sqlTableFolders, + order, sqlPlaceholders[0], sqlPlaceholders[1]) +} + +func getUpdateFolderQuotaQuery(reset bool) string { + if reset { + return fmt.Sprintf(`UPDATE %v SET used_quota_size = %v,used_quota_files = %v,last_quota_update = %v + WHERE path = %v`, sqlTableFolders, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3]) + } + return fmt.Sprintf(`UPDATE %v SET used_quota_size = used_quota_size + %v,used_quota_files = used_quota_files + %v,last_quota_update = %v + WHERE path = %v`, sqlTableFolders, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3]) +} + +func getQuotaFolderQuery() string { + return fmt.Sprintf(`SELECT used_quota_size,used_quota_files FROM %v WHERE path = %v`, sqlTableFolders, + sqlPlaceholders[0]) +} + +func getRelatedFoldersForUsersQuery(users []User) string { + var sb strings.Builder + for _, u := range users { + if sb.Len() == 0 { + sb.WriteString("(") + } else { + sb.WriteString(",") + } + sb.WriteString(strconv.FormatInt(u.ID, 10)) + } + if sb.Len() > 0 { + sb.WriteString(")") + } + return fmt.Sprintf(`SELECT f.id,f.path,f.used_quota_size,f.used_quota_files,f.last_quota_update,fm.virtual_path,fm.quota_size,fm.quota_files,fm.user_id + FROM %v f INNER JOIN %v fm ON f.id = fm.folder_id WHERE fm.user_id IN %v ORDER BY fm.user_id`, sqlTableFolders, + sqlTableFoldersMapping, sb.String()) +} + +func getRelatedUsersForFoldersQuery(folders []vfs.BaseVirtualFolder) string { + var sb strings.Builder + for _, f := range folders { + if sb.Len() == 0 { + sb.WriteString("(") + } else { + sb.WriteString(",") + } + sb.WriteString(strconv.FormatInt(f.ID, 10)) + } + if sb.Len() > 0 { + sb.WriteString(")") + } + return fmt.Sprintf(`SELECT fm.folder_id,u.username FROM %v fm INNER JOIN %v u ON fm.user_id = u.id + WHERE fm.folder_id IN %v ORDER BY fm.folder_id`, sqlTableFoldersMapping, sqlTableUsers, sb.String()) } func getDatabaseVersionQuery() string { - return "SELECT version from schema_version LIMIT 1" + return fmt.Sprintf("SELECT version from %v LIMIT 1", sqlTableSchemaVersion) } func getUpdateDBVersionQuery() string { - return fmt.Sprintf(`UPDATE schema_version SET version=%v`, sqlPlaceholders[0]) + return fmt.Sprintf(`UPDATE %v SET version=%v`, sqlTableSchemaVersion, sqlPlaceholders[0]) +} + +func getCompatVirtualFoldersQuery() string { + return fmt.Sprintf(`SELECT id,username,virtual_folders FROM %v`, sqlTableUsers) } diff --git a/dataprovider/user.go b/dataprovider/user.go index 6d90ea4d..3fe91de3 100644 --- a/dataprovider/user.go +++ b/dataprovider/user.go @@ -2,6 +2,7 @@ package dataprovider import ( "encoding/json" + "errors" "fmt" "net" "os" @@ -54,6 +55,10 @@ const ( SSHLoginMethodKeyAndKeyboardInt = "publickey+keyboard-interactive" ) +var ( + errNoMatchingVirtualFolder = errors.New("no matching virtual folder found") +) + // ExtensionsFilter defines filters based on file extensions. // These restrictions do not apply to files listing for performance reasons, so // a denied file cannot be downloaded/overwritten/renamed but will still be @@ -121,7 +126,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 + // 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"` @@ -191,20 +197,22 @@ func (u *User) GetPermissionsForPath(p string) []string { return permissions } -// IsFileExcludedFromQuota returns true if the file must be excluded from quota usage -func (u *User) IsFileExcludedFromQuota(sftpPath string) bool { +// GetVirtualFolderForPath returns the virtual folder containing the specified sftp path. +// If the path is not inside a virtual folder an error is returned +func (u *User) GetVirtualFolderForPath(sftpPath string) (vfs.VirtualFolder, error) { + var folder vfs.VirtualFolder if len(u.VirtualFolders) == 0 || u.FsConfig.Provider != 0 { - return false + return folder, errNoMatchingVirtualFolder } dirsForPath := utils.GetDirsForSFTPPath(path.Dir(sftpPath)) for _, val := range dirsForPath { for _, v := range u.VirtualFolders { if v.VirtualPath == val { - return v.ExcludeFromQuota + return v, nil } } } - return false + return folder, errNoMatchingVirtualFolder } // AddVirtualDirs adds virtual folders, if defined, to the given files list @@ -241,6 +249,19 @@ func (u *User) IsVirtualFolder(sftpPath string) bool { return false } +// HasVirtualFoldersInside return true if there are virtual folders inside the +// specified SFTP path. We assume that path are cleaned +func (u *User) HasVirtualFoldersInside(sftpPath string) bool { + for _, v := range u.VirtualFolders { + if len(v.VirtualPath) > len(sftpPath) { + if strings.HasPrefix(v.VirtualPath, sftpPath+"/") { + 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) @@ -264,6 +285,14 @@ func (u *User) HasPerms(permissions []string, path string) bool { return true } +// HasNoQuotaRestrictions returns true if no quota restrictions need to be applyed +func (u *User) HasNoQuotaRestrictions(checkFiles bool) bool { + if u.QuotaSize == 0 && (!checkFiles || u.QuotaFiles == 0) { + return true + } + return false +} + // IsLoginMethodAllowed returns true if the specified login method is allowed func (u *User) IsLoginMethodAllowed(loginMethod string, partialSuccessMethods []string) bool { if len(u.Filters.DeniedLoginMethods) == 0 { @@ -421,11 +450,6 @@ 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 { diff --git a/docker/sftpgo/alpine/README.md b/docker/sftpgo/alpine/README.md index 859c2035..c3f66be8 100644 --- a/docker/sftpgo/alpine/README.md +++ b/docker/sftpgo/alpine/README.md @@ -15,7 +15,7 @@ sudo groupadd -g 1003 sftpgrp && \ # Get and build SFTPGo image. # Add --build-arg TAG=LATEST to build the latest tag or e.g. TAG=0.9.6 for a specific tag/commit. -# Add --build-arg FEATURES= to specify the feature to build. +# Add --build-arg FEATURES= to specify the features to build. git clone https://github.com/drakkan/sftpgo.git && \ cd sftpgo && \ sudo docker build -t sftpgo docker/sftpgo/alpine/ diff --git a/docs/account.md b/docs/account.md index a886e77a..29f6c18f 100644 --- a/docs/account.md +++ b/docs/account.md @@ -8,7 +8,7 @@ For each account, the following properties can be configured: - `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. A local home directory is required for Cloud Storage Backends too: in this case it will store temporary files. -- `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. For each mapping you can configure if the folder will be included or not in user quota limit. +- `virtual_folders` list of mappings between virtual SFTP/SCP paths and local filesystem paths outside the user home directory. More information can be found [here](./virtual-folders.md) - `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 or 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. @@ -47,14 +47,14 @@ For each account, the following properties can be configured: - `s3_access_secret`, if provided it is stored encrypted (AES-256-GCM). You can leave access key and access secret blank to use credentials from environment - `s3_endpoint`, specifies a S3 endpoint (server) different from AWS. It is not required if you are connecting to AWS - `s3_storage_class`, leave blank to use the default or specify a valid AWS [storage class](https://docs.aws.amazon.com/AmazonS3/latest/dev/storage-class-intro.html) -- `s3_key_prefix`, allows to restrict access to the virtual folder identified by this prefix and its contents +- `s3_key_prefix`, allows to restrict access to the folder identified by this prefix and its contents - `s3_upload_part_size`, the buffer size for multipart uploads (MB). Zero means the default (5 MB). Minimum is 5 - `s3_upload_concurrency` how many parts are uploaded in parallel - `gcs_bucket`, required for GCS filesystem - `gcs_credentials`, Google Cloud Storage JSON credentials base64 encoded - `gcs_automatic_credentials`, integer. Set to 1 to use Application Default Credentials strategy or set to 0 to use explicit credentials via `gcs_credentials` - `gcs_storage_class` -- `gcs_key_prefix`, allows to restrict access to the virtual folder identified by this prefix and its contents +- `gcs_key_prefix`, allows to restrict access to the folder identified by this prefix and its contents These properties are stored inside the data provider. diff --git a/docs/full-configuration.md b/docs/full-configuration.md index 0f65f173..341aef1a 100644 --- a/docs/full-configuration.md +++ b/docs/full-configuration.md @@ -84,12 +84,12 @@ The configuration file contains the following sections: - `password`, string. Database password. Leave empty for drivers `sqlite`, `bolt` and `memory` - `sslmode`, integer. Used for drivers `mysql` and `postgresql`. 0 disable SSL/TLS connections, 1 require ssl, 2 set ssl mode to `verify-ca` for driver `postgresql` and `skip-verify` for driver `mysql`, 3 set ssl mode to `verify-full` for driver `postgresql` and `preferred` for driver `mysql` - `connectionstring`, string. Provide a custom database connection string. If not empty, this connection string will be used instead of building one using the previous parameters. Leave empty for drivers `bolt` and `memory` - - `users_table`, string. Database table for SFTP users + - `sql_tables_prefix`, string. Prefix for SQL tables - `manage_users`, integer. Set to 0 to disable users management, 1 to enable - `track_quota`, integer. Set the preferred mode to track users quota between the following choices: - - 0, disable quota tracking. REST API to scan user dir and update quota will do nothing + - 0, disable quota tracking. REST API to scan users home directories/virtual folders and update quota will do nothing - 1, quota is updated each time a user uploads or deletes a file, even if the user has no quota restrictions - - 2, quota is updated each time a user uploads or deletes a file, but only for users with quota restrictions. With this configuration, the "quota scan" REST API can still be used to periodically update space usage for users without quota restrictions + - 2, quota is updated each time a user uploads or deletes a file, but only for users with quota restrictions and for virtual folders. With this configuration, the `quota scan` and `folder_quota_scan` REST API can still be used to periodically update space usage for users without quota restrictions and for folders - `pool_size`, integer. Sets the maximum number of open connections for `mysql` and `postgresql` driver. Default 0 (unlimited) - `users_base_dir`, string. Users default base directory. If no home dir is defined while adding a new user, and this value is a valid absolute path, then the user home dir will be automatically defined as the path obtained joining the base dir and the username - `actions`, struct. It contains the command to execute and/or the HTTP URL to notify and the trigger conditions. See the "Custom Actions" paragraph for more details diff --git a/docs/google-cloud-storage.md b/docs/google-cloud-storage.md index 972d40c9..92e59e0d 100644 --- a/docs/google-cloud-storage.md +++ b/docs/google-cloud-storage.md @@ -2,7 +2,7 @@ To connect SFTPGo to Google Cloud Storage you can use use the Application Default Credentials (ADC) strategy to try to find your application's credentials automatically or you can explicitly provide a JSON credentials file that you can obtain from the Google Cloud Console. Take a look [here](https://cloud.google.com/docs/authentication/production#providing_credentials_to_your_application) for details. -Specifying a different `key_prefix`, you can assign different virtual folders of the same bucket to different users. This is similar to a chroot directory for local filesystem. Each SFTP/SCP user can only access the assigned virtual folder and its contents. The virtual folder identified by `key_prefix` does not need to be pre-created. +Specifying a different `key_prefix`, you can assign different "folders" of the same bucket to different users. This is similar to a chroot directory for local filesystem. Each SFTP/SCP user can only access the assigned folder and its contents. The folder identified by `key_prefix` does not need to be pre-created. You can optionally specify a [storage class](https://cloud.google.com/storage/docs/storage-classes) too. Leave it blank to use the default storage class. diff --git a/docs/s3.md b/docs/s3.md index 23015d46..ef26ad67 100644 --- a/docs/s3.md +++ b/docs/s3.md @@ -11,7 +11,7 @@ So, you need to provide access keys to activate option 1, or leave them blank to Most S3 backends require HTTPS connections so if you are running SFTPGo as docker image please be sure to uncomment the line that install `ca-certificates`, inside your `Dockerfile`, to be able to properly verify certificate authorities. -Specifying a different `key_prefix`, you can assign different virtual folders of the same bucket to different users. This is similar to a chroot directory for local filesystem. Each SFTP/SCP user can only access the assigned virtual folder and its contents. The virtual folder identified by `key_prefix` does not need to be pre-created. +Specifying a different `key_prefix`, you can assign different "folders" of the same bucket to different users. This is similar to a chroot directory for local filesystem. Each SFTP/SCP user can only access the assigned folder and its contents. The folder identified by `key_prefix` does not need to be pre-created. SFTPGo uses multipart uploads and parallel downloads for storing and retrieving files from S3. diff --git a/examples/rest-api-cli/README.md b/examples/rest-api-cli/README.md index bc1e9f48..cbe5a6dc 100644 --- a/examples/rest-api-cli/README.md +++ b/examples/rest-api-cli/README.md @@ -140,7 +140,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 --virtual-folders "/vdir1::/tmp/mapped1" "/vdir2::/tmp/mapped2::1" --allowed-extensions "" --denied-extensions "" +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::-1::-1" "/vdir2::/tmp/mapped2::100::104857600" --allowed-extensions "" --denied-extensions "" ``` Output: @@ -203,13 +203,23 @@ Output: "username": "test_username", "virtual_folders": [ { - "exclude_from_quota": false, + "id": 1, + "last_quota_update": 0, "mapped_path": "/tmp/mapped1", + "quota_files": -1, + "quota_size": -1, + "used_quota_files": 0, + "used_quota_size": 0, "virtual_path": "/vdir1" }, { - "exclude_from_quota": true, + "id": 2, + "last_quota_update": 0, "mapped_path": "/tmp/mapped2", + "quota_files": 100, + "quota_size": 104857600, + "used_quota_files": 0, + "used_quota_size": 0, "virtual_path": "/vdir2" } ] @@ -315,6 +325,49 @@ Output: ] ``` +### Get folders + +Command: + +``` +python sftpgo_api_cli.py get-folders --limit 1 --offset 0 --folder-path /tmp/mapped1 --order DESC +``` + +Output: + +```json +[ + { + "id": 1, + "last_quota_update": 1591563422870, + "mapped_path": "/tmp/mapped1", + "used_quota_files": 1, + "used_quota_size": 13313790, + "users": [ + "test_username" + ] + } +] +``` + +### Add folder + +``` +python sftpgo_api_cli.py add-folder /tmp/mapped_folder +``` + +Output: + +```json +{ + "id": 4, + "last_quota_update": 0, + "mapped_path": "/tmp/mapped_folder", + "used_quota_files": 0, + "used_quota_size": 0 +} +``` + ### Close connection Command: @@ -359,6 +412,32 @@ Output: } ``` +### Get folder quota scans + +Command: + +``` +python sftpgo_api_cli.py get-folders-quota-scans +``` + +### Start folder quota scan + +Command: + +``` +python sftpgo_api_cli.py start-folder-quota-scan /tmp/mapped_folder +``` + +Output: + +```json +{ + "status": 201, + "message": "Scan started", + "error": "" +} +``` + ### Delete user Command: @@ -377,6 +456,22 @@ Output: } ``` +### Delete folder + +``` +python sftpgo_api_cli.py delete-folder /tmp/mapped_folder +``` + +Output: + +```json +{ + "error": "", + "message": "Folder deleted", + "status": 200 +} +``` + ### Get version Command: diff --git a/examples/rest-api-cli/sftpgo_api_cli.py b/examples/rest-api-cli/sftpgo_api_cli.py index 90e40fd8..c6d49aa5 100755 --- a/examples/rest-api-cli/sftpgo_api_cli.py +++ b/examples/rest-api-cli/sftpgo_api_cli.py @@ -32,7 +32,9 @@ class SFTPGoApiRequests: def __init__(self, debug, baseUrl, authType, authUser, authPassword, secure, no_color): self.userPath = urlparse.urljoin(baseUrl, '/api/v1/user') + self.folderPath = urlparse.urljoin(baseUrl, '/api/v1/folder') self.quotaScanPath = urlparse.urljoin(baseUrl, '/api/v1/quota_scan') + self.folderQuotaScanPath = urlparse.urljoin(baseUrl, '/api/v1/folder_quota_scan') self.activeConnectionsPath = urlparse.urljoin(baseUrl, '/api/v1/connection') self.versionPath = urlparse.urljoin(baseUrl, '/api/v1/version') self.providerStatusPath = urlparse.urljoin(baseUrl, '/api/v1/providerstatus') @@ -110,19 +112,25 @@ class SFTPGoApiRequests: if '::' in f: vpath = '' mapped_path = '' - exclude_from_quota = False + quota_files = 0 + quota_size = 0 values = f.split('::') if len(values) > 1: vpath = values[0] mapped_path = values[1] if len(values) > 2: try: - exclude_from_quota = int(values[2]) > 0 + quota_files = int(values[2]) + except: + pass + if len(values) > 3: + try: + quota_size = int(values[3]) except: pass if vpath and mapped_path: result.append({"virtual_path":vpath, "mapped_path":mapped_path, - "exclude_from_quota":exclude_from_quota}) + "quota_files":quota_files, "quota_size":quota_size}) return result def buildPermissions(self, root_perms, subdirs_perms): @@ -293,6 +301,29 @@ class SFTPGoApiRequests: r = requests.post(self.quotaScanPath, json=u, auth=self.auth, verify=self.verify) self.printResponse(r) + def getFoldersQuotaScans(self): + r = requests.get(self.folderQuotaScanPath, auth=self.auth, verify=self.verify) + self.printResponse(r) + + def startFolderQuotaScan(self, mapped_path): + f = {"mapped_path":mapped_path} + r = requests.post(self.folderQuotaScanPath, json=f, auth=self.auth, verify=self.verify) + self.printResponse(r) + + def addFolder(self, mapped_path): + f = {"mapped_path":mapped_path} + r = requests.post(self.folderPath, json=f, auth=self.auth, verify=self.verify) + self.printResponse(r) + + def deleteFolder(self, mapped_path): + r = requests.delete(self.folderPath, params={'folder_path':mapped_path}, auth=self.auth, verify=self.verify) + self.printResponse(r) + + def getFolders(self, limit=100, offset=0, order='ASC', mapped_path=''): + r = requests.get(self.folderPath, params={'limit':limit, 'offset':offset, 'order':order, + 'folder_path':mapped_path}, auth=self.auth, verify=self.verify) + self.printResponse(r) + def getVersion(self): r = requests.get(self.versionPath, auth=self.auth, verify=self.verify) self.printResponse(r) @@ -516,8 +547,8 @@ def addCommonUserArguments(parser): parser.add_argument('--subdirs-permissions', type=str, nargs='*', default=[], help='Permissions for subdirs. ' +'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::1". If the optional third argument is > 0 the virtual ' - +'folder will be excluded from user quota. Ignored for non local filesystems. Default: %(default)s') + +'"/vpath::/home/adir" "/vpath::C:\adir::[quota_file]::[quota_size]". Quota parameters -1 means ' + +'included inside user quota, 0 means unlimited. 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, @@ -615,10 +646,29 @@ if __name__ == '__main__': parserCloseConnection = subparsers.add_parser('close-connection', help='Terminate an active SFTP/SCP connection') parserCloseConnection.add_argument('connectionID', type=str) - parserGetQuotaScans = subparsers.add_parser('get-quota-scans', help='Get the active quota scans') + parserGetQuotaScans = subparsers.add_parser('get-quota-scans', help='Get the active quota scans for users home directories') - parserStartQuotaScans = subparsers.add_parser('start-quota-scan', help='Start a new quota scan') - addCommonUserArguments(parserStartQuotaScans) + parserStartQuotaScan = subparsers.add_parser('start-quota-scan', help='Start a new user quota scan') + addCommonUserArguments(parserStartQuotaScan) + + parserGetFolderQuotaScans = subparsers.add_parser('get-folders-quota-scans', help='Get the active quota scans for folders') + + parserStartFolderQuotaScan = subparsers.add_parser('start-folder-quota-scan', help='Start a new folder quota scan') + parserStartFolderQuotaScan.add_argument('folder_path', type=str) + + parserGetFolders = subparsers.add_parser('get-folders', help='Returns an array with one or more folders') + parserGetFolders.add_argument('-L', '--limit', type=int, default=100, choices=range(1, 501), + help='Maximum allowed value is 500. Default: %(default)s', metavar='[1...500]') + parserGetFolders.add_argument('-O', '--offset', type=int, default=0, help='Default: %(default)s') + parserGetFolders.add_argument('-P', '--folder-path', type=str, default='', help='Default: %(default)s') + parserGetFolders.add_argument('-S', '--order', type=str, choices=['ASC', 'DESC'], default='ASC', + help='default: %(default)s') + + parserAddFolder = subparsers.add_parser('add-folder', help='Add a new folder') + parserAddFolder.add_argument('folder_path', type=str) + + parserDeleteFolder = subparsers.add_parser('delete-folder', help='Delete an existing folder') + parserDeleteFolder.add_argument('folder_path', type=str) parserGetVersion = subparsers.add_parser('get-version', help='Get version details') @@ -697,6 +747,16 @@ if __name__ == '__main__': api.getQuotaScans() elif args.command == 'start-quota-scan': api.startQuotaScan(args.username) + elif args.command == 'get-folders': + api.getFolders(args.limit, args.offset, args.order, args.folder_path) + elif args.command == 'add-folder': + api.addFolder(args.folder_path) + elif args.command == 'delete-folder': + api.deleteFolder(args.folder_path) + elif args.command == 'get-folders-quota-scans': + api.getFoldersQuotaScans() + elif args.command == 'start-folder-quota-scan': + api.startFolderQuotaScan(args.folder_path) elif args.command == 'get-version': api.getVersion() elif args.command == 'get-provider-status': diff --git a/httpd/api_folder.go b/httpd/api_folder.go new file mode 100644 index 00000000..3d4b159a --- /dev/null +++ b/httpd/api_folder.go @@ -0,0 +1,104 @@ +package httpd + +import ( + "errors" + "net/http" + "strconv" + + "github.com/go-chi/render" + + "github.com/drakkan/sftpgo/dataprovider" + "github.com/drakkan/sftpgo/vfs" +) + +func getFolders(w http.ResponseWriter, r *http.Request) { + var err error + limit := 100 + offset := 0 + order := dataprovider.OrderASC + folderPath := "" + if _, ok := r.URL.Query()["limit"]; ok { + limit, err = strconv.Atoi(r.URL.Query().Get("limit")) + if err != nil { + err = errors.New("Invalid limit") + sendAPIResponse(w, r, err, "", http.StatusBadRequest) + return + } + if limit > 500 { + limit = 500 + } + } + if _, ok := r.URL.Query()["offset"]; ok { + offset, err = strconv.Atoi(r.URL.Query().Get("offset")) + if err != nil { + err = errors.New("Invalid offset") + sendAPIResponse(w, r, err, "", http.StatusBadRequest) + return + } + } + if _, ok := r.URL.Query()["order"]; ok { + order = r.URL.Query().Get("order") + if order != dataprovider.OrderASC && order != dataprovider.OrderDESC { + err = errors.New("Invalid order") + sendAPIResponse(w, r, err, "", http.StatusBadRequest) + return + } + } + if _, ok := r.URL.Query()["folder_path"]; ok { + folderPath = r.URL.Query().Get("folder_path") + } + folders, err := dataprovider.GetFolders(dataProvider, limit, offset, order, folderPath) + if err == nil { + render.JSON(w, r, folders) + } else { + sendAPIResponse(w, r, err, "", http.StatusInternalServerError) + } +} + +func addFolder(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + var folder vfs.BaseVirtualFolder + err := render.DecodeJSON(r.Body, &folder) + if err != nil { + sendAPIResponse(w, r, err, "", http.StatusBadRequest) + return + } + err = dataprovider.AddFolder(dataProvider, folder) + if err == nil { + folder, err = dataprovider.GetFolderByPath(dataProvider, folder.MappedPath) + if err == nil { + render.JSON(w, r, folder) + } else { + sendAPIResponse(w, r, err, "", http.StatusInternalServerError) + } + } else { + sendAPIResponse(w, r, err, "", getRespStatus(err)) + } +} + +func deleteFolderByPath(w http.ResponseWriter, r *http.Request) { + var folderPath string + if _, ok := r.URL.Query()["folder_path"]; ok { + folderPath = r.URL.Query().Get("folder_path") + } + if len(folderPath) == 0 { + err := errors.New("a non-empty folder path is required") + sendAPIResponse(w, r, err, "", http.StatusBadRequest) + return + } + + folder, err := dataprovider.GetFolderByPath(dataProvider, folderPath) + if _, ok := err.(*dataprovider.RecordNotFoundError); ok { + sendAPIResponse(w, r, err, "", http.StatusNotFound) + return + } else if err != nil { + sendAPIResponse(w, r, err, "", http.StatusInternalServerError) + return + } + err = dataprovider.DeleteFolder(dataProvider, folder) + if err != nil { + sendAPIResponse(w, r, err, "", http.StatusInternalServerError) + } else { + sendAPIResponse(w, r, err, "Folder deleted", http.StatusOK) + } +} diff --git a/httpd/api_maintenance.go b/httpd/api_maintenance.go index b27c5758..2e8162d0 100644 --- a/httpd/api_maintenance.go +++ b/httpd/api_maintenance.go @@ -14,6 +14,7 @@ import ( "github.com/drakkan/sftpgo/dataprovider" "github.com/drakkan/sftpgo/logger" "github.com/drakkan/sftpgo/sftpd" + "github.com/drakkan/sftpgo/vfs" ) func dumpData(w http.ResponseWriter, r *http.Request) { @@ -45,7 +46,7 @@ func dumpData(w http.ResponseWriter, r *http.Request) { } logger.Debug(logSender, "", "dumping data to: %#v", outputFile) - users, err := dataprovider.DumpUsers(dataProvider) + backup, err := dataprovider.DumpData(dataProvider) if err != nil { logger.Warn(logSender, "", "dumping data error: %v, output file: %#v", err, outputFile) sendAPIResponse(w, r, err, "", getRespStatus(err)) @@ -53,13 +54,9 @@ func dumpData(w http.ResponseWriter, r *http.Request) { } var dump []byte if indent == "1" { - dump, err = json.MarshalIndent(dataprovider.BackupData{ - Users: users, - }, "", " ") + dump, err = json.MarshalIndent(backup, "", " ") } else { - dump, err = json.Marshal(dataprovider.BackupData{ - Users: users, - }) + dump, err = json.Marshal(backup) } if err == nil { err = ioutil.WriteFile(outputFile, dump, 0600) @@ -106,39 +103,16 @@ func loadData(w http.ResponseWriter, r *http.Request) { return } - for _, user := range dump.Users { - u, err := dataprovider.UserExists(dataProvider, user.Username) - if err == nil { - if mode == 1 { - logger.Debug(logSender, "", "loaddata mode 1, existing user %#v not updated", u.Username) - continue - } - user.ID = u.ID - user.LastLogin = u.LastLogin - user.UsedQuotaSize = u.UsedQuotaSize - user.UsedQuotaFiles = u.UsedQuotaFiles - err = dataprovider.UpdateUser(dataProvider, user) - user.Password = "[redacted]" - logger.Debug(logSender, "", "restoring existing user: %+v, dump file: %#v, error: %v", user, inputFile, err) - } else { - user.LastLogin = 0 - user.UsedQuotaSize = 0 - user.UsedQuotaFiles = 0 - err = dataprovider.AddUser(dataProvider, user) - user.Password = "[redacted]" - logger.Debug(logSender, "", "adding new user: %+v, dump file: %#v, error: %v", user, inputFile, err) - } - if err != nil { - sendAPIResponse(w, r, err, "", getRespStatus(err)) - return - } - if scanQuota == 1 || (scanQuota == 2 && user.HasQuotaRestrictions()) { - if sftpd.AddQuotaScan(user.Username) { - logger.Debug(logSender, "", "starting quota scan for restored user: %#v", user.Username) - go doQuotaScan(user) //nolint:errcheck - } - } + if err = restoreFolders(dump.Folders, inputFile, scanQuota); err != nil { + sendAPIResponse(w, r, err, "", getRespStatus(err)) + return } + + if err = restoreUsers(dump.Users, inputFile, mode, scanQuota); err != nil { + sendAPIResponse(w, r, err, "", getRespStatus(err)) + return + } + logger.Debug(logSender, "", "backup restored, users: %v", len(dump.Users)) sendAPIResponse(w, r, err, "Data restored", http.StatusOK) } @@ -165,3 +139,56 @@ func getLoaddataOptions(r *http.Request) (string, int, int, error) { } return inputFile, scanQuota, restoreMode, err } + +func restoreFolders(folders []vfs.BaseVirtualFolder, inputFile string, scanQuota int) error { + for _, folder := range folders { + _, err := dataprovider.GetFolderByPath(dataProvider, folder.MappedPath) + if err == nil { + logger.Debug(logSender, "", "folder %#v already exists, restore not needed", folder.MappedPath) + continue + } + folder.Users = nil + err = dataprovider.AddFolder(dataProvider, folder) + logger.Debug(logSender, "", "adding new folder: %+v, dump file: %#v, error: %v", folder, inputFile, err) + if err != nil { + return err + } + if scanQuota >= 1 { + if sftpd.AddVFolderQuotaScan(folder.MappedPath) { + logger.Debug(logSender, "", "starting quota scan for restored folder: %#v", folder.MappedPath) + go doFolderQuotaScan(folder) //nolint:errcheck + } + } + } + return nil +} + +func restoreUsers(users []dataprovider.User, inputFile string, mode, scanQuota int) error { + for _, user := range users { + u, err := dataprovider.UserExists(dataProvider, user.Username) + if err == nil { + if mode == 1 { + logger.Debug(logSender, "", "loaddata mode 1, existing user %#v not updated", u.Username) + continue + } + user.ID = u.ID + err = dataprovider.UpdateUser(dataProvider, user) + user.Password = "[redacted]" + logger.Debug(logSender, "", "restoring existing user: %+v, dump file: %#v, error: %v", user, inputFile, err) + } else { + err = dataprovider.AddUser(dataProvider, user) + user.Password = "[redacted]" + logger.Debug(logSender, "", "adding new user: %+v, dump file: %#v, error: %v", user, inputFile, err) + } + if err != nil { + return err + } + if scanQuota == 1 || (scanQuota == 2 && user.HasQuotaRestrictions()) { + if sftpd.AddQuotaScan(user.Username) { + logger.Debug(logSender, "", "starting quota scan for restored user: %#v", user.Username) + go doQuotaScan(user) //nolint:errcheck + } + } + } + return nil +} diff --git a/httpd/api_quota.go b/httpd/api_quota.go index b3bce523..570a1512 100644 --- a/httpd/api_quota.go +++ b/httpd/api_quota.go @@ -8,12 +8,17 @@ import ( "github.com/drakkan/sftpgo/dataprovider" "github.com/drakkan/sftpgo/logger" "github.com/drakkan/sftpgo/sftpd" + "github.com/drakkan/sftpgo/vfs" ) func getQuotaScans(w http.ResponseWriter, r *http.Request) { render.JSON(w, r, sftpd.GetQuotaScans()) } +func getVFolderQuotaScans(w http.ResponseWriter, r *http.Request) { + render.JSON(w, r, sftpd.GetVFoldersQuotaScans()) +} + func startQuotaScan(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) var u dataprovider.User @@ -27,6 +32,10 @@ func startQuotaScan(w http.ResponseWriter, r *http.Request) { sendAPIResponse(w, r, err, "", http.StatusNotFound) return } + if dataprovider.GetQuotaTracking() == 0 { + sendAPIResponse(w, r, nil, "Quota tracking is disabled!", http.StatusForbidden) + return + } if sftpd.AddQuotaScan(user.Username) { go doQuotaScan(user) //nolint:errcheck sendAPIResponse(w, r, err, "Scan started", http.StatusCreated) @@ -35,6 +44,31 @@ func startQuotaScan(w http.ResponseWriter, r *http.Request) { } } +func startVFolderQuotaScan(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + var f vfs.BaseVirtualFolder + err := render.DecodeJSON(r.Body, &f) + if err != nil { + sendAPIResponse(w, r, err, "", http.StatusBadRequest) + return + } + folder, err := dataprovider.GetFolderByPath(dataProvider, f.MappedPath) + if err != nil { + sendAPIResponse(w, r, err, "", http.StatusNotFound) + return + } + if dataprovider.GetQuotaTracking() == 0 { + sendAPIResponse(w, r, nil, "Quota tracking is disabled!", http.StatusForbidden) + return + } + if sftpd.AddVFolderQuotaScan(folder.MappedPath) { + go doFolderQuotaScan(folder) //nolint:errcheck + sendAPIResponse(w, r, err, "Scan started", http.StatusCreated) + } else { + sendAPIResponse(w, r, err, "Another scan is already in progress", http.StatusConflict) + } +} + func doQuotaScan(user dataprovider.User) error { defer sftpd.RemoveQuotaScan(user.Username) //nolint:errcheck fs, err := user.GetFilesystem("") @@ -45,9 +79,22 @@ func doQuotaScan(user dataprovider.User) error { numFiles, size, err := fs.ScanRootDirContents() if err != nil { logger.Warn(logSender, "", "error scanning user home dir %#v: %v", user.Username, err) - } else { - err = dataprovider.UpdateUserQuota(dataProvider, user, numFiles, size, true) - logger.Debug(logSender, "", "user home dir scanned, user: %#v, error: %v", user.Username, err) + return err } + err = dataprovider.UpdateUserQuota(dataProvider, user, numFiles, size, true) + logger.Debug(logSender, "", "user home dir scanned, user: %#v, error: %v", user.Username, err) + return err +} + +func doFolderQuotaScan(folder vfs.BaseVirtualFolder) error { + defer sftpd.RemoveVFolderQuotaScan(folder.MappedPath) //nolint:errcheck + fs := vfs.NewOsFs("", "", nil).(vfs.OsFs) + numFiles, size, err := fs.GetDirSize(folder.MappedPath) + if err != nil { + logger.Warn(logSender, "", "error scanning folder %#v: %v", folder.MappedPath, err) + return err + } + err = dataprovider.UpdateVirtualFolderQuota(dataProvider, folder, numFiles, size, true) + logger.Debug(logSender, "", "virtual folder %#v scanned, error: %v", folder.MappedPath, err) return err } diff --git a/httpd/api_user.go b/httpd/api_user.go index 5ff59643..254c4224 100644 --- a/httpd/api_user.go +++ b/httpd/api_user.go @@ -15,7 +15,7 @@ import ( func getUsers(w http.ResponseWriter, r *http.Request) { limit := 100 offset := 0 - order := "ASC" + order := dataprovider.OrderASC username := "" var err error if _, ok := r.URL.Query()["limit"]; ok { @@ -39,7 +39,7 @@ func getUsers(w http.ResponseWriter, r *http.Request) { } if _, ok := r.URL.Query()["order"]; ok { order = r.URL.Query().Get("order") - if order != "ASC" && order != "DESC" { + if order != dataprovider.OrderASC && order != dataprovider.OrderDESC { err = errors.New("Invalid order") sendAPIResponse(w, r, err, "", http.StatusBadRequest) return diff --git a/httpd/api_utils.go b/httpd/api_utils.go index 228ef435..845bc3ec 100644 --- a/httpd/api_utils.go +++ b/httpd/api_utils.go @@ -22,6 +22,7 @@ import ( "github.com/drakkan/sftpgo/httpclient" "github.com/drakkan/sftpgo/sftpd" "github.com/drakkan/sftpgo/utils" + "github.com/drakkan/sftpgo/vfs" ) var ( @@ -90,10 +91,7 @@ func getRespStatus(err error) int { func AddUser(user dataprovider.User, expectedStatusCode int) (dataprovider.User, []byte, error) { var newUser dataprovider.User var body []byte - userAsJSON, err := json.Marshal(user) - if err != nil { - return newUser, body, err - } + userAsJSON, _ := json.Marshal(user) resp, err := sendHTTPRequest(http.MethodPost, buildURLRelativeToBase(userPath), bytes.NewBuffer(userAsJSON), "application/json") if err != nil { @@ -120,10 +118,7 @@ func AddUser(user dataprovider.User, expectedStatusCode int) (dataprovider.User, func UpdateUser(user dataprovider.User, expectedStatusCode int) (dataprovider.User, []byte, error) { var newUser dataprovider.User var body []byte - userAsJSON, err := json.Marshal(user) - if err != nil { - return user, body, err - } + userAsJSON, _ := json.Marshal(user) resp, err := sendHTTPRequest(http.MethodPut, buildURLRelativeToBase(userPath, strconv.FormatInt(user.ID, 10)), bytes.NewBuffer(userAsJSON), "application/json") if err != nil { @@ -174,28 +169,22 @@ func GetUserByID(userID int64, expectedStatusCode int) (dataprovider.User, []byt return user, body, err } -// GetUsers allows to get a list of users and checks the received HTTP Status code against expectedStatusCode. +// GetUsers returns a list of users and checks the received HTTP Status code against expectedStatusCode. // The number of results can be limited specifying a limit. // Some results can be skipped specifying an offset. // The results can be filtered specifying a username, the username filter is an exact match -func GetUsers(limit int64, offset int64, username string, expectedStatusCode int) ([]dataprovider.User, []byte, error) { +func GetUsers(limit, offset int64, username string, expectedStatusCode int) ([]dataprovider.User, []byte, error) { var users []dataprovider.User var body []byte - url, err := url.Parse(buildURLRelativeToBase(userPath)) + url, err := addLimitAndOffsetQueryParams(buildURLRelativeToBase(userPath), limit, offset) if err != nil { return users, body, err } - q := url.Query() - if limit > 0 { - q.Add("limit", strconv.FormatInt(limit, 10)) - } - if offset > 0 { - q.Add("offset", strconv.FormatInt(offset, 10)) - } if len(username) > 0 { + q := url.Query() q.Add("username", username) + url.RawQuery = q.Encode() } - url.RawQuery = q.Encode() resp, err := sendHTTPRequest(http.MethodGet, url.String(), nil, "") if err != nil { return users, body, err @@ -210,7 +199,7 @@ func GetUsers(limit int64, offset int64, username string, expectedStatusCode int return users, body, err } -// GetQuotaScans gets active quota scans and checks the received HTTP Status code against expectedStatusCode. +// GetQuotaScans gets active quota scans for users and checks the received HTTP Status code against expectedStatusCode. func GetQuotaScans(expectedStatusCode int) ([]sftpd.ActiveQuotaScan, []byte, error) { var quotaScans []sftpd.ActiveQuotaScan var body []byte @@ -231,10 +220,7 @@ func GetQuotaScans(expectedStatusCode int) ([]sftpd.ActiveQuotaScan, []byte, err // StartQuotaScan start a new quota scan for the given user and checks the received HTTP Status code against expectedStatusCode. func StartQuotaScan(user dataprovider.User, expectedStatusCode int) ([]byte, error) { var body []byte - userAsJSON, err := json.Marshal(user) - if err != nil { - return body, err - } + userAsJSON, _ := json.Marshal(user) resp, err := sendHTTPRequest(http.MethodPost, buildURLRelativeToBase(quotaScanPath), bytes.NewBuffer(userAsJSON), "") if err != nil { return body, err @@ -275,6 +261,114 @@ func CloseConnection(connectionID string, expectedStatusCode int) ([]byte, error return body, err } +// AddFolder adds a new folder and checks the received HTTP Status code against expectedStatusCode +func AddFolder(folder vfs.BaseVirtualFolder, expectedStatusCode int) (vfs.BaseVirtualFolder, []byte, error) { + var newFolder vfs.BaseVirtualFolder + var body []byte + folderAsJSON, _ := json.Marshal(folder) + resp, err := sendHTTPRequest(http.MethodPost, buildURLRelativeToBase(folderPath), bytes.NewBuffer(folderAsJSON), + "application/json") + if err != nil { + return newFolder, body, err + } + defer resp.Body.Close() + err = checkResponse(resp.StatusCode, expectedStatusCode) + if expectedStatusCode != http.StatusOK { + body, _ = getResponseBody(resp) + return newFolder, body, err + } + if err == nil { + err = render.DecodeJSON(resp.Body, &newFolder) + } else { + body, _ = getResponseBody(resp) + } + if err == nil { + err = checkFolder(&folder, &newFolder) + } + return newFolder, body, err +} + +// RemoveFolder removes an existing user and checks the received HTTP Status code against expectedStatusCode. +func RemoveFolder(folder vfs.BaseVirtualFolder, expectedStatusCode int) ([]byte, error) { + var body []byte + baseURL := buildURLRelativeToBase(folderPath) + url, err := url.Parse(baseURL) + if err != nil { + return body, err + } + q := url.Query() + q.Add("folder_path", folder.MappedPath) + url.RawQuery = q.Encode() + resp, err := sendHTTPRequest(http.MethodDelete, url.String(), nil, "") + if err != nil { + return body, err + } + defer resp.Body.Close() + body, _ = getResponseBody(resp) + return body, checkResponse(resp.StatusCode, expectedStatusCode) +} + +// GetFolders returns a list of folders and checks the received HTTP Status code against expectedStatusCode. +// The number of results can be limited specifying a limit. +// Some results can be skipped specifying an offset. +// The results can be filtered specifying a folder path, the folder path filter is an exact match +func GetFolders(limit int64, offset int64, mappedPath string, expectedStatusCode int) ([]vfs.BaseVirtualFolder, []byte, error) { + var folders []vfs.BaseVirtualFolder + var body []byte + url, err := addLimitAndOffsetQueryParams(buildURLRelativeToBase(folderPath), limit, offset) + if err != nil { + return folders, body, err + } + if len(mappedPath) > 0 { + q := url.Query() + q.Add("folder_path", mappedPath) + url.RawQuery = q.Encode() + } + resp, err := sendHTTPRequest(http.MethodGet, url.String(), nil, "") + if err != nil { + return folders, body, err + } + defer resp.Body.Close() + err = checkResponse(resp.StatusCode, expectedStatusCode) + if err == nil && expectedStatusCode == http.StatusOK { + err = render.DecodeJSON(resp.Body, &folders) + } else { + body, _ = getResponseBody(resp) + } + return folders, body, err +} + +// GetFoldersQuotaScans gets active quota scans for folders and checks the received HTTP Status code against expectedStatusCode. +func GetFoldersQuotaScans(expectedStatusCode int) ([]sftpd.ActiveVirtualFolderQuotaScan, []byte, error) { + var quotaScans []sftpd.ActiveVirtualFolderQuotaScan + var body []byte + resp, err := sendHTTPRequest(http.MethodGet, buildURLRelativeToBase(quotaScanVFolderPath), nil, "") + if err != nil { + return quotaScans, body, err + } + defer resp.Body.Close() + err = checkResponse(resp.StatusCode, expectedStatusCode) + if err == nil && expectedStatusCode == http.StatusOK { + err = render.DecodeJSON(resp.Body, "aScans) + } else { + body, _ = getResponseBody(resp) + } + return quotaScans, body, err +} + +// StartFolderQuotaScan start a new quota scan for the given folder and checks the received HTTP Status code against expectedStatusCode. +func StartFolderQuotaScan(folder vfs.BaseVirtualFolder, expectedStatusCode int) ([]byte, error) { + var body []byte + folderAsJSON, _ := json.Marshal(folder) + resp, err := sendHTTPRequest(http.MethodPost, buildURLRelativeToBase(quotaScanVFolderPath), bytes.NewBuffer(folderAsJSON), "") + if err != nil { + return body, err + } + defer resp.Body.Close() + body, _ = getResponseBody(resp) + return body, checkResponse(resp.StatusCode, expectedStatusCode) +} + // GetVersion returns version details func GetVersion(expectedStatusCode int) (utils.VersionInfo, []byte, error) { var version utils.VersionInfo @@ -384,6 +478,39 @@ func getResponseBody(resp *http.Response) ([]byte, error) { return ioutil.ReadAll(resp.Body) } +func checkFolder(expected *vfs.BaseVirtualFolder, actual *vfs.BaseVirtualFolder) error { + if expected.ID <= 0 { + if actual.ID <= 0 { + return errors.New("actual folder ID must be > 0") + } + } else { + if actual.ID != expected.ID { + return errors.New("folder ID mismatch") + } + } + if expected.MappedPath != actual.MappedPath { + return errors.New("mapped path mismatch") + } + if expected.LastQuotaUpdate != actual.LastQuotaUpdate { + return errors.New("last quota update mismatch") + } + if expected.UsedQuotaSize != actual.UsedQuotaSize { + return errors.New("used quota size mismatch") + } + if expected.UsedQuotaFiles != actual.UsedQuotaFiles { + return errors.New("used quota files mismatch") + } + if len(expected.Users) != len(actual.Users) { + return errors.New("folder users mismatch") + } + for _, u := range actual.Users { + if !utils.IsStringInSlice(u, expected.Users) { + return errors.New("folder users mismatch") + } + } + return nil +} + func checkUser(expected *dataprovider.User, actual *dataprovider.User) error { if len(actual.Password) > 0 { return errors.New("User password must not be visible") @@ -634,3 +761,19 @@ func compareEqualsUserFields(expected *dataprovider.User, actual *dataprovider.U } return nil } + +func addLimitAndOffsetQueryParams(rawurl string, limit, offset int64) (*url.URL, error) { + url, err := url.Parse(rawurl) + if err != nil { + return nil, err + } + q := url.Query() + if limit > 0 { + q.Add("limit", strconv.FormatInt(limit, 10)) + } + if offset > 0 { + q.Add("offset", strconv.FormatInt(offset, 10)) + } + url.RawQuery = q.Encode() + return url, err +} diff --git a/httpd/httpd.go b/httpd/httpd.go index a51899bd..1e0dd972 100644 --- a/httpd/httpd.go +++ b/httpd/httpd.go @@ -25,8 +25,10 @@ const ( apiPrefix = "/api/v1" activeConnectionsPath = "/api/v1/connection" quotaScanPath = "/api/v1/quota_scan" + quotaScanVFolderPath = "/api/v1/folder_quota_scan" userPath = "/api/v1/user" versionPath = "/api/v1/version" + folderPath = "/api/v1/folder" providerStatusPath = "/api/v1/providerstatus" dumpDataPath = "/api/v1/dumpdata" loadDataPath = "/api/v1/loaddata" @@ -36,6 +38,8 @@ const ( webUsersPath = "/web/users" webUserPath = "/web/user" webConnectionsPath = "/web/connections" + webFoldersPath = "/web/folders" + webFolderPath = "/web/folder" webStaticFilesPath = "/static" maxRestoreSize = 10485760 // 10 MB maxRequestSize = 1048576 // 1MB diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index a91aca47..044d09d0 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -43,14 +43,18 @@ const ( testPubKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC03jj0D+djk7pxIf/0OhrxrchJTRZklofJ1NoIu4752Sq02mdXmarMVsqJ1cAjV5LBVy3D1F5U6XW4rppkXeVtd04Pxb09ehtH0pRRPaoHHlALiJt8CoMpbKYMA8b3KXPPriGxgGomvtU2T2RMURSwOZbMtpsugfjYSWenyYX+VORYhylWnSXL961LTyC21ehd6d6QnW9G7E5hYMITMY9TuQZz3bROYzXiTsgN0+g6Hn7exFQp50p45StUMfV/SftCMdCxlxuyGny2CrN/vfjO7xxOo2uv7q1qm10Q46KPWJQv+pgZ/OfL+EDjy07n5QVSKHlbx+2nT4Q0EgOSQaCTYwn3YjtABfIxWwgAFdyj6YlPulCL22qU4MYhDcA6PSBwDdf8hvxBfvsiHdM+JcSHvv8/VeJhk6CmnZxGY0fxBupov27z3yEO8nAg8k+6PaUiW1MSUfuGMF/ktB8LOstXsEPXSszuyXiOv4DaryOXUiSn7bmRqKcEFlJusO6aZP0= nicola@p1" logSender = "APITesting" userPath = "/api/v1/user" + folderPath = "/api/v1/folder" activeConnectionsPath = "/api/v1/connection" quotaScanPath = "/api/v1/quota_scan" + quotaScanVFolderPath = "/api/v1/folder_quota_scan" versionPath = "/api/v1/version" metricsPath = "/metrics" pprofPath = "/debug/pprof/" webBasePath = "/web" webUsersPath = "/web/users" webUserPath = "/web/user" + webFoldersPath = "/web/folders" + webFolderPath = "/web/folder" webConnectionsPath = "/web/connections" configDir = ".." httpsCert = `-----BEGIN CERTIFICATE----- @@ -387,102 +391,180 @@ func TestAddUserInvalidFsConfig(t *testing.T) { func TestAddUserInvalidVirtualFolders(t *testing.T) { u := getTestUser() u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: filepath.Join(os.TempDir(), "mapped_dir"), + }, VirtualPath: "vdir", - MappedPath: filepath.Join(os.TempDir(), "mapped_dir"), }) _, _, err := httpd.AddUser(u, http.StatusBadRequest) assert.NoError(t, err) u.VirtualFolders = nil u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: filepath.Join(os.TempDir(), "mapped_dir"), + }, VirtualPath: "/", - MappedPath: filepath.Join(os.TempDir(), "mapped_dir"), }) _, _, err = httpd.AddUser(u, http.StatusBadRequest) assert.NoError(t, err) u.VirtualFolders = nil u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: filepath.Join(u.GetHomeDir(), "mapped_dir"), + }, VirtualPath: "/vdir", - MappedPath: filepath.Join(u.GetHomeDir(), "mapped_dir"), }) _, _, err = httpd.AddUser(u, http.StatusBadRequest) assert.NoError(t, err) u.VirtualFolders = nil u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: u.GetHomeDir(), + }, VirtualPath: "/vdir", - MappedPath: u.GetHomeDir(), }) _, _, err = httpd.AddUser(u, http.StatusBadRequest) assert.NoError(t, err) u.VirtualFolders = nil u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: filepath.Join(u.GetHomeDir(), ".."), + }, VirtualPath: "/vdir", - MappedPath: filepath.Join(u.GetHomeDir(), ".."), }) _, _, err = httpd.AddUser(u, http.StatusBadRequest) assert.NoError(t, err) u.VirtualFolders = nil u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: filepath.Join(os.TempDir(), "mapped_dir"), + }, VirtualPath: "/vdir", - MappedPath: filepath.Join(os.TempDir(), "mapped_dir"), }) u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: filepath.Join(os.TempDir(), "mapped_dir1"), + }, VirtualPath: "/vdir", - MappedPath: filepath.Join(os.TempDir(), "mapped_dir1"), }) _, _, err = httpd.AddUser(u, http.StatusBadRequest) assert.NoError(t, err) u.VirtualFolders = nil u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: filepath.Join(os.TempDir(), "mapped_dir"), + }, VirtualPath: "/vdir1", - MappedPath: filepath.Join(os.TempDir(), "mapped_dir"), }) u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: filepath.Join(os.TempDir(), "mapped_dir"), + }, VirtualPath: "/vdir2", - MappedPath: filepath.Join(os.TempDir(), "mapped_dir"), }) _, _, err = httpd.AddUser(u, http.StatusBadRequest) assert.NoError(t, err) u.VirtualFolders = nil u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: filepath.Join(os.TempDir(), "mapped_dir", "subdir"), + }, VirtualPath: "/vdir1", - MappedPath: filepath.Join(os.TempDir(), "mapped_dir", "subdir"), }) u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: filepath.Join(os.TempDir(), "mapped_dir"), + }, VirtualPath: "/vdir2", - MappedPath: filepath.Join(os.TempDir(), "mapped_dir"), }) _, _, err = httpd.AddUser(u, http.StatusBadRequest) assert.NoError(t, err) u.VirtualFolders = nil u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: filepath.Join(os.TempDir(), "mapped_dir"), + }, VirtualPath: "/vdir1", - MappedPath: filepath.Join(os.TempDir(), "mapped_dir"), }) u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: filepath.Join(os.TempDir(), "mapped_dir", "subdir"), + }, VirtualPath: "/vdir2", - MappedPath: filepath.Join(os.TempDir(), "mapped_dir", "subdir"), }) _, _, err = httpd.AddUser(u, http.StatusBadRequest) assert.NoError(t, err) u.VirtualFolders = nil u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: filepath.Join(os.TempDir(), "mapped_dir1"), + }, VirtualPath: "/vdir1/subdir", - MappedPath: filepath.Join(os.TempDir(), "mapped_dir1"), }) u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: filepath.Join(os.TempDir(), "mapped_dir2"), + }, VirtualPath: "/vdir1/../vdir1", - MappedPath: filepath.Join(os.TempDir(), "mapped_dir2"), }) _, _, err = httpd.AddUser(u, http.StatusBadRequest) assert.NoError(t, err) u.VirtualFolders = nil u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: filepath.Join(os.TempDir(), "mapped_dir1"), + }, VirtualPath: "/vdir1/", - MappedPath: filepath.Join(os.TempDir(), "mapped_dir1"), }) u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: filepath.Join(os.TempDir(), "mapped_dir2"), + }, VirtualPath: "/vdir1/subdir", - MappedPath: filepath.Join(os.TempDir(), "mapped_dir2"), + }) + _, _, err = httpd.AddUser(u, http.StatusBadRequest) + assert.NoError(t, err) + u.VirtualFolders = nil + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: filepath.Join(os.TempDir(), "mapped_dir1"), + }, + VirtualPath: "/vdir1/", + QuotaSize: -1, + QuotaFiles: 1, + }) + _, _, err = httpd.AddUser(u, http.StatusBadRequest) + assert.NoError(t, err) + u.VirtualFolders = nil + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: filepath.Join(os.TempDir(), "mapped_dir1"), + }, + VirtualPath: "/vdir1/", + QuotaSize: 1, + QuotaFiles: -1, + }) + _, _, err = httpd.AddUser(u, http.StatusBadRequest) + assert.NoError(t, err) + u.VirtualFolders = nil + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: filepath.Join(os.TempDir(), "mapped_dir1"), + }, + VirtualPath: "/vdir1/", + QuotaSize: -2, + QuotaFiles: 0, + }) + _, _, err = httpd.AddUser(u, http.StatusBadRequest) + assert.NoError(t, err) + u.VirtualFolders = nil + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: filepath.Join(os.TempDir(), "mapped_dir1"), + }, + VirtualPath: "/vdir1/", + QuotaSize: 0, + QuotaFiles: -2, }) _, _, err = httpd.AddUser(u, http.StatusBadRequest) assert.NoError(t, err) @@ -530,25 +612,204 @@ func TestUpdateUser(t *testing.T) { user.UploadBandwidth = 1024 user.DownloadBandwidth = 512 user.VirtualFolders = nil + mappedPath1 := filepath.Join(os.TempDir(), "mapped_dir1") + mappedPath2 := filepath.Join(os.TempDir(), "mapped_dir2") user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath1, + }, 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"), - ExcludeFromQuota: true, + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath2, + }, + VirtualPath: "/vdir12/subdir", + QuotaSize: 123, + QuotaFiles: 2, }) user, _, err = httpd.UpdateUser(user, http.StatusOK) assert.NoError(t, err) user.Permissions["/subdir"] = []string{} user, _, err = httpd.UpdateUser(user, http.StatusOK) assert.NoError(t, err) - if len(user.Permissions["/subdir"]) > 0 { - t.Errorf("unexpected subdir permissions, must be empty") + assert.Len(t, user.Permissions["/subdir"], 0) + assert.Len(t, user.VirtualFolders, 2) + for _, folder := range user.VirtualFolders { + assert.Greater(t, folder.ID, int64(0)) + if folder.VirtualPath == "/vdir12/subdir" { + assert.Equal(t, int64(123), folder.QuotaSize) + assert.Equal(t, 2, folder.QuotaFiles) + } } + folder, _, err := httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Len(t, f.Users, 1) + assert.Contains(t, f.Users, user.Username) + } + _, err = httpd.RemoveUser(user, http.StatusOK) assert.NoError(t, err) + // removing the user must remove folder mapping + folder, _, err = httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Len(t, f.Users, 0) + _, err = httpd.RemoveFolder(f, http.StatusOK) + assert.NoError(t, err) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Len(t, f.Users, 0) + _, err = httpd.RemoveFolder(f, http.StatusOK) + assert.NoError(t, err) + } +} + +func TestUserFolderMapping(t *testing.T) { + mappedPath1 := filepath.Join(os.TempDir(), "mapped_dir1") + mappedPath2 := filepath.Join(os.TempDir(), "mapped_dir2") + u1 := getTestUser() + u1.VirtualFolders = append(u1.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath1, + }, + VirtualPath: "/vdir", + QuotaSize: -1, + QuotaFiles: -1, + }) + user1, _, err := httpd.AddUser(u1, http.StatusOK) + assert.NoError(t, err) + // virtual folder must be auto created + folders, _, err := httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folders, 1) { + folder := folders[0] + assert.Len(t, folder.Users, 1) + assert.Contains(t, folder.Users, user1.Username) + } + u2 := getTestUser() + u2.Username = defaultUsername + "2" + u2.VirtualFolders = append(u2.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath1, + }, + VirtualPath: "/vdir1", + QuotaSize: 0, + QuotaFiles: 0, + }) + u2.VirtualFolders = append(u2.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath2, + }, + VirtualPath: "/vdir2", + QuotaSize: -1, + QuotaFiles: -1, + }) + user2, _, err := httpd.AddUser(u2, http.StatusOK) + assert.NoError(t, err) + folders, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folders, 1) { + folder := folders[0] + assert.Len(t, folder.Users, 1) + assert.Contains(t, folder.Users, user2.Username) + } + folders, _, err = httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folders, 1) { + folder := folders[0] + assert.Len(t, folder.Users, 2) + assert.Contains(t, folder.Users, user1.Username) + assert.Contains(t, folder.Users, user2.Username) + } + // now update user2 removing mappedPath1 + user2.VirtualFolders = nil + user2.VirtualFolders = append(user2.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath2, + }, + VirtualPath: "/vdir", + QuotaSize: 0, + QuotaFiles: 0, + }) + user2, _, err = httpd.UpdateUser(user2, http.StatusOK) + assert.NoError(t, err) + folders, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folders, 1) { + folder := folders[0] + assert.Len(t, folder.Users, 1) + assert.Contains(t, folder.Users, user2.Username) + } + folders, _, err = httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folders, 1) { + folder := folders[0] + assert.Len(t, folder.Users, 1) + assert.Contains(t, folder.Users, user1.Username) + } + // add mappedPath1 again to user2 + user2.VirtualFolders = append(user2.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath1, + }, + VirtualPath: "/vdir1", + }) + user2, _, err = httpd.UpdateUser(user2, http.StatusOK) + assert.NoError(t, err) + folders, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folders, 1) { + folder := folders[0] + assert.Len(t, folder.Users, 1) + assert.Contains(t, folder.Users, user2.Username) + } + // removing virtual folders should clear relations on both side + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath2}, http.StatusOK) + assert.NoError(t, err) + user2, _, err = httpd.GetUserByID(user2.ID, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, user2.VirtualFolders, 1) { + folder := user2.VirtualFolders[0] + assert.Equal(t, mappedPath1, folder.MappedPath) + } + user1, _, err = httpd.GetUserByID(user1.ID, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, user2.VirtualFolders, 1) { + folder := user2.VirtualFolders[0] + assert.Equal(t, mappedPath1, folder.MappedPath) + } + + folders, _, err = httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folders, 1) { + folder := folders[0] + assert.Len(t, folder.Users, 2) + } + // removing a user should clear virtual folder mapping + _, err = httpd.RemoveUser(user1, http.StatusOK) + assert.NoError(t, err) + folders, _, err = httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folders, 1) { + folder := folders[0] + assert.Len(t, folder.Users, 1) + assert.Contains(t, folder.Users, user2.Username) + } + // removing a folder should clear mapping on the user side too + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath1}, http.StatusOK) + assert.NoError(t, err) + user2, _, err = httpd.GetUserByID(user2.ID, http.StatusOK) + assert.NoError(t, err) + assert.Len(t, user2.VirtualFolders, 0) + _, err = httpd.RemoveUser(user2, http.StatusOK) + assert.NoError(t, err) } func TestUserS3Config(t *testing.T) { @@ -740,7 +1001,11 @@ func TestGetQuotaScans(t *testing.T) { _, _, err := httpd.GetQuotaScans(http.StatusOK) assert.NoError(t, err) _, _, err = httpd.GetQuotaScans(http.StatusInternalServerError) - assert.Error(t, err, "quota scan request must succeed, we requested to check a wrong status code") + assert.Error(t, err) + _, _, err = httpd.GetFoldersQuotaScans(http.StatusOK) + assert.NoError(t, err) + _, _, err = httpd.GetFoldersQuotaScans(http.StatusInternalServerError) + assert.Error(t, err) } func TestStartQuotaScan(t *testing.T) { @@ -750,6 +1015,15 @@ func TestStartQuotaScan(t *testing.T) { assert.NoError(t, err) _, err = httpd.RemoveUser(user, http.StatusOK) assert.NoError(t, err) + folder := vfs.BaseVirtualFolder{ + MappedPath: filepath.Join(os.TempDir(), "folder"), + } + _, _, err = httpd.AddFolder(folder, http.StatusOK) + assert.NoError(t, err) + _, err = httpd.StartFolderQuotaScan(folder, http.StatusCreated) + assert.NoError(t, err) + _, err = httpd.RemoveFolder(folder, http.StatusOK) + assert.NoError(t, err) } func TestGetVersion(t *testing.T) { @@ -791,8 +1065,10 @@ func TestUserBaseDir(t *testing.T) { httpd.SetDataProvider(dataprovider.GetProvider()) u := getTestUser() u.HomeDir = "" - user, _, err := httpd.AddUser(getTestUser(), http.StatusOK) - assert.NoError(t, err) + user, _, err := httpd.AddUser(u, http.StatusOK) + if assert.Error(t, err) { + assert.EqualError(t, err, "HomeDir mismatch") + } assert.Equal(t, filepath.Join(providerConf.UsersBaseDir, u.Username), user.HomeDir) _, err = httpd.RemoveUser(user, http.StatusOK) assert.NoError(t, err) @@ -811,6 +1087,50 @@ func TestUserBaseDir(t *testing.T) { sftpd.SetDataProvider(dataprovider.GetProvider()) } +func TestQuotaTrackingDisabled(t *testing.T) { + dataProvider := dataprovider.GetProvider() + err := dataprovider.Close(dataProvider) + assert.NoError(t, err) + err = config.LoadConfig(configDir, "") + assert.NoError(t, err) + providerConf := config.GetProviderConf() + providerConf.TrackQuota = 0 + err = dataprovider.Initialize(providerConf, configDir) + assert.NoError(t, err) + httpd.SetDataProvider(dataprovider.GetProvider()) + // user quota scan must fail + user, _, err := httpd.AddUser(getTestUser(), http.StatusOK) + assert.NoError(t, err) + _, err = httpd.StartQuotaScan(user, http.StatusForbidden) + assert.NoError(t, err) + _, err = httpd.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + // folder quota scan must fail + folder := vfs.BaseVirtualFolder{ + MappedPath: os.TempDir(), + } + folder, _, err = httpd.AddFolder(folder, http.StatusOK) + assert.NoError(t, err) + _, err = httpd.StartFolderQuotaScan(folder, http.StatusForbidden) + assert.NoError(t, err) + _, err = httpd.RemoveFolder(folder, http.StatusOK) + assert.NoError(t, err) + + dataProvider = dataprovider.GetProvider() + err = dataprovider.Close(dataProvider) + assert.NoError(t, err) + err = config.LoadConfig(configDir, "") + assert.NoError(t, err) + providerConf = config.GetProviderConf() + providerConf.CredentialsPath = credentialsPath + err = os.RemoveAll(credentialsPath) + assert.NoError(t, err) + err = dataprovider.Initialize(providerConf, configDir) + assert.NoError(t, err) + httpd.SetDataProvider(dataprovider.GetProvider()) + sftpd.SetDataProvider(dataprovider.GetProvider()) +} + func TestProviderErrors(t *testing.T) { dataProvider := dataprovider.GetProvider() err := dataprovider.Close(dataProvider) @@ -823,20 +1143,32 @@ func TestProviderErrors(t *testing.T) { assert.NoError(t, err) _, err = httpd.RemoveUser(dataprovider.User{}, http.StatusInternalServerError) assert.NoError(t, err) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: "apath"}, http.StatusInternalServerError) + assert.NoError(t, err) _, _, err = httpd.GetProviderStatus(http.StatusInternalServerError) assert.NoError(t, err) _, _, err = httpd.Dumpdata("backup.json", "", http.StatusInternalServerError) assert.NoError(t, err) + _, _, err = httpd.GetFolders(0, 0, "", http.StatusInternalServerError) + assert.NoError(t, err) user := getTestUser() user.ID = 1 backupData := dataprovider.BackupData{} backupData.Users = append(backupData.Users, user) - backupContent, _ := json.Marshal(backupData) + backupContent, err := json.Marshal(backupData) + assert.NoError(t, err) backupFilePath := filepath.Join(backupsPath, "backup.json") err = ioutil.WriteFile(backupFilePath, backupContent, 0666) assert.NoError(t, err) _, _, err = httpd.Loaddata(backupFilePath, "", "", http.StatusInternalServerError) assert.NoError(t, err) + backupData.Folders = append(backupData.Folders, vfs.BaseVirtualFolder{MappedPath: os.TempDir()}) + backupContent, err = json.Marshal(backupData) + assert.NoError(t, err) + err = ioutil.WriteFile(backupFilePath, backupContent, 0666) + assert.NoError(t, err) + _, _, err = httpd.Loaddata(backupFilePath, "", "", http.StatusInternalServerError) + assert.NoError(t, err) err = os.Remove(backupFilePath) assert.NoError(t, err) err = config.LoadConfig(configDir, "") @@ -851,6 +1183,66 @@ func TestProviderErrors(t *testing.T) { sftpd.SetDataProvider(dataprovider.GetProvider()) } +func TestFolders(t *testing.T) { + folder := vfs.BaseVirtualFolder{ + MappedPath: "relative path", + } + _, _, err := httpd.AddFolder(folder, http.StatusBadRequest) + assert.NoError(t, err) + folder.MappedPath = os.TempDir() + folder1, _, err := httpd.AddFolder(folder, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, folder.MappedPath, folder1.MappedPath) + assert.Equal(t, 0, folder1.UsedQuotaFiles) + assert.Equal(t, int64(0), folder1.UsedQuotaSize) + assert.Equal(t, int64(0), folder1.LastQuotaUpdate) + // adding a duplicate folder must fail + _, _, err = httpd.AddFolder(folder, http.StatusOK) + assert.Error(t, err) + folder.MappedPath = filepath.Join(os.TempDir(), "vfolder") + folder.UsedQuotaFiles = 1 + folder.UsedQuotaSize = 345 + folder.LastQuotaUpdate = 10 + folder2, _, err := httpd.AddFolder(folder, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 1, folder2.UsedQuotaFiles) + assert.Equal(t, int64(345), folder2.UsedQuotaSize) + assert.Equal(t, int64(10), folder2.LastQuotaUpdate) + folders, _, err := httpd.GetFolders(0, 0, "", http.StatusOK) + assert.NoError(t, err) + numResults := len(folders) + assert.GreaterOrEqual(t, numResults, 2) + folders, _, err = httpd.GetFolders(0, 1, "", http.StatusOK) + assert.NoError(t, err) + assert.Len(t, folders, numResults-1) + folders, _, err = httpd.GetFolders(1, 0, "", http.StatusOK) + assert.NoError(t, err) + assert.Len(t, folders, 1) + folders, _, err = httpd.GetFolders(0, 0, folder1.MappedPath, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folders, 1) { + f := folders[0] + assert.Equal(t, folder1.MappedPath, f.MappedPath) + } + folders, _, err = httpd.GetFolders(0, 0, folder2.MappedPath, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folders, 1) { + f := folders[0] + assert.Equal(t, folder2.MappedPath, f.MappedPath) + } + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{}, http.StatusBadRequest) + assert.NoError(t, err) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{ + MappedPath: "invalid", + }, http.StatusNotFound) + assert.NoError(t, err) + + _, err = httpd.RemoveFolder(folder1, http.StatusOK) + assert.NoError(t, err) + _, err = httpd.RemoveFolder(folder2, http.StatusOK) + assert.NoError(t, err) +} + func TestDumpdata(t *testing.T) { dataProvider := dataprovider.GetProvider() err := dataprovider.Close(dataProvider) @@ -901,14 +1293,28 @@ func TestDumpdata(t *testing.T) { } func TestLoaddata(t *testing.T) { + mappedPath := filepath.Join(os.TempDir(), "restored_folder") user := getTestUser() user.ID = 1 user.Username = "test_user_restore" backupData := dataprovider.BackupData{} backupData.Users = append(backupData.Users, user) - backupContent, _ := json.Marshal(backupData) + backupData.Folders = []vfs.BaseVirtualFolder{ + { + MappedPath: mappedPath, + UsedQuotaSize: 123, + UsedQuotaFiles: 456, + LastQuotaUpdate: 789, + Users: []string{"user"}, + }, + { + MappedPath: mappedPath, + }, + } + backupContent, err := json.Marshal(backupData) + assert.NoError(t, err) backupFilePath := filepath.Join(backupsPath, "backup.json") - err := ioutil.WriteFile(backupFilePath, backupContent, 0666) + err = ioutil.WriteFile(backupFilePath, backupContent, 0666) assert.NoError(t, err) _, _, err = httpd.Loaddata(backupFilePath, "a", "", http.StatusBadRequest) assert.NoError(t, err) @@ -926,7 +1332,7 @@ func TestLoaddata(t *testing.T) { err = os.Chmod(backupFilePath, 0644) assert.NoError(t, err) } - // add user from backup + // add user and folder from backup _, _, err = httpd.Loaddata(backupFilePath, "1", "", http.StatusOK) assert.NoError(t, err) // update user from backup @@ -934,11 +1340,23 @@ func TestLoaddata(t *testing.T) { assert.NoError(t, err) users, _, err := httpd.GetUsers(1, 0, user.Username, http.StatusOK) assert.NoError(t, err) - if assert.Equal(t, 1, len(users)) { + if assert.Len(t, users, 1) { user = users[0] _, err = httpd.RemoveUser(user, http.StatusOK) assert.NoError(t, err) } + folders, _, err := httpd.GetFolders(1, 0, mappedPath, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folders, 1) { + folder := folders[0] + assert.Equal(t, mappedPath, folder.MappedPath) + assert.Equal(t, int64(123), folder.UsedQuotaSize) + assert.Equal(t, 456, folder.UsedQuotaFiles) + assert.Equal(t, int64(789), folder.LastQuotaUpdate) + assert.Len(t, folder.Users, 0) + _, err = httpd.RemoveFolder(folder, http.StatusOK) + assert.NoError(t, err) + } err = os.Remove(backupFilePath) assert.NoError(t, err) err = createTestFile(backupFilePath, 10485761) @@ -1077,6 +1495,12 @@ func TestAddUserInvalidPermsMock(t *testing.T) { checkResponseCode(t, http.StatusBadRequest, rr.Code) } +func TestAddFolderInvalidJsonMock(t *testing.T) { + req, _ := http.NewRequest(http.MethodPost, folderPath, bytes.NewBuffer([]byte("invalid json"))) + rr := executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr.Code) +} + func TestAddUserInvalidJsonMock(t *testing.T) { req, _ := http.NewRequest(http.MethodPost, userPath, bytes.NewBuffer([]byte("invalid json"))) rr := executeRequest(req) @@ -1301,21 +1725,19 @@ func TestStartQuotaScanMock(t *testing.T) { rr = executeRequest(req) checkResponseCode(t, http.StatusCreated, rr.Code) - req, _ = http.NewRequest(http.MethodGet, quotaScanPath, nil) - rr = executeRequest(req) - checkResponseCode(t, http.StatusOK, rr.Code) var scans []sftpd.ActiveQuotaScan - err = render.DecodeJSON(rr.Body, &scans) - assert.NoError(t, err) - for len(scans) > 0 { + for { req, _ = http.NewRequest(http.MethodGet, quotaScanPath, nil) rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr.Code) err = render.DecodeJSON(rr.Body, &scans) - if err != nil { + if !assert.NoError(t, err) { assert.Fail(t, err.Error(), "Error get active scans") break } + if len(scans) == 0 { + break + } time.Sleep(100 * time.Millisecond) } _, err = os.Stat(user.HomeDir) @@ -1327,20 +1749,19 @@ func TestStartQuotaScanMock(t *testing.T) { rr = executeRequest(req) checkResponseCode(t, http.StatusCreated, rr.Code) - req, _ = http.NewRequest(http.MethodGet, quotaScanPath, nil) - rr = executeRequest(req) - checkResponseCode(t, http.StatusOK, rr.Code) - err = render.DecodeJSON(rr.Body, &scans) - assert.NoError(t, err) - for len(scans) > 0 { + scans = nil + for { req, _ = http.NewRequest(http.MethodGet, quotaScanPath, nil) rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr.Code) err = render.DecodeJSON(rr.Body, &scans) - if err != nil { + if !assert.NoError(t, err) { assert.Fail(t, err.Error(), "Error get active scans") break } + if len(scans) == 0 { + break + } time.Sleep(100 * time.Millisecond) } @@ -1351,7 +1772,66 @@ func TestStartQuotaScanMock(t *testing.T) { assert.NoError(t, err) } -func TestStartQuotaScanBadUserMock(t *testing.T) { +func TestStartFolderQuotaScanMock(t *testing.T) { + mappedPath := filepath.Join(os.TempDir(), "vfolder") + folder := vfs.BaseVirtualFolder{ + MappedPath: mappedPath, + } + folderAsJSON, err := json.Marshal(folder) + assert.NoError(t, err) + req, _ := http.NewRequest(http.MethodPost, folderPath, bytes.NewBuffer(folderAsJSON)) + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + _, err = os.Stat(mappedPath) + if err == nil { + err = os.Remove(mappedPath) + assert.NoError(t, err) + } + // simulate a duplicate quota scan + sftpd.AddVFolderQuotaScan(mappedPath) + req, _ = http.NewRequest(http.MethodPost, quotaScanVFolderPath, bytes.NewBuffer(folderAsJSON)) + rr = executeRequest(req) + checkResponseCode(t, http.StatusConflict, rr.Code) + err = sftpd.RemoveVFolderQuotaScan(mappedPath) + assert.NoError(t, err) + // and now a real quota scan + _, err = os.Stat(mappedPath) + if err != nil && os.IsNotExist(err) { + err = os.MkdirAll(mappedPath, 0777) + assert.NoError(t, err) + } + req, _ = http.NewRequest(http.MethodPost, quotaScanVFolderPath, bytes.NewBuffer(folderAsJSON)) + rr = executeRequest(req) + checkResponseCode(t, http.StatusCreated, rr.Code) + var scans []sftpd.ActiveVirtualFolderQuotaScan + for { + req, _ = http.NewRequest(http.MethodGet, quotaScanVFolderPath, nil) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + err = render.DecodeJSON(rr.Body, &scans) + if !assert.NoError(t, err) { + assert.Fail(t, err.Error(), "Error get active folders scans") + break + } + if len(scans) == 0 { + break + } + time.Sleep(100 * time.Millisecond) + } + // cleanup + url, err := url.Parse(folderPath) + assert.NoError(t, err) + q := url.Query() + q.Add("folder_path", mappedPath) + url.RawQuery = q.Encode() + req, _ = http.NewRequest(http.MethodDelete, url.String(), nil) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + err = os.RemoveAll(folderPath) + assert.NoError(t, err) +} + +func TestStartQuotaScanNonExistentUserMock(t *testing.T) { user := getTestUser() userAsJSON := getUserAsJSON(t, user) req, _ := http.NewRequest(http.MethodPost, quotaScanPath, bytes.NewBuffer(userAsJSON)) @@ -1359,12 +1839,74 @@ func TestStartQuotaScanBadUserMock(t *testing.T) { checkResponseCode(t, http.StatusNotFound, rr.Code) } -func TestStartQuotaScanNonExistentUserMock(t *testing.T) { +func TestStartQuotaScanBadUserMock(t *testing.T) { req, _ := http.NewRequest(http.MethodPost, quotaScanPath, bytes.NewBuffer([]byte("invalid json"))) rr := executeRequest(req) checkResponseCode(t, http.StatusBadRequest, rr.Code) } +func TestStartQuotaScanBadFolderMock(t *testing.T) { + req, _ := http.NewRequest(http.MethodPost, quotaScanVFolderPath, bytes.NewBuffer([]byte("invalid json"))) + rr := executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr.Code) +} + +func TestStartQuotaScanNonExistentFolderMock(t *testing.T) { + folder := vfs.BaseVirtualFolder{ + MappedPath: os.TempDir(), + } + folderAsJSON, err := json.Marshal(folder) + assert.NoError(t, err) + req, _ := http.NewRequest(http.MethodPost, quotaScanVFolderPath, bytes.NewBuffer(folderAsJSON)) + rr := executeRequest(req) + checkResponseCode(t, http.StatusNotFound, rr.Code) +} + +func TestGetFoldersMock(t *testing.T) { + mappedPath := filepath.Join(os.TempDir(), "vfolder") + folder := vfs.BaseVirtualFolder{ + MappedPath: mappedPath, + } + folderAsJSON, err := json.Marshal(folder) + assert.NoError(t, err) + req, _ := http.NewRequest(http.MethodPost, folderPath, bytes.NewBuffer(folderAsJSON)) + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + err = render.DecodeJSON(rr.Body, &folder) + assert.NoError(t, err) + + var folders []vfs.BaseVirtualFolder + url, err := url.Parse(folderPath + "?limit=510&offset=0&order=DESC") + assert.NoError(t, err) + q := url.Query() + q.Add("folder_path", mappedPath) + url.RawQuery = q.Encode() + req, _ = http.NewRequest(http.MethodGet, url.String(), nil) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + err = render.DecodeJSON(rr.Body, &folders) + assert.NoError(t, err) + assert.Len(t, folders, 1) + req, _ = http.NewRequest(http.MethodGet, folderPath+"?limit=a&offset=0&order=ASC", nil) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr.Code) + req, _ = http.NewRequest(http.MethodGet, folderPath+"?limit=1&offset=a&order=ASC", nil) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr.Code) + req, _ = http.NewRequest(http.MethodGet, folderPath+"?limit=1&offset=0&order=ASCa", nil) + rr = executeRequest(req) + checkResponseCode(t, http.StatusBadRequest, rr.Code) + + url, err = url.Parse(folderPath) + assert.NoError(t, err) + q = url.Query() + q.Add("folder_path", mappedPath) + url.RawQuery = q.Encode() + req, _ = http.NewRequest(http.MethodDelete, url.String(), nil) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) +} + func TestGetVersionMock(t *testing.T) { req, _ := http.NewRequest(http.MethodGet, versionPath, nil) rr := executeRequest(req) @@ -1496,7 +2038,7 @@ func TestWebUserAddMock(t *testing.T) { form.Set("expiration_date", "") form.Set("permissions", "*") form.Set("sub_dirs_permissions", " /subdir::list ,download ") - form.Set("virtual_folders", fmt.Sprintf(" /vdir:: %v ::1", mappedDir)) + form.Set("virtual_folders", fmt.Sprintf(" /vdir:: %v :: 2 :: 1024", mappedDir)) form.Set("allowed_extensions", "/dir1::.jpg,.png") form.Set("denied_extensions", "/dir1::.zip") b, contentType, _ := getMultipartFormData(form, "", "") @@ -1616,18 +2158,26 @@ func TestWebUserAddMock(t *testing.T) { } else { assert.Fail(t, "user permissions must contain /somedir", "actual: %v", newUser.Permissions) } - vfolderFound := false + assert.Len(t, newUser.VirtualFolders, 1) for _, v := range newUser.VirtualFolders { - if v.VirtualPath == "/vdir" && v.MappedPath == mappedDir && v.ExcludeFromQuota == true { - vfolderFound = true - } + assert.Equal(t, v.VirtualPath, "/vdir") + assert.Equal(t, v.MappedPath, mappedDir) + assert.Equal(t, v.QuotaFiles, 2) + assert.Equal(t, v.QuotaSize, int64(1024)) } - assert.True(t, vfolderFound) extFilters := newUser.Filters.FileExtensions[0] assert.True(t, utils.IsStringInSlice(".zip", extFilters.DeniedExtensions)) req, _ = http.NewRequest(http.MethodDelete, userPath+"/"+strconv.FormatInt(newUser.ID, 10), nil) rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr.Code) + url, err := url.Parse(folderPath) + assert.NoError(t, err) + q := url.Query() + q.Add("folder_path", mappedDir) + url.RawQuery = q.Encode() + req, _ = http.NewRequest(http.MethodDelete, url.String(), nil) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) } func TestWebUserUpdateMock(t *testing.T) { @@ -1878,12 +2428,113 @@ func TestWebUserGCSMock(t *testing.T) { assert.NoError(t, err) } +func TestAddWebFoldersMock(t *testing.T) { + mappedPath := os.TempDir() + form := make(url.Values) + form.Set("mapped_path", mappedPath) + req, err := http.NewRequest(http.MethodPost, webFolderPath, strings.NewReader(form.Encode())) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := executeRequest(req) + checkResponseCode(t, http.StatusSeeOther, rr.Code) + // adding the same folder will fail since the path must be unique + req, err = http.NewRequest(http.MethodPost, webFolderPath, strings.NewReader(form.Encode())) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + // invalid form + req, err = http.NewRequest(http.MethodPost, webFolderPath, strings.NewReader(form.Encode())) + assert.NoError(t, err) + req.Header.Set("Content-Type", "text/plain; boundary=") + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + + // now render the add folder page + req, err = http.NewRequest(http.MethodGet, webFolderPath, nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + + var folders []vfs.BaseVirtualFolder + url, err := url.Parse(folderPath) + assert.NoError(t, err) + q := url.Query() + q.Add("folder_path", mappedPath) + url.RawQuery = q.Encode() + req, _ = http.NewRequest(http.MethodGet, url.String(), nil) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + err = render.DecodeJSON(rr.Body, &folders) + assert.NoError(t, err) + if assert.Len(t, folders, 1) { + folder := folders[0] + assert.Equal(t, mappedPath, folder.MappedPath) + } + // cleanup + url, err = url.Parse(folderPath) + assert.NoError(t, err) + q = url.Query() + q.Add("folder_path", mappedPath) + url.RawQuery = q.Encode() + req, _ = http.NewRequest(http.MethodDelete, url.String(), nil) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) +} + +func TestWebFoldersMock(t *testing.T) { + mappedPath1 := filepath.Join(os.TempDir(), "vfolder1") + mappedPath2 := filepath.Join(os.TempDir(), "vfolder2") + folders := []vfs.BaseVirtualFolder{ + { + MappedPath: mappedPath1, + }, + { + MappedPath: mappedPath2, + }, + } + for _, folder := range folders { + folderAsJSON, err := json.Marshal(folder) + assert.NoError(t, err) + req, _ := http.NewRequest(http.MethodPost, folderPath, bytes.NewBuffer(folderAsJSON)) + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + } + + req, err := http.NewRequest(http.MethodGet, webFoldersPath, nil) + assert.NoError(t, err) + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + req, err = http.NewRequest(http.MethodGet, webFoldersPath+"?qlimit=a", nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + req, err = http.NewRequest(http.MethodGet, webFoldersPath+"?qlimit=1", nil) + assert.NoError(t, err) + rr = executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + + for _, folder := range folders { + url, err := url.Parse(folderPath) + assert.NoError(t, err) + q := url.Query() + q.Add("folder_path", folder.MappedPath) + url.RawQuery = q.Encode() + req, _ := http.NewRequest(http.MethodDelete, url.String(), nil) + rr := executeRequest(req) + checkResponseCode(t, http.StatusOK, rr.Code) + } +} + func TestProviderClosedMock(t *testing.T) { dataProvider := dataprovider.GetProvider() dataprovider.Close(dataProvider) - req, _ := http.NewRequest(http.MethodGet, webUsersPath, nil) + req, _ := http.NewRequest(http.MethodGet, webFoldersPath, nil) rr := executeRequest(req) checkResponseCode(t, http.StatusInternalServerError, rr.Code) + req, _ = http.NewRequest(http.MethodGet, webUsersPath, nil) + rr = executeRequest(req) + checkResponseCode(t, http.StatusInternalServerError, rr.Code) req, _ = http.NewRequest(http.MethodGet, webUserPath+"/0", nil) rr = executeRequest(req) checkResponseCode(t, http.StatusInternalServerError, rr.Code) diff --git a/httpd/internal_test.go b/httpd/internal_test.go index 0c87f8ba..9b428037 100644 --- a/httpd/internal_test.go +++ b/httpd/internal_test.go @@ -45,6 +45,43 @@ func TestCheckResponse(t *testing.T) { assert.NoError(t, err) } +func TestCheckFolder(t *testing.T) { + expected := &vfs.BaseVirtualFolder{} + actual := &vfs.BaseVirtualFolder{} + err := checkFolder(expected, actual) + assert.Error(t, err) + expected.ID = 1 + actual.ID = 2 + err = checkFolder(expected, actual) + assert.Error(t, err) + expected.ID = 2 + actual.ID = 2 + expected.MappedPath = "path" + err = checkFolder(expected, actual) + assert.Error(t, err) + expected.MappedPath = "" + expected.LastQuotaUpdate = 1 + err = checkFolder(expected, actual) + assert.Error(t, err) + expected.LastQuotaUpdate = 0 + expected.UsedQuotaFiles = 1 + err = checkFolder(expected, actual) + assert.Error(t, err) + expected.UsedQuotaFiles = 0 + expected.UsedQuotaSize = 1 + err = checkFolder(expected, actual) + assert.Error(t, err) + expected.UsedQuotaSize = 0 + expected.Users = append(expected.Users, "user1") + err = checkFolder(expected, actual) + assert.Error(t, err) + actual.Users = append(actual.Users, "user2") + err = checkFolder(expected, actual) + assert.Error(t, err) + expected.Users = nil + actual.Users = nil +} + func TestCheckUser(t *testing.T) { expected := &dataprovider.User{} actual := &dataprovider.User{} @@ -84,14 +121,18 @@ func TestCheckUser(t *testing.T) { assert.Error(t, err) actual.FsConfig.Provider = 0 expected.VirtualFolders = append(expected.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: os.TempDir(), + }, VirtualPath: "/vdir", - MappedPath: os.TempDir(), }) err = checkUser(expected, actual) assert.Error(t, err) actual.VirtualFolders = append(actual.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: os.TempDir(), + }, VirtualPath: "/vdir1", - MappedPath: os.TempDir(), }) err = checkUser(expected, actual) assert.Error(t, err) @@ -321,8 +362,12 @@ func TestApiCallsWithBadURL(t *testing.T) { assert.Error(t, err) _, err = RemoveUser(u, http.StatusNotFound) assert.Error(t, err) + _, err = RemoveFolder(vfs.BaseVirtualFolder{}, http.StatusNotFound) + assert.Error(t, err) _, _, err = GetUsers(1, 0, "", http.StatusBadRequest) assert.Error(t, err) + _, _, err = GetFolders(1, 0, "", http.StatusBadRequest) + assert.Error(t, err) _, err = CloseConnection("non_existent_id", http.StatusNotFound) assert.Error(t, err) _, _, err = Dumpdata("backup.json", "", http.StatusBadRequest) @@ -352,6 +397,19 @@ func TestApiCallToNotListeningServer(t *testing.T) { assert.Error(t, err) _, err = StartQuotaScan(u, http.StatusNotFound) assert.Error(t, err) + folder := vfs.BaseVirtualFolder{ + MappedPath: os.TempDir(), + } + _, err = StartFolderQuotaScan(folder, http.StatusNotFound) + assert.Error(t, err) + _, _, err = AddFolder(folder, http.StatusOK) + assert.Error(t, err) + _, err = RemoveFolder(folder, http.StatusOK) + assert.Error(t, err) + _, _, err = GetFolders(0, 0, "", http.StatusOK) + assert.Error(t, err) + _, _, err = GetFoldersQuotaScans(http.StatusOK) + assert.Error(t, err) _, _, err = GetConnections(http.StatusOK) assert.Error(t, err) _, err = CloseConnection("non_existent_id", http.StatusNotFound) diff --git a/httpd/router.go b/httpd/router.go index 6b2b81b1..1cf999f3 100644 --- a/httpd/router.go +++ b/httpd/router.go @@ -74,11 +74,16 @@ func initializeRouter(staticFilesPath string, profiler bool) { router.Delete(activeConnectionsPath+"/{connectionID}", handleCloseConnection) router.Get(quotaScanPath, getQuotaScans) router.Post(quotaScanPath, startQuotaScan) + router.Get(quotaScanVFolderPath, getVFolderQuotaScans) + router.Post(quotaScanVFolderPath, startVFolderQuotaScan) router.Get(userPath, getUsers) router.Post(userPath, addUser) router.Get(userPath+"/{userID}", getUserByID) router.Put(userPath+"/{userID}", updateUser) router.Delete(userPath+"/{userID}", deleteUser) + router.Get(folderPath, getFolders) + router.Post(folderPath, addFolder) + router.Delete(folderPath, deleteFolderByPath) router.Get(dumpDataPath, dumpData) router.Get(loadDataPath, loadData) router.Get(webUsersPath, handleGetWebUsers) @@ -87,6 +92,9 @@ func initializeRouter(staticFilesPath string, profiler bool) { router.Post(webUserPath, handleWebAddUserPost) router.Post(webUserPath+"/{userID}", handleWebUpdateUserPost) router.Get(webConnectionsPath, handleWebGetConnections) + router.Get(webFoldersPath, handleWebGetFolders) + router.Get(webFolderPath, handleWebAddFolderGet) + router.Post(webFolderPath, handleWebAddFolderPost) }) router.Group(func(router chi.Router) { diff --git a/httpd/schema/openapi.yaml b/httpd/schema/openapi.yaml index eb695799..6b5b3bab 100644 --- a/httpd/schema/openapi.yaml +++ b/httpd/schema/openapi.yaml @@ -2,7 +2,7 @@ openapi: 3.0.1 info: title: SFTPGo description: 'SFTPGo REST API' - version: 1.8.6 + version: 1.9.0 servers: - url: /api/v1 @@ -224,7 +224,7 @@ paths: get: tags: - quota - summary: Get the active quota scans + summary: Get the active quota scans for users home directories operationId: get_quota_scans responses: 200: @@ -268,8 +268,8 @@ paths: post: tags: - quota - summary: start a new quota scan - description: A quota scan update the number of files and their total size for the given user + summary: start a new user quota scan + description: A quota scan update the number of files and their total size for the specified user operationId: start_quota_scan requestBody: required: true @@ -348,6 +348,354 @@ paths: status: 500 message: "" error: "Error description if any" + /folder_quota_scan: + get: + tags: + - quota + summary: Get the active quota scans for folders + operationId: get_folders_quota_scans + responses: + 200: + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref : '#/components/schemas/FolderQuotaScan' + 401: + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + status: 401 + message: "" + error: "Error description if any" + 403: + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + status: 403 + message: "" + error: "Error description if any" + 500: + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + status: 500 + message: "" + error: "Error description if any" + post: + tags: + - quota + summary: start a new folder quota scan + description: A quota scan update the number of files and their total size for the specified folder + operationId: start_folder_quota_scan + requestBody: + required: true + content: + application/json: + schema: + $ref : '#/components/schemas/BaseVirtualFolder' + responses: + 201: + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + status: 201 + message: "Scan started" + error: "" + 400: + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + status: 400 + message: "" + error: "Error description if any" + 401: + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + status: 401 + message: "" + error: "Error description if any" + 403: + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + status: 403 + message: "" + error: "Error description if any" + 404: + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + status: 404 + message: "" + error: "Error description if any" + 409: + description: Another scan is already in progress for this user + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + status: 409 + message: "Another scan is already in progress" + error: "Error description if any" + 500: + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + status: 500 + message: "" + error: "Error description if any" + /folder: + get: + tags: + - folders + summary: Returns an array with one or more folders + operationId: get_folders + 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 folders by path. Default ASC + schema: + type: string + enum: + - ASC + - DESC + example: ASC + - in: query + name: folder_path + required: false + description: Filter by folder path, extact match case sensitive + schema: + type: string + responses: + 200: + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref : '#/components/schemas/BaseVirtualFolder' + 400: + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + status: 400 + message: "" + error: "Error description if any" + 401: + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + status: 401 + message: "" + error: "Error description if any" + 403: + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + status: 403 + message: "" + error: "Error description if any" + 500: + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + status: 500 + message: "" + error: "Error description if any" + post: + tags: + - folders + summary: Adds a new folder + operationId: add_folder + description: a new folder with the specified mapped_path will be added. To update the used quota parameters a quota scan is needed + requestBody: + required: true + content: + application/json: + schema: + $ref : '#/components/schemas/BaseVirtualFolder' + responses: + 200: + description: successful operation + content: + application/json: + schema: + $ref : '#/components/schemas/BaseVirtualFolder' + 400: + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + status: 400 + message: "" + error: "Error description if any" + 401: + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + status: 401 + message: "" + error: "Error description if any" + 403: + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + status: 403 + message: "" + error: "Error description if any" + 500: + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + status: 500 + message: "" + error: "Error description if any" + delete: + tags: + - folders + summary: Delete an existing folder + operationId: delete_folder + parameters: + - name: folder_path + in: query + description: path to the folder to delete + required: true + schema: + type: string + responses: + 200: + description: successful operation + content: + application/json: + schema: + $ref : '#/components/schemas/ApiResponse' + example: + status: 200 + message: "Folder deleted" + error: "" + 400: + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + status: 400 + message: "" + error: "Error description if any" + 401: + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + status: 401 + message: "" + error: "Error description if any" + 403: + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + status: 403 + message: "" + error: "Error description if any" + 404: + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + status: 404 + message: "" + error: "Error description if any" + 500: + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + example: + status: 500 + message: "" + error: "Error description if any" /user: get: tags: @@ -375,7 +723,7 @@ paths: - in: query name: order required: false - description: Ordering users by username + description: Ordering users by username. Default ASC schema: type: string enum: @@ -802,7 +1150,7 @@ paths: tags: - maintenance summary: Restore SFTPGo data from a JSON backup - description: Users will be restored one by one and the restore is stopped if a user cannot be added or updated, so it could happen a partial restore + description: Users and folders will be restored one by one and the restore is stopped if a user/folder cannot be added or updated, so it could happen a partial restore operationId: loaddata parameters: - in: query @@ -821,7 +1169,7 @@ paths: - 2 description: > Quota scan: - * `0` no quota scan is done, the imported user will have used_quota_size and used_quota_file = 0. This is the default + * `0` no quota scan is done, the imported users will have used_quota_size and used_quota_files = 0 or the existing values if they already exists. This is the default * `1` scan quota * `2` scan quota if the user has quota restrictions required: false @@ -1070,20 +1418,52 @@ components: gcsconfig: $ref: '#/components/schemas/GCSConfig' description: Storage filesystem details - VirtualFolder: + BaseVirtualFolder: type: object properties: - virtual_path: - type: string + id: + type: integer + format: int32 + minimum: 1 mapped_path: type: string - exclude_from_quota: - type: boolean + description: absolute filesystem path to use as virtual folder. This field is unique + used_quota_size: + type: integer + format: int64 + used_quota_files: + type: integer + format: int32 + last_quota_update: + type: integer + format: int64 + description: Last quota update as unix timestamp in milliseconds + users: + type: array nullable: true - description: This folder will be excluded from user quota + items: + type: string + description: list of usernames associated with this virtual folder required: - - virtual_path - mapped_path + description: defines the path for the virtual folder and the used quota limits. The same folder can be shared among multiple users and each user can have different quota limits or a different virtual path. + VirtualFolder: + allOf: + - $ref: '#/components/schemas/BaseVirtualFolder' + - type: object + properties: + virtual_path: + type: string + quota_size: + type: integer + format: int64 + description: Quota as size in bytes. 0 menas unlimited, -1 means included in user quota. Please note that quota is updated if files are added/removed via SFTP/SCP otherwise a quota scan is needed + quota_files: + type: integer + format: int32 + description: Quota as number of files. 0 menas unlimited, , -1 means included in user quota. Please note that quota is updated if files are added/removed via SFTP/SCP otherwise a quota scan is needed + required: + - virtual_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 @@ -1103,6 +1483,7 @@ components: * `1` user is enabled username: type: string + description: username is unique expiration_date: type: integer format: int64 @@ -1125,7 +1506,7 @@ components: 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 + description: mapping between virtual SFTP/SCP paths and filesystem paths outside the user home directory. Supported for local filesystem only. If one or more of the specified folders are not inside the dataprovider they will be automatically created. You have to create the folder on the filesystem yourself uid: type: integer format: int32 @@ -1159,7 +1540,7 @@ components: used_quota_size: type: integer format: int64 - used_quota_file: + used_quota_files: type: integer format: int32 last_quota_update: @@ -1251,6 +1632,16 @@ components: type: integer format: int64 description: scan start time as unix timestamp in milliseconds + FolderQuotaScan: + type: object + properties: + mapped_path: + type: string + description: path with an active scan + start_time: + type: integer + format: int64 + description: scan start time as unix timestamp in milliseconds ApiResponse: type: object properties: @@ -1258,7 +1649,7 @@ components: type: integer format: int32 minimum: 200 - maximum: 500 + maximum: 509 example: 200 description: HTTP Status code, for example 200 OK, 400 Bad request and so on message: diff --git a/httpd/web.go b/httpd/web.go index b0acbaae..ed3a7d6b 100644 --- a/httpd/web.go +++ b/httpd/web.go @@ -22,20 +22,23 @@ import ( ) const ( - templateBase = "base.html" - templateUsers = "users.html" - templateUser = "user.html" - templateConnections = "connections.html" - templateMessage = "message.html" - pageUsersTitle = "Users" - pageConnectionsTitle = "Connections" - page400Title = "Bad request" - page404Title = "Not found" - page404Body = "The page you are looking for does not exist." - page500Title = "Internal Server Error" - page500Body = "The server is unable to fulfill your request." - defaultUsersQueryLimit = 500 - webDateTimeFormat = "2006-01-02 15:04:05" // YYYY-MM-DD HH:MM:SS + templateBase = "base.html" + templateUsers = "users.html" + templateUser = "user.html" + templateConnections = "connections.html" + templateFolders = "folders.html" + templateFolder = "folder.html" + templateMessage = "message.html" + pageUsersTitle = "Users" + pageConnectionsTitle = "Connections" + pageFoldersTitle = "Folders" + page400Title = "Bad request" + page404Title = "Not found" + page404Body = "The page you are looking for does not exist." + page500Title = "Internal Server Error" + page500Body = "The server is unable to fulfill your request." + defaultQueryLimit = 500 + webDateTimeFormat = "2006-01-02 15:04:05" // YYYY-MM-DD HH:MM:SS ) var ( @@ -43,17 +46,22 @@ var ( ) type basePage struct { - Title string - CurrentURL string - UsersURL string - UserURL string - APIUserURL string - APIConnectionsURL string - APIQuotaScanURL string - ConnectionsURL string - UsersTitle string - ConnectionsTitle string - Version string + Title string + CurrentURL string + UsersURL string + UserURL string + APIUserURL string + APIConnectionsURL string + APIQuotaScanURL string + ConnectionsURL string + FoldersURL string + FolderURL string + APIFoldersURL string + APIFolderQuotaScanURL string + UsersTitle string + ConnectionsTitle string + FoldersTitle string + Version string } type usersPage struct { @@ -61,6 +69,11 @@ type usersPage struct { Users []dataprovider.User } +type foldersPage struct { + basePage + Folders []vfs.BaseVirtualFolder +} + type connectionsPage struct { basePage Connections []sftpd.ConnectionStatus @@ -77,6 +90,12 @@ type userPage struct { RootDirPerms []string } +type folderPage struct { + basePage + Folder vfs.BaseVirtualFolder + Error string +} + type messagePage struct { basePage Error string @@ -100,31 +119,48 @@ func loadTemplates(templatesPath string) { filepath.Join(templatesPath, templateBase), filepath.Join(templatesPath, templateMessage), } + foldersPath := []string{ + filepath.Join(templatesPath, templateBase), + filepath.Join(templatesPath, templateFolders), + } + folderPath := []string{ + filepath.Join(templatesPath, templateBase), + filepath.Join(templatesPath, templateFolder), + } usersTmpl := utils.LoadTemplate(template.ParseFiles(usersPaths...)) userTmpl := utils.LoadTemplate(template.ParseFiles(userPaths...)) connectionsTmpl := utils.LoadTemplate(template.ParseFiles(connectionsPaths...)) messageTmpl := utils.LoadTemplate(template.ParseFiles(messagePath...)) + foldersTmpl := utils.LoadTemplate(template.ParseFiles(foldersPath...)) + folderTmpl := utils.LoadTemplate(template.ParseFiles(folderPath...)) templates[templateUsers] = usersTmpl templates[templateUser] = userTmpl templates[templateConnections] = connectionsTmpl templates[templateMessage] = messageTmpl + templates[templateFolders] = foldersTmpl + templates[templateFolder] = folderTmpl } func getBasePageData(title, currentURL string) basePage { version := utils.GetAppVersion() return basePage{ - Title: title, - CurrentURL: currentURL, - UsersURL: webUsersPath, - UserURL: webUserPath, - APIUserURL: userPath, - APIConnectionsURL: activeConnectionsPath, - APIQuotaScanURL: quotaScanPath, - ConnectionsURL: webConnectionsPath, - UsersTitle: pageUsersTitle, - ConnectionsTitle: pageConnectionsTitle, - Version: version.GetVersionAsString(), + Title: title, + CurrentURL: currentURL, + UsersURL: webUsersPath, + UserURL: webUserPath, + FoldersURL: webFoldersPath, + FolderURL: webFolderPath, + APIUserURL: userPath, + APIConnectionsURL: activeConnectionsPath, + APIQuotaScanURL: quotaScanPath, + APIFoldersURL: folderPath, + APIFolderQuotaScanURL: quotaScanVFolderPath, + ConnectionsURL: webConnectionsPath, + UsersTitle: pageUsersTitle, + ConnectionsTitle: pageConnectionsTitle, + FoldersTitle: pageFoldersTitle, + Version: version.GetVersionAsString(), } } @@ -190,6 +226,15 @@ func renderUpdateUserPage(w http.ResponseWriter, user dataprovider.User, error s renderTemplate(w, templateUser, data) } +func renderAddFolderPage(w http.ResponseWriter, folder vfs.BaseVirtualFolder, error string) { + data := folderPage{ + basePage: getBasePageData("Add a new folder", webFolderPath), + Error: error, + Folder: folder, + } + renderTemplate(w, templateFolder, data) +} + func getVirtualFoldersFromPostFields(r *http.Request) []vfs.VirtualFolder { var virtualFolders []vfs.VirtualFolder formValue := r.Form.Get("virtual_folders") @@ -198,13 +243,23 @@ func getVirtualFoldersFromPostFields(r *http.Request) []vfs.VirtualFolder { mapping := strings.Split(cleaned, "::") if len(mapping) > 1 { vfolder := vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: strings.TrimSpace(mapping[1]), + }, VirtualPath: strings.TrimSpace(mapping[0]), - MappedPath: strings.TrimSpace(mapping[1]), + QuotaFiles: -1, + QuotaSize: -1, } if len(mapping) > 2 { - excludeFromQuota, err := strconv.Atoi(strings.TrimSpace(mapping[2])) + quotaFiles, err := strconv.Atoi(strings.TrimSpace(mapping[2])) if err == nil { - vfolder.ExcludeFromQuota = (excludeFromQuota > 0) + vfolder.QuotaFiles = quotaFiles + } + } + if len(mapping) > 3 { + quotaSize, err := strconv.ParseInt(strings.TrimSpace(mapping[3]), 10, 64) + if err == nil { + vfolder.QuotaSize = quotaSize } } virtualFolders = append(virtualFolders, vfolder) @@ -453,29 +508,26 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) { } func handleGetWebUsers(w http.ResponseWriter, r *http.Request) { - limit := defaultUsersQueryLimit + 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 = defaultUsersQueryLimit + limit = defaultQueryLimit } } - var users []dataprovider.User - u, err := dataprovider.GetUsers(dataProvider, limit, 0, "ASC", "") - users = append(users, u...) - for len(u) == limit { - u, err = dataprovider.GetUsers(dataProvider, limit, len(users), "ASC", "") - if err == nil && len(u) > 0 { - users = append(users, u...) - } else { + users := make([]dataprovider.User, 0, limit) + for { + u, err := dataprovider.GetUsers(dataProvider, limit, len(users), dataprovider.OrderASC, "") + if err != nil { + renderInternalServerErrorPage(w, err) + return + } + users = append(users, u...) + if len(u) < limit { break } } - if err != nil { - renderInternalServerErrorPage(w, err) - return - } data := usersPage{ basePage: getBasePageData(pageUsersTitle, webUsersPath), Users: users, @@ -558,3 +610,54 @@ func handleWebGetConnections(w http.ResponseWriter, r *http.Request) { } renderTemplate(w, templateConnections, data) } + +func handleWebAddFolderGet(w http.ResponseWriter, r *http.Request) { + renderAddFolderPage(w, vfs.BaseVirtualFolder{}, "") +} + +func handleWebAddFolderPost(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize) + folder := vfs.BaseVirtualFolder{} + err := r.ParseForm() + if err != nil { + renderAddFolderPage(w, folder, err.Error()) + return + } + folder.MappedPath = r.Form.Get("mapped_path") + + err = dataprovider.AddFolder(dataProvider, folder) + if err == nil { + http.Redirect(w, r, webFoldersPath, http.StatusSeeOther) + } else { + renderAddFolderPage(w, folder, err.Error()) + } +} + +func handleWebGetFolders(w http.ResponseWriter, r *http.Request) { + 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 + } + } + folders := make([]vfs.BaseVirtualFolder, 0, limit) + for { + f, err := dataprovider.GetFolders(dataProvider, limit, len(folders), dataprovider.OrderASC, "") + if err != nil { + renderInternalServerErrorPage(w, err) + return + } + folders = append(folders, f...) + if len(f) < limit { + break + } + } + + data := foldersPage{ + basePage: getBasePageData(pageFoldersTitle, webFoldersPath), + Folders: folders, + } + renderTemplate(w, templateFolders, data) +} diff --git a/sftpd/handler.go b/sftpd/handler.go index a6277452..be39306f 100644 --- a/sftpd/handler.go +++ b/sftpd/handler.go @@ -69,25 +69,25 @@ func (c Connection) Fileread(request *sftp.Request) (io.ReaderAt, error) { c.Log(logger.LevelDebug, logSender, "fileread requested for path: %#v", p) transfer := Transfer{ - file: file, - readerAt: r, - writerAt: nil, - cancelFn: cancelFn, - path: p, - start: time.Now(), - bytesSent: 0, - bytesReceived: 0, - user: c.User, - connectionID: c.ID, - transferType: transferDownload, - lastActivity: time.Now(), - isNewFile: false, - protocol: c.protocol, - transferError: nil, - isFinished: false, - minWriteOffset: 0, - isExcludedFromQuota: c.User.IsFileExcludedFromQuota(request.Filepath), - lock: new(sync.Mutex), + file: file, + readerAt: r, + writerAt: nil, + cancelFn: cancelFn, + path: p, + start: time.Now(), + bytesSent: 0, + bytesReceived: 0, + user: c.User, + connectionID: c.ID, + transferType: transferDownload, + lastActivity: time.Now(), + isNewFile: false, + protocol: c.protocol, + transferError: nil, + isFinished: false, + minWriteOffset: 0, + requestPath: request.Filepath, + lock: new(sync.Mutex), } addTransfer(&transfer) return &transfer, nil @@ -112,12 +112,12 @@ func (c Connection) Filewrite(request *sftp.Request) (io.WriterAt, error) { filePath = c.fs.GetAtomicUploadPath(p) } - stat, statErr := c.fs.Stat(p) - if c.fs.IsNotExist(statErr) { + stat, statErr := c.fs.Lstat(p) + if (statErr == nil && stat.Mode()&os.ModeSymlink == os.ModeSymlink) || c.fs.IsNotExist(statErr) { if !c.User.HasPerm(dataprovider.PermUpload, path.Dir(request.Filepath)) { return nil, sftp.ErrSSHFxPermissionDenied } - return c.handleSFTPUploadToNewFile(p, filePath, c.User.IsFileExcludedFromQuota(request.Filepath)) + return c.handleSFTPUploadToNewFile(p, filePath, request.Filepath) } if statErr != nil { @@ -135,8 +135,7 @@ func (c Connection) Filewrite(request *sftp.Request) (io.WriterAt, error) { return nil, sftp.ErrSSHFxPermissionDenied } - return c.handleSFTPUploadToExistingFile(request.Pflags(), p, filePath, stat.Size(), - c.User.IsFileExcludedFromQuota(request.Filepath)) + return c.handleSFTPUploadToExistingFile(request.Pflags(), p, filePath, stat.Size(), request.Filepath) } // Filecmd hander for basic SFTP system calls related to files, but not anything to do with reading @@ -301,29 +300,46 @@ func (c Connection) handleSFTPSetstat(filePath string, request *sftp.Request) er return nil } -func (c Connection) handleSFTPRename(sourcePath string, targetPath string, request *sftp.Request) error { - if c.fs.GetRelativePath(sourcePath) == "/" { - c.Log(logger.LevelWarn, logSender, "renaming root dir is not allowed") +func (c Connection) handleSFTPRename(sourcePath, targetPath string, request *sftp.Request) error { + if !c.isRenamePermitted(sourcePath, request) { 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.HasVirtualFoldersInside(request.Filepath) { + if fi, err := c.fs.Stat(sourcePath); err == nil { + if fi.IsDir() { + c.Log(logger.LevelDebug, logSender, "renaming the folder %#v is not supported: it has virtual folders inside it", + request.Filepath) + return sftp.ErrSSHFxOpUnsupported + } + } } - if !c.User.IsFileAllowed(request.Filepath) || !c.User.IsFileAllowed(request.Target) { - if fi, err := c.fs.Lstat(sourcePath); err == nil && fi.Mode().IsRegular() { - c.Log(logger.LevelDebug, logSender, "renaming file is not allowed, source: %#v target: %#v", request.Filepath, - request.Target) + initialSize := int64(-1) + if fi, err := c.fs.Lstat(targetPath); err == nil { + if fi.IsDir() { + c.Log(logger.LevelWarn, logSender, "attempted to rename %#v overwriting an existing directory %#v", sourcePath, targetPath) + return sftp.ErrSSHFxOpUnsupported + } + // we are overwriting an existing file/symlink + if fi.Mode().IsRegular() { + initialSize = fi.Size() + } + if !c.User.HasPerm(dataprovider.PermOverwrite, path.Dir(request.Target)) { + c.Log(logger.LevelDebug, logSender, "renaming is not allowed, source: %#v target: %#v. "+ + "Target exists but the user has no overwrite permission", request.Filepath, request.Target) return sftp.ErrSSHFxPermissionDenied } } - if !c.User.HasPerm(dataprovider.PermRename, path.Dir(request.Target)) { - return sftp.ErrSSHFxPermissionDenied + if !c.hasSpaceForRename(request, initialSize, sourcePath) { + c.Log(logger.LevelInfo, logSender, "denying cross rename due to space limit") + return sftp.ErrSSHFxFailure } if err := c.fs.Rename(sourcePath, targetPath); err != nil { - c.Log(logger.LevelWarn, logSender, "failed to rename file, source: %#v target: %#v: %+v", sourcePath, targetPath, err) + c.Log(logger.LevelWarn, logSender, "failed to rename %#v -> %#v: %+v", sourcePath, targetPath, err) return vfs.GetSFTPError(c.fs, err) } + if dataprovider.GetQuotaTracking() > 0 { + c.updateQuotaAfterRename(request, targetPath, initialSize) //nolint:errcheck + } logger.CommandLog(renameLogSender, sourcePath, targetPath, c.User.Username, "", c.ID, c.protocol, -1, -1, "", "", "") // the returned error is used in test cases only, we already log the error inside executeAction go executeAction(newActionNotification(c.User, operationRename, sourcePath, targetPath, "", 0, nil)) //nolint:errcheck @@ -339,6 +355,10 @@ func (c Connection) handleSFTPRmdir(dirPath string, request *sftp.Request) error c.Log(logger.LevelWarn, logSender, "removing a virtual folder is not allowed: %#v", request.Filepath) return sftp.ErrSSHFxPermissionDenied } + if c.User.HasVirtualFoldersInside(request.Filepath) { + c.Log(logger.LevelWarn, logSender, "removing a directory with a virtual folder inside is not allowed: %#v", request.Filepath) + return sftp.ErrSSHFxOpUnsupported + } if !c.User.HasPerm(dataprovider.PermDelete, path.Dir(request.Filepath)) { return sftp.ErrSSHFxPermissionDenied } @@ -375,11 +395,14 @@ func (c Connection) handleSFTPSymlink(sourcePath string, targetPath string, requ if !c.User.HasPerm(dataprovider.PermCreateSymlinks, path.Dir(request.Target)) { return sftp.ErrSSHFxPermissionDenied } + if c.isCrossFoldersRequest(request) { + c.Log(logger.LevelWarn, logSender, "cross folder symlink is not supported, src: %v dst: %v", request.Filepath, request.Target) + return sftp.ErrSSHFxFailure + } if err := c.fs.Symlink(sourcePath, targetPath); err != nil { c.Log(logger.LevelWarn, logSender, "failed to create symlink %#v -> %#v: %+v", sourcePath, targetPath, err) return vfs.GetSFTPError(c.fs, err) } - logger.CommandLog(symlinkLogSender, sourcePath, targetPath, c.User.Username, "", c.ID, c.protocol, -1, -1, "", "", "") return nil } @@ -437,7 +460,13 @@ func (c Connection) handleSFTPRemove(filePath string, request *sftp.Request) err logger.CommandLog(removeLogSender, filePath, "", c.User.Username, "", c.ID, c.protocol, -1, -1, "", "", "") if fi.Mode()&os.ModeSymlink != os.ModeSymlink { - if !c.User.IsFileExcludedFromQuota(request.Filepath) { + vfolder, err := c.User.GetVirtualFolderForPath(request.Filepath) + if err == nil { + dataprovider.UpdateVirtualFolderQuota(dataProvider, vfolder.BaseVirtualFolder, -1, -size, false) //nolint:errcheck + if vfolder.IsIncludedInUserQuota() { + dataprovider.UpdateUserQuota(dataProvider, c.User, -1, -size, false) //nolint:errcheck + } + } else { dataprovider.UpdateUserQuota(dataProvider, c.User, -1, -size, false) //nolint:errcheck } } @@ -448,50 +477,50 @@ func (c Connection) handleSFTPRemove(filePath string, request *sftp.Request) err return sftp.ErrSSHFxOk } -func (c Connection) handleSFTPUploadToNewFile(requestPath, filePath string, isExcludedFromQuota bool) (io.WriterAt, error) { - if !c.hasSpace(true) { - c.Log(logger.LevelInfo, logSender, "denying file write due to space limit") +func (c Connection) handleSFTPUploadToNewFile(resolvedPath, filePath, requestPath string) (io.WriterAt, error) { + if !c.hasSpace(true, requestPath) { + c.Log(logger.LevelInfo, logSender, "denying file write due to quota limits") return nil, sftp.ErrSSHFxFailure } file, w, cancelFn, err := c.fs.Create(filePath, 0) if err != nil { - c.Log(logger.LevelWarn, logSender, "error creating file %#v: %+v", requestPath, err) + c.Log(logger.LevelWarn, logSender, "error creating file %#v: %+v", resolvedPath, err) return nil, vfs.GetSFTPError(c.fs, err) } vfs.SetPathPermissions(c.fs, filePath, c.User.GetUID(), c.User.GetGID()) transfer := Transfer{ - file: file, - writerAt: w, - readerAt: nil, - cancelFn: cancelFn, - path: requestPath, - start: time.Now(), - bytesSent: 0, - bytesReceived: 0, - user: c.User, - connectionID: c.ID, - transferType: transferUpload, - lastActivity: time.Now(), - isNewFile: true, - protocol: c.protocol, - transferError: nil, - isFinished: false, - minWriteOffset: 0, - isExcludedFromQuota: isExcludedFromQuota, - lock: new(sync.Mutex), + file: file, + writerAt: w, + readerAt: nil, + cancelFn: cancelFn, + path: resolvedPath, + start: time.Now(), + bytesSent: 0, + bytesReceived: 0, + user: c.User, + connectionID: c.ID, + transferType: transferUpload, + lastActivity: time.Now(), + isNewFile: true, + protocol: c.protocol, + transferError: nil, + isFinished: false, + minWriteOffset: 0, + requestPath: requestPath, + lock: new(sync.Mutex), } addTransfer(&transfer) return &transfer, nil } -func (c Connection) handleSFTPUploadToExistingFile(pflags sftp.FileOpenFlags, requestPath, filePath string, - fileSize int64, isExcludedFromQuota bool) (io.WriterAt, error) { +func (c Connection) handleSFTPUploadToExistingFile(pflags sftp.FileOpenFlags, resolvedPath, filePath string, + fileSize int64, requestPath string) (io.WriterAt, error) { var err error - if !c.hasSpace(false) { - c.Log(logger.LevelInfo, logSender, "denying file write due to space limit") + if !c.hasSpace(false, requestPath) { + c.Log(logger.LevelInfo, logSender, "denying file write due to quota limits") return nil, sftp.ErrSSHFxFailure } @@ -499,16 +528,15 @@ func (c Connection) handleSFTPUploadToExistingFile(pflags sftp.FileOpenFlags, re osFlags := getOSOpenFlags(pflags) if pflags.Append && osFlags&os.O_TRUNC == 0 && !c.fs.IsUploadResumeSupported() { - c.Log(logger.LevelInfo, logSender, "upload resume requested for path: %#v but not supported in fs implementation", - requestPath) + c.Log(logger.LevelInfo, logSender, "upload resume requested for path: %#v but not supported in fs implementation", resolvedPath) return nil, sftp.ErrSSHFxOpUnsupported } if isAtomicUploadEnabled() && c.fs.IsAtomicUploadSupported() { - err = c.fs.Rename(requestPath, filePath) + err = c.fs.Rename(resolvedPath, filePath) if err != nil { c.Log(logger.LevelWarn, logSender, "error renaming existing file for atomic upload, source: %#v, dest: %#v, err: %+v", - requestPath, filePath, err) + resolvedPath, filePath, err) return nil, vfs.GetSFTPError(c.fs, err) } } @@ -525,7 +553,13 @@ func (c Connection) handleSFTPUploadToExistingFile(pflags sftp.FileOpenFlags, re minWriteOffset = fileSize } else { if vfs.IsLocalOsFs(c.fs) { - if !isExcludedFromQuota { + vfolder, err := c.User.GetVirtualFolderForPath(requestPath) + if err == nil { + dataprovider.UpdateVirtualFolderQuota(dataProvider, vfolder.BaseVirtualFolder, 0, -fileSize, false) //nolint:errcheck + if vfolder.IsIncludedInUserQuota() { + dataprovider.UpdateUserQuota(dataProvider, c.User, 0, -fileSize, false) //nolint:errcheck + } + } else { dataprovider.UpdateUserQuota(dataProvider, c.User, 0, -fileSize, false) //nolint:errcheck } } else { @@ -536,48 +570,104 @@ func (c Connection) handleSFTPUploadToExistingFile(pflags sftp.FileOpenFlags, re vfs.SetPathPermissions(c.fs, filePath, c.User.GetUID(), c.User.GetGID()) transfer := Transfer{ - file: file, - writerAt: w, - readerAt: nil, - cancelFn: cancelFn, - path: requestPath, - start: time.Now(), - bytesSent: 0, - bytesReceived: 0, - user: c.User, - connectionID: c.ID, - transferType: transferUpload, - lastActivity: time.Now(), - isNewFile: false, - protocol: c.protocol, - transferError: nil, - isFinished: false, - minWriteOffset: minWriteOffset, - initialSize: initialSize, - isExcludedFromQuota: isExcludedFromQuota, - lock: new(sync.Mutex), + file: file, + writerAt: w, + readerAt: nil, + cancelFn: cancelFn, + path: resolvedPath, + start: time.Now(), + bytesSent: 0, + bytesReceived: 0, + user: c.User, + connectionID: c.ID, + transferType: transferUpload, + lastActivity: time.Now(), + isNewFile: false, + protocol: c.protocol, + transferError: nil, + isFinished: false, + minWriteOffset: minWriteOffset, + initialSize: initialSize, + requestPath: requestPath, + lock: new(sync.Mutex), } addTransfer(&transfer) return &transfer, nil } -func (c Connection) hasSpace(checkFiles bool) bool { - if (checkFiles && c.User.QuotaFiles > 0) || c.User.QuotaSize > 0 { - numFile, size, err := dataprovider.GetUsedQuota(dataProvider, c.User.Username) - if err != nil { - if _, ok := err.(*dataprovider.MethodDisabledError); ok { - c.Log(logger.LevelWarn, logSender, "quota enforcement not possible for user %#v: %v", c.User.Username, err) +func (c Connection) hasSpaceForRename(request *sftp.Request, initialSize int64, sourcePath string) bool { + if dataprovider.GetQuotaTracking() == 0 { + return true + } + sourceFolder, errSrc := c.User.GetVirtualFolderForPath(request.Filepath) + dstFolder, errDst := c.User.GetVirtualFolderForPath(request.Target) + if errSrc != nil && errDst != nil { + // rename inside the user home dir + return true + } + if errSrc == nil && errDst == nil { + // rename between virtual folders + if sourceFolder.MappedPath == dstFolder.MappedPath { + // rename inside the same virtual folder + return true + } + } + if errSrc != nil && dstFolder.IsIncludedInUserQuota() { + // rename between user root dir and a virtual folder included in user quota + return true + } + if !c.hasSpace(true, request.Target) { + if initialSize != -1 { + // we are overquota but we are overwriting a file so we check the quota size + if c.hasSpace(false, request.Target) { + // we have enough quota size return true } - c.Log(logger.LevelWarn, logSender, "error getting used quota for %#v: %v", c.User.Username, err) - return false + if fi, err := c.fs.Lstat(sourcePath); err == nil { + if fi.Mode().IsRegular() { + // we have space if we are overwriting a bigger file with a smaller one + return initialSize >= fi.Size() + } + } } - if (checkFiles && c.User.QuotaFiles > 0 && numFile >= c.User.QuotaFiles) || - (c.User.QuotaSize > 0 && size >= c.User.QuotaSize) { - c.Log(logger.LevelDebug, logSender, "quota exceed for user %#v, num files: %v/%v, size: %v/%v check files: %v", - c.User.Username, numFile, c.User.QuotaFiles, size, c.User.QuotaSize, checkFiles) - return false + return false + } + return true +} + +func (c Connection) hasSpace(checkFiles bool, requestPath string) bool { + if dataprovider.GetQuotaTracking() == 0 { + return true + } + var quotaSize, usedSize int64 + var quotaFiles, numFiles int + var err error + var vfolder vfs.VirtualFolder + vfolder, err = c.User.GetVirtualFolderForPath(requestPath) + if err == nil && !vfolder.IsIncludedInUserQuota() { + if vfolder.HasNoQuotaRestrictions(checkFiles) { + return true } + quotaSize = vfolder.QuotaSize + quotaFiles = vfolder.QuotaFiles + numFiles, usedSize, err = dataprovider.GetUsedVirtualFolderQuota(dataProvider, vfolder.MappedPath) + } else { + if c.User.HasNoQuotaRestrictions(checkFiles) { + return true + } + quotaSize = c.User.QuotaSize + quotaFiles = c.User.QuotaFiles + numFiles, usedSize, err = dataprovider.GetUsedQuota(dataProvider, c.User.Username) + } + if err != nil { + c.Log(logger.LevelWarn, logSender, "error getting used quota for %#v request path %#v: %v", c.User.Username, requestPath, err) + return false + } + if (checkFiles && quotaFiles > 0 && numFiles >= quotaFiles) || + (quotaSize > 0 && usedSize >= quotaSize) { + c.Log(logger.LevelDebug, logSender, "quota exceed for user %#v, request path %#v, num files: %v/%v, size: %v/%v check files: %v", + c.User.Username, requestPath, numFiles, quotaFiles, usedSize, quotaSize, checkFiles) + return false } return true } @@ -612,3 +702,143 @@ func getOSOpenFlags(requestFlags sftp.FileOpenFlags) (flags int) { } return osFlags } + +func (c Connection) isCrossFoldersRequest(request *sftp.Request) bool { + sourceFolder, errSrc := c.User.GetVirtualFolderForPath(request.Filepath) + dstFolder, errDst := c.User.GetVirtualFolderForPath(request.Target) + if errSrc != nil && errDst != nil { + return false + } + if errSrc == nil && errDst == nil { + return sourceFolder.MappedPath != dstFolder.MappedPath + } + return true +} + +func (c Connection) isRenamePermitted(sourcePath string, request *sftp.Request) bool { + if c.fs.GetRelativePath(sourcePath) == "/" { + c.Log(logger.LevelWarn, logSender, "renaming root dir is not allowed") + return false + } + if c.User.IsVirtualFolder(request.Filepath) || c.User.IsVirtualFolder(request.Target) { + c.Log(logger.LevelWarn, logSender, "renaming a virtual folder is not allowed") + return false + } + if !c.User.IsFileAllowed(request.Filepath) || !c.User.IsFileAllowed(request.Target) { + if fi, err := c.fs.Lstat(sourcePath); err == nil && fi.Mode().IsRegular() { + c.Log(logger.LevelDebug, logSender, "renaming file is not allowed, source: %#v target: %#v", request.Filepath, + request.Target) + return false + } + } + if !c.User.HasPerm(dataprovider.PermRename, path.Dir(request.Target)) { + return false + } + return true +} + +func (c Connection) updateQuotaMoveBetweenVFolders(sourceFolder, dstFolder vfs.VirtualFolder, initialSize, filesSize int64, numFiles int) { + if sourceFolder.MappedPath == dstFolder.MappedPath { + // both files are inside the same virtual folder + if initialSize != -1 { + dataprovider.UpdateVirtualFolderQuota(dataProvider, dstFolder.BaseVirtualFolder, -numFiles, -initialSize, false) //nolint:errcheck + if dstFolder.IsIncludedInUserQuota() { + dataprovider.UpdateUserQuota(dataProvider, c.User, -numFiles, -initialSize, false) //nolint:errcheck + } + } + return + } + // files are inside different virtual folders + dataprovider.UpdateVirtualFolderQuota(dataProvider, sourceFolder.BaseVirtualFolder, -numFiles, -filesSize, false) //nolint:errcheck + if sourceFolder.IsIncludedInUserQuota() { + dataprovider.UpdateUserQuota(dataProvider, c.User, -numFiles, -filesSize, false) //nolint:errcheck + } + if initialSize == -1 { + dataprovider.UpdateVirtualFolderQuota(dataProvider, dstFolder.BaseVirtualFolder, numFiles, filesSize, false) //nolint:errcheck + if dstFolder.IsIncludedInUserQuota() { + dataprovider.UpdateUserQuota(dataProvider, c.User, numFiles, filesSize, false) //nolint:errcheck + } + } else { + // we cannot have a directory here, initialSize != -1 only for files + dataprovider.UpdateVirtualFolderQuota(dataProvider, dstFolder.BaseVirtualFolder, 0, filesSize-initialSize, false) //nolint:errcheck + if dstFolder.IsIncludedInUserQuota() { + dataprovider.UpdateUserQuota(dataProvider, c.User, 0, filesSize-initialSize, false) //nolint:errcheck + } + } +} + +func (c Connection) updateQuotaMoveFromVFolder(sourceFolder vfs.VirtualFolder, initialSize, filesSize int64, numFiles int) { + // move between a virtual folder and the user home dir + dataprovider.UpdateVirtualFolderQuota(dataProvider, sourceFolder.BaseVirtualFolder, -numFiles, -filesSize, false) //nolint:errcheck + if sourceFolder.IsIncludedInUserQuota() { + dataprovider.UpdateUserQuota(dataProvider, c.User, -numFiles, -filesSize, false) //nolint:errcheck + } + if initialSize == -1 { + dataprovider.UpdateUserQuota(dataProvider, c.User, numFiles, filesSize, false) //nolint:errcheck + } else { + // we cannot have a directory here, initialSize != -1 only for files + dataprovider.UpdateUserQuota(dataProvider, c.User, 0, filesSize-initialSize, false) //nolint:errcheck + } +} + +func (c Connection) updateQuotaMoveToVFolder(dstFolder vfs.VirtualFolder, initialSize, filesSize int64, numFiles int) { + // move between the user home dir and a virtual folder + dataprovider.UpdateUserQuota(dataProvider, c.User, -numFiles, -filesSize, false) //nolint:errcheck + if initialSize == -1 { + dataprovider.UpdateVirtualFolderQuota(dataProvider, dstFolder.BaseVirtualFolder, numFiles, filesSize, false) //nolint:errcheck + if dstFolder.IsIncludedInUserQuota() { + dataprovider.UpdateUserQuota(dataProvider, c.User, numFiles, filesSize, false) //nolint:errcheck + } + } else { + // we cannot have a directory here, initialSize != -1 only for files + dataprovider.UpdateVirtualFolderQuota(dataProvider, dstFolder.BaseVirtualFolder, 0, filesSize-initialSize, false) //nolint:errcheck + if dstFolder.IsIncludedInUserQuota() { + dataprovider.UpdateUserQuota(dataProvider, c.User, 0, filesSize-initialSize, false) //nolint:errcheck + } + } +} + +func (c Connection) updateQuotaAfterRename(request *sftp.Request, targetPath string, initialSize int64) error { + // we don't allow to overwrite an existing directory so targetPath can be: + // - a new file, a symlink is as a new file here + // - a file overwriting an existing one + // - a new directory + // initialSize != -1 only when overwriting files + sourceFolder, errSrc := c.User.GetVirtualFolderForPath(request.Filepath) + dstFolder, errDst := c.User.GetVirtualFolderForPath(request.Target) + if errSrc != nil && errDst != nil { + // both files are contained inside the user home dir + if initialSize != -1 { + // we cannot have a directory here + dataprovider.UpdateUserQuota(dataProvider, c.User, -1, -initialSize, false) //nolint:errcheck + } + return nil + } + + filesSize := int64(0) + numFiles := 1 + if fi, err := c.fs.Stat(targetPath); err == nil { + if fi.Mode().IsDir() { + numFiles, filesSize, err = c.fs.GetDirSize(targetPath) + if err != nil { + logger.Warn(logSender, "", "failed to update quota after rename, error scanning moved folder %#v: %v", targetPath, err) + return err + } + } else { + filesSize = fi.Size() + } + } else { + c.Log(logger.LevelWarn, logSender, "failed to update quota after rename, file %#v stat error: %+v", targetPath, err) + return err + } + if errSrc == nil && errDst == nil { + c.updateQuotaMoveBetweenVFolders(sourceFolder, dstFolder, initialSize, filesSize, numFiles) + } + if errSrc == nil && errDst != nil { + c.updateQuotaMoveFromVFolder(sourceFolder, initialSize, filesSize, numFiles) + } + if errSrc != nil && errDst == nil { + c.updateQuotaMoveToVFolder(dstFolder, initialSize, filesSize, numFiles) + } + return nil +} diff --git a/sftpd/internal_test.go b/sftpd/internal_test.go index 4de3a78f..0a597e66 100644 --- a/sftpd/internal_test.go +++ b/sftpd/internal_test.go @@ -9,6 +9,7 @@ import ( "net" "os" "os/exec" + "path" "path/filepath" "runtime" "sync" @@ -99,6 +100,14 @@ func (fs MockOsFs) Stat(name string) (os.FileInfo, error) { return os.Stat(name) } +// Lstat returns a FileInfo describing the named file +func (fs MockOsFs) Lstat(name string) (os.FileInfo, error) { + if fs.statErr != nil { + return nil, fs.statErr + } + return os.Lstat(name) +} + // Remove removes the named file or (empty) directory. func (fs MockOsFs) Remove(name string, isDir bool) error { if fs.err != nil { @@ -203,6 +212,9 @@ func TestActionHTTP(t *testing.T) { } func TestPreDeleteAction(t *testing.T) { + if runtime.GOOS == osWindows { + t.Skip("this test is not available on Windows") + } actionsCopy := actions hookCmd, err := exec.LookPath("true") @@ -443,8 +455,6 @@ func TestMockFsErrors(t *testing.T) { request := sftp.NewRequest("Remove", testfile) err := ioutil.WriteFile(testfile, []byte("test"), 0666) assert.NoError(t, err) - err = c.handleSFTPRemove(testfile, request) - assert.EqualError(t, err, sftp.ErrSSHFxFailure.Error()) _, err = c.Filewrite(request) assert.EqualError(t, err, sftp.ErrSSHFxFailure.Error()) @@ -452,9 +462,14 @@ func TestMockFsErrors(t *testing.T) { flags.Write = true flags.Trunc = false flags.Append = true - _, err = c.handleSFTPUploadToExistingFile(flags, testfile, testfile, 0, false) + _, err = c.handleSFTPUploadToExistingFile(flags, testfile, testfile, 0, "/testfile") assert.EqualError(t, err, sftp.ErrSSHFxOpUnsupported.Error()) + fs = newMockOsFs(errFake, nil, false, "123", os.TempDir()) + c.fs = fs + err = c.handleSFTPRemove(testfile, request) + assert.EqualError(t, err, sftp.ErrSSHFxFailure.Error()) + err = os.Remove(testfile) assert.NoError(t, err) } @@ -468,18 +483,18 @@ func TestUploadFiles(t *testing.T) { var flags sftp.FileOpenFlags flags.Write = true flags.Trunc = true - _, err := c.handleSFTPUploadToExistingFile(flags, "missing_path", "other_missing_path", 0, false) + _, err := c.handleSFTPUploadToExistingFile(flags, "missing_path", "other_missing_path", 0, "/missing_path") assert.Error(t, err, "upload to existing file must fail if one or both paths are invalid") uploadMode = uploadModeStandard - _, err = c.handleSFTPUploadToExistingFile(flags, "missing_path", "other_missing_path", 0, false) + _, err = c.handleSFTPUploadToExistingFile(flags, "missing_path", "other_missing_path", 0, "/missing_path") assert.Error(t, err, "upload to existing file must fail if one or both paths are invalid") missingFile := "missing/relative/file.txt" if runtime.GOOS == osWindows { missingFile = "missing\\relative\\file.txt" } - _, err = c.handleSFTPUploadToNewFile(".", missingFile, false) + _, err = c.handleSFTPUploadToNewFile(".", missingFile, "/missing") assert.Error(t, err, "upload new file in missing path must fail") c.fs = newMockOsFs(nil, nil, false, "123", os.TempDir()) @@ -488,7 +503,7 @@ func TestUploadFiles(t *testing.T) { err = f.Close() assert.NoError(t, err) - _, err = c.handleSFTPUploadToExistingFile(flags, f.Name(), f.Name(), 123, false) + _, err = c.handleSFTPUploadToExistingFile(flags, f.Name(), f.Name(), 123, f.Name()) assert.NoError(t, err) if assert.Equal(t, 1, len(activeTransfers)) { transfer := activeTransfers[0] @@ -572,7 +587,7 @@ func TestSFTPGetUsedQuota(t *testing.T) { connection := Connection{ User: u, } - assert.False(t, connection.hasSpace(false)) + assert.False(t, connection.hasSpace(false, "/")) } func TestSupportedSSHCommands(t *testing.T) { @@ -923,16 +938,20 @@ func TestGitVirtualFolders(t *testing.T) { args: []string{"/vdir"}, } cmd.connection.User.VirtualFolders = append(cmd.connection.User.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: os.TempDir(), + }, VirtualPath: "/vdir", - MappedPath: os.TempDir(), }) _, err = cmd.getSystemCommand() assert.EqualError(t, err, errUnsupportedConfig.Error()) cmd.connection.User.VirtualFolders = nil cmd.connection.User.VirtualFolders = append(cmd.connection.User.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: os.TempDir(), + }, VirtualPath: "/vdir", - MappedPath: os.TempDir(), }) cmd.args = []string{"/vdir/subdir"} _, err = cmd.getSystemCommand() @@ -987,8 +1006,10 @@ func TestRsyncOptions(t *testing.T) { "--munge-links must be added if the user has the create symlinks permission") sshCmd.connection.User.VirtualFolders = append(sshCmd.connection.User.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: os.TempDir(), + }, VirtualPath: "/vdir", - MappedPath: os.TempDir(), }) _, err = sshCmd.getSystemCommand() assert.EqualError(t, err, errUnsupportedConfig.Error()) @@ -1391,7 +1412,7 @@ func TestSCPErrorsMockFs(t *testing.T) { err = scpCommand.handleUpload(filepath.Base(testfile), 0) assert.EqualError(t, err, errFake.Error()) - err = scpCommand.handleUploadFile(testfile, testfile, 0, false, 4, false) + err = scpCommand.handleUploadFile(testfile, testfile, 0, false, 4, "/testfile") assert.NoError(t, err) err = os.Remove(testfile) assert.NoError(t, err) @@ -1810,3 +1831,43 @@ func TestCertCheckerInitErrors(t *testing.T) { err = os.Remove(testfile) assert.NoError(t, err) } + +func TestUpdateQuotaAfterRenameMissingFile(t *testing.T) { + user := dataprovider.User{ + Username: "username", + HomeDir: filepath.Join(os.TempDir(), "home"), + } + mappedPath := filepath.Join(os.TempDir(), "vdir") + user.Permissions = make(map[string][]string) + user.Permissions["/"] = []string{dataprovider.PermAny} + user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath, + }, + VirtualPath: "/vdir", + }) + c := Connection{ + fs: vfs.NewOsFs("id", os.TempDir(), nil), + User: user, + } + request := sftp.NewRequest("Rename", "/testfile") + request.Filepath = "/dir" + request.Target = path.Join("vdir", "dir") + if runtime.GOOS != "windows" { + testDirPath := filepath.Join(mappedPath, "dir") + err := os.MkdirAll(testDirPath, 0777) + assert.NoError(t, err) + err = os.Chmod(testDirPath, 0001) + assert.NoError(t, err) + err = c.updateQuotaAfterRename(request, testDirPath, 0) + assert.Error(t, err) + err = os.Chmod(testDirPath, 0777) + assert.NoError(t, err) + err = os.RemoveAll(testDirPath) + assert.NoError(t, err) + } + request.Target = "/testfile1" + request.Filepath = path.Join("vdir", "file") + err := c.updateQuotaAfterRename(request, filepath.Join(os.TempDir(), "vdir", "file"), 0) + assert.Error(t, err) +} diff --git a/sftpd/scp.go b/sftpd/scp.go index 235b1f58..83a1cece 100644 --- a/sftpd/scp.go +++ b/sftpd/scp.go @@ -187,10 +187,9 @@ func (c *scpCommand) getUploadFileData(sizeToRead int64, transfer *Transfer) err return c.sendConfirmationMessage() } -func (c *scpCommand) handleUploadFile(requestPath, filePath string, sizeToRead int64, isNewFile bool, fileSize int64, - isExcludedFromQuota bool) error { - if !c.connection.hasSpace(true) { - err := fmt.Errorf("denying file write due to space limit") +func (c *scpCommand) handleUploadFile(resolvedPath, filePath string, sizeToRead int64, isNewFile bool, fileSize int64, requestPath string) error { + if !c.connection.hasSpace(true, requestPath) { + err := fmt.Errorf("denying file write due to quota limits") c.connection.Log(logger.LevelWarn, logSenderSCP, "error uploading file: %#v, err: %v", filePath, err) c.sendErrorMessage(err) return err @@ -199,7 +198,13 @@ func (c *scpCommand) handleUploadFile(requestPath, filePath string, sizeToRead i initialSize := int64(0) if !isNewFile { if vfs.IsLocalOsFs(c.connection.fs) { - if !isExcludedFromQuota { + vfolder, err := c.connection.User.GetVirtualFolderForPath(requestPath) + if err == nil { + dataprovider.UpdateVirtualFolderQuota(dataProvider, vfolder.BaseVirtualFolder, 0, -fileSize, false) //nolint:errcheck + if vfolder.IsIncludedInUserQuota() { + dataprovider.UpdateUserQuota(dataProvider, c.connection.User, 0, -fileSize, false) //nolint:errcheck + } + } else { dataprovider.UpdateUserQuota(dataProvider, c.connection.User, 0, -fileSize, false) //nolint:errcheck } } else { @@ -208,7 +213,7 @@ func (c *scpCommand) handleUploadFile(requestPath, filePath string, sizeToRead i } file, w, cancelFn, err := c.connection.fs.Create(filePath, 0) if err != nil { - c.connection.Log(logger.LevelError, logSenderSCP, "error creating file %#v: %v", requestPath, err) + c.connection.Log(logger.LevelError, logSenderSCP, "error creating file %#v: %v", resolvedPath, err) c.sendErrorMessage(err) return err } @@ -216,26 +221,26 @@ func (c *scpCommand) handleUploadFile(requestPath, filePath string, sizeToRead i vfs.SetPathPermissions(c.connection.fs, filePath, c.connection.User.GetUID(), c.connection.User.GetGID()) transfer := Transfer{ - file: file, - readerAt: nil, - writerAt: w, - cancelFn: cancelFn, - path: requestPath, - start: time.Now(), - bytesSent: 0, - bytesReceived: 0, - user: c.connection.User, - connectionID: c.connection.ID, - transferType: transferUpload, - lastActivity: time.Now(), - isNewFile: isNewFile, - protocol: c.connection.protocol, - transferError: nil, - isFinished: false, - minWriteOffset: 0, - initialSize: initialSize, - isExcludedFromQuota: isExcludedFromQuota, - lock: new(sync.Mutex), + file: file, + readerAt: nil, + writerAt: w, + cancelFn: cancelFn, + path: resolvedPath, + start: time.Now(), + bytesSent: 0, + bytesReceived: 0, + user: c.connection.User, + connectionID: c.connection.ID, + transferType: transferUpload, + lastActivity: time.Now(), + isNewFile: isNewFile, + protocol: c.connection.protocol, + transferError: nil, + isFinished: false, + minWriteOffset: 0, + initialSize: initialSize, + requestPath: requestPath, + lock: new(sync.Mutex), } addTransfer(&transfer) @@ -262,14 +267,14 @@ func (c *scpCommand) handleUpload(uploadFilePath string, sizeToRead int64) error if isAtomicUploadEnabled() && c.connection.fs.IsAtomicUploadSupported() { filePath = c.connection.fs.GetAtomicUploadPath(p) } - stat, statErr := c.connection.fs.Stat(p) - if c.connection.fs.IsNotExist(statErr) { + stat, statErr := c.connection.fs.Lstat(p) + if (statErr == nil && stat.Mode()&os.ModeSymlink == os.ModeSymlink) || c.connection.fs.IsNotExist(statErr) { if !c.connection.User.HasPerm(dataprovider.PermUpload, path.Dir(uploadFilePath)) { c.connection.Log(logger.LevelWarn, logSenderSCP, "cannot upload file: %#v, permission denied", uploadFilePath) c.sendErrorMessage(errPermission) return errPermission } - return c.handleUploadFile(p, filePath, sizeToRead, true, 0, c.connection.User.IsFileExcludedFromQuota(uploadFilePath)) + return c.handleUploadFile(p, filePath, sizeToRead, true, 0, uploadFilePath) } if statErr != nil { @@ -301,7 +306,7 @@ func (c *scpCommand) handleUpload(uploadFilePath string, sizeToRead int64) error } } - return c.handleUploadFile(p, filePath, sizeToRead, false, stat.Size(), c.connection.User.IsFileExcludedFromQuota(uploadFilePath)) + return c.handleUploadFile(p, filePath, sizeToRead, false, stat.Size(), uploadFilePath) } func (c *scpCommand) sendDownloadProtocolMessages(dirPath string, stat os.FileInfo) error { @@ -336,8 +341,8 @@ func (c *scpCommand) sendDownloadProtocolMessages(dirPath string, stat os.FileIn return err } -// we send first all the files in the root directory and then the directories -// for each directory we recursively call this method again +// We send first all the files in the root directory and then the directories. +// For each directory we recursively call this method again func (c *scpCommand) handleRecursiveDownload(dirPath string, stat os.FileInfo) error { var err error if c.isRecursive() { diff --git a/sftpd/sftpd.go b/sftpd/sftpd.go index 01e05eda..328d3182 100644 --- a/sftpd/sftpd.go +++ b/sftpd/sftpd.go @@ -59,16 +59,17 @@ const ( ) var ( - mutex sync.RWMutex - openConnections map[string]Connection - activeTransfers []*Transfer - idleTimeout time.Duration - activeQuotaScans []ActiveQuotaScan - dataProvider dataprovider.Provider - actions Actions - uploadMode int - setstatMode int - supportedSSHCommands = []string{"scp", "md5sum", "sha1sum", "sha256sum", "sha384sum", "sha512sum", "cd", "pwd", + mutex sync.RWMutex + openConnections map[string]Connection + activeTransfers []*Transfer + idleTimeout time.Duration + activeQuotaScans []ActiveQuotaScan + activeVFoldersQuotaScan []ActiveVirtualFolderQuotaScan + dataProvider dataprovider.Provider + actions Actions + uploadMode int + setstatMode int + supportedSSHCommands = []string{"scp", "md5sum", "sha1sum", "sha256sum", "sha384sum", "sha512sum", "cd", "pwd", "git-receive-pack", "git-upload-pack", "git-upload-archive", "rsync"} defaultSSHCommands = []string{"md5sum", "sha1sum", "cd", "pwd", "scp"} sshHashCommands = []string{"md5sum", "sha1sum", "sha256sum", "sha384sum", "sha512sum"} @@ -86,7 +87,7 @@ type connectionTransfer struct { Path string `json:"path"` } -// ActiveQuotaScan defines an active quota scan +// ActiveQuotaScan defines an active quota scan for a user home dir type ActiveQuotaScan struct { // Username to which the quota scan refers Username string `json:"username"` @@ -94,6 +95,14 @@ type ActiveQuotaScan struct { StartTime int64 `json:"start_time"` } +// ActiveVirtualFolderQuotaScan defines an active quota scan for a virtual folder +type ActiveVirtualFolderQuotaScan struct { + // folder path to which the quota scan refers + MappedPath string `json:"mapped_path"` + // quota scan start time as unix timestamp in milliseconds + StartTime int64 `json:"start_time"` +} + // Actions to execute on SFTP create, download, delete and rename. // An external command can be executed and/or an HTTP notification can be fired type Actions struct { @@ -278,7 +287,7 @@ func getActiveSessions(username string) int { return numSessions } -// GetQuotaScans returns the active quota scans +// GetQuotaScans returns the active quota scans for users home directories func GetQuotaScans() []ActiveQuotaScan { mutex.RLock() defer mutex.RUnlock() @@ -320,8 +329,56 @@ func RemoveQuotaScan(username string) error { activeQuotaScans[indexToRemove] = activeQuotaScans[len(activeQuotaScans)-1] activeQuotaScans = activeQuotaScans[:len(activeQuotaScans)-1] } else { - logger.Warn(logSender, "", "quota scan to remove not found for user: %v", username) - err = fmt.Errorf("quota scan to remove not found for user: %v", username) + err = fmt.Errorf("quota scan to remove not found for user: %#v", username) + logger.Warn(logSender, "", "error: %v", err) + } + return err +} + +// GetVFoldersQuotaScans returns the active quota scans for virtual folders +func GetVFoldersQuotaScans() []ActiveVirtualFolderQuotaScan { + mutex.RLock() + defer mutex.RUnlock() + scans := make([]ActiveVirtualFolderQuotaScan, len(activeVFoldersQuotaScan)) + copy(scans, activeVFoldersQuotaScan) + return scans +} + +// AddVFolderQuotaScan add a virtual folder to the ones with active quota scans. +// Returns false if the folder has a quota scan already running +func AddVFolderQuotaScan(folderPath string) bool { + mutex.Lock() + defer mutex.Unlock() + for _, s := range activeVFoldersQuotaScan { + if s.MappedPath == folderPath { + return false + } + } + activeVFoldersQuotaScan = append(activeVFoldersQuotaScan, ActiveVirtualFolderQuotaScan{ + MappedPath: folderPath, + StartTime: utils.GetTimeAsMsSinceEpoch(time.Now()), + }) + return true +} + +// RemoveVFolderQuotaScan removes a folder from the ones with active quota scans +func RemoveVFolderQuotaScan(folderPath string) error { + mutex.Lock() + defer mutex.Unlock() + var err error + indexToRemove := -1 + for i, s := range activeVFoldersQuotaScan { + if s.MappedPath == folderPath { + indexToRemove = i + break + } + } + if indexToRemove >= 0 { + activeVFoldersQuotaScan[indexToRemove] = activeVFoldersQuotaScan[len(activeVFoldersQuotaScan)-1] + activeVFoldersQuotaScan = activeVFoldersQuotaScan[:len(activeVFoldersQuotaScan)-1] + } else { + err = fmt.Errorf("quota scan to remove not found for user: %#v", folderPath) + logger.Warn(logSender, "", "error: %v", err) } return err } diff --git a/sftpd/sftpd_test.go b/sftpd/sftpd_test.go index 0c55fff8..adaac8dd 100644 --- a/sftpd/sftpd_test.go +++ b/sftpd/sftpd_test.go @@ -21,6 +21,7 @@ import ( "path" "path/filepath" "runtime" + "strconv" "strings" "testing" "time" @@ -422,6 +423,9 @@ func TestDirCommands(t *testing.T) { assert.NoError(t, err) err = client.Rename("test1", "test") assert.NoError(t, err) + // rename a missing file + err = client.Rename("test1", "test2") + assert.Error(t, err) _, err = client.Lstat("/test1") assert.Error(t, err, "stat for renamed dir must not succeed") err = client.PosixRename("test", "test1") @@ -1412,6 +1416,7 @@ func TestLoginExternalAuth(t *testing.T) { if runtime.GOOS == osWindows { t.Skip("this test is not available on Windows") } + mappedPath := filepath.Join(os.TempDir(), "vdir1") extAuthScopes := []int{1, 2} for _, authScope := range extAuthScopes { var usePubKey bool @@ -1421,6 +1426,14 @@ func TestLoginExternalAuth(t *testing.T) { usePubKey = true } u := getTestUser(usePubKey) + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath, + }, + VirtualPath: "/vpath", + QuotaFiles: 1 + authScope, + QuotaSize: 10 + int64(authScope), + }) dataProvider := dataprovider.GetProvider() err := dataprovider.Close(dataProvider) assert.NoError(t, err) @@ -1454,14 +1467,22 @@ func TestLoginExternalAuth(t *testing.T) { } users, _, err := httpd.GetUsers(0, 0, defaultUsername, http.StatusOK) assert.NoError(t, err) - assert.Equal(t, 1, len(users)) + if assert.Len(t, users, 1) { + user := users[0] + if assert.Len(t, user.VirtualFolders, 1) { + folder := user.VirtualFolders[0] + assert.Equal(t, mappedPath, folder.MappedPath) + assert.Equal(t, 1+authScope, folder.QuotaFiles) + assert.Equal(t, 10+int64(authScope), folder.QuotaSize) + } + _, err = httpd.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + } - user := users[0] - _, err = httpd.RemoveUser(user, http.StatusOK) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath}, http.StatusOK) assert.NoError(t, err) - err = os.RemoveAll(user.GetHomeDir()) - assert.NoError(t, err) - dataProvider = dataprovider.GetProvider() err = dataprovider.Close(dataProvider) assert.NoError(t, err) @@ -1603,7 +1624,7 @@ func TestQuotaDisabledError(t *testing.T) { sftpd.SetDataProvider(dataprovider.GetProvider()) usePubKey := false u := getTestUser(usePubKey) - u.QuotaFiles = 10 + u.QuotaFiles = 1 user, _, err := httpd.AddUser(u, http.StatusOK) assert.NoError(t, err) client, err := getSftpClient(user, usePubKey) @@ -1616,6 +1637,10 @@ func TestQuotaDisabledError(t *testing.T) { assert.NoError(t, err) err = sftpUploadFile(testFilePath, testFileName, testFileSize, client) assert.NoError(t, err) + err = sftpUploadFile(testFilePath, testFileName+"1", testFileSize, client) + assert.NoError(t, err) + err = client.Rename(testFileName+"1", testFileName+".rename") + assert.NoError(t, err) err = os.Remove(testFilePath) assert.NoError(t, err) } @@ -1678,8 +1703,6 @@ func TestQuotaFileReplace(t *testing.T) { assert.NoError(t, err) err = sftpUploadFile(testFilePath, testFileName, testFileSize, client) assert.NoError(t, err) - user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) - assert.NoError(t, err) // now replace the same file, the quota must not change err = sftpUploadFile(testFilePath, testFileName, testFileSize, client) assert.NoError(t, err) @@ -1687,9 +1710,25 @@ func TestQuotaFileReplace(t *testing.T) { assert.NoError(t, err) assert.Equal(t, expectedQuotaFiles, user.UsedQuotaFiles) assert.Equal(t, expectedQuotaSize, user.UsedQuotaSize) + // now create a symlink, replace it with a file and check the quota + // replacing a symlink is like uploading a new file + err = client.Symlink(testFileName, testFileName+".link") + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, expectedQuotaFiles, user.UsedQuotaFiles) + assert.Equal(t, expectedQuotaSize, user.UsedQuotaSize) + expectedQuotaFiles = expectedQuotaFiles + 1 + expectedQuotaSize = expectedQuotaSize + testFileSize + err = sftpUploadFile(testFilePath, testFileName+".link", testFileSize, client) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, expectedQuotaFiles, user.UsedQuotaFiles) + assert.Equal(t, expectedQuotaSize, user.UsedQuotaSize) } // now set a quota size restriction and upload the same file, upload should fail for space limit exceeded - user.QuotaSize = testFileSize - 1 + user.QuotaSize = testFileSize*2 - 1 user, _, err = httpd.UpdateUser(user, http.StatusOK) assert.NoError(t, err) client, err = getSftpClient(user, usePubKey) @@ -1707,6 +1746,90 @@ func TestQuotaFileReplace(t *testing.T) { assert.NoError(t, err) } +func TestQuotaRename(t *testing.T) { + usePubKey := false + u := getTestUser(usePubKey) + u.QuotaFiles = 1000 + user, _, err := httpd.AddUser(u, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + testFileSize := int64(65535) + testFileSize1 := int64(65537) + testFileName := "test_file.dat" + testFileName1 := "test_file1.dat" //nolint:goconst + testFilePath := filepath.Join(homeBasePath, testFileName) + testFilePath1 := filepath.Join(homeBasePath, testFileName1) + client, err := getSftpClient(user, usePubKey) + if assert.NoError(t, err) { + defer client.Close() + err = createTestFile(testFilePath, testFileSize) + assert.NoError(t, err) + err = createTestFile(testFilePath1, testFileSize1) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, testFileName, testFileSize, client) + assert.NoError(t, err) + err = client.Rename(testFileName, testFileName+".rename") + assert.NoError(t, err) + err = sftpUploadFile(testFilePath1, testFileName1, testFileSize1, client) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 2, user.UsedQuotaFiles) + assert.Equal(t, testFileSize+testFileSize1, user.UsedQuotaSize) + err = client.Rename(testFileName1, testFileName+".rename") + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 1, user.UsedQuotaFiles) + assert.Equal(t, testFileSize1, user.UsedQuotaSize) + err = client.Symlink(testFileName+".rename", testFileName+".symlink") + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, testFileName, testFileSize, client) + assert.NoError(t, err) + // overwrite a symlink + err = client.Rename(testFileName, testFileName+".symlink") + assert.NoError(t, err) + err = client.Mkdir("testdir") + assert.NoError(t, err) + err = client.Rename("testdir", "testdir1") + assert.NoError(t, err) + err = client.Mkdir("testdir") + assert.NoError(t, err) + err = client.Rename("testdir", "testdir1") + assert.Error(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 2, user.UsedQuotaFiles) + assert.Equal(t, testFileSize+testFileSize1, user.UsedQuotaSize) + testDir := "tdir" + err = client.Mkdir(testDir) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, path.Join(testDir, testFileName), testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath1, path.Join(testDir, testFileName1), testFileSize1, client) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 4, user.UsedQuotaFiles) + assert.Equal(t, testFileSize*2+testFileSize1*2, user.UsedQuotaSize) + err = client.Rename(testDir, testDir+"1") + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 4, user.UsedQuotaFiles) + assert.Equal(t, testFileSize*2+testFileSize1*2, user.UsedQuotaSize) + } + _, err = httpd.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.Remove(testFilePath) + assert.NoError(t, err) + err = os.Remove(testFilePath1) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + func TestQuotaScan(t *testing.T) { usePubKey := false user, _, err := httpd.AddUser(getTestUser(usePubKey), http.StatusOK) @@ -1733,7 +1856,7 @@ func TestQuotaScan(t *testing.T) { assert.NoError(t, err) _, err = httpd.StartQuotaScan(user, http.StatusCreated) assert.NoError(t, err) - err = waitQuotaScans() + err = waitQuotaScans(1) assert.NoError(t, err) user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) assert.NoError(t, err) @@ -1754,32 +1877,48 @@ func TestMultipleQuotaScans(t *testing.T) { assert.NoError(t, err) activeScans := sftpd.GetQuotaScans() assert.Equal(t, 0, len(activeScans)) + err = sftpd.RemoveQuotaScan(defaultUsername) + assert.Error(t, err) } -func TestQuotaSize(t *testing.T) { +func TestQuotaLimits(t *testing.T) { usePubKey := false - testFileSize := int64(65535) u := getTestUser(usePubKey) u.QuotaFiles = 1 - u.QuotaSize = testFileSize - 1 user, _, err := httpd.AddUser(u, http.StatusOK) assert.NoError(t, err) + testFileSize := int64(65535) + testFileName := "test_file.dat" + testFilePath := filepath.Join(homeBasePath, testFileName) + err = createTestFile(testFilePath, testFileSize) + assert.NoError(t, err) + // test quota files client, err := getSftpClient(user, usePubKey) if assert.NoError(t, err) { defer client.Close() - testFileName := "test_file.dat" - testFilePath := filepath.Join(homeBasePath, testFileName) - err = createTestFile(testFilePath, testFileSize) - assert.NoError(t, err) err = sftpUploadFile(testFilePath, testFileName+".quota", testFileSize, client) assert.NoError(t, err) err = sftpUploadFile(testFilePath, testFileName+".quota.1", testFileSize, client) - assert.Error(t, err, "user is over quota file upload must fail") - err = client.Remove(testFileName + ".quota") - assert.NoError(t, err) - err = os.Remove(testFilePath) + assert.Error(t, err, "user is over quota files, upload must fail") + // rename should work + err = client.Rename(testFileName+".quota", testFileName) assert.NoError(t, err) } + // test quota size + user.QuotaSize = testFileSize - 1 + user.QuotaFiles = 0 + user, _, err = httpd.UpdateUser(user, http.StatusOK) + assert.NoError(t, err) + client, err = getSftpClient(user, usePubKey) + if assert.NoError(t, err) { + defer client.Close() + err = sftpUploadFile(testFilePath, testFileName+".quota.1", testFileSize, client) + assert.Error(t, err, "user is over quota size, upload must fail") + err = client.Rename(testFileName, testFileName+".quota") + assert.NoError(t, err) + } + err = os.Remove(testFilePath) + assert.NoError(t, err) _, err = httpd.RemoveUser(user, http.StatusOK) assert.NoError(t, err) err = os.RemoveAll(user.GetHomeDir()) @@ -1902,10 +2041,12 @@ func TestVirtualFolders(t *testing.T) { usePubKey := true u := getTestUser(usePubKey) mappedPath := filepath.Join(os.TempDir(), "vdir") - vdirPath := "/vdir" //nolint:goconst + vdirPath := "/vdir/subdir" u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath, + }, VirtualPath: vdirPath, - MappedPath: mappedPath, }) err := os.MkdirAll(mappedPath, 0777) assert.NoError(t, err) @@ -1932,6 +2073,14 @@ func TestVirtualFolders(t *testing.T) { assert.Error(t, err, "creating a virtual folder must fail") err = client.Symlink(path.Join(vdirPath, testFileName), vdirPath) assert.Error(t, err, "symlink to a virtual folder must fail") + err = client.Rename("/vdir", "/vdir1") + assert.Error(t, err, "renaming a directory with a virtual folder inside must fail") + err = client.RemoveDirectory("/vdir") + assert.Error(t, err, "removing a directory with a virtual folder inside must fail") + err = client.Mkdir("vdir1") + assert.NoError(t, err) + err = client.Rename("vdir1", "vdir2") + assert.NoError(t, err) err = os.Remove(testFilePath) assert.NoError(t, err) err = os.Remove(localDownloadPath) @@ -1939,28 +2088,236 @@ func TestVirtualFolders(t *testing.T) { } _, err = httpd.RemoveUser(user, http.StatusOK) assert.NoError(t, err) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath}, http.StatusOK) + assert.NoError(t, err) err = os.RemoveAll(user.GetHomeDir()) assert.NoError(t, err) err = os.RemoveAll(mappedPath) assert.NoError(t, err) } -func TestVirtualFoldersQuota(t *testing.T) { +func TestVirtualFoldersQuotaLimit(t *testing.T) { usePubKey := false + u1 := getTestUser(usePubKey) + u1.QuotaFiles = 1 + mappedPath1 := filepath.Join(os.TempDir(), "vdir1") + vdirPath1 := "/vdir1" //nolint:goconst + mappedPath2 := filepath.Join(os.TempDir(), "vdir2") + vdirPath2 := "/vdir2" //nolint:goconst + u1.VirtualFolders = append(u1.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath1, + }, + VirtualPath: vdirPath1, + QuotaFiles: -1, + QuotaSize: -1, + }) + u1.VirtualFolders = append(u1.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath2, + }, + VirtualPath: vdirPath2, + QuotaFiles: 1, + QuotaSize: 0, + }) + testFileSize := int64(131072) + testFileName := "test_file.dat" + testFilePath := filepath.Join(homeBasePath, testFileName) + err := createTestFile(testFilePath, testFileSize) + assert.NoError(t, err) + u2 := getTestUser(usePubKey) + u2.QuotaSize = testFileSize - 1 + u2.VirtualFolders = append(u2.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath1, + }, + VirtualPath: vdirPath1, + QuotaFiles: -1, + QuotaSize: -1, + }) + u2.VirtualFolders = append(u2.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath2, + }, + VirtualPath: vdirPath2, + QuotaFiles: 0, + QuotaSize: testFileSize - 1, + }) + users := []dataprovider.User{u1, u2} + for _, u := range users { + err = os.MkdirAll(mappedPath1, 0777) + assert.NoError(t, err) + err = os.MkdirAll(mappedPath2, 0777) + assert.NoError(t, err) + user, _, err := httpd.AddUser(u, http.StatusOK) + assert.NoError(t, err) + client, err := getSftpClient(user, usePubKey) + if assert.NoError(t, err) { + defer client.Close() + err = sftpUploadFile(testFilePath, path.Join(vdirPath1, testFileName), testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, path.Join(vdirPath2, testFileName), testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, testFileName, testFileSize, client) + assert.Error(t, err) + err = sftpUploadFile(testFilePath, path.Join(vdirPath1, testFileName+"1"), testFileSize, client) + assert.Error(t, err) + err = sftpUploadFile(testFilePath, path.Join(vdirPath2, testFileName+"1"), testFileSize, client) + assert.Error(t, err) + err = client.Remove(path.Join(vdirPath1, testFileName)) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, testFileName, testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, path.Join(vdirPath1, testFileName), testFileSize, client) + assert.Error(t, err) + // now test renames + err = client.Rename(testFileName, path.Join(vdirPath1, testFileName)) + assert.NoError(t, err) + err = client.Rename(path.Join(vdirPath1, testFileName), path.Join(vdirPath1, testFileName+".rename")) + assert.NoError(t, err) + err = client.Rename(path.Join(vdirPath2, testFileName), path.Join(vdirPath2, testFileName+".rename")) + assert.NoError(t, err) + err = client.Rename(path.Join(vdirPath2, testFileName+".rename"), testFileName+".rename") + assert.Error(t, err) + err = client.Rename(path.Join(vdirPath2, testFileName+".rename"), path.Join(vdirPath1, testFileName)) + assert.Error(t, err) + err = client.Rename(path.Join(vdirPath1, testFileName+".rename"), path.Join(vdirPath2, testFileName)) + assert.Error(t, err) + err = client.Rename(path.Join(vdirPath1, testFileName+".rename"), testFileName) + assert.Error(t, err) + } + _, err = httpd.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath1}, http.StatusOK) + assert.NoError(t, err) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath2}, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + err = os.RemoveAll(mappedPath1) + assert.NoError(t, err) + err = os.RemoveAll(mappedPath2) + assert.NoError(t, err) + } + err = os.Remove(testFilePath) + assert.NoError(t, err) +} + +func TestVirtualFoldersQuotaRenameOverwrite(t *testing.T) { + usePubKey := true + testFileSize := int64(131072) + testFileName := "test_file.dat" + testFilePath := filepath.Join(homeBasePath, testFileName) + testFileSize1 := int64(65537) + testFileName1 := "test_file1.dat" + testFilePath1 := filepath.Join(homeBasePath, testFileName1) + err := createTestFile(testFilePath, testFileSize) + assert.NoError(t, err) + err = createTestFile(testFilePath1, testFileSize1) + assert.NoError(t, err) u := getTestUser(usePubKey) - u.QuotaFiles = 100 + u.QuotaFiles = 0 + u.QuotaSize = 0 mappedPath1 := filepath.Join(os.TempDir(), "vdir1") vdirPath1 := "/vdir1" mappedPath2 := filepath.Join(os.TempDir(), "vdir2") vdirPath2 := "/vdir2" u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath1, + }, VirtualPath: vdirPath1, - MappedPath: mappedPath1, + QuotaFiles: 2, + QuotaSize: 0, }) u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ - VirtualPath: vdirPath2, - MappedPath: mappedPath2, - ExcludeFromQuota: true, + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath2, + }, + VirtualPath: vdirPath2, + QuotaFiles: 0, + QuotaSize: testFileSize + testFileSize1 - 1, + }) + err = os.MkdirAll(mappedPath1, 0777) + assert.NoError(t, err) + err = os.MkdirAll(mappedPath2, 0777) + assert.NoError(t, err) + user, _, err := httpd.AddUser(u, http.StatusOK) + assert.NoError(t, err) + client, err := getSftpClient(user, usePubKey) + if assert.NoError(t, err) { + defer client.Close() + err = sftpUploadFile(testFilePath, path.Join(vdirPath1, testFileName), testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, path.Join(vdirPath2, testFileName), testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath1, path.Join(vdirPath1, testFileName1), testFileSize1, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath1, path.Join(vdirPath2, testFileName1), testFileSize1, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, testFileName, testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath1, testFileName1, testFileSize1, client) + assert.NoError(t, err) + err = client.Rename(testFileName, path.Join(vdirPath1, testFileName+".rename")) + assert.Error(t, err) + // we overwrite an existing file and we have unlimited size + err = client.Rename(testFileName, path.Join(vdirPath1, testFileName)) + assert.NoError(t, err) + // we have no space and we try to overwrite a bigger file with a smaller one, this should succeed + err = client.Rename(testFileName1, path.Join(vdirPath2, testFileName)) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, path.Join(vdirPath2, testFileName), testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, testFileName, testFileSize, client) + assert.NoError(t, err) + // we have no space and we try to overwrite a smaller file with a bigger one, this should fail + err = client.Rename(testFileName, path.Join(vdirPath2, testFileName1)) + assert.Error(t, err) + } + _, err = httpd.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath1}, http.StatusOK) + assert.NoError(t, err) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath2}, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + err = os.RemoveAll(mappedPath1) + assert.NoError(t, err) + err = os.RemoveAll(mappedPath2) + assert.NoError(t, err) + err = os.Remove(testFilePath) + assert.NoError(t, err) + err = os.Remove(testFilePath1) + assert.NoError(t, err) +} + +func TestVirtualFoldersQuotaValues(t *testing.T) { + usePubKey := false + u := getTestUser(usePubKey) + u.QuotaFiles = 100 + mappedPath1 := filepath.Join(os.TempDir(), "vdir1") + vdirPath1 := "/vdir1" //nolint:goconst + mappedPath2 := filepath.Join(os.TempDir(), "vdir2") + vdirPath2 := "/vdir2" //nolint:goconst + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath1, + }, + VirtualPath: vdirPath1, + // quota is included in the user's one + QuotaFiles: -1, + QuotaSize: -1, + }) + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath2, + }, + VirtualPath: vdirPath2, + // quota is unlimited and excluded from user's one + QuotaFiles: 0, + QuotaSize: 0, }) err := os.MkdirAll(mappedPath1, 0777) assert.NoError(t, err) @@ -1978,6 +2335,9 @@ func TestVirtualFoldersQuota(t *testing.T) { assert.NoError(t, err) err = sftpUploadFile(testFilePath, testFileName, testFileSize, client) assert.NoError(t, err) + // we copy the same file two times to test quota update on file overwrite + err = sftpUploadFile(testFilePath, path.Join(vdirPath1, testFileName), testFileSize, client) + assert.NoError(t, err) err = sftpUploadFile(testFilePath, path.Join(vdirPath1, testFileName), testFileSize, client) assert.NoError(t, err) err = sftpUploadFile(testFilePath, path.Join(vdirPath2, testFileName), testFileSize, client) @@ -1990,15 +2350,1288 @@ func TestVirtualFoldersQuota(t *testing.T) { assert.NoError(t, err) assert.Equal(t, expectedQuotaFiles, user.UsedQuotaFiles) assert.Equal(t, expectedQuotaSize, user.UsedQuotaSize) + folder, _, err := httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize, f.UsedQuotaSize) + assert.Equal(t, 1, f.UsedQuotaFiles) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize, f.UsedQuotaSize) + assert.Equal(t, 1, f.UsedQuotaFiles) + } + + err = client.Remove(path.Join(vdirPath1, testFileName)) + assert.NoError(t, err) err = client.Remove(path.Join(vdirPath2, testFileName)) assert.NoError(t, err) + + folder, _, err = httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, int64(0), f.UsedQuotaSize) + assert.Equal(t, 0, f.UsedQuotaFiles) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, int64(0), f.UsedQuotaSize) + assert.Equal(t, 0, f.UsedQuotaFiles) + } + err = os.Remove(testFilePath) + assert.NoError(t, err) + } + _, err = httpd.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath1}, http.StatusOK) + assert.NoError(t, err) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath2}, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + err = os.RemoveAll(mappedPath1) + assert.NoError(t, err) + err = os.RemoveAll(mappedPath2) + assert.NoError(t, err) +} + +func TestQuotaRenameInsideSameVirtualFolder(t *testing.T) { + usePubKey := false + u := getTestUser(usePubKey) + u.QuotaFiles = 100 + mappedPath1 := filepath.Join(os.TempDir(), "vdir1") + vdirPath1 := "/vdir1" + mappedPath2 := filepath.Join(os.TempDir(), "vdir2") + vdirPath2 := "/vdir2" + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath1, + }, + VirtualPath: vdirPath1, + // quota is included in the user's one + QuotaFiles: -1, + QuotaSize: -1, + }) + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath2, + }, + VirtualPath: vdirPath2, + // quota is unlimited and excluded from user's one + QuotaFiles: 0, + QuotaSize: 0, + }) + err := os.MkdirAll(mappedPath1, 0777) + assert.NoError(t, err) + err = os.MkdirAll(mappedPath2, 0777) + assert.NoError(t, err) + user, _, err := httpd.AddUser(u, http.StatusOK) + assert.NoError(t, err) + client, err := getSftpClient(user, usePubKey) + if assert.NoError(t, err) { + defer client.Close() + testFileName := "test_file.dat" + testFileName1 := "test_file1.dat" + testFileSize := int64(131072) + testFileSize1 := int64(65535) + testFilePath := filepath.Join(homeBasePath, testFileName) + testFilePath1 := filepath.Join(homeBasePath, testFileName1) + dir1 := "dir1" //nolint:goconst + dir2 := "dir2" //nolint:goconst + err = createTestFile(testFilePath, testFileSize) + assert.NoError(t, err) + err = createTestFile(testFilePath1, testFileSize1) + assert.NoError(t, err) + err = client.Mkdir(path.Join(vdirPath1, dir1)) + assert.NoError(t, err) + err = client.Mkdir(path.Join(vdirPath1, dir2)) + assert.NoError(t, err) + err = client.Mkdir(path.Join(vdirPath2, dir1)) + assert.NoError(t, err) + err = client.Mkdir(path.Join(vdirPath2, dir2)) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, path.Join(vdirPath1, dir1, testFileName), testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath1, path.Join(vdirPath1, dir2, testFileName1), testFileSize1, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, path.Join(vdirPath2, dir1, testFileName), testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath1, path.Join(vdirPath2, dir2, testFileName1), testFileSize1, client) + assert.NoError(t, err) user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) assert.NoError(t, err) - assert.Equal(t, expectedQuotaFiles, user.UsedQuotaFiles) - assert.Equal(t, expectedQuotaSize, user.UsedQuotaSize) + assert.Equal(t, 2, user.UsedQuotaFiles) + assert.Equal(t, testFileSize+testFileSize1, user.UsedQuotaSize) + folder, _, err := httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize) + assert.Equal(t, 2, f.UsedQuotaFiles) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize) + assert.Equal(t, 2, f.UsedQuotaFiles) + } + // initial files: + // - vdir1/dir1/testFileName + // - vdir1/dir2/testFileName1 + // - vdir2/dir1/testFileName + // - vdir2/dir2/testFileName1 + // + // rename a file inside vdir1 it is included inside user quota, so we have: + // - vdir1/dir1/testFileName.rename + // - vdir1/dir2/testFileName1 + // - vdir2/dir1/testFileName + // - vdir2/dir2/testFileName1 + err = client.Rename(path.Join(vdirPath1, dir1, testFileName), path.Join(vdirPath1, dir1, testFileName+".rename")) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 2, user.UsedQuotaFiles) + assert.Equal(t, testFileSize+testFileSize1, user.UsedQuotaSize) + folder, _, err = httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize) + assert.Equal(t, 2, f.UsedQuotaFiles) + } + // rename a file inside vdir2, it isn't included inside user quota, so we have: + // - vdir1/dir1/testFileName.rename + // - vdir1/dir2/testFileName1 + // - vdir2/dir1/testFileName.rename + // - vdir2/dir2/testFileName1 + err = client.Rename(path.Join(vdirPath2, dir1, testFileName), path.Join(vdirPath2, dir1, testFileName+".rename")) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 2, user.UsedQuotaFiles) + assert.Equal(t, testFileSize+testFileSize1, user.UsedQuotaSize) + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize) + assert.Equal(t, 2, f.UsedQuotaFiles) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize) + assert.Equal(t, 2, f.UsedQuotaFiles) + } + // rename a file inside vdir2 overwriting an existing, we now have: + // - vdir1/dir1/testFileName.rename + // - vdir1/dir2/testFileName1 + // - vdir2/dir1/testFileName.rename (initial testFileName1) + err = client.Rename(path.Join(vdirPath2, dir2, testFileName1), path.Join(vdirPath2, dir1, testFileName+".rename")) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 2, user.UsedQuotaFiles) + assert.Equal(t, testFileSize+testFileSize1, user.UsedQuotaSize) + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize1, f.UsedQuotaSize) + assert.Equal(t, 1, f.UsedQuotaFiles) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize) + assert.Equal(t, 2, f.UsedQuotaFiles) + } + // rename a file inside vdir1 overwriting an existing, we now have: + // - vdir1/dir1/testFileName.rename (initial testFileName1) + // - vdir2/dir1/testFileName.rename (initial testFileName1) + err = client.Rename(path.Join(vdirPath1, dir2, testFileName1), path.Join(vdirPath1, dir1, testFileName+".rename")) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 1, user.UsedQuotaFiles) + assert.Equal(t, testFileSize1, user.UsedQuotaSize) + folder, _, err = httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize1, f.UsedQuotaSize) + assert.Equal(t, 1, f.UsedQuotaFiles) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize1, f.UsedQuotaSize) + assert.Equal(t, 1, f.UsedQuotaFiles) + } + // rename a directory inside the same virtual folder, quota should not change + err = client.RemoveDirectory(path.Join(vdirPath1, dir2)) + assert.NoError(t, err) + err = client.RemoveDirectory(path.Join(vdirPath2, dir2)) + assert.NoError(t, err) + err = client.Rename(path.Join(vdirPath1, dir1), path.Join(vdirPath1, dir2)) + assert.NoError(t, err) + err = client.Rename(path.Join(vdirPath2, dir1), path.Join(vdirPath2, dir2)) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 1, user.UsedQuotaFiles) + assert.Equal(t, testFileSize1, user.UsedQuotaSize) + folder, _, err = httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize1, f.UsedQuotaSize) + assert.Equal(t, 1, f.UsedQuotaFiles) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize1, f.UsedQuotaSize) + assert.Equal(t, 1, f.UsedQuotaFiles) + } + + err = os.Remove(testFilePath) + assert.NoError(t, err) + err = os.Remove(testFilePath1) + assert.NoError(t, err) } _, err = httpd.RemoveUser(user, http.StatusOK) assert.NoError(t, err) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath1}, http.StatusOK) + assert.NoError(t, err) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath2}, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + err = os.RemoveAll(mappedPath1) + assert.NoError(t, err) + err = os.RemoveAll(mappedPath2) + assert.NoError(t, err) +} + +func TestQuotaRenameBetweenVirtualFolder(t *testing.T) { + usePubKey := true + u := getTestUser(usePubKey) + u.QuotaFiles = 100 + mappedPath1 := filepath.Join(os.TempDir(), "vdir1") + vdirPath1 := "/vdir1" + mappedPath2 := filepath.Join(os.TempDir(), "vdir2") + vdirPath2 := "/vdir2" + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath1, + }, + VirtualPath: vdirPath1, + // quota is included in the user's one + QuotaFiles: -1, + QuotaSize: -1, + }) + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath2, + }, + VirtualPath: vdirPath2, + // quota is unlimited and excluded from user's one + QuotaFiles: 0, + QuotaSize: 0, + }) + err := os.MkdirAll(mappedPath1, 0777) + assert.NoError(t, err) + err = os.MkdirAll(mappedPath2, 0777) + assert.NoError(t, err) + user, _, err := httpd.AddUser(u, http.StatusOK) + assert.NoError(t, err) + client, err := getSftpClient(user, usePubKey) + if assert.NoError(t, err) { + defer client.Close() + testFileName := "test_file.dat" + testFileName1 := "test_file1.dat" + testFileSize := int64(131072) + testFileSize1 := int64(65535) + testFilePath := filepath.Join(homeBasePath, testFileName) + testFilePath1 := filepath.Join(homeBasePath, testFileName1) + dir1 := "dir1" + dir2 := "dir2" + err = createTestFile(testFilePath, testFileSize) + assert.NoError(t, err) + err = createTestFile(testFilePath1, testFileSize1) + assert.NoError(t, err) + err = client.Mkdir(path.Join(vdirPath1, dir1)) + assert.NoError(t, err) + err = client.Mkdir(path.Join(vdirPath1, dir2)) + assert.NoError(t, err) + err = client.Mkdir(path.Join(vdirPath2, dir1)) + assert.NoError(t, err) + err = client.Mkdir(path.Join(vdirPath2, dir2)) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, path.Join(vdirPath1, dir1, testFileName), testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath1, path.Join(vdirPath1, dir2, testFileName1), testFileSize1, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, path.Join(vdirPath2, dir1, testFileName), testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath1, path.Join(vdirPath2, dir2, testFileName1), testFileSize1, client) + assert.NoError(t, err) + // initial files: + // - vdir1/dir1/testFileName + // - vdir1/dir2/testFileName1 + // - vdir2/dir1/testFileName + // - vdir2/dir2/testFileName1 + // + // rename a file from vdir1 to vdir2, vdir1 is included inside user quota, so we have: + // - vdir1/dir1/testFileName + // - vdir2/dir1/testFileName + // - vdir2/dir2/testFileName1 + // - vdir2/dir1/testFileName1.rename + err = client.Rename(path.Join(vdirPath1, dir2, testFileName1), path.Join(vdirPath2, dir1, testFileName1+".rename")) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 1, user.UsedQuotaFiles) + assert.Equal(t, testFileSize, user.UsedQuotaSize) + folder, _, err := httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize, f.UsedQuotaSize) + assert.Equal(t, 1, f.UsedQuotaFiles) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize+testFileSize1+testFileSize1, f.UsedQuotaSize) + assert.Equal(t, 3, f.UsedQuotaFiles) + } + // rename a file from vdir2 to vdir1, vdir2 is not included inside user quota, so we have: + // - vdir1/dir1/testFileName + // - vdir1/dir2/testFileName.rename + // - vdir2/dir2/testFileName1 + // - vdir2/dir1/testFileName1.rename + err = client.Rename(path.Join(vdirPath2, dir1, testFileName), path.Join(vdirPath1, dir2, testFileName+".rename")) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 2, user.UsedQuotaFiles) + assert.Equal(t, testFileSize*2, user.UsedQuotaSize) + folder, _, err = httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize*2, f.UsedQuotaSize) + assert.Equal(t, 2, f.UsedQuotaFiles) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize1*2, f.UsedQuotaSize) + assert.Equal(t, 2, f.UsedQuotaFiles) + } + // rename a file from vdir1 to vdir2 overwriting an existing file, vdir1 is included inside user quota, so we have: + // - vdir1/dir2/testFileName.rename + // - vdir2/dir2/testFileName1 (is the initial testFileName) + // - vdir2/dir1/testFileName1.rename + err = client.Rename(path.Join(vdirPath1, dir1, testFileName), path.Join(vdirPath2, dir2, testFileName1)) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 1, user.UsedQuotaFiles) + assert.Equal(t, testFileSize, user.UsedQuotaSize) + folder, _, err = httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize, f.UsedQuotaSize) + assert.Equal(t, 1, f.UsedQuotaFiles) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize1+testFileSize, f.UsedQuotaSize) + assert.Equal(t, 2, f.UsedQuotaFiles) + } + // rename a file from vdir2 to vdir1 overwriting an existing file, vdir2 is not included inside user quota, so we have: + // - vdir1/dir2/testFileName.rename (is the initial testFileName1) + // - vdir2/dir2/testFileName1 (is the initial testFileName) + err = client.Rename(path.Join(vdirPath2, dir1, testFileName1+".rename"), path.Join(vdirPath1, dir2, testFileName+".rename")) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 1, user.UsedQuotaFiles) + assert.Equal(t, testFileSize1, user.UsedQuotaSize) + folder, _, err = httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize1, f.UsedQuotaSize) + assert.Equal(t, 1, f.UsedQuotaFiles) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize, f.UsedQuotaSize) + assert.Equal(t, 1, f.UsedQuotaFiles) + } + + err = sftpUploadFile(testFilePath, path.Join(vdirPath1, dir2, testFileName), testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath1, path.Join(vdirPath2, dir2, testFileName), testFileSize1, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath1, path.Join(vdirPath2, dir2, testFileName+"1.dupl"), testFileSize1, client) + assert.NoError(t, err) + err = client.RemoveDirectory(path.Join(vdirPath1, dir1)) + assert.NoError(t, err) + err = client.RemoveDirectory(path.Join(vdirPath2, dir1)) + assert.NoError(t, err) + // - vdir1/dir2/testFileName.rename (initial testFileName1) + // - vdir1/dir2/testFileName + // - vdir2/dir2/testFileName1 (initial testFileName) + // - vdir2/dir2/testFileName (initial testFileName1) + // - vdir2/dir2/testFileName1.dupl + // rename directories between the two virtual folders + err = client.Rename(path.Join(vdirPath2, dir2), path.Join(vdirPath1, dir1)) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 5, user.UsedQuotaFiles) + assert.Equal(t, testFileSize1*3+testFileSize*2, user.UsedQuotaSize) + folder, _, err = httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize1*3+testFileSize*2, f.UsedQuotaSize) + assert.Equal(t, 5, f.UsedQuotaFiles) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, int64(0), f.UsedQuotaSize) + assert.Equal(t, 0, f.UsedQuotaFiles) + } + // now move on vpath2 + err = client.Rename(path.Join(vdirPath1, dir2), path.Join(vdirPath2, dir1)) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 3, user.UsedQuotaFiles) + assert.Equal(t, testFileSize1*2+testFileSize, user.UsedQuotaSize) + folder, _, err = httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize1*2+testFileSize, f.UsedQuotaSize) + assert.Equal(t, 3, f.UsedQuotaFiles) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize) + assert.Equal(t, 2, f.UsedQuotaFiles) + } + + err = os.Remove(testFilePath) + assert.NoError(t, err) + err = os.Remove(testFilePath1) + assert.NoError(t, err) + } + _, err = httpd.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath1}, http.StatusOK) + assert.NoError(t, err) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath2}, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + err = os.RemoveAll(mappedPath1) + assert.NoError(t, err) + err = os.RemoveAll(mappedPath2) + assert.NoError(t, err) +} + +func TestQuotaRenameFromVirtualFolder(t *testing.T) { + usePubKey := false + u := getTestUser(usePubKey) + u.QuotaFiles = 100 + mappedPath1 := filepath.Join(os.TempDir(), "vdir1") + vdirPath1 := "/vdir1" + mappedPath2 := filepath.Join(os.TempDir(), "vdir2") + vdirPath2 := "/vdir2" + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath1, + }, + VirtualPath: vdirPath1, + // quota is included in the user's one + QuotaFiles: -1, + QuotaSize: -1, + }) + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath2, + }, + VirtualPath: vdirPath2, + // quota is unlimited and excluded from user's one + QuotaFiles: 0, + QuotaSize: 0, + }) + err := os.MkdirAll(mappedPath1, 0777) + assert.NoError(t, err) + err = os.MkdirAll(mappedPath2, 0777) + assert.NoError(t, err) + user, _, err := httpd.AddUser(u, http.StatusOK) + assert.NoError(t, err) + client, err := getSftpClient(user, usePubKey) + if assert.NoError(t, err) { + defer client.Close() + testFileName := "test_file.dat" + testFileName1 := "test_file1.dat" + testFileSize := int64(131072) + testFileSize1 := int64(65535) + testFilePath := filepath.Join(homeBasePath, testFileName) + testFilePath1 := filepath.Join(homeBasePath, testFileName1) + dir1 := "dir1" + dir2 := "dir2" + err = createTestFile(testFilePath, testFileSize) + assert.NoError(t, err) + err = createTestFile(testFilePath1, testFileSize1) + assert.NoError(t, err) + err = client.Mkdir(path.Join(vdirPath1, dir1)) + assert.NoError(t, err) + err = client.Mkdir(path.Join(vdirPath1, dir2)) + assert.NoError(t, err) + err = client.Mkdir(path.Join(vdirPath2, dir1)) + assert.NoError(t, err) + err = client.Mkdir(path.Join(vdirPath2, dir2)) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, path.Join(vdirPath1, dir1, testFileName), testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath1, path.Join(vdirPath1, dir2, testFileName1), testFileSize1, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, path.Join(vdirPath2, dir1, testFileName), testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath1, path.Join(vdirPath2, dir2, testFileName1), testFileSize1, client) + assert.NoError(t, err) + // initial files: + // - vdir1/dir1/testFileName + // - vdir1/dir2/testFileName1 + // - vdir2/dir1/testFileName + // - vdir2/dir2/testFileName1 + // + // rename a file from vdir1 to the user home dir, vdir1 is included in user quota so we have: + // - testFileName + // - vdir1/dir2/testFileName1 + // - vdir2/dir1/testFileName + // - vdir2/dir2/testFileName1 + err = client.Rename(path.Join(vdirPath1, dir1, testFileName), path.Join(testFileName)) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 2, user.UsedQuotaFiles) + assert.Equal(t, testFileSize+testFileSize1, user.UsedQuotaSize) + folder, _, err := httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize1, f.UsedQuotaSize) + assert.Equal(t, 1, f.UsedQuotaFiles) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize) + assert.Equal(t, 2, f.UsedQuotaFiles) + } + // rename a file from vdir2 to the user home dir, vdir2 is not included in user quota so we have: + // - testFileName + // - testFileName1 + // - vdir1/dir2/testFileName1 + // - vdir2/dir1/testFileName + err = client.Rename(path.Join(vdirPath2, dir2, testFileName1), path.Join(testFileName1)) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 3, user.UsedQuotaFiles) + assert.Equal(t, testFileSize+testFileSize1+testFileSize1, user.UsedQuotaSize) + folder, _, err = httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize1, f.UsedQuotaSize) + assert.Equal(t, 1, f.UsedQuotaFiles) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize, f.UsedQuotaSize) + assert.Equal(t, 1, f.UsedQuotaFiles) + } + // rename a file from vdir1 to the user home dir overwriting an existing file, vdir1 is included in user quota so we have: + // - testFileName (initial testFileName1) + // - testFileName1 + // - vdir2/dir1/testFileName + err = client.Rename(path.Join(vdirPath1, dir2, testFileName1), path.Join(testFileName)) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 2, user.UsedQuotaFiles) + assert.Equal(t, testFileSize1+testFileSize1, user.UsedQuotaSize) + folder, _, err = httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, int64(0), f.UsedQuotaSize) + assert.Equal(t, 0, f.UsedQuotaFiles) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize, f.UsedQuotaSize) + assert.Equal(t, 1, f.UsedQuotaFiles) + } + // rename a file from vdir2 to the user home dir overwriting an existing file, vdir2 is not included in user quota so we have: + // - testFileName (initial testFileName1) + // - testFileName1 (initial testFileName) + err = client.Rename(path.Join(vdirPath2, dir1, testFileName), path.Join(testFileName1)) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 2, user.UsedQuotaFiles) + assert.Equal(t, testFileSize+testFileSize1, user.UsedQuotaSize) + folder, _, err = httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, int64(0), f.UsedQuotaSize) + assert.Equal(t, 0, f.UsedQuotaFiles) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, int64(0), f.UsedQuotaSize) + assert.Equal(t, 0, f.UsedQuotaFiles) + } + // dir rename + err = sftpUploadFile(testFilePath, path.Join(vdirPath1, dir1, testFileName), testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath1, path.Join(vdirPath1, dir1, testFileName1), testFileSize1, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, path.Join(vdirPath2, dir1, testFileName), testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath1, path.Join(vdirPath2, dir1, testFileName1), testFileSize1, client) + assert.NoError(t, err) + // - testFileName (initial testFileName1) + // - testFileName1 (initial testFileName) + // - vdir1/dir1/testFileName + // - vdir1/dir1/testFileName1 + // - dir1/testFileName + // - dir1/testFileName1 + err = client.Rename(path.Join(vdirPath2, dir1), dir1) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 6, user.UsedQuotaFiles) + assert.Equal(t, testFileSize*3+testFileSize1*3, user.UsedQuotaSize) + folder, _, err = httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize+testFileSize1, f.UsedQuotaSize) + assert.Equal(t, 2, f.UsedQuotaFiles) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, int64(0), f.UsedQuotaSize) + assert.Equal(t, 0, f.UsedQuotaFiles) + } + // - testFileName (initial testFileName1) + // - testFileName1 (initial testFileName) + // - dir2/testFileName + // - dir2/testFileName1 + // - dir1/testFileName + // - dir1/testFileName1 + err = client.Rename(path.Join(vdirPath1, dir1), dir2) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 6, user.UsedQuotaFiles) + assert.Equal(t, testFileSize*3+testFileSize1*3, user.UsedQuotaSize) + folder, _, err = httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, int64(0), f.UsedQuotaSize) + assert.Equal(t, 0, f.UsedQuotaFiles) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, int64(0), f.UsedQuotaSize) + assert.Equal(t, 0, f.UsedQuotaFiles) + } + + err = os.Remove(testFilePath) + assert.NoError(t, err) + err = os.Remove(testFilePath1) + assert.NoError(t, err) + } + _, err = httpd.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath1}, http.StatusOK) + assert.NoError(t, err) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath2}, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + err = os.RemoveAll(mappedPath1) + assert.NoError(t, err) + err = os.RemoveAll(mappedPath2) + assert.NoError(t, err) +} + +func TestQuotaRenameToVirtualFolder(t *testing.T) { + usePubKey := true + u := getTestUser(usePubKey) + u.QuotaFiles = 100 + mappedPath1 := filepath.Join(os.TempDir(), "vdir1") + vdirPath1 := "/vdir1" + mappedPath2 := filepath.Join(os.TempDir(), "vdir2") + vdirPath2 := "/vdir2" + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath1, + }, + VirtualPath: vdirPath1, + // quota is included in the user's one + QuotaFiles: -1, + QuotaSize: -1, + }) + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath2, + }, + VirtualPath: vdirPath2, + // quota is unlimited and excluded from user's one + QuotaFiles: 0, + QuotaSize: 0, + }) + err := os.MkdirAll(mappedPath1, 0777) + assert.NoError(t, err) + err = os.MkdirAll(mappedPath2, 0777) + assert.NoError(t, err) + user, _, err := httpd.AddUser(u, http.StatusOK) + assert.NoError(t, err) + client, err := getSftpClient(user, usePubKey) + if assert.NoError(t, err) { + defer client.Close() + testFileName := "test_file.dat" + testFileName1 := "test_file1.dat" + testFileSize := int64(131072) + testFileSize1 := int64(65535) + testFilePath := filepath.Join(homeBasePath, testFileName) + testFilePath1 := filepath.Join(homeBasePath, testFileName1) + dir1 := "dir1" + dir2 := "dir2" + err = createTestFile(testFilePath, testFileSize) + assert.NoError(t, err) + err = createTestFile(testFilePath1, testFileSize1) + assert.NoError(t, err) + err = client.Mkdir(path.Join(vdirPath1, dir1)) + assert.NoError(t, err) + err = client.Mkdir(path.Join(vdirPath1, dir2)) + assert.NoError(t, err) + err = client.Mkdir(path.Join(vdirPath2, dir1)) + assert.NoError(t, err) + err = client.Mkdir(path.Join(vdirPath2, dir2)) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, testFileName, testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath1, testFileName1, testFileSize1, client) + assert.NoError(t, err) + // initial files: + // - testFileName + // - testFileName1 + // + // rename a file from user home dir to vdir1, vdir1 is included in user quota so we have: + // - testFileName + // - /vdir1/dir1/testFileName1 + err = client.Rename(testFileName1, path.Join(vdirPath1, dir1, testFileName1)) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 2, user.UsedQuotaFiles) + assert.Equal(t, testFileSize+testFileSize1, user.UsedQuotaSize) + folder, _, err := httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize1, f.UsedQuotaSize) + assert.Equal(t, 1, f.UsedQuotaFiles) + } + // rename a file from user home dir to vdir2, vdir2 is not included in user quota so we have: + // - /vdir2/dir1/testFileName + // - /vdir1/dir1/testFileName1 + err = client.Rename(testFileName, path.Join(vdirPath2, dir1, testFileName)) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 1, user.UsedQuotaFiles) + assert.Equal(t, testFileSize1, user.UsedQuotaSize) + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize, f.UsedQuotaSize) + assert.Equal(t, 1, f.UsedQuotaFiles) + } + // upload two new files to the user home dir so we have: + // - testFileName + // - testFileName1 + // - /vdir1/dir1/testFileName1 + // - /vdir2/dir1/testFileName + err = sftpUploadFile(testFilePath, testFileName, testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath1, testFileName1, testFileSize1, client) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 3, user.UsedQuotaFiles) + assert.Equal(t, testFileSize+testFileSize1+testFileSize1, user.UsedQuotaSize) + // rename a file from user home dir to vdir1 overwriting an existing file, vdir1 is included in user quota so we have: + // - testFileName1 + // - /vdir1/dir1/testFileName1 (initial testFileName) + // - /vdir2/dir1/testFileName + err = client.Rename(testFileName, path.Join(vdirPath1, dir1, testFileName1)) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 2, user.UsedQuotaFiles) + assert.Equal(t, testFileSize+testFileSize1, user.UsedQuotaSize) + folder, _, err = httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize, f.UsedQuotaSize) + assert.Equal(t, 1, f.UsedQuotaFiles) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize, f.UsedQuotaSize) + assert.Equal(t, 1, f.UsedQuotaFiles) + } + // rename a file from user home dir to vdir2 overwriting an existing file, vdir2 is not included in user quota so we have: + // - /vdir1/dir1/testFileName1 (initial testFileName) + // - /vdir2/dir1/testFileName (initial testFileName1) + err = client.Rename(testFileName1, path.Join(vdirPath2, dir1, testFileName)) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 1, user.UsedQuotaFiles) + assert.Equal(t, testFileSize, user.UsedQuotaSize) + folder, _, err = httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize, f.UsedQuotaSize) + assert.Equal(t, 1, f.UsedQuotaFiles) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize1, f.UsedQuotaSize) + assert.Equal(t, 1, f.UsedQuotaFiles) + } + + err = client.Mkdir(dir1) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, path.Join(dir1, testFileName), testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath1, path.Join(dir1, testFileName1), testFileSize1, client) + assert.NoError(t, err) + // - /dir1/testFileName + // - /dir1/testFileName1 + // - /vdir1/dir1/testFileName1 (initial testFileName) + // - /vdir2/dir1/testFileName (initial testFileName1) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 3, user.UsedQuotaFiles) + assert.Equal(t, testFileSize*2+testFileSize1, user.UsedQuotaSize) + folder, _, err = httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize, f.UsedQuotaSize) + assert.Equal(t, 1, f.UsedQuotaFiles) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize1, f.UsedQuotaSize) + assert.Equal(t, 1, f.UsedQuotaFiles) + } + // - /vdir1/adir/testFileName + // - /vdir1/adir/testFileName1 + // - /vdir1/dir1/testFileName1 (initial testFileName) + // - /vdir2/dir1/testFileName (initial testFileName1) + err = client.Rename(dir1, path.Join(vdirPath1, "adir")) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 3, user.UsedQuotaFiles) + assert.Equal(t, testFileSize*2+testFileSize1, user.UsedQuotaSize) + folder, _, err = httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize*2+testFileSize1, f.UsedQuotaSize) + assert.Equal(t, 3, f.UsedQuotaFiles) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize1, f.UsedQuotaSize) + assert.Equal(t, 1, f.UsedQuotaFiles) + } + err = client.Mkdir(dir1) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, path.Join(dir1, testFileName), testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath1, path.Join(dir1, testFileName1), testFileSize1, client) + assert.NoError(t, err) + // - /vdir1/adir/testFileName + // - /vdir1/adir/testFileName1 + // - /vdir1/dir1/testFileName1 (initial testFileName) + // - /vdir2/dir1/testFileName (initial testFileName1) + // - /vdir2/adir/testFileName + // - /vdir2/adir/testFileName1 + err = client.Rename(dir1, path.Join(vdirPath2, "adir")) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 3, user.UsedQuotaFiles) + assert.Equal(t, testFileSize*2+testFileSize1, user.UsedQuotaSize) + folder, _, err = httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize*2+testFileSize1, f.UsedQuotaSize) + assert.Equal(t, 3, f.UsedQuotaFiles) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize1*2+testFileSize, f.UsedQuotaSize) + assert.Equal(t, 3, f.UsedQuotaFiles) + } + + err = os.Remove(testFilePath) + assert.NoError(t, err) + err = os.Remove(testFilePath1) + assert.NoError(t, err) + } + _, err = httpd.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath1}, http.StatusOK) + assert.NoError(t, err) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath2}, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + err = os.RemoveAll(mappedPath1) + assert.NoError(t, err) + err = os.RemoveAll(mappedPath2) + assert.NoError(t, err) +} + +func TestVirtualFoldersLink(t *testing.T) { + usePubKey := true + u := getTestUser(usePubKey) + mappedPath1 := filepath.Join(os.TempDir(), "vdir1") + vdirPath1 := "/vdir1" + mappedPath2 := filepath.Join(os.TempDir(), "vdir2") + vdirPath2 := "/vdir2" + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath1, + }, + VirtualPath: vdirPath1, + // quota is included in the user's one + QuotaFiles: -1, + QuotaSize: -1, + }) + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath2, + }, + VirtualPath: vdirPath2, + // quota is unlimited and excluded from user's one + QuotaFiles: 0, + QuotaSize: 0, + }) + err := os.MkdirAll(mappedPath1, 0777) + assert.NoError(t, err) + err = os.MkdirAll(mappedPath2, 0777) + assert.NoError(t, err) + user, _, err := httpd.AddUser(u, http.StatusOK) + assert.NoError(t, err) + client, err := getSftpClient(user, usePubKey) + if assert.NoError(t, err) { + defer client.Close() + testFileName := "test_file.dat" + testFileSize := int64(131072) + testFilePath := filepath.Join(homeBasePath, testFileName) + testDir := "adir" + err = createTestFile(testFilePath, testFileSize) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, testFileName, testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, path.Join(vdirPath1, testFileName), testFileSize, client) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, path.Join(vdirPath2, testFileName), testFileSize, client) + assert.NoError(t, err) + err = client.Mkdir(path.Join(vdirPath1, testDir)) + assert.NoError(t, err) + err = client.Mkdir(path.Join(vdirPath2, testDir)) + assert.NoError(t, err) + err = client.Symlink(testFileName, testFileName+".link") + assert.NoError(t, err) + err = client.Symlink(path.Join(vdirPath1, testFileName), path.Join(vdirPath1, testFileName+".link")) + assert.NoError(t, err) + err = client.Symlink(path.Join(vdirPath1, testFileName), path.Join(vdirPath1, testDir, testFileName+".link")) + assert.NoError(t, err) + err = client.Symlink(path.Join(vdirPath2, testFileName), path.Join(vdirPath2, testFileName+".link")) + assert.NoError(t, err) + err = client.Symlink(path.Join(vdirPath2, testFileName), path.Join(vdirPath2, testDir, testFileName+".link")) + assert.NoError(t, err) + err = client.Symlink(testFileName, path.Join(vdirPath1, testFileName+".link1")) + assert.Error(t, err) + err = client.Symlink(testFileName, path.Join(vdirPath1, testDir, testFileName+".link1")) + assert.Error(t, err) + err = client.Symlink(testFileName, path.Join(vdirPath2, testFileName+".link1")) + assert.Error(t, err) + err = client.Symlink(testFileName, path.Join(vdirPath2, testDir, testFileName+".link1")) + assert.Error(t, err) + err = client.Symlink(path.Join(vdirPath1, testFileName), testFileName+".link1") + assert.Error(t, err) + err = client.Symlink(path.Join(vdirPath2, testFileName), testFileName+".link1") + assert.Error(t, err) + err = client.Symlink(path.Join(vdirPath1, testFileName), path.Join(vdirPath2, testDir, testFileName+".link1")) + assert.Error(t, err) + err = client.Symlink(path.Join(vdirPath2, testFileName), path.Join(vdirPath1, testFileName+".link1")) + assert.Error(t, err) + err = os.Remove(testFilePath) + assert.NoError(t, err) + } + _, err = httpd.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath1}, http.StatusOK) + assert.NoError(t, err) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath2}, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) + err = os.RemoveAll(mappedPath1) + assert.NoError(t, err) + err = os.RemoveAll(mappedPath2) + assert.NoError(t, err) +} + +func TestVirtualFolderQuotaScan(t *testing.T) { + mappedPath := filepath.Join(os.TempDir(), "mapped_dir") + err := os.MkdirAll(mappedPath, 0777) + assert.NoError(t, err) + testFileSize := int64(65535) + testFileName := "test_file.dat" + testFilePath := filepath.Join(mappedPath, testFileName) + err = createTestFile(testFilePath, testFileSize) + assert.NoError(t, err) + expectedQuotaSize := testFileSize + expectedQuotaFiles := 1 + folder, _, err := httpd.AddFolder(vfs.BaseVirtualFolder{ + MappedPath: mappedPath, + }, http.StatusOK) + assert.NoError(t, err) + _, err = httpd.StartFolderQuotaScan(folder, http.StatusCreated) + assert.NoError(t, err) + err = waitQuotaScans(1) + assert.NoError(t, err) + folders, _, err := httpd.GetFolders(0, 0, mappedPath, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folders, 1) { + folder = folders[0] + assert.Equal(t, expectedQuotaFiles, folder.UsedQuotaFiles) + assert.Equal(t, expectedQuotaSize, folder.UsedQuotaSize) + } + _, err = httpd.RemoveFolder(folder, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(mappedPath) + assert.NoError(t, err) +} + +func TestVFolderMultipleQuotaScan(t *testing.T) { + folderPath := filepath.Join(os.TempDir(), "folder_path") + res := sftpd.AddVFolderQuotaScan(folderPath) + assert.True(t, res) + res = sftpd.AddVFolderQuotaScan(folderPath) + assert.False(t, res) + err := sftpd.RemoveVFolderQuotaScan(folderPath) + assert.NoError(t, err) + activeScans := sftpd.GetVFoldersQuotaScans() + assert.Len(t, activeScans, 0) + err = sftpd.RemoveVFolderQuotaScan(folderPath) + assert.Error(t, err) +} + +func TestVFolderQuotaSize(t *testing.T) { + usePubKey := false + u := getTestUser(usePubKey) + testFileSize := int64(131072) + u.QuotaFiles = 1 + u.QuotaSize = testFileSize - 1 + mappedPath1 := filepath.Join(os.TempDir(), "vdir1") + vdirPath1 := "/vpath1" + mappedPath2 := filepath.Join(os.TempDir(), "vdir2") + vdirPath2 := "/vpath2" + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath1, + }, + VirtualPath: vdirPath1, + // quota is included in the user's one + QuotaFiles: -1, + QuotaSize: -1, + }) + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath2, + }, + VirtualPath: vdirPath2, + QuotaFiles: 1, + QuotaSize: testFileSize * 2, + }) + err := os.MkdirAll(mappedPath1, 0777) + assert.NoError(t, err) + err = os.MkdirAll(mappedPath2, 0777) + assert.NoError(t, err) + testFileName := "test_file.dat" + testFilePath := filepath.Join(homeBasePath, testFileName) + err = createTestFile(testFilePath, testFileSize) + assert.NoError(t, err) + user, _, err := httpd.AddUser(u, http.StatusOK) + assert.NoError(t, err) + client, err := getSftpClient(user, usePubKey) + if assert.NoError(t, err) { + defer client.Close() + err = sftpUploadFile(testFilePath, testFileName, testFileSize, client) + assert.NoError(t, err) + // vdir1 is included in the user quota so upload must fail + err = sftpUploadFile(testFilePath, path.Join(vdirPath1, testFileName), testFileSize, client) + assert.Error(t, err) + // upload to vdir2 must work, it has its own quota + err = sftpUploadFile(testFilePath, path.Join(vdirPath2, testFileName), testFileSize, client) + assert.NoError(t, err) + // now vdir2 is over quota + err = sftpUploadFile(testFilePath, path.Join(vdirPath2, testFileName+".quota"), testFileSize, client) + assert.Error(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 1, user.UsedQuotaFiles) + assert.Equal(t, testFileSize, user.UsedQuotaSize) + // remove a file + err = client.Remove(testFileName) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 0, user.UsedQuotaFiles) + assert.Equal(t, int64(0), user.UsedQuotaSize) + // upload to vdir1 must work now + err = sftpUploadFile(testFilePath, path.Join(vdirPath1, testFileName), testFileSize, client) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, 1, user.UsedQuotaFiles) + assert.Equal(t, testFileSize, user.UsedQuotaSize) + + folder, _, err := httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize, f.UsedQuotaSize) + assert.Equal(t, 1, f.UsedQuotaFiles) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, testFileSize, f.UsedQuotaSize) + assert.Equal(t, 1, f.UsedQuotaFiles) + } + } + // now create another user with the same shared folder but a different quota limit + u.Username = defaultUsername + "1" + u.VirtualFolders = nil + u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath2, + }, + VirtualPath: vdirPath2, + QuotaFiles: 10, + QuotaSize: testFileSize*2 - 1, + }) + user1, _, err := httpd.AddUser(u, http.StatusOK) + assert.NoError(t, err) + client, err = getSftpClient(user1, usePubKey) + if assert.NoError(t, err) { + defer client.Close() + err = sftpUploadFile(testFilePath, path.Join(vdirPath2, testFileName+".quota"), testFileSize, client) + assert.NoError(t, err) + // the folder is now over quota for size but not for files + err = sftpUploadFile(testFilePath, path.Join(vdirPath2, testFileName+".quota1"), testFileSize, client) + assert.Error(t, err) + } + + _, err = httpd.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + _, err = httpd.RemoveUser(user1, http.StatusOK) + assert.NoError(t, err) + + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath1}, http.StatusOK) + assert.NoError(t, err) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath2}, http.StatusOK) + assert.NoError(t, err) err = os.RemoveAll(user.GetHomeDir()) assert.NoError(t, err) err = os.RemoveAll(mappedPath1) @@ -2352,7 +3985,47 @@ func TestPermRename(t *testing.T) { err = sftpUploadFile(testFilePath, testFileName, testFileSize, client) assert.NoError(t, err) err = client.Rename(testFileName, testFileName+".rename") - assert.Error(t, err, "rename without permission should not succeed") + if assert.Error(t, err) { + assert.Contains(t, err.Error(), permissionErrorString) + } + err = client.Remove(testFileName) + assert.NoError(t, err) + err = os.Remove(testFilePath) + assert.NoError(t, err) + } + _, err = httpd.RemoveUser(user, http.StatusOK) + assert.NoError(t, err) + err = os.RemoveAll(user.GetHomeDir()) + assert.NoError(t, err) +} + +//nolint:dupl +func TestPermRenameOverwrite(t *testing.T) { + usePubKey := false + u := getTestUser(usePubKey) + u.Permissions["/"] = []string{dataprovider.PermListItems, dataprovider.PermDownload, dataprovider.PermUpload, dataprovider.PermDelete, + dataprovider.PermCreateDirs, dataprovider.PermCreateSymlinks, dataprovider.PermChmod, dataprovider.PermRename, + dataprovider.PermChown, dataprovider.PermChtimes} + user, _, err := httpd.AddUser(u, http.StatusOK) + assert.NoError(t, err) + client, err := getSftpClient(user, usePubKey) + if assert.NoError(t, err) { + defer client.Close() + testFileName := "test_file.dat" + testFilePath := filepath.Join(homeBasePath, testFileName) + testFileSize := int64(65535) + err = createTestFile(testFilePath, testFileSize) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, testFileName, testFileSize, client) + assert.NoError(t, err) + err = client.Rename(testFileName, testFileName+".rename") + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, testFileName, testFileSize, client) + assert.NoError(t, err) + err = client.Rename(testFileName, testFileName+".rename") + if assert.Error(t, err) { + assert.Contains(t, err.Error(), permissionErrorString) + } err = client.Remove(testFileName) assert.NoError(t, err) err = os.Remove(testFilePath) @@ -2515,7 +4188,7 @@ func TestSubDirsUploads(t *testing.T) { usePubKey := true u := getTestUser(usePubKey) u.Permissions["/"] = []string{dataprovider.PermAny} - u.Permissions["/subdir"] = []string{dataprovider.PermChtimes, dataprovider.PermDownload} + u.Permissions["/subdir"] = []string{dataprovider.PermChtimes, dataprovider.PermDownload, dataprovider.PermOverwrite} user, _, err := httpd.AddUser(u, http.StatusOK) assert.NoError(t, err) client, err := getSftpClient(user, usePubKey) @@ -2525,6 +4198,7 @@ func TestSubDirsUploads(t *testing.T) { assert.NoError(t, err) testFileName := "test_file.dat" testFileNameSub := "/subdir/test_file_dat" + testDir := "testdir" testFilePath := filepath.Join(homeBasePath, testFileName) testFileSize := int64(65535) err = createTestFile(testFilePath, testFileSize) @@ -2547,6 +4221,22 @@ func TestSubDirsUploads(t *testing.T) { } err = client.Rename(testFileName, testFileName+".rename") assert.NoError(t, err) + err = sftpUploadFile(testFilePath, testFileName, testFileSize, client) + assert.NoError(t, err) + // rename overwriting an existing file + err = client.Rename(testFileName, testFileName+".rename") + assert.NoError(t, err) + // now try to overwrite a directory + err = client.Mkdir(testDir) + assert.NoError(t, err) + err = sftpUploadFile(testFilePath, testFileName, testFileSize, client) + assert.NoError(t, err) + err = client.Rename(testFileName, testDir) + assert.Error(t, err) + err = client.Remove(testFileName) + assert.NoError(t, err) + err = client.Remove(testDir) + assert.NoError(t, err) err = client.Remove(testFileNameSub) if assert.Error(t, err) { assert.Contains(t, err.Error(), permissionErrorString) @@ -2878,10 +4568,12 @@ func TestResolvePaths(t *testing.T) { func TestVirtualRelativePaths(t *testing.T) { user := getTestUser(true) mappedPath := filepath.Join(os.TempDir(), "vdir") - vdirPath := "/vdir" + vdirPath := "/vdir" //nolint:goconst user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath, + }, VirtualPath: vdirPath, - MappedPath: mappedPath, }) err := os.MkdirAll(mappedPath, 0777) assert.NoError(t, err) @@ -2904,8 +4596,10 @@ func TestResolveVirtualPaths(t *testing.T) { mappedPath := filepath.Join(os.TempDir(), "vdir") vdirPath := "/vdir" user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath, + }, VirtualPath: vdirPath, - MappedPath: mappedPath, }) err := os.MkdirAll(mappedPath, 0777) assert.NoError(t, err) @@ -2924,38 +4618,6 @@ func TestResolveVirtualPaths(t *testing.T) { assert.Equal(t, filepath.Join(user.GetHomeDir(), "/vdir1/a.txt"), f) } -func TestVirtualFoldersExcludeQuota(t *testing.T) { - user := getTestUser(true) - mappedPath := filepath.Join(os.TempDir(), "vdir") - vdirPath := "/vdir/sub" - vSubDirPath := path.Join(vdirPath, "subdir", "subdir") - vSubDir1Path := path.Join(vSubDirPath, "subdir", "subdir") - user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{ - VirtualPath: vdirPath, - MappedPath: mappedPath, - ExcludeFromQuota: false, - }) - user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{ - VirtualPath: vSubDir1Path, - MappedPath: mappedPath, - ExcludeFromQuota: false, - }) - user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{ - VirtualPath: vSubDirPath, - MappedPath: mappedPath, - ExcludeFromQuota: true, - }) - - assert.False(t, user.IsFileExcludedFromQuota("/file")) - assert.False(t, user.IsFileExcludedFromQuota(path.Join(vdirPath, "file"))) - assert.True(t, user.IsFileExcludedFromQuota(path.Join(vSubDirPath, "file"))) - assert.True(t, user.IsFileExcludedFromQuota(path.Join(vSubDir1Path, "..", "file"))) - assert.False(t, user.IsFileExcludedFromQuota(path.Join(vSubDir1Path, "file"))) - assert.False(t, user.IsFileExcludedFromQuota(path.Join(vSubDirPath, "..", "file"))) - // we check the parent dir for a file - assert.False(t, user.IsFileExcludedFromQuota(vSubDirPath)) -} - func TestUserPerms(t *testing.T) { user := getTestUser(true) user.Permissions = make(map[string][]string) @@ -3168,6 +4830,50 @@ func TestUserFiltersIPMaskConditions(t *testing.T) { assert.True(t, user.IsLoginFromAddrAllowed("invalid")) } +func TestGetVirtualFolderForPath(t *testing.T) { + user := getTestUser(true) + mappedPath1 := filepath.Join(os.TempDir(), "vpath1") + mappedPath2 := filepath.Join(os.TempDir(), "vpath1") + mappedPath3 := filepath.Join(os.TempDir(), "vpath3") + vdirPath := "/vdir/sub" + vSubDirPath := path.Join(vdirPath, "subdir", "subdir") + vSubDir1Path := path.Join(vSubDirPath, "subdir", "subdir") + user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath1, + }, + VirtualPath: vdirPath, + }) + user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath2, + }, + VirtualPath: vSubDir1Path, + }) + user.VirtualFolders = append(user.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath3, + }, + VirtualPath: vSubDirPath, + }) + folder, err := user.GetVirtualFolderForPath(path.Join(vSubDirPath, "file")) + assert.NoError(t, err) + assert.Equal(t, folder.MappedPath, mappedPath3) + _, err = user.GetVirtualFolderForPath("/file") + assert.Error(t, err) + folder, err = user.GetVirtualFolderForPath(path.Join(vdirPath, "/file")) + assert.NoError(t, err) + assert.Equal(t, folder.MappedPath, mappedPath1) + folder, err = user.GetVirtualFolderForPath(path.Join(vSubDirPath+"1", "file")) + assert.NoError(t, err) + assert.Equal(t, folder.MappedPath, mappedPath1) + _, err = user.GetVirtualFolderForPath("/vdir/sub1/file") + assert.Error(t, err) + // we check the parent dir + folder, err = user.GetVirtualFolderForPath(vdirPath) + assert.Error(t, err) +} + func TestSSHCommands(t *testing.T) { usePubKey := false user, _, err := httpd.AddUser(getTestUser(usePubKey), http.StatusOK) @@ -3279,7 +4985,7 @@ func TestBasicGitCommands(t *testing.T) { printLatestLogs(10) } - err = waitQuotaScans() + err = waitQuotaScans(1) assert.NoError(t, err) user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) @@ -3405,6 +5111,24 @@ func TestSCPUploadFileOverwrite(t *testing.T) { if assert.NoError(t, err) { assert.Equal(t, testFileSize, fi.Size()) } + // now create a simlink via SFTP, replace the symlink with a file via SCP and check quota usage + client, err := getSftpClient(user, usePubKey) + if assert.NoError(t, err) { + defer client.Close() + err = client.Symlink(testFileName, testFileName+".link") + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, testFileSize, user.UsedQuotaSize) + assert.Equal(t, 1, user.UsedQuotaFiles) + } + err = scpUpload(testFilePath, remoteUpPath+".link", true, false) + assert.NoError(t, err) + user, _, err = httpd.GetUserByID(user.ID, http.StatusOK) + assert.NoError(t, err) + assert.Equal(t, testFileSize*2, user.UsedQuotaSize) + assert.Equal(t, 2, user.UsedQuotaFiles) + err = os.Remove(localPath) assert.NoError(t, err) err = os.Remove(testFilePath) @@ -3529,8 +5253,10 @@ func TestSCPVirtualFolders(t *testing.T) { mappedPath := filepath.Join(os.TempDir(), "vdir") vdirPath := "/vdir" u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath, + }, VirtualPath: vdirPath, - MappedPath: mappedPath, }) err := os.MkdirAll(mappedPath, 0777) assert.NoError(t, err) @@ -3557,6 +5283,8 @@ func TestSCPVirtualFolders(t *testing.T) { _, err = httpd.RemoveUser(user, http.StatusOK) assert.NoError(t, err) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath}, http.StatusOK) + assert.NoError(t, err) err = os.RemoveAll(testBaseDirPath) assert.NoError(t, err) err = os.RemoveAll(testBaseDirDownPath) @@ -3579,13 +5307,20 @@ func TestSCPVirtualFoldersQuota(t *testing.T) { mappedPath2 := filepath.Join(os.TempDir(), "vdir2") vdirPath2 := "/vdir2" u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath1, + }, VirtualPath: vdirPath1, - MappedPath: mappedPath1, + QuotaFiles: -1, + QuotaSize: -1, }) u.VirtualFolders = append(u.VirtualFolders, vfs.VirtualFolder{ - VirtualPath: vdirPath2, - MappedPath: mappedPath2, - ExcludeFromQuota: true, + BaseVirtualFolder: vfs.BaseVirtualFolder{ + MappedPath: mappedPath2, + }, + VirtualPath: vdirPath2, + QuotaFiles: 0, + QuotaSize: 0, }) err := os.MkdirAll(mappedPath1, 0777) assert.NoError(t, err) @@ -3609,6 +5344,11 @@ func TestSCPVirtualFoldersQuota(t *testing.T) { remoteUpPath1 := fmt.Sprintf("%v@127.0.0.1:%v", user.Username, vdirPath1) remoteDownPath2 := fmt.Sprintf("%v@127.0.0.1:%v", user.Username, path.Join("/", vdirPath2)) remoteUpPath2 := fmt.Sprintf("%v@127.0.0.1:%v", user.Username, vdirPath2) + // we upload two times to test overwrite + err = scpUpload(testBaseDirPath, remoteUpPath1, true, false) + assert.NoError(t, err) + err = scpDownload(testBaseDirDownPath, remoteDownPath1, true, true) + assert.NoError(t, err) err = scpUpload(testBaseDirPath, remoteUpPath1, true, false) assert.NoError(t, err) err = scpDownload(testBaseDirDownPath, remoteDownPath1, true, true) @@ -3623,9 +5363,27 @@ func TestSCPVirtualFoldersQuota(t *testing.T) { assert.NoError(t, err) assert.Equal(t, expectedQuotaFiles, user.UsedQuotaFiles) assert.Equal(t, expectedQuotaSize, user.UsedQuotaSize) + folder, _, err := httpd.GetFolders(0, 0, mappedPath1, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, expectedQuotaSize, f.UsedQuotaSize) + assert.Equal(t, expectedQuotaFiles, f.UsedQuotaFiles) + } + folder, _, err = httpd.GetFolders(0, 0, mappedPath2, http.StatusOK) + assert.NoError(t, err) + if assert.Len(t, folder, 1) { + f := folder[0] + assert.Equal(t, expectedQuotaSize, f.UsedQuotaSize) + assert.Equal(t, expectedQuotaFiles, f.UsedQuotaFiles) + } _, err = httpd.RemoveUser(user, http.StatusOK) assert.NoError(t, err) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath1}, http.StatusOK) + assert.NoError(t, err) + _, err = httpd.RemoveFolder(vfs.BaseVirtualFolder{MappedPath: mappedPath2}, http.StatusOK) + assert.NoError(t, err) err = os.RemoveAll(testBaseDirPath) assert.NoError(t, err) err = os.RemoveAll(testBaseDirDownPath) @@ -3634,7 +5392,7 @@ func TestSCPVirtualFoldersQuota(t *testing.T) { assert.NoError(t, err) err = os.RemoveAll(mappedPath1) assert.NoError(t, err) - err = os.RemoveAll(mappedPath1) + err = os.RemoveAll(mappedPath2) assert.NoError(t, err) } @@ -4425,17 +6183,25 @@ func waitForActiveTransfer() { } } -func waitQuotaScans() error { - time.Sleep(100 * time.Millisecond) - scans, _, err := httpd.GetQuotaScans(http.StatusOK) - if err != nil { - return err - } - for len(scans) > 0 { - time.Sleep(100 * time.Millisecond) - scans, _, err = httpd.GetQuotaScans(http.StatusOK) - if err != nil { - return err +func waitQuotaScans(kind int) error { + for { + time.Sleep(50 * time.Millisecond) + var activeScans int + if kind == 1 { + scans, _, err := httpd.GetQuotaScans(http.StatusOK) + if err != nil { + return err + } + activeScans = len(scans) + } else { + scans, _, err := httpd.GetFoldersQuotaScans(http.StatusOK) + if err != nil { + return err + } + activeScans = len(scans) + } + if activeScans == 0 { + break } } return nil @@ -4567,7 +6333,9 @@ func getExtAuthScriptContent(user dataprovider.User, nonJSONResponse bool) []byt if nonJSONResponse { extAuthContent = append(extAuthContent, []byte("echo 'text response'\n")...) } else { - extAuthContent = append(extAuthContent, []byte("echo '{\"username\":\"\"}'\n")...) + json, _ := json.Marshal(user) + quoteJson := strconv.Quote(string(json)) + extAuthContent = append(extAuthContent, []byte(fmt.Sprintf("echo '%v'\n", quoteJson))...) } extAuthContent = append(extAuthContent, []byte("fi\n")...) return extAuthContent diff --git a/sftpd/transfer.go b/sftpd/transfer.go index 4e0d2a65..7d7b5a9d 100644 --- a/sftpd/transfer.go +++ b/sftpd/transfer.go @@ -28,26 +28,26 @@ var ( // Transfer contains the transfer details for an upload or a download. // It implements the io Reader and Writer interface to handle files downloads and uploads type Transfer struct { - file *os.File - writerAt *vfs.PipeWriter - readerAt *pipeat.PipeReaderAt - cancelFn func() - path string - start time.Time - bytesSent int64 - bytesReceived int64 - user dataprovider.User - connectionID string - transferType int - lastActivity time.Time - protocol string - transferError error - minWriteOffset int64 - initialSize int64 - lock *sync.Mutex - isNewFile bool - isFinished bool - isExcludedFromQuota bool + file *os.File + writerAt *vfs.PipeWriter + readerAt *pipeat.PipeReaderAt + cancelFn func() + path string + start time.Time + bytesSent int64 + bytesReceived int64 + user dataprovider.User + connectionID string + transferType int + lastActivity time.Time + protocol string + transferError error + minWriteOffset int64 + initialSize int64 + lock *sync.Mutex + isNewFile bool + isFinished bool + requestPath string } // TransferError is called if there is an unexpected error. @@ -189,11 +189,17 @@ func (t *Transfer) updateQuota(numFiles int) bool { if t.file == nil && t.transferError != nil { return false } - if t.isExcludedFromQuota { - return false - } if t.transferType == transferUpload && (numFiles != 0 || t.bytesReceived > 0) { - dataprovider.UpdateUserQuota(dataProvider, t.user, numFiles, t.bytesReceived-t.initialSize, false) //nolint:errcheck + vfolder, err := t.user.GetVirtualFolderForPath(t.requestPath) + if err == nil { + dataprovider.UpdateVirtualFolderQuota(dataProvider, vfolder.BaseVirtualFolder, numFiles, //nolint:errcheck + t.bytesReceived-t.initialSize, false) + if vfolder.IsIncludedInUserQuota() { + dataprovider.UpdateUserQuota(dataProvider, t.user, numFiles, t.bytesReceived-t.initialSize, false) //nolint:errcheck + } + } else { + dataprovider.UpdateUserQuota(dataProvider, t.user, numFiles, t.bytesReceived-t.initialSize, false) //nolint:errcheck + } return true } return false diff --git a/sftpgo.json b/sftpgo.json index 078e273c..6f3f2699 100644 --- a/sftpgo.json +++ b/sftpgo.json @@ -38,7 +38,7 @@ "password": "", "sslmode": 0, "connection_string": "", - "users_table": "users", + "sql_tables_prefix": "", "manage_users": 1, "track_quota": 2, "pool_size": 0, diff --git a/static/vendor/fontawesome-free/css/all.min.css b/static/vendor/fontawesome-free/css/all.min.css index 3158702f..3d28ab20 100644 --- a/static/vendor/fontawesome-free/css/all.min.css +++ b/static/vendor/fontawesome-free/css/all.min.css @@ -1,5 +1,5 @@ /*! - * Font Awesome Free 5.10.2 by @fontawesome - https://fontawesome.com + * Font Awesome Free 5.13.0 by @fontawesome - https://fontawesome.com * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) */ -.fa,.fab,.fad,.fal,.far,.fas{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:inline-block;font-style:normal;font-variant:normal;text-rendering:auto;line-height:1}.fa-lg{font-size:1.33333em;line-height:.75em;vertical-align:-.0667em}.fa-xs{font-size:.75em}.fa-sm{font-size:.875em}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-fw{text-align:center;width:1.25em}.fa-ul{list-style-type:none;margin-left:2.5em;padding-left:0}.fa-ul>li{position:relative}.fa-li{left:-2em;position:absolute;text-align:center;width:2em;line-height:inherit}.fa-border{border:.08em solid #eee;border-radius:.1em;padding:.2em .25em .15em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left,.fab.fa-pull-left,.fal.fa-pull-left,.far.fa-pull-left,.fas.fa-pull-left{margin-right:.3em}.fa.fa-pull-right,.fab.fa-pull-right,.fal.fa-pull-right,.far.fa-pull-right,.fas.fa-pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s linear infinite;animation:fa-spin 2s linear infinite}.fa-pulse{-webkit-animation:fa-spin 1s steps(8) infinite;animation:fa-spin 1s steps(8) infinite}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scaleX(-1);transform:scaleX(-1)}.fa-flip-vertical{-webkit-transform:scaleY(-1);transform:scaleY(-1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical,.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)"}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{-webkit-transform:scale(-1);transform:scale(-1)}:root .fa-flip-both,:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270{-webkit-filter:none;filter:none}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2.5em}.fa-stack-1x,.fa-stack-2x{left:0;position:absolute;text-align:center;width:100%}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-500px:before{content:"\f26e"}.fa-accessible-icon:before{content:"\f368"}.fa-accusoft:before{content:"\f369"}.fa-acquisitions-incorporated:before{content:"\f6af"}.fa-ad:before{content:"\f641"}.fa-address-book:before{content:"\f2b9"}.fa-address-card:before{content:"\f2bb"}.fa-adjust:before{content:"\f042"}.fa-adn:before{content:"\f170"}.fa-adobe:before{content:"\f778"}.fa-adversal:before{content:"\f36a"}.fa-affiliatetheme:before{content:"\f36b"}.fa-air-freshener:before{content:"\f5d0"}.fa-airbnb:before{content:"\f834"}.fa-algolia:before{content:"\f36c"}.fa-align-center:before{content:"\f037"}.fa-align-justify:before{content:"\f039"}.fa-align-left:before{content:"\f036"}.fa-align-right:before{content:"\f038"}.fa-alipay:before{content:"\f642"}.fa-allergies:before{content:"\f461"}.fa-amazon:before{content:"\f270"}.fa-amazon-pay:before{content:"\f42c"}.fa-ambulance:before{content:"\f0f9"}.fa-american-sign-language-interpreting:before{content:"\f2a3"}.fa-amilia:before{content:"\f36d"}.fa-anchor:before{content:"\f13d"}.fa-android:before{content:"\f17b"}.fa-angellist:before{content:"\f209"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-down:before{content:"\f107"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angry:before{content:"\f556"}.fa-angrycreative:before{content:"\f36e"}.fa-angular:before{content:"\f420"}.fa-ankh:before{content:"\f644"}.fa-app-store:before{content:"\f36f"}.fa-app-store-ios:before{content:"\f370"}.fa-apper:before{content:"\f371"}.fa-apple:before{content:"\f179"}.fa-apple-alt:before{content:"\f5d1"}.fa-apple-pay:before{content:"\f415"}.fa-archive:before{content:"\f187"}.fa-archway:before{content:"\f557"}.fa-arrow-alt-circle-down:before{content:"\f358"}.fa-arrow-alt-circle-left:before{content:"\f359"}.fa-arrow-alt-circle-right:before{content:"\f35a"}.fa-arrow-alt-circle-up:before{content:"\f35b"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-down:before{content:"\f063"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrows-alt:before{content:"\f0b2"}.fa-arrows-alt-h:before{content:"\f337"}.fa-arrows-alt-v:before{content:"\f338"}.fa-artstation:before{content:"\f77a"}.fa-assistive-listening-systems:before{content:"\f2a2"}.fa-asterisk:before{content:"\f069"}.fa-asymmetrik:before{content:"\f372"}.fa-at:before{content:"\f1fa"}.fa-atlas:before{content:"\f558"}.fa-atlassian:before{content:"\f77b"}.fa-atom:before{content:"\f5d2"}.fa-audible:before{content:"\f373"}.fa-audio-description:before{content:"\f29e"}.fa-autoprefixer:before{content:"\f41c"}.fa-avianex:before{content:"\f374"}.fa-aviato:before{content:"\f421"}.fa-award:before{content:"\f559"}.fa-aws:before{content:"\f375"}.fa-baby:before{content:"\f77c"}.fa-baby-carriage:before{content:"\f77d"}.fa-backspace:before{content:"\f55a"}.fa-backward:before{content:"\f04a"}.fa-bacon:before{content:"\f7e5"}.fa-balance-scale:before{content:"\f24e"}.fa-balance-scale-left:before{content:"\f515"}.fa-balance-scale-right:before{content:"\f516"}.fa-ban:before{content:"\f05e"}.fa-band-aid:before{content:"\f462"}.fa-bandcamp:before{content:"\f2d5"}.fa-barcode:before{content:"\f02a"}.fa-bars:before{content:"\f0c9"}.fa-baseball-ball:before{content:"\f433"}.fa-basketball-ball:before{content:"\f434"}.fa-bath:before{content:"\f2cd"}.fa-battery-empty:before{content:"\f244"}.fa-battery-full:before{content:"\f240"}.fa-battery-half:before{content:"\f242"}.fa-battery-quarter:before{content:"\f243"}.fa-battery-three-quarters:before{content:"\f241"}.fa-battle-net:before{content:"\f835"}.fa-bed:before{content:"\f236"}.fa-beer:before{content:"\f0fc"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-bell:before{content:"\f0f3"}.fa-bell-slash:before{content:"\f1f6"}.fa-bezier-curve:before{content:"\f55b"}.fa-bible:before{content:"\f647"}.fa-bicycle:before{content:"\f206"}.fa-biking:before{content:"\f84a"}.fa-bimobject:before{content:"\f378"}.fa-binoculars:before{content:"\f1e5"}.fa-biohazard:before{content:"\f780"}.fa-birthday-cake:before{content:"\f1fd"}.fa-bitbucket:before{content:"\f171"}.fa-bitcoin:before{content:"\f379"}.fa-bity:before{content:"\f37a"}.fa-black-tie:before{content:"\f27e"}.fa-blackberry:before{content:"\f37b"}.fa-blender:before{content:"\f517"}.fa-blender-phone:before{content:"\f6b6"}.fa-blind:before{content:"\f29d"}.fa-blog:before{content:"\f781"}.fa-blogger:before{content:"\f37c"}.fa-blogger-b:before{content:"\f37d"}.fa-bluetooth:before{content:"\f293"}.fa-bluetooth-b:before{content:"\f294"}.fa-bold:before{content:"\f032"}.fa-bolt:before{content:"\f0e7"}.fa-bomb:before{content:"\f1e2"}.fa-bone:before{content:"\f5d7"}.fa-bong:before{content:"\f55c"}.fa-book:before{content:"\f02d"}.fa-book-dead:before{content:"\f6b7"}.fa-book-medical:before{content:"\f7e6"}.fa-book-open:before{content:"\f518"}.fa-book-reader:before{content:"\f5da"}.fa-bookmark:before{content:"\f02e"}.fa-bootstrap:before{content:"\f836"}.fa-border-all:before{content:"\f84c"}.fa-border-none:before{content:"\f850"}.fa-border-style:before{content:"\f853"}.fa-bowling-ball:before{content:"\f436"}.fa-box:before{content:"\f466"}.fa-box-open:before{content:"\f49e"}.fa-boxes:before{content:"\f468"}.fa-braille:before{content:"\f2a1"}.fa-brain:before{content:"\f5dc"}.fa-bread-slice:before{content:"\f7ec"}.fa-briefcase:before{content:"\f0b1"}.fa-briefcase-medical:before{content:"\f469"}.fa-broadcast-tower:before{content:"\f519"}.fa-broom:before{content:"\f51a"}.fa-brush:before{content:"\f55d"}.fa-btc:before{content:"\f15a"}.fa-buffer:before{content:"\f837"}.fa-bug:before{content:"\f188"}.fa-building:before{content:"\f1ad"}.fa-bullhorn:before{content:"\f0a1"}.fa-bullseye:before{content:"\f140"}.fa-burn:before{content:"\f46a"}.fa-buromobelexperte:before{content:"\f37f"}.fa-bus:before{content:"\f207"}.fa-bus-alt:before{content:"\f55e"}.fa-business-time:before{content:"\f64a"}.fa-buysellads:before{content:"\f20d"}.fa-calculator:before{content:"\f1ec"}.fa-calendar:before{content:"\f133"}.fa-calendar-alt:before{content:"\f073"}.fa-calendar-check:before{content:"\f274"}.fa-calendar-day:before{content:"\f783"}.fa-calendar-minus:before{content:"\f272"}.fa-calendar-plus:before{content:"\f271"}.fa-calendar-times:before{content:"\f273"}.fa-calendar-week:before{content:"\f784"}.fa-camera:before{content:"\f030"}.fa-camera-retro:before{content:"\f083"}.fa-campground:before{content:"\f6bb"}.fa-canadian-maple-leaf:before{content:"\f785"}.fa-candy-cane:before{content:"\f786"}.fa-cannabis:before{content:"\f55f"}.fa-capsules:before{content:"\f46b"}.fa-car:before{content:"\f1b9"}.fa-car-alt:before{content:"\f5de"}.fa-car-battery:before{content:"\f5df"}.fa-car-crash:before{content:"\f5e1"}.fa-car-side:before{content:"\f5e4"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-caret-square-down:before{content:"\f150"}.fa-caret-square-left:before{content:"\f191"}.fa-caret-square-right:before{content:"\f152"}.fa-caret-square-up:before{content:"\f151"}.fa-caret-up:before{content:"\f0d8"}.fa-carrot:before{content:"\f787"}.fa-cart-arrow-down:before{content:"\f218"}.fa-cart-plus:before{content:"\f217"}.fa-cash-register:before{content:"\f788"}.fa-cat:before{content:"\f6be"}.fa-cc-amazon-pay:before{content:"\f42d"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-apple-pay:before{content:"\f416"}.fa-cc-diners-club:before{content:"\f24c"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-cc-visa:before{content:"\f1f0"}.fa-centercode:before{content:"\f380"}.fa-centos:before{content:"\f789"}.fa-certificate:before{content:"\f0a3"}.fa-chair:before{content:"\f6c0"}.fa-chalkboard:before{content:"\f51b"}.fa-chalkboard-teacher:before{content:"\f51c"}.fa-charging-station:before{content:"\f5e7"}.fa-chart-area:before{content:"\f1fe"}.fa-chart-bar:before{content:"\f080"}.fa-chart-line:before{content:"\f201"}.fa-chart-pie:before{content:"\f200"}.fa-check:before{content:"\f00c"}.fa-check-circle:before{content:"\f058"}.fa-check-double:before{content:"\f560"}.fa-check-square:before{content:"\f14a"}.fa-cheese:before{content:"\f7ef"}.fa-chess:before{content:"\f439"}.fa-chess-bishop:before{content:"\f43a"}.fa-chess-board:before{content:"\f43c"}.fa-chess-king:before{content:"\f43f"}.fa-chess-knight:before{content:"\f441"}.fa-chess-pawn:before{content:"\f443"}.fa-chess-queen:before{content:"\f445"}.fa-chess-rook:before{content:"\f447"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-down:before{content:"\f078"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-chevron-up:before{content:"\f077"}.fa-child:before{content:"\f1ae"}.fa-chrome:before{content:"\f268"}.fa-chromecast:before{content:"\f838"}.fa-church:before{content:"\f51d"}.fa-circle:before{content:"\f111"}.fa-circle-notch:before{content:"\f1ce"}.fa-city:before{content:"\f64f"}.fa-clinic-medical:before{content:"\f7f2"}.fa-clipboard:before{content:"\f328"}.fa-clipboard-check:before{content:"\f46c"}.fa-clipboard-list:before{content:"\f46d"}.fa-clock:before{content:"\f017"}.fa-clone:before{content:"\f24d"}.fa-closed-captioning:before{content:"\f20a"}.fa-cloud:before{content:"\f0c2"}.fa-cloud-download-alt:before{content:"\f381"}.fa-cloud-meatball:before{content:"\f73b"}.fa-cloud-moon:before{content:"\f6c3"}.fa-cloud-moon-rain:before{content:"\f73c"}.fa-cloud-rain:before{content:"\f73d"}.fa-cloud-showers-heavy:before{content:"\f740"}.fa-cloud-sun:before{content:"\f6c4"}.fa-cloud-sun-rain:before{content:"\f743"}.fa-cloud-upload-alt:before{content:"\f382"}.fa-cloudscale:before{content:"\f383"}.fa-cloudsmith:before{content:"\f384"}.fa-cloudversify:before{content:"\f385"}.fa-cocktail:before{content:"\f561"}.fa-code:before{content:"\f121"}.fa-code-branch:before{content:"\f126"}.fa-codepen:before{content:"\f1cb"}.fa-codiepie:before{content:"\f284"}.fa-coffee:before{content:"\f0f4"}.fa-cog:before{content:"\f013"}.fa-cogs:before{content:"\f085"}.fa-coins:before{content:"\f51e"}.fa-columns:before{content:"\f0db"}.fa-comment:before{content:"\f075"}.fa-comment-alt:before{content:"\f27a"}.fa-comment-dollar:before{content:"\f651"}.fa-comment-dots:before{content:"\f4ad"}.fa-comment-medical:before{content:"\f7f5"}.fa-comment-slash:before{content:"\f4b3"}.fa-comments:before{content:"\f086"}.fa-comments-dollar:before{content:"\f653"}.fa-compact-disc:before{content:"\f51f"}.fa-compass:before{content:"\f14e"}.fa-compress:before{content:"\f066"}.fa-compress-arrows-alt:before{content:"\f78c"}.fa-concierge-bell:before{content:"\f562"}.fa-confluence:before{content:"\f78d"}.fa-connectdevelop:before{content:"\f20e"}.fa-contao:before{content:"\f26d"}.fa-cookie:before{content:"\f563"}.fa-cookie-bite:before{content:"\f564"}.fa-copy:before{content:"\f0c5"}.fa-copyright:before{content:"\f1f9"}.fa-cotton-bureau:before{content:"\f89e"}.fa-couch:before{content:"\f4b8"}.fa-cpanel:before{content:"\f388"}.fa-creative-commons:before{content:"\f25e"}.fa-creative-commons-by:before{content:"\f4e7"}.fa-creative-commons-nc:before{content:"\f4e8"}.fa-creative-commons-nc-eu:before{content:"\f4e9"}.fa-creative-commons-nc-jp:before{content:"\f4ea"}.fa-creative-commons-nd:before{content:"\f4eb"}.fa-creative-commons-pd:before{content:"\f4ec"}.fa-creative-commons-pd-alt:before{content:"\f4ed"}.fa-creative-commons-remix:before{content:"\f4ee"}.fa-creative-commons-sa:before{content:"\f4ef"}.fa-creative-commons-sampling:before{content:"\f4f0"}.fa-creative-commons-sampling-plus:before{content:"\f4f1"}.fa-creative-commons-share:before{content:"\f4f2"}.fa-creative-commons-zero:before{content:"\f4f3"}.fa-credit-card:before{content:"\f09d"}.fa-critical-role:before{content:"\f6c9"}.fa-crop:before{content:"\f125"}.fa-crop-alt:before{content:"\f565"}.fa-cross:before{content:"\f654"}.fa-crosshairs:before{content:"\f05b"}.fa-crow:before{content:"\f520"}.fa-crown:before{content:"\f521"}.fa-crutch:before{content:"\f7f7"}.fa-css3:before{content:"\f13c"}.fa-css3-alt:before{content:"\f38b"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-cut:before{content:"\f0c4"}.fa-cuttlefish:before{content:"\f38c"}.fa-d-and-d:before{content:"\f38d"}.fa-d-and-d-beyond:before{content:"\f6ca"}.fa-dashcube:before{content:"\f210"}.fa-database:before{content:"\f1c0"}.fa-deaf:before{content:"\f2a4"}.fa-delicious:before{content:"\f1a5"}.fa-democrat:before{content:"\f747"}.fa-deploydog:before{content:"\f38e"}.fa-deskpro:before{content:"\f38f"}.fa-desktop:before{content:"\f108"}.fa-dev:before{content:"\f6cc"}.fa-deviantart:before{content:"\f1bd"}.fa-dharmachakra:before{content:"\f655"}.fa-dhl:before{content:"\f790"}.fa-diagnoses:before{content:"\f470"}.fa-diaspora:before{content:"\f791"}.fa-dice:before{content:"\f522"}.fa-dice-d20:before{content:"\f6cf"}.fa-dice-d6:before{content:"\f6d1"}.fa-dice-five:before{content:"\f523"}.fa-dice-four:before{content:"\f524"}.fa-dice-one:before{content:"\f525"}.fa-dice-six:before{content:"\f526"}.fa-dice-three:before{content:"\f527"}.fa-dice-two:before{content:"\f528"}.fa-digg:before{content:"\f1a6"}.fa-digital-ocean:before{content:"\f391"}.fa-digital-tachograph:before{content:"\f566"}.fa-directions:before{content:"\f5eb"}.fa-discord:before{content:"\f392"}.fa-discourse:before{content:"\f393"}.fa-divide:before{content:"\f529"}.fa-dizzy:before{content:"\f567"}.fa-dna:before{content:"\f471"}.fa-dochub:before{content:"\f394"}.fa-docker:before{content:"\f395"}.fa-dog:before{content:"\f6d3"}.fa-dollar-sign:before{content:"\f155"}.fa-dolly:before{content:"\f472"}.fa-dolly-flatbed:before{content:"\f474"}.fa-donate:before{content:"\f4b9"}.fa-door-closed:before{content:"\f52a"}.fa-door-open:before{content:"\f52b"}.fa-dot-circle:before{content:"\f192"}.fa-dove:before{content:"\f4ba"}.fa-download:before{content:"\f019"}.fa-draft2digital:before{content:"\f396"}.fa-drafting-compass:before{content:"\f568"}.fa-dragon:before{content:"\f6d5"}.fa-draw-polygon:before{content:"\f5ee"}.fa-dribbble:before{content:"\f17d"}.fa-dribbble-square:before{content:"\f397"}.fa-dropbox:before{content:"\f16b"}.fa-drum:before{content:"\f569"}.fa-drum-steelpan:before{content:"\f56a"}.fa-drumstick-bite:before{content:"\f6d7"}.fa-drupal:before{content:"\f1a9"}.fa-dumbbell:before{content:"\f44b"}.fa-dumpster:before{content:"\f793"}.fa-dumpster-fire:before{content:"\f794"}.fa-dungeon:before{content:"\f6d9"}.fa-dyalog:before{content:"\f399"}.fa-earlybirds:before{content:"\f39a"}.fa-ebay:before{content:"\f4f4"}.fa-edge:before{content:"\f282"}.fa-edit:before{content:"\f044"}.fa-egg:before{content:"\f7fb"}.fa-eject:before{content:"\f052"}.fa-elementor:before{content:"\f430"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-ello:before{content:"\f5f1"}.fa-ember:before{content:"\f423"}.fa-empire:before{content:"\f1d1"}.fa-envelope:before{content:"\f0e0"}.fa-envelope-open:before{content:"\f2b6"}.fa-envelope-open-text:before{content:"\f658"}.fa-envelope-square:before{content:"\f199"}.fa-envira:before{content:"\f299"}.fa-equals:before{content:"\f52c"}.fa-eraser:before{content:"\f12d"}.fa-erlang:before{content:"\f39d"}.fa-ethereum:before{content:"\f42e"}.fa-ethernet:before{content:"\f796"}.fa-etsy:before{content:"\f2d7"}.fa-euro-sign:before{content:"\f153"}.fa-evernote:before{content:"\f839"}.fa-exchange-alt:before{content:"\f362"}.fa-exclamation:before{content:"\f12a"}.fa-exclamation-circle:before{content:"\f06a"}.fa-exclamation-triangle:before{content:"\f071"}.fa-expand:before{content:"\f065"}.fa-expand-arrows-alt:before{content:"\f31e"}.fa-expeditedssl:before{content:"\f23e"}.fa-external-link-alt:before{content:"\f35d"}.fa-external-link-square-alt:before{content:"\f360"}.fa-eye:before{content:"\f06e"}.fa-eye-dropper:before{content:"\f1fb"}.fa-eye-slash:before{content:"\f070"}.fa-facebook:before{content:"\f09a"}.fa-facebook-f:before{content:"\f39e"}.fa-facebook-messenger:before{content:"\f39f"}.fa-facebook-square:before{content:"\f082"}.fa-fan:before{content:"\f863"}.fa-fantasy-flight-games:before{content:"\f6dc"}.fa-fast-backward:before{content:"\f049"}.fa-fast-forward:before{content:"\f050"}.fa-fax:before{content:"\f1ac"}.fa-feather:before{content:"\f52d"}.fa-feather-alt:before{content:"\f56b"}.fa-fedex:before{content:"\f797"}.fa-fedora:before{content:"\f798"}.fa-female:before{content:"\f182"}.fa-fighter-jet:before{content:"\f0fb"}.fa-figma:before{content:"\f799"}.fa-file:before{content:"\f15b"}.fa-file-alt:before{content:"\f15c"}.fa-file-archive:before{content:"\f1c6"}.fa-file-audio:before{content:"\f1c7"}.fa-file-code:before{content:"\f1c9"}.fa-file-contract:before{content:"\f56c"}.fa-file-csv:before{content:"\f6dd"}.fa-file-download:before{content:"\f56d"}.fa-file-excel:before{content:"\f1c3"}.fa-file-export:before{content:"\f56e"}.fa-file-image:before{content:"\f1c5"}.fa-file-import:before{content:"\f56f"}.fa-file-invoice:before{content:"\f570"}.fa-file-invoice-dollar:before{content:"\f571"}.fa-file-medical:before{content:"\f477"}.fa-file-medical-alt:before{content:"\f478"}.fa-file-pdf:before{content:"\f1c1"}.fa-file-powerpoint:before{content:"\f1c4"}.fa-file-prescription:before{content:"\f572"}.fa-file-signature:before{content:"\f573"}.fa-file-upload:before{content:"\f574"}.fa-file-video:before{content:"\f1c8"}.fa-file-word:before{content:"\f1c2"}.fa-fill:before{content:"\f575"}.fa-fill-drip:before{content:"\f576"}.fa-film:before{content:"\f008"}.fa-filter:before{content:"\f0b0"}.fa-fingerprint:before{content:"\f577"}.fa-fire:before{content:"\f06d"}.fa-fire-alt:before{content:"\f7e4"}.fa-fire-extinguisher:before{content:"\f134"}.fa-firefox:before{content:"\f269"}.fa-first-aid:before{content:"\f479"}.fa-first-order:before{content:"\f2b0"}.fa-first-order-alt:before{content:"\f50a"}.fa-firstdraft:before{content:"\f3a1"}.fa-fish:before{content:"\f578"}.fa-fist-raised:before{content:"\f6de"}.fa-flag:before{content:"\f024"}.fa-flag-checkered:before{content:"\f11e"}.fa-flag-usa:before{content:"\f74d"}.fa-flask:before{content:"\f0c3"}.fa-flickr:before{content:"\f16e"}.fa-flipboard:before{content:"\f44d"}.fa-flushed:before{content:"\f579"}.fa-fly:before{content:"\f417"}.fa-folder:before{content:"\f07b"}.fa-folder-minus:before{content:"\f65d"}.fa-folder-open:before{content:"\f07c"}.fa-folder-plus:before{content:"\f65e"}.fa-font:before{content:"\f031"}.fa-font-awesome:before{content:"\f2b4"}.fa-font-awesome-alt:before{content:"\f35c"}.fa-font-awesome-flag:before{content:"\f425"}.fa-font-awesome-logo-full:before{content:"\f4e6"}.fa-fonticons:before{content:"\f280"}.fa-fonticons-fi:before{content:"\f3a2"}.fa-football-ball:before{content:"\f44e"}.fa-fort-awesome:before{content:"\f286"}.fa-fort-awesome-alt:before{content:"\f3a3"}.fa-forumbee:before{content:"\f211"}.fa-forward:before{content:"\f04e"}.fa-foursquare:before{content:"\f180"}.fa-free-code-camp:before{content:"\f2c5"}.fa-freebsd:before{content:"\f3a4"}.fa-frog:before{content:"\f52e"}.fa-frown:before{content:"\f119"}.fa-frown-open:before{content:"\f57a"}.fa-fulcrum:before{content:"\f50b"}.fa-funnel-dollar:before{content:"\f662"}.fa-futbol:before{content:"\f1e3"}.fa-galactic-republic:before{content:"\f50c"}.fa-galactic-senate:before{content:"\f50d"}.fa-gamepad:before{content:"\f11b"}.fa-gas-pump:before{content:"\f52f"}.fa-gavel:before{content:"\f0e3"}.fa-gem:before{content:"\f3a5"}.fa-genderless:before{content:"\f22d"}.fa-get-pocket:before{content:"\f265"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-ghost:before{content:"\f6e2"}.fa-gift:before{content:"\f06b"}.fa-gifts:before{content:"\f79c"}.fa-git:before{content:"\f1d3"}.fa-git-alt:before{content:"\f841"}.fa-git-square:before{content:"\f1d2"}.fa-github:before{content:"\f09b"}.fa-github-alt:before{content:"\f113"}.fa-github-square:before{content:"\f092"}.fa-gitkraken:before{content:"\f3a6"}.fa-gitlab:before{content:"\f296"}.fa-gitter:before{content:"\f426"}.fa-glass-cheers:before{content:"\f79f"}.fa-glass-martini:before{content:"\f000"}.fa-glass-martini-alt:before{content:"\f57b"}.fa-glass-whiskey:before{content:"\f7a0"}.fa-glasses:before{content:"\f530"}.fa-glide:before{content:"\f2a5"}.fa-glide-g:before{content:"\f2a6"}.fa-globe:before{content:"\f0ac"}.fa-globe-africa:before{content:"\f57c"}.fa-globe-americas:before{content:"\f57d"}.fa-globe-asia:before{content:"\f57e"}.fa-globe-europe:before{content:"\f7a2"}.fa-gofore:before{content:"\f3a7"}.fa-golf-ball:before{content:"\f450"}.fa-goodreads:before{content:"\f3a8"}.fa-goodreads-g:before{content:"\f3a9"}.fa-google:before{content:"\f1a0"}.fa-google-drive:before{content:"\f3aa"}.fa-google-play:before{content:"\f3ab"}.fa-google-plus:before{content:"\f2b3"}.fa-google-plus-g:before{content:"\f0d5"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-wallet:before{content:"\f1ee"}.fa-gopuram:before{content:"\f664"}.fa-graduation-cap:before{content:"\f19d"}.fa-gratipay:before{content:"\f184"}.fa-grav:before{content:"\f2d6"}.fa-greater-than:before{content:"\f531"}.fa-greater-than-equal:before{content:"\f532"}.fa-grimace:before{content:"\f57f"}.fa-grin:before{content:"\f580"}.fa-grin-alt:before{content:"\f581"}.fa-grin-beam:before{content:"\f582"}.fa-grin-beam-sweat:before{content:"\f583"}.fa-grin-hearts:before{content:"\f584"}.fa-grin-squint:before{content:"\f585"}.fa-grin-squint-tears:before{content:"\f586"}.fa-grin-stars:before{content:"\f587"}.fa-grin-tears:before{content:"\f588"}.fa-grin-tongue:before{content:"\f589"}.fa-grin-tongue-squint:before{content:"\f58a"}.fa-grin-tongue-wink:before{content:"\f58b"}.fa-grin-wink:before{content:"\f58c"}.fa-grip-horizontal:before{content:"\f58d"}.fa-grip-lines:before{content:"\f7a4"}.fa-grip-lines-vertical:before{content:"\f7a5"}.fa-grip-vertical:before{content:"\f58e"}.fa-gripfire:before{content:"\f3ac"}.fa-grunt:before{content:"\f3ad"}.fa-guitar:before{content:"\f7a6"}.fa-gulp:before{content:"\f3ae"}.fa-h-square:before{content:"\f0fd"}.fa-hacker-news:before{content:"\f1d4"}.fa-hacker-news-square:before{content:"\f3af"}.fa-hackerrank:before{content:"\f5f7"}.fa-hamburger:before{content:"\f805"}.fa-hammer:before{content:"\f6e3"}.fa-hamsa:before{content:"\f665"}.fa-hand-holding:before{content:"\f4bd"}.fa-hand-holding-heart:before{content:"\f4be"}.fa-hand-holding-usd:before{content:"\f4c0"}.fa-hand-lizard:before{content:"\f258"}.fa-hand-middle-finger:before{content:"\f806"}.fa-hand-paper:before{content:"\f256"}.fa-hand-peace:before{content:"\f25b"}.fa-hand-point-down:before{content:"\f0a7"}.fa-hand-point-left:before{content:"\f0a5"}.fa-hand-point-right:before{content:"\f0a4"}.fa-hand-point-up:before{content:"\f0a6"}.fa-hand-pointer:before{content:"\f25a"}.fa-hand-rock:before{content:"\f255"}.fa-hand-scissors:before{content:"\f257"}.fa-hand-spock:before{content:"\f259"}.fa-hands:before{content:"\f4c2"}.fa-hands-helping:before{content:"\f4c4"}.fa-handshake:before{content:"\f2b5"}.fa-hanukiah:before{content:"\f6e6"}.fa-hard-hat:before{content:"\f807"}.fa-hashtag:before{content:"\f292"}.fa-hat-wizard:before{content:"\f6e8"}.fa-haykal:before{content:"\f666"}.fa-hdd:before{content:"\f0a0"}.fa-heading:before{content:"\f1dc"}.fa-headphones:before{content:"\f025"}.fa-headphones-alt:before{content:"\f58f"}.fa-headset:before{content:"\f590"}.fa-heart:before{content:"\f004"}.fa-heart-broken:before{content:"\f7a9"}.fa-heartbeat:before{content:"\f21e"}.fa-helicopter:before{content:"\f533"}.fa-highlighter:before{content:"\f591"}.fa-hiking:before{content:"\f6ec"}.fa-hippo:before{content:"\f6ed"}.fa-hips:before{content:"\f452"}.fa-hire-a-helper:before{content:"\f3b0"}.fa-history:before{content:"\f1da"}.fa-hockey-puck:before{content:"\f453"}.fa-holly-berry:before{content:"\f7aa"}.fa-home:before{content:"\f015"}.fa-hooli:before{content:"\f427"}.fa-hornbill:before{content:"\f592"}.fa-horse:before{content:"\f6f0"}.fa-horse-head:before{content:"\f7ab"}.fa-hospital:before{content:"\f0f8"}.fa-hospital-alt:before{content:"\f47d"}.fa-hospital-symbol:before{content:"\f47e"}.fa-hot-tub:before{content:"\f593"}.fa-hotdog:before{content:"\f80f"}.fa-hotel:before{content:"\f594"}.fa-hotjar:before{content:"\f3b1"}.fa-hourglass:before{content:"\f254"}.fa-hourglass-end:before{content:"\f253"}.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-start:before{content:"\f251"}.fa-house-damage:before{content:"\f6f1"}.fa-houzz:before{content:"\f27c"}.fa-hryvnia:before{content:"\f6f2"}.fa-html5:before{content:"\f13b"}.fa-hubspot:before{content:"\f3b2"}.fa-i-cursor:before{content:"\f246"}.fa-ice-cream:before{content:"\f810"}.fa-icicles:before{content:"\f7ad"}.fa-icons:before{content:"\f86d"}.fa-id-badge:before{content:"\f2c1"}.fa-id-card:before{content:"\f2c2"}.fa-id-card-alt:before{content:"\f47f"}.fa-igloo:before{content:"\f7ae"}.fa-image:before{content:"\f03e"}.fa-images:before{content:"\f302"}.fa-imdb:before{content:"\f2d8"}.fa-inbox:before{content:"\f01c"}.fa-indent:before{content:"\f03c"}.fa-industry:before{content:"\f275"}.fa-infinity:before{content:"\f534"}.fa-info:before{content:"\f129"}.fa-info-circle:before{content:"\f05a"}.fa-instagram:before{content:"\f16d"}.fa-intercom:before{content:"\f7af"}.fa-internet-explorer:before{content:"\f26b"}.fa-invision:before{content:"\f7b0"}.fa-ioxhost:before{content:"\f208"}.fa-italic:before{content:"\f033"}.fa-itch-io:before{content:"\f83a"}.fa-itunes:before{content:"\f3b4"}.fa-itunes-note:before{content:"\f3b5"}.fa-java:before{content:"\f4e4"}.fa-jedi:before{content:"\f669"}.fa-jedi-order:before{content:"\f50e"}.fa-jenkins:before{content:"\f3b6"}.fa-jira:before{content:"\f7b1"}.fa-joget:before{content:"\f3b7"}.fa-joint:before{content:"\f595"}.fa-joomla:before{content:"\f1aa"}.fa-journal-whills:before{content:"\f66a"}.fa-js:before{content:"\f3b8"}.fa-js-square:before{content:"\f3b9"}.fa-jsfiddle:before{content:"\f1cc"}.fa-kaaba:before{content:"\f66b"}.fa-kaggle:before{content:"\f5fa"}.fa-key:before{content:"\f084"}.fa-keybase:before{content:"\f4f5"}.fa-keyboard:before{content:"\f11c"}.fa-keycdn:before{content:"\f3ba"}.fa-khanda:before{content:"\f66d"}.fa-kickstarter:before{content:"\f3bb"}.fa-kickstarter-k:before{content:"\f3bc"}.fa-kiss:before{content:"\f596"}.fa-kiss-beam:before{content:"\f597"}.fa-kiss-wink-heart:before{content:"\f598"}.fa-kiwi-bird:before{content:"\f535"}.fa-korvue:before{content:"\f42f"}.fa-landmark:before{content:"\f66f"}.fa-language:before{content:"\f1ab"}.fa-laptop:before{content:"\f109"}.fa-laptop-code:before{content:"\f5fc"}.fa-laptop-medical:before{content:"\f812"}.fa-laravel:before{content:"\f3bd"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-laugh:before{content:"\f599"}.fa-laugh-beam:before{content:"\f59a"}.fa-laugh-squint:before{content:"\f59b"}.fa-laugh-wink:before{content:"\f59c"}.fa-layer-group:before{content:"\f5fd"}.fa-leaf:before{content:"\f06c"}.fa-leanpub:before{content:"\f212"}.fa-lemon:before{content:"\f094"}.fa-less:before{content:"\f41d"}.fa-less-than:before{content:"\f536"}.fa-less-than-equal:before{content:"\f537"}.fa-level-down-alt:before{content:"\f3be"}.fa-level-up-alt:before{content:"\f3bf"}.fa-life-ring:before{content:"\f1cd"}.fa-lightbulb:before{content:"\f0eb"}.fa-line:before{content:"\f3c0"}.fa-link:before{content:"\f0c1"}.fa-linkedin:before{content:"\f08c"}.fa-linkedin-in:before{content:"\f0e1"}.fa-linode:before{content:"\f2b8"}.fa-linux:before{content:"\f17c"}.fa-lira-sign:before{content:"\f195"}.fa-list:before{content:"\f03a"}.fa-list-alt:before{content:"\f022"}.fa-list-ol:before{content:"\f0cb"}.fa-list-ul:before{content:"\f0ca"}.fa-location-arrow:before{content:"\f124"}.fa-lock:before{content:"\f023"}.fa-lock-open:before{content:"\f3c1"}.fa-long-arrow-alt-down:before{content:"\f309"}.fa-long-arrow-alt-left:before{content:"\f30a"}.fa-long-arrow-alt-right:before{content:"\f30b"}.fa-long-arrow-alt-up:before{content:"\f30c"}.fa-low-vision:before{content:"\f2a8"}.fa-luggage-cart:before{content:"\f59d"}.fa-lyft:before{content:"\f3c3"}.fa-magento:before{content:"\f3c4"}.fa-magic:before{content:"\f0d0"}.fa-magnet:before{content:"\f076"}.fa-mail-bulk:before{content:"\f674"}.fa-mailchimp:before{content:"\f59e"}.fa-male:before{content:"\f183"}.fa-mandalorian:before{content:"\f50f"}.fa-map:before{content:"\f279"}.fa-map-marked:before{content:"\f59f"}.fa-map-marked-alt:before{content:"\f5a0"}.fa-map-marker:before{content:"\f041"}.fa-map-marker-alt:before{content:"\f3c5"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-markdown:before{content:"\f60f"}.fa-marker:before{content:"\f5a1"}.fa-mars:before{content:"\f222"}.fa-mars-double:before{content:"\f227"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mask:before{content:"\f6fa"}.fa-mastodon:before{content:"\f4f6"}.fa-maxcdn:before{content:"\f136"}.fa-medal:before{content:"\f5a2"}.fa-medapps:before{content:"\f3c6"}.fa-medium:before{content:"\f23a"}.fa-medium-m:before{content:"\f3c7"}.fa-medkit:before{content:"\f0fa"}.fa-medrt:before{content:"\f3c8"}.fa-meetup:before{content:"\f2e0"}.fa-megaport:before{content:"\f5a3"}.fa-meh:before{content:"\f11a"}.fa-meh-blank:before{content:"\f5a4"}.fa-meh-rolling-eyes:before{content:"\f5a5"}.fa-memory:before{content:"\f538"}.fa-mendeley:before{content:"\f7b3"}.fa-menorah:before{content:"\f676"}.fa-mercury:before{content:"\f223"}.fa-meteor:before{content:"\f753"}.fa-microchip:before{content:"\f2db"}.fa-microphone:before{content:"\f130"}.fa-microphone-alt:before{content:"\f3c9"}.fa-microphone-alt-slash:before{content:"\f539"}.fa-microphone-slash:before{content:"\f131"}.fa-microscope:before{content:"\f610"}.fa-microsoft:before{content:"\f3ca"}.fa-minus:before{content:"\f068"}.fa-minus-circle:before{content:"\f056"}.fa-minus-square:before{content:"\f146"}.fa-mitten:before{content:"\f7b5"}.fa-mix:before{content:"\f3cb"}.fa-mixcloud:before{content:"\f289"}.fa-mizuni:before{content:"\f3cc"}.fa-mobile:before{content:"\f10b"}.fa-mobile-alt:before{content:"\f3cd"}.fa-modx:before{content:"\f285"}.fa-monero:before{content:"\f3d0"}.fa-money-bill:before{content:"\f0d6"}.fa-money-bill-alt:before{content:"\f3d1"}.fa-money-bill-wave:before{content:"\f53a"}.fa-money-bill-wave-alt:before{content:"\f53b"}.fa-money-check:before{content:"\f53c"}.fa-money-check-alt:before{content:"\f53d"}.fa-monument:before{content:"\f5a6"}.fa-moon:before{content:"\f186"}.fa-mortar-pestle:before{content:"\f5a7"}.fa-mosque:before{content:"\f678"}.fa-motorcycle:before{content:"\f21c"}.fa-mountain:before{content:"\f6fc"}.fa-mouse-pointer:before{content:"\f245"}.fa-mug-hot:before{content:"\f7b6"}.fa-music:before{content:"\f001"}.fa-napster:before{content:"\f3d2"}.fa-neos:before{content:"\f612"}.fa-network-wired:before{content:"\f6ff"}.fa-neuter:before{content:"\f22c"}.fa-newspaper:before{content:"\f1ea"}.fa-nimblr:before{content:"\f5a8"}.fa-node:before{content:"\f419"}.fa-node-js:before{content:"\f3d3"}.fa-not-equal:before{content:"\f53e"}.fa-notes-medical:before{content:"\f481"}.fa-npm:before{content:"\f3d4"}.fa-ns8:before{content:"\f3d5"}.fa-nutritionix:before{content:"\f3d6"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-oil-can:before{content:"\f613"}.fa-old-republic:before{content:"\f510"}.fa-om:before{content:"\f679"}.fa-opencart:before{content:"\f23d"}.fa-openid:before{content:"\f19b"}.fa-opera:before{content:"\f26a"}.fa-optin-monster:before{content:"\f23c"}.fa-osi:before{content:"\f41a"}.fa-otter:before{content:"\f700"}.fa-outdent:before{content:"\f03b"}.fa-page4:before{content:"\f3d7"}.fa-pagelines:before{content:"\f18c"}.fa-pager:before{content:"\f815"}.fa-paint-brush:before{content:"\f1fc"}.fa-paint-roller:before{content:"\f5aa"}.fa-palette:before{content:"\f53f"}.fa-palfed:before{content:"\f3d8"}.fa-pallet:before{content:"\f482"}.fa-paper-plane:before{content:"\f1d8"}.fa-paperclip:before{content:"\f0c6"}.fa-parachute-box:before{content:"\f4cd"}.fa-paragraph:before{content:"\f1dd"}.fa-parking:before{content:"\f540"}.fa-passport:before{content:"\f5ab"}.fa-pastafarianism:before{content:"\f67b"}.fa-paste:before{content:"\f0ea"}.fa-patreon:before{content:"\f3d9"}.fa-pause:before{content:"\f04c"}.fa-pause-circle:before{content:"\f28b"}.fa-paw:before{content:"\f1b0"}.fa-paypal:before{content:"\f1ed"}.fa-peace:before{content:"\f67c"}.fa-pen:before{content:"\f304"}.fa-pen-alt:before{content:"\f305"}.fa-pen-fancy:before{content:"\f5ac"}.fa-pen-nib:before{content:"\f5ad"}.fa-pen-square:before{content:"\f14b"}.fa-pencil-alt:before{content:"\f303"}.fa-pencil-ruler:before{content:"\f5ae"}.fa-penny-arcade:before{content:"\f704"}.fa-people-carry:before{content:"\f4ce"}.fa-pepper-hot:before{content:"\f816"}.fa-percent:before{content:"\f295"}.fa-percentage:before{content:"\f541"}.fa-periscope:before{content:"\f3da"}.fa-person-booth:before{content:"\f756"}.fa-phabricator:before{content:"\f3db"}.fa-phoenix-framework:before{content:"\f3dc"}.fa-phoenix-squadron:before{content:"\f511"}.fa-phone:before{content:"\f095"}.fa-phone-alt:before{content:"\f879"}.fa-phone-slash:before{content:"\f3dd"}.fa-phone-square:before{content:"\f098"}.fa-phone-square-alt:before{content:"\f87b"}.fa-phone-volume:before{content:"\f2a0"}.fa-photo-video:before{content:"\f87c"}.fa-php:before{content:"\f457"}.fa-pied-piper:before{content:"\f2ae"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-pied-piper-hat:before{content:"\f4e5"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-piggy-bank:before{content:"\f4d3"}.fa-pills:before{content:"\f484"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-p:before{content:"\f231"}.fa-pinterest-square:before{content:"\f0d3"}.fa-pizza-slice:before{content:"\f818"}.fa-place-of-worship:before{content:"\f67f"}.fa-plane:before{content:"\f072"}.fa-plane-arrival:before{content:"\f5af"}.fa-plane-departure:before{content:"\f5b0"}.fa-play:before{content:"\f04b"}.fa-play-circle:before{content:"\f144"}.fa-playstation:before{content:"\f3df"}.fa-plug:before{content:"\f1e6"}.fa-plus:before{content:"\f067"}.fa-plus-circle:before{content:"\f055"}.fa-plus-square:before{content:"\f0fe"}.fa-podcast:before{content:"\f2ce"}.fa-poll:before{content:"\f681"}.fa-poll-h:before{content:"\f682"}.fa-poo:before{content:"\f2fe"}.fa-poo-storm:before{content:"\f75a"}.fa-poop:before{content:"\f619"}.fa-portrait:before{content:"\f3e0"}.fa-pound-sign:before{content:"\f154"}.fa-power-off:before{content:"\f011"}.fa-pray:before{content:"\f683"}.fa-praying-hands:before{content:"\f684"}.fa-prescription:before{content:"\f5b1"}.fa-prescription-bottle:before{content:"\f485"}.fa-prescription-bottle-alt:before{content:"\f486"}.fa-print:before{content:"\f02f"}.fa-procedures:before{content:"\f487"}.fa-product-hunt:before{content:"\f288"}.fa-project-diagram:before{content:"\f542"}.fa-pushed:before{content:"\f3e1"}.fa-puzzle-piece:before{content:"\f12e"}.fa-python:before{content:"\f3e2"}.fa-qq:before{content:"\f1d6"}.fa-qrcode:before{content:"\f029"}.fa-question:before{content:"\f128"}.fa-question-circle:before{content:"\f059"}.fa-quidditch:before{content:"\f458"}.fa-quinscape:before{content:"\f459"}.fa-quora:before{content:"\f2c4"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-quran:before{content:"\f687"}.fa-r-project:before{content:"\f4f7"}.fa-radiation:before{content:"\f7b9"}.fa-radiation-alt:before{content:"\f7ba"}.fa-rainbow:before{content:"\f75b"}.fa-random:before{content:"\f074"}.fa-raspberry-pi:before{content:"\f7bb"}.fa-ravelry:before{content:"\f2d9"}.fa-react:before{content:"\f41b"}.fa-reacteurope:before{content:"\f75d"}.fa-readme:before{content:"\f4d5"}.fa-rebel:before{content:"\f1d0"}.fa-receipt:before{content:"\f543"}.fa-recycle:before{content:"\f1b8"}.fa-red-river:before{content:"\f3e3"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-alien:before{content:"\f281"}.fa-reddit-square:before{content:"\f1a2"}.fa-redhat:before{content:"\f7bc"}.fa-redo:before{content:"\f01e"}.fa-redo-alt:before{content:"\f2f9"}.fa-registered:before{content:"\f25d"}.fa-remove-format:before{content:"\f87d"}.fa-renren:before{content:"\f18b"}.fa-reply:before{content:"\f3e5"}.fa-reply-all:before{content:"\f122"}.fa-replyd:before{content:"\f3e6"}.fa-republican:before{content:"\f75e"}.fa-researchgate:before{content:"\f4f8"}.fa-resolving:before{content:"\f3e7"}.fa-restroom:before{content:"\f7bd"}.fa-retweet:before{content:"\f079"}.fa-rev:before{content:"\f5b2"}.fa-ribbon:before{content:"\f4d6"}.fa-ring:before{content:"\f70b"}.fa-road:before{content:"\f018"}.fa-robot:before{content:"\f544"}.fa-rocket:before{content:"\f135"}.fa-rocketchat:before{content:"\f3e8"}.fa-rockrms:before{content:"\f3e9"}.fa-route:before{content:"\f4d7"}.fa-rss:before{content:"\f09e"}.fa-rss-square:before{content:"\f143"}.fa-ruble-sign:before{content:"\f158"}.fa-ruler:before{content:"\f545"}.fa-ruler-combined:before{content:"\f546"}.fa-ruler-horizontal:before{content:"\f547"}.fa-ruler-vertical:before{content:"\f548"}.fa-running:before{content:"\f70c"}.fa-rupee-sign:before{content:"\f156"}.fa-sad-cry:before{content:"\f5b3"}.fa-sad-tear:before{content:"\f5b4"}.fa-safari:before{content:"\f267"}.fa-salesforce:before{content:"\f83b"}.fa-sass:before{content:"\f41e"}.fa-satellite:before{content:"\f7bf"}.fa-satellite-dish:before{content:"\f7c0"}.fa-save:before{content:"\f0c7"}.fa-schlix:before{content:"\f3ea"}.fa-school:before{content:"\f549"}.fa-screwdriver:before{content:"\f54a"}.fa-scribd:before{content:"\f28a"}.fa-scroll:before{content:"\f70e"}.fa-sd-card:before{content:"\f7c2"}.fa-search:before{content:"\f002"}.fa-search-dollar:before{content:"\f688"}.fa-search-location:before{content:"\f689"}.fa-search-minus:before{content:"\f010"}.fa-search-plus:before{content:"\f00e"}.fa-searchengin:before{content:"\f3eb"}.fa-seedling:before{content:"\f4d8"}.fa-sellcast:before{content:"\f2da"}.fa-sellsy:before{content:"\f213"}.fa-server:before{content:"\f233"}.fa-servicestack:before{content:"\f3ec"}.fa-shapes:before{content:"\f61f"}.fa-share:before{content:"\f064"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-share-square:before{content:"\f14d"}.fa-shekel-sign:before{content:"\f20b"}.fa-shield-alt:before{content:"\f3ed"}.fa-ship:before{content:"\f21a"}.fa-shipping-fast:before{content:"\f48b"}.fa-shirtsinbulk:before{content:"\f214"}.fa-shoe-prints:before{content:"\f54b"}.fa-shopping-bag:before{content:"\f290"}.fa-shopping-basket:before{content:"\f291"}.fa-shopping-cart:before{content:"\f07a"}.fa-shopware:before{content:"\f5b5"}.fa-shower:before{content:"\f2cc"}.fa-shuttle-van:before{content:"\f5b6"}.fa-sign:before{content:"\f4d9"}.fa-sign-in-alt:before{content:"\f2f6"}.fa-sign-language:before{content:"\f2a7"}.fa-sign-out-alt:before{content:"\f2f5"}.fa-signal:before{content:"\f012"}.fa-signature:before{content:"\f5b7"}.fa-sim-card:before{content:"\f7c4"}.fa-simplybuilt:before{content:"\f215"}.fa-sistrix:before{content:"\f3ee"}.fa-sitemap:before{content:"\f0e8"}.fa-sith:before{content:"\f512"}.fa-skating:before{content:"\f7c5"}.fa-sketch:before{content:"\f7c6"}.fa-skiing:before{content:"\f7c9"}.fa-skiing-nordic:before{content:"\f7ca"}.fa-skull:before{content:"\f54c"}.fa-skull-crossbones:before{content:"\f714"}.fa-skyatlas:before{content:"\f216"}.fa-skype:before{content:"\f17e"}.fa-slack:before{content:"\f198"}.fa-slack-hash:before{content:"\f3ef"}.fa-slash:before{content:"\f715"}.fa-sleigh:before{content:"\f7cc"}.fa-sliders-h:before{content:"\f1de"}.fa-slideshare:before{content:"\f1e7"}.fa-smile:before{content:"\f118"}.fa-smile-beam:before{content:"\f5b8"}.fa-smile-wink:before{content:"\f4da"}.fa-smog:before{content:"\f75f"}.fa-smoking:before{content:"\f48d"}.fa-smoking-ban:before{content:"\f54d"}.fa-sms:before{content:"\f7cd"}.fa-snapchat:before{content:"\f2ab"}.fa-snapchat-ghost:before{content:"\f2ac"}.fa-snapchat-square:before{content:"\f2ad"}.fa-snowboarding:before{content:"\f7ce"}.fa-snowflake:before{content:"\f2dc"}.fa-snowman:before{content:"\f7d0"}.fa-snowplow:before{content:"\f7d2"}.fa-socks:before{content:"\f696"}.fa-solar-panel:before{content:"\f5ba"}.fa-sort:before{content:"\f0dc"}.fa-sort-alpha-down:before{content:"\f15d"}.fa-sort-alpha-down-alt:before{content:"\f881"}.fa-sort-alpha-up:before{content:"\f15e"}.fa-sort-alpha-up-alt:before{content:"\f882"}.fa-sort-amount-down:before{content:"\f160"}.fa-sort-amount-down-alt:before{content:"\f884"}.fa-sort-amount-up:before{content:"\f161"}.fa-sort-amount-up-alt:before{content:"\f885"}.fa-sort-down:before{content:"\f0dd"}.fa-sort-numeric-down:before{content:"\f162"}.fa-sort-numeric-down-alt:before{content:"\f886"}.fa-sort-numeric-up:before{content:"\f163"}.fa-sort-numeric-up-alt:before{content:"\f887"}.fa-sort-up:before{content:"\f0de"}.fa-soundcloud:before{content:"\f1be"}.fa-sourcetree:before{content:"\f7d3"}.fa-spa:before{content:"\f5bb"}.fa-space-shuttle:before{content:"\f197"}.fa-speakap:before{content:"\f3f3"}.fa-speaker-deck:before{content:"\f83c"}.fa-spell-check:before{content:"\f891"}.fa-spider:before{content:"\f717"}.fa-spinner:before{content:"\f110"}.fa-splotch:before{content:"\f5bc"}.fa-spotify:before{content:"\f1bc"}.fa-spray-can:before{content:"\f5bd"}.fa-square:before{content:"\f0c8"}.fa-square-full:before{content:"\f45c"}.fa-square-root-alt:before{content:"\f698"}.fa-squarespace:before{content:"\f5be"}.fa-stack-exchange:before{content:"\f18d"}.fa-stack-overflow:before{content:"\f16c"}.fa-stackpath:before{content:"\f842"}.fa-stamp:before{content:"\f5bf"}.fa-star:before{content:"\f005"}.fa-star-and-crescent:before{content:"\f699"}.fa-star-half:before{content:"\f089"}.fa-star-half-alt:before{content:"\f5c0"}.fa-star-of-david:before{content:"\f69a"}.fa-star-of-life:before{content:"\f621"}.fa-staylinked:before{content:"\f3f5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-steam-symbol:before{content:"\f3f6"}.fa-step-backward:before{content:"\f048"}.fa-step-forward:before{content:"\f051"}.fa-stethoscope:before{content:"\f0f1"}.fa-sticker-mule:before{content:"\f3f7"}.fa-sticky-note:before{content:"\f249"}.fa-stop:before{content:"\f04d"}.fa-stop-circle:before{content:"\f28d"}.fa-stopwatch:before{content:"\f2f2"}.fa-store:before{content:"\f54e"}.fa-store-alt:before{content:"\f54f"}.fa-strava:before{content:"\f428"}.fa-stream:before{content:"\f550"}.fa-street-view:before{content:"\f21d"}.fa-strikethrough:before{content:"\f0cc"}.fa-stripe:before{content:"\f429"}.fa-stripe-s:before{content:"\f42a"}.fa-stroopwafel:before{content:"\f551"}.fa-studiovinari:before{content:"\f3f8"}.fa-stumbleupon:before{content:"\f1a4"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-subscript:before{content:"\f12c"}.fa-subway:before{content:"\f239"}.fa-suitcase:before{content:"\f0f2"}.fa-suitcase-rolling:before{content:"\f5c1"}.fa-sun:before{content:"\f185"}.fa-superpowers:before{content:"\f2dd"}.fa-superscript:before{content:"\f12b"}.fa-supple:before{content:"\f3f9"}.fa-surprise:before{content:"\f5c2"}.fa-suse:before{content:"\f7d6"}.fa-swatchbook:before{content:"\f5c3"}.fa-swimmer:before{content:"\f5c4"}.fa-swimming-pool:before{content:"\f5c5"}.fa-symfony:before{content:"\f83d"}.fa-synagogue:before{content:"\f69b"}.fa-sync:before{content:"\f021"}.fa-sync-alt:before{content:"\f2f1"}.fa-syringe:before{content:"\f48e"}.fa-table:before{content:"\f0ce"}.fa-table-tennis:before{content:"\f45d"}.fa-tablet:before{content:"\f10a"}.fa-tablet-alt:before{content:"\f3fa"}.fa-tablets:before{content:"\f490"}.fa-tachometer-alt:before{content:"\f3fd"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-tape:before{content:"\f4db"}.fa-tasks:before{content:"\f0ae"}.fa-taxi:before{content:"\f1ba"}.fa-teamspeak:before{content:"\f4f9"}.fa-teeth:before{content:"\f62e"}.fa-teeth-open:before{content:"\f62f"}.fa-telegram:before{content:"\f2c6"}.fa-telegram-plane:before{content:"\f3fe"}.fa-temperature-high:before{content:"\f769"}.fa-temperature-low:before{content:"\f76b"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-tenge:before{content:"\f7d7"}.fa-terminal:before{content:"\f120"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-th:before{content:"\f00a"}.fa-th-large:before{content:"\f009"}.fa-th-list:before{content:"\f00b"}.fa-the-red-yeti:before{content:"\f69d"}.fa-theater-masks:before{content:"\f630"}.fa-themeco:before{content:"\f5c6"}.fa-themeisle:before{content:"\f2b2"}.fa-thermometer:before{content:"\f491"}.fa-thermometer-empty:before{content:"\f2cb"}.fa-thermometer-full:before{content:"\f2c7"}.fa-thermometer-half:before{content:"\f2c9"}.fa-thermometer-quarter:before{content:"\f2ca"}.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-think-peaks:before{content:"\f731"}.fa-thumbs-down:before{content:"\f165"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbtack:before{content:"\f08d"}.fa-ticket-alt:before{content:"\f3ff"}.fa-times:before{content:"\f00d"}.fa-times-circle:before{content:"\f057"}.fa-tint:before{content:"\f043"}.fa-tint-slash:before{content:"\f5c7"}.fa-tired:before{content:"\f5c8"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-toilet:before{content:"\f7d8"}.fa-toilet-paper:before{content:"\f71e"}.fa-toolbox:before{content:"\f552"}.fa-tools:before{content:"\f7d9"}.fa-tooth:before{content:"\f5c9"}.fa-torah:before{content:"\f6a0"}.fa-torii-gate:before{content:"\f6a1"}.fa-tractor:before{content:"\f722"}.fa-trade-federation:before{content:"\f513"}.fa-trademark:before{content:"\f25c"}.fa-traffic-light:before{content:"\f637"}.fa-train:before{content:"\f238"}.fa-tram:before{content:"\f7da"}.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-trash:before{content:"\f1f8"}.fa-trash-alt:before{content:"\f2ed"}.fa-trash-restore:before{content:"\f829"}.fa-trash-restore-alt:before{content:"\f82a"}.fa-tree:before{content:"\f1bb"}.fa-trello:before{content:"\f181"}.fa-tripadvisor:before{content:"\f262"}.fa-trophy:before{content:"\f091"}.fa-truck:before{content:"\f0d1"}.fa-truck-loading:before{content:"\f4de"}.fa-truck-monster:before{content:"\f63b"}.fa-truck-moving:before{content:"\f4df"}.fa-truck-pickup:before{content:"\f63c"}.fa-tshirt:before{content:"\f553"}.fa-tty:before{content:"\f1e4"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-tv:before{content:"\f26c"}.fa-twitch:before{content:"\f1e8"}.fa-twitter:before{content:"\f099"}.fa-twitter-square:before{content:"\f081"}.fa-typo3:before{content:"\f42b"}.fa-uber:before{content:"\f402"}.fa-ubuntu:before{content:"\f7df"}.fa-uikit:before{content:"\f403"}.fa-umbrella:before{content:"\f0e9"}.fa-umbrella-beach:before{content:"\f5ca"}.fa-underline:before{content:"\f0cd"}.fa-undo:before{content:"\f0e2"}.fa-undo-alt:before{content:"\f2ea"}.fa-uniregistry:before{content:"\f404"}.fa-universal-access:before{content:"\f29a"}.fa-university:before{content:"\f19c"}.fa-unlink:before{content:"\f127"}.fa-unlock:before{content:"\f09c"}.fa-unlock-alt:before{content:"\f13e"}.fa-untappd:before{content:"\f405"}.fa-upload:before{content:"\f093"}.fa-ups:before{content:"\f7e0"}.fa-usb:before{content:"\f287"}.fa-user:before{content:"\f007"}.fa-user-alt:before{content:"\f406"}.fa-user-alt-slash:before{content:"\f4fa"}.fa-user-astronaut:before{content:"\f4fb"}.fa-user-check:before{content:"\f4fc"}.fa-user-circle:before{content:"\f2bd"}.fa-user-clock:before{content:"\f4fd"}.fa-user-cog:before{content:"\f4fe"}.fa-user-edit:before{content:"\f4ff"}.fa-user-friends:before{content:"\f500"}.fa-user-graduate:before{content:"\f501"}.fa-user-injured:before{content:"\f728"}.fa-user-lock:before{content:"\f502"}.fa-user-md:before{content:"\f0f0"}.fa-user-minus:before{content:"\f503"}.fa-user-ninja:before{content:"\f504"}.fa-user-nurse:before{content:"\f82f"}.fa-user-plus:before{content:"\f234"}.fa-user-secret:before{content:"\f21b"}.fa-user-shield:before{content:"\f505"}.fa-user-slash:before{content:"\f506"}.fa-user-tag:before{content:"\f507"}.fa-user-tie:before{content:"\f508"}.fa-user-times:before{content:"\f235"}.fa-users:before{content:"\f0c0"}.fa-users-cog:before{content:"\f509"}.fa-usps:before{content:"\f7e1"}.fa-ussunnah:before{content:"\f407"}.fa-utensil-spoon:before{content:"\f2e5"}.fa-utensils:before{content:"\f2e7"}.fa-vaadin:before{content:"\f408"}.fa-vector-square:before{content:"\f5cb"}.fa-venus:before{content:"\f221"}.fa-venus-double:before{content:"\f226"}.fa-venus-mars:before{content:"\f228"}.fa-viacoin:before{content:"\f237"}.fa-viadeo:before{content:"\f2a9"}.fa-viadeo-square:before{content:"\f2aa"}.fa-vial:before{content:"\f492"}.fa-vials:before{content:"\f493"}.fa-viber:before{content:"\f409"}.fa-video:before{content:"\f03d"}.fa-video-slash:before{content:"\f4e2"}.fa-vihara:before{content:"\f6a7"}.fa-vimeo:before{content:"\f40a"}.fa-vimeo-square:before{content:"\f194"}.fa-vimeo-v:before{content:"\f27d"}.fa-vine:before{content:"\f1ca"}.fa-vk:before{content:"\f189"}.fa-vnv:before{content:"\f40b"}.fa-voicemail:before{content:"\f897"}.fa-volleyball-ball:before{content:"\f45f"}.fa-volume-down:before{content:"\f027"}.fa-volume-mute:before{content:"\f6a9"}.fa-volume-off:before{content:"\f026"}.fa-volume-up:before{content:"\f028"}.fa-vote-yea:before{content:"\f772"}.fa-vr-cardboard:before{content:"\f729"}.fa-vuejs:before{content:"\f41f"}.fa-walking:before{content:"\f554"}.fa-wallet:before{content:"\f555"}.fa-warehouse:before{content:"\f494"}.fa-water:before{content:"\f773"}.fa-wave-square:before{content:"\f83e"}.fa-waze:before{content:"\f83f"}.fa-weebly:before{content:"\f5cc"}.fa-weibo:before{content:"\f18a"}.fa-weight:before{content:"\f496"}.fa-weight-hanging:before{content:"\f5cd"}.fa-weixin:before{content:"\f1d7"}.fa-whatsapp:before{content:"\f232"}.fa-whatsapp-square:before{content:"\f40c"}.fa-wheelchair:before{content:"\f193"}.fa-whmcs:before{content:"\f40d"}.fa-wifi:before{content:"\f1eb"}.fa-wikipedia-w:before{content:"\f266"}.fa-wind:before{content:"\f72e"}.fa-window-close:before{content:"\f410"}.fa-window-maximize:before{content:"\f2d0"}.fa-window-minimize:before{content:"\f2d1"}.fa-window-restore:before{content:"\f2d2"}.fa-windows:before{content:"\f17a"}.fa-wine-bottle:before{content:"\f72f"}.fa-wine-glass:before{content:"\f4e3"}.fa-wine-glass-alt:before{content:"\f5ce"}.fa-wix:before{content:"\f5cf"}.fa-wizards-of-the-coast:before{content:"\f730"}.fa-wolf-pack-battalion:before{content:"\f514"}.fa-won-sign:before{content:"\f159"}.fa-wordpress:before{content:"\f19a"}.fa-wordpress-simple:before{content:"\f411"}.fa-wpbeginner:before{content:"\f297"}.fa-wpexplorer:before{content:"\f2de"}.fa-wpforms:before{content:"\f298"}.fa-wpressr:before{content:"\f3e4"}.fa-wrench:before{content:"\f0ad"}.fa-x-ray:before{content:"\f497"}.fa-xbox:before{content:"\f412"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-y-combinator:before{content:"\f23b"}.fa-yahoo:before{content:"\f19e"}.fa-yammer:before{content:"\f840"}.fa-yandex:before{content:"\f413"}.fa-yandex-international:before{content:"\f414"}.fa-yarn:before{content:"\f7e3"}.fa-yelp:before{content:"\f1e9"}.fa-yen-sign:before{content:"\f157"}.fa-yin-yang:before{content:"\f6ad"}.fa-yoast:before{content:"\f2b1"}.fa-youtube:before{content:"\f167"}.fa-youtube-square:before{content:"\f431"}.fa-zhihu:before{content:"\f63f"}.sr-only{border:0;clip:rect(0,0,0,0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.sr-only-focusable:active,.sr-only-focusable:focus{clip:auto;height:auto;margin:0;overflow:visible;position:static;width:auto}@font-face{font-family:"Font Awesome 5 Brands";font-style:normal;font-weight:normal;font-display:auto;src:url(../webfonts/fa-brands-400.eot);src:url(../webfonts/fa-brands-400.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.woff) format("woff"),url(../webfonts/fa-brands-400.ttf) format("truetype"),url(../webfonts/fa-brands-400.svg#fontawesome) format("svg")}.fab{font-family:"Font Awesome 5 Brands"}@font-face{font-family:"Font Awesome 5 Free";font-style:normal;font-weight:400;font-display:auto;src:url(../webfonts/fa-regular-400.eot);src:url(../webfonts/fa-regular-400.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.woff) format("woff"),url(../webfonts/fa-regular-400.ttf) format("truetype"),url(../webfonts/fa-regular-400.svg#fontawesome) format("svg")}.far{font-weight:400}@font-face{font-family:"Font Awesome 5 Free";font-style:normal;font-weight:900;font-display:auto;src:url(../webfonts/fa-solid-900.eot);src:url(../webfonts/fa-solid-900.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.woff) format("woff"),url(../webfonts/fa-solid-900.ttf) format("truetype"),url(../webfonts/fa-solid-900.svg#fontawesome) format("svg")}.fa,.far,.fas{font-family:"Font Awesome 5 Free"}.fa,.fas{font-weight:900} \ No newline at end of file +.fa,.fab,.fad,.fal,.far,.fas{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:inline-block;font-style:normal;font-variant:normal;text-rendering:auto;line-height:1}.fa-lg{font-size:1.33333em;line-height:.75em;vertical-align:-.0667em}.fa-xs{font-size:.75em}.fa-sm{font-size:.875em}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-fw{text-align:center;width:1.25em}.fa-ul{list-style-type:none;margin-left:2.5em;padding-left:0}.fa-ul>li{position:relative}.fa-li{left:-2em;position:absolute;text-align:center;width:2em;line-height:inherit}.fa-border{border:.08em solid #eee;border-radius:.1em;padding:.2em .25em .15em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left,.fab.fa-pull-left,.fal.fa-pull-left,.far.fa-pull-left,.fas.fa-pull-left{margin-right:.3em}.fa.fa-pull-right,.fab.fa-pull-right,.fal.fa-pull-right,.far.fa-pull-right,.fas.fa-pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s linear infinite;animation:fa-spin 2s linear infinite}.fa-pulse{-webkit-animation:fa-spin 1s steps(8) infinite;animation:fa-spin 1s steps(8) infinite}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scaleX(-1);transform:scaleX(-1)}.fa-flip-vertical{-webkit-transform:scaleY(-1);transform:scaleY(-1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical,.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)"}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{-webkit-transform:scale(-1);transform:scale(-1)}:root .fa-flip-both,:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270{-webkit-filter:none;filter:none}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2.5em}.fa-stack-1x,.fa-stack-2x{left:0;position:absolute;text-align:center;width:100%}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-500px:before{content:"\f26e"}.fa-accessible-icon:before{content:"\f368"}.fa-accusoft:before{content:"\f369"}.fa-acquisitions-incorporated:before{content:"\f6af"}.fa-ad:before{content:"\f641"}.fa-address-book:before{content:"\f2b9"}.fa-address-card:before{content:"\f2bb"}.fa-adjust:before{content:"\f042"}.fa-adn:before{content:"\f170"}.fa-adobe:before{content:"\f778"}.fa-adversal:before{content:"\f36a"}.fa-affiliatetheme:before{content:"\f36b"}.fa-air-freshener:before{content:"\f5d0"}.fa-airbnb:before{content:"\f834"}.fa-algolia:before{content:"\f36c"}.fa-align-center:before{content:"\f037"}.fa-align-justify:before{content:"\f039"}.fa-align-left:before{content:"\f036"}.fa-align-right:before{content:"\f038"}.fa-alipay:before{content:"\f642"}.fa-allergies:before{content:"\f461"}.fa-amazon:before{content:"\f270"}.fa-amazon-pay:before{content:"\f42c"}.fa-ambulance:before{content:"\f0f9"}.fa-american-sign-language-interpreting:before{content:"\f2a3"}.fa-amilia:before{content:"\f36d"}.fa-anchor:before{content:"\f13d"}.fa-android:before{content:"\f17b"}.fa-angellist:before{content:"\f209"}.fa-angle-double-down:before{content:"\f103"}.fa-angle-double-left:before{content:"\f100"}.fa-angle-double-right:before{content:"\f101"}.fa-angle-double-up:before{content:"\f102"}.fa-angle-down:before{content:"\f107"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angry:before{content:"\f556"}.fa-angrycreative:before{content:"\f36e"}.fa-angular:before{content:"\f420"}.fa-ankh:before{content:"\f644"}.fa-app-store:before{content:"\f36f"}.fa-app-store-ios:before{content:"\f370"}.fa-apper:before{content:"\f371"}.fa-apple:before{content:"\f179"}.fa-apple-alt:before{content:"\f5d1"}.fa-apple-pay:before{content:"\f415"}.fa-archive:before{content:"\f187"}.fa-archway:before{content:"\f557"}.fa-arrow-alt-circle-down:before{content:"\f358"}.fa-arrow-alt-circle-left:before{content:"\f359"}.fa-arrow-alt-circle-right:before{content:"\f35a"}.fa-arrow-alt-circle-up:before{content:"\f35b"}.fa-arrow-circle-down:before{content:"\f0ab"}.fa-arrow-circle-left:before{content:"\f0a8"}.fa-arrow-circle-right:before{content:"\f0a9"}.fa-arrow-circle-up:before{content:"\f0aa"}.fa-arrow-down:before{content:"\f063"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-up:before{content:"\f062"}.fa-arrows-alt:before{content:"\f0b2"}.fa-arrows-alt-h:before{content:"\f337"}.fa-arrows-alt-v:before{content:"\f338"}.fa-artstation:before{content:"\f77a"}.fa-assistive-listening-systems:before{content:"\f2a2"}.fa-asterisk:before{content:"\f069"}.fa-asymmetrik:before{content:"\f372"}.fa-at:before{content:"\f1fa"}.fa-atlas:before{content:"\f558"}.fa-atlassian:before{content:"\f77b"}.fa-atom:before{content:"\f5d2"}.fa-audible:before{content:"\f373"}.fa-audio-description:before{content:"\f29e"}.fa-autoprefixer:before{content:"\f41c"}.fa-avianex:before{content:"\f374"}.fa-aviato:before{content:"\f421"}.fa-award:before{content:"\f559"}.fa-aws:before{content:"\f375"}.fa-baby:before{content:"\f77c"}.fa-baby-carriage:before{content:"\f77d"}.fa-backspace:before{content:"\f55a"}.fa-backward:before{content:"\f04a"}.fa-bacon:before{content:"\f7e5"}.fa-bahai:before{content:"\f666"}.fa-balance-scale:before{content:"\f24e"}.fa-balance-scale-left:before{content:"\f515"}.fa-balance-scale-right:before{content:"\f516"}.fa-ban:before{content:"\f05e"}.fa-band-aid:before{content:"\f462"}.fa-bandcamp:before{content:"\f2d5"}.fa-barcode:before{content:"\f02a"}.fa-bars:before{content:"\f0c9"}.fa-baseball-ball:before{content:"\f433"}.fa-basketball-ball:before{content:"\f434"}.fa-bath:before{content:"\f2cd"}.fa-battery-empty:before{content:"\f244"}.fa-battery-full:before{content:"\f240"}.fa-battery-half:before{content:"\f242"}.fa-battery-quarter:before{content:"\f243"}.fa-battery-three-quarters:before{content:"\f241"}.fa-battle-net:before{content:"\f835"}.fa-bed:before{content:"\f236"}.fa-beer:before{content:"\f0fc"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-bell:before{content:"\f0f3"}.fa-bell-slash:before{content:"\f1f6"}.fa-bezier-curve:before{content:"\f55b"}.fa-bible:before{content:"\f647"}.fa-bicycle:before{content:"\f206"}.fa-biking:before{content:"\f84a"}.fa-bimobject:before{content:"\f378"}.fa-binoculars:before{content:"\f1e5"}.fa-biohazard:before{content:"\f780"}.fa-birthday-cake:before{content:"\f1fd"}.fa-bitbucket:before{content:"\f171"}.fa-bitcoin:before{content:"\f379"}.fa-bity:before{content:"\f37a"}.fa-black-tie:before{content:"\f27e"}.fa-blackberry:before{content:"\f37b"}.fa-blender:before{content:"\f517"}.fa-blender-phone:before{content:"\f6b6"}.fa-blind:before{content:"\f29d"}.fa-blog:before{content:"\f781"}.fa-blogger:before{content:"\f37c"}.fa-blogger-b:before{content:"\f37d"}.fa-bluetooth:before{content:"\f293"}.fa-bluetooth-b:before{content:"\f294"}.fa-bold:before{content:"\f032"}.fa-bolt:before{content:"\f0e7"}.fa-bomb:before{content:"\f1e2"}.fa-bone:before{content:"\f5d7"}.fa-bong:before{content:"\f55c"}.fa-book:before{content:"\f02d"}.fa-book-dead:before{content:"\f6b7"}.fa-book-medical:before{content:"\f7e6"}.fa-book-open:before{content:"\f518"}.fa-book-reader:before{content:"\f5da"}.fa-bookmark:before{content:"\f02e"}.fa-bootstrap:before{content:"\f836"}.fa-border-all:before{content:"\f84c"}.fa-border-none:before{content:"\f850"}.fa-border-style:before{content:"\f853"}.fa-bowling-ball:before{content:"\f436"}.fa-box:before{content:"\f466"}.fa-box-open:before{content:"\f49e"}.fa-box-tissue:before{content:"\f95b"}.fa-boxes:before{content:"\f468"}.fa-braille:before{content:"\f2a1"}.fa-brain:before{content:"\f5dc"}.fa-bread-slice:before{content:"\f7ec"}.fa-briefcase:before{content:"\f0b1"}.fa-briefcase-medical:before{content:"\f469"}.fa-broadcast-tower:before{content:"\f519"}.fa-broom:before{content:"\f51a"}.fa-brush:before{content:"\f55d"}.fa-btc:before{content:"\f15a"}.fa-buffer:before{content:"\f837"}.fa-bug:before{content:"\f188"}.fa-building:before{content:"\f1ad"}.fa-bullhorn:before{content:"\f0a1"}.fa-bullseye:before{content:"\f140"}.fa-burn:before{content:"\f46a"}.fa-buromobelexperte:before{content:"\f37f"}.fa-bus:before{content:"\f207"}.fa-bus-alt:before{content:"\f55e"}.fa-business-time:before{content:"\f64a"}.fa-buy-n-large:before{content:"\f8a6"}.fa-buysellads:before{content:"\f20d"}.fa-calculator:before{content:"\f1ec"}.fa-calendar:before{content:"\f133"}.fa-calendar-alt:before{content:"\f073"}.fa-calendar-check:before{content:"\f274"}.fa-calendar-day:before{content:"\f783"}.fa-calendar-minus:before{content:"\f272"}.fa-calendar-plus:before{content:"\f271"}.fa-calendar-times:before{content:"\f273"}.fa-calendar-week:before{content:"\f784"}.fa-camera:before{content:"\f030"}.fa-camera-retro:before{content:"\f083"}.fa-campground:before{content:"\f6bb"}.fa-canadian-maple-leaf:before{content:"\f785"}.fa-candy-cane:before{content:"\f786"}.fa-cannabis:before{content:"\f55f"}.fa-capsules:before{content:"\f46b"}.fa-car:before{content:"\f1b9"}.fa-car-alt:before{content:"\f5de"}.fa-car-battery:before{content:"\f5df"}.fa-car-crash:before{content:"\f5e1"}.fa-car-side:before{content:"\f5e4"}.fa-caravan:before{content:"\f8ff"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-caret-square-down:before{content:"\f150"}.fa-caret-square-left:before{content:"\f191"}.fa-caret-square-right:before{content:"\f152"}.fa-caret-square-up:before{content:"\f151"}.fa-caret-up:before{content:"\f0d8"}.fa-carrot:before{content:"\f787"}.fa-cart-arrow-down:before{content:"\f218"}.fa-cart-plus:before{content:"\f217"}.fa-cash-register:before{content:"\f788"}.fa-cat:before{content:"\f6be"}.fa-cc-amazon-pay:before{content:"\f42d"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-apple-pay:before{content:"\f416"}.fa-cc-diners-club:before{content:"\f24c"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-cc-visa:before{content:"\f1f0"}.fa-centercode:before{content:"\f380"}.fa-centos:before{content:"\f789"}.fa-certificate:before{content:"\f0a3"}.fa-chair:before{content:"\f6c0"}.fa-chalkboard:before{content:"\f51b"}.fa-chalkboard-teacher:before{content:"\f51c"}.fa-charging-station:before{content:"\f5e7"}.fa-chart-area:before{content:"\f1fe"}.fa-chart-bar:before{content:"\f080"}.fa-chart-line:before{content:"\f201"}.fa-chart-pie:before{content:"\f200"}.fa-check:before{content:"\f00c"}.fa-check-circle:before{content:"\f058"}.fa-check-double:before{content:"\f560"}.fa-check-square:before{content:"\f14a"}.fa-cheese:before{content:"\f7ef"}.fa-chess:before{content:"\f439"}.fa-chess-bishop:before{content:"\f43a"}.fa-chess-board:before{content:"\f43c"}.fa-chess-king:before{content:"\f43f"}.fa-chess-knight:before{content:"\f441"}.fa-chess-pawn:before{content:"\f443"}.fa-chess-queen:before{content:"\f445"}.fa-chess-rook:before{content:"\f447"}.fa-chevron-circle-down:before{content:"\f13a"}.fa-chevron-circle-left:before{content:"\f137"}.fa-chevron-circle-right:before{content:"\f138"}.fa-chevron-circle-up:before{content:"\f139"}.fa-chevron-down:before{content:"\f078"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-chevron-up:before{content:"\f077"}.fa-child:before{content:"\f1ae"}.fa-chrome:before{content:"\f268"}.fa-chromecast:before{content:"\f838"}.fa-church:before{content:"\f51d"}.fa-circle:before{content:"\f111"}.fa-circle-notch:before{content:"\f1ce"}.fa-city:before{content:"\f64f"}.fa-clinic-medical:before{content:"\f7f2"}.fa-clipboard:before{content:"\f328"}.fa-clipboard-check:before{content:"\f46c"}.fa-clipboard-list:before{content:"\f46d"}.fa-clock:before{content:"\f017"}.fa-clone:before{content:"\f24d"}.fa-closed-captioning:before{content:"\f20a"}.fa-cloud:before{content:"\f0c2"}.fa-cloud-download-alt:before{content:"\f381"}.fa-cloud-meatball:before{content:"\f73b"}.fa-cloud-moon:before{content:"\f6c3"}.fa-cloud-moon-rain:before{content:"\f73c"}.fa-cloud-rain:before{content:"\f73d"}.fa-cloud-showers-heavy:before{content:"\f740"}.fa-cloud-sun:before{content:"\f6c4"}.fa-cloud-sun-rain:before{content:"\f743"}.fa-cloud-upload-alt:before{content:"\f382"}.fa-cloudscale:before{content:"\f383"}.fa-cloudsmith:before{content:"\f384"}.fa-cloudversify:before{content:"\f385"}.fa-cocktail:before{content:"\f561"}.fa-code:before{content:"\f121"}.fa-code-branch:before{content:"\f126"}.fa-codepen:before{content:"\f1cb"}.fa-codiepie:before{content:"\f284"}.fa-coffee:before{content:"\f0f4"}.fa-cog:before{content:"\f013"}.fa-cogs:before{content:"\f085"}.fa-coins:before{content:"\f51e"}.fa-columns:before{content:"\f0db"}.fa-comment:before{content:"\f075"}.fa-comment-alt:before{content:"\f27a"}.fa-comment-dollar:before{content:"\f651"}.fa-comment-dots:before{content:"\f4ad"}.fa-comment-medical:before{content:"\f7f5"}.fa-comment-slash:before{content:"\f4b3"}.fa-comments:before{content:"\f086"}.fa-comments-dollar:before{content:"\f653"}.fa-compact-disc:before{content:"\f51f"}.fa-compass:before{content:"\f14e"}.fa-compress:before{content:"\f066"}.fa-compress-alt:before{content:"\f422"}.fa-compress-arrows-alt:before{content:"\f78c"}.fa-concierge-bell:before{content:"\f562"}.fa-confluence:before{content:"\f78d"}.fa-connectdevelop:before{content:"\f20e"}.fa-contao:before{content:"\f26d"}.fa-cookie:before{content:"\f563"}.fa-cookie-bite:before{content:"\f564"}.fa-copy:before{content:"\f0c5"}.fa-copyright:before{content:"\f1f9"}.fa-cotton-bureau:before{content:"\f89e"}.fa-couch:before{content:"\f4b8"}.fa-cpanel:before{content:"\f388"}.fa-creative-commons:before{content:"\f25e"}.fa-creative-commons-by:before{content:"\f4e7"}.fa-creative-commons-nc:before{content:"\f4e8"}.fa-creative-commons-nc-eu:before{content:"\f4e9"}.fa-creative-commons-nc-jp:before{content:"\f4ea"}.fa-creative-commons-nd:before{content:"\f4eb"}.fa-creative-commons-pd:before{content:"\f4ec"}.fa-creative-commons-pd-alt:before{content:"\f4ed"}.fa-creative-commons-remix:before{content:"\f4ee"}.fa-creative-commons-sa:before{content:"\f4ef"}.fa-creative-commons-sampling:before{content:"\f4f0"}.fa-creative-commons-sampling-plus:before{content:"\f4f1"}.fa-creative-commons-share:before{content:"\f4f2"}.fa-creative-commons-zero:before{content:"\f4f3"}.fa-credit-card:before{content:"\f09d"}.fa-critical-role:before{content:"\f6c9"}.fa-crop:before{content:"\f125"}.fa-crop-alt:before{content:"\f565"}.fa-cross:before{content:"\f654"}.fa-crosshairs:before{content:"\f05b"}.fa-crow:before{content:"\f520"}.fa-crown:before{content:"\f521"}.fa-crutch:before{content:"\f7f7"}.fa-css3:before{content:"\f13c"}.fa-css3-alt:before{content:"\f38b"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-cut:before{content:"\f0c4"}.fa-cuttlefish:before{content:"\f38c"}.fa-d-and-d:before{content:"\f38d"}.fa-d-and-d-beyond:before{content:"\f6ca"}.fa-dailymotion:before{content:"\f952"}.fa-dashcube:before{content:"\f210"}.fa-database:before{content:"\f1c0"}.fa-deaf:before{content:"\f2a4"}.fa-delicious:before{content:"\f1a5"}.fa-democrat:before{content:"\f747"}.fa-deploydog:before{content:"\f38e"}.fa-deskpro:before{content:"\f38f"}.fa-desktop:before{content:"\f108"}.fa-dev:before{content:"\f6cc"}.fa-deviantart:before{content:"\f1bd"}.fa-dharmachakra:before{content:"\f655"}.fa-dhl:before{content:"\f790"}.fa-diagnoses:before{content:"\f470"}.fa-diaspora:before{content:"\f791"}.fa-dice:before{content:"\f522"}.fa-dice-d20:before{content:"\f6cf"}.fa-dice-d6:before{content:"\f6d1"}.fa-dice-five:before{content:"\f523"}.fa-dice-four:before{content:"\f524"}.fa-dice-one:before{content:"\f525"}.fa-dice-six:before{content:"\f526"}.fa-dice-three:before{content:"\f527"}.fa-dice-two:before{content:"\f528"}.fa-digg:before{content:"\f1a6"}.fa-digital-ocean:before{content:"\f391"}.fa-digital-tachograph:before{content:"\f566"}.fa-directions:before{content:"\f5eb"}.fa-discord:before{content:"\f392"}.fa-discourse:before{content:"\f393"}.fa-disease:before{content:"\f7fa"}.fa-divide:before{content:"\f529"}.fa-dizzy:before{content:"\f567"}.fa-dna:before{content:"\f471"}.fa-dochub:before{content:"\f394"}.fa-docker:before{content:"\f395"}.fa-dog:before{content:"\f6d3"}.fa-dollar-sign:before{content:"\f155"}.fa-dolly:before{content:"\f472"}.fa-dolly-flatbed:before{content:"\f474"}.fa-donate:before{content:"\f4b9"}.fa-door-closed:before{content:"\f52a"}.fa-door-open:before{content:"\f52b"}.fa-dot-circle:before{content:"\f192"}.fa-dove:before{content:"\f4ba"}.fa-download:before{content:"\f019"}.fa-draft2digital:before{content:"\f396"}.fa-drafting-compass:before{content:"\f568"}.fa-dragon:before{content:"\f6d5"}.fa-draw-polygon:before{content:"\f5ee"}.fa-dribbble:before{content:"\f17d"}.fa-dribbble-square:before{content:"\f397"}.fa-dropbox:before{content:"\f16b"}.fa-drum:before{content:"\f569"}.fa-drum-steelpan:before{content:"\f56a"}.fa-drumstick-bite:before{content:"\f6d7"}.fa-drupal:before{content:"\f1a9"}.fa-dumbbell:before{content:"\f44b"}.fa-dumpster:before{content:"\f793"}.fa-dumpster-fire:before{content:"\f794"}.fa-dungeon:before{content:"\f6d9"}.fa-dyalog:before{content:"\f399"}.fa-earlybirds:before{content:"\f39a"}.fa-ebay:before{content:"\f4f4"}.fa-edge:before{content:"\f282"}.fa-edit:before{content:"\f044"}.fa-egg:before{content:"\f7fb"}.fa-eject:before{content:"\f052"}.fa-elementor:before{content:"\f430"}.fa-ellipsis-h:before{content:"\f141"}.fa-ellipsis-v:before{content:"\f142"}.fa-ello:before{content:"\f5f1"}.fa-ember:before{content:"\f423"}.fa-empire:before{content:"\f1d1"}.fa-envelope:before{content:"\f0e0"}.fa-envelope-open:before{content:"\f2b6"}.fa-envelope-open-text:before{content:"\f658"}.fa-envelope-square:before{content:"\f199"}.fa-envira:before{content:"\f299"}.fa-equals:before{content:"\f52c"}.fa-eraser:before{content:"\f12d"}.fa-erlang:before{content:"\f39d"}.fa-ethereum:before{content:"\f42e"}.fa-ethernet:before{content:"\f796"}.fa-etsy:before{content:"\f2d7"}.fa-euro-sign:before{content:"\f153"}.fa-evernote:before{content:"\f839"}.fa-exchange-alt:before{content:"\f362"}.fa-exclamation:before{content:"\f12a"}.fa-exclamation-circle:before{content:"\f06a"}.fa-exclamation-triangle:before{content:"\f071"}.fa-expand:before{content:"\f065"}.fa-expand-alt:before{content:"\f424"}.fa-expand-arrows-alt:before{content:"\f31e"}.fa-expeditedssl:before{content:"\f23e"}.fa-external-link-alt:before{content:"\f35d"}.fa-external-link-square-alt:before{content:"\f360"}.fa-eye:before{content:"\f06e"}.fa-eye-dropper:before{content:"\f1fb"}.fa-eye-slash:before{content:"\f070"}.fa-facebook:before{content:"\f09a"}.fa-facebook-f:before{content:"\f39e"}.fa-facebook-messenger:before{content:"\f39f"}.fa-facebook-square:before{content:"\f082"}.fa-fan:before{content:"\f863"}.fa-fantasy-flight-games:before{content:"\f6dc"}.fa-fast-backward:before{content:"\f049"}.fa-fast-forward:before{content:"\f050"}.fa-faucet:before{content:"\f905"}.fa-fax:before{content:"\f1ac"}.fa-feather:before{content:"\f52d"}.fa-feather-alt:before{content:"\f56b"}.fa-fedex:before{content:"\f797"}.fa-fedora:before{content:"\f798"}.fa-female:before{content:"\f182"}.fa-fighter-jet:before{content:"\f0fb"}.fa-figma:before{content:"\f799"}.fa-file:before{content:"\f15b"}.fa-file-alt:before{content:"\f15c"}.fa-file-archive:before{content:"\f1c6"}.fa-file-audio:before{content:"\f1c7"}.fa-file-code:before{content:"\f1c9"}.fa-file-contract:before{content:"\f56c"}.fa-file-csv:before{content:"\f6dd"}.fa-file-download:before{content:"\f56d"}.fa-file-excel:before{content:"\f1c3"}.fa-file-export:before{content:"\f56e"}.fa-file-image:before{content:"\f1c5"}.fa-file-import:before{content:"\f56f"}.fa-file-invoice:before{content:"\f570"}.fa-file-invoice-dollar:before{content:"\f571"}.fa-file-medical:before{content:"\f477"}.fa-file-medical-alt:before{content:"\f478"}.fa-file-pdf:before{content:"\f1c1"}.fa-file-powerpoint:before{content:"\f1c4"}.fa-file-prescription:before{content:"\f572"}.fa-file-signature:before{content:"\f573"}.fa-file-upload:before{content:"\f574"}.fa-file-video:before{content:"\f1c8"}.fa-file-word:before{content:"\f1c2"}.fa-fill:before{content:"\f575"}.fa-fill-drip:before{content:"\f576"}.fa-film:before{content:"\f008"}.fa-filter:before{content:"\f0b0"}.fa-fingerprint:before{content:"\f577"}.fa-fire:before{content:"\f06d"}.fa-fire-alt:before{content:"\f7e4"}.fa-fire-extinguisher:before{content:"\f134"}.fa-firefox:before{content:"\f269"}.fa-firefox-browser:before{content:"\f907"}.fa-first-aid:before{content:"\f479"}.fa-first-order:before{content:"\f2b0"}.fa-first-order-alt:before{content:"\f50a"}.fa-firstdraft:before{content:"\f3a1"}.fa-fish:before{content:"\f578"}.fa-fist-raised:before{content:"\f6de"}.fa-flag:before{content:"\f024"}.fa-flag-checkered:before{content:"\f11e"}.fa-flag-usa:before{content:"\f74d"}.fa-flask:before{content:"\f0c3"}.fa-flickr:before{content:"\f16e"}.fa-flipboard:before{content:"\f44d"}.fa-flushed:before{content:"\f579"}.fa-fly:before{content:"\f417"}.fa-folder:before{content:"\f07b"}.fa-folder-minus:before{content:"\f65d"}.fa-folder-open:before{content:"\f07c"}.fa-folder-plus:before{content:"\f65e"}.fa-font:before{content:"\f031"}.fa-font-awesome:before{content:"\f2b4"}.fa-font-awesome-alt:before{content:"\f35c"}.fa-font-awesome-flag:before{content:"\f425"}.fa-font-awesome-logo-full:before{content:"\f4e6"}.fa-fonticons:before{content:"\f280"}.fa-fonticons-fi:before{content:"\f3a2"}.fa-football-ball:before{content:"\f44e"}.fa-fort-awesome:before{content:"\f286"}.fa-fort-awesome-alt:before{content:"\f3a3"}.fa-forumbee:before{content:"\f211"}.fa-forward:before{content:"\f04e"}.fa-foursquare:before{content:"\f180"}.fa-free-code-camp:before{content:"\f2c5"}.fa-freebsd:before{content:"\f3a4"}.fa-frog:before{content:"\f52e"}.fa-frown:before{content:"\f119"}.fa-frown-open:before{content:"\f57a"}.fa-fulcrum:before{content:"\f50b"}.fa-funnel-dollar:before{content:"\f662"}.fa-futbol:before{content:"\f1e3"}.fa-galactic-republic:before{content:"\f50c"}.fa-galactic-senate:before{content:"\f50d"}.fa-gamepad:before{content:"\f11b"}.fa-gas-pump:before{content:"\f52f"}.fa-gavel:before{content:"\f0e3"}.fa-gem:before{content:"\f3a5"}.fa-genderless:before{content:"\f22d"}.fa-get-pocket:before{content:"\f265"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-ghost:before{content:"\f6e2"}.fa-gift:before{content:"\f06b"}.fa-gifts:before{content:"\f79c"}.fa-git:before{content:"\f1d3"}.fa-git-alt:before{content:"\f841"}.fa-git-square:before{content:"\f1d2"}.fa-github:before{content:"\f09b"}.fa-github-alt:before{content:"\f113"}.fa-github-square:before{content:"\f092"}.fa-gitkraken:before{content:"\f3a6"}.fa-gitlab:before{content:"\f296"}.fa-gitter:before{content:"\f426"}.fa-glass-cheers:before{content:"\f79f"}.fa-glass-martini:before{content:"\f000"}.fa-glass-martini-alt:before{content:"\f57b"}.fa-glass-whiskey:before{content:"\f7a0"}.fa-glasses:before{content:"\f530"}.fa-glide:before{content:"\f2a5"}.fa-glide-g:before{content:"\f2a6"}.fa-globe:before{content:"\f0ac"}.fa-globe-africa:before{content:"\f57c"}.fa-globe-americas:before{content:"\f57d"}.fa-globe-asia:before{content:"\f57e"}.fa-globe-europe:before{content:"\f7a2"}.fa-gofore:before{content:"\f3a7"}.fa-golf-ball:before{content:"\f450"}.fa-goodreads:before{content:"\f3a8"}.fa-goodreads-g:before{content:"\f3a9"}.fa-google:before{content:"\f1a0"}.fa-google-drive:before{content:"\f3aa"}.fa-google-play:before{content:"\f3ab"}.fa-google-plus:before{content:"\f2b3"}.fa-google-plus-g:before{content:"\f0d5"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-wallet:before{content:"\f1ee"}.fa-gopuram:before{content:"\f664"}.fa-graduation-cap:before{content:"\f19d"}.fa-gratipay:before{content:"\f184"}.fa-grav:before{content:"\f2d6"}.fa-greater-than:before{content:"\f531"}.fa-greater-than-equal:before{content:"\f532"}.fa-grimace:before{content:"\f57f"}.fa-grin:before{content:"\f580"}.fa-grin-alt:before{content:"\f581"}.fa-grin-beam:before{content:"\f582"}.fa-grin-beam-sweat:before{content:"\f583"}.fa-grin-hearts:before{content:"\f584"}.fa-grin-squint:before{content:"\f585"}.fa-grin-squint-tears:before{content:"\f586"}.fa-grin-stars:before{content:"\f587"}.fa-grin-tears:before{content:"\f588"}.fa-grin-tongue:before{content:"\f589"}.fa-grin-tongue-squint:before{content:"\f58a"}.fa-grin-tongue-wink:before{content:"\f58b"}.fa-grin-wink:before{content:"\f58c"}.fa-grip-horizontal:before{content:"\f58d"}.fa-grip-lines:before{content:"\f7a4"}.fa-grip-lines-vertical:before{content:"\f7a5"}.fa-grip-vertical:before{content:"\f58e"}.fa-gripfire:before{content:"\f3ac"}.fa-grunt:before{content:"\f3ad"}.fa-guitar:before{content:"\f7a6"}.fa-gulp:before{content:"\f3ae"}.fa-h-square:before{content:"\f0fd"}.fa-hacker-news:before{content:"\f1d4"}.fa-hacker-news-square:before{content:"\f3af"}.fa-hackerrank:before{content:"\f5f7"}.fa-hamburger:before{content:"\f805"}.fa-hammer:before{content:"\f6e3"}.fa-hamsa:before{content:"\f665"}.fa-hand-holding:before{content:"\f4bd"}.fa-hand-holding-heart:before{content:"\f4be"}.fa-hand-holding-medical:before{content:"\f95c"}.fa-hand-holding-usd:before{content:"\f4c0"}.fa-hand-holding-water:before{content:"\f4c1"}.fa-hand-lizard:before{content:"\f258"}.fa-hand-middle-finger:before{content:"\f806"}.fa-hand-paper:before{content:"\f256"}.fa-hand-peace:before{content:"\f25b"}.fa-hand-point-down:before{content:"\f0a7"}.fa-hand-point-left:before{content:"\f0a5"}.fa-hand-point-right:before{content:"\f0a4"}.fa-hand-point-up:before{content:"\f0a6"}.fa-hand-pointer:before{content:"\f25a"}.fa-hand-rock:before{content:"\f255"}.fa-hand-scissors:before{content:"\f257"}.fa-hand-sparkles:before{content:"\f95d"}.fa-hand-spock:before{content:"\f259"}.fa-hands:before{content:"\f4c2"}.fa-hands-helping:before{content:"\f4c4"}.fa-hands-wash:before{content:"\f95e"}.fa-handshake:before{content:"\f2b5"}.fa-handshake-alt-slash:before{content:"\f95f"}.fa-handshake-slash:before{content:"\f960"}.fa-hanukiah:before{content:"\f6e6"}.fa-hard-hat:before{content:"\f807"}.fa-hashtag:before{content:"\f292"}.fa-hat-cowboy:before{content:"\f8c0"}.fa-hat-cowboy-side:before{content:"\f8c1"}.fa-hat-wizard:before{content:"\f6e8"}.fa-hdd:before{content:"\f0a0"}.fa-head-side-cough:before{content:"\f961"}.fa-head-side-cough-slash:before{content:"\f962"}.fa-head-side-mask:before{content:"\f963"}.fa-head-side-virus:before{content:"\f964"}.fa-heading:before{content:"\f1dc"}.fa-headphones:before{content:"\f025"}.fa-headphones-alt:before{content:"\f58f"}.fa-headset:before{content:"\f590"}.fa-heart:before{content:"\f004"}.fa-heart-broken:before{content:"\f7a9"}.fa-heartbeat:before{content:"\f21e"}.fa-helicopter:before{content:"\f533"}.fa-highlighter:before{content:"\f591"}.fa-hiking:before{content:"\f6ec"}.fa-hippo:before{content:"\f6ed"}.fa-hips:before{content:"\f452"}.fa-hire-a-helper:before{content:"\f3b0"}.fa-history:before{content:"\f1da"}.fa-hockey-puck:before{content:"\f453"}.fa-holly-berry:before{content:"\f7aa"}.fa-home:before{content:"\f015"}.fa-hooli:before{content:"\f427"}.fa-hornbill:before{content:"\f592"}.fa-horse:before{content:"\f6f0"}.fa-horse-head:before{content:"\f7ab"}.fa-hospital:before{content:"\f0f8"}.fa-hospital-alt:before{content:"\f47d"}.fa-hospital-symbol:before{content:"\f47e"}.fa-hospital-user:before{content:"\f80d"}.fa-hot-tub:before{content:"\f593"}.fa-hotdog:before{content:"\f80f"}.fa-hotel:before{content:"\f594"}.fa-hotjar:before{content:"\f3b1"}.fa-hourglass:before{content:"\f254"}.fa-hourglass-end:before{content:"\f253"}.fa-hourglass-half:before{content:"\f252"}.fa-hourglass-start:before{content:"\f251"}.fa-house-damage:before{content:"\f6f1"}.fa-house-user:before{content:"\f965"}.fa-houzz:before{content:"\f27c"}.fa-hryvnia:before{content:"\f6f2"}.fa-html5:before{content:"\f13b"}.fa-hubspot:before{content:"\f3b2"}.fa-i-cursor:before{content:"\f246"}.fa-ice-cream:before{content:"\f810"}.fa-icicles:before{content:"\f7ad"}.fa-icons:before{content:"\f86d"}.fa-id-badge:before{content:"\f2c1"}.fa-id-card:before{content:"\f2c2"}.fa-id-card-alt:before{content:"\f47f"}.fa-ideal:before{content:"\f913"}.fa-igloo:before{content:"\f7ae"}.fa-image:before{content:"\f03e"}.fa-images:before{content:"\f302"}.fa-imdb:before{content:"\f2d8"}.fa-inbox:before{content:"\f01c"}.fa-indent:before{content:"\f03c"}.fa-industry:before{content:"\f275"}.fa-infinity:before{content:"\f534"}.fa-info:before{content:"\f129"}.fa-info-circle:before{content:"\f05a"}.fa-instagram:before{content:"\f16d"}.fa-instagram-square:before{content:"\f955"}.fa-intercom:before{content:"\f7af"}.fa-internet-explorer:before{content:"\f26b"}.fa-invision:before{content:"\f7b0"}.fa-ioxhost:before{content:"\f208"}.fa-italic:before{content:"\f033"}.fa-itch-io:before{content:"\f83a"}.fa-itunes:before{content:"\f3b4"}.fa-itunes-note:before{content:"\f3b5"}.fa-java:before{content:"\f4e4"}.fa-jedi:before{content:"\f669"}.fa-jedi-order:before{content:"\f50e"}.fa-jenkins:before{content:"\f3b6"}.fa-jira:before{content:"\f7b1"}.fa-joget:before{content:"\f3b7"}.fa-joint:before{content:"\f595"}.fa-joomla:before{content:"\f1aa"}.fa-journal-whills:before{content:"\f66a"}.fa-js:before{content:"\f3b8"}.fa-js-square:before{content:"\f3b9"}.fa-jsfiddle:before{content:"\f1cc"}.fa-kaaba:before{content:"\f66b"}.fa-kaggle:before{content:"\f5fa"}.fa-key:before{content:"\f084"}.fa-keybase:before{content:"\f4f5"}.fa-keyboard:before{content:"\f11c"}.fa-keycdn:before{content:"\f3ba"}.fa-khanda:before{content:"\f66d"}.fa-kickstarter:before{content:"\f3bb"}.fa-kickstarter-k:before{content:"\f3bc"}.fa-kiss:before{content:"\f596"}.fa-kiss-beam:before{content:"\f597"}.fa-kiss-wink-heart:before{content:"\f598"}.fa-kiwi-bird:before{content:"\f535"}.fa-korvue:before{content:"\f42f"}.fa-landmark:before{content:"\f66f"}.fa-language:before{content:"\f1ab"}.fa-laptop:before{content:"\f109"}.fa-laptop-code:before{content:"\f5fc"}.fa-laptop-house:before{content:"\f966"}.fa-laptop-medical:before{content:"\f812"}.fa-laravel:before{content:"\f3bd"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-laugh:before{content:"\f599"}.fa-laugh-beam:before{content:"\f59a"}.fa-laugh-squint:before{content:"\f59b"}.fa-laugh-wink:before{content:"\f59c"}.fa-layer-group:before{content:"\f5fd"}.fa-leaf:before{content:"\f06c"}.fa-leanpub:before{content:"\f212"}.fa-lemon:before{content:"\f094"}.fa-less:before{content:"\f41d"}.fa-less-than:before{content:"\f536"}.fa-less-than-equal:before{content:"\f537"}.fa-level-down-alt:before{content:"\f3be"}.fa-level-up-alt:before{content:"\f3bf"}.fa-life-ring:before{content:"\f1cd"}.fa-lightbulb:before{content:"\f0eb"}.fa-line:before{content:"\f3c0"}.fa-link:before{content:"\f0c1"}.fa-linkedin:before{content:"\f08c"}.fa-linkedin-in:before{content:"\f0e1"}.fa-linode:before{content:"\f2b8"}.fa-linux:before{content:"\f17c"}.fa-lira-sign:before{content:"\f195"}.fa-list:before{content:"\f03a"}.fa-list-alt:before{content:"\f022"}.fa-list-ol:before{content:"\f0cb"}.fa-list-ul:before{content:"\f0ca"}.fa-location-arrow:before{content:"\f124"}.fa-lock:before{content:"\f023"}.fa-lock-open:before{content:"\f3c1"}.fa-long-arrow-alt-down:before{content:"\f309"}.fa-long-arrow-alt-left:before{content:"\f30a"}.fa-long-arrow-alt-right:before{content:"\f30b"}.fa-long-arrow-alt-up:before{content:"\f30c"}.fa-low-vision:before{content:"\f2a8"}.fa-luggage-cart:before{content:"\f59d"}.fa-lungs:before{content:"\f604"}.fa-lungs-virus:before{content:"\f967"}.fa-lyft:before{content:"\f3c3"}.fa-magento:before{content:"\f3c4"}.fa-magic:before{content:"\f0d0"}.fa-magnet:before{content:"\f076"}.fa-mail-bulk:before{content:"\f674"}.fa-mailchimp:before{content:"\f59e"}.fa-male:before{content:"\f183"}.fa-mandalorian:before{content:"\f50f"}.fa-map:before{content:"\f279"}.fa-map-marked:before{content:"\f59f"}.fa-map-marked-alt:before{content:"\f5a0"}.fa-map-marker:before{content:"\f041"}.fa-map-marker-alt:before{content:"\f3c5"}.fa-map-pin:before{content:"\f276"}.fa-map-signs:before{content:"\f277"}.fa-markdown:before{content:"\f60f"}.fa-marker:before{content:"\f5a1"}.fa-mars:before{content:"\f222"}.fa-mars-double:before{content:"\f227"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-h:before{content:"\f22b"}.fa-mars-stroke-v:before{content:"\f22a"}.fa-mask:before{content:"\f6fa"}.fa-mastodon:before{content:"\f4f6"}.fa-maxcdn:before{content:"\f136"}.fa-mdb:before{content:"\f8ca"}.fa-medal:before{content:"\f5a2"}.fa-medapps:before{content:"\f3c6"}.fa-medium:before{content:"\f23a"}.fa-medium-m:before{content:"\f3c7"}.fa-medkit:before{content:"\f0fa"}.fa-medrt:before{content:"\f3c8"}.fa-meetup:before{content:"\f2e0"}.fa-megaport:before{content:"\f5a3"}.fa-meh:before{content:"\f11a"}.fa-meh-blank:before{content:"\f5a4"}.fa-meh-rolling-eyes:before{content:"\f5a5"}.fa-memory:before{content:"\f538"}.fa-mendeley:before{content:"\f7b3"}.fa-menorah:before{content:"\f676"}.fa-mercury:before{content:"\f223"}.fa-meteor:before{content:"\f753"}.fa-microblog:before{content:"\f91a"}.fa-microchip:before{content:"\f2db"}.fa-microphone:before{content:"\f130"}.fa-microphone-alt:before{content:"\f3c9"}.fa-microphone-alt-slash:before{content:"\f539"}.fa-microphone-slash:before{content:"\f131"}.fa-microscope:before{content:"\f610"}.fa-microsoft:before{content:"\f3ca"}.fa-minus:before{content:"\f068"}.fa-minus-circle:before{content:"\f056"}.fa-minus-square:before{content:"\f146"}.fa-mitten:before{content:"\f7b5"}.fa-mix:before{content:"\f3cb"}.fa-mixcloud:before{content:"\f289"}.fa-mixer:before{content:"\f956"}.fa-mizuni:before{content:"\f3cc"}.fa-mobile:before{content:"\f10b"}.fa-mobile-alt:before{content:"\f3cd"}.fa-modx:before{content:"\f285"}.fa-monero:before{content:"\f3d0"}.fa-money-bill:before{content:"\f0d6"}.fa-money-bill-alt:before{content:"\f3d1"}.fa-money-bill-wave:before{content:"\f53a"}.fa-money-bill-wave-alt:before{content:"\f53b"}.fa-money-check:before{content:"\f53c"}.fa-money-check-alt:before{content:"\f53d"}.fa-monument:before{content:"\f5a6"}.fa-moon:before{content:"\f186"}.fa-mortar-pestle:before{content:"\f5a7"}.fa-mosque:before{content:"\f678"}.fa-motorcycle:before{content:"\f21c"}.fa-mountain:before{content:"\f6fc"}.fa-mouse:before{content:"\f8cc"}.fa-mouse-pointer:before{content:"\f245"}.fa-mug-hot:before{content:"\f7b6"}.fa-music:before{content:"\f001"}.fa-napster:before{content:"\f3d2"}.fa-neos:before{content:"\f612"}.fa-network-wired:before{content:"\f6ff"}.fa-neuter:before{content:"\f22c"}.fa-newspaper:before{content:"\f1ea"}.fa-nimblr:before{content:"\f5a8"}.fa-node:before{content:"\f419"}.fa-node-js:before{content:"\f3d3"}.fa-not-equal:before{content:"\f53e"}.fa-notes-medical:before{content:"\f481"}.fa-npm:before{content:"\f3d4"}.fa-ns8:before{content:"\f3d5"}.fa-nutritionix:before{content:"\f3d6"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-oil-can:before{content:"\f613"}.fa-old-republic:before{content:"\f510"}.fa-om:before{content:"\f679"}.fa-opencart:before{content:"\f23d"}.fa-openid:before{content:"\f19b"}.fa-opera:before{content:"\f26a"}.fa-optin-monster:before{content:"\f23c"}.fa-orcid:before{content:"\f8d2"}.fa-osi:before{content:"\f41a"}.fa-otter:before{content:"\f700"}.fa-outdent:before{content:"\f03b"}.fa-page4:before{content:"\f3d7"}.fa-pagelines:before{content:"\f18c"}.fa-pager:before{content:"\f815"}.fa-paint-brush:before{content:"\f1fc"}.fa-paint-roller:before{content:"\f5aa"}.fa-palette:before{content:"\f53f"}.fa-palfed:before{content:"\f3d8"}.fa-pallet:before{content:"\f482"}.fa-paper-plane:before{content:"\f1d8"}.fa-paperclip:before{content:"\f0c6"}.fa-parachute-box:before{content:"\f4cd"}.fa-paragraph:before{content:"\f1dd"}.fa-parking:before{content:"\f540"}.fa-passport:before{content:"\f5ab"}.fa-pastafarianism:before{content:"\f67b"}.fa-paste:before{content:"\f0ea"}.fa-patreon:before{content:"\f3d9"}.fa-pause:before{content:"\f04c"}.fa-pause-circle:before{content:"\f28b"}.fa-paw:before{content:"\f1b0"}.fa-paypal:before{content:"\f1ed"}.fa-peace:before{content:"\f67c"}.fa-pen:before{content:"\f304"}.fa-pen-alt:before{content:"\f305"}.fa-pen-fancy:before{content:"\f5ac"}.fa-pen-nib:before{content:"\f5ad"}.fa-pen-square:before{content:"\f14b"}.fa-pencil-alt:before{content:"\f303"}.fa-pencil-ruler:before{content:"\f5ae"}.fa-penny-arcade:before{content:"\f704"}.fa-people-arrows:before{content:"\f968"}.fa-people-carry:before{content:"\f4ce"}.fa-pepper-hot:before{content:"\f816"}.fa-percent:before{content:"\f295"}.fa-percentage:before{content:"\f541"}.fa-periscope:before{content:"\f3da"}.fa-person-booth:before{content:"\f756"}.fa-phabricator:before{content:"\f3db"}.fa-phoenix-framework:before{content:"\f3dc"}.fa-phoenix-squadron:before{content:"\f511"}.fa-phone:before{content:"\f095"}.fa-phone-alt:before{content:"\f879"}.fa-phone-slash:before{content:"\f3dd"}.fa-phone-square:before{content:"\f098"}.fa-phone-square-alt:before{content:"\f87b"}.fa-phone-volume:before{content:"\f2a0"}.fa-photo-video:before{content:"\f87c"}.fa-php:before{content:"\f457"}.fa-pied-piper:before{content:"\f2ae"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-pied-piper-hat:before{content:"\f4e5"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-pied-piper-square:before{content:"\f91e"}.fa-piggy-bank:before{content:"\f4d3"}.fa-pills:before{content:"\f484"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-p:before{content:"\f231"}.fa-pinterest-square:before{content:"\f0d3"}.fa-pizza-slice:before{content:"\f818"}.fa-place-of-worship:before{content:"\f67f"}.fa-plane:before{content:"\f072"}.fa-plane-arrival:before{content:"\f5af"}.fa-plane-departure:before{content:"\f5b0"}.fa-plane-slash:before{content:"\f969"}.fa-play:before{content:"\f04b"}.fa-play-circle:before{content:"\f144"}.fa-playstation:before{content:"\f3df"}.fa-plug:before{content:"\f1e6"}.fa-plus:before{content:"\f067"}.fa-plus-circle:before{content:"\f055"}.fa-plus-square:before{content:"\f0fe"}.fa-podcast:before{content:"\f2ce"}.fa-poll:before{content:"\f681"}.fa-poll-h:before{content:"\f682"}.fa-poo:before{content:"\f2fe"}.fa-poo-storm:before{content:"\f75a"}.fa-poop:before{content:"\f619"}.fa-portrait:before{content:"\f3e0"}.fa-pound-sign:before{content:"\f154"}.fa-power-off:before{content:"\f011"}.fa-pray:before{content:"\f683"}.fa-praying-hands:before{content:"\f684"}.fa-prescription:before{content:"\f5b1"}.fa-prescription-bottle:before{content:"\f485"}.fa-prescription-bottle-alt:before{content:"\f486"}.fa-print:before{content:"\f02f"}.fa-procedures:before{content:"\f487"}.fa-product-hunt:before{content:"\f288"}.fa-project-diagram:before{content:"\f542"}.fa-pump-medical:before{content:"\f96a"}.fa-pump-soap:before{content:"\f96b"}.fa-pushed:before{content:"\f3e1"}.fa-puzzle-piece:before{content:"\f12e"}.fa-python:before{content:"\f3e2"}.fa-qq:before{content:"\f1d6"}.fa-qrcode:before{content:"\f029"}.fa-question:before{content:"\f128"}.fa-question-circle:before{content:"\f059"}.fa-quidditch:before{content:"\f458"}.fa-quinscape:before{content:"\f459"}.fa-quora:before{content:"\f2c4"}.fa-quote-left:before{content:"\f10d"}.fa-quote-right:before{content:"\f10e"}.fa-quran:before{content:"\f687"}.fa-r-project:before{content:"\f4f7"}.fa-radiation:before{content:"\f7b9"}.fa-radiation-alt:before{content:"\f7ba"}.fa-rainbow:before{content:"\f75b"}.fa-random:before{content:"\f074"}.fa-raspberry-pi:before{content:"\f7bb"}.fa-ravelry:before{content:"\f2d9"}.fa-react:before{content:"\f41b"}.fa-reacteurope:before{content:"\f75d"}.fa-readme:before{content:"\f4d5"}.fa-rebel:before{content:"\f1d0"}.fa-receipt:before{content:"\f543"}.fa-record-vinyl:before{content:"\f8d9"}.fa-recycle:before{content:"\f1b8"}.fa-red-river:before{content:"\f3e3"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-alien:before{content:"\f281"}.fa-reddit-square:before{content:"\f1a2"}.fa-redhat:before{content:"\f7bc"}.fa-redo:before{content:"\f01e"}.fa-redo-alt:before{content:"\f2f9"}.fa-registered:before{content:"\f25d"}.fa-remove-format:before{content:"\f87d"}.fa-renren:before{content:"\f18b"}.fa-reply:before{content:"\f3e5"}.fa-reply-all:before{content:"\f122"}.fa-replyd:before{content:"\f3e6"}.fa-republican:before{content:"\f75e"}.fa-researchgate:before{content:"\f4f8"}.fa-resolving:before{content:"\f3e7"}.fa-restroom:before{content:"\f7bd"}.fa-retweet:before{content:"\f079"}.fa-rev:before{content:"\f5b2"}.fa-ribbon:before{content:"\f4d6"}.fa-ring:before{content:"\f70b"}.fa-road:before{content:"\f018"}.fa-robot:before{content:"\f544"}.fa-rocket:before{content:"\f135"}.fa-rocketchat:before{content:"\f3e8"}.fa-rockrms:before{content:"\f3e9"}.fa-route:before{content:"\f4d7"}.fa-rss:before{content:"\f09e"}.fa-rss-square:before{content:"\f143"}.fa-ruble-sign:before{content:"\f158"}.fa-ruler:before{content:"\f545"}.fa-ruler-combined:before{content:"\f546"}.fa-ruler-horizontal:before{content:"\f547"}.fa-ruler-vertical:before{content:"\f548"}.fa-running:before{content:"\f70c"}.fa-rupee-sign:before{content:"\f156"}.fa-sad-cry:before{content:"\f5b3"}.fa-sad-tear:before{content:"\f5b4"}.fa-safari:before{content:"\f267"}.fa-salesforce:before{content:"\f83b"}.fa-sass:before{content:"\f41e"}.fa-satellite:before{content:"\f7bf"}.fa-satellite-dish:before{content:"\f7c0"}.fa-save:before{content:"\f0c7"}.fa-schlix:before{content:"\f3ea"}.fa-school:before{content:"\f549"}.fa-screwdriver:before{content:"\f54a"}.fa-scribd:before{content:"\f28a"}.fa-scroll:before{content:"\f70e"}.fa-sd-card:before{content:"\f7c2"}.fa-search:before{content:"\f002"}.fa-search-dollar:before{content:"\f688"}.fa-search-location:before{content:"\f689"}.fa-search-minus:before{content:"\f010"}.fa-search-plus:before{content:"\f00e"}.fa-searchengin:before{content:"\f3eb"}.fa-seedling:before{content:"\f4d8"}.fa-sellcast:before{content:"\f2da"}.fa-sellsy:before{content:"\f213"}.fa-server:before{content:"\f233"}.fa-servicestack:before{content:"\f3ec"}.fa-shapes:before{content:"\f61f"}.fa-share:before{content:"\f064"}.fa-share-alt:before{content:"\f1e0"}.fa-share-alt-square:before{content:"\f1e1"}.fa-share-square:before{content:"\f14d"}.fa-shekel-sign:before{content:"\f20b"}.fa-shield-alt:before{content:"\f3ed"}.fa-shield-virus:before{content:"\f96c"}.fa-ship:before{content:"\f21a"}.fa-shipping-fast:before{content:"\f48b"}.fa-shirtsinbulk:before{content:"\f214"}.fa-shoe-prints:before{content:"\f54b"}.fa-shopify:before{content:"\f957"}.fa-shopping-bag:before{content:"\f290"}.fa-shopping-basket:before{content:"\f291"}.fa-shopping-cart:before{content:"\f07a"}.fa-shopware:before{content:"\f5b5"}.fa-shower:before{content:"\f2cc"}.fa-shuttle-van:before{content:"\f5b6"}.fa-sign:before{content:"\f4d9"}.fa-sign-in-alt:before{content:"\f2f6"}.fa-sign-language:before{content:"\f2a7"}.fa-sign-out-alt:before{content:"\f2f5"}.fa-signal:before{content:"\f012"}.fa-signature:before{content:"\f5b7"}.fa-sim-card:before{content:"\f7c4"}.fa-simplybuilt:before{content:"\f215"}.fa-sistrix:before{content:"\f3ee"}.fa-sitemap:before{content:"\f0e8"}.fa-sith:before{content:"\f512"}.fa-skating:before{content:"\f7c5"}.fa-sketch:before{content:"\f7c6"}.fa-skiing:before{content:"\f7c9"}.fa-skiing-nordic:before{content:"\f7ca"}.fa-skull:before{content:"\f54c"}.fa-skull-crossbones:before{content:"\f714"}.fa-skyatlas:before{content:"\f216"}.fa-skype:before{content:"\f17e"}.fa-slack:before{content:"\f198"}.fa-slack-hash:before{content:"\f3ef"}.fa-slash:before{content:"\f715"}.fa-sleigh:before{content:"\f7cc"}.fa-sliders-h:before{content:"\f1de"}.fa-slideshare:before{content:"\f1e7"}.fa-smile:before{content:"\f118"}.fa-smile-beam:before{content:"\f5b8"}.fa-smile-wink:before{content:"\f4da"}.fa-smog:before{content:"\f75f"}.fa-smoking:before{content:"\f48d"}.fa-smoking-ban:before{content:"\f54d"}.fa-sms:before{content:"\f7cd"}.fa-snapchat:before{content:"\f2ab"}.fa-snapchat-ghost:before{content:"\f2ac"}.fa-snapchat-square:before{content:"\f2ad"}.fa-snowboarding:before{content:"\f7ce"}.fa-snowflake:before{content:"\f2dc"}.fa-snowman:before{content:"\f7d0"}.fa-snowplow:before{content:"\f7d2"}.fa-soap:before{content:"\f96e"}.fa-socks:before{content:"\f696"}.fa-solar-panel:before{content:"\f5ba"}.fa-sort:before{content:"\f0dc"}.fa-sort-alpha-down:before{content:"\f15d"}.fa-sort-alpha-down-alt:before{content:"\f881"}.fa-sort-alpha-up:before{content:"\f15e"}.fa-sort-alpha-up-alt:before{content:"\f882"}.fa-sort-amount-down:before{content:"\f160"}.fa-sort-amount-down-alt:before{content:"\f884"}.fa-sort-amount-up:before{content:"\f161"}.fa-sort-amount-up-alt:before{content:"\f885"}.fa-sort-down:before{content:"\f0dd"}.fa-sort-numeric-down:before{content:"\f162"}.fa-sort-numeric-down-alt:before{content:"\f886"}.fa-sort-numeric-up:before{content:"\f163"}.fa-sort-numeric-up-alt:before{content:"\f887"}.fa-sort-up:before{content:"\f0de"}.fa-soundcloud:before{content:"\f1be"}.fa-sourcetree:before{content:"\f7d3"}.fa-spa:before{content:"\f5bb"}.fa-space-shuttle:before{content:"\f197"}.fa-speakap:before{content:"\f3f3"}.fa-speaker-deck:before{content:"\f83c"}.fa-spell-check:before{content:"\f891"}.fa-spider:before{content:"\f717"}.fa-spinner:before{content:"\f110"}.fa-splotch:before{content:"\f5bc"}.fa-spotify:before{content:"\f1bc"}.fa-spray-can:before{content:"\f5bd"}.fa-square:before{content:"\f0c8"}.fa-square-full:before{content:"\f45c"}.fa-square-root-alt:before{content:"\f698"}.fa-squarespace:before{content:"\f5be"}.fa-stack-exchange:before{content:"\f18d"}.fa-stack-overflow:before{content:"\f16c"}.fa-stackpath:before{content:"\f842"}.fa-stamp:before{content:"\f5bf"}.fa-star:before{content:"\f005"}.fa-star-and-crescent:before{content:"\f699"}.fa-star-half:before{content:"\f089"}.fa-star-half-alt:before{content:"\f5c0"}.fa-star-of-david:before{content:"\f69a"}.fa-star-of-life:before{content:"\f621"}.fa-staylinked:before{content:"\f3f5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-steam-symbol:before{content:"\f3f6"}.fa-step-backward:before{content:"\f048"}.fa-step-forward:before{content:"\f051"}.fa-stethoscope:before{content:"\f0f1"}.fa-sticker-mule:before{content:"\f3f7"}.fa-sticky-note:before{content:"\f249"}.fa-stop:before{content:"\f04d"}.fa-stop-circle:before{content:"\f28d"}.fa-stopwatch:before{content:"\f2f2"}.fa-stopwatch-20:before{content:"\f96f"}.fa-store:before{content:"\f54e"}.fa-store-alt:before{content:"\f54f"}.fa-store-alt-slash:before{content:"\f970"}.fa-store-slash:before{content:"\f971"}.fa-strava:before{content:"\f428"}.fa-stream:before{content:"\f550"}.fa-street-view:before{content:"\f21d"}.fa-strikethrough:before{content:"\f0cc"}.fa-stripe:before{content:"\f429"}.fa-stripe-s:before{content:"\f42a"}.fa-stroopwafel:before{content:"\f551"}.fa-studiovinari:before{content:"\f3f8"}.fa-stumbleupon:before{content:"\f1a4"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-subscript:before{content:"\f12c"}.fa-subway:before{content:"\f239"}.fa-suitcase:before{content:"\f0f2"}.fa-suitcase-rolling:before{content:"\f5c1"}.fa-sun:before{content:"\f185"}.fa-superpowers:before{content:"\f2dd"}.fa-superscript:before{content:"\f12b"}.fa-supple:before{content:"\f3f9"}.fa-surprise:before{content:"\f5c2"}.fa-suse:before{content:"\f7d6"}.fa-swatchbook:before{content:"\f5c3"}.fa-swift:before{content:"\f8e1"}.fa-swimmer:before{content:"\f5c4"}.fa-swimming-pool:before{content:"\f5c5"}.fa-symfony:before{content:"\f83d"}.fa-synagogue:before{content:"\f69b"}.fa-sync:before{content:"\f021"}.fa-sync-alt:before{content:"\f2f1"}.fa-syringe:before{content:"\f48e"}.fa-table:before{content:"\f0ce"}.fa-table-tennis:before{content:"\f45d"}.fa-tablet:before{content:"\f10a"}.fa-tablet-alt:before{content:"\f3fa"}.fa-tablets:before{content:"\f490"}.fa-tachometer-alt:before{content:"\f3fd"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-tape:before{content:"\f4db"}.fa-tasks:before{content:"\f0ae"}.fa-taxi:before{content:"\f1ba"}.fa-teamspeak:before{content:"\f4f9"}.fa-teeth:before{content:"\f62e"}.fa-teeth-open:before{content:"\f62f"}.fa-telegram:before{content:"\f2c6"}.fa-telegram-plane:before{content:"\f3fe"}.fa-temperature-high:before{content:"\f769"}.fa-temperature-low:before{content:"\f76b"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-tenge:before{content:"\f7d7"}.fa-terminal:before{content:"\f120"}.fa-text-height:before{content:"\f034"}.fa-text-width:before{content:"\f035"}.fa-th:before{content:"\f00a"}.fa-th-large:before{content:"\f009"}.fa-th-list:before{content:"\f00b"}.fa-the-red-yeti:before{content:"\f69d"}.fa-theater-masks:before{content:"\f630"}.fa-themeco:before{content:"\f5c6"}.fa-themeisle:before{content:"\f2b2"}.fa-thermometer:before{content:"\f491"}.fa-thermometer-empty:before{content:"\f2cb"}.fa-thermometer-full:before{content:"\f2c7"}.fa-thermometer-half:before{content:"\f2c9"}.fa-thermometer-quarter:before{content:"\f2ca"}.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-think-peaks:before{content:"\f731"}.fa-thumbs-down:before{content:"\f165"}.fa-thumbs-up:before{content:"\f164"}.fa-thumbtack:before{content:"\f08d"}.fa-ticket-alt:before{content:"\f3ff"}.fa-times:before{content:"\f00d"}.fa-times-circle:before{content:"\f057"}.fa-tint:before{content:"\f043"}.fa-tint-slash:before{content:"\f5c7"}.fa-tired:before{content:"\f5c8"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-toilet:before{content:"\f7d8"}.fa-toilet-paper:before{content:"\f71e"}.fa-toilet-paper-slash:before{content:"\f972"}.fa-toolbox:before{content:"\f552"}.fa-tools:before{content:"\f7d9"}.fa-tooth:before{content:"\f5c9"}.fa-torah:before{content:"\f6a0"}.fa-torii-gate:before{content:"\f6a1"}.fa-tractor:before{content:"\f722"}.fa-trade-federation:before{content:"\f513"}.fa-trademark:before{content:"\f25c"}.fa-traffic-light:before{content:"\f637"}.fa-trailer:before{content:"\f941"}.fa-train:before{content:"\f238"}.fa-tram:before{content:"\f7da"}.fa-transgender:before{content:"\f224"}.fa-transgender-alt:before{content:"\f225"}.fa-trash:before{content:"\f1f8"}.fa-trash-alt:before{content:"\f2ed"}.fa-trash-restore:before{content:"\f829"}.fa-trash-restore-alt:before{content:"\f82a"}.fa-tree:before{content:"\f1bb"}.fa-trello:before{content:"\f181"}.fa-tripadvisor:before{content:"\f262"}.fa-trophy:before{content:"\f091"}.fa-truck:before{content:"\f0d1"}.fa-truck-loading:before{content:"\f4de"}.fa-truck-monster:before{content:"\f63b"}.fa-truck-moving:before{content:"\f4df"}.fa-truck-pickup:before{content:"\f63c"}.fa-tshirt:before{content:"\f553"}.fa-tty:before{content:"\f1e4"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-tv:before{content:"\f26c"}.fa-twitch:before{content:"\f1e8"}.fa-twitter:before{content:"\f099"}.fa-twitter-square:before{content:"\f081"}.fa-typo3:before{content:"\f42b"}.fa-uber:before{content:"\f402"}.fa-ubuntu:before{content:"\f7df"}.fa-uikit:before{content:"\f403"}.fa-umbraco:before{content:"\f8e8"}.fa-umbrella:before{content:"\f0e9"}.fa-umbrella-beach:before{content:"\f5ca"}.fa-underline:before{content:"\f0cd"}.fa-undo:before{content:"\f0e2"}.fa-undo-alt:before{content:"\f2ea"}.fa-uniregistry:before{content:"\f404"}.fa-unity:before{content:"\f949"}.fa-universal-access:before{content:"\f29a"}.fa-university:before{content:"\f19c"}.fa-unlink:before{content:"\f127"}.fa-unlock:before{content:"\f09c"}.fa-unlock-alt:before{content:"\f13e"}.fa-untappd:before{content:"\f405"}.fa-upload:before{content:"\f093"}.fa-ups:before{content:"\f7e0"}.fa-usb:before{content:"\f287"}.fa-user:before{content:"\f007"}.fa-user-alt:before{content:"\f406"}.fa-user-alt-slash:before{content:"\f4fa"}.fa-user-astronaut:before{content:"\f4fb"}.fa-user-check:before{content:"\f4fc"}.fa-user-circle:before{content:"\f2bd"}.fa-user-clock:before{content:"\f4fd"}.fa-user-cog:before{content:"\f4fe"}.fa-user-edit:before{content:"\f4ff"}.fa-user-friends:before{content:"\f500"}.fa-user-graduate:before{content:"\f501"}.fa-user-injured:before{content:"\f728"}.fa-user-lock:before{content:"\f502"}.fa-user-md:before{content:"\f0f0"}.fa-user-minus:before{content:"\f503"}.fa-user-ninja:before{content:"\f504"}.fa-user-nurse:before{content:"\f82f"}.fa-user-plus:before{content:"\f234"}.fa-user-secret:before{content:"\f21b"}.fa-user-shield:before{content:"\f505"}.fa-user-slash:before{content:"\f506"}.fa-user-tag:before{content:"\f507"}.fa-user-tie:before{content:"\f508"}.fa-user-times:before{content:"\f235"}.fa-users:before{content:"\f0c0"}.fa-users-cog:before{content:"\f509"}.fa-usps:before{content:"\f7e1"}.fa-ussunnah:before{content:"\f407"}.fa-utensil-spoon:before{content:"\f2e5"}.fa-utensils:before{content:"\f2e7"}.fa-vaadin:before{content:"\f408"}.fa-vector-square:before{content:"\f5cb"}.fa-venus:before{content:"\f221"}.fa-venus-double:before{content:"\f226"}.fa-venus-mars:before{content:"\f228"}.fa-viacoin:before{content:"\f237"}.fa-viadeo:before{content:"\f2a9"}.fa-viadeo-square:before{content:"\f2aa"}.fa-vial:before{content:"\f492"}.fa-vials:before{content:"\f493"}.fa-viber:before{content:"\f409"}.fa-video:before{content:"\f03d"}.fa-video-slash:before{content:"\f4e2"}.fa-vihara:before{content:"\f6a7"}.fa-vimeo:before{content:"\f40a"}.fa-vimeo-square:before{content:"\f194"}.fa-vimeo-v:before{content:"\f27d"}.fa-vine:before{content:"\f1ca"}.fa-virus:before{content:"\f974"}.fa-virus-slash:before{content:"\f975"}.fa-viruses:before{content:"\f976"}.fa-vk:before{content:"\f189"}.fa-vnv:before{content:"\f40b"}.fa-voicemail:before{content:"\f897"}.fa-volleyball-ball:before{content:"\f45f"}.fa-volume-down:before{content:"\f027"}.fa-volume-mute:before{content:"\f6a9"}.fa-volume-off:before{content:"\f026"}.fa-volume-up:before{content:"\f028"}.fa-vote-yea:before{content:"\f772"}.fa-vr-cardboard:before{content:"\f729"}.fa-vuejs:before{content:"\f41f"}.fa-walking:before{content:"\f554"}.fa-wallet:before{content:"\f555"}.fa-warehouse:before{content:"\f494"}.fa-water:before{content:"\f773"}.fa-wave-square:before{content:"\f83e"}.fa-waze:before{content:"\f83f"}.fa-weebly:before{content:"\f5cc"}.fa-weibo:before{content:"\f18a"}.fa-weight:before{content:"\f496"}.fa-weight-hanging:before{content:"\f5cd"}.fa-weixin:before{content:"\f1d7"}.fa-whatsapp:before{content:"\f232"}.fa-whatsapp-square:before{content:"\f40c"}.fa-wheelchair:before{content:"\f193"}.fa-whmcs:before{content:"\f40d"}.fa-wifi:before{content:"\f1eb"}.fa-wikipedia-w:before{content:"\f266"}.fa-wind:before{content:"\f72e"}.fa-window-close:before{content:"\f410"}.fa-window-maximize:before{content:"\f2d0"}.fa-window-minimize:before{content:"\f2d1"}.fa-window-restore:before{content:"\f2d2"}.fa-windows:before{content:"\f17a"}.fa-wine-bottle:before{content:"\f72f"}.fa-wine-glass:before{content:"\f4e3"}.fa-wine-glass-alt:before{content:"\f5ce"}.fa-wix:before{content:"\f5cf"}.fa-wizards-of-the-coast:before{content:"\f730"}.fa-wolf-pack-battalion:before{content:"\f514"}.fa-won-sign:before{content:"\f159"}.fa-wordpress:before{content:"\f19a"}.fa-wordpress-simple:before{content:"\f411"}.fa-wpbeginner:before{content:"\f297"}.fa-wpexplorer:before{content:"\f2de"}.fa-wpforms:before{content:"\f298"}.fa-wpressr:before{content:"\f3e4"}.fa-wrench:before{content:"\f0ad"}.fa-x-ray:before{content:"\f497"}.fa-xbox:before{content:"\f412"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-y-combinator:before{content:"\f23b"}.fa-yahoo:before{content:"\f19e"}.fa-yammer:before{content:"\f840"}.fa-yandex:before{content:"\f413"}.fa-yandex-international:before{content:"\f414"}.fa-yarn:before{content:"\f7e3"}.fa-yelp:before{content:"\f1e9"}.fa-yen-sign:before{content:"\f157"}.fa-yin-yang:before{content:"\f6ad"}.fa-yoast:before{content:"\f2b1"}.fa-youtube:before{content:"\f167"}.fa-youtube-square:before{content:"\f431"}.fa-zhihu:before{content:"\f63f"}.sr-only{border:0;clip:rect(0,0,0,0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.sr-only-focusable:active,.sr-only-focusable:focus{clip:auto;height:auto;margin:0;overflow:visible;position:static;width:auto}@font-face{font-family:"Font Awesome 5 Brands";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-brands-400.eot);src:url(../webfonts/fa-brands-400.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.woff) format("woff"),url(../webfonts/fa-brands-400.ttf) format("truetype"),url(../webfonts/fa-brands-400.svg#fontawesome) format("svg")}.fab{font-family:"Font Awesome 5 Brands"}@font-face{font-family:"Font Awesome 5 Free";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-regular-400.eot);src:url(../webfonts/fa-regular-400.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.woff) format("woff"),url(../webfonts/fa-regular-400.ttf) format("truetype"),url(../webfonts/fa-regular-400.svg#fontawesome) format("svg")}.fab,.far{font-weight:400}@font-face{font-family:"Font Awesome 5 Free";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.eot);src:url(../webfonts/fa-solid-900.eot?#iefix) format("embedded-opentype"),url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.woff) format("woff"),url(../webfonts/fa-solid-900.ttf) format("truetype"),url(../webfonts/fa-solid-900.svg#fontawesome) format("svg")}.fa,.far,.fas{font-family:"Font Awesome 5 Free"}.fa,.fas{font-weight:900} \ No newline at end of file diff --git a/static/vendor/fontawesome-free/package.json b/static/vendor/fontawesome-free/package.json deleted file mode 100644 index 8fb4c534..00000000 --- a/static/vendor/fontawesome-free/package.json +++ /dev/null @@ -1,81 +0,0 @@ -{ - "_from": "@fortawesome/fontawesome-free@5.10.2", - "_id": "@fortawesome/fontawesome-free@5.10.2", - "_inBundle": false, - "_integrity": "sha512-9pw+Nsnunl9unstGEHQ+u41wBEQue6XPBsILXtJF/4fNN1L3avJcMF/gGF86rIjeTAgfLjTY9ndm68/X4f4idQ==", - "_location": "/@fortawesome/fontawesome-free", - "_phantomChildren": {}, - "_requested": { - "type": "version", - "registry": true, - "raw": "@fortawesome/fontawesome-free@5.10.2", - "name": "@fortawesome/fontawesome-free", - "escapedName": "@fortawesome%2ffontawesome-free", - "scope": "@fortawesome", - "rawSpec": "5.10.2", - "saveSpec": null, - "fetchSpec": "5.10.2" - }, - "_requiredBy": [ - "/" - ], - "_resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.10.2.tgz", - "_shasum": "27e02da1e34b50c9869179d364fb46627b521130", - "_spec": "@fortawesome/fontawesome-free@5.10.2", - "_where": "/Users/DANGER_DAVID/Sites/startbootstrap-themes/startbootstrap-sb-admin-2", - "author": { - "name": "Dave Gandy", - "email": "dave@fontawesome.com", - "url": "http://twitter.com/davegandy" - }, - "bugs": { - "url": "http://github.com/FortAwesome/Font-Awesome/issues" - }, - "bundleDependencies": false, - "contributors": [ - { - "name": "Brian Talbot", - "url": "http://twitter.com/talbs" - }, - { - "name": "Travis Chase", - "url": "http://twitter.com/supercodepoet" - }, - { - "name": "Rob Madole", - "url": "http://twitter.com/robmadole" - }, - { - "name": "Geremia Taglialatela", - "url": "http://twitter.com/gtagliala" - }, - { - "name": "Mike Wilkerson", - "url": "http://twitter.com/mw77" - } - ], - "dependencies": {}, - "deprecated": false, - "description": "The iconic font, CSS, and SVG framework", - "engines": { - "node": ">=6" - }, - "homepage": "https://fontawesome.com", - "keywords": [ - "font", - "awesome", - "fontawesome", - "icon", - "svg", - "bootstrap" - ], - "license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)", - "main": "js/fontawesome.js", - "name": "@fortawesome/fontawesome-free", - "repository": { - "type": "git", - "url": "git+https://github.com/FortAwesome/Font-Awesome.git" - }, - "style": "css/fontawesome.css", - "version": "5.10.2" -} diff --git a/static/vendor/fontawesome-free/svgs/solid/folder.svg b/static/vendor/fontawesome-free/svgs/solid/folder.svg new file mode 100644 index 00000000..c9607689 --- /dev/null +++ b/static/vendor/fontawesome-free/svgs/solid/folder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/vendor/fontawesome-free/webfonts/fa-solid-900.eot b/static/vendor/fontawesome-free/webfonts/fa-solid-900.eot index 4101e318..d3b77c22 100644 Binary files a/static/vendor/fontawesome-free/webfonts/fa-solid-900.eot and b/static/vendor/fontawesome-free/webfonts/fa-solid-900.eot differ diff --git a/static/vendor/fontawesome-free/webfonts/fa-solid-900.svg b/static/vendor/fontawesome-free/webfonts/fa-solid-900.svg index 228a9b67..7742838b 100644 --- a/static/vendor/fontawesome-free/webfonts/fa-solid-900.svg +++ b/static/vendor/fontawesome-free/webfonts/fa-solid-900.svg @@ -1,12 +1,12 @@ -Created by FontForge 20190801 at Thu Aug 22 14:41:09 2019 +Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 By Robert Madole Copyright (c) Font Awesome @@ -23,15 +23,15 @@ Copyright (c) Font Awesome bbox="-0.983398 -64.9834 640.104 448.427" underline-thickness="25" underline-position="-50" - unicode-range="U+0020-F897" + unicode-range="U+0020-F976" /> +d="M470.38 446.49c2.59277 0.816406 6.90234 1.48047 9.62012 1.48047c17.6475 0 31.9834 -14.3232 32 -31.9707v-352c0 -35.3496 -43 -64 -96 -64s-96 28.6602 -96 64s43 64 96 64c8.95898 -0.0488281 23.2949 -1.80957 32 -3.92969v184.609l-256 -75v-233.68 +c0 -35.3398 -43 -64 -96 -64s-96 28.6602 -96 64s43 64 96 64c8.95801 -0.0507812 23.2939 -1.80664 32 -3.91992v261.41c0.0078125 12.958 10.0479 26.626 22.4102 30.5098z" /> +d="M256 440c137 0 248 -111 248 -248s-111 -248 -248 -248s-248 111 -248 248s111 248 248 248zM348.49 127c1.94043 2.4209 3.51465 6.90332 3.51465 10.0059c0 4.24512 -2.69043 9.84277 -6.00488 12.4941l-58 42.5v144c0 8.83203 -7.16797 16 -16 16h-32 +c-8.83203 0 -16 -7.16797 -16 -16v-155.55v-0.00488281c0 -10.6074 6.71973 -24.5957 15 -31.2256l67 -49.7197v0c2.41895 -1.93555 6.89746 -3.50586 9.99512 -3.50586c4.24512 0 9.84277 2.69043 12.4951 6.00586l20 25v0z" /> +d="M480.07 352c88.2939 -0.0263672 159.952 -71.7061 159.952 -160c0 -88.3203 -71.6797 -160 -160 -160c-37.1016 0 -88.291 21.5039 -114.263 48h-91.5195c-25.9717 -26.4961 -77.1611 -48 -114.263 -48c-88.3203 0 -160 71.6797 -160 160s71.6797 160 160 160h0.0224609 +h320.07zM248 180v24c0 6.62402 -5.37598 12 -12 12h-52v52c0 6.62402 -5.37598 12 -12 12h-24c-6.62402 0 -12 -5.37598 -12 -12v-52h-52c-6.62402 0 -12 -5.37598 -12 -12v-24c0 -6.62402 5.37598 -12 12 -12h52v-52c0 -6.62402 5.37598 -12 12 -12h24 +c6.62402 0 12 5.37598 12 12v52h52c6.62402 0 12 5.37598 12 12zM464 104c22.0801 0 40 17.9199 40 40s-17.9199 40 -40 40s-40 -17.9199 -40 -40s17.9199 -40 40 -40zM528 200c22.0801 0 40 17.9199 40 40s-17.9199 40 -40 40s-40 -17.9199 -40 -40s17.9199 -40 40 -40z +" /> +d="M505.12 428.906c6.95508 -32.2031 6.95508 -57.4062 6.86133 -82.6094c0 -102.688 -55.4375 -164.781 -128.035 -211.094v-104.438c0 -16.3594 -11.8789 -35.5625 -26.5078 -42.8594l-98.7275 -49.3906c-2.81934 -1.27441 -7.61621 -2.40137 -10.707 -2.51562 +c-13.2471 0.00195312 -24.002 10.7539 -24.0059 24v103.844l-22.4746 -22.4688c-13.1211 -13.1562 -34.1211 -11.1875 -45.2773 0l-50.9043 50.9062c-12.9961 12.9922 -11.3652 33.8887 0 45.25l22.4746 22.4688h-103.811c-13.2461 0.00195312 -24.001 10.7539 -24.0059 24 +c0.111328 3.09082 1.23828 7.88574 2.51562 10.7031l49.4355 98.8125c7.33008 14.6094 26.5391 26.4688 42.8867 26.4844h104.215c46.2168 72.7969 108.122 128 211.354 128c25.0996 0 50.3086 0 82.5059 -6.90625c5.54883 -1.1875 11.0176 -6.65625 12.207 -12.1875z +M384.04 280c22.0732 0.0078125 39.9971 17.9277 40.0098 40c0 22.0801 -17.9199 40 -40 40s-40 -17.9199 -40 -40c0 -22.0742 17.916 -39.9951 39.9902 -40z" /> @@ -1159,11 +1160,11 @@ c10.9004 -8.7998 22.8008 -17.0996 35.4004 -24.8994c5.7998 -3.5 13.2998 -1.60059 c6.59961 0 12 5.40039 12 12zM0 328c0 13.2998 10.7002 24 24 24h280v-320h-280c-13.2998 0 -24 10.7002 -24 24v272zM58.9004 111.9c-2.60059 -7.80078 3.19922 -15.9004 11.3994 -15.9004h22.9004c5.2998 0 10 3.59961 11.5 8.7002l9.09961 31.7998h60.2002 l9.40039 -31.9004c1.40137 -4.74316 6.55273 -8.59668 11.5 -8.59961h22.8994c8.2998 0 14 8.09961 11.4004 15.9004l-57.5 169.1c-1.7002 4.7998 -6.2998 8.09961 -11.4004 8.09961h-32.5c-5.2002 0 -9.7002 -3.19922 -11.3994 -8.09961z" /> +d="M480 288c17.6641 0 32 -14.3359 32 -32v-288c0 -17.6641 -14.3359 -32 -32 -32h-320c-17.6641 0 -32 14.3359 -32 32v448c0 17.6641 14.3359 32 32 32h242.75c7.31348 -0.000976562 17.4473 -4.19922 22.6201 -9.37012l45.25 -45.25 +c5.17676 -5.17285 9.37891 -15.3115 9.37988 -22.6299v-82.75zM288 16v32c0 8.83203 -7.16797 16 -16 16h-32c-8.83203 0 -16 -7.16797 -16 -16v-32c0 -8.83203 7.16797 -16 16 -16h32c8.83203 0 16 7.16797 16 16zM288 144v32c0 8.83203 -7.16797 16 -16 16h-32 +c-8.83203 0 -16 -7.16797 -16 -16v-32c0 -8.83203 7.16797 -16 16 -16h32c8.83203 0 16 7.16797 16 16zM416 16v32c0 8.83203 -7.16797 16 -16 16h-32c-8.83203 0 -16 -7.16797 -16 -16v-32c0 -8.83203 7.16797 -16 16 -16h32c8.83203 0 16 7.16797 16 16zM416 144v32 +c0 8.83203 -7.16797 16 -16 16h-32c-8.83203 0 -16 -7.16797 -16 -16v-32c0 -8.83203 7.16797 -16 16 -16h32c8.83203 0 16 7.16797 16 16zM416 256v64h-48c-8.83203 0 -16 7.16797 -16 16v48h-160v-128h224zM64 320c17.6641 0 32 -14.3359 32 -32v-320 +c0 -17.6641 -14.3359 -32 -32 -32h-32c-17.6641 0 -32 14.3359 -32 32v320c0 17.6641 14.3359 32 32 32h32z" /> +d="M320 416v-96h-64v96c0 17.6641 14.3359 32 32 32s32 -14.3359 32 -32zM368 288c8.83203 0 16 -7.16797 16 -16v-32c0 -8.83203 -7.16797 -16 -16 -16h-16v-32c-0.0107422 -72.1074 -57.3555 -142.354 -128 -156.8v-99.2002h-64v99.2002 +c-70.6445 14.4463 -127.989 84.6924 -128 156.8v32h-16c-8.83203 0 -16 7.16797 -16 16v32c0 8.83203 7.16797 16 16 16h352zM128 416v-96h-64v96c0 17.6641 14.3359 32 32 32s32 -14.3359 32 -32z" /> +d="M510.9 302.729l-68.2969 -286.823c-10.502 -44.1084 -55.8252 -79.9062 -101.166 -79.9062h-127.363c-29.7637 0 -71.5107 16.5547 -93.1855 36.9531l-108.298 101.92c-6.92383 6.53418 -12.542 19.5635 -12.542 29.083c0 22.0762 17.916 39.9922 39.9922 39.9922 +c8.7334 0 20.9922 -4.84961 27.3623 -10.8252l60.5928 -57.0254v0c0 22.6758 -5.22852 58.7256 -11.6699 80.4668l-42.6885 144.075c-0.90918 3.06934 -1.64746 8.1582 -1.64746 11.3594c0 22.083 17.9229 40.0059 40.0059 40.0059 +c16.4922 0 33.6768 -12.833 38.3594 -28.6465l37.1543 -125.395c0.975586 -3.29199 4.55469 -5.96484 7.98828 -5.96484c4.59863 0 8.33105 3.73242 8.33105 8.33105c0 0.582031 -0.117188 1.51172 -0.262695 2.0752l-50.3047 195.641 +c-0.696289 2.70703 -1.26172 7.17285 -1.26172 9.96875c0 22.0781 17.918 39.9961 39.9961 39.9961c17.1152 0 34.4678 -13.4521 38.7344 -30.0273l56.0947 -218.158c1.11035 -4.31934 5.63184 -7.82617 10.0918 -7.82617c4.69238 0 9.26562 3.73047 10.208 8.32715 +l37.6826 183.704c3.6416 17.6387 21.2139 31.9541 39.2246 31.9541c3.41309 0 8.82422 -0.835938 12.0781 -1.86426c19.8604 -6.2998 30.8623 -27.6738 26.6758 -48.085l-33.8389 -164.967c-0.0849609 -0.414062 -0.154297 -1.09375 -0.154297 -1.51758 +c0 -4.16797 3.38281 -7.55176 7.55176 -7.55176c3.29297 0 6.58398 2.59961 7.34668 5.80273l29.3975 123.459c4.03906 16.9619 21.4688 30.7285 38.9053 30.7285c22.0771 0 39.9941 -17.917 39.9941 -39.9941c0 -2.59277 -0.487305 -6.74316 -1.08789 -9.26562z" /> +d="M592 448c26.4961 0 48 -21.5039 48 -48v-320c0 -26.4961 -21.5039 -48 -48 -48h-240v-32h176c8.83203 0 16 -7.16797 16 -16v-32c0 -8.83203 -7.16797 -16 -16 -16h-416c-8.83203 0 -16 7.16797 -16 16v32c0 8.83203 7.16797 16 16 16h176v32h-240 +c-26.4961 0 -48 21.5039 -48 48v320c0 26.4961 21.5039 48 48 48h544zM576 96v288h-512v-288h512z" /> +d="M304 128c8.83203 0 16 -7.16797 16 -16s-7.16797 -16 -16 -16s-16 7.16797 -16 16s7.16797 16 16 16zM336 224c8.83203 0 16 -7.16797 16 -16s-7.16797 -16 -16 -16s-16 7.16797 -16 16s7.16797 16 16 16zM368 160c-8.83203 0 -16 7.16797 -16 16s7.16797 16 16 16 +s16 -7.16797 16 -16s-7.16797 -16 -16 -16zM336 128c-8.83203 0 -16 7.16797 -16 16s7.16797 16 16 16s16 -7.16797 16 -16s-7.16797 -16 -16 -16zM304 192c8.83203 0 16 -7.16797 16 -16s-7.16797 -16 -16 -16s-16 7.16797 -16 16s7.16797 16 16 16zM432 224 +c-8.83203 0 -16 7.16797 -16 16s7.16797 16 16 16s16 -7.16797 16 -16s-7.16797 -16 -16 -16zM384 208c0 8.83203 7.16797 16 16 16s16 -7.16797 16 -16s-7.16797 -16 -16 -16s-16 7.16797 -16 16zM368 256c8.83203 0 16 -7.16797 16 -16s-7.16797 -16 -16 -16 +s-16 7.16797 -16 16s7.16797 16 16 16zM464 224c8.83203 0 16 -7.16797 16 -16s-7.16797 -16 -16 -16s-16 7.16797 -16 16s7.16797 16 16 16zM496 256c8.83203 0 16 -7.16797 16 -16s-7.16797 -16 -16 -16s-16 7.16797 -16 16s7.16797 16 16 16zM432 192 +c8.83203 0 16 -7.16797 16 -16s-7.16797 -16 -16 -16s-16 7.16797 -16 16s7.16797 16 16 16zM400 160c8.83203 0 16 -7.16797 16 -16s-7.16797 -16 -16 -16s-16 7.16797 -16 16s7.16797 16 16 16zM336 96c8.83203 0 16 -7.16797 16 -16s-7.16797 -16 -16 -16 +s-16 7.16797 -16 16s7.16797 16 16 16zM304 64c8.83203 0 16 -7.16797 16 -16s-7.16797 -16 -16 -16s-16 7.16797 -16 16s7.16797 16 16 16zM368 128c8.83203 0 16 -7.16797 16 -16s-7.16797 -16 -16 -16s-16 7.16797 -16 16s7.16797 16 16 16zM389.65 346.35 +c2.58691 -2.58691 4.6875 -7.65527 4.6875 -11.3145s-2.10059 -8.72852 -4.6875 -11.3154l-169.381 -169.37c-2.58691 -2.58691 -7.65527 -4.6875 -11.3145 -4.6875s-8.72852 2.10059 -11.3154 4.6875l-11.2998 11.3105c-2.58496 2.58594 -4.68262 7.65332 -4.68262 11.3096 +c0 3.65723 2.09766 8.72363 4.68262 11.3105l5.66016 5.66992c-17.6602 17.9219 -31.9961 52.8887 -32 78.0498c0 19.2402 5.2998 37.0801 13.9297 52.8604l-10 10c-9.44434 9.47461 -27.9678 17.1641 -41.3457 17.1641c-2.10254 0 -5.5 -0.22168 -7.58398 -0.494141 +c-30 -3.73047 -51 -31.7803 -51 -61.9307v-305.6c0 -8.83203 -7.16797 -16 -16 -16h-32c-8.83203 0 -16 7.16797 -16 16v303.15c0 67.9395 55.4902 129.35 123.44 128.85c27.7246 -0.138672 66.1006 -16.1992 85.6592 -35.8496l10 -10 +c15.8203 8.5498 33.6602 13.8496 52.9004 13.8496c25.1631 -0.000976562 60.1289 -14.3369 78.0498 -32l5.66992 5.66016c2.58691 2.58691 7.65625 4.6875 11.3154 4.6875s8.72754 -2.10059 11.3145 -4.6875z" /> +d="M32 64v48h448v-48c-0.0478516 -23.5742 -14.3848 -55.4229 -32 -71.0898v-40.9102c0 -8.83203 -7.16797 -16 -16 -16h-32c-8.83203 0 -16 7.16797 -16 16v16h-256v-16c0 -8.83203 -7.16797 -16 -16 -16h-32c-8.83203 0 -16 7.16797 -16 16v40.9102 +c-17.6152 15.667 -31.9521 47.5156 -32 71.0898zM496 192c8.83203 0 16 -7.16797 16 -16v-16c0 -8.83203 -7.16797 -16 -16 -16h-480c-8.83203 0 -16 7.16797 -16 16v16c0 8.83203 7.16797 16 16 16h16v186.75v0.00585938c0 38.2256 31.0244 69.25 69.25 69.25 +c15.835 0 37.7734 -9.08789 48.9697 -20.2861l19.2607 -19.2695c29.8994 13.1299 59.1094 7.60938 79.7295 -8.62012l0.169922 0.169922c2.58691 2.58496 7.65332 4.68262 11.3105 4.68262c3.65625 0 8.72266 -2.09766 11.3096 -4.68262l11.3096 -11.3096 +c2.58789 -2.58691 4.68848 -7.65625 4.68848 -11.3154s-2.10059 -8.72852 -4.68848 -11.3154l-105.369 -105.369c-2.58691 -2.58789 -7.65625 -4.68848 -11.3154 -4.68848s-8.72852 2.10059 -11.3154 4.68848l-11.3096 11.3096 +c-2.57617 2.58496 -4.66797 7.64551 -4.66797 11.2949s2.0918 8.70996 4.66797 11.2949l0.169922 0.169922c-16.2295 20.6201 -21.75 49.8506 -8.62012 79.7305l-19.2695 19.2598c-3.43652 3.42969 -10.165 6.21387 -15.0205 6.21387 +c-11.71 0 -21.2344 -9.50391 -21.2598 -21.2139v-186.75h416z" /> - + @@ -2068,6 +2074,14 @@ c22.3008 -10.2002 46.9004 -16 72.9004 -16s50.7002 5.7998 72.9004 16h55.0996z" /> d="M464 416c26.5 0 48 -21.5 48 -48v-352c0 -26.5 -21.5 -48 -48 -48h-416c-26.5 0 -48 21.5 -48 48v352c0 26.5 21.5 48 48 48h416zM380.4 125.5l-67.1006 66.5l67.1006 66.5c4.7998 4.7998 4.7998 12.5996 0 17.4004l-40.5 40.5 c-4.80078 4.7998 -12.6006 4.7998 -17.4004 0l-66.5 -67.1006l-66.5 67.1006c-4.7998 4.7998 -12.5996 4.7998 -17.4004 0l-40.5 -40.5c-4.7998 -4.80078 -4.7998 -12.6006 0 -17.4004l67.1006 -66.5l-67.1006 -66.5c-4.7998 -4.7998 -4.7998 -12.5996 0 -17.4004 l40.5 -40.5c4.80078 -4.7998 12.6006 -4.7998 17.4004 0l66.5 67.1006l66.5 -67.1006c4.7998 -4.7998 12.5996 -4.7998 17.4004 0l40.5 40.5c4.7998 4.80078 4.7998 12.6006 0 17.4004z" /> + + - + + +d="M32 224h32v-192h-32h-0.0380859c-17.6436 0 -31.9619 14.3184 -31.9619 31.9619v0.0380859v128v0.0380859c0 17.6436 14.3184 31.9619 31.9619 31.9619h0.0380859zM544 272v-272c-0.0351562 -35.293 -28.707 -63.9648 -64 -64h-320 +c-35.293 0.0351562 -63.9648 28.707 -64 64v272v0.0263672c0 44.1455 35.8281 79.9736 79.9736 79.9736h0.0263672h112v64c0 17.6641 14.3359 32 32 32s32 -14.3359 32 -32v-64h112h0.0263672c44.1455 0 79.9736 -35.8281 79.9736 -79.9736v-0.0263672zM264 192 +c0 22.0801 -17.9199 40 -40 40s-40 -17.9199 -40 -40s17.9199 -40 40 -40h0.00292969c22.0781 0 39.9971 17.9189 39.9971 39.9971v0.00292969zM256 64h-64v-32h64v32zM352 64h-64v-32h64v32zM456 192c0 22.0801 -17.9199 40 -40 40s-40 -17.9199 -40 -40 +s17.9199 -40 40 -40h0.00292969c22.0781 0 39.9971 17.9189 39.9971 39.9971v0.00292969zM448 64h-64v-32h64v32zM640 192v-128v-0.0380859c0 -17.6436 -14.3184 -31.9619 -31.9619 -31.9619h-0.0380859h-32v192h32h0.0380859c17.6436 0 31.9619 -14.3184 31.9619 -31.9619 +v-0.0380859z" /> - + - + +d="M223.999 224c17.6328 -0.03125 31.9727 -14.3672 32.0078 -32c0 -17.6641 -14.3359 -32 -32 -32s-32 14.3359 -32 32c0 17.6602 14.333 31.9961 31.9922 32zM438.171 320c16.3789 -29.375 15.0039 -73.125 -25.1309 -128c40.1348 -54.875 41.5098 -98.625 25.1309 -128 +c-29.1309 -52.375 -101.646 -43.625 -116.275 -41.875c-21.5039 -51.25 -54.2617 -86.125 -97.8965 -86.125s-76.3906 34.875 -97.8965 86.125c-14.627 -1.75 -87.1426 -10.5 -116.273 41.875c-16.3789 29.375 -15.0039 73.125 25.1289 128 +c-40.1328 54.875 -41.5078 98.625 -25.1289 128c10.877 19.5 40.5078 50.625 116.273 41.875c21.5059 51.25 54.2617 86.125 97.8965 86.125s76.3926 -34.875 97.8965 -86.125c75.7656 8.875 105.398 -22.375 116.275 -41.875zM63.3389 96 +c3.75195 -6.625 19.0059 -11.875 43.6348 -11c-2.75 13 -5.125 26.375 -6.75 40.125c-7.75195 6.25 -15.0039 12.625 -21.8809 19.125c-15.1289 -23.5 -19.0039 -41 -15.0039 -48.25zM100.224 258.875c1.625 13.5 3.875 26.875 6.75 40.25c-1.875 0 -4 0.375 -5.75 0.375 +c-21.5059 0 -34.5078 -5.375 -37.8848 -11.5c-4 -7.25 -0.125 -24.75 15.0039 -48.25c6.87695 6.5 14.1289 12.875 21.8809 19.125zM223.999 384c-9.50195 0 -22.2539 -13.5 -33.8828 -37.25c11.2539 -3.75 22.5059 -8 33.8828 -12.875 +c11.3789 4.875 22.6309 9.125 33.8828 12.875c-11.627 23.75 -24.3809 37.25 -33.8828 37.25zM223.999 0c9.50195 0 22.2559 13.5 33.8828 37.25c-11.252 3.75 -22.5039 8 -33.8828 12.875c-11.377 -4.875 -22.6289 -9.125 -33.8828 -12.875 +c11.6289 -23.75 24.3809 -37.25 33.8828 -37.25zM223.999 112c44.1602 0 80 35.8398 80 80s-35.8398 80 -80 80s-80 -35.8398 -80 -80s35.8398 -80 80 -80zM384.659 96c4 7.25 0.125 24.75 -15.0039 48.25c-6.875 -6.5 -14.127 -12.875 -21.8789 -19.125 +c-1.625 -13.75 -4 -27.125 -6.75195 -40.125c24.6309 -0.875 40.0098 4.375 43.6348 11zM369.655 239.75c15.1289 23.5 19.0039 41 15.0039 48.25c-3.375 6.125 -16.3789 11.5 -37.8828 11.5c-1.75 0 -3.87695 -0.375 -5.75195 -0.375 +c2.87695 -13.375 5.12695 -26.75 6.75195 -40.25c7.75195 -6.25 15.0039 -12.625 21.8789 -19.125z" /> + +d="M128 192c70.6562 0 128 -57.3438 128 -128s-57.3438 -128 -128 -128s-128 57.3438 -128 128s57.3438 128 128 128zM507 246.86c14.2402 -24.3799 -3.58008 -54.8604 -32.0898 -54.8604h-213.82c-28.5098 0 -46.3301 30.4805 -32.0898 54.8604l106.93 182.85 +c5.97266 10.0967 20.3398 18.291 32.0703 18.291s26.0977 -8.19434 32.0703 -18.291zM480 160c17.6641 0 32 -14.3359 32 -32v-160c0 -17.6641 -14.3359 -32 -32 -32h-160c-17.6641 0 -32 14.3359 -32 32v160c0 17.6641 14.3359 32 32 32h160z" /> +d="M464 320c26.4961 0 48 -21.5039 48 -48v-224c0 -26.4961 -21.5039 -48 -48 -48h-416c-26.4961 0 -48 21.5039 -48 48v288c0 26.4961 21.5039 48 48 48h160l64 -64h192zM359.5 152v16c0 8.83203 -7.16797 16 -16 16h-64v64c0 8.83203 -7.16797 16 -16 16h-16 +c-8.83203 0 -16 -7.16797 -16 -16v-64h-64c-8.83203 0 -16 -7.16797 -16 -16v-16c0 -8.83203 7.16797 -16 16 -16h64v-64c0 -8.83203 7.16797 -16 16 -16h16c8.83203 0 16 7.16797 16 16v64h64c8.83203 0 16 7.16797 16 16z" /> - - + +d="M438.406 70.4062c-3.20312 -12.8125 -3.20312 -57.6094 0 -73.6094c6.39062 -6.39062 9.58887 -12.792 9.59375 -19.2031v-16c0 -16 -12.7969 -25.5938 -25.5938 -25.5938h-326.406c-54.4062 0 -96 41.5938 -96 96v320c0 54.4062 41.5938 96 96 96h326.406 +c16 0 25.5938 -9.59375 25.5938 -25.5938v-332.812c0 -9.59375 -3.19824 -15.9893 -9.59375 -19.1875zM380.797 64h-284.797c-16 0 -32 -12.7969 -32 -32s12.7969 -32 32 -32h284.797v64zM128.016 271.984c0 -0.515625 0.140625 -0.984375 0.140625 -1.5l37.1094 -32.4688 +c1.50488 -1.31934 2.72656 -4.01465 2.72656 -6.01562c0 -4.41211 -3.58008 -7.99609 -7.99219 -8h-0.015625c-1.625 0.0820312 -3.97656 0.97168 -5.25 1.98438l-23.5938 20.6406c11.5469 -49.5781 55.7656 -86.625 108.859 -86.625s97.3125 37.0469 108.875 86.625 +l-23.5938 -20.6406c-1.25 -1.08691 -3.60938 -1.96875 -5.26562 -1.96875v0h-0.015625c-1.9502 0.108398 -4.64551 1.32617 -6.01562 2.71875c-1.01074 1.27832 -1.89941 3.6377 -1.98438 5.26562c0.107422 1.9541 1.33203 4.64941 2.73438 6.01562l37.1094 32.4688 +c0.015625 0.53125 0.15625 1 0.15625 1.51562c0 11.0469 -2.09375 21.5156 -5.0625 31.5938l-21.2656 -21.25c-1.29492 -1.2959 -3.83105 -2.34766 -5.66309 -2.34766c-4.41895 0 -8.00488 3.58594 -8.00488 8.00488c0 1.82812 1.04883 4.36133 2.33984 5.65527 +l26.4219 26.4062c-8.47949 17.6582 -29.249 39.7295 -46.3594 49.2656c5.2959 -8.46484 9.59375 -23.4395 9.59375 -33.4248c0 -16.7217 -10.5977 -38.7705 -23.6562 -49.2158c8.64258 -8.95605 15.6562 -26.3262 15.6562 -38.7725 +c0 -25.0283 -19.8799 -49.5117 -44.375 -54.6494l-1.42188 34.2812l12.6719 -8.625c0.557617 -0.379883 1.55762 -0.6875 2.23242 -0.6875h0.0175781h0.0253906c2.19727 0 3.98145 1.7832 3.98145 3.98047c0 0.609375 -0.254883 1.52832 -0.569336 2.05078l-8.53125 14.3125 +l17.9062 3.71875c1.75977 0.367188 3.1875 2.12402 3.1875 3.92188s-1.42773 3.55469 -3.1875 3.92188l-17.9062 3.71875l8.53125 14.3125c0.314453 0.522461 0.569336 1.44141 0.569336 2.05078c0 2.19727 -1.78418 3.98047 -3.98145 3.98047h-0.0253906 +c-0.668945 -0.0263672 -1.67676 -0.327148 -2.25 -0.671875l-14.1875 -9.65625l-4.6875 112.297c-0.0927734 2.11328 -1.88477 3.82812 -4 3.82812s-3.90723 -1.71484 -4 -3.82812l-4.625 -110.812l-12 8.15625c-0.561523 0.380859 -1.56836 0.69043 -2.24707 0.69043 +c-2.20996 0 -4.00293 -1.79297 -4.00293 -4.00293c0 -0.607422 0.251953 -1.52441 0.5625 -2.04688l8.53125 -14.3125l-17.9062 -3.71875c-1.75977 -0.364258 -3.1875 -2.11719 -3.1875 -3.91406s1.42773 -3.5498 3.1875 -3.91406l17.9062 -3.73438l-8.53125 -14.2969 +c-0.285156 -0.529297 -0.537109 -1.44629 -0.5625 -2.04688c0.0507812 -0.928711 0.611328 -2.23047 1.25 -2.90625c0.639648 -0.603516 1.87109 -1.09277 2.75 -1.09375c0.677734 0.00292969 1.68555 0.311523 2.25 0.6875l10.3594 7.04688l-1.35938 -32.7188 +c-24.4951 5.14746 -44.375 29.6396 -44.375 54.6699c0 12.4482 7.01367 29.8232 15.6562 38.7832c-13.0586 10.4434 -23.6562 32.4893 -23.6562 49.21c0 9.99316 4.30469 24.9775 9.60938 33.4463c-17.1104 -9.53906 -37.8867 -31.6104 -46.375 -49.2656l26.4219 -26.4219 +c1.28516 -1.29199 2.3291 -3.81934 2.3291 -5.64258c0 -4.41504 -3.58398 -7.99902 -7.99902 -7.99902c-1.82324 0 -4.35059 1.04395 -5.64258 2.3291l-21.2656 21.2656c-2.98438 -10.0938 -5.07812 -20.5625 -5.0625 -31.625z" /> d="M422.19 338.05c5.3291 -3.24023 5.2998 -11.2695 -0.0507812 -14.46l-198.14 -118.14l-198.13 118.14c-5.35059 3.19043 -5.37988 11.2305 -0.0605469 14.46l165.971 100.88c19.9102 12.1006 44.5195 12.1006 64.4297 0zM436.03 293.42 c5.33008 3.17969 11.9697 -0.839844 11.9697 -7.25v-197.7c0 -23.7598 -12.1104 -45.7393 -31.79 -57.7002l-152.16 -92.4795c-10.6602 -6.48047 -24.0498 1.5498 -24.0498 14.4297v223.82zM0 286.17c0 6.41016 6.63965 10.4297 11.9697 7.25l196.03 -116.88v-223.81 c0 -12.8906 -13.3799 -20.9102 -24.0498 -14.4307l-152.16 92.4697c-19.6797 11.9609 -31.79 33.9307 -31.79 57.7002v197.7z" /> - + +d="M511.328 427.197c-11.6074 -38.7021 -34.3076 -111.702 -61.3037 -187.701c6.99902 -2.09375 13.4043 -4 18.6074 -5.59277c6.28125 -1.91504 11.3789 -8.79785 11.3789 -15.3643c0 -2.21094 -0.842773 -5.58984 -1.88086 -7.54199 +c-22.1055 -42.2969 -82.6904 -152.795 -142.479 -214.403c-0.999023 -1.09375 -1.99902 -2.5 -2.99902 -3.5c-31.501 -31.5098 -93.2285 -57.083 -137.784 -57.083c-107.546 0 -194.83 87.2842 -194.83 194.831c0 44.5391 25.5566 106.25 57.0469 137.748 +c1 1 2.40625 2 3.49902 3c61.6006 59.9053 171.975 120.405 214.374 142.498c1.95215 1.03809 5.33008 1.88086 7.54102 1.88086c6.56641 0 13.4492 -5.09863 15.3613 -11.3809c1.59375 -5.09375 3.5 -11.5928 5.59277 -18.5928 +c75.8955 26.999 148.978 49.7021 187.675 61.2959c1.26465 0.382812 3.36426 0.692383 4.68555 0.692383c8.93262 0 16.1826 -7.25 16.1826 -16.1826c0 -1.29785 -0.298828 -3.35938 -0.667969 -4.60352zM319.951 127.998 +c-0.00976562 70.6348 -57.3457 127.962 -127.98 127.962c-70.6455 0 -127.98 -57.335 -127.98 -127.98c0 -70.6445 57.335 -127.979 127.98 -127.979h0.00488281c70.6426 0 127.976 57.333 127.976 127.976v0.0224609zM191.971 159.997 +c-0.00292969 -17.6582 -14.3359 -31.9902 -31.9951 -31.9902c-17.6611 0 -31.9951 14.334 -31.9951 31.9951s14.334 31.9951 31.9951 31.9951h0.0361328c17.6416 0 31.959 -14.3174 31.959 -31.959v-0.0410156zM223.966 79.998 +c-0.000976562 -8.8291 -7.16797 -15.9951 -15.998 -15.9951s-15.9971 7.16699 -15.9971 15.998c0 8.83008 7.16699 15.9971 15.9971 15.9971c8.80371 -0.0283203 15.9707 -7.19629 15.998 -16z" /> d="M96 -48c0 -8.7998 -7.2002 -16 -16 -16h-32c-8.7998 0 -16 7.2002 -16 16v480c0 8.7998 7.2002 16 16 16h32c8.7998 0 16 -7.2002 16 -16v-480zM224 -48c0 -8.7998 -7.2002 -16 -16 -16h-32c-8.7998 0 -16 7.2002 -16 16v480c0 8.7998 7.2002 16 16 16h32 c8.7998 0 16 -7.2002 16 -16v-480z" /> +d="M502.63 409c5.15625 -5.1709 9.33984 -15.293 9.33984 -22.5947c0 -7.31543 -4.19727 -17.4521 -9.37012 -22.625l-46.3301 -46.3203c-3.24707 -3.25684 -9.4248 -7.07812 -13.7891 -8.53027l-36.4805 -12.1602l-76.2402 -76.2393 +c8.79004 -12.2002 15.7705 -25.5605 19.1602 -40.2002c7.74023 -33.3896 0.870117 -66.8701 -22 -89.75c-7.87793 -7.8418 -22.877 -16.9141 -33.4795 -20.25c-18.54 -6.00977 -32.6709 -23.29 -34.4307 -42.1396c-2.29004 -23.8105 -11.4502 -45.8301 -28.4502 -62.71 +c-45.5596 -45.4805 -127.5 -37.3809 -182.979 18.0693c-55.4805 55.4502 -63.6904 137.45 -18.0498 182.96c16.8799 16.9902 38.9102 26.1699 62.6094 28.4404c18.9404 1.76953 36.1504 15.8994 42.1504 34.46c3.33105 10.6016 12.3984 25.5957 20.2402 33.4697 +c22.8799 22.8799 56.4297 29.7803 89.8799 22c14.5996 -3.39941 27.9395 -10.3799 40.0996 -19.1396l76.2598 76.2598l12.1602 36.5098c1.45215 4.36426 5.27344 10.542 8.53027 13.79l46.2803 46.3301c5.17383 5.1748 15.3115 9.375 22.6299 9.375 +c7.31738 0 17.4561 -4.2002 22.6299 -9.375zM208 96c26.4961 0 48 21.5039 48 48s-21.5039 48 -48 48s-48 -21.5039 -48 -48s21.5039 -48 48 -48z" /> @@ -4352,16 +4387,20 @@ c14.2998 -1.2002 26.5 -10.7002 29.7998 -24.2002zM336 448c8.7998 0 16 -7.2002 16 c0 -13.2998 -10.7002 -24 -24 -24h-8v-136c0 -13.2998 -10.7002 -24 -24 -24h-80c-13.2998 0 -24 10.7002 -24 24v136h-8c-13.2998 0 -24 10.7002 -24 24v136c0 25.0996 19.2998 45.5 43.9004 47.5996c15 -9.7998 32.8994 -15.5996 52.0996 -15.5996 s37.0996 5.7998 52.0996 15.5996z" /> +d="M502.609 137.958l-96.7041 -96.7168c-5.15039 -5.13184 -15.2324 -9.29785 -22.5029 -9.29785c-7.27148 0 -17.3535 4.16602 -22.5039 9.29785l-80.3262 80.418l-9.89258 -9.9082c9.41016 -20.7256 17.0469 -56.0186 17.0469 -78.7803 +c0 -26.3193 -10.0596 -66.5244 -22.4541 -89.7422c-4.50098 -8.50098 -16.3936 -9.59473 -23.207 -2.79785l-107.519 107.515l-17.7998 -17.7988c0.703125 -2.60938 1.60938 -5.00098 1.60938 -7.79785v-0.000976562c0 -17.667 -14.3379 -32.0059 -32.0049 -32.0059 +s-32.0059 14.3389 -32.0059 32.0059s14.3389 32.0049 32.0059 32.0049c2.79688 0 5.18848 -0.90625 7.79785 -1.60938l17.7998 17.7998l-107.518 107.515c-6.79883 6.8125 -5.7041 18.6113 2.79688 23.2061c23.2197 12.3936 63.4248 22.4531 89.7451 22.4531 +c22.7627 0 58.0576 -7.63672 78.7832 -17.0469l9.79883 9.79883l-80.3105 80.417c-5.13086 5.16602 -9.29395 15.2686 -9.29395 22.5498s4.16309 17.3838 9.29395 22.5498l96.7197 96.7168c5.11621 5.13281 15.1514 9.29785 22.3984 9.29785h0.105469h0.0449219 +c7.28223 0 17.3857 -4.16602 22.5527 -9.29785l80.3262 -80.3076l47.8047 47.8965c5.43262 5.42773 16.0742 9.83398 23.7539 9.83398s18.3213 -4.40625 23.7539 -9.83398l47.5088 -47.5059c5.42188 -5.43555 9.82129 -16.0771 9.82129 -23.7539 +s-4.39941 -18.3184 -9.82129 -23.7529l-47.8057 -47.8975l80.3105 -80.417c5.12305 -5.13672 9.28125 -15.1934 9.28125 -22.4482c0 -7.30469 -4.20703 -17.4111 -9.39062 -22.5576zM219.562 250.567l73.8252 73.8223l-68.918 68.8994l-73.8096 -73.8066zM457.305 160.461 +l-68.9023 68.916l-73.8242 -73.8232l68.918 -68.8994z" /> +d="M305.449 -14.5898c7.3916 -7.29785 6.18848 -20.0967 -3 -25.0039c-77.7129 -41.8027 -176.726 -29.9102 -242.344 35.708c-65.6016 65.6035 -77.5098 164.523 -35.6914 242.332c4.89062 9.09473 17.6895 10.2979 25.0029 3l116.812 -116.813l27.3945 27.3945 +c-0.6875 2.60938 -1.59375 5.00098 -1.59375 7.81348c0 17.666 14.3379 32.0039 32.0039 32.0039s32.0039 -14.3379 32.0039 -32.0039s-14.3379 -32.0039 -32.0039 -32.0039c-2.79785 0 -5.2041 0.890625 -7.79785 1.59375l-27.4102 -27.4102zM511.976 144.933 +c0.0136719 -0.248047 0.0253906 -0.650391 0.0253906 -0.899414c0 -8.84668 -7.18066 -16.0615 -16.0273 -16.1025h-32.1133c-8.27148 0.0244141 -15.3916 6.74512 -15.8926 15.002c-7.50098 129.519 -111.515 234.533 -240.937 241.534 +c-8.28125 0.441406 -15.0029 7.5293 -15.0029 15.8223c0 0.0234375 0 0.0625 0.000976562 0.0859375v31.5986c0.0361328 8.84766 7.24609 16.0273 16.0938 16.0273c0.250977 0 0.657227 -0.0107422 0.908203 -0.0253906c163.224 -8.59473 294.443 -139.816 302.944 -303.043 +zM415.964 145.229c0.0195312 -0.299805 0.0361328 -0.788086 0.0361328 -1.08887c0 -8.91309 -7.23438 -16.1758 -16.1475 -16.21h-32.208c-8.08594 0.0585938 -15.2061 6.64648 -15.8926 14.7051c-6.90625 77.0107 -68.1172 138.91 -144.924 145.224 +c-8.16602 0.585938 -14.7959 7.70605 -14.7988 15.8926v32.1143v0.00390625c0 8.90625 7.22754 16.1338 16.1338 16.1338c0.322266 0 0.84375 -0.0185547 1.16504 -0.0419922c110.123 -8.50098 198.229 -96.6074 206.636 -206.732z" /> + + +d="M319.41 128c71.4902 -3.09961 128.59 -61.5996 128.59 -133.79c0 -32.1318 -26.0781 -58.21 -58.21 -58.21h-331.58c-32.1318 0 -58.21 26.0781 -58.21 58.21c0 72.1904 57.0996 130.69 128.59 133.79l95.4102 -95.3896zM224 144c-70.6562 0 -128 57.3438 -128 128 +v110.18c0 12.2393 9.30078 25.6611 20.7598 29.96l84.7705 31.79c5.99707 2.24902 16.0645 4.07422 22.4697 4.07422s16.4727 -1.8252 22.4697 -4.07422l84.7705 -31.75c11.459 -4.29883 20.7598 -17.7217 20.7598 -29.9609v-0.0390625v-110.18 +c0 -70.6562 -57.3438 -128 -128 -128zM184 376.33v-16.6602c0 -2.75977 2.24023 -5 5 -5h21.6699v-21.6699c0 -2.75977 2.24023 -5 5 -5h16.6602c2.75977 0 5 2.24023 5 5v21.6699h21.6699c2.75977 0 5 2.24023 5 5v16.6602c0 2.75977 -2.24023 5 -5 5h-21.6699v21.6699 +c0 2.75977 -2.24023 5 -5 5h-16.6602c-2.75977 0 -5 -2.24023 -5 -5v-21.6699h-21.6699c-2.75977 0 -5 -2.24023 -5 -5zM144 288v-16c0 -44.1602 35.8398 -80 80 -80s80 35.8398 80 80v16h-160z" /> @@ -4645,5 +4697,242 @@ c2.57324 2.60352 7.63379 4.71777 11.2949 4.71777s8.72168 -2.11426 11.2949 -4.717 d="M496 320c79.4883 0 144 -64.5117 144 -144s-64.5117 -144 -144 -144h-352c-79.4844 0.00390625 -143.993 64.5156 -143.993 144c0 79.4883 64.5117 144 144 144s144 -64.5117 144 -144c0 -24.1113 -10.8711 -59.9512 -24.2666 -80h112.52 c-13.3955 20.0488 -24.2666 55.8887 -24.2666 80c0 79.4883 64.5117 144 144 144h0.00683594zM64 176c0 -44.1602 35.8398 -80 80 -80s80 35.8398 80 80s-35.8398 80 -80 80s-80 -35.8398 -80 -80zM496 96c44.1602 0 80 35.8398 80 80s-35.8398 80 -80 80 s-80 -35.8398 -80 -80s35.8398 -80 80 -80z" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/vendor/fontawesome-free/webfonts/fa-solid-900.ttf b/static/vendor/fontawesome-free/webfonts/fa-solid-900.ttf index 704a2a99..5b979039 100644 Binary files a/static/vendor/fontawesome-free/webfonts/fa-solid-900.ttf and b/static/vendor/fontawesome-free/webfonts/fa-solid-900.ttf differ diff --git a/static/vendor/fontawesome-free/webfonts/fa-solid-900.woff b/static/vendor/fontawesome-free/webfonts/fa-solid-900.woff index 7adb9d59..beec7917 100644 Binary files a/static/vendor/fontawesome-free/webfonts/fa-solid-900.woff and b/static/vendor/fontawesome-free/webfonts/fa-solid-900.woff differ diff --git a/static/vendor/fontawesome-free/webfonts/fa-solid-900.woff2 b/static/vendor/fontawesome-free/webfonts/fa-solid-900.woff2 index ac6a6e31..978a681a 100644 Binary files a/static/vendor/fontawesome-free/webfonts/fa-solid-900.woff2 and b/static/vendor/fontawesome-free/webfonts/fa-solid-900.woff2 differ diff --git a/static/vendor/jquery/jquery.min.js b/static/vendor/jquery/jquery.min.js index a1c07fd8..b0614034 100644 --- a/static/vendor/jquery/jquery.min.js +++ b/static/vendor/jquery/jquery.min.js @@ -1,2 +1,2 @@ -/*! jQuery v3.4.1 | (c) JS Foundation and other contributors | jquery.org/license */ -!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],E=C.document,r=Object.getPrototypeOf,s=t.slice,g=t.concat,u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType},x=function(e){return null!=e&&e===e.window},c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.4.1",k=function(e,t){return new k.fn.init(e,t)},p=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g;function d(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp($),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+$),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\([\\da-f]{1,6}"+M+"?|("+M+")|.)","ig"),ne=function(e,t,n){var r="0x"+t-65536;return r!=r||n?t:r<0?String.fromCharCode(r+65536):String.fromCharCode(r>>10|55296,1023&r|56320)},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(m.childNodes),m.childNodes),t[m.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&((e?e.ownerDocument||e:m)!==C&&T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!A[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&U.test(t)){(s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=k),o=(l=h(t)).length;while(o--)l[o]="#"+s+" "+xe(l[o]);c=l.join(","),f=ee.test(t)&&ye(e.parentNode)||e}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){A(t,!0)}finally{s===k&&e.removeAttribute("id")}}}return g(t.replace(B,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[k]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e.namespaceURI,n=(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:m;return r!==C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),m!==C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=k,!C.getElementsByName||!C.getElementsByName(k).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+k+"-]").length||v.push("~="),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+k+"+*").length||v.push(".#.+[+~]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",$)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},D=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)===(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e===C||e.ownerDocument===m&&y(m,e)?-1:t===C||t.ownerDocument===m&&y(m,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e===C?-1:t===C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]===m?-1:s[r]===m?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if((e.ownerDocument||e)!==C&&T(e),d.matchesSelector&&E&&!A[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){A(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=p[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&p(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,n,r){return m(n)?k.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?k.grep(e,function(e){return e===n!==r}):"string"!=typeof n?k.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(k.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||q,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:L.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof k?t[0]:t,k.merge(this,k.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),D.test(r[1])&&k.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(k):k.makeArray(e,this)}).prototype=k.fn,q=k(E);var H=/^(?:parents|prev(?:Until|All))/,O={children:!0,contents:!0,next:!0,prev:!0};function P(e,t){while((e=e[t])&&1!==e.nodeType);return e}k.fn.extend({has:function(e){var t=k(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i,ge={option:[1,""],thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?k.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;nx",y.noCloneChecked=!!me.cloneNode(!0).lastChild.defaultValue;var Te=/^key/,Ce=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,Ee=/^([^.]*)(?:\.(.+)|)/;function ke(){return!0}function Se(){return!1}function Ne(e,t){return e===function(){try{return E.activeElement}catch(e){}}()==("focus"===t)}function Ae(e,t,n,r,i,o){var a,s;if("object"==typeof t){for(s in"string"!=typeof n&&(r=r||n,n=void 0),t)Ae(e,s,n,r,t[s],o);return e}if(null==r&&null==i?(i=n,r=n=void 0):null==i&&("string"==typeof n?(i=r,r=void 0):(i=r,r=n,n=void 0)),!1===i)i=Se;else if(!i)return e;return 1===o&&(a=i,(i=function(e){return k().off(e),a.apply(this,arguments)}).guid=a.guid||(a.guid=k.guid++)),e.each(function(){k.event.add(this,t,i,r,n)})}function De(e,i,o){o?(Q.set(e,i,!1),k.event.add(e,i,{namespace:!1,handler:function(e){var t,n,r=Q.get(this,i);if(1&e.isTrigger&&this[i]){if(r.length)(k.event.special[i]||{}).delegateType&&e.stopPropagation();else if(r=s.call(arguments),Q.set(this,i,r),t=o(this,i),this[i](),r!==(n=Q.get(this,i))||t?Q.set(this,i,!1):n={},r!==n)return e.stopImmediatePropagation(),e.preventDefault(),n.value}else r.length&&(Q.set(this,i,{value:k.event.trigger(k.extend(r[0],k.Event.prototype),r.slice(1),this)}),e.stopImmediatePropagation())}})):void 0===Q.get(e,i)&&k.event.add(e,i,ke)}k.event={global:{},add:function(t,e,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,v=Q.get(t);if(v){n.handler&&(n=(o=n).handler,i=o.selector),i&&k.find.matchesSelector(ie,i),n.guid||(n.guid=k.guid++),(u=v.events)||(u=v.events={}),(a=v.handle)||(a=v.handle=function(e){return"undefined"!=typeof k&&k.event.triggered!==e.type?k.event.dispatch.apply(t,arguments):void 0}),l=(e=(e||"").match(R)||[""]).length;while(l--)d=g=(s=Ee.exec(e[l])||[])[1],h=(s[2]||"").split(".").sort(),d&&(f=k.event.special[d]||{},d=(i?f.delegateType:f.bindType)||d,f=k.event.special[d]||{},c=k.extend({type:d,origType:g,data:r,handler:n,guid:n.guid,selector:i,needsContext:i&&k.expr.match.needsContext.test(i),namespace:h.join(".")},o),(p=u[d])||((p=u[d]=[]).delegateCount=0,f.setup&&!1!==f.setup.call(t,r,h,a)||t.addEventListener&&t.addEventListener(d,a)),f.add&&(f.add.call(t,c),c.handler.guid||(c.handler.guid=n.guid)),i?p.splice(p.delegateCount++,0,c):p.push(c),k.event.global[d]=!0)}},remove:function(e,t,n,r,i){var o,a,s,u,l,c,f,p,d,h,g,v=Q.hasData(e)&&Q.get(e);if(v&&(u=v.events)){l=(t=(t||"").match(R)||[""]).length;while(l--)if(d=g=(s=Ee.exec(t[l])||[])[1],h=(s[2]||"").split(".").sort(),d){f=k.event.special[d]||{},p=u[d=(r?f.delegateType:f.bindType)||d]||[],s=s[2]&&new RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),a=o=p.length;while(o--)c=p[o],!i&&g!==c.origType||n&&n.guid!==c.guid||s&&!s.test(c.namespace)||r&&r!==c.selector&&("**"!==r||!c.selector)||(p.splice(o,1),c.selector&&p.delegateCount--,f.remove&&f.remove.call(e,c));a&&!p.length&&(f.teardown&&!1!==f.teardown.call(e,h,v.handle)||k.removeEvent(e,d,v.handle),delete u[d])}else for(d in u)k.event.remove(e,d+t[l],n,r,!0);k.isEmptyObject(u)&&Q.remove(e,"handle events")}},dispatch:function(e){var t,n,r,i,o,a,s=k.event.fix(e),u=new Array(arguments.length),l=(Q.get(this,"events")||{})[s.type]||[],c=k.event.special[s.type]||{};for(u[0]=s,t=1;t\x20\t\r\n\f]*)[^>]*)\/>/gi,qe=/\s*$/g;function Oe(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&k(e).children("tbody")[0]||e}function Pe(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function Re(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Me(e,t){var n,r,i,o,a,s,u,l;if(1===t.nodeType){if(Q.hasData(e)&&(o=Q.access(e),a=Q.set(t,o),l=o.events))for(i in delete a.handle,a.events={},l)for(n=0,r=l[i].length;n")},clone:function(e,t,n){var r,i,o,a,s,u,l,c=e.cloneNode(!0),f=oe(e);if(!(y.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||k.isXMLDoc(e)))for(a=ve(c),r=0,i=(o=ve(e)).length;r").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var Vt,Gt=[],Yt=/(=)\?(?=&|$)|\?\?/;k.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Gt.pop()||k.expando+"_"+kt++;return this[e]=!0,e}}),k.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Yt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Yt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Yt,"$1"+r):!1!==e.jsonp&&(e.url+=(St.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||k.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?k(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Gt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((Vt=E.implementation.createHTMLDocument("").body).innerHTML="
",2===Vt.childNodes.length),k.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=D.exec(e))?[t.createElement(i[1])]:(i=we([e],t,o),o&&o.length&&k(o).remove(),k.merge([],i.childNodes)));var r,i,o},k.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(k.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},k.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){k.fn[t]=function(e){return this.on(t,e)}}),k.expr.pseudos.animated=function(t){return k.grep(k.timers,function(e){return t===e.elem}).length},k.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=k.css(e,"position"),c=k(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=k.css(e,"top"),u=k.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,k.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},k.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){k.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===k.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===k.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=k(e).offset()).top+=k.css(e,"borderTopWidth",!0),i.left+=k.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-k.css(r,"marginTop",!0),left:t.left-i.left-k.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===k.css(e,"position"))e=e.offsetParent;return e||ie})}}),k.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;k.fn[t]=function(e){return _(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),k.each(["top","left"],function(e,n){k.cssHooks[n]=ze(y.pixelPosition,function(e,t){if(t)return t=_e(e,n),$e.test(t)?k(e).position()[n]+"px":t})}),k.each({Height:"height",Width:"width"},function(a,s){k.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){k.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return _(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?k.css(e,t,i):k.style(e,t,n,i)},s,n?e:void 0,n)}})}),k.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){k.fn[n]=function(e,t){return 0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ye(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace($,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e.namespaceURI,n=(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||v.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||v.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||v.push(".#.+[+~]"),e.querySelectorAll("\\\f"),v.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},D=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&y(p,e)?-1:t==C||t.ownerDocument==p&&y(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function D(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||j,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,j=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),y.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",y.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",y.option=!!ce.lastChild;var ge={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;n",""]);var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function qe(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function Le(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function He(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Oe(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var Ut,Xt=[],Vt=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Xt.pop()||S.expando+"_"+Ct.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Vt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Vt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Vt,"$1"+r):!1!==e.jsonp&&(e.url+=(Et.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Xt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((Ut=E.implementation.createHTMLDocument("").body).innerHTML="
",2===Ut.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):("number"==typeof f.top&&(f.top+="px"),"number"==typeof f.left&&(f.left+="px"),c.css(f))}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return $(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=$e(y.pixelPosition,function(e,t){if(t)return t=Be(e,n),Me.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return $(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 0+~]|"+R+")"+R+"*"),U=new RegExp(R+"|>"),V=new RegExp(W),X=new RegExp("^"+B+"$"),Q={ID:new RegExp("^#("+B+")"),CLASS:new RegExp("^\\.("+B+")"),TAG:new RegExp("^("+B+"|[*])"),ATTR:new RegExp("^"+M),PSEUDO:new RegExp("^"+W),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+R+"*(even|odd|(([+-]|)(\\d*)n|)"+R+"*(?:([+-]|)"+R+"*(\\d+)|))"+R+"*\\)|)","i"),bool:new RegExp("^(?:"+I+")$","i"),needsContext:new RegExp("^"+R+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+R+"*((?:-\\d)?\\d*)"+R+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,G=/^(?:input|select|textarea|button)$/i,K=/^h\d$/i,J=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\([\\da-f]{1,6}"+R+"?|("+R+")|.)","ig"),ne=function(e,t,n){var r="0x"+t-65536;return r!=r||n?t:r<0?String.fromCharCode(r+65536):String.fromCharCode(r>>10|55296,1023&r|56320)},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){C()},ae=xe(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{O.apply(t=P.call(m.childNodes),m.childNodes),t[m.childNodes.length].nodeType}catch(e){O={apply:t.length?function(e,t){q.apply(e,P.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,d=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==d&&9!==d&&11!==d)return n;if(!r&&((e?e.ownerDocument||e:m)!==T&&C(e),e=e||T,E)){if(11!==d&&(u=Z.exec(t)))if(i=u[1]){if(9===d){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return O.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&p.getElementsByClassName&&e.getElementsByClassName)return O.apply(n,e.getElementsByClassName(i)),n}if(p.qsa&&!S[t+" "]&&(!v||!v.test(t))&&(1!==d||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===d&&U.test(t)){(s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=N),o=(l=h(t)).length;while(o--)l[o]="#"+s+" "+be(l[o]);c=l.join(","),f=ee.test(t)&&ye(e.parentNode)||e}try{return O.apply(n,f.querySelectorAll(c)),n}catch(e){S(t,!0)}finally{s===N&&e.removeAttribute("id")}}}return g(t.replace(F,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>x.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[N]=!0,e}function ce(e){var t=T.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)x.attrHandle[n[r]]=t}function de(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function pe(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in p=se.support={},i=se.isXML=function(e){var t=e.namespaceURI,n=(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},C=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:m;return r!==T&&9===r.nodeType&&r.documentElement&&(a=(T=r).documentElement,E=!i(T),m!==T&&(n=T.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),p.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),p.getElementsByTagName=ce(function(e){return e.appendChild(T.createComment("")),!e.getElementsByTagName("*").length}),p.getElementsByClassName=J.test(T.getElementsByClassName),p.getById=ce(function(e){return a.appendChild(e).id=N,!T.getElementsByName||!T.getElementsByName(N).length}),p.getById?(x.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},x.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(x.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},x.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),x.find.TAG=p.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):p.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},x.find.CLASS=p.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(p.qsa=J.test(T.querySelectorAll))&&(ce(function(e){a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+R+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+R+"*(?:value|"+I+")"),e.querySelectorAll("[id~="+N+"-]").length||v.push("~="),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+N+"+*").length||v.push(".#.+[+~]")}),ce(function(e){e.innerHTML="";var t=T.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+R+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(p.matchesSelector=J.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){p.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",W)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=J.test(a.compareDocumentPosition),y=t||J.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},D=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)===(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!p.sortDetached&&t.compareDocumentPosition(e)===n?e===T||e.ownerDocument===m&&y(m,e)?-1:t===T||t.ownerDocument===m&&y(m,t)?1:u?H(u,e)-H(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e===T?-1:t===T?1:i?-1:o?1:u?H(u,e)-H(u,t):0;if(i===o)return de(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?de(a[r],s[r]):a[r]===m?-1:s[r]===m?1:0}),T},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if((e.ownerDocument||e)!==T&&C(e),p.matchesSelector&&E&&!S[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||p.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){S(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return Q.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&V.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=d[e+" "];return t||(t=new RegExp("(^|"+R+")"+e+"("+R+"|$)"))&&d(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function L(e,n,r){return x(n)?E.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?E.grep(e,function(e){return e===n!==r}):"string"!=typeof n?E.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(E.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||j,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof E?t[0]:t,E.merge(this,E.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:v,!0)),D.test(r[1])&&E.isPlainObject(t))for(r in t)x(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=v.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):x(e)?void 0!==n.ready?n.ready(e):e(E):E.makeArray(e,this)}).prototype=E.fn,j=E(v);var O=/^(?:parents|prev(?:Until|All))/,P={children:!0,contents:!0,next:!0,prev:!0};function H(e,t){while((e=e[t])&&1!==e.nodeType);return e}E.fn.extend({has:function(e){var t=E(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,pe=/^$|^module$|\/(?:java|ecma)script/i,he={option:[1,""],thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ge(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&S(e,t)?E.merge([e],n):n}function ve(e,t){for(var n=0,r=e.length;nx",b.noCloneChecked=!!ye.cloneNode(!0).lastChild.defaultValue;var we=/^key/,Ce=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,Te=/^([^.]*)(?:\.(.+)|)/;function Ee(){return!0}function Ne(){return!1}function Ae(e,t){return e===function(){try{return v.activeElement}catch(e){}}()==("focus"===t)}function ke(e,t,n,r,i,o){var a,s;if("object"==typeof t){for(s in"string"!=typeof n&&(r=r||n,n=void 0),t)ke(e,s,n,r,t[s],o);return e}if(null==r&&null==i?(i=n,r=n=void 0):null==i&&("string"==typeof n?(i=r,r=void 0):(i=r,r=n,n=void 0)),!1===i)i=Ne;else if(!i)return e;return 1===o&&(a=i,(i=function(e){return E().off(e),a.apply(this,arguments)}).guid=a.guid||(a.guid=E.guid++)),e.each(function(){E.event.add(this,t,i,r,n)})}function Se(e,i,o){o?(G.set(e,i,!1),E.event.add(e,i,{namespace:!1,handler:function(e){var t,n,r=G.get(this,i);if(1&e.isTrigger&&this[i]){if(r.length)(E.event.special[i]||{}).delegateType&&e.stopPropagation();else if(r=s.call(arguments),G.set(this,i,r),t=o(this,i),this[i](),r!==(n=G.get(this,i))||t?G.set(this,i,!1):n={},r!==n)return e.stopImmediatePropagation(),e.preventDefault(),n.value}else r.length&&(G.set(this,i,{value:E.event.trigger(E.extend(r[0],E.Event.prototype),r.slice(1),this)}),e.stopImmediatePropagation())}})):void 0===G.get(e,i)&&E.event.add(e,i,Ee)}E.event={global:{},add:function(t,e,n,r,i){var o,a,s,u,l,c,f,d,p,h,g,v=G.get(t);if(v){n.handler&&(n=(o=n).handler,i=o.selector),i&&E.find.matchesSelector(ie,i),n.guid||(n.guid=E.guid++),(u=v.events)||(u=v.events={}),(a=v.handle)||(a=v.handle=function(e){return"undefined"!=typeof E&&E.event.triggered!==e.type?E.event.dispatch.apply(t,arguments):void 0}),l=(e=(e||"").match(I)||[""]).length;while(l--)p=g=(s=Te.exec(e[l])||[])[1],h=(s[2]||"").split(".").sort(),p&&(f=E.event.special[p]||{},p=(i?f.delegateType:f.bindType)||p,f=E.event.special[p]||{},c=E.extend({type:p,origType:g,data:r,handler:n,guid:n.guid,selector:i,needsContext:i&&E.expr.match.needsContext.test(i),namespace:h.join(".")},o),(d=u[p])||((d=u[p]=[]).delegateCount=0,f.setup&&!1!==f.setup.call(t,r,h,a)||t.addEventListener&&t.addEventListener(p,a)),f.add&&(f.add.call(t,c),c.handler.guid||(c.handler.guid=n.guid)),i?d.splice(d.delegateCount++,0,c):d.push(c),E.event.global[p]=!0)}},remove:function(e,t,n,r,i){var o,a,s,u,l,c,f,d,p,h,g,v=G.hasData(e)&&G.get(e);if(v&&(u=v.events)){l=(t=(t||"").match(I)||[""]).length;while(l--)if(p=g=(s=Te.exec(t[l])||[])[1],h=(s[2]||"").split(".").sort(),p){f=E.event.special[p]||{},d=u[p=(r?f.delegateType:f.bindType)||p]||[],s=s[2]&&new RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),a=o=d.length;while(o--)c=d[o],!i&&g!==c.origType||n&&n.guid!==c.guid||s&&!s.test(c.namespace)||r&&r!==c.selector&&("**"!==r||!c.selector)||(d.splice(o,1),c.selector&&d.delegateCount--,f.remove&&f.remove.call(e,c));a&&!d.length&&(f.teardown&&!1!==f.teardown.call(e,h,v.handle)||E.removeEvent(e,p,v.handle),delete u[p])}else for(p in u)E.event.remove(e,p+t[l],n,r,!0);E.isEmptyObject(u)&&G.remove(e,"handle events")}},dispatch:function(e){var t,n,r,i,o,a,s=E.event.fix(e),u=new Array(arguments.length),l=(G.get(this,"events")||{})[s.type]||[],c=E.event.special[s.type]||{};for(u[0]=s,t=1;t\x20\t\r\n\f]*)[^>]*)\/>/gi,Le=/\s*$/g;function Oe(e,t){return S(e,"table")&&S(11!==t.nodeType?t:t.firstChild,"tr")&&E(e).children("tbody")[0]||e}function Pe(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function He(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Ie(e,t){var n,r,i,o,a,s,u,l;if(1===t.nodeType){if(G.hasData(e)&&(o=G.access(e),a=G.set(t,o),l=o.events))for(i in delete a.handle,a.events={},l)for(n=0,r=l[i].length;n")},clone:function(e,t,n){var r,i,o,a,s,u,l,c=e.cloneNode(!0),f=oe(e);if(!(b.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||E.isXMLDoc(e)))for(a=ge(c),r=0,i=(o=ge(e)).length;r
",2===pt.childNodes.length),E.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(b.createHTMLDocument?((r=(t=v.implementation.createHTMLDocument("")).createElement("base")).href=v.location.href,t.head.appendChild(r)):t=v),o=!n&&[],(i=D.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&E(o).remove(),E.merge([],i.childNodes)));var r,i,o},E.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=E.css(e,"position"),c=E(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=E.css(e,"top"),u=E.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),x(t)&&(t=t.call(e,n,E.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},E.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){E.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===E.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===E.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=E(e).offset()).top+=E.css(e,"borderTopWidth",!0),i.left+=E.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-E.css(r,"marginTop",!0),left:t.left-i.left-E.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===E.css(e,"position"))e=e.offsetParent;return e||ie})}}),E.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;E.fn[t]=function(e){return z(this,function(e,t,n){var r;if(w(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),E.each(["top","left"],function(e,n){E.cssHooks[n]=ze(b.pixelPosition,function(e,t){if(t)return t=Fe(e,n),Me.test(t)?E(e).position()[n]+"px":t})}),E.each({Height:"height",Width:"width"},function(a,s){E.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){E.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return z(this,function(e,t,n){var r;return w(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?E.css(e,t,i):E.style(e,t,n,i)},s,n?e:void 0,n)}})}),E.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){E.fn[n]=function(e,t){return 0{{.UsersTitle}} + +