From 01996c044539f77d60ea9bd1a295a7a9d164b15e Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Sun, 31 May 2026 22:40:48 +0300 Subject: [PATCH] feat(policy): support unlimited values in user policies and box expiry - Update user policy and user update handlers to accept -1 as an unlimited value for MaxDays, DailyBoxes, ActiveBoxes, and ShortWindowRequests. - Introduce `optionalIntAllowUnlimited` helper and update `optionalMBAllowZero` to support -1. - Use `boxExpiryLabel` helper across admin, dashboard, and download handlers to properly format expiration dates, supporting boxes that never expire. --- backend/libs/handlers/admin.go | 33 +++++++++++++------ backend/libs/handlers/dashboard.go | 2 +- backend/libs/handlers/download.go | 17 +++++++++- backend/libs/handlers/manage.go | 2 +- backend/libs/handlers/pages.go | 14 +++++++- backend/libs/handlers/upload.go | 32 +++++++++++++----- backend/libs/services/auth.go | 20 +++++------ backend/libs/services/upload.go | 19 +++++++---- backend/static/WarpBoxLogo.png | Bin 0 -> 423 bytes backend/templates/pages/admin_user_edit.html | 10 +++--- 10 files changed, 106 insertions(+), 43 deletions(-) create mode 100644 backend/static/WarpBoxLogo.png diff --git a/backend/libs/handlers/admin.go b/backend/libs/handlers/admin.go index de1c1c9..a98981c 100644 --- a/backend/libs/handlers/admin.go +++ b/backend/libs/handlers/admin.go @@ -1004,10 +1004,10 @@ func (a *App) AdminUpdateUserPolicy(w http.ResponseWriter, r *http.Request) { MaxUploadMB: optionalMB(r.FormValue("max_upload_mb")), DailyUploadMB: optionalMB(r.FormValue("daily_upload_mb")), StorageQuotaMB: optionalMBAllowZero(r.FormValue("storage_quota_mb")), - MaxDays: optionalInt(r.FormValue("max_days")), - DailyBoxes: optionalInt(r.FormValue("daily_boxes")), - ActiveBoxes: optionalInt(r.FormValue("active_boxes")), - ShortWindowRequests: optionalInt(r.FormValue("short_window_requests")), + MaxDays: optionalIntAllowUnlimited(r.FormValue("max_days")), + DailyBoxes: optionalIntAllowUnlimited(r.FormValue("daily_boxes")), + ActiveBoxes: optionalIntAllowUnlimited(r.FormValue("active_boxes")), + ShortWindowRequests: optionalIntAllowUnlimited(r.FormValue("short_window_requests")), } if backendID := r.FormValue("storage_backend_id"); backendID != "" { if _, err := a.uploadService.Storage().BackendConfig(backendID); err != nil { @@ -1036,10 +1036,10 @@ func (a *App) AdminUpdateUser(w http.ResponseWriter, r *http.Request) { MaxUploadMB: optionalMB(r.FormValue("max_upload_mb")), DailyUploadMB: optionalMB(r.FormValue("daily_upload_mb")), StorageQuotaMB: optionalMBAllowZero(r.FormValue("storage_quota_mb")), - MaxDays: optionalInt(r.FormValue("max_days")), - DailyBoxes: optionalInt(r.FormValue("daily_boxes")), - ActiveBoxes: optionalInt(r.FormValue("active_boxes")), - ShortWindowRequests: optionalInt(r.FormValue("short_window_requests")), + MaxDays: optionalIntAllowUnlimited(r.FormValue("max_days")), + DailyBoxes: optionalIntAllowUnlimited(r.FormValue("daily_boxes")), + ActiveBoxes: optionalIntAllowUnlimited(r.FormValue("active_boxes")), + ShortWindowRequests: optionalIntAllowUnlimited(r.FormValue("short_window_requests")), } if backendID := r.FormValue("storage_backend_id"); backendID != "" { if _, err := a.uploadService.Storage().BackendConfig(backendID); err != nil { @@ -1206,7 +1206,7 @@ func (a *App) adminBoxes(limit int) ([]adminBoxView, error) { ID: box.ID, Owner: owner, CreatedAt: box.CreatedAt.Format("Jan 2 15:04"), - ExpiresAt: box.ExpiresAt.Format("Jan 2 15:04"), + ExpiresAt: boxExpiryLabel(box.ExpiresAt, "Jan 2 15:04"), FileCount: box.FileCount, TotalSizeLabel: box.TotalSizeLabel, DownloadCount: box.DownloadCount, @@ -1296,7 +1296,8 @@ func optionalMBAllowZero(value string) *float64 { return nil } parsed, err := strconv.ParseFloat(value, 64) - if err != nil || parsed < 0 { + // 0 and -1 both mean unlimited; reject other negatives. + if err != nil || (parsed < 0 && parsed != -1) { return nil } return &parsed @@ -1313,6 +1314,18 @@ func optionalInt(value string) *int { return &parsed } +// optionalIntAllowUnlimited is like optionalInt but also accepts -1 (unlimited). +func optionalIntAllowUnlimited(value string) *int { + if value == "" { + return nil + } + parsed, err := strconv.Atoi(value) + if err != nil || (parsed <= 0 && parsed != -1) { + return nil + } + return &parsed +} + func formatMB(value float64) string { return strconv.FormatFloat(value, 'f', -1, 64) + " MB" } diff --git a/backend/libs/handlers/dashboard.go b/backend/libs/handlers/dashboard.go index 2aff203..7f1fad9 100644 --- a/backend/libs/handlers/dashboard.go +++ b/backend/libs/handlers/dashboard.go @@ -82,7 +82,7 @@ func (a *App) Dashboard(w http.ResponseWriter, r *http.Request) { FileCount: len(row.Box.Files), Size: row.TotalSizeLabel, CreatedAt: row.Box.CreatedAt.Format("Jan 2 15:04"), - ExpiresAt: row.Box.ExpiresAt.Format("Jan 2 15:04"), + ExpiresAt: boxExpiryLabel(row.Box.ExpiresAt, "Jan 2 15:04"), URL: "/d/" + row.Box.ID, }) } diff --git a/backend/libs/handlers/download.go b/backend/libs/handlers/download.go index 5eb8ba4..57991e5 100644 --- a/backend/libs/handlers/download.go +++ b/backend/libs/handlers/download.go @@ -78,7 +78,7 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) { } } - expiresLabel := box.ExpiresAt.Format("Jan 2, 2006 15:04 MST") + expiresLabel := boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST") title := "Shared files on Warpbox" description := fmt.Sprintf("%d file%s shared via Warpbox · expires %s", len(box.Files), plural(len(box.Files)), expiresLabel) if locked && box.Obfuscate { @@ -337,6 +337,21 @@ func unlockCookieName(boxID string) string { return "warpbox_unlock_" + strings.NewReplacer("-", "_", ".", "_").Replace(boxID) } +// neverExpires reports whether a box's expiry is far enough out to be treated as +// "forever" (set via the unlimited / -1 expiry option). +func neverExpires(t time.Time) bool { + return time.Until(t) > 50*365*24*time.Hour +} + +// boxExpiryLabel formats a box's expiry with the given layout, rendering +// "forever" boxes as "Never" instead of a meaningless far-future date. +func boxExpiryLabel(t time.Time, layout string) string { + if neverExpires(t) { + return "Never" + } + return t.Format(layout) +} + func absoluteURL(r *http.Request, path string) string { if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { return path diff --git a/backend/libs/handlers/manage.go b/backend/libs/handlers/manage.go index 95d4d93..17681e0 100644 --- a/backend/libs/handlers/manage.go +++ b/backend/libs/handlers/manage.go @@ -82,7 +82,7 @@ func (a *App) managePageData(box services.Box, token string) managePageData { Token: token, FileCount: len(box.Files), TotalSize: helpers.FormatBytes(totalSize), - ExpiresLabel: box.ExpiresAt.Format("Jan 2, 2006 15:04 MST"), + ExpiresLabel: boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04 MST"), DownloadCount: box.DownloadCount, MaxDownloads: box.MaxDownloads, Protected: a.uploadService.IsProtected(box), diff --git a/backend/libs/handlers/pages.go b/backend/libs/handlers/pages.go index cfeda39..8906e3b 100644 --- a/backend/libs/handlers/pages.go +++ b/backend/libs/handlers/pages.go @@ -75,6 +75,10 @@ func (a *App) homeExpiryOptions(settings services.UploadPolicySettings, user ser unlimited = true case loggedIn: maxDays = a.settingsService.EffectivePolicyForUser(settings, user).MaxDays + // A negative per-user MaxDays override means unlimited retention. + if maxDays < 0 { + unlimited = true + } } return buildExpiryOptions(maxDays, unlimited) } @@ -103,6 +107,10 @@ func buildExpiryOptions(maxDays int, unlimited bool) ([]expiryOption, int) { if len(options) == 0 { options = append(options, expiryOption{Minutes: capMinutes, Label: expiryLabel(capMinutes)}) } + // Unlimited uploaders can pick "never expires" (sentinel -1) after the ladder. + if unlimited { + options = append(options, expiryOption{Minutes: -1, Label: "Unlimited (never expires)"}) + } // Default to 24h when available, otherwise the smallest option offered. defaultMinutes := options[0].Minutes @@ -154,5 +162,9 @@ func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, use if policy.StorageQuotaSet { quota = services.FormatMegabytesLabel(policy.StorageQuotaMB) } - return maxUpload, "Daily cap: " + services.FormatMegabytesLabel(policy.DailyUploadMB) + " · Storage quota: " + quota + " · " + strconv.Itoa(policy.MaxDays) + " day max." + expiryLimit := strconv.Itoa(policy.MaxDays) + " day max." + if policy.MaxDays < 0 { + expiryLimit = "no expiry limit." + } + return maxUpload, "Daily cap: " + services.FormatMegabytesLabel(policy.DailyUploadMB) + " · Storage quota: " + quota + " · " + expiryLimit } diff --git a/backend/libs/handlers/upload.go b/backend/libs/handlers/upload.go index 111cecb..96e3134 100644 --- a/backend/libs/handlers/upload.go +++ b/backend/libs/handlers/upload.go @@ -36,7 +36,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) { } effectivePolicy := a.effectiveUploadPolicy(settings, user, loggedIn) rateKey := uploadRateKey(r, user, loggedIn) - if !isAdminUpload && !a.rateLimiter.Allow("upload:"+rateKey, effectivePolicy.ShortRequests, effectivePolicy.ShortWindow, time.Now().UTC()) { + if !isAdminUpload && effectivePolicy.ShortRequests > 0 && !a.rateLimiter.Allow("upload:"+rateKey, effectivePolicy.ShortRequests, effectivePolicy.ShortWindow, time.Now().UTC()) { a.logger.Warn("upload rate limited", "source", "user-upload", "severity", "warn", "code", 4290, "ip", uploadClientIP(r), "user_id", user.ID) helpers.WriteJSONError(w, http.StatusTooManyRequests, "too many upload requests, please slow down") return @@ -77,17 +77,33 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) { return } } + // Unlimited expiry: admins, or users whose effective MaxDays is negative. + unlimitedExpiry := isAdminUpload || effectivePolicy.MaxDays < 0 + maxDays := parseInt(r.FormValue("max_days")) if maxDays <= 0 { - maxDays = min(7, effectivePolicy.MaxDays) + maxDays = 7 + if effectivePolicy.MaxDays > 0 && effectivePolicy.MaxDays < maxDays { + maxDays = effectivePolicy.MaxDays + } } - if !isAdminUpload && maxDays > effectivePolicy.MaxDays { + if !unlimitedExpiry && maxDays > effectivePolicy.MaxDays { a.logger.Warn("upload rejected expiration days", "source", "user-upload", "severity", "warn", "code", 4131, "ip", uploadClientIP(r), "user_id", user.ID, "requested_days", maxDays, "max_days", effectivePolicy.MaxDays) helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays)) return } + expiresMinutes := parseInt(r.FormValue("expires_minutes")) - if expiresMinutes > 0 && !isAdminUpload && expiresMinutes > effectivePolicy.MaxDays*24*60 { + // A negative expires_minutes (or max_days) is the "never expires" request. + // Only honour it for unlimited uploaders; otherwise it's an invalid value. + if expiresMinutes < 0 || parseInt(r.FormValue("max_days")) < 0 { + if !unlimitedExpiry { + a.logger.Warn("upload rejected unlimited expiration", "source", "user-upload", "severity", "warn", "code", 4133, "ip", uploadClientIP(r), "user_id", user.ID) + helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays)) + return + } + expiresMinutes = -1 + } else if expiresMinutes > 0 && !unlimitedExpiry && expiresMinutes > effectivePolicy.MaxDays*24*60 { a.logger.Warn("upload rejected expiration minutes", "source", "user-upload", "severity", "warn", "code", 4132, "ip", uploadClientIP(r), "user_id", user.ID, "requested_minutes", expiresMinutes, "max_days", effectivePolicy.MaxDays) helpers.WriteJSONError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("expiration cannot exceed %d days", effectivePolicy.MaxDays)) return @@ -213,14 +229,14 @@ func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bo 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 { + if policy.DailyBoxes > 0 && usage.UploadedBoxes+1 > policy.DailyBoxes { return http.StatusTooManyRequests, "anonymous daily box limit reached" } activeBoxes, err := a.uploadService.ActiveBoxCountForIP(uploadClientIP(r)) if err != nil { return http.StatusInternalServerError, "active box limit could not be checked" } - if activeBoxes+1 > policy.ActiveBoxes { + if policy.ActiveBoxes > 0 && activeBoxes+1 > policy.ActiveBoxes { return http.StatusTooManyRequests, "anonymous active box limit reached" } if status, message := a.checkStorageBackendCapacity(policy.StorageBackendID, settings, totalBytes); message != "" { @@ -236,14 +252,14 @@ func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bo if policy.DailyUploadMB > 0 && usage.UploadedBytes+totalBytes > services.MegabytesToBytes(policy.DailyUploadMB) { return http.StatusTooManyRequests, "daily upload limit reached" } - if usage.UploadedBoxes+1 > policy.DailyBoxes { + if policy.DailyBoxes > 0 && usage.UploadedBoxes+1 > policy.DailyBoxes { return http.StatusTooManyRequests, "daily box limit reached" } activeBoxes, err := a.uploadService.ActiveBoxCountForUser(user.ID) if err != nil { return http.StatusInternalServerError, "active box limit could not be checked" } - if activeBoxes+1 > policy.ActiveBoxes { + if policy.ActiveBoxes > 0 && activeBoxes+1 > policy.ActiveBoxes { return http.StatusTooManyRequests, "active box limit reached" } activeStorage, err := a.uploadService.UserActiveStorageUsed(user.ID) diff --git a/backend/libs/services/auth.go b/backend/libs/services/auth.go index ccfb94f..4f785aa 100644 --- a/backend/libs/services/auth.go +++ b/backend/libs/services/auth.go @@ -862,20 +862,20 @@ func validateUserPolicy(policy UserPolicy) error { 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") + if policy.StorageQuotaMB != nil && *policy.StorageQuotaMB < 0 && *policy.StorageQuotaMB != -1 { + return fmt.Errorf("storage quota override must be 0/positive or -1 for unlimited") } - if policy.MaxDays != nil && *policy.MaxDays <= 0 { - return fmt.Errorf("expiration override must be positive") + if policy.MaxDays != nil && *policy.MaxDays <= 0 && *policy.MaxDays != -1 { + return fmt.Errorf("expiration override must be positive or -1 for unlimited") } - if policy.DailyBoxes != nil && *policy.DailyBoxes <= 0 { - return fmt.Errorf("daily box override must be positive") + if policy.DailyBoxes != nil && *policy.DailyBoxes <= 0 && *policy.DailyBoxes != -1 { + return fmt.Errorf("daily box override must be positive or -1 for unlimited") } - if policy.ActiveBoxes != nil && *policy.ActiveBoxes <= 0 { - return fmt.Errorf("active box override must be positive") + if policy.ActiveBoxes != nil && *policy.ActiveBoxes <= 0 && *policy.ActiveBoxes != -1 { + return fmt.Errorf("active box override must be positive or -1 for unlimited") } - if policy.ShortWindowRequests != nil && *policy.ShortWindowRequests <= 0 { - return fmt.Errorf("short-window request override must be positive") + if policy.ShortWindowRequests != nil && *policy.ShortWindowRequests <= 0 && *policy.ShortWindowRequests != -1 { + return fmt.Errorf("short-window request override must be positive or -1 for unlimited") } return nil } diff --git a/backend/libs/services/upload.go b/backend/libs/services/upload.go index cfbe46f..838b3d6 100644 --- a/backend/libs/services/upload.go +++ b/backend/libs/services/upload.go @@ -198,14 +198,21 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti if len(files) == 0 { return UploadResult{}, fmt.Errorf("no files were uploaded") } - if opts.MaxDays <= 0 { - opts.MaxDays = 7 - } - now := time.Now().UTC() - expiresAt := now.Add(time.Duration(opts.MaxDays) * 24 * time.Hour) - if opts.ExpiresInMinutes > 0 { + var expiresAt time.Time + switch { + case opts.ExpiresInMinutes < 0 || opts.MaxDays < 0: + // "Forever" — a date far enough out that the box effectively never + // expires. No schema change; CanDownload/cleanup keep working as-is. + expiresAt = now.AddDate(100, 0, 0) + case opts.ExpiresInMinutes > 0: expiresAt = now.Add(time.Duration(opts.ExpiresInMinutes) * time.Minute) + default: + days := opts.MaxDays + if days <= 0 { + days = 7 + } + expiresAt = now.Add(time.Duration(days) * 24 * time.Hour) } box := Box{ diff --git a/backend/static/WarpBoxLogo.png b/backend/static/WarpBoxLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..9eb5c37129acc9f34f00e19773426b27da2f5028 GIT binary patch literal 423 zcmV;Y0a*TtP))~z5QIm5j_)1?IHm<+TA)F!o2F*^}zMx8Tnx#uRfjY7I^xsw(?B_npnLxEta}_x4l;~ zVH#lcA%u`OAZ;<`dfc8>Cf2^%-QB4VODrh7J12Ey1s=kJ43boPZaIJd4NPv*EQqHpn z=W|}=8>@7)^l7%FfvxIY12h2~KbDo?RR)e2@y>yV4CF?NgPuI5UApwA`UW`KBT&sr R$D#lL002ovPDHLkV1j)~z!U%g literal 0 HcmV?d00001 diff --git a/backend/templates/pages/admin_user_edit.html b/backend/templates/pages/admin_user_edit.html index 968409f..defbf1d 100644 --- a/backend/templates/pages/admin_user_edit.html +++ b/backend/templates/pages/admin_user_edit.html @@ -52,7 +52,7 @@

Identity and limits

-

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.

+

Blank limit fields inherit the global user defaults. Use -1 for unlimited in any limit field — upload size, daily caps, storage quota, max expiration (the box can then last forever), daily boxes, active boxes, and short-window requests. Storage quota 0 also means unlimited.

@@ -89,10 +89,10 @@

Upload limits

- - - - + + + +