diff --git a/SECURITY_PROXY.md b/SECURITY_PROXY.md index af5ac48..364553d 100644 --- a/SECURITY_PROXY.md +++ b/SECURITY_PROXY.md @@ -24,10 +24,10 @@ public internet. ## Trusted Proxies For stricter deployments, set `WARPBOX_TRUSTED_PROXIES` to the IPs or CIDR -ranges that are allowed to provide forwarded headers: +ranges that are allowed to provide forwarded headers. Use proxy IPs only. ```env -WARPBOX_TRUSTED_PROXIES=127.0.0.1,::1,172.16.0.0/12,10.0.0.0/8 +WARPBOX_TRUSTED_PROXIES=127.0.0.1,::1,172.30.0.1 ``` When this value is set, Warpbox trusts `X-Forwarded-For` and `X-Real-IP` only @@ -37,9 +37,15 @@ directly from any other IP ignore forwarded headers and use the socket address. Recommended values: - Same-host Caddy with systemd: `127.0.0.1,::1` -- Docker bridge networks: add the bridge CIDR, often `172.16.0.0/12` +- Docker/Podman bridge gateway: add the exact gateway IP, for example `172.30.0.1` +- Docker bridge networks: use a CIDR such as `172.16.0.0/12` only if the exact gateway changes often - Private reverse-proxy networks: add the exact private CIDR used by the proxy +Warpbox prefers the first public address in `X-Forwarded-For` when a trusted +proxy sends a chain. Loopback addresses and trusted proxy addresses are also +protected from manual and automatic bans so a bad header setup cannot ban Caddy, +the container gateway, or Warpbox itself. + ## Direct Exposure If you expose Warpbox directly without Caddy, either leave diff --git a/backend/libs/handlers/accounts_test.go b/backend/libs/handlers/accounts_test.go index fa74146..09abf26 100644 --- a/backend/libs/handlers/accounts_test.go +++ b/backend/libs/handlers/accounts_test.go @@ -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}) diff --git a/backend/libs/handlers/admin.go b/backend/libs/handlers/admin.go index a98981c..d1b0913 100644 --- a/backend/libs/handlers/admin.go +++ b/backend/libs/handlers/admin.go @@ -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 { diff --git a/backend/libs/handlers/app.go b/backend/libs/handlers/app.go index 8f03fdb..c353f1f 100644 --- a/backend/libs/handlers/app.go +++ b/backend/libs/handlers/app.go @@ -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) diff --git a/backend/libs/httpserver/server.go b/backend/libs/httpserver/server.go index 92d51ce..ffd4987 100644 --- a/backend/libs/httpserver/server.go +++ b/backend/libs/httpserver/server.go @@ -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), ) diff --git a/backend/libs/middleware/bans.go b/backend/libs/middleware/bans.go index 4869bfc..0d6d68b 100644 --- a/backend/libs/middleware/bans.go +++ b/backend/libs/middleware/bans.go @@ -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 { diff --git a/backend/libs/middleware/bans_test.go b/backend/libs/middleware/bans_test.go index b6dcb61..8c1c861 100644 --- a/backend/libs/middleware/bans_test.go +++ b/backend/libs/middleware/bans_test.go @@ -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() diff --git a/backend/libs/middleware/client_ip.go b/backend/libs/middleware/client_ip.go new file mode 100644 index 0000000..df0d51c --- /dev/null +++ b/backend/libs/middleware/client_ip.go @@ -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)) + }) + } +} diff --git a/backend/libs/middleware/logger.go b/backend/libs/middleware/logger.go index e8dfdb3..aa0ef42 100644 --- a/backend/libs/middleware/logger.go +++ b/backend/libs/middleware/logger.go @@ -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(), ) diff --git a/backend/libs/services/auth.go b/backend/libs/services/auth.go index 4f785aa..d2ebe84 100644 --- a/backend/libs/services/auth.go +++ b/backend/libs/services/auth.go @@ -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 diff --git a/backend/libs/services/proxy.go b/backend/libs/services/proxy.go index 0347e19..686e127 100644 --- a/backend/libs/services/proxy.go +++ b/backend/libs/services/proxy.go @@ -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) +} diff --git a/backend/libs/services/proxy_test.go b/backend/libs/services/proxy_test.go index c2d6e59..16d9548 100644 --- a/backend/libs/services/proxy_test.go +++ b/backend/libs/services/proxy_test.go @@ -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") + } +} diff --git a/backend/libs/services/settings.go b/backend/libs/services/settings.go index d7fa8af..08f7500 100644 --- a/backend/libs/services/settings.go +++ b/backend/libs/services/settings.go @@ -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 diff --git a/backend/libs/services/storage.go b/backend/libs/services/storage.go index 67cf385..a7f0f8f 100644 --- a/backend/libs/services/storage.go +++ b/backend/libs/services/storage.go @@ -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") diff --git a/backend/libs/services/upload.go b/backend/libs/services/upload.go index 838b3d6..23c9bb1 100644 --- a/backend/libs/services/upload.go +++ b/backend/libs/services/upload.go @@ -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 } diff --git a/backend/static/css/00-base.css b/backend/static/css/00-base.css index eaa0450..a9d896a 100644 --- a/backend/static/css/00-base.css +++ b/backend/static/css/00-base.css @@ -58,6 +58,69 @@ --surface-2: rgba(39, 39, 42, 0.28); } +:root[data-theme="gruvbox"] { + color-scheme: dark; + --background: #1d2021; + --foreground: #ebdbb2; + --card: #282828; + --card-foreground: #ebdbb2; + --muted: #32302f; + --muted-foreground: #bdae93; + --accent: #3c3836; + --accent-foreground: #fbf1c7; + --border: rgba(235, 219, 178, 0.18); + --input: rgba(235, 219, 178, 0.24); + --primary: #d79921; + --primary-foreground: #1d2021; + --primary-hover: #fabd2f; + --ring: #fe8019; + --success: #b8bb26; + --danger: #fb4934; + --radius: 0.65rem; + --shadow: 0 24px 70px rgba(0, 0, 0, 0.42); + --font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --header-bg: rgba(29, 32, 33, 0.86); + --body-bg: + radial-gradient(circle at 20% -8%, rgba(215, 153, 33, 0.2), transparent 28rem), + radial-gradient(circle at 85% 8%, rgba(184, 187, 38, 0.12), transparent 26rem), + linear-gradient(180deg, #1d2021 0%, #181a1b 100%); + --surface-1: rgba(235, 219, 178, 0.06); + --surface-1-hover: rgba(235, 219, 178, 0.11); + --surface-2: rgba(251, 241, 199, 0.04); +} + +:root[data-theme="cyberpunk"] { + color-scheme: dark; + --background: #08070d; + --foreground: #fff36f; + --card: #16131f; + --card-foreground: #fff36f; + --muted: #251d34; + --muted-foreground: #9bfaff; + --accent: #332246; + --accent-foreground: #fff36f; + --border: rgba(255, 242, 0, 0.24); + --input: rgba(0, 240, 255, 0.34); + --primary: #fff200; + --primary-foreground: #08070d; + --primary-hover: #00f0ff; + --ring: #ff2a6d; + --success: #00ff9f; + --danger: #ff2a6d; + --radius: 0.35rem; + --shadow: 0 24px 70px rgba(255, 42, 109, 0.16), 0 0 34px rgba(0, 240, 255, 0.12); + --font-sans: "Inter", "Rajdhani", "Orbitron", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --header-bg: rgba(8, 7, 13, 0.86); + --body-bg: + radial-gradient(circle at 10% -10%, rgba(255, 242, 0, 0.2), transparent 26rem), + radial-gradient(circle at 90% 8%, rgba(0, 240, 255, 0.18), transparent 26rem), + radial-gradient(circle at 45% 110%, rgba(255, 42, 109, 0.18), transparent 30rem), + linear-gradient(180deg, #08070d 0%, #120b1a 100%); + --surface-1: rgba(0, 240, 255, 0.07); + --surface-1-hover: rgba(255, 242, 0, 0.12); + --surface-2: rgba(255, 42, 109, 0.06); +} + :root[data-theme="retro"] { color-scheme: light; --background: #ffffff; @@ -98,6 +161,7 @@ html { font-family: var(--font-sans); background: var(--background); color: var(--foreground); + overflow-x: clip; } body { @@ -107,12 +171,27 @@ body { display: flex; flex-direction: column; background: var(--body-bg); + overflow-x: clip; +} + +@supports not (overflow-x: clip) { + html, + body { + overflow-x: hidden; + } } a { color: inherit; } +img, +video, +canvas, +iframe { + max-width: 100%; +} + svg { width: 1rem; height: 1rem; @@ -176,10 +255,18 @@ svg { } .brand { + min-width: 0; font-weight: 650; text-decoration: none; } +.brand > span:last-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .brand-mark { width: 1.75rem; height: 1.75rem; @@ -312,12 +399,15 @@ label span { input, select, +textarea, button { font: inherit; + max-width: 100%; } input, -select { +select, +textarea { width: 100%; min-height: 2.25rem; border: 1px solid var(--input); @@ -354,6 +444,8 @@ input:disabled { .button, button { + min-width: 0; + max-width: 100%; min-height: 2.25rem; display: inline-flex; align-items: center; @@ -372,6 +464,14 @@ button { cursor: pointer; } +.button > span, +button > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .button-primary { background: var(--primary); color: var(--primary-foreground); @@ -433,6 +533,8 @@ pre code { .badge { display: inline-flex; align-items: center; + max-width: 100%; + min-width: 0; min-height: 1.5rem; border-radius: 999px; background: var(--muted); @@ -440,6 +542,9 @@ pre code { padding: 0.2rem 0.6rem; font-size: 0.75rem; font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .sr-only { diff --git a/backend/static/css/10-layout.css b/backend/static/css/10-layout.css index 8516ab0..fff1d9a 100644 --- a/backend/static/css/10-layout.css +++ b/backend/static/css/10-layout.css @@ -1,5 +1,6 @@ .app-shell { width: min(86rem, calc(100% - 2rem)); + max-width: 100%; margin: 0 auto; padding: 2rem 0; display: grid; @@ -8,6 +9,7 @@ } .app-sidebar { + min-width: 0; position: sticky; top: 5rem; align-self: start; @@ -20,6 +22,7 @@ } .sidebar-link { + min-width: 0; display: flex; align-items: center; gap: 0.55rem; @@ -30,6 +33,13 @@ text-decoration: none; } +.sidebar-link span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .sidebar-link:hover, .sidebar-link.is-active { border-color: var(--border); @@ -100,7 +110,7 @@ .inline-controls input, .inline-controls select { - min-width: 15rem; + min-width: min(15rem, 100%); } .compact-input { @@ -108,10 +118,18 @@ } .settings-form { + min-width: 0; display: grid; gap: 1.5rem; } +.settings-form > *, +.settings-section > *, +.tabs-bar > *, +.tab-list > * { + min-width: 0; +} + .settings-form-narrow { grid-template-columns: minmax(0, 1fr); gap: 0.9rem; @@ -207,6 +225,7 @@ top: calc(100% + 0.5rem); z-index: 10; width: 15rem; + max-width: min(15rem, calc(100vw - 2rem)); padding: 1rem; background: color-mix(in srgb, var(--card) 97%, #000); border: 1px solid var(--border); @@ -226,6 +245,7 @@ /* Copyable URL field */ .copy-field { display: flex; + min-width: 0; gap: 0.5rem; align-items: center; margin-top: 0.75rem; diff --git a/backend/static/css/15-revamp.css b/backend/static/css/15-revamp.css index ff05973..36f30c4 100644 --- a/backend/static/css/15-revamp.css +++ b/backend/static/css/15-revamp.css @@ -2,19 +2,19 @@ * Revamp ("Aurora glass") flourishes. * * These rules only apply to the default/revamp theme. They are scoped to - * :root:not([data-theme="classic"]):not([data-theme="retro"]) so they cover both the explicit - * data-theme="revamp" attribute AND the no-JS default (no attribute), while - * never touching the classic theme. Token colours live in 00-base.css; this - * file adds the things a flat token swap can't: the animated aurora backdrop, - * frosted glass, gradient accents, glow and motion. + * :root exclusions so they cover both the explicit data-theme="revamp" + * attribute AND the no-JS default (no attribute), while never touching the + * alternate themes. Token colours live in 00-base.css; this file adds the + * things a flat token swap can't: the animated aurora backdrop, frosted glass, + * gradient accents, glow and motion. */ -:root:not([data-theme="classic"]):not([data-theme="retro"]) { +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) { scroll-behavior: smooth; } /* Animated aurora backdrop ------------------------------------------------ */ -:root:not([data-theme="classic"]):not([data-theme="retro"]) body::before { +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) body::before { content: ""; position: fixed; inset: -20vmax; @@ -29,7 +29,7 @@ animation: aurora-drift 26s ease-in-out infinite alternate; } -:root:not([data-theme="classic"]):not([data-theme="retro"]) body::after { +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) body::after { content: ""; position: fixed; inset: 0; @@ -52,13 +52,13 @@ } @media (prefers-reduced-motion: reduce) { - :root:not([data-theme="classic"]):not([data-theme="retro"]) body::before { + :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) body::before { animation: none; } } /* Frosted glass cards ----------------------------------------------------- */ -:root:not([data-theme="classic"]):not([data-theme="retro"]) .card { +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .card { background: linear-gradient( 155deg, color-mix(in srgb, var(--card) 78%, transparent), @@ -70,20 +70,20 @@ } /* Sticky header gets the same glassy treatment */ -:root:not([data-theme="classic"]):not([data-theme="retro"]) .site-header { +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .site-header { backdrop-filter: blur(20px) saturate(150%); -webkit-backdrop-filter: blur(20px) saturate(150%); } /* Brand mark glows */ -:root:not([data-theme="classic"]):not([data-theme="retro"]) .brand-mark { +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .brand-mark { background: linear-gradient(135deg, #8b5cf6, #6366f1 55%, #22d3ee); color: #fff; box-shadow: 0 6px 18px rgba(124, 58, 237, 0.45); } /* Headings get a soft gradient sheen */ -:root:not([data-theme="classic"]):not([data-theme="retro"]) h1 { +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) h1 { background: linear-gradient(120deg, #f5f3ff 0%, #c4b5fd 60%, #67e8f9 100%); -webkit-background-clip: text; background-clip: text; @@ -91,8 +91,8 @@ } /* Gradient primary buttons ------------------------------------------------ */ -:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-primary, -:root:not([data-theme="classic"]):not([data-theme="retro"]) .button.is-active { +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .button-primary, +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .button.is-active { background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 55%, #22d3ee 130%); color: #fff; border-color: transparent; @@ -100,43 +100,43 @@ transition: transform 140ms ease, box-shadow 160ms ease, filter 160ms ease; } -:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-primary:hover { +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .button-primary:hover { background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 55%, #22d3ee 130%); filter: brightness(1.08); box-shadow: 0 12px 30px rgba(99, 102, 241, 0.5); transform: translateY(-1px); } -:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-primary:active { +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .button-primary:active { transform: translateY(0); } /* Outline / ghost buttons get a subtle lift on hover */ -:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-outline, -:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-ghost { +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .button-outline, +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .button-ghost { transition: background 140ms ease, border-color 140ms ease, transform 140ms ease; } -:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-outline:hover, -:root:not([data-theme="classic"]):not([data-theme="retro"]) .button-ghost:hover { +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .button-outline:hover, +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .button-ghost:hover { border-color: rgba(168, 150, 255, 0.4); transform: translateY(-1px); } /* Glow focus rings -------------------------------------------------------- */ -:root:not([data-theme="classic"]):not([data-theme="retro"]) :focus-visible { +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) :focus-visible { outline: 2px solid transparent; box-shadow: 0 0 0 2px var(--background), 0 0 0 4px var(--ring), 0 0 16px rgba(167, 139, 250, 0.55); } -:root:not([data-theme="classic"]):not([data-theme="retro"]) input:focus, -:root:not([data-theme="classic"]):not([data-theme="retro"]) select:focus { +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) input:focus, +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) select:focus { border-color: var(--ring); box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.22); } /* Drop zone: animated, glowing -------------------------------------------- */ -:root:not([data-theme="classic"]):not([data-theme="retro"]) .drop-zone { +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .drop-zone { border-color: rgba(168, 150, 255, 0.3); background: radial-gradient(120% 90% at 50% 0%, rgba(139, 92, 246, 0.1), transparent 70%), @@ -144,18 +144,18 @@ transition: border-color 180ms ease, background 180ms ease, transform 180ms ease, box-shadow 180ms ease; } -:root:not([data-theme="classic"]):not([data-theme="retro"]) .drop-zone:hover, -:root:not([data-theme="classic"]):not([data-theme="retro"]) .drop-zone.is-dragging { +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .drop-zone:hover, +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .drop-zone.is-dragging { border-color: #a78bfa; box-shadow: 0 0 0 1px rgba(167, 139, 250, 0.4), 0 18px 50px rgba(99, 102, 241, 0.28); transform: translateY(-2px); } -:root:not([data-theme="classic"]):not([data-theme="retro"]) .drop-icon { +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .drop-icon { color: #c4b5fd; } -:root:not([data-theme="classic"]):not([data-theme="retro"]) .drop-zone.is-dragging .drop-icon { +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .drop-zone.is-dragging .drop-icon { animation: drop-bounce 700ms ease infinite; } @@ -165,34 +165,34 @@ } /* Badges pick up a tinted glass look */ -:root:not([data-theme="classic"]):not([data-theme="retro"]) .badge { +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .badge { background: rgba(139, 92, 246, 0.14); color: #d6ccff; border: 1px solid rgba(168, 150, 255, 0.22); } /* File / result rows lift on hover */ -:root:not([data-theme="classic"]):not([data-theme="retro"]) .download-item, -:root:not([data-theme="classic"]):not([data-theme="retro"]) .result-item { +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .download-item, +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .result-item { background: color-mix(in srgb, var(--card) 60%, transparent); border-color: rgba(168, 150, 255, 0.14); transition: border-color 140ms ease, transform 140ms ease, background 140ms ease; } -:root:not([data-theme="classic"]):not([data-theme="retro"]) .download-item:hover { +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .download-item:hover { border-color: rgba(168, 150, 255, 0.34); transform: translateY(-1px); } /* Thumbnails on the download page */ -:root:not([data-theme="classic"]):not([data-theme="retro"]) .file-emblem { +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) .file-emblem { background: linear-gradient(135deg, rgba(139, 92, 246, 0.25), rgba(34, 211, 238, 0.18)); color: #d6ccff; border: 1px solid rgba(168, 150, 255, 0.22); } /* Gentle entrance for primary content cards */ -:root:not([data-theme="classic"]):not([data-theme="retro"]) main > * { +:root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) main > * { animation: rise-in 420ms ease both; } @@ -208,7 +208,7 @@ } @media (prefers-reduced-motion: reduce) { - :root:not([data-theme="classic"]):not([data-theme="retro"]) main > * { + :root:not([data-theme="classic"]):not([data-theme="retro"]):not([data-theme="gruvbox"]):not([data-theme="cyberpunk"]) main > * { animation: none; } } diff --git a/backend/static/css/17-gruvbox.css b/backend/static/css/17-gruvbox.css new file mode 100644 index 0000000..43bfcd6 --- /dev/null +++ b/backend/static/css/17-gruvbox.css @@ -0,0 +1,88 @@ +/* + * Gruvbox theme polish. + * + * Core colour tokens live in 00-base.css. This file adds the warmer, grounded + * Gruvbox-specific surface treatment without changing layout behavior. + */ + +:root[data-theme="gruvbox"] .site-header { + border-bottom-color: rgba(250, 189, 47, 0.2); + backdrop-filter: blur(16px) saturate(130%); + -webkit-backdrop-filter: blur(16px) saturate(130%); +} + +:root[data-theme="gruvbox"] .brand-mark { + background: linear-gradient(135deg, #d79921, #fe8019); + color: #1d2021; + box-shadow: 0 8px 22px rgba(254, 128, 25, 0.22); +} + +:root[data-theme="gruvbox"] .card, +:root[data-theme="gruvbox"] .app-sidebar, +:root[data-theme="gruvbox"] .storage-card, +:root[data-theme="gruvbox"] .storage-op-card, +:root[data-theme="gruvbox"] .metric-card, +:root[data-theme="gruvbox"] .logs-filter-card { + background: color-mix(in srgb, var(--card) 92%, #1d2021); + border-color: rgba(235, 219, 178, 0.16); +} + +:root[data-theme="gruvbox"] .admin-shell .app-sidebar { + border-color: rgba(250, 189, 47, 0.32); + background: linear-gradient(180deg, rgba(215, 153, 33, 0.12), rgba(40, 40, 40, 0.94)); +} + +:root[data-theme="gruvbox"] .admin-shell .sidebar-link.is-active { + border-color: rgba(250, 189, 47, 0.36); + background: rgba(215, 153, 33, 0.14); +} + +:root[data-theme="gruvbox"] .admin-shell .kicker, +:root[data-theme="gruvbox"] .kicker { + color: #fabd2f; +} + +:root[data-theme="gruvbox"] h1 { + color: #fbf1c7; +} + +:root[data-theme="gruvbox"] .button-primary, +:root[data-theme="gruvbox"] .button.is-active { + border-color: rgba(250, 189, 47, 0.3); + background: linear-gradient(135deg, #d79921, #fabd2f); + color: #1d2021; + box-shadow: 0 10px 24px rgba(215, 153, 33, 0.2); +} + +:root[data-theme="gruvbox"] .button-primary:hover { + background: linear-gradient(135deg, #fabd2f, #fe8019); +} + +:root[data-theme="gruvbox"] .button-outline, +:root[data-theme="gruvbox"] .button-ghost:hover, +:root[data-theme="gruvbox"] .button-outline:hover { + border-color: rgba(235, 219, 178, 0.2); +} + +:root[data-theme="gruvbox"] .badge-active, +:root[data-theme="gruvbox"] .storage-detail-test.is-ok > span:last-child { + color: #b8bb26; +} + +:root[data-theme="gruvbox"] .badge-disabled, +:root[data-theme="gruvbox"] .storage-detail-test.is-err > span:last-child, +:root[data-theme="gruvbox"] .form-error { + color: #fb4934; +} + +:root[data-theme="gruvbox"] input:focus, +:root[data-theme="gruvbox"] select:focus, +:root[data-theme="gruvbox"] textarea:focus { + border-color: #fe8019; + box-shadow: 0 0 0 3px rgba(254, 128, 25, 0.18); +} + +:root[data-theme="gruvbox"] ::selection { + background: #d79921; + color: #1d2021; +} diff --git a/backend/static/css/18-cyberpunk.css b/backend/static/css/18-cyberpunk.css new file mode 100644 index 0000000..02d7df8 --- /dev/null +++ b/backend/static/css/18-cyberpunk.css @@ -0,0 +1,196 @@ +/* + * CyberPunk theme polish. + * + * Inspired by neon Cyberpunk 2077 UI treatments: warning yellow surfaces, + * cyan/magenta light, hard edges, scanlines, and high-contrast panels. + */ + +:root[data-theme="cyberpunk"] body::before { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + background: + linear-gradient(rgba(255, 242, 0, 0.035) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 240, 255, 0.03) 1px, transparent 1px); + background-size: 100% 3px, 3rem 100%; + mix-blend-mode: screen; +} + +:root[data-theme="cyberpunk"] body::after { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + background: + linear-gradient(115deg, transparent 0 18%, rgba(255, 242, 0, 0.06) 18% 19%, transparent 19% 100%), + linear-gradient(245deg, transparent 0 76%, rgba(255, 42, 109, 0.08) 76% 77%, transparent 77% 100%); +} + +:root[data-theme="cyberpunk"] .site-header { + border-bottom-color: rgba(255, 242, 0, 0.32); + box-shadow: 0 0 22px rgba(0, 240, 255, 0.12); + backdrop-filter: blur(12px) saturate(150%); + -webkit-backdrop-filter: blur(12px) saturate(150%); +} + +:root[data-theme="cyberpunk"] .brand { + text-transform: lowercase; + letter-spacing: 0.02em; +} + +:root[data-theme="cyberpunk"] .brand-mark { + background: #fff200; + color: #08070d; + box-shadow: 0 0 0 1px rgba(0, 240, 255, 0.45), 0 0 18px rgba(255, 242, 0, 0.42); + clip-path: polygon(0 0, 100% 0, 100% 72%, 78% 100%, 0 100%); +} + +:root[data-theme="cyberpunk"] h1 { + color: #fff200; + text-shadow: 2px 0 0 rgba(255, 42, 109, 0.58), -2px 0 0 rgba(0, 240, 255, 0.46); +} + +:root[data-theme="cyberpunk"] .card, +:root[data-theme="cyberpunk"] .app-sidebar, +:root[data-theme="cyberpunk"] .storage-card, +:root[data-theme="cyberpunk"] .storage-op-card, +:root[data-theme="cyberpunk"] .metric-card, +:root[data-theme="cyberpunk"] .logs-filter-card, +:root[data-theme="cyberpunk"] .advanced-options { + position: relative; + background: + linear-gradient(145deg, rgba(22, 19, 31, 0.96), rgba(13, 10, 20, 0.96)), + linear-gradient(90deg, rgba(255, 242, 0, 0.16), rgba(0, 240, 255, 0.08)); + border-color: rgba(255, 242, 0, 0.28); + box-shadow: var(--shadow); +} + +:root[data-theme="cyberpunk"] .card::before, +:root[data-theme="cyberpunk"] .storage-card::before, +:root[data-theme="cyberpunk"] .metric-card::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + border-top: 1px solid rgba(0, 240, 255, 0.4); + clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%); +} + +:root[data-theme="cyberpunk"] .admin-shell .app-sidebar { + border-color: rgba(255, 42, 109, 0.38); + background: + linear-gradient(180deg, rgba(255, 42, 109, 0.16), rgba(8, 7, 13, 0.94)), + #16131f; +} + +:root[data-theme="cyberpunk"] .sidebar-link:hover, +:root[data-theme="cyberpunk"] .sidebar-link.is-active, +:root[data-theme="cyberpunk"] .admin-shell .sidebar-link.is-active { + border-color: rgba(255, 242, 0, 0.42); + background: linear-gradient(90deg, rgba(255, 242, 0, 0.2), rgba(0, 240, 255, 0.08)); + color: #fff200; +} + +:root[data-theme="cyberpunk"] .kicker, +:root[data-theme="cyberpunk"] .admin-shell .kicker { + color: #00f0ff; + text-shadow: 0 0 12px rgba(0, 240, 255, 0.36); +} + +:root[data-theme="cyberpunk"] .button-primary, +:root[data-theme="cyberpunk"] .button.is-active { + border-color: #fff200; + background: #fff200; + color: #08070d; + box-shadow: 4px 4px 0 rgba(255, 42, 109, 0.7), 0 0 18px rgba(255, 242, 0, 0.3); + clip-path: polygon(0 0, calc(100% - 0.7rem) 0, 100% 0.7rem, 100% 100%, 0.7rem 100%, 0 calc(100% - 0.7rem)); +} + +:root[data-theme="cyberpunk"] .button-primary:hover, +:root[data-theme="cyberpunk"] .button.is-active:hover { + background: #00f0ff; + border-color: #00f0ff; + color: #08070d; + box-shadow: 4px 4px 0 rgba(255, 42, 109, 0.78), 0 0 22px rgba(0, 240, 255, 0.42); +} + +:root[data-theme="cyberpunk"] .button-outline, +:root[data-theme="cyberpunk"] .button-ghost { + border-color: rgba(0, 240, 255, 0.28); +} + +:root[data-theme="cyberpunk"] .button-outline:hover, +:root[data-theme="cyberpunk"] .button-ghost:hover { + border-color: rgba(255, 242, 0, 0.46); + background: rgba(255, 242, 0, 0.1); +} + +:root[data-theme="cyberpunk"] input, +:root[data-theme="cyberpunk"] select, +:root[data-theme="cyberpunk"] textarea { + background: rgba(8, 7, 13, 0.92); + border-color: rgba(0, 240, 255, 0.34); +} + +:root[data-theme="cyberpunk"] input:focus, +:root[data-theme="cyberpunk"] select:focus, +:root[data-theme="cyberpunk"] textarea:focus { + border-color: #fff200; + box-shadow: 0 0 0 3px rgba(255, 242, 0, 0.16), 0 0 22px rgba(0, 240, 255, 0.18); +} + +:root[data-theme="cyberpunk"] .badge { + border: 1px solid rgba(0, 240, 255, 0.22); + background: rgba(0, 240, 255, 0.08); + color: #9bfaff; +} + +:root[data-theme="cyberpunk"] .badge-active, +:root[data-theme="cyberpunk"] .storage-detail-test.is-ok > span:last-child { + color: #00ff9f; +} + +:root[data-theme="cyberpunk"] .badge-disabled, +:root[data-theme="cyberpunk"] .storage-detail-test.is-err > span:last-child, +:root[data-theme="cyberpunk"] .form-error { + color: #ff2a6d; +} + +:root[data-theme="cyberpunk"] .drop-zone { + border-color: rgba(255, 242, 0, 0.34); + background: + linear-gradient(145deg, rgba(255, 242, 0, 0.08), transparent 38%), + rgba(8, 7, 13, 0.76); +} + +:root[data-theme="cyberpunk"] .drop-zone:hover, +:root[data-theme="cyberpunk"] .drop-zone.is-dragging { + border-color: #00f0ff; + background: + linear-gradient(145deg, rgba(0, 240, 255, 0.14), transparent 42%), + rgba(8, 7, 13, 0.82); +} + +:root[data-theme="cyberpunk"] ::selection { + background: #ff2a6d; + color: #ffffff; +} + +@media (prefers-reduced-motion: no-preference) { + :root[data-theme="cyberpunk"] .brand-mark, + :root[data-theme="cyberpunk"] h1 { + animation: cyberpunk-pulse 4s ease-in-out infinite; + } +} + +@keyframes cyberpunk-pulse { + 0%, 100% { + filter: none; + } + 50% { + filter: drop-shadow(0 0 0.45rem rgba(0, 240, 255, 0.28)); + } +} diff --git a/backend/static/css/50-admin.css b/backend/static/css/50-admin.css index 073a1ca..2a59824 100644 --- a/backend/static/css/50-admin.css +++ b/backend/static/css/50-admin.css @@ -1,11 +1,19 @@ .admin-header, .table-header { display: flex; + min-width: 0; align-items: center; justify-content: space-between; gap: 1rem; } +.admin-header > *, +.table-header > *, +.admin-grid-two > *, +.logs-filter-card > * { + min-width: 0; +} + .kicker { margin: 0 0 0.4rem; color: var(--muted-foreground); @@ -72,12 +80,15 @@ } .admin-table-wrap { + max-width: 100%; overflow-x: auto; margin-top: 1rem; + -webkit-overflow-scrolling: touch; } .admin-table { width: 100%; + min-width: 46rem; border-collapse: collapse; font-size: 0.85rem; } @@ -204,6 +215,7 @@ display: flex; gap: 0.4rem; align-items: center; + flex-wrap: wrap; margin-top: 0.4rem; } diff --git a/backend/static/css/60-storage.css b/backend/static/css/60-storage.css index 61190ed..1f8162e 100644 --- a/backend/static/css/60-storage.css +++ b/backend/static/css/60-storage.css @@ -23,6 +23,7 @@ .storage-card-header { display: flex; + min-width: 0; align-items: center; justify-content: space-between; gap: 1rem; @@ -56,6 +57,10 @@ .storage-card-name { display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; font-size: 0.95rem; font-weight: 650; color: var(--foreground); @@ -82,9 +87,15 @@ flex-wrap: wrap; } +.storage-card-actions form { + min-width: 0; + margin: 0; +} + /* View-mode summary */ .storage-card-summary { display: flex; + min-width: 0; flex-wrap: wrap; gap: 0 1.75rem; padding: 0.65rem 1.1rem 0.9rem; @@ -96,6 +107,7 @@ flex-direction: column; gap: 0.15rem; min-width: 8rem; + max-width: 100%; } .storage-detail > span:first-child, @@ -137,6 +149,14 @@ align-items: end; } +.storage-card-fields > *, +.storage-ops-grid > *, +.storage-result-row, +.storage-result-row summary > *, +.storage-result-detail > * { + min-width: 0; +} + .storage-card-fields label { display: grid; gap: 0.28rem; diff --git a/backend/static/css/90-responsive.css b/backend/static/css/90-responsive.css index d037565..43b3b18 100644 --- a/backend/static/css/90-responsive.css +++ b/backend/static/css/90-responsive.css @@ -1,12 +1,34 @@ @media (max-width: 720px) { - .nav-links { - display: inline-flex; + .nav { + width: min(72rem, calc(100% - 1rem)); + min-height: auto; + padding: 0.55rem 0; + align-items: flex-start; flex-wrap: wrap; - justify-content: flex-end; + gap: 0.55rem; + } + + .brand { + flex: 1 1 auto; + } + + .nav-links { + width: 100%; + display: flex; + flex-wrap: wrap; + justify-content: stretch; + gap: 0.4rem; + } + + .nav-links .button { + flex: 1 1 auto; + min-width: 0; + padding-inline: 0.55rem; } .upload-view, .download-view { + width: min(100%, calc(100% - 1rem)); min-height: auto; padding: 2rem 0; } @@ -37,6 +59,23 @@ .app-sidebar { position: static; + width: 100%; + overflow: hidden; + } + + .sidebar-nav { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.35rem; + } + + .sidebar-link { + justify-content: flex-start; + padding-inline: 0.65rem; + } + + .sidebar-logout .button { + justify-content: center; } .endpoint-list div { @@ -86,9 +125,59 @@ .new-collection-body { position: static; width: 100%; + max-width: 100%; margin-top: 0.5rem; box-shadow: none; } + + .inline-controls { + align-items: stretch; + } + + .inline-controls label, + .inline-controls input, + .inline-controls select, + .compact-input { + width: 100%; + min-width: 0; + } + + .copy-field, + .token-reveal-row, + .storage-card-edit-bar { + flex-wrap: wrap; + } + + .copy-field .button, + .token-reveal-row .button, + .storage-card-edit-bar .button { + flex: 1 1 auto; + } + + .storage-card-header, + .storage-card-actions { + align-items: stretch; + } + + .storage-card-header { + flex-direction: column; + } + + .storage-card-actions, + .storage-card-actions form, + .storage-card-actions .button, + .storage-card-actions button { + width: 100%; + } + + .storage-card-summary { + gap: 0.65rem; + } + + .storage-detail { + min-width: 0; + width: 100%; + } } @media (max-width: 640px) { @@ -96,3 +185,61 @@ grid-template-columns: 1fr; } } + +@media (max-width: 520px) { + .app-shell { + width: min(100%, calc(100% - 1rem)); + padding: 1rem 0; + gap: 1rem; + } + + .card-content { + padding: 1rem; + } + + .metric-grid, + .user-edit-metrics { + grid-template-columns: 1fr; + } + + .storage-type-grid, + .storage-ops-grid { + grid-template-columns: 1fr; + } + + .result-item, + .download-item { + align-items: stretch; + flex-wrap: wrap; + } + + .file-actions, + .file-browser.is-thumbs .file-actions { + width: 100%; + display: grid; + grid-template-columns: 1fr; + } + + .file-progress-side { + width: 100%; + } + + .site-footer { + width: min(100%, calc(100% - 1rem)); + } +} + +@media (max-width: 380px) { + .sidebar-nav { + grid-template-columns: 1fr; + } + + .badge-row .badge { + flex: 1 1 100%; + justify-content: center; + } + + .nav-links .button { + flex-basis: 100%; + } +} diff --git a/backend/static/js/05-theme.js b/backend/static/js/05-theme.js index 6a40d64..72d2da7 100644 --- a/backend/static/js/05-theme.js +++ b/backend/static/js/05-theme.js @@ -11,7 +11,7 @@ */ (function () { var STORAGE_KEY = "warpbox-theme"; - var THEMES = ["revamp", "classic", "retro"]; + var THEMES = ["revamp", "classic", "retro", "gruvbox", "cyberpunk"]; function stored() { try { diff --git a/backend/static/js/20-storage-admin.js b/backend/static/js/20-storage-admin.js index 798199b..86cc655 100644 --- a/backend/static/js/20-storage-admin.js +++ b/backend/static/js/20-storage-admin.js @@ -1,4 +1,16 @@ (function () { + document.querySelectorAll("[data-storage-delete-warning]").forEach((button) => { + button.addEventListener("click", (event) => { + const name = button.getAttribute("data-storage-delete-warning") || "this storage backend"; + const confirmed = window.confirm( + `Delete ${name}?\n\nAll boxes stored on this location will also be deleted. Any global defaults or user storage overrides pointing at it will be reset back to inherited local storage.` + ); + if (!confirmed) { + event.preventDefault(); + } + }); + }); + document.querySelectorAll("[data-storage-speed-open]").forEach((button) => { button.addEventListener("click", () => { const modal = document.querySelector("[data-storage-speed-modal]"); diff --git a/backend/templates/layouts/base.html b/backend/templates/layouts/base.html index be830b4..18ae514 100644 --- a/backend/templates/layouts/base.html +++ b/backend/templates/layouts/base.html @@ -15,23 +15,25 @@ {{if .ImageURL}}{{end}} {{if .ImageURL}}{{end}} - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + +
Skip to content @@ -67,6 +69,8 @@ + + diff --git a/backend/templates/pages/admin_storage.html b/backend/templates/pages/admin_storage.html index 16aa08d..8aa2b3a 100644 --- a/backend/templates/pages/admin_storage.html +++ b/backend/templates/pages/admin_storage.html @@ -76,6 +76,7 @@ {{if eq .Config.ID "local"}}Required {{else if .Config.Enabled}}Enabled {{else}}Disabled{{end}} + {{if .InUseReason}}In use{{end}} {{if .UsageLabel}}{{.UsageLabel}}{{end}} @@ -92,15 +93,9 @@ {{end}} {{if ne .Config.ID "local"}} Edit - {{if .Config.Enabled}} - - {{end}} {{end}}