Przeglądaj źródła

improve defender and quotas REST API

Nicola Murino 4 lat temu
rodzic
commit
feec2118bb

+ 21 - 3
common/common.go

@@ -199,13 +199,31 @@ func GetDefenderBanTime(ip string) *time.Time {
 	return Config.defender.GetBanTime(ip)
 	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 {
 	if Config.defender == nil {
 		return false
 		return false
 	}
 	}
 
 
-	return Config.defender.Unban(ip)
+	return Config.defender.DeleteHost(ip)
 }
 }
 
 
 // GetDefenderScore returns the score for the given IP
 // GetDefenderScore returns the score for the given IP

+ 20 - 5
common/common_test.go

@@ -1,6 +1,7 @@
 package common
 package common
 
 
 import (
 import (
+	"encoding/json"
 	"fmt"
 	"fmt"
 	"net"
 	"net"
 	"os"
 	"os"
@@ -129,8 +130,11 @@ func TestDefenderIntegration(t *testing.T) {
 	assert.False(t, IsBanned(ip))
 	assert.False(t, IsBanned(ip))
 
 
 	assert.Nil(t, GetDefenderBanTime(ip))
 	assert.Nil(t, GetDefenderBanTime(ip))
-	assert.False(t, Unban(ip))
+	assert.False(t, DeleteDefenderHost(ip))
 	assert.Equal(t, 0, GetDefenderScore(ip))
 	assert.Equal(t, 0, GetDefenderScore(ip))
+	_, err := GetDefenderHost(ip)
+	assert.Error(t, err)
+	assert.Nil(t, GetDefenderHosts())
 
 
 	Config.DefenderConfig = DefenderConfig{
 	Config.DefenderConfig = DefenderConfig{
 		Enabled:          true,
 		Enabled:          true,
@@ -143,7 +147,7 @@ func TestDefenderIntegration(t *testing.T) {
 		EntriesSoftLimit: 100,
 		EntriesSoftLimit: 100,
 		EntriesHardLimit: 150,
 		EntriesHardLimit: 150,
 	}
 	}
-	err := Initialize(Config)
+	err = Initialize(Config)
 	assert.Error(t, err)
 	assert.Error(t, err)
 	Config.DefenderConfig.Threshold = 3
 	Config.DefenderConfig.Threshold = 3
 	err = Initialize(Config)
 	err = Initialize(Config)
@@ -153,16 +157,27 @@ func TestDefenderIntegration(t *testing.T) {
 	AddDefenderEvent(ip, HostEventNoLoginTried)
 	AddDefenderEvent(ip, HostEventNoLoginTried)
 	assert.False(t, IsBanned(ip))
 	assert.False(t, IsBanned(ip))
 	assert.Equal(t, 2, GetDefenderScore(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))
 	assert.Nil(t, GetDefenderBanTime(ip))
 
 
 	AddDefenderEvent(ip, HostEventLoginFailed)
 	AddDefenderEvent(ip, HostEventLoginFailed)
+	AddDefenderEvent(ip, HostEventNoLoginTried)
 	assert.True(t, IsBanned(ip))
 	assert.True(t, IsBanned(ip))
 	assert.Equal(t, 0, GetDefenderScore(ip))
 	assert.Equal(t, 0, GetDefenderScore(ip))
 	assert.NotNil(t, GetDefenderBanTime(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.Nil(t, GetDefenderBanTime(ip))
-	assert.False(t, Unban(ip))
+	assert.False(t, DeleteDefenderHost(ip))
 
 
 	Config = configCopy
 	Config = configCopy
 }
 }

+ 96 - 3
common/defender.go

@@ -1,6 +1,7 @@
 package common
 package common
 
 
 import (
 import (
+	"encoding/hex"
 	"encoding/json"
 	"encoding/json"
 	"fmt"
 	"fmt"
 	"net"
 	"net"
@@ -11,6 +12,7 @@ import (
 
 
 	"github.com/yl2chen/cidranger"
 	"github.com/yl2chen/cidranger"
 
 
+	"github.com/drakkan/sftpgo/dataprovider"
 	"github.com/drakkan/sftpgo/logger"
 	"github.com/drakkan/sftpgo/logger"
 	"github.com/drakkan/sftpgo/utils"
 	"github.com/drakkan/sftpgo/utils"
 )
 )
@@ -26,13 +28,50 @@ const (
 	HostEventLimitExceeded
 	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
 // Defender defines the interface that a defender must implements
 type Defender interface {
 type Defender interface {
+	GetHosts() []*DefenderEntry
+	GetHost(ip string) (*DefenderEntry, error)
 	AddEvent(ip string, event HostEvent)
 	AddEvent(ip string, event HostEvent)
 	IsBanned(ip string) bool
 	IsBanned(ip string) bool
 	GetBanTime(ip string) *time.Time
 	GetBanTime(ip string) *time.Time
 	GetScore(ip string) int
 	GetScore(ip string) int
-	Unban(ip string) bool
+	DeleteHost(ip string) bool
 	Reload() error
 	Reload() error
 }
 }
 
 
@@ -190,6 +229,50 @@ func (d *memoryDefender) Reload() error {
 	return nil
 	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
 // IsBanned returns true if the specified IP is banned
 // and increase ban time if the IP is found.
 // and increase ban time if the IP is found.
 // This method must be called as soon as the client connects
 // This method must be called as soon as the client connects
@@ -227,8 +310,8 @@ func (d *memoryDefender) IsBanned(ip string) bool {
 	return false
 	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()
 	d.Lock()
 	defer d.Unlock()
 	defer d.Unlock()
 
 
@@ -237,6 +320,11 @@ func (d *memoryDefender) Unban(ip string) bool {
 		return true
 		return true
 	}
 	}
 
 
+	if _, ok := d.hosts[ip]; ok {
+		delete(d.hosts, ip)
+		return true
+	}
+
 	return false
 	return false
 }
 }
 
 
@@ -250,6 +338,11 @@ func (d *memoryDefender) AddEvent(ip string, event HostEvent) {
 		return
 		return
 	}
 	}
 
 
+	// ignore events for already banned hosts
+	if _, ok := d.banned[ip]; ok {
+		return
+	}
+
 	var score int
 	var score int
 
 
 	switch event {
 	switch event {

+ 29 - 2
common/defender_test.go

@@ -2,6 +2,7 @@ package common
 
 
 import (
 import (
 	"crypto/rand"
 	"crypto/rand"
+	"encoding/hex"
 	"encoding/json"
 	"encoding/json"
 	"fmt"
 	"fmt"
 	"net"
 	"net"
@@ -72,6 +73,9 @@ func TestBasicDefender(t *testing.T) {
 	assert.False(t, defender.IsBanned("invalid ip"))
 	assert.False(t, defender.IsBanned("invalid ip"))
 	assert.Equal(t, 0, defender.countBanned())
 	assert.Equal(t, 0, defender.countBanned())
 	assert.Equal(t, 0, defender.countHosts())
 	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("172.16.1.4", HostEventLoginFailed)
 	defender.AddEvent("192.168.8.4", HostEventUserNotFound)
 	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, 1, defender.countHosts())
 	assert.Equal(t, 0, defender.countBanned())
 	assert.Equal(t, 0, defender.countBanned())
 	assert.Equal(t, 1, defender.GetScore(testIP))
 	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))
 	assert.Nil(t, defender.GetBanTime(testIP))
 	defender.AddEvent(testIP, HostEventLimitExceeded)
 	defender.AddEvent(testIP, HostEventLimitExceeded)
 	assert.Equal(t, 1, defender.countHosts())
 	assert.Equal(t, 1, defender.countHosts())
 	assert.Equal(t, 0, defender.countBanned())
 	assert.Equal(t, 0, defender.countBanned())
 	assert.Equal(t, 4, defender.GetScore(testIP))
 	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)
 	defender.AddEvent(testIP, HostEventNoLoginTried)
 	assert.Equal(t, 0, defender.countHosts())
 	assert.Equal(t, 0, defender.countHosts())
 	assert.Equal(t, 1, defender.countBanned())
 	assert.Equal(t, 1, defender.countBanned())
 	assert.Equal(t, 0, defender.GetScore(testIP))
 	assert.Equal(t, 0, defender.GetScore(testIP))
 	assert.NotNil(t, defender.GetBanTime(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
 	// now test cleanup, testIP is already banned
 	testIP1 := "12.34.56.79"
 	testIP1 := "12.34.56.79"
@@ -143,8 +170,8 @@ func TestBasicDefender(t *testing.T) {
 		assert.True(t, newBanTime.After(*banTime))
 		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)
 	err = os.Remove(slFile)
 	assert.NoError(t, err)
 	assert.NoError(t, err)

+ 7 - 0
dataprovider/dataprovider.go

@@ -403,6 +403,13 @@ func (e *RecordNotFoundError) Error() string {
 	return fmt.Sprintf("not found: %s", e.err)
 	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
 // GetQuotaTracking returns the configured mode for user's quota tracking
 func GetQuotaTracking() int {
 func GetQuotaTracking() int {
 	return config.TrackQuota
 	return config.TrackQuota

+ 47 - 1
httpd/api_defender.go

@@ -1,6 +1,7 @@
 package httpd
 package httpd
 
 
 import (
 import (
+	"encoding/hex"
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
 	"net"
 	"net"
@@ -12,6 +13,38 @@ import (
 	"github.com/drakkan/sftpgo/common"
 	"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) {
 func getBanTime(w http.ResponseWriter, r *http.Request) {
 	ip := r.URL.Query().Get("ip")
 	ip := r.URL.Query().Get("ip")
 	err := validateIPAddress(ip)
 	err := validateIPAddress(ip)
@@ -64,13 +97,26 @@ func unban(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 
 
-	if common.Unban(ip) {
+	if common.DeleteDefenderHost(ip) {
 		sendAPIResponse(w, r, nil, "OK", http.StatusOK)
 		sendAPIResponse(w, r, nil, "OK", http.StatusOK)
 	} else {
 	} else {
 		sendAPIResponse(w, r, nil, "Not found", http.StatusNotFound)
 		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 {
 func validateIPAddress(ip string) error {
 	if ip == "" {
 	if ip == "" {
 		return errors.New("ip address is required")
 		return errors.New("ip address is required")

+ 1 - 1
httpd/api_maintenance.go

@@ -282,7 +282,7 @@ func RestoreUsers(users []dataprovider.User, inputFile string, mode, scanQuota i
 		if scanQuota == 1 || (scanQuota == 2 && user.HasQuotaRestrictions()) {
 		if scanQuota == 1 || (scanQuota == 2 && user.HasQuotaRestrictions()) {
 			if common.QuotaScans.AddUserQuotaScan(user.Username) {
 			if common.QuotaScans.AddUserQuotaScan(user.Username) {
 				logger.Debug(logSender, "", "starting quota scan for restored user: %#v", user.Username)
 				logger.Debug(logSender, "", "starting quota scan for restored user: %#v", user.Username)
-				go doQuotaScan(user) //nolint:errcheck
+				go doUserQuotaScan(user) //nolint:errcheck
 			}
 			}
 		}
 		}
 	}
 	}

+ 105 - 43
httpd/api_quota.go

@@ -17,15 +17,31 @@ const (
 	quotaUpdateModeReset = "reset"
 	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())
 	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())
 	render.JSON(w, r, common.QuotaScans.GetVFoldersQuotaScans())
 }
 }
 
 
 func updateUserQuotaUsage(w http.ResponseWriter, r *http.Request) {
 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)
 	r.Body = http.MaxBytesReader(w, r.Body, maxRequestSize)
 	var u dataprovider.User
 	var u dataprovider.User
 	err := render.DecodeJSON(r.Body, &u)
 	err := render.DecodeJSON(r.Body, &u)
@@ -33,7 +49,74 @@ func updateUserQuotaUsage(w http.ResponseWriter, r *http.Request) {
 		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
 		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
 		return
 		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"),
 		sendAPIResponse(w, r, errors.New("invalid used quota parameters, negative values are not allowed"),
 			"", http.StatusBadRequest)
 			"", http.StatusBadRequest)
 		return
 		return
@@ -43,7 +126,7 @@ func updateUserQuotaUsage(w http.ResponseWriter, r *http.Request) {
 		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
 		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
 		return
 		return
 	}
 	}
-	user, err := dataprovider.UserExists(u.Username)
+	user, err := dataprovider.UserExists(username)
 	if err != nil {
 	if err != nil {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		return
 		return
@@ -58,7 +141,7 @@ func updateUserQuotaUsage(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 	defer common.QuotaScans.RemoveUserQuotaScan(user.Username)
 	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 {
 	if err != nil {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 	} else {
 	} 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"),
 		sendAPIResponse(w, r, errors.New("invalid used quota parameters, negative values are not allowed"),
 			"", http.StatusBadRequest)
 			"", http.StatusBadRequest)
 		return
 		return
@@ -84,7 +160,7 @@ func updateVFolderQuotaUsage(w http.ResponseWriter, r *http.Request) {
 		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
 		sendAPIResponse(w, r, err, "", http.StatusBadRequest)
 		return
 		return
 	}
 	}
-	folder, err := dataprovider.GetFolderByName(f.Name)
+	folder, err := dataprovider.GetFolderByName(name)
 	if err != nil {
 	if err != nil {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		return
 		return
@@ -94,7 +170,7 @@ func updateVFolderQuotaUsage(w http.ResponseWriter, r *http.Request) {
 		return
 		return
 	}
 	}
 	defer common.QuotaScans.RemoveVFolderQuotaScan(folder.Name)
 	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 {
 	if err != nil {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 	} else {
 	} 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 {
 	if dataprovider.GetQuotaTracking() == 0 {
 		sendAPIResponse(w, r, nil, "Quota tracking is disabled!", http.StatusForbidden)
 		sendAPIResponse(w, r, nil, "Quota tracking is disabled!", http.StatusForbidden)
 		return
 		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 {
 	if err != nil {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		return
 		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)
 		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 {
 	if dataprovider.GetQuotaTracking() == 0 {
 		sendAPIResponse(w, r, nil, "Quota tracking is disabled!", http.StatusForbidden)
 		sendAPIResponse(w, r, nil, "Quota tracking is disabled!", http.StatusForbidden)
 		return
 		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 {
 	if err != nil {
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		sendAPIResponse(w, r, err, "", getRespStatus(err))
 		return
 		return
 	}
 	}
-	if common.QuotaScans.AddVFolderQuotaScan(folder.Name) {
-		go doFolderQuotaScan(folder) //nolint:errcheck
-		sendAPIResponse(w, r, err, "Scan started", http.StatusAccepted)
-	} else {
+	if !common.QuotaScans.AddVFolderQuotaScan(folder.Name) {
 		sendAPIResponse(w, r, err, "Another scan is already in progress", http.StatusConflict)
 		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)
 }
 }
 
 
-func doQuotaScan(user dataprovider.User) error {
+func doUserQuotaScan(user dataprovider.User) error {
 	defer common.QuotaScans.RemoveUserQuotaScan(user.Username)
 	defer common.QuotaScans.RemoveUserQuotaScan(user.Username)
 	numFiles, size, err := user.ScanQuota()
 	numFiles, size, err := user.ScanQuota()
 	if err != nil {
 	if err != nil {

+ 4 - 2
httpd/httpd.go

@@ -35,6 +35,7 @@ const (
 	userTokenPath                   = "/api/v2/user/token"
 	userTokenPath                   = "/api/v2/user/token"
 	userLogoutPath                  = "/api/v2/user/logout"
 	userLogoutPath                  = "/api/v2/user/logout"
 	activeConnectionsPath           = "/api/v2/connections"
 	activeConnectionsPath           = "/api/v2/connections"
+	quotasBasePath                  = "/api/v2/quotas"
 	quotaScanPath                   = "/api/v2/quota-scans"
 	quotaScanPath                   = "/api/v2/quota-scans"
 	quotaScanVFolderPath            = "/api/v2/folder-quota-scans"
 	quotaScanVFolderPath            = "/api/v2/folder-quota-scans"
 	userPath                        = "/api/v2/users"
 	userPath                        = "/api/v2/users"
@@ -45,6 +46,7 @@ const (
 	loadDataPath                    = "/api/v2/loaddata"
 	loadDataPath                    = "/api/v2/loaddata"
 	updateUsedQuotaPath             = "/api/v2/quota-update"
 	updateUsedQuotaPath             = "/api/v2/quota-update"
 	updateFolderUsedQuotaPath       = "/api/v2/folder-quota-update"
 	updateFolderUsedQuotaPath       = "/api/v2/folder-quota-update"
+	defenderHosts                   = "/api/v2/defender/hosts"
 	defenderBanTime                 = "/api/v2/defender/bantime"
 	defenderBanTime                 = "/api/v2/defender/bantime"
 	defenderUnban                   = "/api/v2/defender/unban"
 	defenderUnban                   = "/api/v2/defender/unban"
 	defenderScore                   = "/api/v2/defender/score"
 	defenderScore                   = "/api/v2/defender/score"
@@ -75,8 +77,8 @@ const (
 	webMaintenancePathDefault       = "/web/admin/maintenance"
 	webMaintenancePathDefault       = "/web/admin/maintenance"
 	webBackupPathDefault            = "/web/admin/backup"
 	webBackupPathDefault            = "/web/admin/backup"
 	webRestorePathDefault           = "/web/admin/restore"
 	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"
 	webChangeAdminPwdPathDefault    = "/web/admin/changepwd"
 	webTemplateUserDefault          = "/web/admin/template/user"
 	webTemplateUserDefault          = "/web/admin/template/user"
 	webTemplateFolderDefault        = "/web/admin/template/folder"
 	webTemplateFolderDefault        = "/web/admin/template/folder"

+ 237 - 133
httpd/httpd_test.go

@@ -46,68 +46,72 @@ import (
 )
 )
 
 
 const (
 const (
-	defaultUsername           = "test_user"
-	defaultPassword           = "test_password"
-	testPubKey                = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC03jj0D+djk7pxIf/0OhrxrchJTRZklofJ1NoIu4752Sq02mdXmarMVsqJ1cAjV5LBVy3D1F5U6XW4rppkXeVtd04Pxb09ehtH0pRRPaoHHlALiJt8CoMpbKYMA8b3KXPPriGxgGomvtU2T2RMURSwOZbMtpsugfjYSWenyYX+VORYhylWnSXL961LTyC21ehd6d6QnW9G7E5hYMITMY9TuQZz3bROYzXiTsgN0+g6Hn7exFQp50p45StUMfV/SftCMdCxlxuyGny2CrN/vfjO7xxOo2uv7q1qm10Q46KPWJQv+pgZ/OfL+EDjy07n5QVSKHlbx+2nT4Q0EgOSQaCTYwn3YjtABfIxWwgAFdyj6YlPulCL22qU4MYhDcA6PSBwDdf8hvxBfvsiHdM+JcSHvv8/VeJhk6CmnZxGY0fxBupov27z3yEO8nAg8k+6PaUiW1MSUfuGMF/ktB8LOstXsEPXSszuyXiOv4DaryOXUiSn7bmRqKcEFlJusO6aZP0= nicola@p1"
-	testPubKey1               = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCd60+/j+y8f0tLftihWV1YN9RSahMI9btQMDIMqts/jeNbD8jgoogM3nhF7KxfcaMKURuD47KC4Ey6iAJUJ0sWkSNNxOcIYuvA+5MlspfZDsa8Ag76Fe1vyz72WeHMHMeh/hwFo2TeIeIXg480T1VI6mzfDrVp2GzUx0SS0dMsQBjftXkuVR8YOiOwMCAH2a//M1OrvV7d/NBk6kBN0WnuIBb2jKm15PAA7+jQQG7tzwk2HedNH3jeL5GH31xkSRwlBczRK0xsCQXehAlx6cT/e/s44iJcJTHfpPKoSk6UAhPJYe7Z1QnuoawY9P9jQaxpyeImBZxxUEowhjpj2avBxKdRGBVK8R7EL8tSOeLbhdyWe5Mwc1+foEbq9Zz5j5Kd+hn3Wm1UnsGCrXUUUoZp1jnlNl0NakCto+5KmqnT9cHxaY+ix2RLUWAZyVFlRq71OYux1UHJnEJPiEI1/tr4jFBSL46qhQZv/TfpkfVW8FLz0lErfqu0gQEZnNHr3Fc= nicola@p1"
-	defaultTokenAuthUser      = "admin"
-	defaultTokenAuthPass      = "password"
-	altAdminUsername          = "newTestAdmin"
-	altAdminPassword          = "password1"
-	csrfFormToken             = "_form_token"
-	tokenPath                 = "/api/v2/token"
-	userTokenPath             = "/api/v2/user/token"
-	userLogoutPath            = "/api/v2/user/logout"
-	userPath                  = "/api/v2/users"
-	adminPath                 = "/api/v2/admins"
-	adminPwdPath              = "/api/v2/admin/changepwd"
-	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"
-	defenderUnban             = "/api/v2/defender/unban"
-	versionPath               = "/api/v2/version"
-	logoutPath                = "/api/v2/logout"
-	userPwdPath               = "/api/v2/user/changepwd"
-	userPublicKeysPath        = "/api/v2/user/publickeys"
-	userReadFolderPath        = "/api/v2/user/folder"
-	userGetFilePath           = "/api/v2/user/file"
-	userStreamZipPath         = "/api/v2/user/streamzip"
-	healthzPath               = "/healthz"
-	webBasePath               = "/web"
-	webBasePathAdmin          = "/web/admin"
-	webAdminSetupPath         = "/web/admin/setup"
-	webLoginPath              = "/web/admin/login"
-	webLogoutPath             = "/web/admin/logout"
-	webUsersPath              = "/web/admin/users"
-	webUserPath               = "/web/admin/user"
-	webFoldersPath            = "/web/admin/folders"
-	webFolderPath             = "/web/admin/folder"
-	webConnectionsPath        = "/web/admin/connections"
-	webStatusPath             = "/web/admin/status"
-	webAdminsPath             = "/web/admin/managers"
-	webAdminPath              = "/web/admin/manager"
-	webMaintenancePath        = "/web/admin/maintenance"
-	webRestorePath            = "/web/admin/restore"
-	webChangeAdminPwdPath     = "/web/admin/changepwd"
-	webTemplateUser           = "/web/admin/template/user"
-	webTemplateFolder         = "/web/admin/template/folder"
-	webBasePathClient         = "/web/client"
-	webClientLoginPath        = "/web/client/login"
-	webClientFilesPath        = "/web/client/files"
-	webClientDirContentsPath  = "/web/client/listdir"
-	webClientDownloadZipPath  = "/web/client/downloadzip"
-	webClientCredentialsPath  = "/web/client/credentials"
-	webChangeClientPwdPath    = "/web/client/changepwd"
-	webChangeClientKeysPath   = "/web/client/managekeys"
-	webClientLogoutPath       = "/web/client/logout"
-	httpBaseURL               = "http://127.0.0.1:8081"
-	sftpServerAddr            = "127.0.0.1:8022"
-	configDir                 = ".."
-	httpsCert                 = `-----BEGIN CERTIFICATE-----
+	defaultUsername                 = "test_user"
+	defaultPassword                 = "test_password"
+	testPubKey                      = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC03jj0D+djk7pxIf/0OhrxrchJTRZklofJ1NoIu4752Sq02mdXmarMVsqJ1cAjV5LBVy3D1F5U6XW4rppkXeVtd04Pxb09ehtH0pRRPaoHHlALiJt8CoMpbKYMA8b3KXPPriGxgGomvtU2T2RMURSwOZbMtpsugfjYSWenyYX+VORYhylWnSXL961LTyC21ehd6d6QnW9G7E5hYMITMY9TuQZz3bROYzXiTsgN0+g6Hn7exFQp50p45StUMfV/SftCMdCxlxuyGny2CrN/vfjO7xxOo2uv7q1qm10Q46KPWJQv+pgZ/OfL+EDjy07n5QVSKHlbx+2nT4Q0EgOSQaCTYwn3YjtABfIxWwgAFdyj6YlPulCL22qU4MYhDcA6PSBwDdf8hvxBfvsiHdM+JcSHvv8/VeJhk6CmnZxGY0fxBupov27z3yEO8nAg8k+6PaUiW1MSUfuGMF/ktB8LOstXsEPXSszuyXiOv4DaryOXUiSn7bmRqKcEFlJusO6aZP0= nicola@p1"
+	testPubKey1                     = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCd60+/j+y8f0tLftihWV1YN9RSahMI9btQMDIMqts/jeNbD8jgoogM3nhF7KxfcaMKURuD47KC4Ey6iAJUJ0sWkSNNxOcIYuvA+5MlspfZDsa8Ag76Fe1vyz72WeHMHMeh/hwFo2TeIeIXg480T1VI6mzfDrVp2GzUx0SS0dMsQBjftXkuVR8YOiOwMCAH2a//M1OrvV7d/NBk6kBN0WnuIBb2jKm15PAA7+jQQG7tzwk2HedNH3jeL5GH31xkSRwlBczRK0xsCQXehAlx6cT/e/s44iJcJTHfpPKoSk6UAhPJYe7Z1QnuoawY9P9jQaxpyeImBZxxUEowhjpj2avBxKdRGBVK8R7EL8tSOeLbhdyWe5Mwc1+foEbq9Zz5j5Kd+hn3Wm1UnsGCrXUUUoZp1jnlNl0NakCto+5KmqnT9cHxaY+ix2RLUWAZyVFlRq71OYux1UHJnEJPiEI1/tr4jFBSL46qhQZv/TfpkfVW8FLz0lErfqu0gQEZnNHr3Fc= nicola@p1"
+	defaultTokenAuthUser            = "admin"
+	defaultTokenAuthPass            = "password"
+	altAdminUsername                = "newTestAdmin"
+	altAdminPassword                = "password1"
+	csrfFormToken                   = "_form_token"
+	tokenPath                       = "/api/v2/token"
+	userTokenPath                   = "/api/v2/user/token"
+	userLogoutPath                  = "/api/v2/user/logout"
+	userPath                        = "/api/v2/users"
+	adminPath                       = "/api/v2/admins"
+	adminPwdPath                    = "/api/v2/admin/changepwd"
+	folderPath                      = "/api/v2/folders"
+	activeConnectionsPath           = "/api/v2/connections"
+	serverStatusPath                = "/api/v2/status"
+	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"
+	userPwdPath                     = "/api/v2/user/changepwd"
+	userPublicKeysPath              = "/api/v2/user/publickeys"
+	userReadFolderPath              = "/api/v2/user/folder"
+	userGetFilePath                 = "/api/v2/user/file"
+	userStreamZipPath               = "/api/v2/user/streamzip"
+	healthzPath                     = "/healthz"
+	webBasePath                     = "/web"
+	webBasePathAdmin                = "/web/admin"
+	webAdminSetupPath               = "/web/admin/setup"
+	webLoginPath                    = "/web/admin/login"
+	webLogoutPath                   = "/web/admin/logout"
+	webUsersPath                    = "/web/admin/users"
+	webUserPath                     = "/web/admin/user"
+	webFoldersPath                  = "/web/admin/folders"
+	webFolderPath                   = "/web/admin/folder"
+	webConnectionsPath              = "/web/admin/connections"
+	webStatusPath                   = "/web/admin/status"
+	webAdminsPath                   = "/web/admin/managers"
+	webAdminPath                    = "/web/admin/manager"
+	webMaintenancePath              = "/web/admin/maintenance"
+	webRestorePath                  = "/web/admin/restore"
+	webChangeAdminPwdPath           = "/web/admin/changepwd"
+	webTemplateUser                 = "/web/admin/template/user"
+	webTemplateFolder               = "/web/admin/template/folder"
+	webBasePathClient               = "/web/client"
+	webClientLoginPath              = "/web/client/login"
+	webClientFilesPath              = "/web/client/files"
+	webClientDirContentsPath        = "/web/client/listdir"
+	webClientDownloadZipPath        = "/web/client/downloadzip"
+	webClientCredentialsPath        = "/web/client/credentials"
+	webChangeClientPwdPath          = "/web/client/changepwd"
+	webChangeClientKeysPath         = "/web/client/managekeys"
+	webClientLogoutPath             = "/web/client/logout"
+	httpBaseURL                     = "http://127.0.0.1:8081"
+	sftpServerAddr                  = "127.0.0.1:8022"
+	configDir                       = ".."
+	httpsCert                       = `-----BEGIN CERTIFICATE-----
 MIICHTCCAaKgAwIBAgIUHnqw7QnB1Bj9oUsNpdb+ZkFPOxMwCgYIKoZIzj0EAwIw
 MIICHTCCAaKgAwIBAgIUHnqw7QnB1Bj9oUsNpdb+ZkFPOxMwCgYIKoZIzj0EAwIw
 RTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGElu
 RTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGElu
 dGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMDAyMDQwOTUzMDRaFw0zMDAyMDEw
 dGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMDAyMDQwOTUzMDRaFw0zMDAyMDEw
@@ -3144,6 +3148,10 @@ func TestDefenderAPI(t *testing.T) {
 	require.True(t, ok)
 	require.True(t, ok)
 	assert.Nil(t, banTime)
 	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)
 	response, _, err = httpdtest.GetScore(ip, http.StatusOK)
 	require.NoError(t, err)
 	require.NoError(t, err)
 	score, ok := response["score"]
 	score, ok := response["score"]
@@ -3153,6 +3161,9 @@ func TestDefenderAPI(t *testing.T) {
 	err = httpdtest.UnbanIP(ip, http.StatusNotFound)
 	err = httpdtest.UnbanIP(ip, http.StatusNotFound)
 	require.NoError(t, err)
 	require.NoError(t, err)
 
 
+	_, err = httpdtest.RemoveDefenderHostByIP(ip, http.StatusNotFound)
+	require.NoError(t, err)
+
 	common.AddDefenderEvent(ip, common.HostEventNoLoginTried)
 	common.AddDefenderEvent(ip, common.HostEventNoLoginTried)
 	response, _, err = httpdtest.GetScore(ip, http.StatusOK)
 	response, _, err = httpdtest.GetScore(ip, http.StatusOK)
 	require.NoError(t, err)
 	require.NoError(t, err)
@@ -3160,12 +3171,37 @@ func TestDefenderAPI(t *testing.T) {
 	require.True(t, ok)
 	require.True(t, ok)
 	assert.Equal(t, float64(2), score)
 	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)
 	common.AddDefenderEvent(ip, common.HostEventNoLoginTried)
 	response, _, err = httpdtest.GetBanTime(ip, http.StatusOK)
 	response, _, err = httpdtest.GetBanTime(ip, http.StatusOK)
 	require.NoError(t, err)
 	require.NoError(t, err)
 	banTime, ok = response["date_time"]
 	banTime, ok = response["date_time"]
 	require.True(t, ok)
 	require.True(t, ok)
 	assert.NotNil(t, banTime)
 	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)
 	err = httpdtest.UnbanIP(ip, http.StatusOK)
 	require.NoError(t, err)
 	require.NoError(t, err)
@@ -3173,6 +3209,28 @@ func TestDefenderAPI(t *testing.T) {
 	err = httpdtest.UnbanIP(ip, http.StatusNotFound)
 	err = httpdtest.UnbanIP(ip, http.StatusNotFound)
 	require.NoError(t, err)
 	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)
 	err = common.Initialize(oldConfig)
 	require.NoError(t, err)
 	require.NoError(t, err)
 }
 }
@@ -3899,7 +3957,11 @@ func TestUpdateUserQuotaUsageMock(t *testing.T) {
 	checkResponseCode(t, http.StatusCreated, rr)
 	checkResponseCode(t, http.StatusCreated, rr)
 	err = render.DecodeJSON(rr.Body, &user)
 	err = render.DecodeJSON(rr.Body, &user)
 	assert.NoError(t, err)
 	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)
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
 	checkResponseCode(t, http.StatusOK, rr)
@@ -3914,7 +3976,7 @@ func TestUpdateUserQuotaUsageMock(t *testing.T) {
 	// now update only quota size
 	// now update only quota size
 	u.UsedQuotaFiles = 0
 	u.UsedQuotaFiles = 0
 	userAsJSON = getUserAsJSON(t, u)
 	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)
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
 	checkResponseCode(t, http.StatusOK, rr)
@@ -3930,7 +3992,7 @@ func TestUpdateUserQuotaUsageMock(t *testing.T) {
 	u.UsedQuotaFiles = usedQuotaFiles
 	u.UsedQuotaFiles = usedQuotaFiles
 	u.UsedQuotaSize = 0
 	u.UsedQuotaSize = 0
 	userAsJSON = getUserAsJSON(t, u)
 	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)
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
 	checkResponseCode(t, http.StatusOK, rr)
@@ -3942,12 +4004,16 @@ func TestUpdateUserQuotaUsageMock(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	assert.Equal(t, usedQuotaFiles*2, user.UsedQuotaFiles)
 	assert.Equal(t, usedQuotaFiles*2, user.UsedQuotaFiles)
 	assert.Equal(t, usedQuotaSize*2, user.UsedQuotaSize)
 	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)
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusBadRequest, rr)
 	checkResponseCode(t, http.StatusBadRequest, rr)
 	assert.True(t, common.QuotaScans.AddUserQuotaScan(user.Username))
 	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)
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusConflict, rr)
 	checkResponseCode(t, http.StatusConflict, rr)
@@ -4198,7 +4264,7 @@ func TestDeleteUserInvalidParamsMock(t *testing.T) {
 func TestGetQuotaScansMock(t *testing.T) {
 func TestGetQuotaScansMock(t *testing.T) {
 	token, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
 	token, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
-	req, err := http.NewRequest("GET", quotaScanPath, nil)
+	req, err := http.NewRequest(http.MethodGet, quotaScanPath, nil)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	setBearerForReq(req, token)
 	setBearerForReq(req, token)
 	rr := executeRequest(req)
 	rr := executeRequest(req)
@@ -4222,61 +4288,47 @@ func TestStartQuotaScanMock(t *testing.T) {
 		assert.NoError(t, err)
 		assert.NoError(t, err)
 	}
 	}
 	// simulate a duplicate quota scan
 	// simulate a duplicate quota scan
-	userAsJSON = getUserAsJSON(t, user)
 	common.QuotaScans.AddUserQuotaScan(user.Username)
 	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)
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusConflict, rr)
 	checkResponseCode(t, http.StatusConflict, rr)
 	assert.True(t, common.QuotaScans.RemoveUserQuotaScan(user.Username))
 	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)
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusAccepted, rr)
 	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)
 	_, err = os.Stat(user.HomeDir)
 	if err != nil && os.IsNotExist(err) {
 	if err != nil && os.IsNotExist(err) {
 		err = os.MkdirAll(user.HomeDir, os.ModePerm)
 		err = os.MkdirAll(user.HomeDir, os.ModePerm)
 		assert.NoError(t, err)
 		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)
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusAccepted, rr)
 	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) {
-			assert.Fail(t, err.Error(), "Error getting active scans")
-			break
-		}
-		if len(scans) == 0 {
-			break
-		}
-		time.Sleep(100 * time.Millisecond)
-	}
+	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.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)
 	req, _ = http.NewRequest(http.MethodDelete, path.Join(userPath, user.Username), nil)
 	setBearerForReq(req, token)
 	setBearerForReq(req, token)
@@ -4308,7 +4360,11 @@ func TestUpdateFolderQuotaUsageMock(t *testing.T) {
 	checkResponseCode(t, http.StatusCreated, rr)
 	checkResponseCode(t, http.StatusCreated, rr)
 	err = render.DecodeJSON(rr.Body, &folder)
 	err = render.DecodeJSON(rr.Body, &folder)
 	assert.NoError(t, err)
 	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)
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
 	checkResponseCode(t, http.StatusOK, rr)
@@ -4325,7 +4381,8 @@ func TestUpdateFolderQuotaUsageMock(t *testing.T) {
 	f.UsedQuotaFiles = 0
 	f.UsedQuotaFiles = 0
 	folderAsJSON, err = json.Marshal(f)
 	folderAsJSON, err = json.Marshal(f)
 	assert.NoError(t, err)
 	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)
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
 	checkResponseCode(t, http.StatusOK, rr)
@@ -4343,7 +4400,8 @@ func TestUpdateFolderQuotaUsageMock(t *testing.T) {
 	f.UsedQuotaFiles = 1
 	f.UsedQuotaFiles = 1
 	folderAsJSON, err = json.Marshal(f)
 	folderAsJSON, err = json.Marshal(f)
 	assert.NoError(t, err)
 	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)
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusOK, rr)
 	checkResponseCode(t, http.StatusOK, rr)
@@ -4356,13 +4414,19 @@ func TestUpdateFolderQuotaUsageMock(t *testing.T) {
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	assert.Equal(t, usedQuotaFiles*2, folderGet.UsedQuotaFiles)
 	assert.Equal(t, usedQuotaFiles*2, folderGet.UsedQuotaFiles)
 	assert.Equal(t, usedQuotaSize*2, folderGet.UsedQuotaSize)
 	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)
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusBadRequest, rr)
 	checkResponseCode(t, http.StatusBadRequest, rr)
 
 
 	assert.True(t, common.QuotaScans.AddVFolderQuotaScan(folderName))
 	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)
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusConflict, rr)
 	checkResponseCode(t, http.StatusConflict, rr)
@@ -4396,7 +4460,7 @@ func TestStartFolderQuotaScanMock(t *testing.T) {
 	}
 	}
 	// simulate a duplicate quota scan
 	// simulate a duplicate quota scan
 	common.QuotaScans.AddVFolderQuotaScan(folderName)
 	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)
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusConflict, rr)
 	checkResponseCode(t, http.StatusConflict, rr)
@@ -4407,25 +4471,20 @@ func TestStartFolderQuotaScanMock(t *testing.T) {
 		err = os.MkdirAll(mappedPath, os.ModePerm)
 		err = os.MkdirAll(mappedPath, os.ModePerm)
 		assert.NoError(t, err)
 		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)
 	setBearerForReq(req, token)
 	rr = executeRequest(req)
 	rr = executeRequest(req)
 	checkResponseCode(t, http.StatusAccepted, rr)
 	checkResponseCode(t, http.StatusAccepted, rr)
-	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)
-	}
+	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.StatusAccepted, rr)
+	waitForFoldersQuotaScanPath(t, token)
+
 	// cleanup
 	// cleanup
 
 
 	req, _ = http.NewRequest(http.MethodDelete, path.Join(folderPath, folderName), nil)
 	req, _ = http.NewRequest(http.MethodDelete, path.Join(folderPath, folderName), nil)
@@ -4442,8 +4501,8 @@ func TestStartQuotaScanNonExistentUserMock(t *testing.T) {
 	token, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
 	token, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	user := getTestUser()
 	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)
 	setBearerForReq(req, token)
 	rr := executeRequest(req)
 	rr := executeRequest(req)
 	checkResponseCode(t, http.StatusNotFound, rr)
 	checkResponseCode(t, http.StatusNotFound, rr)
@@ -4452,7 +4511,7 @@ func TestStartQuotaScanNonExistentUserMock(t *testing.T) {
 func TestStartQuotaScanBadUserMock(t *testing.T) {
 func TestStartQuotaScanBadUserMock(t *testing.T) {
 	token, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
 	token, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
 	assert.NoError(t, err)
 	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)
 	setBearerForReq(req, token)
 	rr := executeRequest(req)
 	rr := executeRequest(req)
 	checkResponseCode(t, http.StatusBadRequest, rr)
 	checkResponseCode(t, http.StatusBadRequest, rr)
@@ -4461,7 +4520,7 @@ func TestStartQuotaScanBadUserMock(t *testing.T) {
 func TestStartQuotaScanBadFolderMock(t *testing.T) {
 func TestStartQuotaScanBadFolderMock(t *testing.T) {
 	token, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
 	token, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
 	assert.NoError(t, err)
 	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)
 	setBearerForReq(req, token)
 	rr := executeRequest(req)
 	rr := executeRequest(req)
 	checkResponseCode(t, http.StatusBadRequest, rr)
 	checkResponseCode(t, http.StatusBadRequest, rr)
@@ -4474,9 +4533,7 @@ func TestStartQuotaScanNonExistentFolderMock(t *testing.T) {
 		MappedPath: os.TempDir(),
 		MappedPath: os.TempDir(),
 		Name:       "afolder",
 		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)
 	setBearerForReq(req, token)
 	rr := executeRequest(req)
 	rr := executeRequest(req)
 	checkResponseCode(t, http.StatusNotFound, rr)
 	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")
 	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) {
 func TestTokenHeaderCookie(t *testing.T) {
 	apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
 	apiToken, err := getJWTAPITokenFromTestServer(defaultTokenAuthUser, defaultTokenAuthPass)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
@@ -8473,6 +8540,43 @@ func TestStaticFilesMock(t *testing.T) {
 	checkResponseCode(t, http.StatusOK, rr)
 	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) {
 func waitTCPListening(address string) {
 	for {
 	for {
 		conn, err := net.Dial("tcp", address)
 		conn, err := net.Dial("tcp", address)

+ 1 - 1
httpd/internal_test.go

@@ -869,7 +869,7 @@ func TestQuotaScanInvalidFs(t *testing.T) {
 		},
 		},
 	}
 	}
 	common.QuotaScans.AddUserQuotaScan(user.Username)
 	common.QuotaScans.AddUserQuotaScan(user.Username)
-	err := doQuotaScan(user)
+	err := doUserQuotaScan(user)
 	assert.Error(t, err)
 	assert.Error(t, err)
 }
 }
 
 

+ 400 - 24
httpd/schema/openapi.yaml

@@ -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.
     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.
     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.
     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:
   contact:
     name: API support
     name: API support
     url: 'https://github.com/drakkan/sftpgo'
     url: 'https://github.com/drakkan/sftpgo'
@@ -166,8 +166,8 @@ paths:
       tags:
       tags:
         - admins
         - admins
       summary: Change admin password
       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
       deprecated: true
       requestBody:
       requestBody:
         required: true
         required: true
@@ -275,12 +275,93 @@ paths:
           $ref: '#/components/responses/InternalServerError'
           $ref: '#/components/responses/InternalServerError'
         default:
         default:
           $ref: '#/components/responses/DefaultResponse'
           $ref: '#/components/responses/DefaultResponse'
+  /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:
   /defender/bantime:
     get:
     get:
+      deprecated: true
       tags:
       tags:
         - defender
         - defender
       summary: Get ban time
       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
       operationId: get_ban_time
       parameters:
       parameters:
         - in: query
         - in: query
@@ -308,10 +389,11 @@ paths:
           $ref: '#/components/responses/DefaultResponse'
           $ref: '#/components/responses/DefaultResponse'
   /defender/unban:
   /defender/unban:
     post:
     post:
+      deprecated: true
       tags:
       tags:
         - defender
         - defender
       summary: Unban
       summary: Unban
-      description: Removes the specified IPv4/IPv6 from the banned ones
+      description: Deprecated, please use '/defender/hosts/{id}' instead
       operationId: unban_host
       operationId: unban_host
       requestBody:
       requestBody:
         required: true
         required: true
@@ -344,10 +426,11 @@ paths:
           $ref: '#/components/responses/DefaultResponse'
           $ref: '#/components/responses/DefaultResponse'
   /defender/score:
   /defender/score:
     get:
     get:
+      deprecated: true
       tags:
       tags:
         - defender
         - defender
       summary: Get score
       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
       operationId: get_score
       parameters:
       parameters:
         - in: query
         - in: query
@@ -373,13 +456,282 @@ paths:
           $ref: '#/components/responses/InternalServerError'
           $ref: '#/components/responses/InternalServerError'
         default:
         default:
           $ref: '#/components/responses/DefaultResponse'
           $ref: '#/components/responses/DefaultResponse'
+  /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:
   /quota-scans:
     get:
     get:
+      deprecated: true
       tags:
       tags:
         - quota
         - quota
       summary: Get quota scans
       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:
       responses:
         '200':
         '200':
           description: successful operation
           description: successful operation
@@ -398,11 +750,12 @@ paths:
         default:
         default:
           $ref: '#/components/responses/DefaultResponse'
           $ref: '#/components/responses/DefaultResponse'
     post:
     post:
+      deprecated: true
       tags:
       tags:
         - quota
         - quota
       summary: Start user quota scan
       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:
       requestBody:
         required: true
         required: true
         content:
         content:
@@ -434,11 +787,12 @@ paths:
           $ref: '#/components/responses/DefaultResponse'
           $ref: '#/components/responses/DefaultResponse'
   /quota-update:
   /quota-update:
     put:
     put:
+      deprecated: true
       tags:
       tags:
         - quota
         - 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:
       parameters:
         - in: query
         - in: query
           name: mode
           name: mode
@@ -478,19 +832,18 @@ paths:
           $ref: '#/components/responses/Forbidden'
           $ref: '#/components/responses/Forbidden'
         '404':
         '404':
           $ref: '#/components/responses/NotFound'
           $ref: '#/components/responses/NotFound'
-        '409':
-          $ref: '#/components/responses/Conflict'
         '500':
         '500':
           $ref: '#/components/responses/InternalServerError'
           $ref: '#/components/responses/InternalServerError'
         default:
         default:
           $ref: '#/components/responses/DefaultResponse'
           $ref: '#/components/responses/DefaultResponse'
   /folder-quota-update:
   /folder-quota-update:
     put:
     put:
+      deprecated: true
       tags:
       tags:
         - quota
         - quota
       summary: Update folder quota limits
       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:
       parameters:
         - in: query
         - in: query
           name: mode
           name: mode
@@ -530,19 +883,18 @@ paths:
           $ref: '#/components/responses/Forbidden'
           $ref: '#/components/responses/Forbidden'
         '404':
         '404':
           $ref: '#/components/responses/NotFound'
           $ref: '#/components/responses/NotFound'
-        '409':
-          $ref: '#/components/responses/Conflict'
         '500':
         '500':
           $ref: '#/components/responses/InternalServerError'
           $ref: '#/components/responses/InternalServerError'
         default:
         default:
           $ref: '#/components/responses/DefaultResponse'
           $ref: '#/components/responses/DefaultResponse'
   /folder-quota-scans:
   /folder-quota-scans:
     get:
     get:
+      deprecated: true
       tags:
       tags:
         - quota
         - quota
       summary: Get folders quota scans
       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:
       responses:
         '200':
         '200':
           description: successful operation
           description: successful operation
@@ -561,11 +913,12 @@ paths:
         default:
         default:
           $ref: '#/components/responses/DefaultResponse'
           $ref: '#/components/responses/DefaultResponse'
     post:
     post:
+      deprecated: true
       tags:
       tags:
         - quota
         - 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:
       requestBody:
         required: true
         required: true
         content:
         content:
@@ -2142,6 +2495,15 @@ components:
         additional_info:
         additional_info:
           type: string
           type: string
           description: Free form text field
           description: Free form text field
+    QuotaUsage:
+      type: object
+      properties:
+        used_quota_size:
+          type: integer
+          format: int64
+        used_quota_files:
+          type: integer
+          format: int32
     Transfer:
     Transfer:
       type: object
       type: object
       properties:
       properties:
@@ -2223,6 +2585,20 @@ components:
           type: integer
           type: integer
           format: int64
           format: int64
           description: scan start time as unix timestamp in milliseconds
           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:
     SSHHostKey:
       type: object
       type: object
       properties:
       properties:

+ 17 - 8
httpd/server.go

@@ -580,10 +580,14 @@ func (s *httpdServer) initializeRouter() {
 
 
 		router.With(checkPerm(dataprovider.PermAdminCloseConnections)).
 		router.With(checkPerm(dataprovider.PermAdminCloseConnections)).
 			Delete(activeConnectionsPath+"/{connectionID}", handleCloseConnection)
 			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.PermAdminViewUsers)).Get(userPath, getUsers)
 		router.With(checkPerm(dataprovider.PermAdminAddUsers)).Post(userPath, addUser)
 		router.With(checkPerm(dataprovider.PermAdminAddUsers)).Post(userPath, addUser)
 		router.With(checkPerm(dataprovider.PermAdminViewUsers)).Get(userPath+"/{username}", getUserByUsername)
 		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(dumpDataPath, dumpData)
 		router.With(checkPerm(dataprovider.PermAdminManageSystem)).Get(loadDataPath, loadData)
 		router.With(checkPerm(dataprovider.PermAdminManageSystem)).Get(loadDataPath, loadData)
 		router.With(checkPerm(dataprovider.PermAdminManageSystem)).Post(loadDataPath, loadDataFromRequest)
 		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(defenderBanTime, getBanTime)
 		router.With(checkPerm(dataprovider.PermAdminViewDefender)).Get(defenderScore, getScore)
 		router.With(checkPerm(dataprovider.PermAdminViewDefender)).Get(defenderScore, getScore)
 		router.With(checkPerm(dataprovider.PermAdminManageDefender)).Post(defenderUnban, unban)
 		router.With(checkPerm(dataprovider.PermAdminManageDefender)).Post(defenderUnban, unban)
@@ -719,11 +728,11 @@ func (s *httpdServer) initializeRouter() {
 			router.With(checkPerm(dataprovider.PermAdminDeleteUsers), verifyCSRFHeader).
 			router.With(checkPerm(dataprovider.PermAdminDeleteUsers), verifyCSRFHeader).
 				Delete(webFolderPath+"/{name}", deleteFolder)
 				Delete(webFolderPath+"/{name}", deleteFolder)
 			router.With(checkPerm(dataprovider.PermAdminQuotaScans), verifyCSRFHeader).
 			router.With(checkPerm(dataprovider.PermAdminQuotaScans), verifyCSRFHeader).
-				Post(webScanVFolderPath, startVFolderQuotaScan)
+				Post(webScanVFolderPath+"/{name}", startFolderQuotaScan)
 			router.With(checkPerm(dataprovider.PermAdminDeleteUsers), verifyCSRFHeader).
 			router.With(checkPerm(dataprovider.PermAdminDeleteUsers), verifyCSRFHeader).
 				Delete(webUserPath+"/{username}", deleteUser)
 				Delete(webUserPath+"/{username}", deleteUser)
 			router.With(checkPerm(dataprovider.PermAdminQuotaScans), verifyCSRFHeader).
 			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(webMaintenancePath, handleWebMaintenance)
 			router.With(checkPerm(dataprovider.PermAdminManageSystem)).Get(webBackupPath, dumpData)
 			router.With(checkPerm(dataprovider.PermAdminManageSystem)).Get(webBackupPath, dumpData)
 			router.With(checkPerm(dataprovider.PermAdminManageSystem)).Post(webRestorePath, handleWebRestore)
 			router.With(checkPerm(dataprovider.PermAdminManageSystem)).Post(webRestorePath, handleWebRestore)

+ 79 - 25
httpdtest/httpdtest.go

@@ -3,6 +3,7 @@ package httpdtest
 
 
 import (
 import (
 	"bytes"
 	"bytes"
+	"encoding/hex"
 	"encoding/json"
 	"encoding/json"
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
@@ -26,23 +27,23 @@ import (
 )
 )
 
 
 const (
 const (
-	tokenPath                 = "/api/v2/token"
-	activeConnectionsPath     = "/api/v2/connections"
-	quotaScanPath             = "/api/v2/quota-scans"
-	quotaScanVFolderPath      = "/api/v2/folder-quota-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"
-	defenderBanTime           = "/api/v2/defender/bantime"
-	defenderUnban             = "/api/v2/defender/unban"
-	defenderScore             = "/api/v2/defender/score"
-	adminPath                 = "/api/v2/admins"
-	adminPwdPath              = "/api/v2/admin/changepwd"
+	tokenPath             = "/api/v2/token"
+	activeConnectionsPath = "/api/v2/connections"
+	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"
+	defenderHosts         = "/api/v2/defender/hosts"
+	defenderBanTime       = "/api/v2/defender/bantime"
+	defenderUnban         = "/api/v2/defender/unban"
+	defenderScore         = "/api/v2/defender/score"
+	adminPath             = "/api/v2/admins"
+	adminPwdPath          = "/api/v2/admin/changepwd"
 )
 )
 
 
 const (
 const (
@@ -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.
 // 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) {
 func StartQuotaScan(user dataprovider.User, expectedStatusCode int) ([]byte, error) {
 	var body []byte
 	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 {
 	if err != nil {
 		return body, err
 		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) {
 func UpdateQuotaUsage(user dataprovider.User, mode string, expectedStatusCode int) ([]byte, error) {
 	var body []byte
 	var body []byte
 	userAsJSON, _ := json.Marshal(user)
 	userAsJSON, _ := json.Marshal(user)
-	url, err := addModeQueryParam(buildURLRelativeToBase(updateUsedQuotaPath), mode)
+	url, err := addModeQueryParam(buildURLRelativeToBase(quotasBasePath, "users", user.Username, "usage"), mode)
 	if err != nil {
 	if err != nil {
 		return body, err
 		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.
 // 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) {
 func StartFolderQuotaScan(folder vfs.BaseVirtualFolder, expectedStatusCode int) ([]byte, error) {
 	var body []byte
 	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 {
 	if err != nil {
 		return body, err
 		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) {
 func UpdateFolderQuotaUsage(folder vfs.BaseVirtualFolder, mode string, expectedStatusCode int) ([]byte, error) {
 	var body []byte
 	var body []byte
 	folderAsJSON, _ := json.Marshal(folder)
 	folderAsJSON, _ := json.Marshal(folder)
-	url, err := addModeQueryParam(buildURLRelativeToBase(updateFolderUsedQuotaPath), mode)
+	url, err := addModeQueryParam(buildURLRelativeToBase(quotasBasePath, "folders", folder.Name, "usage"), mode)
 	if err != nil {
 	if err != nil {
 		return body, err
 		return body, err
 	}
 	}
@@ -648,6 +647,61 @@ func GetStatus(expectedStatusCode int) (httpd.ServicesStatus, []byte, error) {
 	return response, body, err
 	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
 // GetBanTime returns the ban time for the given IP address
 func GetBanTime(ip string, expectedStatusCode int) (map[string]interface{}, []byte, error) {
 func GetBanTime(ip string, expectedStatusCode int) (map[string]interface{}, []byte, error) {
 	var response map[string]interface{}
 	var response map[string]interface{}

+ 1 - 3
templates/webadmin/folders.html

@@ -179,13 +179,11 @@ function deleteAction() {
             action: function (e, dt, node, config) {
             action: function (e, dt, node, config) {
                 dt.button('quota_scan:name').enable(false);
                 dt.button('quota_scan:name').enable(false);
                 var folderName = dt.row({ selected: true }).data()[0];
                 var folderName = dt.row({ selected: true }).data()[0];
-                var path = '{{.FolderQuotaScanURL}}'
+                var path = '{{.FolderQuotaScanURL}}'+ "/" + fixedEncodeURIComponent(folderName);
                 $.ajax({
                 $.ajax({
                     url: path,
                     url: path,
                     type: 'POST',
                     type: 'POST',
-                    dataType: 'json',
                     headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
                     headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
-                    data: JSON.stringify({ "name": folderName }),
                     timeout: 15000,
                     timeout: 15000,
                     success: function (result) {
                     success: function (result) {
                         dt.button('quota_scan:name').enable(true);
                         dt.button('quota_scan:name').enable(true);

+ 1 - 3
templates/webadmin/users.html

@@ -199,13 +199,11 @@
             action: function (e, dt, node, config) {
             action: function (e, dt, node, config) {
                 dt.button('quota_scan:name').enable(false);
                 dt.button('quota_scan:name').enable(false);
                 var username = dt.row({ selected: true }).data()[1];
                 var username = dt.row({ selected: true }).data()[1];
-                var path = '{{.QuotaScanURL}}'
+                var path = '{{.QuotaScanURL}}'+ "/" + fixedEncodeURIComponent(username);
                 $.ajax({
                 $.ajax({
                     url: path,
                     url: path,
                     type: 'POST',
                     type: 'POST',
-                    dataType: 'json',
                     headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
                     headers: {'X-CSRF-TOKEN' : '{{.CSRFToken}}'},
-                    data: JSON.stringify({ "username": username }),
                     timeout: 15000,
                     timeout: 15000,
                     success: function (result) {
                     success: function (result) {
                         dt.button('quota_scan:name').enable(true);
                         dt.button('quota_scan:name').enable(true);