mirror of
https://github.com/drakkan/sftpgo.git
synced 2024-11-22 07:30:25 +00:00
improve defender and quotas REST API
This commit is contained in:
parent
43182fc25e
commit
feec2118bb
16 changed files with 1068 additions and 259 deletions
|
@ -199,13 +199,31 @@ func GetDefenderBanTime(ip string) *time.Time {
|
|||
return Config.defender.GetBanTime(ip)
|
||||
}
|
||||
|
||||
// Unban removes the specified IP address from the banned ones
|
||||
func Unban(ip string) bool {
|
||||
// GetDefenderHosts returns hosts that are banned or for which some violations have been detected
|
||||
func GetDefenderHosts() []*DefenderEntry {
|
||||
if Config.defender == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Config.defender.GetHosts()
|
||||
}
|
||||
|
||||
// GetDefenderHost returns a defender host by ip, if any
|
||||
func GetDefenderHost(ip string) (*DefenderEntry, error) {
|
||||
if Config.defender == nil {
|
||||
return nil, errors.New("defender is disabled")
|
||||
}
|
||||
|
||||
return Config.defender.GetHost(ip)
|
||||
}
|
||||
|
||||
// DeleteDefenderHost removes the specified IP address from the defender lists
|
||||
func DeleteDefenderHost(ip string) bool {
|
||||
if Config.defender == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return Config.defender.Unban(ip)
|
||||
return Config.defender.DeleteHost(ip)
|
||||
}
|
||||
|
||||
// GetDefenderScore returns the score for the given IP
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
|
@ -129,8 +130,11 @@ func TestDefenderIntegration(t *testing.T) {
|
|||
assert.False(t, IsBanned(ip))
|
||||
|
||||
assert.Nil(t, GetDefenderBanTime(ip))
|
||||
assert.False(t, Unban(ip))
|
||||
assert.False(t, DeleteDefenderHost(ip))
|
||||
assert.Equal(t, 0, GetDefenderScore(ip))
|
||||
_, err := GetDefenderHost(ip)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, GetDefenderHosts())
|
||||
|
||||
Config.DefenderConfig = DefenderConfig{
|
||||
Enabled: true,
|
||||
|
@ -143,7 +147,7 @@ func TestDefenderIntegration(t *testing.T) {
|
|||
EntriesSoftLimit: 100,
|
||||
EntriesHardLimit: 150,
|
||||
}
|
||||
err := Initialize(Config)
|
||||
err = Initialize(Config)
|
||||
assert.Error(t, err)
|
||||
Config.DefenderConfig.Threshold = 3
|
||||
err = Initialize(Config)
|
||||
|
@ -153,16 +157,27 @@ func TestDefenderIntegration(t *testing.T) {
|
|||
AddDefenderEvent(ip, HostEventNoLoginTried)
|
||||
assert.False(t, IsBanned(ip))
|
||||
assert.Equal(t, 2, GetDefenderScore(ip))
|
||||
assert.False(t, Unban(ip))
|
||||
entry, err := GetDefenderHost(ip)
|
||||
assert.NoError(t, err)
|
||||
asJSON, err := json.Marshal(&entry)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, `{"id":"3132372e312e312e31","ip":"127.1.1.1","score":2}`, string(asJSON), "entry %v", entry)
|
||||
assert.True(t, DeleteDefenderHost(ip))
|
||||
assert.Nil(t, GetDefenderBanTime(ip))
|
||||
|
||||
AddDefenderEvent(ip, HostEventLoginFailed)
|
||||
AddDefenderEvent(ip, HostEventNoLoginTried)
|
||||
assert.True(t, IsBanned(ip))
|
||||
assert.Equal(t, 0, GetDefenderScore(ip))
|
||||
assert.NotNil(t, GetDefenderBanTime(ip))
|
||||
assert.True(t, Unban(ip))
|
||||
assert.Len(t, GetDefenderHosts(), 1)
|
||||
entry, err = GetDefenderHost(ip)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, entry.BanTime.IsZero())
|
||||
assert.True(t, DeleteDefenderHost(ip))
|
||||
assert.Len(t, GetDefenderHosts(), 0)
|
||||
assert.Nil(t, GetDefenderBanTime(ip))
|
||||
assert.False(t, Unban(ip))
|
||||
assert.False(t, DeleteDefenderHost(ip))
|
||||
|
||||
Config = configCopy
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
|
@ -11,6 +12,7 @@ import (
|
|||
|
||||
"github.com/yl2chen/cidranger"
|
||||
|
||||
"github.com/drakkan/sftpgo/dataprovider"
|
||||
"github.com/drakkan/sftpgo/logger"
|
||||
"github.com/drakkan/sftpgo/utils"
|
||||
)
|
||||
|
@ -26,13 +28,50 @@ const (
|
|||
HostEventLimitExceeded
|
||||
)
|
||||
|
||||
// DefenderEntry defines a defender entry
|
||||
type DefenderEntry struct {
|
||||
IP string `json:"ip"`
|
||||
Score int `json:"score,omitempty"`
|
||||
BanTime time.Time `json:"ban_time,omitempty"`
|
||||
}
|
||||
|
||||
// GetID returns an unique ID for a defender entry
|
||||
func (d *DefenderEntry) GetID() string {
|
||||
return hex.EncodeToString([]byte(d.IP))
|
||||
}
|
||||
|
||||
// GetBanTime returns the ban time for a defender entry as string
|
||||
func (d *DefenderEntry) GetBanTime() string {
|
||||
if d.BanTime.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return d.BanTime.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// MarshalJSON returns the JSON encoding of a DefenderEntry.
|
||||
func (d *DefenderEntry) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(&struct {
|
||||
ID string `json:"id"`
|
||||
IP string `json:"ip"`
|
||||
Score int `json:"score,omitempty"`
|
||||
BanTime string `json:"ban_time,omitempty"`
|
||||
}{
|
||||
ID: d.GetID(),
|
||||
IP: d.IP,
|
||||
Score: d.Score,
|
||||
BanTime: d.GetBanTime(),
|
||||
})
|
||||
}
|
||||
|
||||
// Defender defines the interface that a defender must implements
|
||||
type Defender interface {
|
||||
GetHosts() []*DefenderEntry
|
||||
GetHost(ip string) (*DefenderEntry, error)
|
||||
AddEvent(ip string, event HostEvent)
|
||||
IsBanned(ip string) bool
|
||||
GetBanTime(ip string) *time.Time
|
||||
GetScore(ip string) int
|
||||
Unban(ip string) bool
|
||||
DeleteHost(ip string) bool
|
||||
Reload() error
|
||||
}
|
||||
|
||||
|
@ -190,6 +229,50 @@ func (d *memoryDefender) Reload() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// GetHosts returns hosts that are banned or for which some violations have been detected
|
||||
func (d *memoryDefender) GetHosts() []*DefenderEntry {
|
||||
d.RLock()
|
||||
defer d.RUnlock()
|
||||
|
||||
var result []*DefenderEntry
|
||||
for k, v := range d.banned {
|
||||
result = append(result, &DefenderEntry{
|
||||
IP: k,
|
||||
BanTime: v,
|
||||
})
|
||||
}
|
||||
for k, v := range d.hosts {
|
||||
result = append(result, &DefenderEntry{
|
||||
IP: k,
|
||||
Score: v.TotalScore,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetHost returns a defender host by ip, if any
|
||||
func (d *memoryDefender) GetHost(ip string) (*DefenderEntry, error) {
|
||||
d.RLock()
|
||||
defer d.RUnlock()
|
||||
|
||||
if banTime, ok := d.banned[ip]; ok {
|
||||
return &DefenderEntry{
|
||||
IP: ip,
|
||||
BanTime: banTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if ev, ok := d.hosts[ip]; ok {
|
||||
return &DefenderEntry{
|
||||
IP: ip,
|
||||
Score: ev.TotalScore,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, dataprovider.NewRecordNotFoundError("host not found")
|
||||
}
|
||||
|
||||
// IsBanned returns true if the specified IP is banned
|
||||
// and increase ban time if the IP is found.
|
||||
// This method must be called as soon as the client connects
|
||||
|
@ -227,8 +310,8 @@ func (d *memoryDefender) IsBanned(ip string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// Unban removes the specified IP address from the banned ones
|
||||
func (d *memoryDefender) Unban(ip string) bool {
|
||||
// DeleteHost removes the specified IP from the defender lists
|
||||
func (d *memoryDefender) DeleteHost(ip string) bool {
|
||||
d.Lock()
|
||||
defer d.Unlock()
|
||||
|
||||
|
@ -237,6 +320,11 @@ func (d *memoryDefender) Unban(ip string) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
if _, ok := d.hosts[ip]; ok {
|
||||
delete(d.hosts, ip)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
@ -250,6 +338,11 @@ func (d *memoryDefender) AddEvent(ip string, event HostEvent) {
|
|||
return
|
||||
}
|
||||
|
||||
// ignore events for already banned hosts
|
||||
if _, ok := d.banned[ip]; ok {
|
||||
return
|
||||
}
|
||||
|
||||
var score int
|
||||
|
||||
switch event {
|
||||
|
|
|
@ -2,6 +2,7 @@ package common
|
|||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
|
@ -72,6 +73,9 @@ func TestBasicDefender(t *testing.T) {
|
|||
assert.False(t, defender.IsBanned("invalid ip"))
|
||||
assert.Equal(t, 0, defender.countBanned())
|
||||
assert.Equal(t, 0, defender.countHosts())
|
||||
assert.Len(t, defender.GetHosts(), 0)
|
||||
_, err = defender.GetHost("10.8.0.4")
|
||||
assert.Error(t, err)
|
||||
|
||||
defender.AddEvent("172.16.1.4", HostEventLoginFailed)
|
||||
defender.AddEvent("192.168.8.4", HostEventUserNotFound)
|
||||
|
@ -83,16 +87,39 @@ func TestBasicDefender(t *testing.T) {
|
|||
assert.Equal(t, 1, defender.countHosts())
|
||||
assert.Equal(t, 0, defender.countBanned())
|
||||
assert.Equal(t, 1, defender.GetScore(testIP))
|
||||
if assert.Len(t, defender.GetHosts(), 1) {
|
||||
assert.Equal(t, 1, defender.GetHosts()[0].Score)
|
||||
assert.True(t, defender.GetHosts()[0].BanTime.IsZero())
|
||||
assert.Empty(t, defender.GetHosts()[0].GetBanTime())
|
||||
}
|
||||
host, err := defender.GetHost(testIP)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, host.Score)
|
||||
assert.Empty(t, host.GetBanTime())
|
||||
assert.Nil(t, defender.GetBanTime(testIP))
|
||||
defender.AddEvent(testIP, HostEventLimitExceeded)
|
||||
assert.Equal(t, 1, defender.countHosts())
|
||||
assert.Equal(t, 0, defender.countBanned())
|
||||
assert.Equal(t, 4, defender.GetScore(testIP))
|
||||
if assert.Len(t, defender.GetHosts(), 1) {
|
||||
assert.Equal(t, 4, defender.GetHosts()[0].Score)
|
||||
}
|
||||
defender.AddEvent(testIP, HostEventNoLoginTried)
|
||||
defender.AddEvent(testIP, HostEventNoLoginTried)
|
||||
assert.Equal(t, 0, defender.countHosts())
|
||||
assert.Equal(t, 1, defender.countBanned())
|
||||
assert.Equal(t, 0, defender.GetScore(testIP))
|
||||
assert.NotNil(t, defender.GetBanTime(testIP))
|
||||
if assert.Len(t, defender.GetHosts(), 1) {
|
||||
assert.Equal(t, 0, defender.GetHosts()[0].Score)
|
||||
assert.False(t, defender.GetHosts()[0].BanTime.IsZero())
|
||||
assert.NotEmpty(t, defender.GetHosts()[0].GetBanTime())
|
||||
assert.Equal(t, hex.EncodeToString([]byte(testIP)), defender.GetHosts()[0].GetID())
|
||||
}
|
||||
host, err = defender.GetHost(testIP)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, host.Score)
|
||||
assert.NotEmpty(t, host.GetBanTime())
|
||||
|
||||
// now test cleanup, testIP is already banned
|
||||
testIP1 := "12.34.56.79"
|
||||
|
@ -143,8 +170,8 @@ func TestBasicDefender(t *testing.T) {
|
|||
assert.True(t, newBanTime.After(*banTime))
|
||||
}
|
||||
|
||||
assert.True(t, defender.Unban(testIP3))
|
||||
assert.False(t, defender.Unban(testIP3))
|
||||
assert.True(t, defender.DeleteHost(testIP3))
|
||||
assert.False(t, defender.DeleteHost(testIP3))
|
||||
|
||||
err = os.Remove(slFile)
|
||||
assert.NoError(t, err)
|
||||
|
|
|
@ -403,6 +403,13 @@ func (e *RecordNotFoundError) Error() string {
|
|||
return fmt.Sprintf("not found: %s", e.err)
|
||||
}
|
||||
|
||||
// NewRecordNotFoundError returns a not found error
|
||||
func NewRecordNotFoundError(error string) *RecordNotFoundError {
|
||||
return &RecordNotFoundError{
|
||||
err: error,
|
||||
}
|
||||
}
|
||||
|
||||
// GetQuotaTracking returns the configured mode for user's quota tracking
|
||||
func GetQuotaTracking() int {
|
||||
return config.TrackQuota
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package httpd
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
|
@ -12,6 +13,38 @@ import (
|
|||
"github.com/drakkan/sftpgo/common"
|
||||
)
|
||||
|
||||
func getDefenderHosts(w http.ResponseWriter, r *http.Request) {
|
||||
render.JSON(w, r, common.GetDefenderHosts())
|
||||
}
|
||||
|
||||
func getDefenderHostByID(w http.ResponseWriter, r *http.Request) {
|
||||
ip, err := getIPFromID(r)
|
||||
if err != nil {
|
||||
sendAPIResponse(w, r, err, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
host, err := common.GetDefenderHost(ip)
|
||||
if err != nil {
|
||||
sendAPIResponse(w, r, err, "", getRespStatus(err))
|
||||
return
|
||||
}
|
||||
render.JSON(w, r, host)
|
||||
}
|
||||
|
||||
func deleteDefenderHostByID(w http.ResponseWriter, r *http.Request) {
|
||||
ip, err := getIPFromID(r)
|
||||
if err != nil {
|
||||
sendAPIResponse(w, r, err, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !common.DeleteDefenderHost(ip) {
|
||||
sendAPIResponse(w, r, nil, "Not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
sendAPIResponse(w, r, nil, "OK", http.StatusOK)
|
||||
}
|
||||
|
||||
func getBanTime(w http.ResponseWriter, r *http.Request) {
|
||||
ip := r.URL.Query().Get("ip")
|
||||
err := validateIPAddress(ip)
|
||||
|
@ -64,13 +97,26 @@ func unban(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
if common.Unban(ip) {
|
||||
if common.DeleteDefenderHost(ip) {
|
||||
sendAPIResponse(w, r, nil, "OK", http.StatusOK)
|
||||
} else {
|
||||
sendAPIResponse(w, r, nil, "Not found", http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func getIPFromID(r *http.Request) (string, error) {
|
||||
decoded, err := hex.DecodeString(getURLParam(r, "id"))
|
||||
if err != nil {
|
||||
return "", errors.New("invalid host id")
|
||||
}
|
||||
ip := string(decoded)
|
||||
err = validateIPAddress(ip)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ip, nil
|
||||
}
|
||||
|
||||
func validateIPAddress(ip string) error {
|
||||
if ip == "" {
|
||||
return errors.New("ip address is required")
|
||||
|
|
|
@ -282,7 +282,7 @@ func RestoreUsers(users []dataprovider.User, inputFile string, mode, scanQuota i
|
|||
if scanQuota == 1 || (scanQuota == 2 && user.HasQuotaRestrictions()) {
|
||||
if common.QuotaScans.AddUserQuotaScan(user.Username) {
|
||||
logger.Debug(logSender, "", "starting quota scan for restored user: %#v", user.Username)
|
||||
go doQuotaScan(user) //nolint:errcheck
|
||||
go doUserQuotaScan(user) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,15 +17,31 @@ const (
|
|||
quotaUpdateModeReset = "reset"
|
||||
)
|
||||
|
||||
func getQuotaScans(w http.ResponseWriter, r *http.Request) {
|
||||
type quotaUsage struct {
|
||||
UsedQuotaSize int64 `json:"used_quota_size"`
|
||||
UsedQuotaFiles int `json:"used_quota_files"`
|
||||
}
|
||||
|
||||
func getUsersQuotaScans(w http.ResponseWriter, r *http.Request) {
|
||||
render.JSON(w, r, common.QuotaScans.GetUsersQuotaScans())
|
||||
}
|
||||
|
||||
func getVFolderQuotaScans(w http.ResponseWriter, r *http.Request) {
|
||||
func getFoldersQuotaScans(w http.ResponseWriter, r *http.Request) {
|
||||
render.JSON(w, r, common.QuotaScans.GetVFoldersQuotaScans())
|
||||
}
|
||||
|
||||
func updateUserQuotaUsage(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
|
||||
var usage quotaUsage
|
||||
err := render.DecodeJSON(r.Body, &usage)
|
||||
if err != nil {
|
||||
sendAPIResponse(w, r, err, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
doUpdateUserQuotaUsage(w, r, getURLParam(r, "username"), usage)
|
||||
}
|
||||
|
||||
func updateUserQuotaUsageCompat(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
|
||||
var u dataprovider.User
|
||||
err := render.DecodeJSON(r.Body, &u)
|
||||
|
@ -33,7 +49,74 @@ func updateUserQuotaUsage(w http.ResponseWriter, r *http.Request) {
|
|||
sendAPIResponse(w, r, err, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if u.UsedQuotaFiles < 0 || u.UsedQuotaSize < 0 {
|
||||
usage := quotaUsage{
|
||||
UsedQuotaSize: u.UsedQuotaSize,
|
||||
UsedQuotaFiles: u.UsedQuotaFiles,
|
||||
}
|
||||
|
||||
doUpdateUserQuotaUsage(w, r, u.Username, usage)
|
||||
}
|
||||
|
||||
func updateFolderQuotaUsage(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
|
||||
var usage quotaUsage
|
||||
err := render.DecodeJSON(r.Body, &usage)
|
||||
if err != nil {
|
||||
sendAPIResponse(w, r, err, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
doUpdateFolderQuotaUsage(w, r, getURLParam(r, "name"), usage)
|
||||
}
|
||||
|
||||
func updateFolderQuotaUsageCompat(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
|
||||
var f vfs.BaseVirtualFolder
|
||||
err := render.DecodeJSON(r.Body, &f)
|
||||
if err != nil {
|
||||
sendAPIResponse(w, r, err, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
usage := quotaUsage{
|
||||
UsedQuotaSize: f.UsedQuotaSize,
|
||||
UsedQuotaFiles: f.UsedQuotaFiles,
|
||||
}
|
||||
doUpdateFolderQuotaUsage(w, r, f.Name, usage)
|
||||
}
|
||||
|
||||
func startUserQuotaScan(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
|
||||
doStartUserQuotaScan(w, r, getURLParam(r, "username"))
|
||||
}
|
||||
|
||||
func startUserQuotaScanCompat(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
|
||||
var u dataprovider.User
|
||||
err := render.DecodeJSON(r.Body, &u)
|
||||
if err != nil {
|
||||
sendAPIResponse(w, r, err, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
doStartUserQuotaScan(w, r, u.Username)
|
||||
}
|
||||
|
||||
func startFolderQuotaScan(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
|
||||
doStartFolderQuotaScan(w, r, getURLParam(r, "name"))
|
||||
}
|
||||
|
||||
func startFolderQuotaScanCompat(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
|
||||
var f vfs.BaseVirtualFolder
|
||||
err := render.DecodeJSON(r.Body, &f)
|
||||
if err != nil {
|
||||
sendAPIResponse(w, r, err, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
doStartFolderQuotaScan(w, r, f.Name)
|
||||
}
|
||||
|
||||
func doUpdateUserQuotaUsage(w http.ResponseWriter, r *http.Request, username string, usage quotaUsage) {
|
||||
if usage.UsedQuotaFiles < 0 || usage.UsedQuotaSize < 0 {
|
||||
sendAPIResponse(w, r, errors.New("invalid used quota parameters, negative values are not allowed"),
|
||||
"", http.StatusBadRequest)
|
||||
return
|
||||
|
@ -43,7 +126,7 @@ func updateUserQuotaUsage(w http.ResponseWriter, r *http.Request) {
|
|||
sendAPIResponse(w, r, err, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user, err := dataprovider.UserExists(u.Username)
|
||||
user, err := dataprovider.UserExists(username)
|
||||
if err != nil {
|
||||
sendAPIResponse(w, r, err, "", getRespStatus(err))
|
||||
return
|
||||
|
@ -58,7 +141,7 @@ func updateUserQuotaUsage(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
defer common.QuotaScans.RemoveUserQuotaScan(user.Username)
|
||||
err = dataprovider.UpdateUserQuota(&user, u.UsedQuotaFiles, u.UsedQuotaSize, mode == quotaUpdateModeReset)
|
||||
err = dataprovider.UpdateUserQuota(&user, usage.UsedQuotaFiles, usage.UsedQuotaSize, mode == quotaUpdateModeReset)
|
||||
if err != nil {
|
||||
sendAPIResponse(w, r, err, "", getRespStatus(err))
|
||||
} else {
|
||||
|
@ -66,15 +149,8 @@ func updateUserQuotaUsage(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
func updateVFolderQuotaUsage(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
|
||||
var f vfs.BaseVirtualFolder
|
||||
err := render.DecodeJSON(r.Body, &f)
|
||||
if err != nil {
|
||||
sendAPIResponse(w, r, err, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if f.UsedQuotaFiles < 0 || f.UsedQuotaSize < 0 {
|
||||
func doUpdateFolderQuotaUsage(w http.ResponseWriter, r *http.Request, name string, usage quotaUsage) {
|
||||
if usage.UsedQuotaFiles < 0 || usage.UsedQuotaSize < 0 {
|
||||
sendAPIResponse(w, r, errors.New("invalid used quota parameters, negative values are not allowed"),
|
||||
"", http.StatusBadRequest)
|
||||
return
|
||||
|
@ -84,7 +160,7 @@ func updateVFolderQuotaUsage(w http.ResponseWriter, r *http.Request) {
|
|||
sendAPIResponse(w, r, err, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
folder, err := dataprovider.GetFolderByName(f.Name)
|
||||
folder, err := dataprovider.GetFolderByName(name)
|
||||
if err != nil {
|
||||
sendAPIResponse(w, r, err, "", getRespStatus(err))
|
||||
return
|
||||
|
@ -94,7 +170,7 @@ func updateVFolderQuotaUsage(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
defer common.QuotaScans.RemoveVFolderQuotaScan(folder.Name)
|
||||
err = dataprovider.UpdateVirtualFolderQuota(&folder, f.UsedQuotaFiles, f.UsedQuotaSize, mode == quotaUpdateModeReset)
|
||||
err = dataprovider.UpdateVirtualFolderQuota(&folder, usage.UsedQuotaFiles, usage.UsedQuotaSize, mode == quotaUpdateModeReset)
|
||||
if err != nil {
|
||||
sendAPIResponse(w, r, err, "", getRespStatus(err))
|
||||
} else {
|
||||
|
@ -102,57 +178,43 @@ func updateVFolderQuotaUsage(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
func startQuotaScan(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
|
||||
func doStartUserQuotaScan(w http.ResponseWriter, r *http.Request, username string) {
|
||||
if dataprovider.GetQuotaTracking() == 0 {
|
||||
sendAPIResponse(w, r, nil, "Quota tracking is disabled!", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
var u dataprovider.User
|
||||
err := render.DecodeJSON(r.Body, &u)
|
||||
if err != nil {
|
||||
sendAPIResponse(w, r, err, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user, err := dataprovider.UserExists(u.Username)
|
||||
user, err := dataprovider.UserExists(username)
|
||||
if err != nil {
|
||||
sendAPIResponse(w, r, err, "", getRespStatus(err))
|
||||
return
|
||||
}
|
||||
if common.QuotaScans.AddUserQuotaScan(user.Username) {
|
||||
go doQuotaScan(user) //nolint:errcheck
|
||||
sendAPIResponse(w, r, err, "Scan started", http.StatusAccepted)
|
||||
} else {
|
||||
if !common.QuotaScans.AddUserQuotaScan(user.Username) {
|
||||
sendAPIResponse(w, r, err, "Another scan is already in progress", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
go doUserQuotaScan(user) //nolint:errcheck
|
||||
sendAPIResponse(w, r, err, "Scan started", http.StatusAccepted)
|
||||
}
|
||||
|
||||
func startVFolderQuotaScan(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
|
||||
func doStartFolderQuotaScan(w http.ResponseWriter, r *http.Request, name string) {
|
||||
if dataprovider.GetQuotaTracking() == 0 {
|
||||
sendAPIResponse(w, r, nil, "Quota tracking is disabled!", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
var f vfs.BaseVirtualFolder
|
||||
err := render.DecodeJSON(r.Body, &f)
|
||||
if err != nil {
|
||||
sendAPIResponse(w, r, err, "", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
folder, err := dataprovider.GetFolderByName(f.Name)
|
||||
folder, err := dataprovider.GetFolderByName(name)
|
||||
if err != nil {
|
||||
sendAPIResponse(w, r, err, "", getRespStatus(err))
|
||||
return
|
||||
}
|
||||
if common.QuotaScans.AddVFolderQuotaScan(folder.Name) {
|
||||
if !common.QuotaScans.AddVFolderQuotaScan(folder.Name) {
|
||||
sendAPIResponse(w, r, err, "Another scan is already in progress", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
go doFolderQuotaScan(folder) //nolint:errcheck
|
||||
sendAPIResponse(w, r, err, "Scan started", http.StatusAccepted)
|
||||
} else {
|
||||
sendAPIResponse(w, r, err, "Another scan is already in progress", http.StatusConflict)
|
||||
}
|
||||
}
|
||||
|
||||
func doQuotaScan(user dataprovider.User) error {
|
||||
func doUserQuotaScan(user dataprovider.User) error {
|
||||
defer common.QuotaScans.RemoveUserQuotaScan(user.Username)
|
||||
numFiles, size, err := user.ScanQuota()
|
||||
if err != nil {
|
||||
|
|
|
@ -35,6 +35,7 @@ const (
|
|||
userTokenPath = "/api/v2/user/token"
|
||||
userLogoutPath = "/api/v2/user/logout"
|
||||
activeConnectionsPath = "/api/v2/connections"
|
||||
quotasBasePath = "/api/v2/quotas"
|
||||
quotaScanPath = "/api/v2/quota-scans"
|
||||
quotaScanVFolderPath = "/api/v2/folder-quota-scans"
|
||||
userPath = "/api/v2/users"
|
||||
|
@ -45,6 +46,7 @@ const (
|
|||
loadDataPath = "/api/v2/loaddata"
|
||||
updateUsedQuotaPath = "/api/v2/quota-update"
|
||||
updateFolderUsedQuotaPath = "/api/v2/folder-quota-update"
|
||||
defenderHosts = "/api/v2/defender/hosts"
|
||||
defenderBanTime = "/api/v2/defender/bantime"
|
||||
defenderUnban = "/api/v2/defender/unban"
|
||||
defenderScore = "/api/v2/defender/score"
|
||||
|
@ -75,8 +77,8 @@ const (
|
|||
webMaintenancePathDefault = "/web/admin/maintenance"
|
||||
webBackupPathDefault = "/web/admin/backup"
|
||||
webRestorePathDefault = "/web/admin/restore"
|
||||
webScanVFolderPathDefault = "/web/admin/folder-quota-scans"
|
||||
webQuotaScanPathDefault = "/web/admin/quota-scans"
|
||||
webScanVFolderPathDefault = "/web/admin/quotas/scanfolder"
|
||||
webQuotaScanPathDefault = "/web/admin/quotas/scanuser"
|
||||
webChangeAdminPwdPathDefault = "/web/admin/changepwd"
|
||||
webTemplateUserDefault = "/web/admin/template/user"
|
||||
webTemplateFolderDefault = "/web/admin/template/folder"
|
||||
|
|
|
@ -64,10 +64,14 @@ const (
|
|||
folderPath = "/api/v2/folders"
|
||||
activeConnectionsPath = "/api/v2/connections"
|
||||
serverStatusPath = "/api/v2/status"
|
||||
quotaScanPath = "/api/v2/quota-scans"
|
||||
quotaScanVFolderPath = "/api/v2/folder-quota-scans"
|
||||
updateUsedQuotaPath = "/api/v2/quota-update"
|
||||
updateFolderUsedQuotaPath = "/api/v2/folder-quota-update"
|
||||
quotasBasePath = "/api/v2/quotas"
|
||||
quotaScanPath = "/api/v2/quotas/users/scans"
|
||||
quotaScanVFolderPath = "/api/v2/quotas/folders/scans"
|
||||
quotaScanCompatPath = "/api/v2/quota-scans"
|
||||
quotaScanVFolderCompatPath = "/api/v2/folder-quota-scans"
|
||||
updateUsedQuotaCompatPath = "/api/v2/quota-update"
|
||||
updateFolderUsedQuotaCompatPath = "/api/v2/folder-quota-update"
|
||||
defenderHosts = "/api/v2/defender/hosts"
|
||||
defenderUnban = "/api/v2/defender/unban"
|
||||
versionPath = "/api/v2/version"
|
||||
logoutPath = "/api/v2/logout"
|
||||
|
@ -3144,6 +3148,10 @@ func TestDefenderAPI(t *testing.T) {
|
|||
require.True(t, ok)
|
||||
assert.Nil(t, banTime)
|
||||
|
||||
hosts, _, err := httpdtest.GetDefenderHosts(http.StatusOK)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, hosts, 0)
|
||||
|
||||
response, _, err = httpdtest.GetScore(ip, http.StatusOK)
|
||||
require.NoError(t, err)
|
||||
score, ok := response["score"]
|
||||
|
@ -3153,6 +3161,9 @@ func TestDefenderAPI(t *testing.T) {
|
|||
err = httpdtest.UnbanIP(ip, http.StatusNotFound)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = httpdtest.RemoveDefenderHostByIP(ip, http.StatusNotFound)
|
||||
require.NoError(t, err)
|
||||
|
||||
common.AddDefenderEvent(ip, common.HostEventNoLoginTried)
|
||||
response, _, err = httpdtest.GetScore(ip, http.StatusOK)
|
||||
require.NoError(t, err)
|
||||
|
@ -3160,12 +3171,37 @@ func TestDefenderAPI(t *testing.T) {
|
|||
require.True(t, ok)
|
||||
assert.Equal(t, float64(2), score)
|
||||
|
||||
hosts, _, err = httpdtest.GetDefenderHosts(http.StatusOK)
|
||||
require.NoError(t, err)
|
||||
if assert.Len(t, hosts, 1) {
|
||||
host := hosts[0]
|
||||
assert.Empty(t, host.GetBanTime())
|
||||
assert.Equal(t, 2, host.Score)
|
||||
assert.Equal(t, ip, host.IP)
|
||||
}
|
||||
host, _, err := httpdtest.GetDefenderHostByIP(ip, http.StatusOK)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, host.GetBanTime())
|
||||
assert.Equal(t, 2, host.Score)
|
||||
|
||||
common.AddDefenderEvent(ip, common.HostEventNoLoginTried)
|
||||
response, _, err = httpdtest.GetBanTime(ip, http.StatusOK)
|
||||
require.NoError(t, err)
|
||||
banTime, ok = response["date_time"]
|
||||
require.True(t, ok)
|
||||
assert.NotNil(t, banTime)
|
||||
hosts, _, err = httpdtest.GetDefenderHosts(http.StatusOK)
|
||||
require.NoError(t, err)
|
||||
if assert.Len(t, hosts, 1) {
|
||||
host := hosts[0]
|
||||
assert.NotEmpty(t, host.GetBanTime())
|
||||
assert.Equal(t, 0, host.Score)
|
||||
assert.Equal(t, ip, host.IP)
|
||||
}
|
||||
host, _, err = httpdtest.GetDefenderHostByIP(ip, http.StatusOK)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, host.GetBanTime())
|
||||
assert.Equal(t, 0, host.Score)
|
||||
|
||||
err = httpdtest.UnbanIP(ip, http.StatusOK)
|
||||
require.NoError(t, err)
|
||||
|
@ -3173,6 +3209,28 @@ func TestDefenderAPI(t *testing.T) {
|
|||
err = httpdtest.UnbanIP(ip, http.StatusNotFound)
|
||||
require.NoError(t, err)
|
||||
|
||||
host, _, err = httpdtest.GetDefenderHostByIP(ip, http.StatusNotFound)
|
||||
assert.NoError(t, err)
|
||||
|
||||
common.AddDefenderEvent(ip, common.HostEventNoLoginTried)
|
||||
common.AddDefenderEvent(ip, common.HostEventNoLoginTried)
|
||||
hosts, _, err = httpdtest.GetDefenderHosts(http.StatusOK)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, hosts, 1)
|
||||
|
||||
_, err = httpdtest.RemoveDefenderHostByIP(ip, http.StatusOK)
|
||||
assert.NoError(t, err)
|
||||
|
||||
host, _, err = httpdtest.GetDefenderHostByIP(ip, http.StatusNotFound)
|
||||
assert.NoError(t, err)
|
||||
_, err = httpdtest.RemoveDefenderHostByIP(ip, http.StatusNotFound)
|
||||
assert.NoError(t, err)
|
||||
|
||||
host, _, err = httpdtest.GetDefenderHostByIP("invalid_ip", http.StatusBadRequest)
|
||||
assert.NoError(t, err)
|
||||
_, err = httpdtest.RemoveDefenderHostByIP("invalid_ip", http.StatusBadRequest)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = common.Initialize(oldConfig)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
@ -3899,7 +3957,11 @@ func TestUpdateUserQuotaUsageMock(t *testing.T) {
|
|||
checkResponseCode(t, http.StatusCreated, rr)
|
||||
err = render.DecodeJSON(rr.Body, &user)
|
||||
assert.NoError(t, err)
|
||||
req, _ = http.NewRequest(http.MethodPut, updateUsedQuotaPath, bytes.NewBuffer(userAsJSON))
|
||||
req, _ = http.NewRequest(http.MethodPut, path.Join(quotasBasePath, "users", u.Username, "usage"), bytes.NewBuffer(userAsJSON))
|
||||
setBearerForReq(req, token)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr)
|
||||
req, _ = http.NewRequest(http.MethodPut, updateUsedQuotaCompatPath, bytes.NewBuffer(userAsJSON))
|
||||
setBearerForReq(req, token)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr)
|
||||
|
@ -3914,7 +3976,7 @@ func TestUpdateUserQuotaUsageMock(t *testing.T) {
|
|||
// now update only quota size
|
||||
u.UsedQuotaFiles = 0
|
||||
userAsJSON = getUserAsJSON(t, u)
|
||||
req, _ = http.NewRequest(http.MethodPut, updateUsedQuotaPath+"?mode=add", bytes.NewBuffer(userAsJSON))
|
||||
req, _ = http.NewRequest(http.MethodPut, path.Join(quotasBasePath, "users", u.Username, "usage")+"?mode=add", bytes.NewBuffer(userAsJSON))
|
||||
setBearerForReq(req, token)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr)
|
||||
|
@ -3930,7 +3992,7 @@ func TestUpdateUserQuotaUsageMock(t *testing.T) {
|
|||
u.UsedQuotaFiles = usedQuotaFiles
|
||||
u.UsedQuotaSize = 0
|
||||
userAsJSON = getUserAsJSON(t, u)
|
||||
req, _ = http.NewRequest(http.MethodPut, updateUsedQuotaPath+"?mode=add", bytes.NewBuffer(userAsJSON))
|
||||
req, _ = http.NewRequest(http.MethodPut, path.Join(quotasBasePath, "users", u.Username, "usage")+"?mode=add", bytes.NewBuffer(userAsJSON))
|
||||
setBearerForReq(req, token)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr)
|
||||
|
@ -3942,12 +4004,16 @@ func TestUpdateUserQuotaUsageMock(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
assert.Equal(t, usedQuotaFiles*2, user.UsedQuotaFiles)
|
||||
assert.Equal(t, usedQuotaSize*2, user.UsedQuotaSize)
|
||||
req, _ = http.NewRequest(http.MethodPut, updateUsedQuotaPath, bytes.NewBuffer([]byte("string")))
|
||||
req, _ = http.NewRequest(http.MethodPut, updateUsedQuotaCompatPath, bytes.NewBuffer([]byte("string")))
|
||||
setBearerForReq(req, token)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusBadRequest, rr)
|
||||
req, _ = http.NewRequest(http.MethodPut, path.Join(quotasBasePath, "users", u.Username, "usage"), bytes.NewBuffer([]byte("string")))
|
||||
setBearerForReq(req, token)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusBadRequest, rr)
|
||||
assert.True(t, common.QuotaScans.AddUserQuotaScan(user.Username))
|
||||
req, _ = http.NewRequest(http.MethodPut, updateUsedQuotaPath, bytes.NewBuffer(userAsJSON))
|
||||
req, _ = http.NewRequest(http.MethodPut, path.Join(quotasBasePath, "users", u.Username, "usage"), bytes.NewBuffer(userAsJSON))
|
||||
setBearerForReq(req, token)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusConflict, rr)
|
||||
|
@ -4198,7 +4264,7 @@ func TestDeleteUserInvalidParamsMock(t *testing.T) {
|
|||
func TestGetQuotaScansMock(t *testing.T) {
|
||||
token, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
|
||||
assert.NoError(t, err)
|
||||
req, err := http.NewRequest("GET", quotaScanPath, nil)
|
||||
req, err := http.NewRequest(http.MethodGet, quotaScanPath, nil)
|
||||
assert.NoError(t, err)
|
||||
setBearerForReq(req, token)
|
||||
rr := executeRequest(req)
|
||||
|
@ -4222,61 +4288,47 @@ func TestStartQuotaScanMock(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
}
|
||||
// simulate a duplicate quota scan
|
||||
userAsJSON = getUserAsJSON(t, user)
|
||||
common.QuotaScans.AddUserQuotaScan(user.Username)
|
||||
req, _ = http.NewRequest(http.MethodPost, quotaScanPath, bytes.NewBuffer(userAsJSON))
|
||||
req, _ = http.NewRequest(http.MethodPost, path.Join(quotasBasePath, "users", user.Username, "scan"), nil)
|
||||
setBearerForReq(req, token)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusConflict, rr)
|
||||
assert.True(t, common.QuotaScans.RemoveUserQuotaScan(user.Username))
|
||||
|
||||
userAsJSON = getUserAsJSON(t, user)
|
||||
req, _ = http.NewRequest(http.MethodPost, quotaScanPath, bytes.NewBuffer(userAsJSON))
|
||||
req, _ = http.NewRequest(http.MethodPost, path.Join(quotasBasePath, "users", user.Username, "scan"), nil)
|
||||
setBearerForReq(req, token)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusAccepted, rr)
|
||||
|
||||
for {
|
||||
var scans []common.ActiveQuotaScan
|
||||
req, _ = http.NewRequest(http.MethodGet, quotaScanPath, nil)
|
||||
setBearerForReq(req, token)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr)
|
||||
err = render.DecodeJSON(rr.Body, &scans)
|
||||
if !assert.NoError(t, err, "Error getting active scans") {
|
||||
break
|
||||
}
|
||||
if len(scans) == 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
waitForUsersQuotaScan(t, token)
|
||||
|
||||
_, err = os.Stat(user.HomeDir)
|
||||
if err != nil && os.IsNotExist(err) {
|
||||
err = os.MkdirAll(user.HomeDir, os.ModePerm)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
req, _ = http.NewRequest(http.MethodPost, quotaScanPath, bytes.NewBuffer(userAsJSON))
|
||||
req, _ = http.NewRequest(http.MethodPost, path.Join(quotasBasePath, "users", user.Username, "scan"), nil)
|
||||
setBearerForReq(req, token)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusAccepted, rr)
|
||||
|
||||
for {
|
||||
var scans []common.ActiveQuotaScan
|
||||
req, _ = http.NewRequest(http.MethodGet, quotaScanPath, nil)
|
||||
waitForUsersQuotaScan(t, token)
|
||||
|
||||
req, _ = http.NewRequest(http.MethodPost, path.Join(quotasBasePath, "users", user.Username, "scan"), nil)
|
||||
setBearerForReq(req, token)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr)
|
||||
err = render.DecodeJSON(rr.Body, &scans)
|
||||
if !assert.NoError(t, err) {
|
||||
assert.Fail(t, err.Error(), "Error getting active scans")
|
||||
break
|
||||
}
|
||||
if len(scans) == 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
checkResponseCode(t, http.StatusAccepted, rr)
|
||||
|
||||
waitForUsersQuotaScan(t, token)
|
||||
|
||||
asJSON, err := json.Marshal(user)
|
||||
assert.NoError(t, err)
|
||||
req, _ = http.NewRequest(http.MethodPost, quotaScanCompatPath, bytes.NewBuffer(asJSON))
|
||||
setBearerForReq(req, token)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusAccepted, rr)
|
||||
|
||||
waitForUsersQuotaScan(t, token)
|
||||
|
||||
req, _ = http.NewRequest(http.MethodDelete, path.Join(userPath, user.Username), nil)
|
||||
setBearerForReq(req, token)
|
||||
|
@ -4308,7 +4360,11 @@ func TestUpdateFolderQuotaUsageMock(t *testing.T) {
|
|||
checkResponseCode(t, http.StatusCreated, rr)
|
||||
err = render.DecodeJSON(rr.Body, &folder)
|
||||
assert.NoError(t, err)
|
||||
req, _ = http.NewRequest(http.MethodPut, updateFolderUsedQuotaPath, bytes.NewBuffer(folderAsJSON))
|
||||
req, _ = http.NewRequest(http.MethodPut, path.Join(quotasBasePath, "folders", folder.Name, "usage"), bytes.NewBuffer(folderAsJSON))
|
||||
setBearerForReq(req, token)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr)
|
||||
req, _ = http.NewRequest(http.MethodPut, updateFolderUsedQuotaCompatPath, bytes.NewBuffer(folderAsJSON))
|
||||
setBearerForReq(req, token)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr)
|
||||
|
@ -4325,7 +4381,8 @@ func TestUpdateFolderQuotaUsageMock(t *testing.T) {
|
|||
f.UsedQuotaFiles = 0
|
||||
folderAsJSON, err = json.Marshal(f)
|
||||
assert.NoError(t, err)
|
||||
req, _ = http.NewRequest(http.MethodPut, updateFolderUsedQuotaPath+"?mode=add", bytes.NewBuffer(folderAsJSON))
|
||||
req, _ = http.NewRequest(http.MethodPut, path.Join(quotasBasePath, "folders", folder.Name, "usage")+"?mode=add",
|
||||
bytes.NewBuffer(folderAsJSON))
|
||||
setBearerForReq(req, token)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr)
|
||||
|
@ -4343,7 +4400,8 @@ func TestUpdateFolderQuotaUsageMock(t *testing.T) {
|
|||
f.UsedQuotaFiles = 1
|
||||
folderAsJSON, err = json.Marshal(f)
|
||||
assert.NoError(t, err)
|
||||
req, _ = http.NewRequest(http.MethodPut, updateFolderUsedQuotaPath+"?mode=add", bytes.NewBuffer(folderAsJSON))
|
||||
req, _ = http.NewRequest(http.MethodPut, path.Join(quotasBasePath, "folders", folder.Name, "usage")+"?mode=add",
|
||||
bytes.NewBuffer(folderAsJSON))
|
||||
setBearerForReq(req, token)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr)
|
||||
|
@ -4356,13 +4414,19 @@ func TestUpdateFolderQuotaUsageMock(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
assert.Equal(t, usedQuotaFiles*2, folderGet.UsedQuotaFiles)
|
||||
assert.Equal(t, usedQuotaSize*2, folderGet.UsedQuotaSize)
|
||||
req, _ = http.NewRequest(http.MethodPut, updateFolderUsedQuotaPath, bytes.NewBuffer([]byte("string")))
|
||||
req, _ = http.NewRequest(http.MethodPut, updateFolderUsedQuotaCompatPath, bytes.NewBuffer([]byte("string")))
|
||||
setBearerForReq(req, token)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusBadRequest, rr)
|
||||
req, _ = http.NewRequest(http.MethodPut, path.Join(quotasBasePath, "folders", folder.Name, "usage"),
|
||||
bytes.NewBuffer([]byte("not a json")))
|
||||
setBearerForReq(req, token)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusBadRequest, rr)
|
||||
|
||||
assert.True(t, common.QuotaScans.AddVFolderQuotaScan(folderName))
|
||||
req, _ = http.NewRequest(http.MethodPut, updateFolderUsedQuotaPath, bytes.NewBuffer(folderAsJSON))
|
||||
req, _ = http.NewRequest(http.MethodPut, path.Join(quotasBasePath, "folders", folder.Name, "usage"),
|
||||
bytes.NewBuffer(folderAsJSON))
|
||||
setBearerForReq(req, token)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusConflict, rr)
|
||||
|
@ -4396,7 +4460,7 @@ func TestStartFolderQuotaScanMock(t *testing.T) {
|
|||
}
|
||||
// simulate a duplicate quota scan
|
||||
common.QuotaScans.AddVFolderQuotaScan(folderName)
|
||||
req, _ = http.NewRequest(http.MethodPost, quotaScanVFolderPath, bytes.NewBuffer(folderAsJSON))
|
||||
req, _ = http.NewRequest(http.MethodPost, path.Join(quotasBasePath, "folders", folder.Name, "scan"), nil)
|
||||
setBearerForReq(req, token)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusConflict, rr)
|
||||
|
@ -4407,25 +4471,20 @@ func TestStartFolderQuotaScanMock(t *testing.T) {
|
|||
err = os.MkdirAll(mappedPath, os.ModePerm)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
req, _ = http.NewRequest(http.MethodPost, quotaScanVFolderPath, bytes.NewBuffer(folderAsJSON))
|
||||
req, _ = http.NewRequest(http.MethodPost, path.Join(quotasBasePath, "folders", folder.Name, "scan"), nil)
|
||||
setBearerForReq(req, token)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusAccepted, rr)
|
||||
var scans []common.ActiveVirtualFolderQuotaScan
|
||||
for {
|
||||
req, _ = http.NewRequest(http.MethodGet, quotaScanVFolderPath, nil)
|
||||
waitForFoldersQuotaScanPath(t, token)
|
||||
|
||||
asJSON, err := json.Marshal(folder)
|
||||
assert.NoError(t, err)
|
||||
req, _ = http.NewRequest(http.MethodPost, quotaScanVFolderCompatPath, bytes.NewBuffer(asJSON))
|
||||
setBearerForReq(req, token)
|
||||
rr = executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr)
|
||||
err = render.DecodeJSON(rr.Body, &scans)
|
||||
if !assert.NoError(t, err, "Error getting active folders scans") {
|
||||
break
|
||||
}
|
||||
if len(scans) == 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
checkResponseCode(t, http.StatusAccepted, rr)
|
||||
waitForFoldersQuotaScanPath(t, token)
|
||||
|
||||
// cleanup
|
||||
|
||||
req, _ = http.NewRequest(http.MethodDelete, path.Join(folderPath, folderName), nil)
|
||||
|
@ -4442,8 +4501,8 @@ func TestStartQuotaScanNonExistentUserMock(t *testing.T) {
|
|||
token, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
|
||||
assert.NoError(t, err)
|
||||
user := getTestUser()
|
||||
userAsJSON := getUserAsJSON(t, user)
|
||||
req, _ := http.NewRequest(http.MethodPost, quotaScanPath, bytes.NewBuffer(userAsJSON))
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, path.Join(quotasBasePath, "users", user.Username, "scan"), nil)
|
||||
setBearerForReq(req, token)
|
||||
rr := executeRequest(req)
|
||||
checkResponseCode(t, http.StatusNotFound, rr)
|
||||
|
@ -4452,7 +4511,7 @@ func TestStartQuotaScanNonExistentUserMock(t *testing.T) {
|
|||
func TestStartQuotaScanBadUserMock(t *testing.T) {
|
||||
token, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
|
||||
assert.NoError(t, err)
|
||||
req, _ := http.NewRequest(http.MethodPost, quotaScanPath, bytes.NewBuffer([]byte("invalid json")))
|
||||
req, _ := http.NewRequest(http.MethodPost, quotaScanCompatPath, bytes.NewBuffer([]byte("invalid json")))
|
||||
setBearerForReq(req, token)
|
||||
rr := executeRequest(req)
|
||||
checkResponseCode(t, http.StatusBadRequest, rr)
|
||||
|
@ -4461,7 +4520,7 @@ func TestStartQuotaScanBadUserMock(t *testing.T) {
|
|||
func TestStartQuotaScanBadFolderMock(t *testing.T) {
|
||||
token, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
|
||||
assert.NoError(t, err)
|
||||
req, _ := http.NewRequest(http.MethodPost, quotaScanVFolderPath, bytes.NewBuffer([]byte("invalid json")))
|
||||
req, _ := http.NewRequest(http.MethodPost, quotaScanVFolderCompatPath, bytes.NewBuffer([]byte("invalid json")))
|
||||
setBearerForReq(req, token)
|
||||
rr := executeRequest(req)
|
||||
checkResponseCode(t, http.StatusBadRequest, rr)
|
||||
|
@ -4474,9 +4533,7 @@ func TestStartQuotaScanNonExistentFolderMock(t *testing.T) {
|
|||
MappedPath: os.TempDir(),
|
||||
Name: "afolder",
|
||||
}
|
||||
folderAsJSON, err := json.Marshal(folder)
|
||||
assert.NoError(t, err)
|
||||
req, _ := http.NewRequest(http.MethodPost, quotaScanVFolderPath, bytes.NewBuffer(folderAsJSON))
|
||||
req, _ := http.NewRequest(http.MethodPost, path.Join(quotasBasePath, "folders", folder.Name, "scan"), nil)
|
||||
setBearerForReq(req, token)
|
||||
rr := executeRequest(req)
|
||||
checkResponseCode(t, http.StatusNotFound, rr)
|
||||
|
@ -4667,6 +4724,16 @@ func TestLogout(t *testing.T) {
|
|||
assert.Contains(t, rr.Body.String(), "Your token is no longer valid")
|
||||
}
|
||||
|
||||
func TestDefenderAPIInvalidIDMock(t *testing.T) {
|
||||
token, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
|
||||
assert.NoError(t, err)
|
||||
req, _ := http.NewRequest(http.MethodGet, path.Join(defenderHosts, "abc"), nil) // not hex id
|
||||
setBearerForReq(req, token)
|
||||
rr := executeRequest(req)
|
||||
checkResponseCode(t, http.StatusBadRequest, rr)
|
||||
assert.Contains(t, rr.Body.String(), "invalid host id")
|
||||
}
|
||||
|
||||
func TestTokenHeaderCookie(t *testing.T) {
|
||||
apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
|
||||
assert.NoError(t, err)
|
||||
|
@ -8473,6 +8540,43 @@ func TestStaticFilesMock(t *testing.T) {
|
|||
checkResponseCode(t, http.StatusOK, rr)
|
||||
}
|
||||
|
||||
func waitForUsersQuotaScan(t *testing.T, token string) {
|
||||
for {
|
||||
var scans []common.ActiveQuotaScan
|
||||
req, _ := http.NewRequest(http.MethodGet, quotaScanPath, nil)
|
||||
setBearerForReq(req, token)
|
||||
rr := executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr)
|
||||
err := render.DecodeJSON(rr.Body, &scans)
|
||||
|
||||
if !assert.NoError(t, err, "Error getting active scans") {
|
||||
break
|
||||
}
|
||||
if len(scans) == 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForFoldersQuotaScanPath(t *testing.T, token string) {
|
||||
var scans []common.ActiveVirtualFolderQuotaScan
|
||||
for {
|
||||
req, _ := http.NewRequest(http.MethodGet, quotaScanVFolderPath, nil)
|
||||
setBearerForReq(req, token)
|
||||
rr := executeRequest(req)
|
||||
checkResponseCode(t, http.StatusOK, rr)
|
||||
err := render.DecodeJSON(rr.Body, &scans)
|
||||
if !assert.NoError(t, err, "Error getting active folders scans") {
|
||||
break
|
||||
}
|
||||
if len(scans) == 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func waitTCPListening(address string) {
|
||||
for {
|
||||
conn, err := net.Dial("tcp", address)
|
||||
|
|
|
@ -869,7 +869,7 @@ func TestQuotaScanInvalidFs(t *testing.T) {
|
|||
},
|
||||
}
|
||||
common.QuotaScans.AddUserQuotaScan(user.Username)
|
||||
err := doQuotaScan(user)
|
||||
err := doUserQuotaScan(user)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ info:
|
|||
Several storage backends are supported and they are configurable per user, so you can serve a local directory for a user and an S3 bucket (or part of it) for another one.
|
||||
SFTPGo also supports virtual folders, a virtual folder can use any of the supported storage backends. So you can have, for example, an S3 user that exposes a GCS bucket (or part of it) on a specified path and an encrypted local filesystem on another one.
|
||||
Virtual folders can be private or shared among multiple users, for shared virtual folders you can define different quota limits for each user.
|
||||
version: 2.0.9
|
||||
version: 2.1.0
|
||||
contact:
|
||||
name: API support
|
||||
url: 'https://github.com/drakkan/sftpgo'
|
||||
|
@ -166,8 +166,8 @@ paths:
|
|||
tags:
|
||||
- admins
|
||||
summary: Change admin password
|
||||
description: Changes the password for the logged in admin. Please use /admin/changepwd instead
|
||||
operationId: change_admin_pwd
|
||||
description: Changes the password for the logged in admin. Please use '/admin/changepwd' instead
|
||||
operationId: change_admin_password_deprecated
|
||||
deprecated: true
|
||||
requestBody:
|
||||
required: true
|
||||
|
@ -275,12 +275,93 @@ paths:
|
|||
$ref: '#/components/responses/InternalServerError'
|
||||
default:
|
||||
$ref: '#/components/responses/DefaultResponse'
|
||||
/defender/bantime:
|
||||
/defender/hosts:
|
||||
get:
|
||||
tags:
|
||||
- defender
|
||||
summary: Get hosts
|
||||
description: Returns hosts that are banned or for which some violations have been detected
|
||||
operationId: get_defender_hosts
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/DefenderEntry'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
default:
|
||||
$ref: '#/components/responses/DefaultResponse'
|
||||
/defender/hosts/{id}:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
description: host id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
get:
|
||||
tags:
|
||||
- defender
|
||||
summary: Get host by id
|
||||
description: Returns the host with the given id, if it exists
|
||||
operationId: get_defender_host_by_id
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/DefenderEntry'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
default:
|
||||
$ref: '#/components/responses/DefaultResponse'
|
||||
delete:
|
||||
tags:
|
||||
- defender
|
||||
summary: Removes a host from the defender lists
|
||||
description: Unbans the specified host or clears its violations
|
||||
operationId: delete_defender_host_by_id
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ApiResponse'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
default:
|
||||
$ref: '#/components/responses/DefaultResponse'
|
||||
/defender/bantime:
|
||||
get:
|
||||
deprecated: true
|
||||
tags:
|
||||
- defender
|
||||
summary: Get ban time
|
||||
description: Returns the ban time for the specified IPv4/IPv6 address
|
||||
description: Deprecated, please use '/defender/hosts', '/defender/hosts/{id}' instead
|
||||
operationId: get_ban_time
|
||||
parameters:
|
||||
- in: query
|
||||
|
@ -308,10 +389,11 @@ paths:
|
|||
$ref: '#/components/responses/DefaultResponse'
|
||||
/defender/unban:
|
||||
post:
|
||||
deprecated: true
|
||||
tags:
|
||||
- defender
|
||||
summary: Unban
|
||||
description: Removes the specified IPv4/IPv6 from the banned ones
|
||||
description: Deprecated, please use '/defender/hosts/{id}' instead
|
||||
operationId: unban_host
|
||||
requestBody:
|
||||
required: true
|
||||
|
@ -344,10 +426,11 @@ paths:
|
|||
$ref: '#/components/responses/DefaultResponse'
|
||||
/defender/score:
|
||||
get:
|
||||
deprecated: true
|
||||
tags:
|
||||
- defender
|
||||
summary: Get score
|
||||
description: Returns the score for the specified IPv4/IPv6 address
|
||||
description: Deprecated, please use '/defender/hosts', '/defender/hosts/{id}' instead
|
||||
operationId: get_score
|
||||
parameters:
|
||||
- in: query
|
||||
|
@ -373,13 +456,282 @@ paths:
|
|||
$ref: '#/components/responses/InternalServerError'
|
||||
default:
|
||||
$ref: '#/components/responses/DefaultResponse'
|
||||
/quota-scans:
|
||||
/quotas/users/scans:
|
||||
get:
|
||||
tags:
|
||||
- quota
|
||||
summary: Get active user quota scans
|
||||
description: Returns the active user quota scans
|
||||
operationId: get_users_quota_scans
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/QuotaScan'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
default:
|
||||
$ref: '#/components/responses/DefaultResponse'
|
||||
/quotas/users/{username}/scan:
|
||||
parameters:
|
||||
- name: username
|
||||
in: path
|
||||
description: the username
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
post:
|
||||
tags:
|
||||
- quota
|
||||
summary: Start a user quota scan
|
||||
description: Starts a new quota scan for the given user. A quota scan updates the number of files and their total size for the specified user and the virtual folders, if any, included in his quota
|
||||
operationId: start_user_quota_scan
|
||||
responses:
|
||||
'202':
|
||||
description: successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ApiResponse'
|
||||
example:
|
||||
message: Scan started
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'409':
|
||||
$ref: '#/components/responses/Conflict'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
default:
|
||||
$ref: '#/components/responses/DefaultResponse'
|
||||
/quotas/users/{username}/usage:
|
||||
parameters:
|
||||
- name: username
|
||||
in: path
|
||||
description: the username
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: mode
|
||||
required: false
|
||||
description: the update mode specifies if the given quota usage values should be added or replace the current ones
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- add
|
||||
- reset
|
||||
description: |
|
||||
Update type:
|
||||
* `add` - add the specified quota limits to the current used ones
|
||||
* `reset` - reset the values to the specified ones. This is the default
|
||||
example: reset
|
||||
put:
|
||||
tags:
|
||||
- quota
|
||||
summary: Update quota usage limits
|
||||
description: Sets the current used quota limits for the given user
|
||||
operationId: user_quota_update_usage
|
||||
parameters:
|
||||
- in: query
|
||||
name: mode
|
||||
required: false
|
||||
description: the update mode specifies if the given quota usage values should be added or replace the current ones
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- add
|
||||
- reset
|
||||
description: |
|
||||
Update type:
|
||||
* `add` - add the specified quota limits to the current used ones
|
||||
* `reset` - reset the values to the specified ones. This is the default
|
||||
example: reset
|
||||
requestBody:
|
||||
required: true
|
||||
description: 'If used_quota_size and used_quota_files are missing they will default to 0, this means that if mode is "add" the current value, for the missing field, will remain unchanged, if mode is "reset" the missing field is set to 0'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/QuotaUsage'
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ApiResponse'
|
||||
example:
|
||||
message: Quota updated
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'409':
|
||||
$ref: '#/components/responses/Conflict'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
default:
|
||||
$ref: '#/components/responses/DefaultResponse'
|
||||
/quotas/folders/scans:
|
||||
get:
|
||||
tags:
|
||||
- quota
|
||||
summary: Get active folder quota scans
|
||||
description: Returns the active folder quota scans
|
||||
operationId: get_folders_quota_scans
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/FolderQuotaScan'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
default:
|
||||
$ref: '#/components/responses/DefaultResponse'
|
||||
/quotas/folders/{name}/scan:
|
||||
parameters:
|
||||
- name: name
|
||||
in: path
|
||||
description: folder name
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
post:
|
||||
tags:
|
||||
- quota
|
||||
summary: Start a folder quota scan
|
||||
description: Starts a new quota scan for the given folder. A quota scan update the number of files and their total size for the specified folder
|
||||
operationId: start_folder_quota_scan
|
||||
responses:
|
||||
'202':
|
||||
description: successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ApiResponse'
|
||||
example:
|
||||
message: Scan started
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'409':
|
||||
$ref: '#/components/responses/Conflict'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
default:
|
||||
$ref: '#/components/responses/DefaultResponse'
|
||||
/quotas/folders/{name}/usage:
|
||||
parameters:
|
||||
- name: name
|
||||
in: path
|
||||
description: folder name
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: mode
|
||||
required: false
|
||||
description: the update mode specifies if the given quota usage values should be added or replace the current ones
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- add
|
||||
- reset
|
||||
description: |
|
||||
Update type:
|
||||
* `add` - add the specified quota limits to the current used ones
|
||||
* `reset` - reset the values to the specified ones. This is the default
|
||||
example: reset
|
||||
put:
|
||||
tags:
|
||||
- quota
|
||||
summary: Update folder quota usage limits
|
||||
description: Sets the current used quota limits for the given folder
|
||||
operationId: folder_quota_update_usage
|
||||
parameters:
|
||||
- in: query
|
||||
name: mode
|
||||
required: false
|
||||
description: the update mode specifies if the given quota usage values should be added or replace the current ones
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- add
|
||||
- reset
|
||||
description: |
|
||||
Update type:
|
||||
* `add` - add the specified quota limits to the current used ones
|
||||
* `reset` - reset the values to the specified ones. This is the default
|
||||
example: reset
|
||||
requestBody:
|
||||
required: true
|
||||
description: 'If used_quota_size and used_quota_files are missing they will default to 0, this means that if mode is "add" the current value, for the missing field, will remain unchanged, if mode is "reset" the missing field is set to 0'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/QuotaUsage'
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ApiResponse'
|
||||
example:
|
||||
message: Quota updated
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'409':
|
||||
$ref: '#/components/responses/Conflict'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
default:
|
||||
$ref: '#/components/responses/DefaultResponse'
|
||||
/quota-scans:
|
||||
get:
|
||||
deprecated: true
|
||||
tags:
|
||||
- quota
|
||||
summary: Get quota scans
|
||||
description: Returns active user quota scans
|
||||
operationId: get_quota_scans
|
||||
description: Deprecated, please use '/quotas/users/scans' instead
|
||||
operationId: get_users_quota_scans_deprecated
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
|
@ -398,11 +750,12 @@ paths:
|
|||
default:
|
||||
$ref: '#/components/responses/DefaultResponse'
|
||||
post:
|
||||
deprecated: true
|
||||
tags:
|
||||
- quota
|
||||
summary: Start user quota scan
|
||||
description: Starts a new quota scan for the given user. A quota scan updates the number of files and their total size for the specified user and the virtual folders, if any, included in his quota
|
||||
operationId: start_quota_scan
|
||||
description: Deprecated, please use '/quotas/users/{username}/scan' instead
|
||||
operationId: start_user_quota_scan_deprecated
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
|
@ -434,11 +787,12 @@ paths:
|
|||
$ref: '#/components/responses/DefaultResponse'
|
||||
/quota-update:
|
||||
put:
|
||||
deprecated: true
|
||||
tags:
|
||||
- quota
|
||||
summary: Update user quota limits
|
||||
description: Sets the current used quota limits for the given user
|
||||
operationId: quota_update
|
||||
summary: Update quota usage limits
|
||||
description: Deprecated, please use '/quotas/users/{username}/usage' instead
|
||||
operationId: user_quota_update_usage_deprecated
|
||||
parameters:
|
||||
- in: query
|
||||
name: mode
|
||||
|
@ -478,19 +832,18 @@ paths:
|
|||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'409':
|
||||
$ref: '#/components/responses/Conflict'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
default:
|
||||
$ref: '#/components/responses/DefaultResponse'
|
||||
/folder-quota-update:
|
||||
put:
|
||||
deprecated: true
|
||||
tags:
|
||||
- quota
|
||||
summary: Update folder quota limits
|
||||
description: Sets the current used quota limits for the given folder
|
||||
operationId: folder_quota_update
|
||||
description: Deprecated, please use '/quotas/folders/{name}/usage' instead
|
||||
operationId: folder_quota_update_usage_deprecated
|
||||
parameters:
|
||||
- in: query
|
||||
name: mode
|
||||
|
@ -530,19 +883,18 @@ paths:
|
|||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'409':
|
||||
$ref: '#/components/responses/Conflict'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalServerError'
|
||||
default:
|
||||
$ref: '#/components/responses/DefaultResponse'
|
||||
/folder-quota-scans:
|
||||
get:
|
||||
deprecated: true
|
||||
tags:
|
||||
- quota
|
||||
summary: Get folders quota scans
|
||||
description: Returns the active quota scans for folders
|
||||
operationId: get_folders_quota_scans
|
||||
description: Deprecated, please use '/quotas/folders/scans' instead
|
||||
operationId: get_folders_quota_scans_deprecated
|
||||
responses:
|
||||
'200':
|
||||
description: successful operation
|
||||
|
@ -561,11 +913,12 @@ paths:
|
|||
default:
|
||||
$ref: '#/components/responses/DefaultResponse'
|
||||
post:
|
||||
deprecated: true
|
||||
tags:
|
||||
- quota
|
||||
summary: Start folder quota scan
|
||||
description: Starts a new quota scan for the given folder. A quota scan update the number of files and their total size for the specified folder
|
||||
operationId: start_folder_quota_scan
|
||||
summary: Start a folder quota scan
|
||||
description: Deprecated, please use '/quotas/folders/{name}/scan' instead
|
||||
operationId: start_folder_quota_scan_deprecated
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
|
@ -2142,6 +2495,15 @@ components:
|
|||
additional_info:
|
||||
type: string
|
||||
description: Free form text field
|
||||
QuotaUsage:
|
||||
type: object
|
||||
properties:
|
||||
used_quota_size:
|
||||
type: integer
|
||||
format: int64
|
||||
used_quota_files:
|
||||
type: integer
|
||||
format: int32
|
||||
Transfer:
|
||||
type: object
|
||||
properties:
|
||||
|
@ -2223,6 +2585,20 @@ components:
|
|||
type: integer
|
||||
format: int64
|
||||
description: scan start time as unix timestamp in milliseconds
|
||||
DefenderEntry:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
ip:
|
||||
type: string
|
||||
score:
|
||||
type: integer
|
||||
description: the score increases whenever a violation is detected, such as an attempt to log in using an incorrect password or invalid username. If the score exceeds the configured threshold, the IP is banned. Omitted for banned IPs
|
||||
ban_time:
|
||||
type: string
|
||||
format: date-time
|
||||
description: date time until the IP is banned. For already banned hosts, the ban time is increased each time a new violation is detected. Omitted if the IP is not banned
|
||||
SSHHostKey:
|
||||
type: object
|
||||
properties:
|
||||
|
|
|
@ -580,10 +580,14 @@ func (s *httpdServer) initializeRouter() {
|
|||
|
||||
router.With(checkPerm(dataprovider.PermAdminCloseConnections)).
|
||||
Delete(activeConnectionsPath+"/{connectionID}", handleCloseConnection)
|
||||
router.With(checkPerm(dataprovider.PermAdminQuotaScans)).Get(quotaScanPath, getQuotaScans)
|
||||
router.With(checkPerm(dataprovider.PermAdminQuotaScans)).Post(quotaScanPath, startQuotaScan)
|
||||
router.With(checkPerm(dataprovider.PermAdminQuotaScans)).Get(quotaScanVFolderPath, getVFolderQuotaScans)
|
||||
router.With(checkPerm(dataprovider.PermAdminQuotaScans)).Post(quotaScanVFolderPath, startVFolderQuotaScan)
|
||||
router.With(checkPerm(dataprovider.PermAdminQuotaScans)).Get(quotaScanPath, getUsersQuotaScans)
|
||||
router.With(checkPerm(dataprovider.PermAdminQuotaScans)).Get(quotasBasePath+"/users/scans", getUsersQuotaScans)
|
||||
router.With(checkPerm(dataprovider.PermAdminQuotaScans)).Post(quotaScanPath, startUserQuotaScanCompat)
|
||||
router.With(checkPerm(dataprovider.PermAdminQuotaScans)).Post(quotasBasePath+"/users/{username}/scan", startUserQuotaScan)
|
||||
router.With(checkPerm(dataprovider.PermAdminQuotaScans)).Get(quotaScanVFolderPath, getFoldersQuotaScans)
|
||||
router.With(checkPerm(dataprovider.PermAdminQuotaScans)).Get(quotasBasePath+"/folders/scans", getFoldersQuotaScans)
|
||||
router.With(checkPerm(dataprovider.PermAdminQuotaScans)).Post(quotaScanVFolderPath, startFolderQuotaScanCompat)
|
||||
router.With(checkPerm(dataprovider.PermAdminQuotaScans)).Post(quotasBasePath+"/folders/{name}/scan", startFolderQuotaScan)
|
||||
router.With(checkPerm(dataprovider.PermAdminViewUsers)).Get(userPath, getUsers)
|
||||
router.With(checkPerm(dataprovider.PermAdminAddUsers)).Post(userPath, addUser)
|
||||
router.With(checkPerm(dataprovider.PermAdminViewUsers)).Get(userPath+"/{username}", getUserByUsername)
|
||||
|
@ -597,8 +601,13 @@ func (s *httpdServer) initializeRouter() {
|
|||
router.With(checkPerm(dataprovider.PermAdminManageSystem)).Get(dumpDataPath, dumpData)
|
||||
router.With(checkPerm(dataprovider.PermAdminManageSystem)).Get(loadDataPath, loadData)
|
||||
router.With(checkPerm(dataprovider.PermAdminManageSystem)).Post(loadDataPath, loadDataFromRequest)
|
||||
router.With(checkPerm(dataprovider.PermAdminChangeUsers)).Put(updateUsedQuotaPath, updateUserQuotaUsage)
|
||||
router.With(checkPerm(dataprovider.PermAdminChangeUsers)).Put(updateFolderUsedQuotaPath, updateVFolderQuotaUsage)
|
||||
router.With(checkPerm(dataprovider.PermAdminChangeUsers)).Put(updateUsedQuotaPath, updateUserQuotaUsageCompat)
|
||||
router.With(checkPerm(dataprovider.PermAdminChangeUsers)).Put(quotasBasePath+"/users/{username}/usage", updateUserQuotaUsage)
|
||||
router.With(checkPerm(dataprovider.PermAdminChangeUsers)).Put(updateFolderUsedQuotaPath, updateFolderQuotaUsageCompat)
|
||||
router.With(checkPerm(dataprovider.PermAdminChangeUsers)).Put(quotasBasePath+"/folders/{name}/usage", updateFolderQuotaUsage)
|
||||
router.With(checkPerm(dataprovider.PermAdminViewDefender)).Get(defenderHosts, getDefenderHosts)
|
||||
router.With(checkPerm(dataprovider.PermAdminViewDefender)).Get(defenderHosts+"/{id}", getDefenderHostByID)
|
||||
router.With(checkPerm(dataprovider.PermAdminManageDefender)).Delete(defenderHosts+"/{id}", deleteDefenderHostByID)
|
||||
router.With(checkPerm(dataprovider.PermAdminViewDefender)).Get(defenderBanTime, getBanTime)
|
||||
router.With(checkPerm(dataprovider.PermAdminViewDefender)).Get(defenderScore, getScore)
|
||||
router.With(checkPerm(dataprovider.PermAdminManageDefender)).Post(defenderUnban, unban)
|
||||
|
@ -719,11 +728,11 @@ func (s *httpdServer) initializeRouter() {
|
|||
router.With(checkPerm(dataprovider.PermAdminDeleteUsers), verifyCSRFHeader).
|
||||
Delete(webFolderPath+"/{name}", deleteFolder)
|
||||
router.With(checkPerm(dataprovider.PermAdminQuotaScans), verifyCSRFHeader).
|
||||
Post(webScanVFolderPath, startVFolderQuotaScan)
|
||||
Post(webScanVFolderPath+"/{name}", startFolderQuotaScan)
|
||||
router.With(checkPerm(dataprovider.PermAdminDeleteUsers), verifyCSRFHeader).
|
||||
Delete(webUserPath+"/{username}", deleteUser)
|
||||
router.With(checkPerm(dataprovider.PermAdminQuotaScans), verifyCSRFHeader).
|
||||
Post(webQuotaScanPath, startQuotaScan)
|
||||
Post(webQuotaScanPath+"/{username}", startUserQuotaScan)
|
||||
router.With(checkPerm(dataprovider.PermAdminManageSystem)).Get(webMaintenancePath, handleWebMaintenance)
|
||||
router.With(checkPerm(dataprovider.PermAdminManageSystem)).Get(webBackupPath, dumpData)
|
||||
router.With(checkPerm(dataprovider.PermAdminManageSystem)).Post(webRestorePath, handleWebRestore)
|
||||
|
|
|
@ -3,6 +3,7 @@ package httpdtest
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
@ -28,16 +29,16 @@ import (
|
|||
const (
|
||||
tokenPath = "/api/v2/token"
|
||||
activeConnectionsPath = "/api/v2/connections"
|
||||
quotaScanPath = "/api/v2/quota-scans"
|
||||
quotaScanVFolderPath = "/api/v2/folder-quota-scans"
|
||||
quotasBasePath = "/api/v2/quotas"
|
||||
quotaScanPath = "/api/v2/quotas/users/scans"
|
||||
quotaScanVFolderPath = "/api/v2/quotas/folders/scans"
|
||||
userPath = "/api/v2/users"
|
||||
versionPath = "/api/v2/version"
|
||||
folderPath = "/api/v2/folders"
|
||||
serverStatusPath = "/api/v2/status"
|
||||
dumpDataPath = "/api/v2/dumpdata"
|
||||
loadDataPath = "/api/v2/loaddata"
|
||||
updateUsedQuotaPath = "/api/v2/quota-update"
|
||||
updateFolderUsedQuotaPath = "/api/v2/folder-quota-update"
|
||||
defenderHosts = "/api/v2/defender/hosts"
|
||||
defenderBanTime = "/api/v2/defender/bantime"
|
||||
defenderUnban = "/api/v2/defender/unban"
|
||||
defenderScore = "/api/v2/defender/score"
|
||||
|
@ -392,9 +393,8 @@ func GetQuotaScans(expectedStatusCode int) ([]common.ActiveQuotaScan, []byte, er
|
|||
// StartQuotaScan starts a new quota scan for the given user and checks the received HTTP Status code against expectedStatusCode.
|
||||
func StartQuotaScan(user dataprovider.User, expectedStatusCode int) ([]byte, error) {
|
||||
var body []byte
|
||||
userAsJSON, _ := json.Marshal(user)
|
||||
resp, err := sendHTTPRequest(http.MethodPost, buildURLRelativeToBase(quotaScanPath), bytes.NewBuffer(userAsJSON),
|
||||
"", getDefaultToken())
|
||||
resp, err := sendHTTPRequest(http.MethodPost, buildURLRelativeToBase(quotasBasePath, "users", user.Username, "scan"),
|
||||
nil, "", getDefaultToken())
|
||||
if err != nil {
|
||||
return body, err
|
||||
}
|
||||
|
@ -407,7 +407,7 @@ func StartQuotaScan(user dataprovider.User, expectedStatusCode int) ([]byte, err
|
|||
func UpdateQuotaUsage(user dataprovider.User, mode string, expectedStatusCode int) ([]byte, error) {
|
||||
var body []byte
|
||||
userAsJSON, _ := json.Marshal(user)
|
||||
url, err := addModeQueryParam(buildURLRelativeToBase(updateUsedQuotaPath), mode)
|
||||
url, err := addModeQueryParam(buildURLRelativeToBase(quotasBasePath, "users", user.Username, "usage"), mode)
|
||||
if err != nil {
|
||||
return body, err
|
||||
}
|
||||
|
@ -584,9 +584,8 @@ func GetFoldersQuotaScans(expectedStatusCode int) ([]common.ActiveVirtualFolderQ
|
|||
// StartFolderQuotaScan start a new quota scan for the given folder and checks the received HTTP Status code against expectedStatusCode.
|
||||
func StartFolderQuotaScan(folder vfs.BaseVirtualFolder, expectedStatusCode int) ([]byte, error) {
|
||||
var body []byte
|
||||
folderAsJSON, _ := json.Marshal(folder)
|
||||
resp, err := sendHTTPRequest(http.MethodPost, buildURLRelativeToBase(quotaScanVFolderPath),
|
||||
bytes.NewBuffer(folderAsJSON), "", getDefaultToken())
|
||||
resp, err := sendHTTPRequest(http.MethodPost, buildURLRelativeToBase(quotasBasePath, "folders", folder.Name, "scan"),
|
||||
nil, "", getDefaultToken())
|
||||
if err != nil {
|
||||
return body, err
|
||||
}
|
||||
|
@ -599,7 +598,7 @@ func StartFolderQuotaScan(folder vfs.BaseVirtualFolder, expectedStatusCode int)
|
|||
func UpdateFolderQuotaUsage(folder vfs.BaseVirtualFolder, mode string, expectedStatusCode int) ([]byte, error) {
|
||||
var body []byte
|
||||
folderAsJSON, _ := json.Marshal(folder)
|
||||
url, err := addModeQueryParam(buildURLRelativeToBase(updateFolderUsedQuotaPath), mode)
|
||||
url, err := addModeQueryParam(buildURLRelativeToBase(quotasBasePath, "folders", folder.Name, "usage"), mode)
|
||||
if err != nil {
|
||||
return body, err
|
||||
}
|
||||
|
@ -648,6 +647,61 @@ func GetStatus(expectedStatusCode int) (httpd.ServicesStatus, []byte, error) {
|
|||
return response, body, err
|
||||
}
|
||||
|
||||
// GetDefenderHosts returns hosts that are banned or for which some violations have been detected
|
||||
func GetDefenderHosts(expectedStatusCode int) ([]common.DefenderEntry, []byte, error) {
|
||||
var response []common.DefenderEntry
|
||||
var body []byte
|
||||
url, err := url.Parse(buildURLRelativeToBase(defenderHosts))
|
||||
if err != nil {
|
||||
return response, body, err
|
||||
}
|
||||
resp, err := sendHTTPRequest(http.MethodGet, url.String(), nil, "", getDefaultToken())
|
||||
if err != nil {
|
||||
return response, body, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
err = checkResponse(resp.StatusCode, expectedStatusCode)
|
||||
if err == nil && expectedStatusCode == http.StatusOK {
|
||||
err = render.DecodeJSON(resp.Body, &response)
|
||||
} else {
|
||||
body, _ = getResponseBody(resp)
|
||||
}
|
||||
return response, body, err
|
||||
}
|
||||
|
||||
// GetDefenderHostByIP returns the host with the given IP, if it exists
|
||||
func GetDefenderHostByIP(ip string, expectedStatusCode int) (common.DefenderEntry, []byte, error) {
|
||||
var host common.DefenderEntry
|
||||
var body []byte
|
||||
id := hex.EncodeToString([]byte(ip))
|
||||
resp, err := sendHTTPRequest(http.MethodGet, buildURLRelativeToBase(defenderHosts, id),
|
||||
nil, "", getDefaultToken())
|
||||
if err != nil {
|
||||
return host, body, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
err = checkResponse(resp.StatusCode, expectedStatusCode)
|
||||
if err == nil && expectedStatusCode == http.StatusOK {
|
||||
err = render.DecodeJSON(resp.Body, &host)
|
||||
} else {
|
||||
body, _ = getResponseBody(resp)
|
||||
}
|
||||
return host, body, err
|
||||
}
|
||||
|
||||
// RemoveDefenderHostByIP removes the host with the given IP from the defender list
|
||||
func RemoveDefenderHostByIP(ip string, expectedStatusCode int) ([]byte, error) {
|
||||
var body []byte
|
||||
id := hex.EncodeToString([]byte(ip))
|
||||
resp, err := sendHTTPRequest(http.MethodDelete, buildURLRelativeToBase(defenderHosts, id), nil, "", getDefaultToken())
|
||||
if err != nil {
|
||||
return body, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ = getResponseBody(resp)
|
||||
return body, checkResponse(resp.StatusCode, expectedStatusCode)
|
||||
}
|
||||
|
||||
// GetBanTime returns the ban time for the given IP address
|
||||
func GetBanTime(ip string, expectedStatusCode int) (map[string]interface{}, []byte, error) {
|
||||
var response map[string]interface{}
|
||||
|
|
|
@ -179,13 +179,11 @@ function deleteAction() {
|
|||
action: function (e, dt, node, config) {
|
||||
dt.button('quota_scan:name').enable(false);
|
||||
var folderName = dt.row({ selected: true }).data()[0];
|
||||
var path = '{{.FolderQuotaScanURL}}'
|
||||
var path = '{{.FolderQuotaScanURL}}'+ "/" + fixedEncodeURIComponent(folderName);
|
||||
$.ajax({
|
||||
url: path,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
|
||||
data: JSON.stringify({ "name": folderName }),
|
||||
timeout: 15000,
|
||||
success: function (result) {
|
||||
dt.button('quota_scan:name').enable(true);
|
||||
|
|
|
@ -199,13 +199,11 @@
|
|||
action: function (e, dt, node, config) {
|
||||
dt.button('quota_scan:name').enable(false);
|
||||
var username = dt.row({ selected: true }).data()[1];
|
||||
var path = '{{.QuotaScanURL}}'
|
||||
var path = '{{.QuotaScanURL}}'+ "/" + fixedEncodeURIComponent(username);
|
||||
$.ajax({
|
||||
url: path,
|
||||
type: 'POST',
|
||||
dataType: 'json',
|
||||
headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
|
||||
data: JSON.stringify({ "username": username }),
|
||||
timeout: 15000,
|
||||
success: function (result) {
|
||||
dt.button('quota_scan:name').enable(true);
|
||||
|
|
Loading…
Reference in a new issue