dataprovider: add support for user status and expiration
an user can now be disabled or expired. If you are using an SQL database as dataprovider please remember to execute the sql update script inside "sql" folder. Fixes #57
This commit is contained in:
parent
363b9ccc7f
commit
c2ff50c917
35 changed files with 1101 additions and 88 deletions
|
@ -12,7 +12,7 @@ env:
|
||||||
- GO111MODULE=on
|
- GO111MODULE=on
|
||||||
|
|
||||||
before_script:
|
before_script:
|
||||||
- sqlite3 sftpgo.db 'CREATE TABLE "users" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "username" varchar(255) NOT NULL UNIQUE, "password" varchar(255) 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, "upload_bandwidth" integer NOT NULL, "download_bandwidth" integer NOT NULL);'
|
- sqlite3 sftpgo.db 'CREATE TABLE "users" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "username" varchar(255) NOT NULL UNIQUE, "password" varchar(255) 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, "upload_bandwidth" integer NOT NULL, "download_bandwidth" integer NOT NULL, "expiration_date" bigint NOT NULL, "last_login" bigint NOT NULL, "status" integer NOT NULL);'
|
||||||
|
|
||||||
install:
|
install:
|
||||||
- go get -v -t ./...
|
- go get -v -t ./...
|
||||||
|
|
16
README.md
16
README.md
|
@ -119,7 +119,7 @@ If you don't configure any private host keys, the daemon will use `id_rsa` in th
|
||||||
|
|
||||||
Before starting `sftpgo` a dataprovider must be configured.
|
Before starting `sftpgo` a dataprovider must be configured.
|
||||||
|
|
||||||
Sample SQL scripts to create the required database structure can be found inside the source tree [sql](./sql "sql") directory. The SQL scripts filename's is, by convention, the date as `YYYYMMDD` and the suffix `.sql`. You need to apply all the SQL scripts for your database ordered by name, for example `20190706.sql` must be applied before `20190728.sql` and so on.
|
Sample SQL scripts to create the required database structure can be found inside the source tree [sql](./sql "sql") directory. The SQL scripts filename's is, by convention, the date as `YYYYMMDD` and the suffix `.sql`. You need to apply all the SQL scripts for your database ordered by name, for example `20190828.sql` must be applied before `20191112.sql` and so on.
|
||||||
|
|
||||||
The `sftpgo` configuration file contains the following sections:
|
The `sftpgo` configuration file contains the following sections:
|
||||||
|
|
||||||
|
@ -329,11 +329,13 @@ For each account the following properties can be configured:
|
||||||
- `username`
|
- `username`
|
||||||
- `password` used for password authentication. For users created using SFTPGo REST API if the password has no known hashing algo prefix it will be stored using argon2id. SFTPGo supports checking passwords stored with bcrypt, pbkdf2 and sha512crypt too. For pbkdf2 the supported format is `$<algo>$<iterations>$<salt>$<hashed pwd base64 encoded>`, where algo is `pbkdf2-sha1` or `pbkdf2-sha256` or `pbkdf2-sha512`. For example the `pbkdf2-sha256` of the word `password` using 150000 iterations and `E86a9YMX3zC7` as salt must be stored as `$pbkdf2-sha256$150000$E86a9YMX3zC7$R5J62hsSq+pYw00hLLPKBbcGXmq7fj5+/M0IFoYtZbo=`. For bcrypt the format must be the one supported by golang's [crypto/bcrypt](https://godoc.org/golang.org/x/crypto/bcrypt) package, for example the password `secret` with cost `14` must be stored as `$2a$14$ajq8Q7fbtFRQvXpdCq7Jcuy.Rx1h/L4J60Otx.gyNLbAYctGMJ9tK`. For sha512crypt we support the format used in `/etc/shadow` with the `$6$` prefix, this is useful if you are migrating from Unix system user accounts. Using the REST API you can send a password hashed as bcrypt, pbkdf2 or sha512crypt and it will be stored as is.
|
- `password` used for password authentication. For users created using SFTPGo REST API if the password has no known hashing algo prefix it will be stored using argon2id. SFTPGo supports checking passwords stored with bcrypt, pbkdf2 and sha512crypt too. For pbkdf2 the supported format is `$<algo>$<iterations>$<salt>$<hashed pwd base64 encoded>`, where algo is `pbkdf2-sha1` or `pbkdf2-sha256` or `pbkdf2-sha512`. For example the `pbkdf2-sha256` of the word `password` using 150000 iterations and `E86a9YMX3zC7` as salt must be stored as `$pbkdf2-sha256$150000$E86a9YMX3zC7$R5J62hsSq+pYw00hLLPKBbcGXmq7fj5+/M0IFoYtZbo=`. For bcrypt the format must be the one supported by golang's [crypto/bcrypt](https://godoc.org/golang.org/x/crypto/bcrypt) package, for example the password `secret` with cost `14` must be stored as `$2a$14$ajq8Q7fbtFRQvXpdCq7Jcuy.Rx1h/L4J60Otx.gyNLbAYctGMJ9tK`. For sha512crypt we support the format used in `/etc/shadow` with the `$6$` prefix, this is useful if you are migrating from Unix system user accounts. Using the REST API you can send a password hashed as bcrypt, pbkdf2 or sha512crypt and it will be stored as is.
|
||||||
- `public_keys` array of public keys. At least one public key or the password is mandatory.
|
- `public_keys` array of public keys. At least one public key or the password is mandatory.
|
||||||
- `home_dir` The user cannot upload or download files outside this directory. Must be an absolute path
|
- `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.
|
||||||
- `uid`, `gid`. If sftpgo runs as root system user then the created files and directories will be assigned to this system uid/gid. Ignored on windows and if sftpgo runs as non root user: in this case files and directories for all SFTP users will be owned by the system user that runs sftpgo.
|
- `uid`, `gid`. If sftpgo runs as root system user then the created files and directories will be assigned to this system uid/gid. Ignored on windows and if sftpgo runs as non root user: in this case files and directories for all SFTP users will be owned by the system user that runs sftpgo.
|
||||||
- `max_sessions` maximum concurrent sessions. 0 means unlimited
|
- `max_sessions` maximum concurrent sessions. 0 means unlimited.
|
||||||
- `quota_size` maximum size allowed as bytes. 0 means unlimited
|
- `quota_size` maximum size allowed as bytes. 0 means unlimited.
|
||||||
- `quota_files` maximum number of files allowed. 0 means unlimited
|
- `quota_files` maximum number of files allowed. 0 means unlimited.
|
||||||
- `permissions` the following permissions are supported:
|
- `permissions` the following permissions are supported:
|
||||||
- `*` all permissions are granted
|
- `*` all permissions are granted
|
||||||
- `list` list items is allowed
|
- `list` list items is allowed
|
||||||
|
@ -344,8 +346,8 @@ For each account the following properties can be configured:
|
||||||
- `rename` rename files or directories is allowed
|
- `rename` rename files or directories is allowed
|
||||||
- `create_dirs` create directories is allowed
|
- `create_dirs` create directories is allowed
|
||||||
- `create_symlinks` create symbolic links is allowed
|
- `create_symlinks` create symbolic links is allowed
|
||||||
- `upload_bandwidth` maximum upload bandwidth as KB/s, 0 means unlimited
|
- `upload_bandwidth` maximum upload bandwidth as KB/s, 0 means unlimited.
|
||||||
- `download_bandwidth` maximum download bandwidth as KB/s, 0 means unlimited
|
- `download_bandwidth` maximum download bandwidth as KB/s, 0 means unlimited.
|
||||||
|
|
||||||
These properties are stored inside the data provider. If you want to use your existing accounts, you can create a database view. Since a view is read only, you have to disable user management and quota tracking so SFTPGo will never try to write to the view.
|
These properties are stored inside the data provider. If you want to use your existing accounts, you can create a database view. Since a view is read only, you have to disable user management and quota tracking so SFTPGo will never try to write to the view.
|
||||||
|
|
||||||
|
|
|
@ -49,6 +49,7 @@ Please take a look at the usage below to customize the serving parameters`,
|
||||||
PublicKeys: portablePublicKeys,
|
PublicKeys: portablePublicKeys,
|
||||||
Permissions: portablePermissions,
|
Permissions: portablePermissions,
|
||||||
HomeDir: portableDir,
|
HomeDir: portableDir,
|
||||||
|
Status: 1,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if err := service.StartPortableMode(portableSFTPDPort, portableEnableSCP, portableAdvertiseService,
|
if err := service.StartPortableMode(portableSFTPDPort, portableEnableSCP, portableAdvertiseService,
|
||||||
|
|
|
@ -13,9 +13,15 @@ import (
|
||||||
bolt "go.etcd.io/bbolt"
|
bolt "go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
databaseVersion = 2
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
usersBucket = []byte("users")
|
usersBucket = []byte("users")
|
||||||
usersIDIdxBucket = []byte("users_id_idx")
|
usersIDIdxBucket = []byte("users_id_idx")
|
||||||
|
dbVersionBucket = []byte("db_version")
|
||||||
|
dbVersionKey = []byte("version")
|
||||||
)
|
)
|
||||||
|
|
||||||
// BoltProvider auth provider for bolt key/value store
|
// BoltProvider auth provider for bolt key/value store
|
||||||
|
@ -23,6 +29,10 @@ type BoltProvider struct {
|
||||||
dbHandle *bolt.DB
|
dbHandle *bolt.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type boltDatabaseVersion struct {
|
||||||
|
Version int
|
||||||
|
}
|
||||||
|
|
||||||
func initializeBoltProvider(basePath string) error {
|
func initializeBoltProvider(basePath string) error {
|
||||||
var err error
|
var err error
|
||||||
logSender = BoltDataProviderName
|
logSender = BoltDataProviderName
|
||||||
|
@ -52,7 +62,16 @@ func initializeBoltProvider(basePath string) error {
|
||||||
providerLog(logger.LevelWarn, "error creating username idx bucket: %v", err)
|
providerLog(logger.LevelWarn, "error creating username idx bucket: %v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
err = dbHandle.Update(func(tx *bolt.Tx) error {
|
||||||
|
_, e := tx.CreateBucketIfNotExists(dbVersionBucket)
|
||||||
|
return e
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
providerLog(logger.LevelWarn, "error creating database version bucket: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
provider = BoltProvider{dbHandle: dbHandle}
|
provider = BoltProvider{dbHandle: dbHandle}
|
||||||
|
err = checkBoltDatabaseVersion(dbHandle)
|
||||||
} else {
|
} else {
|
||||||
providerLog(logger.LevelWarn, "error creating bolt key/value store handler: %v", err)
|
providerLog(logger.LevelWarn, "error creating bolt key/value store handler: %v", err)
|
||||||
}
|
}
|
||||||
|
@ -104,7 +123,7 @@ func (p BoltProvider) getUserByID(ID int64) (User, error) {
|
||||||
}
|
}
|
||||||
u := bucket.Get(username)
|
u := bucket.Get(username)
|
||||||
if u == nil {
|
if u == nil {
|
||||||
return &RecordNotFoundError{err: fmt.Sprintf("username %v and ID: %v does not exist", string(username), ID)}
|
return &RecordNotFoundError{err: fmt.Sprintf("username %#v and ID: %v does not exist", string(username), ID)}
|
||||||
}
|
}
|
||||||
return json.Unmarshal(u, &user)
|
return json.Unmarshal(u, &user)
|
||||||
})
|
})
|
||||||
|
@ -112,6 +131,30 @@ func (p BoltProvider) getUserByID(ID int64) (User, error) {
|
||||||
return user, err
|
return user, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p BoltProvider) updateLastLogin(username string) error {
|
||||||
|
return p.dbHandle.Update(func(tx *bolt.Tx) error {
|
||||||
|
bucket, _, err := getBuckets(tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var u []byte
|
||||||
|
if u = bucket.Get([]byte(username)); u == nil {
|
||||||
|
return &RecordNotFoundError{err: fmt.Sprintf("username %#v does not exist, unable to update last login", username)}
|
||||||
|
}
|
||||||
|
var user User
|
||||||
|
err = json.Unmarshal(u, &user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user.LastLogin = utils.GetTimeAsMsSinceEpoch(time.Now())
|
||||||
|
buf, err := json.Marshal(user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return bucket.Put([]byte(username), buf)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (p BoltProvider) updateQuota(username string, filesAdd int, sizeAdd int64, reset bool) error {
|
func (p BoltProvider) updateQuota(username string, filesAdd int, sizeAdd int64, reset bool) error {
|
||||||
return p.dbHandle.Update(func(tx *bolt.Tx) error {
|
return p.dbHandle.Update(func(tx *bolt.Tx) error {
|
||||||
bucket, _, err := getBuckets(tx)
|
bucket, _, err := getBuckets(tx)
|
||||||
|
@ -120,7 +163,7 @@ func (p BoltProvider) updateQuota(username string, filesAdd int, sizeAdd int64,
|
||||||
}
|
}
|
||||||
var u []byte
|
var u []byte
|
||||||
if u = bucket.Get([]byte(username)); u == nil {
|
if u = bucket.Get([]byte(username)); u == nil {
|
||||||
return &RecordNotFoundError{err: fmt.Sprintf("username %v does not exist, unable to update quota", username)}
|
return &RecordNotFoundError{err: fmt.Sprintf("username %#v does not exist, unable to update quota", username)}
|
||||||
}
|
}
|
||||||
var user User
|
var user User
|
||||||
err = json.Unmarshal(u, &user)
|
err = json.Unmarshal(u, &user)
|
||||||
|
@ -322,3 +365,90 @@ func getBuckets(tx *bolt.Tx) (*bolt.Bucket, *bolt.Bucket, error) {
|
||||||
}
|
}
|
||||||
return bucket, idxBucket, err
|
return bucket, idxBucket, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkBoltDatabaseVersion(dbHandle *bolt.DB) error {
|
||||||
|
dbVersion, err := getBoltDatabaseVersion(dbHandle)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if dbVersion.Version == databaseVersion {
|
||||||
|
providerLog(logger.LevelDebug, "bolt database updated, version: %v", dbVersion.Version)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if dbVersion.Version == 1 {
|
||||||
|
providerLog(logger.LevelInfo, "update bolt database version: 1 -> 2")
|
||||||
|
usernames, err := getBoltAvailableUsernames(dbHandle)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, u := range usernames {
|
||||||
|
user, err := provider.userExists(u)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user.Status = 1
|
||||||
|
err = provider.updateUser(user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
providerLog(logger.LevelInfo, "user %#v updated, \"status\" setted to 1", user.Username)
|
||||||
|
}
|
||||||
|
return updateBoltDatabaseVersion(dbHandle, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBoltAvailableUsernames(dbHandle *bolt.DB) ([]string, error) {
|
||||||
|
usernames := []string{}
|
||||||
|
err := dbHandle.View(func(tx *bolt.Tx) error {
|
||||||
|
_, idxBucket, err := getBuckets(tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cursor := idxBucket.Cursor()
|
||||||
|
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||||
|
usernames = append(usernames, string(v))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return usernames, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBoltDatabaseVersion(dbHandle *bolt.DB) (boltDatabaseVersion, error) {
|
||||||
|
var dbVersion boltDatabaseVersion
|
||||||
|
err := dbHandle.View(func(tx *bolt.Tx) error {
|
||||||
|
bucket := tx.Bucket(dbVersionBucket)
|
||||||
|
if bucket == nil {
|
||||||
|
return fmt.Errorf("unable to find database version bucket")
|
||||||
|
}
|
||||||
|
v := bucket.Get(dbVersionKey)
|
||||||
|
if v == nil {
|
||||||
|
dbVersion = boltDatabaseVersion{
|
||||||
|
Version: 1,
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.Unmarshal(v, &dbVersion)
|
||||||
|
})
|
||||||
|
return dbVersion, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateBoltDatabaseVersion(dbHandle *bolt.DB, version int) error {
|
||||||
|
err := dbHandle.Update(func(tx *bolt.Tx) error {
|
||||||
|
bucket := tx.Bucket(dbVersionBucket)
|
||||||
|
if bucket == nil {
|
||||||
|
return fmt.Errorf("unable to find database version bucket")
|
||||||
|
}
|
||||||
|
newDbVersion := boltDatabaseVersion{
|
||||||
|
Version: version,
|
||||||
|
}
|
||||||
|
buf, err := json.Marshal(newDbVersion)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return bucket.Put(dbVersionKey, buf)
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
|
@ -160,6 +160,7 @@ type Provider interface {
|
||||||
deleteUser(user User) error
|
deleteUser(user User) error
|
||||||
getUsers(limit int, offset int, order string, username string) ([]User, error)
|
getUsers(limit int, offset int, order string, username string) ([]User, error)
|
||||||
getUserByID(ID int64) (User, error)
|
getUserByID(ID int64) (User, error)
|
||||||
|
updateLastLogin(username string) error
|
||||||
checkAvailability() error
|
checkAvailability() error
|
||||||
close() error
|
close() error
|
||||||
}
|
}
|
||||||
|
@ -203,6 +204,14 @@ func CheckUserAndPubKey(p Provider, username string, pubKey string) (User, strin
|
||||||
return p.validateUserAndPubKey(username, pubKey)
|
return p.validateUserAndPubKey(username, pubKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateLastLogin updates the last login fields for the given SFTP user
|
||||||
|
func UpdateLastLogin(p Provider, user User) error {
|
||||||
|
if config.ManageUsers == 0 {
|
||||||
|
return &MethodDisabledError{err: manageUsersDisabledError}
|
||||||
|
}
|
||||||
|
return p.updateLastLogin(user.Username)
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateUserQuota updates the quota for the given SFTP user adding filesAdd and sizeAdd.
|
// UpdateUserQuota updates the quota for the given SFTP user adding filesAdd and sizeAdd.
|
||||||
// If reset is true filesAdd and sizeAdd indicates the total files and the total size instead of the difference.
|
// If reset is true filesAdd and sizeAdd indicates the total files and the total size instead of the difference.
|
||||||
func UpdateUserQuota(p Provider, user User, filesAdd int, sizeAdd int64, reset bool) error {
|
func UpdateUserQuota(p Provider, user User, filesAdd int, sizeAdd int64, reset bool) error {
|
||||||
|
@ -211,6 +220,9 @@ func UpdateUserQuota(p Provider, user User, filesAdd int, sizeAdd int64, reset b
|
||||||
} else if config.TrackQuota == 2 && !reset && !user.HasQuotaRestrictions() {
|
} else if config.TrackQuota == 2 && !reset && !user.HasQuotaRestrictions() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if config.ManageUsers == 0 {
|
||||||
|
return &MethodDisabledError{err: manageUsersDisabledError}
|
||||||
|
}
|
||||||
return p.updateQuota(user.Username, filesAdd, sizeAdd, reset)
|
return p.updateQuota(user.Username, filesAdd, sizeAdd, reset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -311,6 +323,9 @@ func validateUser(user *User) error {
|
||||||
if err := validatePermissions(user); err != nil {
|
if err := validatePermissions(user); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if user.Status < 0 || user.Status > 1 {
|
||||||
|
return &ValidationError{err: fmt.Sprintf("invalid user status: %v", user.Status)}
|
||||||
|
}
|
||||||
if len(user.Password) > 0 && !utils.IsStringPrefixInSlice(user.Password, hashPwdPrefixes) {
|
if len(user.Password) > 0 && !utils.IsStringPrefixInSlice(user.Password, hashPwdPrefixes) {
|
||||||
pwd, err := argon2id.CreateHash(user.Password, argon2id.DefaultParams)
|
pwd, err := argon2id.CreateHash(user.Password, argon2id.DefaultParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -327,8 +342,22 @@ func validateUser(user *User) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func checkLoginConditions(user User) error {
|
||||||
|
if user.Status < 1 {
|
||||||
|
return fmt.Errorf("user %#v is disabled", user.Username)
|
||||||
|
}
|
||||||
|
if user.ExpirationDate > 0 && user.ExpirationDate < utils.GetTimeAsMsSinceEpoch(time.Now()) {
|
||||||
|
return fmt.Errorf("user %#v is expired, expiration timestamp: %v current timestamp: %v", user.Username,
|
||||||
|
user.ExpirationDate, utils.GetTimeAsMsSinceEpoch(time.Now()))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func checkUserAndPass(user User, password string) (User, error) {
|
func checkUserAndPass(user User, password string) (User, error) {
|
||||||
var err error
|
err := checkLoginConditions(user)
|
||||||
|
if err != nil {
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
if len(user.Password) == 0 {
|
if len(user.Password) == 0 {
|
||||||
return user, errors.New("Credentials cannot be null or empty")
|
return user, errors.New("Credentials cannot be null or empty")
|
||||||
}
|
}
|
||||||
|
@ -372,6 +401,10 @@ func checkUserAndPass(user User, password string) (User, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkUserAndPubKey(user User, pubKey string) (User, string, error) {
|
func checkUserAndPubKey(user User, pubKey string) (User, string, error) {
|
||||||
|
err := checkLoginConditions(user)
|
||||||
|
if err != nil {
|
||||||
|
return user, "", err
|
||||||
|
}
|
||||||
if len(user.PublicKeys) == 0 {
|
if len(user.PublicKeys) == 0 {
|
||||||
return user, "", errors.New("Invalid credentials")
|
return user, "", errors.New("Invalid credentials")
|
||||||
}
|
}
|
||||||
|
|
|
@ -101,6 +101,21 @@ func (p MemoryProvider) getUserByID(ID int64) (User, error) {
|
||||||
return User{}, &RecordNotFoundError{err: fmt.Sprintf("user with ID %v does not exist", ID)}
|
return User{}, &RecordNotFoundError{err: fmt.Sprintf("user with ID %v does not exist", ID)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p MemoryProvider) updateLastLogin(username string) error {
|
||||||
|
p.dbHandle.lock.Lock()
|
||||||
|
defer p.dbHandle.lock.Unlock()
|
||||||
|
if p.dbHandle.isClosed {
|
||||||
|
return errMemoryProviderClosed
|
||||||
|
}
|
||||||
|
user, err := p.userExistsInternal(username)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
user.LastLogin = utils.GetTimeAsMsSinceEpoch(time.Now())
|
||||||
|
p.dbHandle.users[user.Username] = user
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p MemoryProvider) updateQuota(username string, filesAdd int, sizeAdd int64, reset bool) error {
|
func (p MemoryProvider) updateQuota(username string, filesAdd int, sizeAdd int64, reset bool) error {
|
||||||
p.dbHandle.lock.Lock()
|
p.dbHandle.lock.Lock()
|
||||||
defer p.dbHandle.lock.Unlock()
|
defer p.dbHandle.lock.Unlock()
|
||||||
|
|
|
@ -64,6 +64,10 @@ func (p MySQLProvider) updateQuota(username string, filesAdd int, sizeAdd int64,
|
||||||
return sqlCommonUpdateQuota(username, filesAdd, sizeAdd, reset, p.dbHandle)
|
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) {
|
func (p MySQLProvider) getUsedQuota(username string) (int, int64, error) {
|
||||||
return sqlCommonGetUsedQuota(username, p.dbHandle)
|
return sqlCommonGetUsedQuota(username, p.dbHandle)
|
||||||
}
|
}
|
||||||
|
|
|
@ -63,6 +63,10 @@ func (p PGSQLProvider) updateQuota(username string, filesAdd int, sizeAdd int64,
|
||||||
return sqlCommonUpdateQuota(username, filesAdd, sizeAdd, reset, p.dbHandle)
|
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) {
|
func (p PGSQLProvider) getUsedQuota(username string) (int, int64, error) {
|
||||||
return sqlCommonGetUsedQuota(username, p.dbHandle)
|
return sqlCommonGetUsedQuota(username, p.dbHandle)
|
||||||
}
|
}
|
||||||
|
|
|
@ -81,10 +81,27 @@ func sqlCommonUpdateQuota(username string, filesAdd int, sizeAdd int64, reset bo
|
||||||
defer stmt.Close()
|
defer stmt.Close()
|
||||||
_, err = stmt.Exec(sizeAdd, filesAdd, utils.GetTimeAsMsSinceEpoch(time.Now()), username)
|
_, err = stmt.Exec(sizeAdd, filesAdd, utils.GetTimeAsMsSinceEpoch(time.Now()), username)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
providerLog(logger.LevelDebug, "quota updated for user %v, files increment: %v size increment: %v is reset? %v",
|
providerLog(logger.LevelDebug, "quota updated for user %#v, files increment: %v size increment: %v is reset? %v",
|
||||||
username, filesAdd, sizeAdd, reset)
|
username, filesAdd, sizeAdd, reset)
|
||||||
} else {
|
} else {
|
||||||
providerLog(logger.LevelWarn, "error updating quota for username %v: %v", username, err)
|
providerLog(logger.LevelWarn, "error updating quota for user %#v: %v", username, err)
|
||||||
|
}
|
||||||
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
@ -142,7 +159,7 @@ func sqlCommonAddUser(user User, dbHandle *sql.DB) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = stmt.Exec(user.Username, user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions, user.QuotaSize,
|
_, 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.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -167,7 +184,7 @@ func sqlCommonUpdateUser(user User, dbHandle *sql.DB) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = stmt.Exec(user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions, user.QuotaSize,
|
_, 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.ID)
|
user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate, user.ID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -224,12 +241,12 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) {
|
||||||
if row != nil {
|
if row != nil {
|
||||||
err = row.Scan(&user.ID, &user.Username, &password, &publicKey, &user.HomeDir, &user.UID, &user.GID, &user.MaxSessions,
|
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.QuotaSize, &user.QuotaFiles, &permissions, &user.UsedQuotaSize, &user.UsedQuotaFiles, &user.LastQuotaUpdate,
|
||||||
&user.UploadBandwidth, &user.DownloadBandwidth)
|
&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status)
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
err = rows.Scan(&user.ID, &user.Username, &password, &publicKey, &user.HomeDir, &user.UID, &user.GID, &user.MaxSessions,
|
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.QuotaSize, &user.QuotaFiles, &permissions, &user.UsedQuotaSize, &user.UsedQuotaFiles, &user.LastQuotaUpdate,
|
||||||
&user.UploadBandwidth, &user.DownloadBandwidth)
|
&user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
|
|
|
@ -70,6 +70,10 @@ func (p SQLiteProvider) updateQuota(username string, filesAdd int, sizeAdd int64
|
||||||
return sqlCommonUpdateQuota(username, filesAdd, sizeAdd, reset, p.dbHandle)
|
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) {
|
func (p SQLiteProvider) getUsedQuota(username string) (int, int64, error) {
|
||||||
return sqlCommonGetUsedQuota(username, p.dbHandle)
|
return sqlCommonGetUsedQuota(username, p.dbHandle)
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ import "fmt"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
selectUserFields = "id,username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions," +
|
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"
|
"used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,expiration_date,last_login,status"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getSQLPlaceholders() []string {
|
func getSQLPlaceholders() []string {
|
||||||
|
@ -45,6 +45,10 @@ func getUpdateQuotaQuery(reset bool) string {
|
||||||
WHERE username = %v`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3])
|
WHERE username = %v`, config.UsersTable, 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])
|
||||||
|
}
|
||||||
|
|
||||||
func getQuotaQuery() string {
|
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`, config.UsersTable,
|
||||||
sqlPlaceholders[0])
|
sqlPlaceholders[0])
|
||||||
|
@ -52,17 +56,18 @@ func getQuotaQuery() string {
|
||||||
|
|
||||||
func getAddUserQuery() string {
|
func getAddUserQuery() string {
|
||||||
return fmt.Sprintf(`INSERT INTO %v (username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions,
|
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)
|
used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,status,last_login,expiration_date)
|
||||||
VALUES (%v,%v,%v,%v,%v,%v,%v,%v,%v,%v,0,0,0,%v,%v)`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1],
|
VALUES (%v,%v,%v,%v,%v,%v,%v,%v,%v,%v,0,0,0,%v,%v,%v,0,%v)`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1],
|
||||||
sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7],
|
sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7],
|
||||||
sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10], sqlPlaceholders[11])
|
sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13])
|
||||||
}
|
}
|
||||||
|
|
||||||
func getUpdateUserQuery() string {
|
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,
|
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 WHERE id = %v`, config.UsersTable,
|
quota_files=%v,permissions=%v,upload_bandwidth=%v,download_bandwidth=%v,status=%v,expiration_date=%v WHERE id = %v`, config.UsersTable,
|
||||||
sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5],
|
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[6], sqlPlaceholders[7], sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10], sqlPlaceholders[11],
|
||||||
|
sqlPlaceholders[12], sqlPlaceholders[13])
|
||||||
}
|
}
|
||||||
|
|
||||||
func getDeleteUserQuery() string {
|
func getDeleteUserQuery() string {
|
||||||
|
|
|
@ -36,13 +36,16 @@ const (
|
||||||
type User struct {
|
type User struct {
|
||||||
// Database unique identifier
|
// Database unique identifier
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
// 1 enabled, 0 disabled (login is not allowed)
|
||||||
|
Status int `json:"status"`
|
||||||
// Username
|
// Username
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
|
// Account expiration date as unix timestamp in milliseconds. An expired account cannot login.
|
||||||
|
// 0 means no expiration
|
||||||
|
ExpirationDate int64 `json:"expiration_date"`
|
||||||
// Password used for password authentication.
|
// Password used for password authentication.
|
||||||
// For users created using SFTPGo REST API the password is be stored using argon2id hashing algo.
|
// For users created using SFTPGo REST API the password is be stored using argon2id hashing algo.
|
||||||
// Checking passwords stored with bcrypt is supported too.
|
// Checking passwords stored with bcrypt, pbkdf2 and sha512crypt is supported too.
|
||||||
// Currently, as fallback, there is a clear text password checking but you should not store passwords
|
|
||||||
// as clear text and this support could be removed at any time, so please don't depend on it.
|
|
||||||
Password string `json:"password,omitempty"`
|
Password string `json:"password,omitempty"`
|
||||||
// PublicKeys used for public key authentication. At least one between password and a public key is mandatory
|
// PublicKeys used for public key authentication. At least one between password and a public key is mandatory
|
||||||
PublicKeys []string `json:"public_keys,omitempty"`
|
PublicKeys []string `json:"public_keys,omitempty"`
|
||||||
|
@ -70,6 +73,8 @@ type User struct {
|
||||||
UploadBandwidth int64 `json:"upload_bandwidth"`
|
UploadBandwidth int64 `json:"upload_bandwidth"`
|
||||||
// Maximum download bandwidth as KB/s, 0 means unlimited
|
// Maximum download bandwidth as KB/s, 0 means unlimited
|
||||||
DownloadBandwidth int64 `json:"download_bandwidth"`
|
DownloadBandwidth int64 `json:"download_bandwidth"`
|
||||||
|
// Last login as unix timestamp in milliseconds
|
||||||
|
LastLogin int64 `json:"last_login"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasPerm returns true if the user has the given permission or any permission
|
// HasPerm returns true if the user has the given permission or any permission
|
||||||
|
@ -175,6 +180,10 @@ func (u *User) GetBandwidthAsString() string {
|
||||||
// Number of public keys, max sessions, uid and gid are returned
|
// Number of public keys, max sessions, uid and gid are returned
|
||||||
func (u *User) GetInfoString() string {
|
func (u *User) GetInfoString() string {
|
||||||
var result string
|
var result string
|
||||||
|
if u.LastLogin > 0 {
|
||||||
|
t := utils.GetTimeFromMsecSinceEpoch(u.LastLogin)
|
||||||
|
result += fmt.Sprintf("Last login: %v ", t.Format("2006-01-02 15:04:05")) // YYYY-MM-DD HH:MM:SS
|
||||||
|
}
|
||||||
if len(u.PublicKeys) > 0 {
|
if len(u.PublicKeys) > 0 {
|
||||||
result += fmt.Sprintf("Public keys: %v ", len(u.PublicKeys))
|
result += fmt.Sprintf("Public keys: %v ", len(u.PublicKeys))
|
||||||
}
|
}
|
||||||
|
@ -190,6 +199,15 @@ func (u *User) GetInfoString() string {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetExpirationDateAsString returns expiration date formatted as YYYY-MM-DD
|
||||||
|
func (u *User) GetExpirationDateAsString() string {
|
||||||
|
if u.ExpirationDate > 0 {
|
||||||
|
t := utils.GetTimeFromMsecSinceEpoch(u.ExpirationDate)
|
||||||
|
return t.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (u *User) getACopy() User {
|
func (u *User) getACopy() User {
|
||||||
pubKeys := make([]string, len(u.PublicKeys))
|
pubKeys := make([]string, len(u.PublicKeys))
|
||||||
copy(pubKeys, u.PublicKeys)
|
copy(pubKeys, u.PublicKeys)
|
||||||
|
@ -212,5 +230,8 @@ func (u *User) getACopy() User {
|
||||||
LastQuotaUpdate: u.LastQuotaUpdate,
|
LastQuotaUpdate: u.LastQuotaUpdate,
|
||||||
UploadBandwidth: u.UploadBandwidth,
|
UploadBandwidth: u.UploadBandwidth,
|
||||||
DownloadBandwidth: u.DownloadBandwidth,
|
DownloadBandwidth: u.DownloadBandwidth,
|
||||||
|
Status: u.Status,
|
||||||
|
ExpirationDate: u.ExpirationDate,
|
||||||
|
LastLogin: u.LastLogin,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -350,5 +350,11 @@ func compareEqualsUserFields(expected dataprovider.User, actual dataprovider.Use
|
||||||
if expected.DownloadBandwidth != actual.DownloadBandwidth {
|
if expected.DownloadBandwidth != actual.DownloadBandwidth {
|
||||||
return errors.New("DownloadBandwidth mismatch")
|
return errors.New("DownloadBandwidth mismatch")
|
||||||
}
|
}
|
||||||
|
if expected.Status != actual.Status {
|
||||||
|
return errors.New("Status mismatch")
|
||||||
|
}
|
||||||
|
if expected.ExpirationDate != actual.ExpirationDate {
|
||||||
|
return errors.New("ExpirationDate mismatch")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -108,6 +108,7 @@ func TestBasicUserHandling(t *testing.T) {
|
||||||
user.QuotaFiles = 2
|
user.QuotaFiles = 2
|
||||||
user.UploadBandwidth = 128
|
user.UploadBandwidth = 128
|
||||||
user.DownloadBandwidth = 64
|
user.DownloadBandwidth = 64
|
||||||
|
user.ExpirationDate = utils.GetTimeAsMsSinceEpoch(time.Now())
|
||||||
user, _, err = httpd.UpdateUser(user, http.StatusOK)
|
user, _, err = httpd.UpdateUser(user, http.StatusOK)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("unable to update user: %v", err)
|
t.Errorf("unable to update user: %v", err)
|
||||||
|
@ -125,6 +126,34 @@ func TestBasicUserHandling(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUserStatus(t *testing.T) {
|
||||||
|
u := getTestUser()
|
||||||
|
u.Status = 3
|
||||||
|
_, _, err := httpd.AddUser(u, http.StatusBadRequest)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error adding user with bad status: %v", err)
|
||||||
|
}
|
||||||
|
u.Status = 0
|
||||||
|
user, _, err := httpd.AddUser(u, http.StatusOK)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to add user: %v", err)
|
||||||
|
}
|
||||||
|
user.Status = 2
|
||||||
|
_, _, err = httpd.UpdateUser(user, http.StatusBadRequest)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error updating user with bad status: %v", err)
|
||||||
|
}
|
||||||
|
user.Status = 1
|
||||||
|
user, _, err = httpd.UpdateUser(user, http.StatusOK)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to update user: %v", err)
|
||||||
|
}
|
||||||
|
_, err = httpd.RemoveUser(user, http.StatusOK)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to remove: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAddUserNoCredentials(t *testing.T) {
|
func TestAddUserNoCredentials(t *testing.T) {
|
||||||
u := getTestUser()
|
u := getTestUser()
|
||||||
u.Password = ""
|
u.Password = ""
|
||||||
|
@ -875,6 +904,8 @@ func TestWebUserAddMock(t *testing.T) {
|
||||||
form.Set("username", user.Username)
|
form.Set("username", user.Username)
|
||||||
form.Set("home_dir", user.HomeDir)
|
form.Set("home_dir", user.HomeDir)
|
||||||
form.Set("password", user.Password)
|
form.Set("password", user.Password)
|
||||||
|
form.Set("status", strconv.Itoa(user.Status))
|
||||||
|
form.Set("expiration_date", "")
|
||||||
form.Set("permissions", "*")
|
form.Set("permissions", "*")
|
||||||
// test invalid url escape
|
// test invalid url escape
|
||||||
req, _ := http.NewRequest(http.MethodPost, webUserPath+"?a=%2", strings.NewReader(form.Encode()))
|
req, _ := http.NewRequest(http.MethodPost, webUserPath+"?a=%2", strings.NewReader(form.Encode()))
|
||||||
|
@ -925,6 +956,20 @@ func TestWebUserAddMock(t *testing.T) {
|
||||||
rr = executeRequest(req)
|
rr = executeRequest(req)
|
||||||
checkResponseCode(t, http.StatusOK, rr.Code)
|
checkResponseCode(t, http.StatusOK, rr.Code)
|
||||||
form.Set("download_bandwidth", strconv.FormatInt(user.DownloadBandwidth, 10))
|
form.Set("download_bandwidth", strconv.FormatInt(user.DownloadBandwidth, 10))
|
||||||
|
form.Set("status", "a")
|
||||||
|
// test invalid status
|
||||||
|
req, _ = http.NewRequest(http.MethodPost, webUserPath, strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rr = executeRequest(req)
|
||||||
|
checkResponseCode(t, http.StatusOK, rr.Code)
|
||||||
|
form.Set("status", strconv.Itoa(user.Status))
|
||||||
|
form.Set("expiration_date", "123")
|
||||||
|
// test invalid expiration date
|
||||||
|
req, _ = http.NewRequest(http.MethodPost, webUserPath, strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rr = executeRequest(req)
|
||||||
|
checkResponseCode(t, http.StatusOK, rr.Code)
|
||||||
|
form.Set("expiration_date", "")
|
||||||
req, _ = http.NewRequest(http.MethodPost, webUserPath, strings.NewReader(form.Encode()))
|
req, _ = http.NewRequest(http.MethodPost, webUserPath, strings.NewReader(form.Encode()))
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
rr = executeRequest(req)
|
rr = executeRequest(req)
|
||||||
|
@ -988,6 +1033,8 @@ func TestWebUserUpdateMock(t *testing.T) {
|
||||||
form.Set("upload_bandwidth", "0")
|
form.Set("upload_bandwidth", "0")
|
||||||
form.Set("download_bandwidth", "0")
|
form.Set("download_bandwidth", "0")
|
||||||
form.Set("permissions", "*")
|
form.Set("permissions", "*")
|
||||||
|
form.Set("status", strconv.Itoa(user.Status))
|
||||||
|
form.Set("expiration_date", "2020-01-01 00:00:00")
|
||||||
req, _ = http.NewRequest(http.MethodPost, webUserPath+"/"+strconv.FormatInt(user.ID, 10), strings.NewReader(form.Encode()))
|
req, _ = http.NewRequest(http.MethodPost, webUserPath+"/"+strconv.FormatInt(user.ID, 10), strings.NewReader(form.Encode()))
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
rr = executeRequest(req)
|
rr = executeRequest(req)
|
||||||
|
@ -1083,6 +1130,7 @@ func getTestUser() dataprovider.User {
|
||||||
Password: defaultPassword,
|
Password: defaultPassword,
|
||||||
HomeDir: filepath.Join(homeBasePath, defaultUsername),
|
HomeDir: filepath.Join(homeBasePath, defaultUsername),
|
||||||
Permissions: defaultPerms,
|
Permissions: defaultPerms,
|
||||||
|
Status: 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -144,6 +144,18 @@ func TestCompareUserFields(t *testing.T) {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Errorf("DownloadBandwidth does not match")
|
t.Errorf("DownloadBandwidth does not match")
|
||||||
}
|
}
|
||||||
|
expected.DownloadBandwidth = 0
|
||||||
|
expected.Status = 1
|
||||||
|
err = compareEqualsUserFields(expected, actual)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Status does not match")
|
||||||
|
}
|
||||||
|
expected.Status = 0
|
||||||
|
expected.ExpirationDate = 123
|
||||||
|
err = compareEqualsUserFields(expected, actual)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Expiration date does not match")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApiCallsWithBadURL(t *testing.T) {
|
func TestApiCallsWithBadURL(t *testing.T) {
|
||||||
|
|
|
@ -2,7 +2,7 @@ openapi: 3.0.1
|
||||||
info:
|
info:
|
||||||
title: SFTPGo
|
title: SFTPGo
|
||||||
description: 'SFTPGo REST API'
|
description: 'SFTPGo REST API'
|
||||||
version: 1.0.0
|
version: 1.1.0
|
||||||
|
|
||||||
servers:
|
servers:
|
||||||
- url: /api/v1
|
- url: /api/v1
|
||||||
|
@ -534,8 +534,21 @@ components:
|
||||||
type: integer
|
type: integer
|
||||||
format: int32
|
format: int32
|
||||||
minimum: 1
|
minimum: 1
|
||||||
|
status:
|
||||||
|
type: integer
|
||||||
|
enum:
|
||||||
|
- 0
|
||||||
|
- 1
|
||||||
|
description: >
|
||||||
|
status:
|
||||||
|
* `0` user is disabled, login is not allowed
|
||||||
|
* `1` user is enabled
|
||||||
username:
|
username:
|
||||||
type: string
|
type: string
|
||||||
|
expiration_date:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: expiration date as unix timestamp in milliseconds. An expired account cannot login. 0 means no expiration
|
||||||
password:
|
password:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
|
@ -596,6 +609,10 @@ components:
|
||||||
type: integer
|
type: integer
|
||||||
format: int32
|
format: int32
|
||||||
description: Maximum download bandwidth as KB/s, 0 means unlimited
|
description: Maximum download bandwidth as KB/s, 0 means unlimited
|
||||||
|
last_login:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: last user login as unix timestamp in milliseconds
|
||||||
Transfer:
|
Transfer:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
@ -688,4 +705,3 @@ components:
|
||||||
type: string
|
type: string
|
||||||
commit_hash:
|
commit_hash:
|
||||||
type: string
|
type: string
|
||||||
|
|
19
httpd/web.go
19
httpd/web.go
|
@ -7,6 +7,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/drakkan/sftpgo/dataprovider"
|
"github.com/drakkan/sftpgo/dataprovider"
|
||||||
"github.com/drakkan/sftpgo/sftpd"
|
"github.com/drakkan/sftpgo/sftpd"
|
||||||
|
@ -27,6 +28,7 @@ const (
|
||||||
page500Title = "Internal Server Error"
|
page500Title = "Internal Server Error"
|
||||||
page500Body = "The server is unable to fulfill your request."
|
page500Body = "The server is unable to fulfill your request."
|
||||||
defaultUsersQueryLimit = 500
|
defaultUsersQueryLimit = 500
|
||||||
|
webDateTimeFormat = "2006-01-02 15:04:05" // YYYY-MM-DD HH:MM:SS
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
@ -216,6 +218,19 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return user, err
|
return user, err
|
||||||
}
|
}
|
||||||
|
status, err := strconv.Atoi(r.Form.Get("status"))
|
||||||
|
if err != nil {
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
expirationDateMillis := int64(0)
|
||||||
|
expirationDateString := r.Form.Get("expiration_date")
|
||||||
|
if len(strings.TrimSpace(expirationDateString)) > 0 {
|
||||||
|
expirationDate, err := time.Parse(webDateTimeFormat, expirationDateString)
|
||||||
|
if err != nil {
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
expirationDateMillis = utils.GetTimeAsMsSinceEpoch(expirationDate)
|
||||||
|
}
|
||||||
user = dataprovider.User{
|
user = dataprovider.User{
|
||||||
Username: r.Form.Get("username"),
|
Username: r.Form.Get("username"),
|
||||||
Password: r.Form.Get("password"),
|
Password: r.Form.Get("password"),
|
||||||
|
@ -229,6 +244,8 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) {
|
||||||
QuotaFiles: quotaFiles,
|
QuotaFiles: quotaFiles,
|
||||||
UploadBandwidth: bandwidthUL,
|
UploadBandwidth: bandwidthUL,
|
||||||
DownloadBandwidth: bandwidthDL,
|
DownloadBandwidth: bandwidthDL,
|
||||||
|
Status: status,
|
||||||
|
ExpirationDate: expirationDateMillis,
|
||||||
}
|
}
|
||||||
return user, err
|
return user, err
|
||||||
}
|
}
|
||||||
|
@ -265,7 +282,7 @@ func handleGetWebUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleWebAddUserGet(w http.ResponseWriter, r *http.Request) {
|
func handleWebAddUserGet(w http.ResponseWriter, r *http.Request) {
|
||||||
renderAddUserPage(w, dataprovider.User{}, "")
|
renderAddUserPage(w, dataprovider.User{Status: 1}, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleWebUpdateUserGet(userID string, w http.ResponseWriter, r *http.Request) {
|
func handleWebUpdateUserGet(userID string, w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
|
@ -41,7 +41,7 @@ Let's see a sample usage for each REST API.
|
||||||
Command:
|
Command:
|
||||||
|
|
||||||
```
|
```
|
||||||
python sftpgo_api_cli.py add-user test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 33 --gid 1000 --max-sessions 2 --quota-size 0 --quota-files 3 --permissions "list" "download" "upload" "delete" "rename" "create_dirs" "overwrite" --upload-bandwidth 100 --download-bandwidth 60
|
python sftpgo_api_cli.py add-user test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 33 --gid 1000 --max-sessions 2 --quota-size 0 --quota-files 3 --permissions "list" "download" "upload" "delete" "rename" "create_dirs" "overwrite" --upload-bandwidth 100 --download-bandwidth 60 --status 0 --expiration-date 2019-01-01
|
||||||
```
|
```
|
||||||
|
|
||||||
Output:
|
Output:
|
||||||
|
@ -68,6 +68,9 @@ Output:
|
||||||
"used_quota_size": 0,
|
"used_quota_size": 0,
|
||||||
"used_quota_files": 0,
|
"used_quota_files": 0,
|
||||||
"last_quota_update": 0,
|
"last_quota_update": 0,
|
||||||
|
"last_login": 0,
|
||||||
|
"expiration_date": 1546297200000,
|
||||||
|
"status": 0,
|
||||||
"upload_bandwidth": 100,
|
"upload_bandwidth": 100,
|
||||||
"download_bandwidth": 60
|
"download_bandwidth": 60
|
||||||
}
|
}
|
||||||
|
@ -78,7 +81,7 @@ Output:
|
||||||
Command:
|
Command:
|
||||||
|
|
||||||
```
|
```
|
||||||
python sftpgo_api_cli.py update-user 5140 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 "*" --upload-bandwidth 90 --download-bandwidth 80
|
python sftpgo_api_cli.py update-user 5140 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 "*" --upload-bandwidth 90 --download-bandwidth 80 --status 1 --expiration-date ""
|
||||||
```
|
```
|
||||||
|
|
||||||
Output:
|
Output:
|
||||||
|
@ -117,6 +120,9 @@ Output:
|
||||||
"used_quota_size": 0,
|
"used_quota_size": 0,
|
||||||
"used_quota_files": 0,
|
"used_quota_files": 0,
|
||||||
"last_quota_update": 0,
|
"last_quota_update": 0,
|
||||||
|
"last_login": 0,
|
||||||
|
"expiration_date": 0,
|
||||||
|
"status": 1,
|
||||||
"upload_bandwidth": 90,
|
"upload_bandwidth": 90,
|
||||||
"download_bandwidth": 80
|
"download_bandwidth": 80
|
||||||
}
|
}
|
||||||
|
@ -149,6 +155,9 @@ Output:
|
||||||
"used_quota_size": 0,
|
"used_quota_size": 0,
|
||||||
"used_quota_files": 0,
|
"used_quota_files": 0,
|
||||||
"last_quota_update": 0,
|
"last_quota_update": 0,
|
||||||
|
"last_login": 0,
|
||||||
|
"expiration_date": 0,
|
||||||
|
"status": 1,
|
||||||
"upload_bandwidth": 90,
|
"upload_bandwidth": 90,
|
||||||
"download_bandwidth": 80
|
"download_bandwidth": 80
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
import argparse
|
import argparse
|
||||||
|
from datetime import datetime
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
@ -59,10 +60,11 @@ class SFTPGoApiRequests:
|
||||||
|
|
||||||
def buildUserObject(self, user_id=0, username="", password="", public_keys="", home_dir="", uid=0,
|
def buildUserObject(self, user_id=0, username="", password="", public_keys="", home_dir="", uid=0,
|
||||||
gid=0, max_sessions=0, quota_size=0, quota_files=0, permissions=[], upload_bandwidth=0,
|
gid=0, max_sessions=0, quota_size=0, quota_files=0, permissions=[], upload_bandwidth=0,
|
||||||
download_bandwidth=0):
|
download_bandwidth=0, status=1, expiration_date=0):
|
||||||
user = {"id":user_id, "username":username, "uid":uid, "gid":gid,
|
user = {"id":user_id, "username":username, "uid":uid, "gid":gid,
|
||||||
"max_sessions":max_sessions, "quota_size":quota_size, "quota_files":quota_files,
|
"max_sessions":max_sessions, "quota_size":quota_size, "quota_files":quota_files,
|
||||||
"upload_bandwidth":upload_bandwidth, "download_bandwidth":download_bandwidth}
|
"upload_bandwidth":upload_bandwidth, "download_bandwidth":download_bandwidth,
|
||||||
|
"status":status, "expiration_date":expiration_date}
|
||||||
if password:
|
if password:
|
||||||
user.update({"password":password})
|
user.update({"password":password})
|
||||||
if public_keys:
|
if public_keys:
|
||||||
|
@ -83,17 +85,18 @@ class SFTPGoApiRequests:
|
||||||
self.printResponse(r)
|
self.printResponse(r)
|
||||||
|
|
||||||
def addUser(self, username="", password="", public_keys="", home_dir="", uid=0, gid=0, max_sessions=0,
|
def addUser(self, username="", password="", public_keys="", home_dir="", uid=0, gid=0, max_sessions=0,
|
||||||
quota_size=0, quota_files=0, permissions=[], upload_bandwidth=0, download_bandwidth=0):
|
quota_size=0, quota_files=0, permissions=[], upload_bandwidth=0, download_bandwidth=0, status=1,
|
||||||
|
expiration_date=0):
|
||||||
u = self.buildUserObject(0, username, password, public_keys, home_dir, uid, gid, max_sessions,
|
u = self.buildUserObject(0, username, password, public_keys, home_dir, uid, gid, max_sessions,
|
||||||
quota_size, quota_files, permissions, upload_bandwidth, download_bandwidth)
|
quota_size, quota_files, permissions, upload_bandwidth, download_bandwidth, status, expiration_date)
|
||||||
r = requests.post(self.userPath, json=u, auth=self.auth, verify=self.verify)
|
r = requests.post(self.userPath, json=u, auth=self.auth, verify=self.verify)
|
||||||
self.printResponse(r)
|
self.printResponse(r)
|
||||||
|
|
||||||
def updateUser(self, user_id, username="", password="", public_keys="", home_dir="", uid=0, gid=0,
|
def updateUser(self, user_id, username="", password="", public_keys="", home_dir="", uid=0, gid=0,
|
||||||
max_sessions=0, quota_size=0, quota_files=0, permissions=[], upload_bandwidth=0,
|
max_sessions=0, quota_size=0, quota_files=0, permissions=[], upload_bandwidth=0,
|
||||||
download_bandwidth=0):
|
download_bandwidth=0, status=1, expiration_date=0):
|
||||||
u = self.buildUserObject(user_id, username, password, public_keys, home_dir, uid, gid, max_sessions,
|
u = self.buildUserObject(user_id, username, password, public_keys, home_dir, uid, gid, max_sessions,
|
||||||
quota_size, quota_files, permissions, upload_bandwidth, download_bandwidth)
|
quota_size, quota_files, permissions, upload_bandwidth, download_bandwidth, status, expiration_date)
|
||||||
r = requests.put(urlparse.urljoin(self.userPath, "user/" + str(user_id)), json=u, auth=self.auth, verify=self.verify)
|
r = requests.put(urlparse.urljoin(self.userPath, "user/" + str(user_id)), json=u, auth=self.auth, verify=self.verify)
|
||||||
self.printResponse(r)
|
self.printResponse(r)
|
||||||
|
|
||||||
|
@ -123,6 +126,21 @@ class SFTPGoApiRequests:
|
||||||
self.printResponse(r)
|
self.printResponse(r)
|
||||||
|
|
||||||
|
|
||||||
|
def validDate(s):
|
||||||
|
if not s:
|
||||||
|
return datetime.fromtimestamp(0)
|
||||||
|
try:
|
||||||
|
return datetime.strptime(s, "%Y-%m-%d")
|
||||||
|
except ValueError:
|
||||||
|
msg = "Not a valid date: '{0}'.".format(s)
|
||||||
|
raise argparse.ArgumentTypeError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def getDatetimeAsMillisSinceEpoch(dt):
|
||||||
|
epoch = datetime.fromtimestamp(0)
|
||||||
|
return int((dt - epoch).total_seconds() * 1000)
|
||||||
|
|
||||||
|
|
||||||
def addCommonUserArguments(parser):
|
def addCommonUserArguments(parser):
|
||||||
parser.add_argument('username', type=str)
|
parser.add_argument('username', type=str)
|
||||||
parser.add_argument('-P', '--password', type=str, default="", help='Default: %(default)s')
|
parser.add_argument('-P', '--password', type=str, default="", help='Default: %(default)s')
|
||||||
|
@ -142,6 +160,10 @@ def addCommonUserArguments(parser):
|
||||||
help='Maximum upload bandwidth as KB/s, 0 means unlimited. Default: %(default)s')
|
help='Maximum upload bandwidth as KB/s, 0 means unlimited. Default: %(default)s')
|
||||||
parser.add_argument('-D', '--download-bandwidth', type=int, default=0,
|
parser.add_argument('-D', '--download-bandwidth', type=int, default=0,
|
||||||
help='Maximum download bandwidth as KB/s, 0 means unlimited. Default: %(default)s')
|
help='Maximum download bandwidth as KB/s, 0 means unlimited. Default: %(default)s')
|
||||||
|
parser.add_argument('--status', type=int, choices=[0, 1], default=1,
|
||||||
|
help='User\'s status. 1 enabled, 0 disabled. Default: %(default)s')
|
||||||
|
parser.add_argument('-E', '--expiration-date', type=validDate, default="",
|
||||||
|
help='Expiration date as YYYY-MM-DD, empty string means no expiration. Default: %(default)s')
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
@ -206,13 +228,13 @@ if __name__ == '__main__':
|
||||||
args.no_color)
|
args.no_color)
|
||||||
|
|
||||||
if args.command == 'add-user':
|
if args.command == 'add-user':
|
||||||
api.addUser(args.username, args.password, args.public_keys, args.home_dir,
|
api.addUser(args.username, args.password, args.public_keys, args.home_dir, args.uid, args.gid, args.max_sessions,
|
||||||
args.uid, args.gid, args.max_sessions, args.quota_size, args.quota_files,
|
args.quota_size, args.quota_files, args.permissions, args.upload_bandwidth, args.download_bandwidth,
|
||||||
args.permissions, args.upload_bandwidth, args.download_bandwidth)
|
args.status, getDatetimeAsMillisSinceEpoch(args.expiration_date))
|
||||||
elif args.command == 'update-user':
|
elif args.command == 'update-user':
|
||||||
api.updateUser(args.id, args.username, args.password, args.public_keys, args.home_dir,
|
api.updateUser(args.id, args.username, args.password, args.public_keys, args.home_dir, args.uid, args.gid,
|
||||||
args.uid, args.gid, args.max_sessions, args.quota_size, args.quota_files,
|
args.max_sessions, args.quota_size, args.quota_files, args.permissions, args.upload_bandwidth,
|
||||||
args.permissions, args.upload_bandwidth, args.download_bandwidth)
|
args.download_bandwidth, args.status, getDatetimeAsMillisSinceEpoch(args.expiration_date))
|
||||||
elif args.command == 'delete-user':
|
elif args.command == 'delete-user':
|
||||||
api.deleteUser(args.id)
|
api.deleteUser(args.id)
|
||||||
elif args.command == 'get-users':
|
elif args.command == 'get-users':
|
||||||
|
|
|
@ -260,6 +260,7 @@ func (c Configuration) AcceptInboundConnection(conn net.Conn, config *ssh.Server
|
||||||
}
|
}
|
||||||
connection.Log(logger.LevelInfo, logSender, "User id: %d, logged in with: %#v, username: %#v, home_dir: %#v remote addr: %#v",
|
connection.Log(logger.LevelInfo, logSender, "User id: %d, logged in with: %#v, username: %#v, home_dir: %#v remote addr: %#v",
|
||||||
user.ID, loginType, user.Username, user.HomeDir, remoteAddr.String())
|
user.ID, loginType, user.Username, user.HomeDir, remoteAddr.String())
|
||||||
|
dataprovider.UpdateLastLogin(dataProvider, user)
|
||||||
|
|
||||||
go ssh.DiscardRequests(reqs)
|
go ssh.DiscardRequests(reqs)
|
||||||
|
|
||||||
|
|
|
@ -28,6 +28,7 @@ import (
|
||||||
"github.com/drakkan/sftpgo/httpd"
|
"github.com/drakkan/sftpgo/httpd"
|
||||||
"github.com/drakkan/sftpgo/logger"
|
"github.com/drakkan/sftpgo/logger"
|
||||||
"github.com/drakkan/sftpgo/sftpd"
|
"github.com/drakkan/sftpgo/sftpd"
|
||||||
|
"github.com/drakkan/sftpgo/utils"
|
||||||
"github.com/pkg/sftp"
|
"github.com/pkg/sftp"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
)
|
)
|
||||||
|
@ -687,6 +688,13 @@ func TestLogin(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("sftp client with valid password must work")
|
t.Errorf("sftp client with valid password must work")
|
||||||
}
|
}
|
||||||
|
user, _, err = httpd.GetUserByID(user.ID, http.StatusOK)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error getting user: %v", err)
|
||||||
|
}
|
||||||
|
if user.LastLogin <= 0 {
|
||||||
|
t.Errorf("last login must be updated after a successful login: %v", user.LastLogin)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
client, err = getSftpClient(user, true)
|
client, err = getSftpClient(user, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -740,6 +748,97 @@ func TestLogin(t *testing.T) {
|
||||||
os.RemoveAll(user.GetHomeDir())
|
os.RemoveAll(user.GetHomeDir())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoginUserStatus(t *testing.T) {
|
||||||
|
usePubKey := true
|
||||||
|
user, _, err := httpd.AddUser(getTestUser(usePubKey), http.StatusOK)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to add user: %v", err)
|
||||||
|
}
|
||||||
|
client, err := getSftpClient(user, usePubKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to create sftp client: %v", err)
|
||||||
|
} else {
|
||||||
|
defer client.Close()
|
||||||
|
_, err := client.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("sftp client with valid credentials must work")
|
||||||
|
}
|
||||||
|
user, _, err = httpd.GetUserByID(user.ID, http.StatusOK)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error getting user: %v", err)
|
||||||
|
}
|
||||||
|
if user.LastLogin <= 0 {
|
||||||
|
t.Errorf("last login must be updated after a successful login: %v", user.LastLogin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user.Status = 0
|
||||||
|
user, _, err = httpd.UpdateUser(user, http.StatusOK)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to update user: %v", err)
|
||||||
|
}
|
||||||
|
client, err = getSftpClient(user, usePubKey)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("login for a disabled user must fail")
|
||||||
|
defer client.Close()
|
||||||
|
}
|
||||||
|
_, err = httpd.RemoveUser(user, http.StatusOK)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to remove user: %v", err)
|
||||||
|
}
|
||||||
|
os.RemoveAll(user.GetHomeDir())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginUserExpiration(t *testing.T) {
|
||||||
|
usePubKey := true
|
||||||
|
user, _, err := httpd.AddUser(getTestUser(usePubKey), http.StatusOK)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to add user: %v", err)
|
||||||
|
}
|
||||||
|
client, err := getSftpClient(user, usePubKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to create sftp client: %v", err)
|
||||||
|
} else {
|
||||||
|
defer client.Close()
|
||||||
|
_, err := client.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("sftp client with valid credentials must work")
|
||||||
|
}
|
||||||
|
user, _, err = httpd.GetUserByID(user.ID, http.StatusOK)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("error getting user: %v", err)
|
||||||
|
}
|
||||||
|
if user.LastLogin <= 0 {
|
||||||
|
t.Errorf("last login must be updated after a successful login: %v", user.LastLogin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user.ExpirationDate = utils.GetTimeAsMsSinceEpoch(time.Now()) - 120000
|
||||||
|
user, _, err = httpd.UpdateUser(user, http.StatusOK)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to update user: %v", err)
|
||||||
|
}
|
||||||
|
client, err = getSftpClient(user, usePubKey)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("login for an expired user must fail")
|
||||||
|
defer client.Close()
|
||||||
|
}
|
||||||
|
user.ExpirationDate = utils.GetTimeAsMsSinceEpoch(time.Now()) + 120000
|
||||||
|
_, _, err = httpd.UpdateUser(user, http.StatusOK)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to update user: %v", err)
|
||||||
|
}
|
||||||
|
client, err = getSftpClient(user, usePubKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("login for a non expired user must succeed: %v", err)
|
||||||
|
} else {
|
||||||
|
defer client.Close()
|
||||||
|
}
|
||||||
|
_, err = httpd.RemoveUser(user, http.StatusOK)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unable to remove user: %v", err)
|
||||||
|
}
|
||||||
|
os.RemoveAll(user.GetHomeDir())
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoginAfterUserUpdateEmptyPwd(t *testing.T) {
|
func TestLoginAfterUserUpdateEmptyPwd(t *testing.T) {
|
||||||
usePubKey := false
|
usePubKey := false
|
||||||
user, _, err := httpd.AddUser(getTestUser(usePubKey), http.StatusOK)
|
user, _, err := httpd.AddUser(getTestUser(usePubKey), http.StatusOK)
|
||||||
|
@ -2330,10 +2429,12 @@ func waitTCPListening(address string) {
|
||||||
|
|
||||||
func getTestUser(usePubKey bool) dataprovider.User {
|
func getTestUser(usePubKey bool) dataprovider.User {
|
||||||
user := dataprovider.User{
|
user := dataprovider.User{
|
||||||
Username: defaultUsername,
|
Username: defaultUsername,
|
||||||
Password: defaultPassword,
|
Password: defaultPassword,
|
||||||
HomeDir: filepath.Join(homeBasePath, defaultUsername),
|
HomeDir: filepath.Join(homeBasePath, defaultUsername),
|
||||||
Permissions: allPerms,
|
Permissions: allPerms,
|
||||||
|
Status: 1,
|
||||||
|
ExpirationDate: 0,
|
||||||
}
|
}
|
||||||
if usePubKey {
|
if usePubKey {
|
||||||
user.PublicKeys = []string{testPubKey}
|
user.PublicKeys = []string{testPubKey}
|
||||||
|
|
17
sql/mysql/20191112.sql
Normal file
17
sql/mysql/20191112.sql
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
BEGIN;
|
||||||
|
--
|
||||||
|
-- Add field expiration_date to user
|
||||||
|
--
|
||||||
|
ALTER TABLE `users` ADD COLUMN `expiration_date` bigint DEFAULT 0 NOT NULL;
|
||||||
|
ALTER TABLE `users` ALTER COLUMN `expiration_date` DROP DEFAULT;
|
||||||
|
--
|
||||||
|
-- Add field last_login to user
|
||||||
|
--
|
||||||
|
ALTER TABLE `users` ADD COLUMN `last_login` bigint DEFAULT 0 NOT NULL;
|
||||||
|
ALTER TABLE `users` ALTER COLUMN `last_login` DROP DEFAULT;
|
||||||
|
--
|
||||||
|
-- Add field status to user
|
||||||
|
--
|
||||||
|
ALTER TABLE `users` ADD COLUMN `status` integer DEFAULT 1 NOT NULL;
|
||||||
|
ALTER TABLE `users` ALTER COLUMN `status` DROP DEFAULT;
|
||||||
|
COMMIT;
|
17
sql/pgsql/20191112.sql
Normal file
17
sql/pgsql/20191112.sql
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
BEGIN;
|
||||||
|
--
|
||||||
|
-- Add field expiration_date to user
|
||||||
|
--
|
||||||
|
ALTER TABLE "users" ADD COLUMN "expiration_date" bigint DEFAULT 0 NOT NULL;
|
||||||
|
ALTER TABLE "users" ALTER COLUMN "expiration_date" DROP DEFAULT;
|
||||||
|
--
|
||||||
|
-- Add field last_login to user
|
||||||
|
--
|
||||||
|
ALTER TABLE "users" ADD COLUMN "last_login" bigint DEFAULT 0 NOT NULL;
|
||||||
|
ALTER TABLE "users" ALTER COLUMN "last_login" DROP DEFAULT;
|
||||||
|
--
|
||||||
|
-- Add field status to user
|
||||||
|
--
|
||||||
|
ALTER TABLE "users" ADD COLUMN "status" integer DEFAULT 1 NOT NULL;
|
||||||
|
ALTER TABLE "users" ALTER COLUMN "status" DROP DEFAULT;
|
||||||
|
COMMIT;
|
23
sql/sqlite/20191112.sql
Normal file
23
sql/sqlite/20191112.sql
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
BEGIN;
|
||||||
|
--
|
||||||
|
-- Add field expiration_date to user
|
||||||
|
--
|
||||||
|
CREATE TABLE "new__users" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "expiration_date" bigint NOT NULL, "username" varchar(255) NOT NULL UNIQUE, "password" varchar(255) 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, "upload_bandwidth" integer NOT NULL, "download_bandwidth" integer NOT 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") 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", 0 FROM "users";
|
||||||
|
DROP TABLE "users";
|
||||||
|
ALTER TABLE "new__users" RENAME TO "users";
|
||||||
|
--
|
||||||
|
-- Add field last_login to user
|
||||||
|
--
|
||||||
|
CREATE TABLE "new__users" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "username" varchar(255) NOT NULL UNIQUE, "password" varchar(255) 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, "upload_bandwidth" integer NOT NULL, "download_bandwidth" integer NOT NULL, "expiration_date" bigint NOT NULL, "last_login" bigint NOT 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") 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", 0 FROM "users";
|
||||||
|
DROP TABLE "users";
|
||||||
|
ALTER TABLE "new__users" RENAME TO "users";
|
||||||
|
--
|
||||||
|
-- Add field status to user
|
||||||
|
--
|
||||||
|
CREATE TABLE "new__users" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "username" varchar(255) NOT NULL UNIQUE, "password" varchar(255) 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, "upload_bandwidth" integer NOT NULL, "download_bandwidth" integer NOT NULL, "expiration_date" bigint NOT NULL, "last_login" bigint NOT NULL, "status" integer NOT 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") 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", 1 FROM "users";
|
||||||
|
DROP TABLE "users";
|
||||||
|
ALTER TABLE "new__users" RENAME TO "users";
|
||||||
|
COMMIT;
|
1
static/vendor/fontawesome-free/svgs/solid/calendar.svg
vendored
Normal file
1
static/vendor/fontawesome-free/svgs/solid/calendar.svg
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M12 192h424c6.6 0 12 5.4 12 12v260c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V204c0-6.6 5.4-12 12-12zm436-44v-36c0-26.5-21.5-48-48-48h-48V12c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v52H160V12c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v52H48C21.5 64 0 85.5 0 112v36c0 6.6 5.4 12 12 12h424c6.6 0 12-5.4 12-12z"/></svg>
|
After Width: | Height: | Size: 392 B |
202
static/vendor/fonts/LICENSE.txt
vendored
Normal file
202
static/vendor/fonts/LICENSE.txt
vendored
Normal file
|
@ -0,0 +1,202 @@
|
||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
2
static/vendor/fonts/README.txt
vendored
Normal file
2
static/vendor/fonts/README.txt
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
Roboto webfont source: https://code.google.com/p/roboto-webfont/
|
||||||
|
Weights used in this project: Light (300), Regular (400), Bold (700)
|
BIN
static/vendor/fonts/Roboto-Bold-webfont.woff
vendored
Normal file
BIN
static/vendor/fonts/Roboto-Bold-webfont.woff
vendored
Normal file
Binary file not shown.
BIN
static/vendor/fonts/Roboto-Light-webfont.woff
vendored
Normal file
BIN
static/vendor/fonts/Roboto-Light-webfont.woff
vendored
Normal file
Binary file not shown.
BIN
static/vendor/fonts/Roboto-Regular-webfont.woff
vendored
Normal file
BIN
static/vendor/fonts/Roboto-Regular-webfont.woff
vendored
Normal file
Binary file not shown.
1
static/vendor/moment/js/moment.min.js
vendored
Normal file
1
static/vendor/moment/js/moment.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
206
static/vendor/tempusdominus/css/tempusdominus-bootstrap-4.min.css
vendored
Normal file
206
static/vendor/tempusdominus/css/tempusdominus-bootstrap-4.min.css
vendored
Normal file
|
@ -0,0 +1,206 @@
|
||||||
|
/*@preserve
|
||||||
|
* Tempus Dominus Bootstrap4 v5.1.2 (https://tempusdominus.github.io/bootstrap-4/)
|
||||||
|
* Copyright 2016-2018 Jonathan Peterson
|
||||||
|
* Licensed under MIT (https://github.com/tempusdominus/bootstrap-3/blob/master/LICENSE)
|
||||||
|
*/
|
||||||
|
|
||||||
|
.sr-only, .bootstrap-datetimepicker-widget .btn[data-action="incrementHours"]::after, .bootstrap-datetimepicker-widget .btn[data-action="incrementMinutes"]::after, .bootstrap-datetimepicker-widget .btn[data-action="decrementHours"]::after, .bootstrap-datetimepicker-widget .btn[data-action="decrementMinutes"]::after, .bootstrap-datetimepicker-widget .btn[data-action="showHours"]::after, .bootstrap-datetimepicker-widget .btn[data-action="showMinutes"]::after, .bootstrap-datetimepicker-widget .btn[data-action="togglePeriod"]::after, .bootstrap-datetimepicker-widget .btn[data-action="clear"]::after, .bootstrap-datetimepicker-widget .btn[data-action="today"]::after, .bootstrap-datetimepicker-widget .picker-switch::after, .bootstrap-datetimepicker-widget table th.prev::after, .bootstrap-datetimepicker-widget table th.next::after {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
border: 0; }
|
||||||
|
|
||||||
|
.bootstrap-datetimepicker-widget {
|
||||||
|
list-style: none; }
|
||||||
|
.bootstrap-datetimepicker-widget.dropdown-menu {
|
||||||
|
display: block;
|
||||||
|
margin: 2px 0;
|
||||||
|
padding: 4px;
|
||||||
|
width: 14rem; }
|
||||||
|
@media (min-width: 576px) {
|
||||||
|
.bootstrap-datetimepicker-widget.dropdown-menu.timepicker-sbs {
|
||||||
|
width: 38em; } }
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.bootstrap-datetimepicker-widget.dropdown-menu.timepicker-sbs {
|
||||||
|
width: 38em; } }
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
.bootstrap-datetimepicker-widget.dropdown-menu.timepicker-sbs {
|
||||||
|
width: 38em; } }
|
||||||
|
.bootstrap-datetimepicker-widget.dropdown-menu:before, .bootstrap-datetimepicker-widget.dropdown-menu:after {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
position: absolute; }
|
||||||
|
.bootstrap-datetimepicker-widget.dropdown-menu.bottom:before {
|
||||||
|
border-left: 7px solid transparent;
|
||||||
|
border-right: 7px solid transparent;
|
||||||
|
border-bottom: 7px solid #ccc;
|
||||||
|
border-bottom-color: rgba(0, 0, 0, 0.2);
|
||||||
|
top: -7px;
|
||||||
|
left: 7px; }
|
||||||
|
.bootstrap-datetimepicker-widget.dropdown-menu.bottom:after {
|
||||||
|
border-left: 6px solid transparent;
|
||||||
|
border-right: 6px solid transparent;
|
||||||
|
border-bottom: 6px solid white;
|
||||||
|
top: -6px;
|
||||||
|
left: 8px; }
|
||||||
|
.bootstrap-datetimepicker-widget.dropdown-menu.top:before {
|
||||||
|
border-left: 7px solid transparent;
|
||||||
|
border-right: 7px solid transparent;
|
||||||
|
border-top: 7px solid #ccc;
|
||||||
|
border-top-color: rgba(0, 0, 0, 0.2);
|
||||||
|
bottom: -7px;
|
||||||
|
left: 6px; }
|
||||||
|
.bootstrap-datetimepicker-widget.dropdown-menu.top:after {
|
||||||
|
border-left: 6px solid transparent;
|
||||||
|
border-right: 6px solid transparent;
|
||||||
|
border-top: 6px solid white;
|
||||||
|
bottom: -6px;
|
||||||
|
left: 7px; }
|
||||||
|
.bootstrap-datetimepicker-widget.dropdown-menu.float-right:before {
|
||||||
|
left: auto;
|
||||||
|
right: 6px; }
|
||||||
|
.bootstrap-datetimepicker-widget.dropdown-menu.float-right:after {
|
||||||
|
left: auto;
|
||||||
|
right: 7px; }
|
||||||
|
.bootstrap-datetimepicker-widget.dropdown-menu.wider {
|
||||||
|
width: 16rem; }
|
||||||
|
.bootstrap-datetimepicker-widget .list-unstyled {
|
||||||
|
margin: 0; }
|
||||||
|
.bootstrap-datetimepicker-widget a[data-action] {
|
||||||
|
padding: 6px 0; }
|
||||||
|
.bootstrap-datetimepicker-widget a[data-action]:active {
|
||||||
|
box-shadow: none; }
|
||||||
|
.bootstrap-datetimepicker-widget .timepicker-hour, .bootstrap-datetimepicker-widget .timepicker-minute, .bootstrap-datetimepicker-widget .timepicker-second {
|
||||||
|
width: 54px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.2em;
|
||||||
|
margin: 0; }
|
||||||
|
.bootstrap-datetimepicker-widget button[data-action] {
|
||||||
|
padding: 6px; }
|
||||||
|
.bootstrap-datetimepicker-widget .btn[data-action="incrementHours"]::after {
|
||||||
|
content: "Increment Hours"; }
|
||||||
|
.bootstrap-datetimepicker-widget .btn[data-action="incrementMinutes"]::after {
|
||||||
|
content: "Increment Minutes"; }
|
||||||
|
.bootstrap-datetimepicker-widget .btn[data-action="decrementHours"]::after {
|
||||||
|
content: "Decrement Hours"; }
|
||||||
|
.bootstrap-datetimepicker-widget .btn[data-action="decrementMinutes"]::after {
|
||||||
|
content: "Decrement Minutes"; }
|
||||||
|
.bootstrap-datetimepicker-widget .btn[data-action="showHours"]::after {
|
||||||
|
content: "Show Hours"; }
|
||||||
|
.bootstrap-datetimepicker-widget .btn[data-action="showMinutes"]::after {
|
||||||
|
content: "Show Minutes"; }
|
||||||
|
.bootstrap-datetimepicker-widget .btn[data-action="togglePeriod"]::after {
|
||||||
|
content: "Toggle AM/PM"; }
|
||||||
|
.bootstrap-datetimepicker-widget .btn[data-action="clear"]::after {
|
||||||
|
content: "Clear the picker"; }
|
||||||
|
.bootstrap-datetimepicker-widget .btn[data-action="today"]::after {
|
||||||
|
content: "Set the date to today"; }
|
||||||
|
.bootstrap-datetimepicker-widget .picker-switch {
|
||||||
|
text-align: center; }
|
||||||
|
.bootstrap-datetimepicker-widget .picker-switch::after {
|
||||||
|
content: "Toggle Date and Time Screens"; }
|
||||||
|
.bootstrap-datetimepicker-widget .picker-switch td {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
height: auto;
|
||||||
|
width: auto;
|
||||||
|
line-height: inherit; }
|
||||||
|
.bootstrap-datetimepicker-widget .picker-switch td span {
|
||||||
|
line-height: 2.5;
|
||||||
|
height: 2.5em;
|
||||||
|
width: 100%; }
|
||||||
|
.bootstrap-datetimepicker-widget table {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0; }
|
||||||
|
.bootstrap-datetimepicker-widget table td,
|
||||||
|
.bootstrap-datetimepicker-widget table th {
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 0.25rem; }
|
||||||
|
.bootstrap-datetimepicker-widget table th {
|
||||||
|
height: 20px;
|
||||||
|
line-height: 20px;
|
||||||
|
width: 20px; }
|
||||||
|
.bootstrap-datetimepicker-widget table th.picker-switch {
|
||||||
|
width: 145px; }
|
||||||
|
.bootstrap-datetimepicker-widget table th.disabled, .bootstrap-datetimepicker-widget table th.disabled:hover {
|
||||||
|
background: none;
|
||||||
|
color: #6c757d;
|
||||||
|
cursor: not-allowed; }
|
||||||
|
.bootstrap-datetimepicker-widget table th.prev::after {
|
||||||
|
content: "Previous Month"; }
|
||||||
|
.bootstrap-datetimepicker-widget table th.next::after {
|
||||||
|
content: "Next Month"; }
|
||||||
|
.bootstrap-datetimepicker-widget table thead tr:first-child th {
|
||||||
|
cursor: pointer; }
|
||||||
|
.bootstrap-datetimepicker-widget table thead tr:first-child th:hover {
|
||||||
|
background: #e9ecef; }
|
||||||
|
.bootstrap-datetimepicker-widget table td {
|
||||||
|
height: 54px;
|
||||||
|
line-height: 54px;
|
||||||
|
width: 54px; }
|
||||||
|
.bootstrap-datetimepicker-widget table td.cw {
|
||||||
|
font-size: .8em;
|
||||||
|
height: 20px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: #6c757d; }
|
||||||
|
.bootstrap-datetimepicker-widget table td.day {
|
||||||
|
height: 20px;
|
||||||
|
line-height: 20px;
|
||||||
|
width: 20px; }
|
||||||
|
.bootstrap-datetimepicker-widget table td.day:hover, .bootstrap-datetimepicker-widget table td.hour:hover, .bootstrap-datetimepicker-widget table td.minute:hover, .bootstrap-datetimepicker-widget table td.second:hover {
|
||||||
|
background: #e9ecef;
|
||||||
|
cursor: pointer; }
|
||||||
|
.bootstrap-datetimepicker-widget table td.old, .bootstrap-datetimepicker-widget table td.new {
|
||||||
|
color: #6c757d; }
|
||||||
|
.bootstrap-datetimepicker-widget table td.today {
|
||||||
|
position: relative; }
|
||||||
|
.bootstrap-datetimepicker-widget table td.today:before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
border: solid transparent;
|
||||||
|
border-width: 0 0 7px 7px;
|
||||||
|
border-bottom-color: #007bff;
|
||||||
|
border-top-color: rgba(0, 0, 0, 0.2);
|
||||||
|
position: absolute;
|
||||||
|
bottom: 4px;
|
||||||
|
right: 4px; }
|
||||||
|
.bootstrap-datetimepicker-widget table td.active, .bootstrap-datetimepicker-widget table td.active:hover {
|
||||||
|
background-color: #007bff;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); }
|
||||||
|
.bootstrap-datetimepicker-widget table td.active.today:before {
|
||||||
|
border-bottom-color: #fff; }
|
||||||
|
.bootstrap-datetimepicker-widget table td.disabled, .bootstrap-datetimepicker-widget table td.disabled:hover {
|
||||||
|
background: none;
|
||||||
|
color: #6c757d;
|
||||||
|
cursor: not-allowed; }
|
||||||
|
.bootstrap-datetimepicker-widget table td span {
|
||||||
|
display: inline-block;
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
line-height: 54px;
|
||||||
|
margin: 2px 1.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0.25rem; }
|
||||||
|
.bootstrap-datetimepicker-widget table td span:hover {
|
||||||
|
background: #e9ecef; }
|
||||||
|
.bootstrap-datetimepicker-widget table td span.active {
|
||||||
|
background-color: #007bff;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); }
|
||||||
|
.bootstrap-datetimepicker-widget table td span.old {
|
||||||
|
color: #6c757d; }
|
||||||
|
.bootstrap-datetimepicker-widget table td span.disabled, .bootstrap-datetimepicker-widget table td span.disabled:hover {
|
||||||
|
background: none;
|
||||||
|
color: #6c757d;
|
||||||
|
cursor: not-allowed; }
|
||||||
|
.bootstrap-datetimepicker-widget.usetwentyfour td.hour {
|
||||||
|
height: 27px;
|
||||||
|
line-height: 27px; }
|
||||||
|
|
||||||
|
.input-group [data-toggle="datetimepicker"] {
|
||||||
|
cursor: pointer; }
|
7
static/vendor/tempusdominus/js/tempusdominus-bootstrap-4.min.js
vendored
Normal file
7
static/vendor/tempusdominus/js/tempusdominus-bootstrap-4.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
{{define "title"}}{{.Title}}{{end}}
|
{{define "title"}}{{.Title}}{{end}}
|
||||||
|
|
||||||
|
{{define "extra_css"}}
|
||||||
|
<link href="/static/vendor/tempusdominus/css/tempusdominus-bootstrap-4.min.css" rel="stylesheet">
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{define "page_body"}}
|
{{define "page_body"}}
|
||||||
|
|
||||||
<!-- Page Heading -->
|
<!-- Page Heading -->
|
||||||
|
@ -11,7 +15,7 @@
|
||||||
<div class="card-body text-form-error">{{.Error}}</div>
|
<div class="card-body text-form-error">{{.Error}}</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<form action="{{.CurrentURL}}" method="POST" autocomplete="off">
|
<form id="user_form" action="{{.CurrentURL}}" method="POST" autocomplete="off">
|
||||||
<div class="form-group row">
|
<div class="form-group row">
|
||||||
<label for="idUsername" class="col-sm-2 col-form-label">Username</label>
|
<label for="idUsername" class="col-sm-2 col-form-label">Username</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
|
@ -21,6 +25,27 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group row">
|
||||||
|
<label for="idStatus" class="col-sm-2 col-form-label">Status</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<select class="form-control" id="idStatus" name="status">
|
||||||
|
<option value="1" {{if eq .User.Status 1 }}selected{{end}}>Active</option>
|
||||||
|
<option value="0" {{if eq .User.Status 0 }}selected{{end}}>Inactive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group row">
|
||||||
|
<label for="idExpirationDate" class="col-sm-2 col-form-label">Expiration Date</label>
|
||||||
|
<div class="col-sm-10 input-group date" id="expirationDatePicker" data-target-input="nearest">
|
||||||
|
<input type="text" class="form-control datetimepicker-input" id="idExpirationDate"
|
||||||
|
data-target="#expirationDatePicker">
|
||||||
|
<div class="input-group-append" data-target="#expirationDatePicker" data-toggle="datetimepicker">
|
||||||
|
<div class="input-group-text"><i class="fas fa-calendar"></i></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group row">
|
<div class="form-group row">
|
||||||
<label for="idPassword" class="col-sm-2 col-form-label">Password</label>
|
<label for="idPassword" class="col-sm-2 col-form-label">Password</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
|
@ -129,7 +154,47 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" name="expiration_date" id="hidden_start_datetime" value="">
|
||||||
<button type="submit" class="btn btn-primary float-right mt-3 mb-5 px-5 px-3">Submit</button>
|
<button type="submit" class="btn btn-primary float-right mt-3 mb-5 px-5 px-3">Submit</button>
|
||||||
</form>
|
</form>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "extra_js"}}
|
||||||
|
<script src="/static/vendor/moment/js/moment.min.js"></script>
|
||||||
|
<script src="/static/vendor/tempusdominus/js/tempusdominus-bootstrap-4.min.js"></script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
$(document).ready(function () {
|
||||||
|
|
||||||
|
$('#expirationDatePicker').datetimepicker({
|
||||||
|
format: 'YYYY-MM-DD',
|
||||||
|
buttons: {
|
||||||
|
showClear: false,
|
||||||
|
showClose: true,
|
||||||
|
showToday: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
{{ if gt .User.ExpirationDate 0 }}
|
||||||
|
var input_dt = moment({{.User.ExpirationDate }}).format('YYYY-MM-DD');
|
||||||
|
$('#idExpirationDate').val(input_dt);
|
||||||
|
$('#expirationDatePicker').datetimepicker('viewDate', input_dt);
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
$("#user_form").submit(function( event ) {
|
||||||
|
var dt = $('#idExpirationDate').val();
|
||||||
|
if (dt){
|
||||||
|
var d = $('#expirationDatePicker').datetimepicker('viewDate');
|
||||||
|
if (d){
|
||||||
|
var dateString = moment(d).format('YYYY-MM-DD HH:mm:ss');
|
||||||
|
$('#hidden_start_datetime').val(dateString);
|
||||||
|
} else {
|
||||||
|
$('#hidden_start_datetime').val("");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$('#hidden_start_datetime').val("");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{{end}}
|
{{end}}
|
|
@ -29,6 +29,8 @@
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Username</th>
|
<th>Username</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Expiration</th>
|
||||||
<th>Permissions</th>
|
<th>Permissions</th>
|
||||||
<th>Bandwidth</th>
|
<th>Bandwidth</th>
|
||||||
<th>Quota</th>
|
<th>Quota</th>
|
||||||
|
@ -40,6 +42,8 @@
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{.ID}}</td>
|
<td>{{.ID}}</td>
|
||||||
<td>{{.Username}}</td>
|
<td>{{.Username}}</td>
|
||||||
|
<td>{{if eq .Status 1 }}Active{{else}}Inactive{{end}}</td>
|
||||||
|
<td>{{.GetExpirationDateAsString}}</td>
|
||||||
<td>{{.GetPermissionsAsString}}</td>
|
<td>{{.GetPermissionsAsString}}</td>
|
||||||
<td>{{.GetBandwidthAsString}}</td>
|
<td>{{.GetBandwidthAsString}}</td>
|
||||||
<td>{{.GetQuotaSummary}}</td>
|
<td>{{.GetQuotaSummary}}</td>
|
||||||
|
|
Loading…
Reference in a new issue