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:
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -805,6 +806,101 @@ func TestAdminStorageJobRoutesRequireAdminAndCSRF(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminStorageDeleteAction(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
adminToken := createAdminSession(t, app)
|
||||
cfg, err := app.uploadService.Storage().CreateBackend(services.StorageBackendConfig{
|
||||
Provider: services.StorageProviderWebDAV,
|
||||
Name: "DAV",
|
||||
Endpoint: "https://dav.example.test",
|
||||
Username: "warpbox",
|
||||
Password: "secret",
|
||||
RemotePath: "/warpbox",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBackend returned error: %v", err)
|
||||
}
|
||||
|
||||
deleteRequest := httptest.NewRequest(http.MethodPost, "/admin/storage/"+cfg.ID+"/delete", strings.NewReader("csrf_token=test-csrf"))
|
||||
deleteRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
deleteRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
deleteRequest.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
|
||||
deleteRequest.SetPathValue("backendID", cfg.ID)
|
||||
deleteResponse := httptest.NewRecorder()
|
||||
app.AdminDeleteStorage(deleteResponse, deleteRequest)
|
||||
if deleteResponse.Code != http.StatusSeeOther {
|
||||
t.Fatalf("AdminDeleteStorage status = %d, body = %s", deleteResponse.Code, deleteResponse.Body.String())
|
||||
}
|
||||
if _, err := app.uploadService.Storage().BackendConfig(cfg.ID); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("BackendConfig after delete = %v, want not exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminStorageDeleteResetsDefaultsAndUserOverrides(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
adminToken := createAdminSession(t, app)
|
||||
user, err := app.authService.UserByEmail("admin@example.test")
|
||||
if err != nil {
|
||||
t.Fatalf("UserByEmail returned error: %v", err)
|
||||
}
|
||||
cfg, err := app.uploadService.Storage().CreateBackend(services.StorageBackendConfig{
|
||||
Provider: services.StorageProviderWebDAV,
|
||||
Name: "DAV",
|
||||
Endpoint: "https://dav.example.test",
|
||||
Username: "warpbox",
|
||||
Password: "secret",
|
||||
RemotePath: "/warpbox",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBackend returned error: %v", err)
|
||||
}
|
||||
settings, err := app.settingsService.UploadPolicy()
|
||||
if err != nil {
|
||||
t.Fatalf("UploadPolicy returned error: %v", err)
|
||||
}
|
||||
settings.UserStorageBackend = cfg.ID
|
||||
if err := app.settingsService.UpdateUploadPolicy(settings); err != nil {
|
||||
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
|
||||
}
|
||||
if err := app.authService.SetUserStorageBackend(user.ID, cfg.ID); err != nil {
|
||||
t.Fatalf("SetUserStorageBackend returned error: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/admin/storage/"+cfg.ID+"/delete", strings.NewReader("csrf_token=test-csrf"))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
request.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "test-csrf"})
|
||||
request.SetPathValue("backendID", cfg.ID)
|
||||
response := httptest.NewRecorder()
|
||||
app.AdminDeleteStorage(response, request)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("AdminDeleteStorage status = %d, body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
location := response.Header().Get("Location")
|
||||
if !strings.Contains(location, "Storage+backend+deleted") || !strings.Contains(location, "cleared+1+user+overrides") {
|
||||
t.Fatalf("delete redirect did not include cascade notice: %s", location)
|
||||
}
|
||||
if _, err := app.uploadService.Storage().BackendConfig(cfg.ID); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("BackendConfig after delete = %v, want not exist", err)
|
||||
}
|
||||
nextSettings, err := app.settingsService.UploadPolicy()
|
||||
if err != nil {
|
||||
t.Fatalf("UploadPolicy returned error: %v", err)
|
||||
}
|
||||
if nextSettings.UserStorageBackend != services.StorageBackendLocal {
|
||||
t.Fatalf("UserStorageBackend = %q, want local", nextSettings.UserStorageBackend)
|
||||
}
|
||||
nextUser, err := app.authService.UserByID(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("UserByID returned error: %v", err)
|
||||
}
|
||||
if nextUser.Policy.StorageBackendID != nil {
|
||||
t.Fatalf("user storage override was not cleared: %+v", nextUser.Policy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminStorageSpeedTestStartsBackgroundJob(t *testing.T) {
|
||||
app, cleanup := newTestApp(t)
|
||||
defer cleanup()
|
||||
@@ -888,8 +984,12 @@ func TestAdminLogsAndBansPagesRender(t *testing.T) {
|
||||
t.Fatalf("MkdirAll returned error: %v", err)
|
||||
}
|
||||
logPath := filepath.Join(logDir, "2026-05-31.log")
|
||||
line := `{"date":"2026-05-31","time":"12:34:56","source":"user-upload","severity":"user_activity","code":2001,"log":"upload response sent","ip":"127.0.0.1","box_id":"box123"}` + "\n"
|
||||
if err := os.WriteFile(logPath, []byte(line), 0o644); err != nil {
|
||||
lines := strings.Join([]string{
|
||||
`{"date":"2026-05-31","time":"12:34:56","source":"user-upload","severity":"user_activity","code":2001,"log":"upload response sent","ip":"127.0.0.1","box_id":"box123"}`,
|
||||
`{"date":"2026-05-31","time":"12:35:56","source":"http","severity":"dev","code":200,"log":"http request","remote_addr":"172.30.0.1:48358","box_id":"box456"}`,
|
||||
"",
|
||||
}, "\n")
|
||||
if err := os.WriteFile(logPath, []byte(lines), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -904,6 +1004,9 @@ func TestAdminLogsAndBansPagesRender(t *testing.T) {
|
||||
if !strings.Contains(logsBody, "upload response sent") || !strings.Contains(logsBody, "box123") {
|
||||
t.Fatalf("AdminLogs missing expected log entry: %s", logsBody)
|
||||
}
|
||||
if strings.Contains(logsBody, "172.30.0.1:48358") {
|
||||
t.Fatalf("AdminLogs rendered remote address with port: %s", logsBody)
|
||||
}
|
||||
|
||||
bansRequest := httptest.NewRequest(http.MethodGet, "/admin/bans", nil)
|
||||
bansRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
|
||||
|
||||
@@ -564,6 +564,10 @@ func (a *App) AdminCreateBan(w http.ResponseWriter, r *http.Request) {
|
||||
if user, ok := a.currentUser(r); ok {
|
||||
createdBy = user.ID
|
||||
}
|
||||
if services.ProtectedBanTarget(r.FormValue("target"), a.cfg.TrustedProxies) {
|
||||
http.Redirect(w, r, "/admin/bans?error="+url.QueryEscape("Refusing to ban loopback or trusted proxy addresses."), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
ban, err := a.banService.CreateManualBan(r.FormValue("target"), r.FormValue("reason"), createdBy, expiresAt.UTC())
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/admin/bans?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
@@ -883,32 +887,45 @@ func (a *App) AdminStartStorageSpeedTest(w http.ResponseWriter, r *http.Request)
|
||||
http.Redirect(w, r, "/admin/storage/"+r.PathValue("backendID")+"/tests?notice="+url.QueryEscape("Storage speed test started in the background."), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) AdminDisableStorage(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("backendID")
|
||||
inUse, _ := a.storageBackendInUse(id)
|
||||
if err := a.uploadService.Storage().DisableBackend(id, inUse); err != nil {
|
||||
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.logger.Info("storage backend disabled", "source", "admin", "severity", "user_activity", "code", 2324, "ip", uploadClientIP(r), "backend_id", id)
|
||||
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) AdminDeleteStorage(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.requireAdmin(w, r) || !a.validateCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("backendID")
|
||||
inUse, _ := a.storageBackendInUse(id)
|
||||
if err := a.uploadService.Storage().DeleteBackend(id, inUse); err != nil {
|
||||
cfg, err := a.uploadService.Storage().BackendConfig(id)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if cfg.ID == services.StorageBackendLocal {
|
||||
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape("local storage cannot be deleted"), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
deletedBoxes, err := a.uploadService.DeleteBoxesForStorageBackend(id, "storage-delete")
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
resetAnonymous, resetUsersDefault, err := a.settingsService.ResetStorageBackend(id)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
clearedUsers, err := a.authService.ClearStorageBackendOverrides(id)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := a.uploadService.Storage().DeleteBackend(id, false); err != nil {
|
||||
http.Redirect(w, r, "/admin/storage?error="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
notice := fmt.Sprintf("Storage backend deleted. Removed %d related boxes and cleared %d user overrides.", deletedBoxes, clearedUsers)
|
||||
if resetAnonymous || resetUsersDefault {
|
||||
notice += " Global storage defaults were reset to local."
|
||||
}
|
||||
a.logger.Info("storage backend deleted", "source", "admin", "severity", "user_activity", "code", 2325, "ip", uploadClientIP(r), "backend_id", id)
|
||||
http.Redirect(w, r, "/admin/storage", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/admin/storage?notice="+url.QueryEscape(notice), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *App) AdminRunStorageCleanup(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1548,7 +1565,7 @@ func logEntryFromMap(raw map[string]any) adminLogEntry {
|
||||
Method: logString(raw, "method"),
|
||||
Path: logString(raw, "path"),
|
||||
Status: logAnyString(raw["status"]),
|
||||
IP: firstLogString(raw, "ip", "client_ip", "remote_addr"),
|
||||
IP: services.IPOnly(firstLogString(raw, "ip", "client_ip", "remote_addr")),
|
||||
UserID: logString(raw, "user_id"),
|
||||
}
|
||||
entry.Details = logDetails(raw)
|
||||
@@ -1767,13 +1784,14 @@ func (a *App) storageBackendViews() ([]services.StorageBackendView, error) {
|
||||
usage, _ = concrete.Usage(context.Background())
|
||||
}
|
||||
}
|
||||
inUse, _ := a.storageBackendInUse(cfg.ID)
|
||||
inUse, inUseReason, _ := a.storageBackendUseReason(cfg.ID)
|
||||
speedTests, _ := a.uploadService.Storage().ListSpeedTests(cfg.ID, 25)
|
||||
views = append(views, services.StorageBackendView{
|
||||
Config: cfg,
|
||||
UsageBytes: usage,
|
||||
UsageLabel: services.FormatMegabytesFromBytes(usage),
|
||||
InUse: inUse,
|
||||
InUseReason: inUseReason,
|
||||
SpeedTests: speedTests,
|
||||
CanSpeedTest: cfg.LastTestSuccess,
|
||||
})
|
||||
@@ -1822,32 +1840,40 @@ func (a *App) adminUserEdit(user services.User, settings services.UploadPolicySe
|
||||
}
|
||||
|
||||
func (a *App) storageBackendInUse(id string) (bool, error) {
|
||||
inUse, _, err := a.storageBackendUseReason(id)
|
||||
return inUse, err
|
||||
}
|
||||
|
||||
func (a *App) storageBackendUseReason(id string) (bool, string, error) {
|
||||
settings, err := a.settingsService.UploadPolicy()
|
||||
if err != nil {
|
||||
return false, err
|
||||
return false, "", err
|
||||
}
|
||||
if settings.AnonymousStorageBackend == id || settings.UserStorageBackend == id {
|
||||
return true, nil
|
||||
if settings.AnonymousStorageBackend == id {
|
||||
return true, "selected as the global anonymous storage backend", nil
|
||||
}
|
||||
if settings.UserStorageBackend == id {
|
||||
return true, "selected as the global user storage backend", nil
|
||||
}
|
||||
boxes, err := a.uploadService.ListBoxes(0)
|
||||
if err != nil {
|
||||
return false, err
|
||||
return false, "", err
|
||||
}
|
||||
for _, box := range boxes {
|
||||
if a.uploadService.BoxStorageBackendID(box) == id {
|
||||
return true, nil
|
||||
return true, "used by existing boxes", nil
|
||||
}
|
||||
}
|
||||
users, err := a.authService.ListUsers()
|
||||
if err != nil {
|
||||
return false, err
|
||||
return false, "", err
|
||||
}
|
||||
for _, user := range users {
|
||||
if user.Policy.StorageBackendID != nil && *user.Policy.StorageBackendID == id {
|
||||
return true, nil
|
||||
return true, "assigned to one or more users", nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
func floatPtrString(value *float64) string {
|
||||
|
||||
@@ -96,7 +96,6 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /admin/storage/{backendID}/edit", a.AdminEditStorage)
|
||||
mux.HandleFunc("POST /admin/storage/{backendID}/test", a.AdminTestStorage)
|
||||
mux.HandleFunc("POST /admin/storage/{backendID}/speed-test", a.AdminStartStorageSpeedTest)
|
||||
mux.HandleFunc("POST /admin/storage/{backendID}/disable", a.AdminDisableStorage)
|
||||
mux.HandleFunc("POST /admin/storage/{backendID}/delete", a.AdminDeleteStorage)
|
||||
mux.HandleFunc("POST /admin/storage/jobs/cleanup", a.AdminRunStorageCleanup)
|
||||
mux.HandleFunc("POST /admin/storage/jobs/thumbnails", a.AdminRunStorageThumbnails)
|
||||
|
||||
@@ -49,6 +49,7 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
|
||||
middleware.RequestID,
|
||||
middleware.SecurityHeaders,
|
||||
middleware.Gzip,
|
||||
middleware.ClientIP(cfg.TrustedProxies),
|
||||
middleware.Logger(logger),
|
||||
middleware.Bans(logger, banService, cfg.TrustedProxies),
|
||||
)
|
||||
|
||||
@@ -11,11 +11,15 @@ import (
|
||||
func Bans(logger *slog.Logger, bans *services.BanService, trustedProxies []string) Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Real-IP"), trustedProxies)
|
||||
r = services.WithClientIP(r, ip)
|
||||
ip, ok := services.ClientIPFromContext(r)
|
||||
if !ok {
|
||||
ip = services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Real-IP"), trustedProxies)
|
||||
r = services.WithClientIP(r, ip)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
protectedProxy := services.IsProtectedProxyIP(ip, trustedProxies)
|
||||
|
||||
if bans != nil {
|
||||
if bans != nil && !protectedProxy {
|
||||
if matched, ok, err := bans.Match(ip, now); err != nil {
|
||||
logger.Error("ban match failed", "source", "ban", "severity", "error", "code", 5001, "ip", ip, "error", err.Error())
|
||||
} else if ok {
|
||||
|
||||
@@ -99,6 +99,55 @@ func TestBansMiddlewareSkipsAutoBanWhenDisabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBansMiddlewareDoesNotBlockProtectedProxyIP(t *testing.T) {
|
||||
bans := newMiddlewareBanService(t)
|
||||
if _, err := bans.CreateManualBan("127.0.0.1", "bad historical ban", "admin", time.Now().UTC().Add(time.Hour)); err != nil {
|
||||
t.Fatalf("CreateManualBan returned error: %v", err)
|
||||
}
|
||||
called := false
|
||||
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
_, _ = io.WriteString(w, "ok")
|
||||
}), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, []string{"127.0.0.1"}))
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
request.RemoteAddr = "127.0.0.1:6070"
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
if !called || response.Code != http.StatusOK {
|
||||
t.Fatalf("protected proxy response = called %v code %d", called, response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBansMiddlewareDoesNotAutoBanProtectedProxyIP(t *testing.T) {
|
||||
bans := newMiddlewareBanService(t)
|
||||
settings, err := bans.Settings()
|
||||
if err != nil {
|
||||
t.Fatalf("Settings returned error: %v", err)
|
||||
}
|
||||
settings.AutoBanEnabled = true
|
||||
settings.MaliciousPathThreshold = 1
|
||||
if err := bans.UpdateSettings(settings); err != nil {
|
||||
t.Fatalf("UpdateSettings returned error: %v", err)
|
||||
}
|
||||
handler := Chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
}), Bans(slog.New(slog.NewTextHandler(io.Discard, nil)), bans, []string{"127.0.0.1"}))
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/.env", nil)
|
||||
request.RemoteAddr = "127.0.0.1:6070"
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
|
||||
if response.Code == http.StatusForbidden {
|
||||
t.Fatalf("protected proxy was auto-banned")
|
||||
}
|
||||
if _, ok, err := bans.Match("127.0.0.1", time.Now().UTC()); err != nil || ok {
|
||||
t.Fatalf("protected proxy Match = %v, %v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newMiddlewareBanService(t *testing.T) *services.BanService {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
|
||||
16
backend/libs/middleware/client_ip.go
Normal file
16
backend/libs/middleware/client_ip.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"warpbox.dev/backend/libs/services"
|
||||
)
|
||||
|
||||
func ClientIP(trustedProxies []string) Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Real-IP"), trustedProxies)
|
||||
next.ServeHTTP(w, services.WithClientIP(r, ip))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/services"
|
||||
)
|
||||
|
||||
type statusRecorder struct {
|
||||
@@ -38,6 +40,10 @@ func Logger(logger *slog.Logger) Middleware {
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
ip, ok := services.ClientIPFromContext(r)
|
||||
if !ok {
|
||||
ip = services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"), r.Header.Get("X-Real-IP"), nil)
|
||||
}
|
||||
|
||||
logger.Info("http request",
|
||||
"source", "http",
|
||||
@@ -49,6 +55,7 @@ func Logger(logger *slog.Logger) Middleware {
|
||||
"bytes", recorder.bytes,
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
"request_id", RequestIDFromContext(r.Context()),
|
||||
"ip", ip,
|
||||
"remote_addr", r.RemoteAddr,
|
||||
"user_agent", r.UserAgent(),
|
||||
)
|
||||
|
||||
@@ -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