diff --git a/.travis.yml b/.travis.yml index ab501d27..7de5a331 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,7 +12,7 @@ env: - GO111MODULE=on 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: - go get -v -t ./... diff --git a/README.md b/README.md index 9132ee40..66ff9d76 100644 --- a/README.md +++ b/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. -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: @@ -329,11 +329,13 @@ For each account the following properties can be configured: - `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 `$$$$`, 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. -- `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. -- `max_sessions` maximum concurrent sessions. 0 means unlimited -- `quota_size` maximum size allowed as bytes. 0 means unlimited -- `quota_files` maximum number of files allowed. 0 means unlimited +- `max_sessions` maximum concurrent sessions. 0 means unlimited. +- `quota_size` maximum size allowed as bytes. 0 means unlimited. +- `quota_files` maximum number of files allowed. 0 means unlimited. - `permissions` the following permissions are supported: - `*` all permissions are granted - `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 - `create_dirs` create directories is allowed - `create_symlinks` create symbolic links is allowed -- `upload_bandwidth` maximum upload bandwidth as KB/s, 0 means unlimited -- `download_bandwidth` maximum download 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. 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. diff --git a/cmd/portable.go b/cmd/portable.go index d21c98f9..cc349220 100644 --- a/cmd/portable.go +++ b/cmd/portable.go @@ -49,6 +49,7 @@ Please take a look at the usage below to customize the serving parameters`, PublicKeys: portablePublicKeys, Permissions: portablePermissions, HomeDir: portableDir, + Status: 1, }, } if err := service.StartPortableMode(portableSFTPDPort, portableEnableSCP, portableAdvertiseService, diff --git a/dataprovider/bolt.go b/dataprovider/bolt.go index 5d071956..0db7a152 100644 --- a/dataprovider/bolt.go +++ b/dataprovider/bolt.go @@ -13,9 +13,15 @@ import ( bolt "go.etcd.io/bbolt" ) +const ( + databaseVersion = 2 +) + var ( usersBucket = []byte("users") usersIDIdxBucket = []byte("users_id_idx") + dbVersionBucket = []byte("db_version") + dbVersionKey = []byte("version") ) // BoltProvider auth provider for bolt key/value store @@ -23,6 +29,10 @@ type BoltProvider struct { dbHandle *bolt.DB } +type boltDatabaseVersion struct { + Version int +} + func initializeBoltProvider(basePath string) error { var err error logSender = BoltDataProviderName @@ -52,7 +62,16 @@ func initializeBoltProvider(basePath string) error { providerLog(logger.LevelWarn, "error creating username idx bucket: %v", err) return err } + err = dbHandle.Update(func(tx *bolt.Tx) error { + _, e := tx.CreateBucketIfNotExists(dbVersionBucket) + return e + }) + if err != nil { + providerLog(logger.LevelWarn, "error creating database version bucket: %v", err) + return err + } provider = BoltProvider{dbHandle: dbHandle} + err = checkBoltDatabaseVersion(dbHandle) } else { 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) 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) }) @@ -112,6 +131,30 @@ func (p BoltProvider) getUserByID(ID int64) (User, error) { 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 { return p.dbHandle.Update(func(tx *bolt.Tx) error { bucket, _, err := getBuckets(tx) @@ -120,7 +163,7 @@ func (p BoltProvider) updateQuota(username string, filesAdd int, sizeAdd int64, } var u []byte 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 err = json.Unmarshal(u, &user) @@ -322,3 +365,90 @@ func getBuckets(tx *bolt.Tx) (*bolt.Bucket, *bolt.Bucket, error) { } 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 +} diff --git a/dataprovider/dataprovider.go b/dataprovider/dataprovider.go index b038a4b7..a0f923b4 100644 --- a/dataprovider/dataprovider.go +++ b/dataprovider/dataprovider.go @@ -160,6 +160,7 @@ type Provider interface { deleteUser(user User) error getUsers(limit int, offset int, order string, username string) ([]User, error) getUserByID(ID int64) (User, error) + updateLastLogin(username string) error checkAvailability() error close() error } @@ -203,6 +204,14 @@ func CheckUserAndPubKey(p Provider, username string, pubKey string) (User, strin 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. // 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 { @@ -211,6 +220,9 @@ func UpdateUserQuota(p Provider, user User, filesAdd int, sizeAdd int64, reset b } else if config.TrackQuota == 2 && !reset && !user.HasQuotaRestrictions() { return nil } + if config.ManageUsers == 0 { + return &MethodDisabledError{err: manageUsersDisabledError} + } return p.updateQuota(user.Username, filesAdd, sizeAdd, reset) } @@ -311,6 +323,9 @@ func validateUser(user *User) error { if err := validatePermissions(user); err != nil { 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) { pwd, err := argon2id.CreateHash(user.Password, argon2id.DefaultParams) if err != nil { @@ -327,8 +342,22 @@ func validateUser(user *User) error { 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) { - var err error + err := checkLoginConditions(user) + if err != nil { + return user, err + } if len(user.Password) == 0 { 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) { + err := checkLoginConditions(user) + if err != nil { + return user, "", err + } if len(user.PublicKeys) == 0 { return user, "", errors.New("Invalid credentials") } diff --git a/dataprovider/memory.go b/dataprovider/memory.go index 4c4b7fba..761991e6 100644 --- a/dataprovider/memory.go +++ b/dataprovider/memory.go @@ -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)} } +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 { p.dbHandle.lock.Lock() defer p.dbHandle.lock.Unlock() diff --git a/dataprovider/mysql.go b/dataprovider/mysql.go index 2bacf88a..fd6aa960 100644 --- a/dataprovider/mysql.go +++ b/dataprovider/mysql.go @@ -64,6 +64,10 @@ func (p MySQLProvider) updateQuota(username string, filesAdd int, sizeAdd int64, return sqlCommonUpdateQuota(username, filesAdd, sizeAdd, reset, p.dbHandle) } +func (p MySQLProvider) updateLastLogin(username string) error { + return sqlCommonUpdateLastLogin(username, p.dbHandle) +} + func (p MySQLProvider) getUsedQuota(username string) (int, int64, error) { return sqlCommonGetUsedQuota(username, p.dbHandle) } diff --git a/dataprovider/pgsql.go b/dataprovider/pgsql.go index 8b292add..b5df2465 100644 --- a/dataprovider/pgsql.go +++ b/dataprovider/pgsql.go @@ -63,6 +63,10 @@ func (p PGSQLProvider) updateQuota(username string, filesAdd int, sizeAdd int64, return sqlCommonUpdateQuota(username, filesAdd, sizeAdd, reset, p.dbHandle) } +func (p PGSQLProvider) updateLastLogin(username string) error { + return sqlCommonUpdateLastLogin(username, p.dbHandle) +} + func (p PGSQLProvider) getUsedQuota(username string) (int, int64, error) { return sqlCommonGetUsedQuota(username, p.dbHandle) } diff --git a/dataprovider/sqlcommon.go b/dataprovider/sqlcommon.go index f5443124..c70e16f4 100644 --- a/dataprovider/sqlcommon.go +++ b/dataprovider/sqlcommon.go @@ -81,10 +81,27 @@ func sqlCommonUpdateQuota(username string, filesAdd int, sizeAdd int64, reset bo defer stmt.Close() _, err = stmt.Exec(sizeAdd, filesAdd, utils.GetTimeAsMsSinceEpoch(time.Now()), username) 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) } 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 } @@ -142,7 +159,7 @@ func sqlCommonAddUser(user User, dbHandle *sql.DB) error { return err } _, err = stmt.Exec(user.Username, user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions, user.QuotaSize, - user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth) + user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate) return err } @@ -167,7 +184,7 @@ func sqlCommonUpdateUser(user User, dbHandle *sql.DB) error { return err } _, err = stmt.Exec(user.Password, string(publicKeys), user.HomeDir, user.UID, user.GID, user.MaxSessions, user.QuotaSize, - user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.ID) + user.QuotaFiles, string(permissions), user.UploadBandwidth, user.DownloadBandwidth, user.Status, user.ExpirationDate, user.ID) return err } @@ -224,12 +241,12 @@ func getUserFromDbRow(row *sql.Row, rows *sql.Rows) (User, error) { if row != nil { err = row.Scan(&user.ID, &user.Username, &password, &publicKey, &user.HomeDir, &user.UID, &user.GID, &user.MaxSessions, &user.QuotaSize, &user.QuotaFiles, &permissions, &user.UsedQuotaSize, &user.UsedQuotaFiles, &user.LastQuotaUpdate, - &user.UploadBandwidth, &user.DownloadBandwidth) + &user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status) } else { err = rows.Scan(&user.ID, &user.Username, &password, &publicKey, &user.HomeDir, &user.UID, &user.GID, &user.MaxSessions, &user.QuotaSize, &user.QuotaFiles, &permissions, &user.UsedQuotaSize, &user.UsedQuotaFiles, &user.LastQuotaUpdate, - &user.UploadBandwidth, &user.DownloadBandwidth) + &user.UploadBandwidth, &user.DownloadBandwidth, &user.ExpirationDate, &user.LastLogin, &user.Status) } if err != nil { if err == sql.ErrNoRows { diff --git a/dataprovider/sqlite.go b/dataprovider/sqlite.go index 72c7c2ec..38747637 100644 --- a/dataprovider/sqlite.go +++ b/dataprovider/sqlite.go @@ -70,6 +70,10 @@ func (p SQLiteProvider) updateQuota(username string, filesAdd int, sizeAdd int64 return sqlCommonUpdateQuota(username, filesAdd, sizeAdd, reset, p.dbHandle) } +func (p SQLiteProvider) updateLastLogin(username string) error { + return sqlCommonUpdateLastLogin(username, p.dbHandle) +} + func (p SQLiteProvider) getUsedQuota(username string) (int, int64, error) { return sqlCommonGetUsedQuota(username, p.dbHandle) } diff --git a/dataprovider/sqlqueries.go b/dataprovider/sqlqueries.go index f5f40a71..6affb113 100644 --- a/dataprovider/sqlqueries.go +++ b/dataprovider/sqlqueries.go @@ -4,7 +4,7 @@ import "fmt" const ( selectUserFields = "id,username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions," + - "used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth" + "used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth,expiration_date,last_login,status" ) func getSQLPlaceholders() []string { @@ -38,13 +38,17 @@ func getUsersQuery(order string, username string) string { func getUpdateQuotaQuery(reset bool) string { if reset { - return fmt.Sprintf(`UPDATE %v SET used_quota_size = %v,used_quota_files = %v,last_quota_update = %v + return fmt.Sprintf(`UPDATE %v SET used_quota_size = %v,used_quota_files = %v,last_quota_update = %v WHERE username = %v`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3]) } - return fmt.Sprintf(`UPDATE %v SET used_quota_size = used_quota_size + %v,used_quota_files = used_quota_files + %v,last_quota_update = %v + return fmt.Sprintf(`UPDATE %v SET used_quota_size = used_quota_size + %v,used_quota_files = used_quota_files + %v,last_quota_update = %v WHERE username = %v`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3]) } +func getUpdateLastLoginQuery() string { + return fmt.Sprintf(`UPDATE %v SET last_login = %v WHERE username = %v`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1]) +} + func getQuotaQuery() string { return fmt.Sprintf(`SELECT used_quota_size,used_quota_files FROM %v WHERE username = %v`, config.UsersTable, sqlPlaceholders[0]) @@ -52,17 +56,18 @@ func getQuotaQuery() string { func getAddUserQuery() string { return fmt.Sprintf(`INSERT INTO %v (username,password,public_keys,home_dir,uid,gid,max_sessions,quota_size,quota_files,permissions, - used_quota_size,used_quota_files,last_quota_update,upload_bandwidth,download_bandwidth) - VALUES (%v,%v,%v,%v,%v,%v,%v,%v,%v,%v,0,0,0,%v,%v)`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1], + 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,%v,0,%v)`, config.UsersTable, sqlPlaceholders[0], sqlPlaceholders[1], sqlPlaceholders[2], sqlPlaceholders[3], sqlPlaceholders[4], sqlPlaceholders[5], sqlPlaceholders[6], sqlPlaceholders[7], - sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10], sqlPlaceholders[11]) + sqlPlaceholders[8], sqlPlaceholders[9], sqlPlaceholders[10], sqlPlaceholders[11], sqlPlaceholders[12], sqlPlaceholders[13]) } func getUpdateUserQuery() string { return fmt.Sprintf(`UPDATE %v SET password=%v,public_keys=%v,home_dir=%v,uid=%v,gid=%v,max_sessions=%v,quota_size=%v, - quota_files=%v,permissions=%v,upload_bandwidth=%v,download_bandwidth=%v 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[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 { diff --git a/dataprovider/user.go b/dataprovider/user.go index c86796d3..fab34aa0 100644 --- a/dataprovider/user.go +++ b/dataprovider/user.go @@ -36,13 +36,16 @@ const ( type User struct { // Database unique identifier ID int64 `json:"id"` + // 1 enabled, 0 disabled (login is not allowed) + Status int `json:"status"` // 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. // For users created using SFTPGo REST API the password is be stored using argon2id hashing algo. - // Checking passwords stored with bcrypt 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. + // Checking passwords stored with bcrypt, pbkdf2 and sha512crypt is supported too. Password string `json:"password,omitempty"` // PublicKeys used for public key authentication. At least one between password and a public key is mandatory PublicKeys []string `json:"public_keys,omitempty"` @@ -70,6 +73,8 @@ type User struct { UploadBandwidth int64 `json:"upload_bandwidth"` // Maximum download bandwidth as KB/s, 0 means unlimited 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 @@ -175,6 +180,10 @@ func (u *User) GetBandwidthAsString() string { // Number of public keys, max sessions, uid and gid are returned func (u *User) GetInfoString() 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 { result += fmt.Sprintf("Public keys: %v ", len(u.PublicKeys)) } @@ -190,6 +199,15 @@ func (u *User) GetInfoString() string { 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 { pubKeys := make([]string, len(u.PublicKeys)) copy(pubKeys, u.PublicKeys) @@ -212,5 +230,8 @@ func (u *User) getACopy() User { LastQuotaUpdate: u.LastQuotaUpdate, UploadBandwidth: u.UploadBandwidth, DownloadBandwidth: u.DownloadBandwidth, + Status: u.Status, + ExpirationDate: u.ExpirationDate, + LastLogin: u.LastLogin, } } diff --git a/httpd/api_utils.go b/httpd/api_utils.go index d09e2d9d..7e6ee330 100644 --- a/httpd/api_utils.go +++ b/httpd/api_utils.go @@ -350,5 +350,11 @@ func compareEqualsUserFields(expected dataprovider.User, actual dataprovider.Use if expected.DownloadBandwidth != actual.DownloadBandwidth { 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 } diff --git a/httpd/httpd_test.go b/httpd/httpd_test.go index f79c5383..03d6228b 100644 --- a/httpd/httpd_test.go +++ b/httpd/httpd_test.go @@ -108,6 +108,7 @@ func TestBasicUserHandling(t *testing.T) { user.QuotaFiles = 2 user.UploadBandwidth = 128 user.DownloadBandwidth = 64 + user.ExpirationDate = utils.GetTimeAsMsSinceEpoch(time.Now()) user, _, err = httpd.UpdateUser(user, http.StatusOK) if err != nil { 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) { u := getTestUser() u.Password = "" @@ -875,6 +904,8 @@ func TestWebUserAddMock(t *testing.T) { form.Set("username", user.Username) form.Set("home_dir", user.HomeDir) form.Set("password", user.Password) + form.Set("status", strconv.Itoa(user.Status)) + form.Set("expiration_date", "") form.Set("permissions", "*") // test invalid url escape req, _ := http.NewRequest(http.MethodPost, webUserPath+"?a=%2", strings.NewReader(form.Encode())) @@ -925,6 +956,20 @@ func TestWebUserAddMock(t *testing.T) { rr = executeRequest(req) checkResponseCode(t, http.StatusOK, rr.Code) 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.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr = executeRequest(req) @@ -988,6 +1033,8 @@ func TestWebUserUpdateMock(t *testing.T) { form.Set("upload_bandwidth", "0") form.Set("download_bandwidth", "0") 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.Header.Set("Content-Type", "application/x-www-form-urlencoded") rr = executeRequest(req) @@ -1083,6 +1130,7 @@ func getTestUser() dataprovider.User { Password: defaultPassword, HomeDir: filepath.Join(homeBasePath, defaultUsername), Permissions: defaultPerms, + Status: 1, } } diff --git a/httpd/internal_test.go b/httpd/internal_test.go index e7fc1b3a..2302f023 100644 --- a/httpd/internal_test.go +++ b/httpd/internal_test.go @@ -144,6 +144,18 @@ func TestCompareUserFields(t *testing.T) { if err == nil { 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) { diff --git a/httpd/schema/openapi.yaml b/httpd/schema/openapi.yaml index 611dec28..01fdcc39 100644 --- a/httpd/schema/openapi.yaml +++ b/httpd/schema/openapi.yaml @@ -2,8 +2,8 @@ openapi: 3.0.1 info: title: SFTPGo description: 'SFTPGo REST API' - version: 1.0.0 - + version: 1.1.0 + servers: - url: /api/v1 paths: @@ -43,7 +43,7 @@ paths: - connections summary: Terminate an active connection operationId: close_connection - parameters: + parameters: - name: connectionID in: path description: ID of the connection to close @@ -57,7 +57,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 200 message: "Connection closed" error: "" @@ -67,7 +67,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 400 message: "" error: "Error description if any" @@ -77,7 +77,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 404 message: "" error: "Error description if any" @@ -87,7 +87,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 500 message: "" error: "Error description if any" @@ -125,7 +125,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 201 message: "Scan started" error: "" @@ -135,7 +135,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 400 message: "" error: "Error description if any" @@ -145,7 +145,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 403 message: "" error: "Error description if any" @@ -155,7 +155,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 404 message: "" error: "Error description if any" @@ -165,17 +165,17 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 409 message: "Another scan is already in progress" - error: "Error description if any" + error: "Error description if any" 500: description: Internal Server Error content: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 500 message: "" error: "Error description if any" @@ -234,7 +234,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 400 message: "" error: "Error description if any" @@ -244,7 +244,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 403 message: "" error: "Error description if any" @@ -254,7 +254,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 500 message: "" error: "Error description if any" @@ -282,7 +282,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 400 message: "" error: "Error description if any" @@ -292,7 +292,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 403 message: "" error: "Error description if any" @@ -302,7 +302,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 500 message: "" error: "Error description if any" @@ -313,7 +313,7 @@ paths: summary: Find user by ID description: For security reasons the hashed password is omitted in the response operationId: get_user_by_id - parameters: + parameters: - name: userID in: path description: ID of the user to retrieve @@ -334,7 +334,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 400 message: "" error: "Error description if any" @@ -344,7 +344,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 403 message: "" error: "Error description if any" @@ -354,7 +354,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 404 message: "" error: "Error description if any" @@ -364,7 +364,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 500 message: "" error: "Error description if any" @@ -373,7 +373,7 @@ paths: - users summary: Update an existing user operationId: update_user - parameters: + parameters: - name: userID in: path description: ID of the user to update @@ -394,7 +394,7 @@ paths: application/json: schema: $ref : '#/components/schemas/ApiResponse' - example: + example: status: 200 message: "User updated" error: "" @@ -404,7 +404,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 400 message: "" error: "Error description if any" @@ -414,7 +414,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 403 message: "" error: "Error description if any" @@ -424,7 +424,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 404 message: "" error: "Error description if any" @@ -434,7 +434,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 500 message: "" error: "Error description if any" @@ -443,7 +443,7 @@ paths: - users summary: Delete an existing user operationId: delete_user - parameters: + parameters: - name: userID in: path description: ID of the user to delete @@ -458,7 +458,7 @@ paths: application/json: schema: $ref : '#/components/schemas/ApiResponse' - example: + example: status: 200 message: "User deleted" error: "" @@ -468,7 +468,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 400 message: "" error: "Error description if any" @@ -478,7 +478,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 403 message: "" error: "Error description if any" @@ -488,7 +488,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 404 message: "" error: "Error description if any" @@ -498,7 +498,7 @@ paths: application/json: schema: $ref: '#/components/schemas/ApiResponse' - example: + example: status: 500 message: "" error: "Error description if any" @@ -534,8 +534,21 @@ components: type: integer format: int32 minimum: 1 + status: + type: integer + enum: + - 0 + - 1 + description: > + status: + * `0` user is disabled, login is not allowed + * `1` user is enabled username: 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: type: string nullable: true @@ -596,12 +609,16 @@ components: type: integer format: int32 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: type: object properties: operation_type: type: string - enum: + enum: - upload - download path: @@ -688,4 +705,3 @@ components: type: string commit_hash: type: string - \ No newline at end of file diff --git a/httpd/web.go b/httpd/web.go index de858dda..9c2a83e3 100644 --- a/httpd/web.go +++ b/httpd/web.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strconv" "strings" + "time" "github.com/drakkan/sftpgo/dataprovider" "github.com/drakkan/sftpgo/sftpd" @@ -27,6 +28,7 @@ const ( page500Title = "Internal Server Error" page500Body = "The server is unable to fulfill your request." defaultUsersQueryLimit = 500 + webDateTimeFormat = "2006-01-02 15:04:05" // YYYY-MM-DD HH:MM:SS ) var ( @@ -216,6 +218,19 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) { if err != nil { 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{ Username: r.Form.Get("username"), Password: r.Form.Get("password"), @@ -229,6 +244,8 @@ func getUserFromPostFields(r *http.Request) (dataprovider.User, error) { QuotaFiles: quotaFiles, UploadBandwidth: bandwidthUL, DownloadBandwidth: bandwidthDL, + Status: status, + ExpirationDate: expirationDateMillis, } return user, err } @@ -265,7 +282,7 @@ func handleGetWebUsers(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) { diff --git a/scripts/README.md b/scripts/README.md index 7a4b82c5..833842b7 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -41,7 +41,7 @@ Let's see a sample usage for each REST API. Command: ``` -python sftpgo_api_cli.py add-user test_username --password "test_pwd" --home-dir="/tmp/test_home_dir" --uid 33 --gid 1000 --max-sessions 2 --quota-size 0 --quota-files 3 --permissions "list" "download" "upload" "delete" "rename" "create_dirs" "overwrite" --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: @@ -68,6 +68,9 @@ Output: "used_quota_size": 0, "used_quota_files": 0, "last_quota_update": 0, + "last_login": 0, + "expiration_date": 1546297200000, + "status": 0, "upload_bandwidth": 100, "download_bandwidth": 60 } @@ -78,7 +81,7 @@ Output: 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: @@ -117,6 +120,9 @@ Output: "used_quota_size": 0, "used_quota_files": 0, "last_quota_update": 0, + "last_login": 0, + "expiration_date": 0, + "status": 1, "upload_bandwidth": 90, "download_bandwidth": 80 } @@ -149,6 +155,9 @@ Output: "used_quota_size": 0, "used_quota_files": 0, "last_quota_update": 0, + "last_login": 0, + "expiration_date": 0, + "status": 1, "upload_bandwidth": 90, "download_bandwidth": 80 } diff --git a/scripts/sftpgo_api_cli.py b/scripts/sftpgo_api_cli.py index 86c2ec20..e67fc286 100755 --- a/scripts/sftpgo_api_cli.py +++ b/scripts/sftpgo_api_cli.py @@ -1,5 +1,6 @@ #!/usr/bin/env python import argparse +from datetime import datetime import json import requests @@ -59,10 +60,11 @@ class SFTPGoApiRequests: 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, - download_bandwidth=0): + download_bandwidth=0, status=1, expiration_date=0): user = {"id":user_id, "username":username, "uid":uid, "gid":gid, "max_sessions":max_sessions, "quota_size":quota_size, "quota_files":quota_files, - "upload_bandwidth":upload_bandwidth, "download_bandwidth":download_bandwidth} + "upload_bandwidth":upload_bandwidth, "download_bandwidth":download_bandwidth, + "status":status, "expiration_date":expiration_date} if password: user.update({"password":password}) if public_keys: @@ -83,17 +85,18 @@ class SFTPGoApiRequests: self.printResponse(r) 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, - 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) self.printResponse(r) 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, - 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, - 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) self.printResponse(r) @@ -123,6 +126,21 @@ class SFTPGoApiRequests: 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): parser.add_argument('username', type=str) 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') parser.add_argument('-D', '--download-bandwidth', type=int, default=0, 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__': @@ -206,13 +228,13 @@ if __name__ == '__main__': args.no_color) if args.command == 'add-user': - api.addUser(args.username, args.password, args.public_keys, args.home_dir, - args.uid, args.gid, args.max_sessions, args.quota_size, args.quota_files, - args.permissions, args.upload_bandwidth, args.download_bandwidth) + api.addUser(args.username, args.password, args.public_keys, args.home_dir, args.uid, args.gid, args.max_sessions, + args.quota_size, args.quota_files, args.permissions, args.upload_bandwidth, args.download_bandwidth, + args.status, getDatetimeAsMillisSinceEpoch(args.expiration_date)) elif args.command == 'update-user': - api.updateUser(args.id, args.username, args.password, args.public_keys, args.home_dir, - args.uid, args.gid, args.max_sessions, args.quota_size, args.quota_files, - args.permissions, args.upload_bandwidth, args.download_bandwidth) + api.updateUser(args.id, args.username, args.password, args.public_keys, args.home_dir, args.uid, args.gid, + args.max_sessions, args.quota_size, args.quota_files, args.permissions, args.upload_bandwidth, + args.download_bandwidth, args.status, getDatetimeAsMillisSinceEpoch(args.expiration_date)) elif args.command == 'delete-user': api.deleteUser(args.id) elif args.command == 'get-users': diff --git a/sftpd/server.go b/sftpd/server.go index ac503ca7..6716283f 100644 --- a/sftpd/server.go +++ b/sftpd/server.go @@ -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", user.ID, loginType, user.Username, user.HomeDir, remoteAddr.String()) + dataprovider.UpdateLastLogin(dataProvider, user) go ssh.DiscardRequests(reqs) diff --git a/sftpd/sftpd_test.go b/sftpd/sftpd_test.go index c4ef43e1..2f2d0935 100644 --- a/sftpd/sftpd_test.go +++ b/sftpd/sftpd_test.go @@ -28,6 +28,7 @@ import ( "github.com/drakkan/sftpgo/httpd" "github.com/drakkan/sftpgo/logger" "github.com/drakkan/sftpgo/sftpd" + "github.com/drakkan/sftpgo/utils" "github.com/pkg/sftp" "github.com/rs/zerolog" ) @@ -687,6 +688,13 @@ func TestLogin(t *testing.T) { if err != nil { 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) if err != nil { @@ -740,6 +748,97 @@ func TestLogin(t *testing.T) { 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) { usePubKey := false user, _, err := httpd.AddUser(getTestUser(usePubKey), http.StatusOK) @@ -2330,10 +2429,12 @@ func waitTCPListening(address string) { func getTestUser(usePubKey bool) dataprovider.User { user := dataprovider.User{ - Username: defaultUsername, - Password: defaultPassword, - HomeDir: filepath.Join(homeBasePath, defaultUsername), - Permissions: allPerms, + Username: defaultUsername, + Password: defaultPassword, + HomeDir: filepath.Join(homeBasePath, defaultUsername), + Permissions: allPerms, + Status: 1, + ExpirationDate: 0, } if usePubKey { user.PublicKeys = []string{testPubKey} diff --git a/sql/mysql/20191112.sql b/sql/mysql/20191112.sql new file mode 100644 index 00000000..18e444f8 --- /dev/null +++ b/sql/mysql/20191112.sql @@ -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; \ No newline at end of file diff --git a/sql/pgsql/20191112.sql b/sql/pgsql/20191112.sql new file mode 100644 index 00000000..f667a8a3 --- /dev/null +++ b/sql/pgsql/20191112.sql @@ -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; \ No newline at end of file diff --git a/sql/sqlite/20191112.sql b/sql/sqlite/20191112.sql new file mode 100644 index 00000000..201d9a9c --- /dev/null +++ b/sql/sqlite/20191112.sql @@ -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; \ No newline at end of file diff --git a/static/vendor/fontawesome-free/svgs/solid/calendar.svg b/static/vendor/fontawesome-free/svgs/solid/calendar.svg new file mode 100644 index 00000000..2d3eefe8 --- /dev/null +++ b/static/vendor/fontawesome-free/svgs/solid/calendar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/vendor/fonts/LICENSE.txt b/static/vendor/fonts/LICENSE.txt new file mode 100644 index 00000000..75b52484 --- /dev/null +++ b/static/vendor/fonts/LICENSE.txt @@ -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. diff --git a/static/vendor/fonts/README.txt b/static/vendor/fonts/README.txt new file mode 100644 index 00000000..93b476cf --- /dev/null +++ b/static/vendor/fonts/README.txt @@ -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) diff --git a/static/vendor/fonts/Roboto-Bold-webfont.woff b/static/vendor/fonts/Roboto-Bold-webfont.woff new file mode 100644 index 00000000..03357ce4 Binary files /dev/null and b/static/vendor/fonts/Roboto-Bold-webfont.woff differ diff --git a/static/vendor/fonts/Roboto-Light-webfont.woff b/static/vendor/fonts/Roboto-Light-webfont.woff new file mode 100644 index 00000000..f6abd871 Binary files /dev/null and b/static/vendor/fonts/Roboto-Light-webfont.woff differ diff --git a/static/vendor/fonts/Roboto-Regular-webfont.woff b/static/vendor/fonts/Roboto-Regular-webfont.woff new file mode 100644 index 00000000..6ff6afd8 Binary files /dev/null and b/static/vendor/fonts/Roboto-Regular-webfont.woff differ diff --git a/static/vendor/moment/js/moment.min.js b/static/vendor/moment/js/moment.min.js new file mode 100644 index 00000000..5787a408 --- /dev/null +++ b/static/vendor/moment/js/moment.min.js @@ -0,0 +1 @@ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):e.moment=t()}(this,function(){"use strict";var e,i;function c(){return e.apply(null,arguments)}function o(e){return e instanceof Array||"[object Array]"===Object.prototype.toString.call(e)}function u(e){return null!=e&&"[object Object]"===Object.prototype.toString.call(e)}function l(e){return void 0===e}function h(e){return"number"==typeof e||"[object Number]"===Object.prototype.toString.call(e)}function d(e){return e instanceof Date||"[object Date]"===Object.prototype.toString.call(e)}function f(e,t){var n,s=[];for(n=0;n>>0,s=0;sSe(e)?(r=e+1,o-Se(e)):(r=e,o),{year:r,dayOfYear:a}}function Ie(e,t,n){var s,i,r=Ve(e.year(),t,n),a=Math.floor((e.dayOfYear()-r-1)/7)+1;return a<1?s=a+Ae(i=e.year()-1,t,n):a>Ae(e.year(),t,n)?(s=a-Ae(e.year(),t,n),i=e.year()+1):(i=e.year(),s=a),{week:s,year:i}}function Ae(e,t,n){var s=Ve(e,t,n),i=Ve(e+1,t,n);return(Se(e)-s+i)/7}I("w",["ww",2],"wo","week"),I("W",["WW",2],"Wo","isoWeek"),C("week","w"),C("isoWeek","W"),F("week",5),F("isoWeek",5),ue("w",B),ue("ww",B,z),ue("W",B),ue("WW",B,z),fe(["w","ww","W","WW"],function(e,t,n,s){t[s.substr(0,1)]=D(e)});function je(e,t){return e.slice(t,7).concat(e.slice(0,t))}I("d",0,"do","day"),I("dd",0,0,function(e){return this.localeData().weekdaysMin(this,e)}),I("ddd",0,0,function(e){return this.localeData().weekdaysShort(this,e)}),I("dddd",0,0,function(e){return this.localeData().weekdays(this,e)}),I("e",0,0,"weekday"),I("E",0,0,"isoWeekday"),C("day","d"),C("weekday","e"),C("isoWeekday","E"),F("day",11),F("weekday",11),F("isoWeekday",11),ue("d",B),ue("e",B),ue("E",B),ue("dd",function(e,t){return t.weekdaysMinRegex(e)}),ue("ddd",function(e,t){return t.weekdaysShortRegex(e)}),ue("dddd",function(e,t){return t.weekdaysRegex(e)}),fe(["dd","ddd","dddd"],function(e,t,n,s){var i=n._locale.weekdaysParse(e,s,n._strict);null!=i?t.d=i:g(n).invalidWeekday=e}),fe(["d","e","E"],function(e,t,n,s){t[s]=D(e)});var Ze="Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_");var ze="Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_");var $e="Su_Mo_Tu_We_Th_Fr_Sa".split("_");var qe=ae;var Je=ae;var Be=ae;function Qe(){function e(e,t){return t.length-e.length}var t,n,s,i,r,a=[],o=[],u=[],l=[];for(t=0;t<7;t++)n=y([2e3,1]).day(t),s=this.weekdaysMin(n,""),i=this.weekdaysShort(n,""),r=this.weekdays(n,""),a.push(s),o.push(i),u.push(r),l.push(s),l.push(i),l.push(r);for(a.sort(e),o.sort(e),u.sort(e),l.sort(e),t=0;t<7;t++)o[t]=he(o[t]),u[t]=he(u[t]),l[t]=he(l[t]);this._weekdaysRegex=new RegExp("^("+l.join("|")+")","i"),this._weekdaysShortRegex=this._weekdaysRegex,this._weekdaysMinRegex=this._weekdaysRegex,this._weekdaysStrictRegex=new RegExp("^("+u.join("|")+")","i"),this._weekdaysShortStrictRegex=new RegExp("^("+o.join("|")+")","i"),this._weekdaysMinStrictRegex=new RegExp("^("+a.join("|")+")","i")}function Xe(){return this.hours()%12||12}function Ke(e,t){I(e,0,0,function(){return this.localeData().meridiem(this.hours(),this.minutes(),t)})}function et(e,t){return t._meridiemParse}I("H",["HH",2],0,"hour"),I("h",["hh",2],0,Xe),I("k",["kk",2],0,function(){return this.hours()||24}),I("hmm",0,0,function(){return""+Xe.apply(this)+L(this.minutes(),2)}),I("hmmss",0,0,function(){return""+Xe.apply(this)+L(this.minutes(),2)+L(this.seconds(),2)}),I("Hmm",0,0,function(){return""+this.hours()+L(this.minutes(),2)}),I("Hmmss",0,0,function(){return""+this.hours()+L(this.minutes(),2)+L(this.seconds(),2)}),Ke("a",!0),Ke("A",!1),C("hour","h"),F("hour",13),ue("a",et),ue("A",et),ue("H",B),ue("h",B),ue("k",B),ue("HH",B,z),ue("hh",B,z),ue("kk",B,z),ue("hmm",Q),ue("hmmss",X),ue("Hmm",Q),ue("Hmmss",X),ce(["H","HH"],ge),ce(["k","kk"],function(e,t,n){var s=D(e);t[ge]=24===s?0:s}),ce(["a","A"],function(e,t,n){n._isPm=n._locale.isPM(e),n._meridiem=e}),ce(["h","hh"],function(e,t,n){t[ge]=D(e),g(n).bigHour=!0}),ce("hmm",function(e,t,n){var s=e.length-2;t[ge]=D(e.substr(0,s)),t[ve]=D(e.substr(s)),g(n).bigHour=!0}),ce("hmmss",function(e,t,n){var s=e.length-4,i=e.length-2;t[ge]=D(e.substr(0,s)),t[ve]=D(e.substr(s,2)),t[pe]=D(e.substr(i)),g(n).bigHour=!0}),ce("Hmm",function(e,t,n){var s=e.length-2;t[ge]=D(e.substr(0,s)),t[ve]=D(e.substr(s))}),ce("Hmmss",function(e,t,n){var s=e.length-4,i=e.length-2;t[ge]=D(e.substr(0,s)),t[ve]=D(e.substr(s,2)),t[pe]=D(e.substr(i))});var tt,nt=Te("Hours",!0),st={calendar:{sameDay:"[Today at] LT",nextDay:"[Tomorrow at] LT",nextWeek:"dddd [at] LT",lastDay:"[Yesterday at] LT",lastWeek:"[Last] dddd [at] LT",sameElse:"L"},longDateFormat:{LTS:"h:mm:ss A",LT:"h:mm A",L:"MM/DD/YYYY",LL:"MMMM D, YYYY",LLL:"MMMM D, YYYY h:mm A",LLLL:"dddd, MMMM D, YYYY h:mm A"},invalidDate:"Invalid date",ordinal:"%d",dayOfMonthOrdinalParse:/\d{1,2}/,relativeTime:{future:"in %s",past:"%s ago",s:"a few seconds",ss:"%d seconds",m:"a minute",mm:"%d minutes",h:"an hour",hh:"%d hours",d:"a day",dd:"%d days",M:"a month",MM:"%d months",y:"a year",yy:"%d years"},months:Ce,monthsShort:He,week:{dow:0,doy:6},weekdays:Ze,weekdaysMin:$e,weekdaysShort:ze,meridiemParse:/[ap]\.?m?\.?/i},it={},rt={};function at(e){return e?e.toLowerCase().replace("_","-"):e}function ot(e){var t=null;if(!it[e]&&"undefined"!=typeof module&&module&&module.exports)try{t=tt._abbr,require("./locale/"+e),ut(t)}catch(e){}return it[e]}function ut(e,t){var n;return e&&((n=l(t)?ht(e):lt(e,t))?tt=n:"undefined"!=typeof console&&console.warn&&console.warn("Locale "+e+" not found. Did you forget to load it?")),tt._abbr}function lt(e,t){if(null===t)return delete it[e],null;var n,s=st;if(t.abbr=e,null!=it[e])T("defineLocaleOverride","use moment.updateLocale(localeName, config) to change an existing locale. moment.defineLocale(localeName, config) should only be used for creating a new locale See http://momentjs.com/guides/#/warnings/define-locale/ for more info."),s=it[e]._config;else if(null!=t.parentLocale)if(null!=it[t.parentLocale])s=it[t.parentLocale]._config;else{if(null==(n=ot(t.parentLocale)))return rt[t.parentLocale]||(rt[t.parentLocale]=[]),rt[t.parentLocale].push({name:e,config:t}),null;s=n._config}return it[e]=new P(x(s,t)),rt[e]&&rt[e].forEach(function(e){lt(e.name,e.config)}),ut(e),it[e]}function ht(e){var t;if(e&&e._locale&&e._locale._abbr&&(e=e._locale._abbr),!e)return tt;if(!o(e)){if(t=ot(e))return t;e=[e]}return function(e){for(var t,n,s,i,r=0;r=t&&a(i,n,!0)>=t-1)break;t--}r++}return tt}(e)}function dt(e){var t,n=e._a;return n&&-2===g(e).overflow&&(t=n[_e]<0||11Pe(n[me],n[_e])?ye:n[ge]<0||24Ae(n,r,a)?g(e)._overflowWeeks=!0:null!=u?g(e)._overflowWeekday=!0:(o=Ee(n,s,i,r,a),e._a[me]=o.year,e._dayOfYear=o.dayOfYear)}(e),null!=e._dayOfYear&&(r=ct(e._a[me],s[me]),(e._dayOfYear>Se(r)||0===e._dayOfYear)&&(g(e)._overflowDayOfYear=!0),n=Ge(r,0,e._dayOfYear),e._a[_e]=n.getUTCMonth(),e._a[ye]=n.getUTCDate()),t=0;t<3&&null==e._a[t];++t)e._a[t]=a[t]=s[t];for(;t<7;t++)e._a[t]=a[t]=null==e._a[t]?2===t?1:0:e._a[t];24===e._a[ge]&&0===e._a[ve]&&0===e._a[pe]&&0===e._a[we]&&(e._nextDay=!0,e._a[ge]=0),e._d=(e._useUTC?Ge:function(e,t,n,s,i,r,a){var o;return e<100&&0<=e?(o=new Date(e+400,t,n,s,i,r,a),isFinite(o.getFullYear())&&o.setFullYear(e)):o=new Date(e,t,n,s,i,r,a),o}).apply(null,a),i=e._useUTC?e._d.getUTCDay():e._d.getDay(),null!=e._tzm&&e._d.setUTCMinutes(e._d.getUTCMinutes()-e._tzm),e._nextDay&&(e._a[ge]=24),e._w&&void 0!==e._w.d&&e._w.d!==i&&(g(e).weekdayMismatch=!0)}}var mt=/^\s*((?:[+-]\d{6}|\d{4})-(?:\d\d-\d\d|W\d\d-\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?::\d\d(?::\d\d(?:[.,]\d+)?)?)?)([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,_t=/^\s*((?:[+-]\d{6}|\d{4})(?:\d\d\d\d|W\d\d\d|W\d\d|\d\d\d|\d\d))(?:(T| )(\d\d(?:\d\d(?:\d\d(?:[.,]\d+)?)?)?)([\+\-]\d\d(?::?\d\d)?|\s*Z)?)?$/,yt=/Z|[+-]\d\d(?::?\d\d)?/,gt=[["YYYYYY-MM-DD",/[+-]\d{6}-\d\d-\d\d/],["YYYY-MM-DD",/\d{4}-\d\d-\d\d/],["GGGG-[W]WW-E",/\d{4}-W\d\d-\d/],["GGGG-[W]WW",/\d{4}-W\d\d/,!1],["YYYY-DDD",/\d{4}-\d{3}/],["YYYY-MM",/\d{4}-\d\d/,!1],["YYYYYYMMDD",/[+-]\d{10}/],["YYYYMMDD",/\d{8}/],["GGGG[W]WWE",/\d{4}W\d{3}/],["GGGG[W]WW",/\d{4}W\d{2}/,!1],["YYYYDDD",/\d{7}/]],vt=[["HH:mm:ss.SSSS",/\d\d:\d\d:\d\d\.\d+/],["HH:mm:ss,SSSS",/\d\d:\d\d:\d\d,\d+/],["HH:mm:ss",/\d\d:\d\d:\d\d/],["HH:mm",/\d\d:\d\d/],["HHmmss.SSSS",/\d\d\d\d\d\d\.\d+/],["HHmmss,SSSS",/\d\d\d\d\d\d,\d+/],["HHmmss",/\d\d\d\d\d\d/],["HHmm",/\d\d\d\d/],["HH",/\d\d/]],pt=/^\/?Date\((\-?\d+)/i;function wt(e){var t,n,s,i,r,a,o=e._i,u=mt.exec(o)||_t.exec(o);if(u){for(g(e).iso=!0,t=0,n=gt.length;tn.valueOf():n.valueOf()this.clone().month(0).utcOffset()||this.utcOffset()>this.clone().month(5).utcOffset()},mn.isLocal=function(){return!!this.isValid()&&!this._isUTC},mn.isUtcOffset=function(){return!!this.isValid()&&this._isUTC},mn.isUtc=Et,mn.isUTC=Et,mn.zoneAbbr=function(){return this._isUTC?"UTC":""},mn.zoneName=function(){return this._isUTC?"Coordinated Universal Time":""},mn.dates=n("dates accessor is deprecated. Use date instead.",un),mn.months=n("months accessor is deprecated. Use month instead",Ue),mn.years=n("years accessor is deprecated. Use year instead",Oe),mn.zone=n("moment().zone is deprecated, use moment().utcOffset instead. http://momentjs.com/guides/#/warnings/zone/",function(e,t){return null!=e?("string"!=typeof e&&(e=-e),this.utcOffset(e,t),this):-this.utcOffset()}),mn.isDSTShifted=n("isDSTShifted is deprecated. See http://momentjs.com/guides/#/warnings/dst-shifted/ for more information",function(){if(!l(this._isDSTShifted))return this._isDSTShifted;var e={};if(w(e,this),(e=Ot(e))._a){var t=e._isUTC?y(e._a):bt(e._a);this._isDSTShifted=this.isValid()&&0=4)throw new Error("Tempus Dominus Bootstrap4's requires at least jQuery v3.0.0 but less than v4.0.0")}(jQuery),"undefined"==typeof moment)throw new Error("Tempus Dominus Bootstrap4's requires moment.js. Moment.js must be included before Tempus Dominus Bootstrap4's JavaScript.");var version=moment.version.split(".");if(version[0]<=2&&version[1]<17||version[0]>=3)throw new Error("Tempus Dominus Bootstrap4's requires at least moment.js v2.17.0 but less than v3.0.0");+function(){function a(a,b){if(!a)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return!b||"object"!=typeof b&&"function"!=typeof b?a:b}function b(a,b){if("function"!=typeof b&&null!==b)throw new TypeError("Super expression must either be null or a function, not "+typeof b);a.prototype=Object.create(b&&b.prototype,{constructor:{value:a,enumerable:!1,writable:!0,configurable:!0}}),b&&(Object.setPrototypeOf?Object.setPrototypeOf(a,b):a.__proto__=b)}function c(a,b){if(!(a instanceof b))throw new TypeError("Cannot call a class as a function")}var d="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(a){return typeof a}:function(a){return a&&"function"==typeof Symbol&&a.constructor===Symbol&&a!==Symbol.prototype?"symbol":typeof a},e=function(){function a(a,b){for(var c=0;c1){for(var e=0;e1)throw new TypeError("isEnabled expects a single character string parameter");switch(a){case"y":return this.actualFormat.indexOf("Y")!==-1;case"M":return this.actualFormat.indexOf("M")!==-1;case"d":return this.actualFormat.toLowerCase().indexOf("d")!==-1;case"h":case"H":return this.actualFormat.toLowerCase().indexOf("h")!==-1;case"m":return this.actualFormat.indexOf("m")!==-1;case"s":return this.actualFormat.indexOf("s")!==-1;case"a":case"A":return this.actualFormat.toLowerCase().indexOf("a")!==-1;default:return!1}},r.prototype._hasTime=function(){return this._isEnabled("h")||this._isEnabled("m")||this._isEnabled("s")},r.prototype._hasDate=function(){return this._isEnabled("y")||this._isEnabled("M")||this._isEnabled("d")},r.prototype._dataToOptions=function(){var b=this._element.data(),c={};return b.dateOptions&&b.dateOptions instanceof Object&&(c=a.extend(!0,c,b.dateOptions)),a.each(this._options,function(a){var d="date"+a.charAt(0).toUpperCase()+a.slice(1);void 0!==b[d]?c[a]=b[d]:delete c[a]}),c},r.prototype._notifyEvent=function(a){a.type===r.Event.CHANGE&&(a.date&&a.date.isSame(a.oldDate))||!a.date&&!a.oldDate||this._element.trigger(a)},r.prototype._viewUpdate=function(a){"y"===a&&(a="YYYY"),this._notifyEvent({type:r.Event.UPDATE,change:a,viewDate:this._viewDate.clone()})},r.prototype._showMode=function(a){this.widget&&(a&&(this.currentViewMode=Math.max(this.MinViewModeNumber,Math.min(3,this.currentViewMode+a))),this.widget.find(".datepicker > div").hide().filter(".datepicker-"+l[this.currentViewMode].CLASS_NAME).show())},r.prototype._isInDisabledDates=function(a){return this._options.disabledDates[a.format("YYYY-MM-DD")]===!0},r.prototype._isInEnabledDates=function(a){return this._options.enabledDates[a.format("YYYY-MM-DD")]===!0},r.prototype._isInDisabledHours=function(a){return this._options.disabledHours[a.format("H")]===!0},r.prototype._isInEnabledHours=function(a){return this._options.enabledHours[a.format("H")]===!0},r.prototype._isValid=function(b,c){if(!b.isValid())return!1;if(this._options.disabledDates&&"d"===c&&this._isInDisabledDates(b))return!1;if(this._options.enabledDates&&"d"===c&&!this._isInEnabledDates(b))return!1;if(this._options.minDate&&b.isBefore(this._options.minDate,c))return!1;if(this._options.maxDate&&b.isAfter(this._options.maxDate,c))return!1;if(this._options.daysOfWeekDisabled&&"d"===c&&this._options.daysOfWeekDisabled.indexOf(b.day())!==-1)return!1;if(this._options.disabledHours&&("h"===c||"m"===c||"s"===c)&&this._isInDisabledHours(b))return!1;if(this._options.enabledHours&&("h"===c||"m"===c||"s"===c)&&!this._isInEnabledHours(b))return!1;if(this._options.disabledTimeIntervals&&("h"===c||"m"===c||"s"===c)){var d=!1;if(a.each(this._options.disabledTimeIntervals,function(){if(b.isBetween(this[0],this[1]))return d=!0,!1}),d)return!1}return!0},r.prototype._parseInputDate=function(a){return void 0===this._options.parseInputDate?b.isMoment(a)||(a=this.getMoment(a)):a=this._options.parseInputDate(a),a},r.prototype._keydown=function(a){var b=null,c=void 0,d=void 0,e=void 0,f=void 0,g=[],h={},i=a.which,j="p";o[i]=j;for(c in o)o.hasOwnProperty(c)&&o[c]===j&&(g.push(c),parseInt(c,10)!==i&&(h[c]=!0));for(c in this._options.keyBinds)if(this._options.keyBinds.hasOwnProperty(c)&&"function"==typeof this._options.keyBinds[c]&&(e=c.split(" "),e.length===g.length&&m[i]===e[e.length-1])){for(f=!0,d=e.length-2;d>=0;d--)if(!(m[e[d]]in h)){f=!1;break}if(f){b=this._options.keyBinds[c];break}}b&&b.call(this)&&(a.stopPropagation(),a.preventDefault())},r.prototype._keyup=function(a){o[a.which]="r",p[a.which]&&(p[a.which]=!1,a.stopPropagation(),a.preventDefault())},r.prototype._indexGivenDates=function(b){var c={},d=this;return a.each(b,function(){var a=d._parseInputDate(this);a.isValid()&&(c[a.format("YYYY-MM-DD")]=!0)}),!!Object.keys(c).length&&c},r.prototype._indexGivenHours=function(b){var c={};return a.each(b,function(){c[this]=!0}),!!Object.keys(c).length&&c},r.prototype._initFormatting=function(){var a=this._options.format||"L LT",b=this;this.actualFormat=a.replace(/(\[[^\[]*])|(\\)?(LTS|LT|LL?L?L?|l{1,4})/g,function(a){return b._dates[0].localeData().longDateFormat(a)||a}),this.parseFormats=this._options.extraFormats?this._options.extraFormats.slice():[],this.parseFormats.indexOf(a)<0&&this.parseFormats.indexOf(this.actualFormat)<0&&this.parseFormats.push(this.actualFormat),this.use24Hours=this.actualFormat.toLowerCase().indexOf("a")<1&&this.actualFormat.replace(/\[.*?]/g,"").indexOf("h")<1,this._isEnabled("y")&&(this.MinViewModeNumber=2),this._isEnabled("M")&&(this.MinViewModeNumber=1),this._isEnabled("d")&&(this.MinViewModeNumber=0),this.currentViewMode=Math.max(this.MinViewModeNumber,this.currentViewMode),this.unset||this._setValue(this._dates[0],0)},r.prototype._getLastPickedDate=function(){return this._dates[this._getLastPickedDateIndex()]},r.prototype._getLastPickedDateIndex=function(){return this._dates.length-1},r.prototype.getMoment=function(a){var c=void 0;return c=void 0===a||null===a?b():this._hasTimeZone()?b.tz(a,this.parseFormats,this._options.locale,this._options.useStrict,this._options.timeZone):b(a,this.parseFormats,this._options.locale,this._options.useStrict),this._hasTimeZone()&&c.tz(this._options.timeZone),c},r.prototype.toggle=function(){return this.widget?this.hide():this.show()},r.prototype.ignoreReadonly=function(a){if(0===arguments.length)return this._options.ignoreReadonly;if("boolean"!=typeof a)throw new TypeError("ignoreReadonly () expects a boolean parameter");this._options.ignoreReadonly=a},r.prototype.options=function(b){if(0===arguments.length)return a.extend(!0,{},this._options);if(!(b instanceof Object))throw new TypeError("options() this.options parameter should be an object");a.extend(!0,this._options,b);var c=this;a.each(this._options,function(a,b){void 0!==c[a]&&c[a](b)})},r.prototype.date=function(a,c){if(c=c||0,0===arguments.length)return this.unset?null:this._options.allowMultidate?this._dates.join(this._options.multidateSeparator):this._dates[c].clone();if(!(null===a||"string"==typeof a||b.isMoment(a)||a instanceof Date))throw new TypeError("date() parameter must be one of [null, string, moment or Date]");this._setValue(null===a?null:this._parseInputDate(a),c)},r.prototype.format=function(a){if(0===arguments.length)return this._options.format;if("string"!=typeof a&&("boolean"!=typeof a||a!==!1))throw new TypeError("format() expects a string or boolean:false parameter "+a);this._options.format=a,this.actualFormat&&this._initFormatting()},r.prototype.timeZone=function(a){if(0===arguments.length)return this._options.timeZone;if("string"!=typeof a)throw new TypeError("newZone() expects a string parameter");this._options.timeZone=a},r.prototype.dayViewHeaderFormat=function(a){if(0===arguments.length)return this._options.dayViewHeaderFormat;if("string"!=typeof a)throw new TypeError("dayViewHeaderFormat() expects a string parameter");this._options.dayViewHeaderFormat=a},r.prototype.extraFormats=function(a){if(0===arguments.length)return this._options.extraFormats;if(a!==!1&&!(a instanceof Array))throw new TypeError("extraFormats() expects an array or false parameter");this._options.extraFormats=a,this.parseFormats&&this._initFormatting()},r.prototype.disabledDates=function(b){if(0===arguments.length)return this._options.disabledDates?a.extend({},this._options.disabledDates):this._options.disabledDates;if(!b)return this._options.disabledDates=!1,this._update(),!0;if(!(b instanceof Array))throw new TypeError("disabledDates() expects an array parameter");this._options.disabledDates=this._indexGivenDates(b),this._options.enabledDates=!1,this._update()},r.prototype.enabledDates=function(b){if(0===arguments.length)return this._options.enabledDates?a.extend({},this._options.enabledDates):this._options.enabledDates;if(!b)return this._options.enabledDates=!1,this._update(),!0;if(!(b instanceof Array))throw new TypeError("enabledDates() expects an array parameter");this._options.enabledDates=this._indexGivenDates(b),this._options.disabledDates=!1,this._update()},r.prototype.daysOfWeekDisabled=function(a){if(0===arguments.length)return this._options.daysOfWeekDisabled.splice(0);if("boolean"==typeof a&&!a)return this._options.daysOfWeekDisabled=!1,this._update(),!0;if(!(a instanceof Array))throw new TypeError("daysOfWeekDisabled() expects an array parameter");if(this._options.daysOfWeekDisabled=a.reduce(function(a,b){return b=parseInt(b,10),b>6||b<0||isNaN(b)?a:(a.indexOf(b)===-1&&a.push(b),a)},[]).sort(),this._options.useCurrent&&!this._options.keepInvalid)for(var b=0;b1)throw new TypeError("multidateSeparator expects a single character string parameter");this._options.multidateSeparator=a},e(r,null,[{key:"NAME",get:function(){return d}},{key:"DATA_KEY",get:function(){return f}},{key:"EVENT_KEY",get:function(){return g}},{key:"DATA_API_KEY",get:function(){return h}},{key:"DatePickerModes",get:function(){return l}},{key:"ViewModes",get:function(){return n}},{key:"Event",get:function(){return k}},{key:"Selector",get:function(){return i}},{key:"Default",get:function(){return q},set:function(a){q=a}},{key:"ClassName",get:function(){return j}}]),r}();return r}(jQuery,moment);(function(e){var g=e.fn[f.NAME],h=["top","bottom","auto"],i=["left","right","auto"],j=["default","top","bottom"],k=function(a){var b=a.data("target"),c=void 0;return b||(b=a.attr("href")||"",b=/^#[a-z]/i.test(b)?b:null),c=e(b),0===c.length?c:(c.data(f.DATA_KEY)||e.extend({},c.data(),e(this).data()),c)},l=function(g){function k(b,d){c(this,k);var e=a(this,g.call(this,b,d));return e._init(),e}return b(k,g),k.prototype._init=function(){if(this._element.hasClass("input-group")){var a=this._element.find(".datepickerbutton");0===a.length?this.component=this._element.find('[data-toggle="datetimepicker"]'):this.component=a}},k.prototype._getDatePickerTemplate=function(){var a=e("").append(e("").append(e("").addClass("prev").attr("data-action","previous").append(e("").addClass(this._options.icons.previous))).append(e("").addClass("picker-switch").attr("data-action","pickerSwitch").attr("colspan",""+(this._options.calendarWeeks?"6":"5"))).append(e("").addClass("next").attr("data-action","next").append(e("").addClass(this._options.icons.next)))),b=e("").append(e("").append(e("").attr("colspan",""+(this._options.calendarWeeks?"8":"7"))));return[e("
").addClass("datepicker-days").append(e("").addClass("table table-sm").append(a).append(e(""))),e("
").addClass("datepicker-months").append(e("
").addClass("table-condensed").append(a.clone()).append(b.clone())),e("
").addClass("datepicker-years").append(e("
").addClass("table-condensed").append(a.clone()).append(b.clone())),e("
").addClass("datepicker-decades").append(e("
").addClass("table-condensed").append(a.clone()).append(b.clone()))]},k.prototype._getTimePickerMainTemplate=function(){var a=e(""),b=e(""),c=e("");return this._isEnabled("h")&&(a.append(e("
").append(e("").attr({href:"#",tabindex:"-1",title:this._options.tooltips.incrementHour}).addClass("btn").attr("data-action","incrementHours").append(e("").addClass(this._options.icons.up)))),b.append(e("").append(e("").addClass("timepicker-hour").attr({"data-time-component":"hours",title:this._options.tooltips.pickHour}).attr("data-action","showHours"))),c.append(e("").append(e("").attr({href:"#",tabindex:"-1",title:this._options.tooltips.decrementHour}).addClass("btn").attr("data-action","decrementHours").append(e("").addClass(this._options.icons.down))))),this._isEnabled("m")&&(this._isEnabled("h")&&(a.append(e("").addClass("separator")),b.append(e("").addClass("separator").html(":")),c.append(e("").addClass("separator"))),a.append(e("").append(e("").attr({href:"#",tabindex:"-1",title:this._options.tooltips.incrementMinute}).addClass("btn").attr("data-action","incrementMinutes").append(e("").addClass(this._options.icons.up)))),b.append(e("").append(e("").addClass("timepicker-minute").attr({"data-time-component":"minutes",title:this._options.tooltips.pickMinute}).attr("data-action","showMinutes"))),c.append(e("").append(e("").attr({href:"#",tabindex:"-1",title:this._options.tooltips.decrementMinute +}).addClass("btn").attr("data-action","decrementMinutes").append(e("").addClass(this._options.icons.down))))),this._isEnabled("s")&&(this._isEnabled("m")&&(a.append(e("").addClass("separator")),b.append(e("").addClass("separator").html(":")),c.append(e("").addClass("separator"))),a.append(e("").append(e("").attr({href:"#",tabindex:"-1",title:this._options.tooltips.incrementSecond}).addClass("btn").attr("data-action","incrementSeconds").append(e("").addClass(this._options.icons.up)))),b.append(e("").append(e("").addClass("timepicker-second").attr({"data-time-component":"seconds",title:this._options.tooltips.pickSecond}).attr("data-action","showSeconds"))),c.append(e("").append(e("").attr({href:"#",tabindex:"-1",title:this._options.tooltips.decrementSecond}).addClass("btn").attr("data-action","decrementSeconds").append(e("").addClass(this._options.icons.down))))),this.use24Hours||(a.append(e("").addClass("separator")),b.append(e("").append(e("").addClass("separator"))),e("
").addClass("timepicker-picker").append(e("").addClass("table-condensed").append([a,b,c]))},k.prototype._getTimePickerTemplate=function(){var a=e("
").addClass("timepicker-hours").append(e("
").addClass("table-condensed")),b=e("
").addClass("timepicker-minutes").append(e("
").addClass("table-condensed")),c=e("
").addClass("timepicker-seconds").append(e("
").addClass("table-condensed")),d=[this._getTimePickerMainTemplate()];return this._isEnabled("h")&&d.push(a),this._isEnabled("m")&&d.push(b),this._isEnabled("s")&&d.push(c),d},k.prototype._getToolbar=function(){var a=[];if(this._options.buttons.showToday&&a.push(e("
").append(e("").attr({href:"#",tabindex:"-1","data-action":"today",title:this._options.tooltips.today}).append(e("").addClass(this._options.icons.today)))),!this._options.sideBySide&&this._hasDate()&&this._hasTime()){var b=void 0,c=void 0;"times"===this._options.viewMode?(b=this._options.tooltips.selectDate,c=this._options.icons.date):(b=this._options.tooltips.selectTime,c=this._options.icons.time),a.push(e("").append(e("").attr({href:"#",tabindex:"-1","data-action":"togglePicker",title:b}).append(e("").addClass(c))))}return this._options.buttons.showClear&&a.push(e("").append(e("").attr({href:"#",tabindex:"-1","data-action":"clear",title:this._options.tooltips.clear}).append(e("").addClass(this._options.icons.clear)))),this._options.buttons.showClose&&a.push(e("").append(e("").attr({href:"#",tabindex:"-1","data-action":"close",title:this._options.tooltips.close}).append(e("").addClass(this._options.icons.close)))),0===a.length?"":e("").addClass("table-condensed").append(e("").append(e("").append(a)))},k.prototype._getTemplate=function(){var a=e("
").addClass("bootstrap-datetimepicker-widget dropdown-menu"),b=e("
").addClass("datepicker").append(this._getDatePickerTemplate()),c=e("
").addClass("timepicker").append(this._getTimePickerTemplate()),d=e("
    ").addClass("list-unstyled"),f=e("
  • ").addClass("picker-switch"+(this._options.collapse?" accordion-toggle":"")).append(this._getToolbar());return this._options.inline&&a.removeClass("dropdown-menu"),this.use24Hours&&a.addClass("usetwentyfour"),this._isEnabled("s")&&!this.use24Hours&&a.addClass("wider"),this._options.sideBySide&&this._hasDate()&&this._hasTime()?(a.addClass("timepicker-sbs"),"top"===this._options.toolbarPlacement&&a.append(f),a.append(e("
    ").addClass("row").append(b.addClass("col-md-6")).append(c.addClass("col-md-6"))),"bottom"!==this._options.toolbarPlacement&&"default"!==this._options.toolbarPlacement||a.append(f),a):("top"===this._options.toolbarPlacement&&d.append(f),this._hasDate()&&d.append(e("
  • ").addClass(this._options.collapse&&this._hasTime()?"collapse":"").addClass(this._options.collapse&&this._hasTime()&&"times"===this._options.viewMode?"":"show").append(b)),"default"===this._options.toolbarPlacement&&d.append(f),this._hasTime()&&d.append(e("
  • ").addClass(this._options.collapse&&this._hasDate()?"collapse":"").addClass(this._options.collapse&&this._hasDate()&&"times"===this._options.viewMode?"show":"").append(c)),"bottom"===this._options.toolbarPlacement&&d.append(f),a.append(d))},k.prototype._place=function(a){var b=a&&a.data&&a.data.picker||this,c=b._options.widgetPositioning.vertical,d=b._options.widgetPositioning.horizontal,f=void 0,g=(b.component&&b.component.length?b.component:b._element).position(),h=(b.component&&b.component.length?b.component:b._element).offset();if(b._options.widgetParent)f=b._options.widgetParent.append(b.widget);else if(b._element.is("input"))f=b._element.after(b.widget).parent();else{if(b._options.inline)return void(f=b._element.append(b.widget));f=b._element,b._element.children().first().after(b.widget)}if("auto"===c&&(c=h.top+1.5*b.widget.height()>=e(window).height()+e(window).scrollTop()&&b.widget.height()+b._element.outerHeight()e(window).width()?"right":"left"),"top"===c?b.widget.addClass("top").removeClass("bottom"):b.widget.addClass("bottom").removeClass("top"),"right"===d?b.widget.addClass("float-right"):b.widget.removeClass("float-right"),"relative"!==f.css("position")&&(f=f.parents().filter(function(){return"relative"===e(this).css("position")}).first()),0===f.length)throw new Error("datetimepicker component should be placed within a relative positioned container");b.widget.css({top:"top"===c?"auto":g.top+b._element.outerHeight()+"px",bottom:"top"===c?f.outerHeight()-(f===b._element?0:g.top)+"px":"auto",left:"left"===d?(f===b._element?0:g.left)+"px":"auto",right:"left"===d?"auto":f.outerWidth()-b._element.outerWidth()-(f===b._element?0:g.left)+"px"})},k.prototype._fillDow=function(){var a=e("
"),b=this._viewDate.clone().startOf("w").startOf("d");for(this._options.calendarWeeks===!0&&a.append(e(""),this._options.calendarWeeks&&f.append('"),c.push(f)),g="",d.isBefore(this._viewDate,"M")&&(g+=" old"),d.isAfter(this._viewDate,"M")&&(g+=" new"),this._options.allowMultidate){var i=this._datesFormatted.indexOf(d.format("YYYY-MM-DD"));i!==-1&&d.isSame(this._datesFormatted[i],"d")&&!this.unset&&(g+=" active")}else d.isSame(this._getLastPickedDate(),"d")&&!this.unset&&(g+=" active");this._isValid(d,"d")||(g+=" disabled"),d.isSame(this.getMoment(),"d")&&(g+=" today"),0!==d.day()&&6!==d.day()||(g+=" weekend"),f.append('"),d.add(1,"d")}a.find("tbody").empty().append(c),this._updateMonths(),this._updateYears(),this._updateDecades()}},k.prototype._fillHours=function(){var a=this.widget.find(".timepicker-hours table"),b=this._viewDate.clone().startOf("d"),c=[],d=e("");for(this._viewDate.hour()>11&&!this.use24Hours&&b.hour(12);b.isSame(this._viewDate,"d")&&(this.use24Hours||this._viewDate.hour()<12&&b.hour()<12||this._viewDate.hour()>11);)b.hour()%4===0&&(d=e(""),c.push(d)),d.append('"),b.add(1,"h");a.empty().append(c)},k.prototype._fillMinutes=function(){for(var a=this.widget.find(".timepicker-minutes table"),b=this._viewDate.clone().startOf("h"),c=[],d=1===this._options.stepping?5:this._options.stepping,f=e("");this._viewDate.isSame(b,"h");)b.minute()%(4*d)===0&&(f=e(""),c.push(f)),f.append('"),b.add(d,"m");a.empty().append(c)},k.prototype._fillSeconds=function(){for(var a=this.widget.find(".timepicker-seconds table"),b=this._viewDate.clone().startOf("m"),c=[],d=e("");this._viewDate.isSame(b,"m");)b.second()%20===0&&(d=e(""),c.push(d)),d.append('"),b.add(5,"s");a.empty().append(c)},k.prototype._fillTime=function(){var a=void 0,b=void 0,c=this.widget.find(".timepicker span[data-time-component]");this.use24Hours||(a=this.widget.find(".timepicker [data-action=togglePeriod]"),b=this._getLastPickedDate().clone().add(this._getLastPickedDate().hours()>=12?-12:12,"h"),a.text(this._getLastPickedDate().format("A")),this._isValid(b,"h")?a.removeClass("disabled"):a.addClass("disabled")),c.filter("[data-time-component=hours]").text(this._getLastPickedDate().format(""+(this.use24Hours?"HH":"hh"))),c.filter("[data-time-component=minutes]").text(this._getLastPickedDate().format("mm")),c.filter("[data-time-component=seconds]").text(this._getLastPickedDate().format("ss")),this._fillHours(),this._fillMinutes(),this._fillSeconds()},k.prototype._doAction=function(a,b){var c=this._getLastPickedDate();if(e(a.currentTarget).is(".disabled"))return!1;switch(b=b||e(a.currentTarget).data("action")){case"next":var d=f.DatePickerModes[this.currentViewMode].NAV_FUNCTION;this._viewDate.add(f.DatePickerModes[this.currentViewMode].NAV_STEP,d),this._fillDate(),this._viewUpdate(d);break;case"previous":var g=f.DatePickerModes[this.currentViewMode].NAV_FUNCTION;this._viewDate.subtract(f.DatePickerModes[this.currentViewMode].NAV_STEP,g),this._fillDate(),this._viewUpdate(g);break;case"pickerSwitch":this._showMode(1);break;case"selectMonth":var h=e(a.target).closest("tbody").find("span").index(e(a.target));this._viewDate.month(h),this.currentViewMode===this.MinViewModeNumber?(this._setValue(c.clone().year(this._viewDate.year()).month(this._viewDate.month()),this._getLastPickedDateIndex()),this._options.inline||this.hide()):(this._showMode(-1),this._fillDate()),this._viewUpdate("M");break;case"selectYear":var i=parseInt(e(a.target).text(),10)||0;this._viewDate.year(i),this.currentViewMode===this.MinViewModeNumber?(this._setValue(c.clone().year(this._viewDate.year()),this._getLastPickedDateIndex()),this._options.inline||this.hide()):(this._showMode(-1),this._fillDate()),this._viewUpdate("YYYY");break;case"selectDecade":var j=parseInt(e(a.target).data("selection"),10)||0;this._viewDate.year(j),this.currentViewMode===this.MinViewModeNumber?(this._setValue(c.clone().year(this._viewDate.year()),this._getLastPickedDateIndex()),this._options.inline||this.hide()):(this._showMode(-1),this._fillDate()),this._viewUpdate("YYYY");break;case"selectDay":var k=this._viewDate.clone();e(a.target).is(".old")&&k.subtract(1,"M"),e(a.target).is(".new")&&k.add(1,"M");var l=k.date(parseInt(e(a.target).text(),10)),m=0;this._options.allowMultidate?(m=this._datesFormatted.indexOf(l.format("YYYY-MM-DD")),m!==-1?this._setValue(null,m):this._setValue(l,this._getLastPickedDateIndex()+1)):this._setValue(l,this._getLastPickedDateIndex()),this._hasTime()||this._options.keepOpen||this._options.inline||this._options.allowMultidate||this.hide();break;case"incrementHours":var n=c.clone().add(1,"h");this._isValid(n,"h")&&this._setValue(n,this._getLastPickedDateIndex());break;case"incrementMinutes":var o=c.clone().add(this._options.stepping,"m");this._isValid(o,"m")&&this._setValue(o,this._getLastPickedDateIndex());break;case"incrementSeconds":var p=c.clone().add(1,"s");this._isValid(p,"s")&&this._setValue(p,this._getLastPickedDateIndex());break;case"decrementHours":var q=c.clone().subtract(1,"h");this._isValid(q,"h")&&this._setValue(q,this._getLastPickedDateIndex());break;case"decrementMinutes":var r=c.clone().subtract(this._options.stepping,"m");this._isValid(r,"m")&&this._setValue(r,this._getLastPickedDateIndex());break;case"decrementSeconds":var s=c.clone().subtract(1,"s");this._isValid(s,"s")&&this._setValue(s,this._getLastPickedDateIndex());break;case"togglePeriod":this._setValue(c.clone().add(c.hours()>=12?-12:12,"h"),this._getLastPickedDateIndex());break;case"togglePicker":var t=e(a.target),u=t.closest("a"),v=t.closest("ul"),w=v.find(".show"),x=v.find(".collapse:not(.show)"),y=t.is("span")?t:t.find("span"),z=void 0;if(w&&w.length){if(z=w.data("collapse"),z&&z.transitioning)return!0;w.collapse?(w.collapse("hide"),x.collapse("show")):(w.removeClass("show"),x.addClass("show")),y.toggleClass(this._options.icons.time+" "+this._options.icons.date),y.hasClass(this._options.icons.date)?u.attr("title",this._options.tooltips.selectDate):u.attr("title",this._options.tooltips.selectTime)}break;case"showPicker":this.widget.find(".timepicker > div:not(.timepicker-picker)").hide(),this.widget.find(".timepicker .timepicker-picker").show();break;case"showHours":this.widget.find(".timepicker .timepicker-picker").hide(),this.widget.find(".timepicker .timepicker-hours").show();break;case"showMinutes":this.widget.find(".timepicker .timepicker-picker").hide(),this.widget.find(".timepicker .timepicker-minutes").show();break;case"showSeconds":this.widget.find(".timepicker .timepicker-picker").hide(),this.widget.find(".timepicker .timepicker-seconds").show();break;case"selectHour":var A=parseInt(e(a.target).text(),10);this.use24Hours||(c.hours()>=12?12!==A&&(A+=12):12===A&&(A=0)),this._setValue(c.clone().hours(A),this._getLastPickedDateIndex()),this._isEnabled("a")||this._isEnabled("m")||this._options.keepOpen||this._options.inline?this._doAction(a,"showPicker"):this.hide();break;case"selectMinute":this._setValue(c.clone().minutes(parseInt(e(a.target).text(),10)),this._getLastPickedDateIndex()),this._isEnabled("a")||this._isEnabled("s")||this._options.keepOpen||this._options.inline?this._doAction(a,"showPicker"):this.hide();break;case"selectSecond":this._setValue(c.clone().seconds(parseInt(e(a.target).text(),10)),this._getLastPickedDateIndex()),this._isEnabled("a")||this._options.keepOpen||this._options.inline?this._doAction(a,"showPicker"):this.hide();break;case"clear":this.clear();break;case"close":this.hide();break;case"today":var B=this.getMoment();this._isValid(B,"d")&&this._setValue(B,this._getLastPickedDateIndex())}return!1},k.prototype.hide=function(){var a=!1;this.widget&&(this.widget.find(".collapse").each(function(){var b=e(this).data("collapse");return!b||!b.transitioning||(a=!0,!1)}),a||(this.component&&this.component.hasClass("btn")&&this.component.toggleClass("active"),this.widget.hide(),e(window).off("resize",this._place()),this.widget.off("click","[data-action]"),this.widget.off("mousedown",!1),this.widget.remove(),this.widget=!1,this._notifyEvent({type:f.Event.HIDE,date:this._getLastPickedDate().clone()}),void 0!==this.input&&this.input.blur(),this._viewDate=this._getLastPickedDate().clone()))},k.prototype.show=function(){var a=void 0,b={year:function(a){return a.month(0).date(1).hours(0).seconds(0).minutes(0)},month:function(a){return a.date(1).hours(0).seconds(0).minutes(0)},day:function(a){return a.hours(0).seconds(0).minutes(0)},hour:function(a){return a.seconds(0).minutes(0)},minute:function(a){return a.seconds(0)}};if(void 0!==this.input){if(this.input.prop("disabled")||!this._options.ignoreReadonly&&this.input.prop("readonly")||this.widget)return;void 0!==this.input.val()&&0!==this.input.val().trim().length?this._setValue(this._parseInputDate(this.input.val().trim()),0):this.unset&&this._options.useCurrent&&(a=this.getMoment(),"string"==typeof this._options.useCurrent&&(a=b[this._options.useCurrent](a)),this._setValue(a,0))}else this.unset&&this._options.useCurrent&&(a=this.getMoment(),"string"==typeof this._options.useCurrent&&(a=b[this._options.useCurrent](a)),this._setValue(a,0));this.widget=this._getTemplate(),this._fillDow(),this._fillMonths(),this.widget.find(".timepicker-hours").hide(),this.widget.find(".timepicker-minutes").hide(),this.widget.find(".timepicker-seconds").hide(),this._update(),this._showMode(),e(window).on("resize",{picker:this},this._place),this.widget.on("click","[data-action]",e.proxy(this._doAction,this)),this.widget.on("mousedown",!1),this.component&&this.component.hasClass("btn")&&this.component.toggleClass("active"),this._place(),this.widget.show(),void 0!==this.input&&this._options.focusOnShow&&!this.input.is(":focus")&&this.input.focus(),this._notifyEvent({type:f.Event.SHOW})},k.prototype.destroy=function(){this.hide(),this._element.removeData(f.DATA_KEY),this._element.removeData("date")},k.prototype.disable=function(){this.hide(),this.component&&this.component.hasClass("btn")&&this.component.addClass("disabled"),void 0!==this.input&&this.input.prop("disabled",!0)},k.prototype.enable=function(){this.component&&this.component.hasClass("btn")&&this.component.removeClass("disabled"),void 0!==this.input&&this.input.prop("disabled",!1)},k.prototype.toolbarPlacement=function(a){if(0===arguments.length)return this._options.toolbarPlacement;if("string"!=typeof a)throw new TypeError("toolbarPlacement() expects a string parameter");if(j.indexOf(a)===-1)throw new TypeError("toolbarPlacement() parameter must be one of ("+j.join(", ")+") value");this._options.toolbarPlacement=a,this.widget&&(this.hide(),this.show())},k.prototype.widgetPositioning=function(a){if(0===arguments.length)return e.extend({},this._options.widgetPositioning);if("[object Object]"!=={}.toString.call(a))throw new TypeError("widgetPositioning() expects an object variable");if(a.horizontal){if("string"!=typeof a.horizontal)throw new TypeError("widgetPositioning() horizontal variable must be a string");if(a.horizontal=a.horizontal.toLowerCase(),i.indexOf(a.horizontal)===-1)throw new TypeError("widgetPositioning() expects horizontal parameter to be one of ("+i.join(", ")+")");this._options.widgetPositioning.horizontal=a.horizontal}if(a.vertical){if("string"!=typeof a.vertical)throw new TypeError("widgetPositioning() vertical variable must be a string");if(a.vertical=a.vertical.toLowerCase(),h.indexOf(a.vertical)===-1)throw new TypeError("widgetPositioning() expects vertical parameter to be one of ("+h.join(", ")+")");this._options.widgetPositioning.vertical=a.vertical}this._update()},k.prototype.widgetParent=function(a){if(0===arguments.length)return this._options.widgetParent;if("string"==typeof a&&(a=e(a)),null!==a&&"string"!=typeof a&&!(a instanceof e))throw new TypeError("widgetParent() expects a string or a jQuery object parameter");this._options.widgetParent=a,this.widget&&(this.hide(),this.show())},k._jQueryHandleThis=function(a,b,c){var g=e(a).data(f.DATA_KEY);if("object"===("undefined"==typeof b?"undefined":d(b))&&e.extend({},f.Default,b),g||(g=new k(e(a),b),e(a).data(f.DATA_KEY,g)),"string"==typeof b){if(void 0===g[b])throw new Error('No method named "'+b+'"');return void 0===c?g[b]():g[b](c)}},k._jQueryInterface=function(a,b){return 1===this.length?k._jQueryHandleThis(this[0],a,b):this.each(function(){k._jQueryHandleThis(this,a,b)})},k}(f);return e(document).on(f.Event.CLICK_DATA_API,f.Selector.DATA_TOGGLE,function(){var a=k(e(this));0!==a.length&&l._jQueryInterface.call(a,"toggle")}).on(f.Event.CHANGE,"."+f.ClassName.INPUT,function(a){var b=k(e(this));0!==b.length&&l._jQueryInterface.call(b,"_change",a)}).on(f.Event.BLUR,"."+f.ClassName.INPUT,function(a){var b=k(e(this)),c=b.data(f.DATA_KEY);0!==b.length&&(c._options.debug||window.debug||l._jQueryInterface.call(b,"hide",a))}).on(f.Event.KEYDOWN,"."+f.ClassName.INPUT,function(a){var b=k(e(this));0!==b.length&&l._jQueryInterface.call(b,"_keydown",a)}).on(f.Event.KEYUP,"."+f.ClassName.INPUT,function(a){var b=k(e(this));0!==b.length&&l._jQueryInterface.call(b,"_keyup",a)}).on(f.Event.FOCUS,"."+f.ClassName.INPUT,function(a){var b=k(e(this)),c=b.data(f.DATA_KEY);0!==b.length&&c._options.allowInputToggle&&l._jQueryInterface.call(b,"show",a)}),e.fn[f.NAME]=l._jQueryInterface,e.fn[f.NAME].Constructor=l,e.fn[f.NAME].noConflict=function(){return e.fn[f.NAME]=g,l._jQueryInterface},l})(jQuery)}(); \ No newline at end of file diff --git a/templates/user.html b/templates/user.html index 39d61ebb..d7af01c1 100644 --- a/templates/user.html +++ b/templates/user.html @@ -2,6 +2,10 @@ {{define "title"}}{{.Title}}{{end}} +{{define "extra_css"}} + +{{end}} + {{define "page_body"}} @@ -11,7 +15,7 @@
{{.Error}}
{{end}} - +
@@ -21,6 +25,27 @@
+
+ +
+ +
+
+ +
+ +
+ +
+
+
+
+
+
@@ -129,7 +154,47 @@
+ +{{end}} +{{define "extra_js"}} + + + {{end}} \ No newline at end of file diff --git a/templates/users.html b/templates/users.html index a36dd4db..91b2cfc2 100644 --- a/templates/users.html +++ b/templates/users.html @@ -29,6 +29,8 @@ + + @@ -40,6 +42,8 @@ + + @@ -227,6 +231,6 @@ table.button(2).enable(selectedRows == 1); table.button(3).enable(selectedRows == 1); }); - }); + }); {{end}} \ No newline at end of file
").addClass("cw").text("#"));b.isBefore(this._viewDate.clone().endOf("w"));)a.append(e("").addClass("dow").text(b.format("dd"))),b.add(1,"d");this.widget.find(".datepicker-days thead").append(a)},k.prototype._fillMonths=function(){for(var a=[],b=this._viewDate.clone().startOf("y").startOf("d");b.isSame(this._viewDate,"y");)a.push(e("").attr("data-action","selectMonth").addClass("month").text(b.format("MMM"))),b.add(1,"M");this.widget.find(".datepicker-months td").empty().append(a)},k.prototype._updateMonths=function(){var a=this.widget.find(".datepicker-months"),b=a.find("th"),c=a.find("tbody").find("span"),d=this;b.eq(0).find("span").attr("title",this._options.tooltips.prevYear),b.eq(1).attr("title",this._options.tooltips.selectYear),b.eq(2).find("span").attr("title",this._options.tooltips.nextYear),a.find(".disabled").removeClass("disabled"),this._isValid(this._viewDate.clone().subtract(1,"y"),"y")||b.eq(0).addClass("disabled"),b.eq(1).text(this._viewDate.year()),this._isValid(this._viewDate.clone().add(1,"y"),"y")||b.eq(2).addClass("disabled"),c.removeClass("active"),this._getLastPickedDate().isSame(this._viewDate,"y")&&!this.unset&&c.eq(this._getLastPickedDate().month()).addClass("active"),c.each(function(a){d._isValid(d._viewDate.clone().month(a),"M")||e(this).addClass("disabled")})},k.prototype._getStartEndYear=function(a,b){var c=a/10,d=Math.floor(b/a)*a,e=d+9*c,f=Math.floor(b/c)*c;return[d,e,f]},k.prototype._updateYears=function(){var a=this.widget.find(".datepicker-years"),b=a.find("th"),c=this._getStartEndYear(10,this._viewDate.year()),d=this._viewDate.clone().year(c[0]),e=this._viewDate.clone().year(c[1]),f="";for(b.eq(0).find("span").attr("title",this._options.tooltips.prevDecade),b.eq(1).attr("title",this._options.tooltips.selectDecade),b.eq(2).find("span").attr("title",this._options.tooltips.nextDecade),a.find(".disabled").removeClass("disabled"),this._options.minDate&&this._options.minDate.isAfter(d,"y")&&b.eq(0).addClass("disabled"),b.eq(1).text(d.year()+"-"+e.year()),this._options.maxDate&&this._options.maxDate.isBefore(e,"y")&&b.eq(2).addClass("disabled"),f+=''+(d.year()-1)+"";!d.isAfter(e,"y");)f+=''+d.year()+"",d.add(1,"y");f+=''+d.year()+"",a.find("td").html(f)},k.prototype._updateDecades=function(){var a=this.widget.find(".datepicker-decades"),b=a.find("th"),c=this._getStartEndYear(100,this._viewDate.year()),d=this._viewDate.clone().year(c[0]),e=this._viewDate.clone().year(c[1]),f=!1,g=!1,h=void 0,i="";for(b.eq(0).find("span").attr("title",this._options.tooltips.prevCentury),b.eq(2).find("span").attr("title",this._options.tooltips.nextCentury),a.find(".disabled").removeClass("disabled"),(0===d.year()||this._options.minDate&&this._options.minDate.isAfter(d,"y"))&&b.eq(0).addClass("disabled"),b.eq(1).text(d.year()+"-"+e.year()),this._options.maxDate&&this._options.maxDate.isBefore(e,"y")&&b.eq(2).addClass("disabled"),i+=d.year()-10<0?" ":''+(d.year()-10)+"";!d.isAfter(e,"y");)h=d.year()+11,f=this._options.minDate&&this._options.minDate.isAfter(d,"y")&&this._options.minDate.year()<=h,g=this._options.maxDate&&this._options.maxDate.isAfter(d,"y")&&this._options.maxDate.year()<=h,i+=''+d.year()+"",d.add(10,"y");i+=''+d.year()+"",a.find("td").html(i)},k.prototype._fillDate=function(){var a=this.widget.find(".datepicker-days"),b=a.find("th"),c=[],d=void 0,f=void 0,g=void 0,h=void 0;if(this._hasDate()){for(b.eq(0).find("span").attr("title",this._options.tooltips.prevMonth),b.eq(1).attr("title",this._options.tooltips.selectMonth),b.eq(2).find("span").attr("title",this._options.tooltips.nextMonth),a.find(".disabled").removeClass("disabled"),b.eq(1).text(this._viewDate.format(this._options.dayViewHeaderFormat)),this._isValid(this._viewDate.clone().subtract(1,"M"),"M")||b.eq(0).addClass("disabled"),this._isValid(this._viewDate.clone().add(1,"M"),"M")||b.eq(2).addClass("disabled"),d=this._viewDate.clone().startOf("M").startOf("w").startOf("d"),h=0;h<42;h++){if(0===d.weekday()&&(f=e("
'+d.week()+"'+d.date()+"
'+b.format(this.use24Hours?"HH":"hh")+"
'+b.format("mm")+"
'+b.format("ss")+"
ID UsernameStatusExpiration Permissions Bandwidth Quota
{{.ID}} {{.Username}}{{if eq .Status 1 }}Active{{else}}Inactive{{end}}{{.GetExpirationDateAsString}} {{.GetPermissionsAsString}} {{.GetBandwidthAsString}} {{.GetQuotaSummary}}