feat(storage): support deleting backends and improve admin UI
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m41s
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m41s
- Implement storage backend deletion, which automatically resets default storage settings and user-specific overrides when a backend is removed. - Add unit tests covering the delete action and its cleanup side effects. - Improve admin UI responsiveness, fixing table scrolling, flex wrapping, and text truncation for long storage backend names. - Update security documentation to clarify trusted proxy configurations and explain how trusted proxies are protected from automatic bans.
This commit is contained in:
@@ -574,6 +574,38 @@ func (s *AuthService) SetUserStorageBackend(userID, backendID string) error {
|
||||
return s.saveUser(user)
|
||||
}
|
||||
|
||||
func (s *AuthService) ClearStorageBackendOverrides(backendID string) (int, error) {
|
||||
backendID = strings.TrimSpace(backendID)
|
||||
if backendID == "" {
|
||||
return 0, nil
|
||||
}
|
||||
cleared := 0
|
||||
err := s.db.Update(func(tx *bbolt.Tx) error {
|
||||
users := tx.Bucket(usersBucket)
|
||||
return users.ForEach(func(key, value []byte) error {
|
||||
var user User
|
||||
if err := json.Unmarshal(value, &user); err != nil {
|
||||
return err
|
||||
}
|
||||
if user.Policy.StorageBackendID == nil || *user.Policy.StorageBackendID != backendID {
|
||||
return nil
|
||||
}
|
||||
user.Policy.StorageBackendID = nil
|
||||
user.UpdatedAt = time.Now().UTC()
|
||||
next, err := json.Marshal(user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := users.Put(key, next); err != nil {
|
||||
return err
|
||||
}
|
||||
cleared++
|
||||
return nil
|
||||
})
|
||||
})
|
||||
return cleared, err
|
||||
}
|
||||
|
||||
func (s *AuthService) UpdateUserAdminFields(userID, username, email, role, status string, policy UserPolicy) (User, error) {
|
||||
if err := validateUserPolicy(policy); err != nil {
|
||||
return User{}, err
|
||||
|
||||
@@ -21,19 +21,19 @@ func ClientIPFromContext(r *http.Request) (string, bool) {
|
||||
// ClientIP resolves the effective client IP. When trustedProxies is empty,
|
||||
// forwarded headers are trusted for easy reverse-proxy/container defaults.
|
||||
func ClientIP(remoteAddr, forwardedFor, realIP string, trustedProxies []string) string {
|
||||
remoteIP := remoteIPOnly(remoteAddr)
|
||||
remoteIP := IPOnly(remoteAddr)
|
||||
if len(trustedProxies) == 0 || remoteTrusted(remoteIP, trustedProxies) {
|
||||
if ip := firstForwardedIP(forwardedFor); ip != "" {
|
||||
return ip
|
||||
return IPOnly(ip)
|
||||
}
|
||||
if ip := strings.TrimSpace(realIP); ip != "" {
|
||||
return ip
|
||||
return IPOnly(ip)
|
||||
}
|
||||
}
|
||||
return remoteIP
|
||||
}
|
||||
|
||||
func remoteIPOnly(remoteAddr string) string {
|
||||
func IPOnly(remoteAddr string) string {
|
||||
host := strings.TrimSpace(remoteAddr)
|
||||
if splitHost, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||
host = splitHost
|
||||
@@ -41,14 +41,65 @@ func remoteIPOnly(remoteAddr string) string {
|
||||
return strings.Trim(host, "[]")
|
||||
}
|
||||
|
||||
func firstForwardedIP(forwardedFor string) string {
|
||||
for _, part := range strings.Split(forwardedFor, ",") {
|
||||
ip := strings.TrimSpace(part)
|
||||
if ip != "" {
|
||||
return strings.Trim(ip, "[]")
|
||||
func IsProtectedProxyIP(ip string, trustedProxies []string) bool {
|
||||
parsed := net.ParseIP(IPOnly(ip))
|
||||
if parsed == nil {
|
||||
return false
|
||||
}
|
||||
if parsed.IsLoopback() {
|
||||
return true
|
||||
}
|
||||
return remoteTrusted(parsed.String(), trustedProxies)
|
||||
}
|
||||
|
||||
func ProtectedBanTarget(target string, trustedProxies []string) bool {
|
||||
normalized, err := NormalizeBanTarget(target)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if !strings.Contains(normalized, "/") {
|
||||
return IsProtectedProxyIP(normalized, trustedProxies)
|
||||
}
|
||||
_, targetNet, err := net.ParseCIDR(normalized)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if targetNet.Contains(net.ParseIP("127.0.0.1")) || targetNet.Contains(net.ParseIP("::1")) {
|
||||
return true
|
||||
}
|
||||
for _, trusted := range trustedProxies {
|
||||
trusted = strings.TrimSpace(trusted)
|
||||
if trusted == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(trusted, "/") {
|
||||
if _, trustedNet, err := net.ParseCIDR(trusted); err == nil && networksOverlap(targetNet, trustedNet) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ip := net.ParseIP(IPOnly(trusted)); ip != nil && targetNet.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return false
|
||||
}
|
||||
|
||||
func firstForwardedIP(forwardedFor string) string {
|
||||
var fallback string
|
||||
for _, part := range strings.Split(forwardedFor, ",") {
|
||||
ip := IPOnly(part)
|
||||
if net.ParseIP(ip) == nil {
|
||||
continue
|
||||
}
|
||||
if fallback == "" {
|
||||
fallback = ip
|
||||
}
|
||||
if isExternalIP(ip) {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func remoteTrusted(remoteIP string, trustedProxies []string) bool {
|
||||
@@ -73,3 +124,17 @@ func remoteTrusted(remoteIP string, trustedProxies []string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isExternalIP(ip string) bool {
|
||||
parsed := net.ParseIP(IPOnly(ip))
|
||||
return parsed != nil &&
|
||||
!parsed.IsLoopback() &&
|
||||
!parsed.IsPrivate() &&
|
||||
!parsed.IsLinkLocalUnicast() &&
|
||||
!parsed.IsLinkLocalMulticast() &&
|
||||
!parsed.IsUnspecified()
|
||||
}
|
||||
|
||||
func networksOverlap(a, b *net.IPNet) bool {
|
||||
return a.Contains(b.IP) || b.Contains(a.IP)
|
||||
}
|
||||
|
||||
@@ -27,3 +27,48 @@ func TestClientIPFallsBackToRealIP(t *testing.T) {
|
||||
t.Fatalf("ClientIP = %q, want real IP", ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPStripsPortsFromForwardedHeaders(t *testing.T) {
|
||||
ip := ClientIP("127.0.0.1:6070", "203.0.113.15:49152", "", nil)
|
||||
if ip != "203.0.113.15" {
|
||||
t.Fatalf("ClientIP = %q, want forwarded IP without port", ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPPrefersExternalForwardedAddress(t *testing.T) {
|
||||
ip := ClientIP("127.0.0.1:6070", "172.30.0.1, 198.51.100.30", "", nil)
|
||||
if ip != "198.51.100.30" {
|
||||
t.Fatalf("ClientIP = %q, want public forwarded IP", ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPOnlyHandlesIPv6HostPort(t *testing.T) {
|
||||
ip := IPOnly("[2001:db8::1]:6070")
|
||||
if ip != "2001:db8::1" {
|
||||
t.Fatalf("IPOnly = %q, want IPv6 address without port", ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectedProxyIP(t *testing.T) {
|
||||
trusted := []string{"127.0.0.1", "172.30.0.1", "10.88.0.0/16"}
|
||||
for _, ip := range []string{"127.0.0.1:48122", "172.30.0.1", "10.88.0.12"} {
|
||||
if !IsProtectedProxyIP(ip, trusted) {
|
||||
t.Fatalf("IsProtectedProxyIP(%q) = false, want true", ip)
|
||||
}
|
||||
}
|
||||
if IsProtectedProxyIP("203.0.113.50", trusted) {
|
||||
t.Fatalf("external IP treated as protected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectedBanTarget(t *testing.T) {
|
||||
trusted := []string{"172.30.0.1", "10.88.0.0/16"}
|
||||
for _, target := range []string{"127.0.0.1", "172.30.0.1", "172.30.0.0/24", "10.88.12.0/24"} {
|
||||
if !ProtectedBanTarget(target, trusted) {
|
||||
t.Fatalf("ProtectedBanTarget(%q) = false, want true", target)
|
||||
}
|
||||
}
|
||||
if ProtectedBanTarget("203.0.113.0/24", trusted) {
|
||||
t.Fatalf("external target treated as protected")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +233,29 @@ func (s *SettingsService) UpdateUploadPolicy(settings UploadPolicySettings) erro
|
||||
})
|
||||
}
|
||||
|
||||
func (s *SettingsService) ResetStorageBackend(backendID string) (bool, bool, error) {
|
||||
backendID = strings.TrimSpace(backendID)
|
||||
if backendID == "" || backendID == StorageBackendLocal {
|
||||
return false, false, nil
|
||||
}
|
||||
settings, err := s.UploadPolicy()
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
resetAnonymous := settings.AnonymousStorageBackend == backendID
|
||||
resetUser := settings.UserStorageBackend == backendID
|
||||
if !resetAnonymous && !resetUser {
|
||||
return false, false, nil
|
||||
}
|
||||
if resetAnonymous {
|
||||
settings.AnonymousStorageBackend = StorageBackendLocal
|
||||
}
|
||||
if resetUser {
|
||||
settings.UserStorageBackend = StorageBackendLocal
|
||||
}
|
||||
return resetAnonymous, resetUser, s.UpdateUploadPolicy(settings)
|
||||
}
|
||||
|
||||
func (s *SettingsService) Usage(subjectType, subject string, now time.Time) (UsageRecord, error) {
|
||||
key := usageKey(subjectType, subject, now)
|
||||
var record UsageRecord
|
||||
|
||||
@@ -86,6 +86,7 @@ type StorageBackendView struct {
|
||||
UsageBytes int64
|
||||
UsageLabel string
|
||||
InUse bool
|
||||
InUseReason string
|
||||
SpeedTests []StorageSpeedTest
|
||||
CanSpeedTest bool
|
||||
}
|
||||
@@ -132,6 +133,14 @@ func (s *StorageService) Backend(id string) (StorageBackend, error) {
|
||||
return s.backendFromConfig(cfg)
|
||||
}
|
||||
|
||||
func (s *StorageService) BackendForMaintenance(id string) (StorageBackend, error) {
|
||||
cfg, err := s.BackendConfig(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.backendFromConfig(cfg)
|
||||
}
|
||||
|
||||
func (s *StorageService) BackendConfig(id string) (StorageBackendConfig, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || id == StorageBackendLocal {
|
||||
@@ -340,21 +349,6 @@ func (s *StorageService) SaveBackendConfig(cfg StorageBackendConfig) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *StorageService) DisableBackend(id string, inUse bool) error {
|
||||
if id == "" || id == StorageBackendLocal {
|
||||
return fmt.Errorf("local storage cannot be disabled")
|
||||
}
|
||||
if inUse {
|
||||
return fmt.Errorf("storage backend is in use")
|
||||
}
|
||||
cfg, err := s.BackendConfig(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Enabled = false
|
||||
return s.SaveBackendConfig(cfg)
|
||||
}
|
||||
|
||||
func (s *StorageService) DeleteBackend(id string, inUse bool) error {
|
||||
if id == "" || id == StorageBackendLocal {
|
||||
return fmt.Errorf("local storage cannot be deleted")
|
||||
|
||||
@@ -553,6 +553,28 @@ func (s *UploadService) DeleteBox(boxID string) error {
|
||||
return s.DeleteBoxWithSource(boxID, "admin")
|
||||
}
|
||||
|
||||
func (s *UploadService) DeleteBoxesForStorageBackend(backendID, source string) (int, error) {
|
||||
backendID = normalizeBackendID(backendID)
|
||||
if backendID == StorageBackendLocal {
|
||||
return 0, fmt.Errorf("local storage cannot be deleted")
|
||||
}
|
||||
boxes, err := s.ListBoxes(0)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
deleted := 0
|
||||
for _, box := range boxes {
|
||||
if s.BoxStorageBackendID(box) != backendID {
|
||||
continue
|
||||
}
|
||||
if err := s.DeleteBoxWithSource(box.ID, source); err != nil {
|
||||
return deleted, err
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func (s *UploadService) DeleteBoxWithToken(boxID, token string) error {
|
||||
box, err := s.GetBox(boxID)
|
||||
if err != nil {
|
||||
@@ -572,7 +594,12 @@ func (s *UploadService) DeleteBoxWithSource(boxID, source string) error {
|
||||
return err
|
||||
}
|
||||
if box.ID != "" {
|
||||
if backend, err := s.storage.Backend(s.BoxStorageBackendID(box)); err == nil {
|
||||
backendID := s.BoxStorageBackendID(box)
|
||||
backend, err := s.storage.Backend(backendID)
|
||||
if err != nil {
|
||||
backend, err = s.storage.BackendForMaintenance(backendID)
|
||||
}
|
||||
if err == nil {
|
||||
if err := backend.DeletePrefix(context.Background(), box.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user