diff --git a/backend/libs/config/config.go b/backend/libs/config/config.go index 9358f5b..3b5c6fb 100644 --- a/backend/libs/config/config.go +++ b/backend/libs/config/config.go @@ -72,9 +72,9 @@ func Load() (Config, error) { MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default. DefaultSettings: SettingsDefaults{ AnonymousUploadsEnabled: envBool("WARPBOX_ANONYMOUS_UPLOADS_ENABLED", true), - AnonymousMaxUploadMB: envMegabytesFloat("WARPBOX_ANONYMOUS_MAX_UPLOAD_MB", 512), - AnonymousDailyUploadMB: envMegabytesFloat("WARPBOX_ANONYMOUS_DAILY_UPLOAD_MB", 2048), - UserDailyUploadMB: envMegabytesFloat("WARPBOX_USER_DAILY_UPLOAD_MB", 8192), + AnonymousMaxUploadMB: envMegabytesLimitFloat("WARPBOX_ANONYMOUS_MAX_UPLOAD_MB", 512), + AnonymousDailyUploadMB: envMegabytesLimitFloat("WARPBOX_ANONYMOUS_DAILY_UPLOAD_MB", 2048), + UserDailyUploadMB: envMegabytesLimitFloat("WARPBOX_USER_DAILY_UPLOAD_MB", 8192), DefaultUserStorageMB: envMegabytesFloat("WARPBOX_DEFAULT_USER_STORAGE_MB", 51200), UsageRetentionDays: envInt("WARPBOX_USAGE_RETENTION_DAYS", 30), LocalStorageMaxGB: envGigabytesFloat("WARPBOX_LOCAL_STORAGE_MAX_GB", 100), @@ -97,9 +97,9 @@ func Load() (Config, error) { if cfg.MaxUploadSize <= 0 { return Config{}, fmt.Errorf("WARPBOX_MAX_UPLOAD_SIZE_MB must be positive") } - if cfg.DefaultSettings.AnonymousMaxUploadMB <= 0 || - cfg.DefaultSettings.AnonymousDailyUploadMB <= 0 || - cfg.DefaultSettings.UserDailyUploadMB <= 0 || + if !validUnlimitedMegabyteLimit(cfg.DefaultSettings.AnonymousMaxUploadMB) || + !validUnlimitedMegabyteLimit(cfg.DefaultSettings.AnonymousDailyUploadMB) || + !validUnlimitedMegabyteLimit(cfg.DefaultSettings.UserDailyUploadMB) || cfg.DefaultSettings.DefaultUserStorageMB <= 0 || cfg.DefaultSettings.UsageRetentionDays <= 0 || cfg.DefaultSettings.LocalStorageMaxGB <= 0 || @@ -111,7 +111,7 @@ func Load() (Config, error) { cfg.DefaultSettings.UserActiveBoxes <= 0 || cfg.DefaultSettings.ShortWindowRequests <= 0 || cfg.DefaultSettings.ShortWindowSeconds <= 0 { - return Config{}, fmt.Errorf("upload policy settings must be positive") + return Config{}, fmt.Errorf("upload policy settings must be positive, with -1 allowed for upload MB limits") } return cfg, nil @@ -203,6 +203,18 @@ func envMegabytesFloat(key string, fallback float64) float64 { return parsed } +func envMegabytesLimitFloat(key string, fallback float64) float64 { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + parsed, err := parseMegabytesLimitFloat(value) + if err != nil { + return fallback + } + return parsed +} + func envGigabytesFloat(key string, fallback float64) float64 { value := strings.TrimSpace(os.Getenv(key)) if value == "" { @@ -246,6 +258,35 @@ func parseMegabytesFloat(value string) (float64, error) { return sizeMB, nil } +func parseMegabytesLimitFloat(value string) (float64, error) { + sizeMB, err := parseMegabytesFloatAllowNegativeOne(value) + if err != nil { + return 0, err + } + if !validUnlimitedMegabyteLimit(sizeMB) { + return 0, fmt.Errorf("megabyte value must be positive or -1 for unlimited") + } + return sizeMB, nil +} + +func parseMegabytesFloatAllowNegativeOne(value string) (float64, error) { + normalized := strings.TrimSpace(value) + normalized = strings.TrimSuffix(normalized, "MB") + normalized = strings.TrimSuffix(normalized, "Mb") + normalized = strings.TrimSuffix(normalized, "mb") + normalized = strings.TrimSpace(normalized) + + sizeMB, err := strconv.ParseFloat(normalized, 64) + if err != nil { + return 0, fmt.Errorf("invalid megabyte value %q: %w", value, err) + } + return sizeMB, nil +} + +func validUnlimitedMegabyteLimit(value float64) bool { + return value > 0 || value == -1 +} + func megabytesToBytes(sizeMB float64) int64 { return int64(math.Round(sizeMB * 1024 * 1024)) } diff --git a/backend/libs/handlers/accounts_test.go b/backend/libs/handlers/accounts_test.go index 8d10c4e..dc2c949 100644 --- a/backend/libs/handlers/accounts_test.go +++ b/backend/libs/handlers/accounts_test.go @@ -220,6 +220,29 @@ func TestAdminUploadBypassesMaxUploadSize(t *testing.T) { } } +func TestUnlimitedAnonymousUploadPolicyUsesNegativeOne(t *testing.T) { + app, cleanup := newTestApp(t) + defer cleanup() + + policy, err := app.settingsService.UploadPolicy() + if err != nil { + t.Fatalf("UploadPolicy returned error: %v", err) + } + policy.AnonymousMaxUploadMB = -1 + policy.AnonymousDailyUploadMB = -1 + if err := app.settingsService.UpdateUploadPolicy(policy); err != nil { + t.Fatalf("UpdateUploadPolicy returned error: %v", err) + } + + request := multipartUploadRequest(t, "/api/v1/upload", "file", "large.txt", strings.Repeat("x", int(app.uploadService.MaxUploadSize())+1)) + request.Header.Set("Accept", "application/json") + response := httptest.NewRecorder() + app.Upload(response, request) + if response.Code != http.StatusCreated { + t.Fatalf("unlimited anonymous upload status = %d, body = %s", response.Code, response.Body.String()) + } +} + func TestAnonymousUploadDisabled(t *testing.T) { app, cleanup := newTestApp(t) defer cleanup() diff --git a/backend/libs/handlers/admin.go b/backend/libs/handlers/admin.go index 071b481..ae613c2 100644 --- a/backend/libs/handlers/admin.go +++ b/backend/libs/handlers/admin.go @@ -359,15 +359,15 @@ func (a *App) AdminSettingsPost(w http.ResponseWriter, r *http.Request) { if value := r.FormValue("user_storage_backend"); value != "" { settings.UserStorageBackend = value } - if settings.AnonymousMaxUploadMB, err = services.ParseMegabytesValue(r.FormValue("anonymous_max_upload_mb")); err != nil { + if settings.AnonymousMaxUploadMB, err = services.ParseMegabytesLimitValue(r.FormValue("anonymous_max_upload_mb")); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - if settings.AnonymousDailyUploadMB, err = services.ParseMegabytesValue(r.FormValue("anonymous_daily_upload_mb")); err != nil { + if settings.AnonymousDailyUploadMB, err = services.ParseMegabytesLimitValue(r.FormValue("anonymous_daily_upload_mb")); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - if settings.UserDailyUploadMB, err = services.ParseMegabytesValue(r.FormValue("user_daily_upload_mb")); err != nil { + if settings.UserDailyUploadMB, err = services.ParseMegabytesLimitValue(r.FormValue("user_daily_upload_mb")); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -839,7 +839,7 @@ func optionalMB(value string) *float64 { if value == "" { return nil } - parsed, err := services.ParseMegabytesValue(value) + parsed, err := services.ParseMegabytesLimitValue(value) if err != nil { return nil } diff --git a/backend/libs/handlers/pages.go b/backend/libs/handlers/pages.go index 7ffb130..50220de 100644 --- a/backend/libs/handlers/pages.go +++ b/backend/libs/handlers/pages.go @@ -66,7 +66,9 @@ func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, use } policy := a.settingsService.EffectivePolicyForUser(settings, user) maxUpload := a.uploadService.MaxUploadSizeLabel() - if policy.MaxUploadMB > 0 { + if policy.MaxUploadMB < 0 { + maxUpload = "unlimited" + } else if policy.MaxUploadMB > 0 { maxUpload = services.FormatMegabytesLabel(policy.MaxUploadMB) } quota := "unlimited" diff --git a/backend/libs/handlers/upload.go b/backend/libs/handlers/upload.go index 80fd2e8..0983224 100644 --- a/backend/libs/handlers/upload.go +++ b/backend/libs/handlers/upload.go @@ -39,12 +39,14 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) { return } - if !isAdminUpload { - r.Body = http.MaxBytesReader(w, r.Body, uploadParseLimit(effectivePolicy, loggedIn, a.uploadService.MaxUploadSize())) - } parseLimit := uploadParseLimit(effectivePolicy, loggedIn, a.uploadService.MaxUploadSize()) + if !isAdminUpload && parseLimit > 0 { + r.Body = http.MaxBytesReader(w, r.Body, parseLimit) + } if isAdminUpload { parseLimit = 32 << 20 + } else if parseLimit <= 0 { + parseLimit = 32 << 20 } if err := r.ParseMultipartForm(parseLimit); err != nil { helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read") @@ -84,7 +86,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) { ObfuscateMetadata: r.FormValue("obfuscate_metadata") == "on", OwnerID: ownerID, CollectionID: collectionID, - SkipSizeLimit: isAdminUpload, + SkipSizeLimit: isAdminUpload || effectivePolicy.MaxUploadMB < 0, CreatorIP: uploadClientIP(r), StorageBackendID: effectivePolicy.StorageBackendID, }) @@ -131,7 +133,7 @@ func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bo if err != nil { return http.StatusInternalServerError, "upload usage could not be checked" } - if usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) { + if policy.DailyUploadMB > 0 && usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) { return http.StatusTooManyRequests, "anonymous daily upload limit reached" } if usage.UploadedBoxes+1 > policy.DailyBoxes { @@ -154,7 +156,7 @@ func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bo if err != nil { return http.StatusInternalServerError, "upload usage could not be checked" } - if usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) { + if policy.DailyUploadMB > 0 && usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) { return http.StatusTooManyRequests, "daily upload limit reached" } if usage.UploadedBoxes+1 > policy.DailyBoxes { @@ -214,6 +216,9 @@ func (a *App) checkStorageBackendCapacity(backendID string, settings services.Up } func uploadParseLimit(policy services.EffectiveUploadPolicy, loggedIn bool, fallback int64) int64 { + if policy.MaxUploadMB < 0 { + return -1 + } if loggedIn && policy.MaxUploadMB <= 0 { return fallback * 8 } diff --git a/backend/libs/services/auth.go b/backend/libs/services/auth.go index b66172b..ccfb94f 100644 --- a/backend/libs/services/auth.go +++ b/backend/libs/services/auth.go @@ -856,11 +856,11 @@ func VerifyPasswordHash(encoded, password string) bool { } func validateUserPolicy(policy UserPolicy) error { - if policy.MaxUploadMB != nil && *policy.MaxUploadMB < 0 { - return fmt.Errorf("max upload override cannot be negative") + if policy.MaxUploadMB != nil && *policy.MaxUploadMB < 0 && *policy.MaxUploadMB != -1 { + return fmt.Errorf("max upload override must be positive or -1 for unlimited") } - if policy.DailyUploadMB != nil && *policy.DailyUploadMB <= 0 { - return fmt.Errorf("daily upload override must be positive") + if policy.DailyUploadMB != nil && ((*policy.DailyUploadMB < 0 && *policy.DailyUploadMB != -1) || *policy.DailyUploadMB == 0) { + return fmt.Errorf("daily upload override must be positive or -1 for unlimited") } if policy.StorageQuotaMB != nil && *policy.StorageQuotaMB < 0 { return fmt.Errorf("storage quota override cannot be negative") diff --git a/backend/libs/services/auth_test.go b/backend/libs/services/auth_test.go index b1dd2b8..0769818 100644 --- a/backend/libs/services/auth_test.go +++ b/backend/libs/services/auth_test.go @@ -205,6 +205,26 @@ func TestAPITokenScopedToOwnerAndDisabledUser(t *testing.T) { } } +func TestUserPolicyAllowsNegativeOneForUnlimitedUploadLimits(t *testing.T) { + auth := newTestAuthService(t) + user, err := auth.CreateBootstrapUser("daniel", "daniel@example.test", "password123") + if err != nil { + t.Fatalf("CreateBootstrapUser returned error: %v", err) + } + + unlimited := -1.0 + if err := auth.SetUserPolicy(user.ID, UserPolicy{MaxUploadMB: &unlimited, DailyUploadMB: &unlimited}); err != nil { + t.Fatalf("SetUserPolicy rejected -1 unlimited upload limits: %v", err) + } + updated, err := auth.UserByID(user.ID) + if err != nil { + t.Fatalf("UserByID returned error: %v", err) + } + if updated.Policy.MaxUploadMB == nil || *updated.Policy.MaxUploadMB != -1 || updated.Policy.DailyUploadMB == nil || *updated.Policy.DailyUploadMB != -1 { + t.Fatalf("unlimited policy was not persisted: %+v", updated.Policy) + } +} + func newTestAuthService(t *testing.T) *AuthService { t.Helper() root := t.TempDir() diff --git a/backend/libs/services/settings.go b/backend/libs/services/settings.go index 8413d55..63f03cd 100644 --- a/backend/libs/services/settings.go +++ b/backend/libs/services/settings.go @@ -170,13 +170,13 @@ func (s *SettingsService) UploadPolicy() (UploadPolicySettings, error) { } func (s *SettingsService) withDefaultGaps(settings UploadPolicySettings) UploadPolicySettings { - if settings.AnonymousMaxUploadMB <= 0 { + if settings.AnonymousMaxUploadMB == 0 { settings.AnonymousMaxUploadMB = s.defaults.AnonymousMaxUploadMB } - if settings.AnonymousDailyUploadMB <= 0 { + if settings.AnonymousDailyUploadMB == 0 { settings.AnonymousDailyUploadMB = s.defaults.AnonymousDailyUploadMB } - if settings.UserDailyUploadMB <= 0 { + if settings.UserDailyUploadMB == 0 { settings.UserDailyUploadMB = s.defaults.UserDailyUploadMB } if settings.DefaultUserStorageMB <= 0 { @@ -370,14 +370,14 @@ func (s *SettingsService) UsageForIP(ip string, now time.Time) (UsageRecord, err } func (s *SettingsService) validate(settings UploadPolicySettings) error { - if settings.AnonymousMaxUploadMB <= 0 { - return fmt.Errorf("anonymous max upload must be positive") + if settings.AnonymousMaxUploadMB < 0 && settings.AnonymousMaxUploadMB != -1 || settings.AnonymousMaxUploadMB == 0 { + return fmt.Errorf("anonymous max upload must be positive or -1 for unlimited") } - if settings.AnonymousDailyUploadMB <= 0 { - return fmt.Errorf("anonymous daily upload must be positive") + if settings.AnonymousDailyUploadMB < 0 && settings.AnonymousDailyUploadMB != -1 || settings.AnonymousDailyUploadMB == 0 { + return fmt.Errorf("anonymous daily upload must be positive or -1 for unlimited") } - if settings.UserDailyUploadMB <= 0 { - return fmt.Errorf("user daily upload must be positive") + if settings.UserDailyUploadMB < 0 && settings.UserDailyUploadMB != -1 || settings.UserDailyUploadMB == 0 { + return fmt.Errorf("user daily upload must be positive or -1 for unlimited") } if settings.DefaultUserStorageMB <= 0 { return fmt.Errorf("default user storage must be positive") @@ -422,6 +422,32 @@ func ParseMegabytesValue(value string) (float64, error) { return parsed, nil } +func ParseMegabytesLimitValue(value string) (float64, error) { + parsed, err := parseMegabytesNumber(value) + if err != nil { + return 0, err + } + if parsed == -1 { + return -1, nil + } + if parsed <= 0 { + return 0, fmt.Errorf("megabyte value must be positive or -1 for unlimited") + } + return parsed, nil +} + +func parseMegabytesNumber(value string) (float64, error) { + value = strings.TrimSpace(value) + if value == "" { + return 0, fmt.Errorf("megabyte value is required") + } + value = strings.TrimSuffix(value, "MB") + value = strings.TrimSuffix(value, "Mb") + value = strings.TrimSuffix(value, "mb") + value = strings.TrimSpace(value) + return strconv.ParseFloat(value, 64) +} + func MegabytesToBytes(value float64) int64 { return int64(value * 1024 * 1024) } @@ -437,6 +463,9 @@ func FormatMegabytesFromBytes(value int64) string { } func FormatMegabytesLabel(value float64) string { + if value < 0 { + return "unlimited" + } return strconv.FormatFloat(value, 'f', -1, 64) + " MB" } diff --git a/backend/libs/services/settings_test.go b/backend/libs/services/settings_test.go index 71a5a99..7942998 100644 --- a/backend/libs/services/settings_test.go +++ b/backend/libs/services/settings_test.go @@ -117,6 +117,30 @@ func TestSettingsRejectInvalidMegabytes(t *testing.T) { } } +func TestUploadPolicyAllowsNegativeOneForUnlimitedUploadLimits(t *testing.T) { + settings := newTestSettingsService(t) + policy, err := settings.UploadPolicy() + if err != nil { + t.Fatalf("UploadPolicy returned error: %v", err) + } + policy.AnonymousMaxUploadMB = -1 + policy.AnonymousDailyUploadMB = -1 + policy.UserDailyUploadMB = -1 + if err := settings.UpdateUploadPolicy(policy); err != nil { + t.Fatalf("UpdateUploadPolicy rejected -1 unlimited upload limits: %v", err) + } + next, err := settings.UploadPolicy() + if err != nil { + t.Fatalf("UploadPolicy returned error: %v", err) + } + if next.AnonymousMaxUploadMB != -1 || next.AnonymousDailyUploadMB != -1 || next.UserDailyUploadMB != -1 { + t.Fatalf("unlimited upload limits were not persisted: %+v", next) + } + if got := FormatMegabytesLabel(-1); got != "unlimited" { + t.Fatalf("FormatMegabytesLabel(-1) = %q, want unlimited", got) + } +} + func TestDailyUsageAndCleanup(t *testing.T) { settings := newTestSettingsService(t) now := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC) diff --git a/backend/static/css/50-admin.css b/backend/static/css/50-admin.css index 48d90f4..7d82b84 100644 --- a/backend/static/css/50-admin.css +++ b/backend/static/css/50-admin.css @@ -22,6 +22,7 @@ } .metric-card { + min-width: 0; border: 1px solid var(--border); border-radius: var(--radius); background: rgba(24, 24, 27, 0.78); @@ -37,11 +38,26 @@ .metric-card strong { display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; margin-top: 0.4rem; color: var(--foreground); font-size: 1.35rem; } +.metric-card span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-edit-metrics { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + .admin-table-card { margin-top: 1rem; } diff --git a/backend/templates/pages/admin_settings.html b/backend/templates/pages/admin_settings.html index 7aa924f..dd0c6f8 100644 --- a/backend/templates/pages/admin_settings.html +++ b/backend/templates/pages/admin_settings.html @@ -34,7 +34,7 @@

Upload policy

-

Admin users bypass all upload caps. Values are in megabytes.

+

Admin users bypass all upload caps. Values are in megabytes; use -1 for unlimited upload size or daily upload caps.

diff --git a/backend/templates/pages/admin_user_edit.html b/backend/templates/pages/admin_user_edit.html index 2755ffb..aa7583f 100644 --- a/backend/templates/pages/admin_user_edit.html +++ b/backend/templates/pages/admin_user_edit.html @@ -38,7 +38,7 @@ {{end}} -
+
Storage used{{.Data.UserEdit.StorageUsed}}
Uploaded today{{.Data.UserEdit.DailyUsed}}
Effective quota{{.Data.UserEdit.EffectiveStorage}}
@@ -50,7 +50,7 @@

Identity and limits

-

Blank limit fields inherit the global user defaults. Storage quota set to 0 means unlimited.

+

Blank limit fields inherit the global user defaults. Use -1 for unlimited upload size or daily upload caps. Storage quota set to 0 means unlimited.