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.
This commit is contained in:
2026-05-31 22:40:48 +03:00
parent adb1a12dfd
commit 01996c0445
10 changed files with 106 additions and 43 deletions

View File

@@ -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"
}

View File

@@ -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,
})
}

View File

@@ -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

View File

@@ -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),

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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{