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:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
BIN
backend/static/WarpBoxLogo.png
Normal file
BIN
backend/static/WarpBoxLogo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 423 B |
@@ -52,7 +52,7 @@
|
||||
<div class="table-header">
|
||||
<div>
|
||||
<h2>Identity and limits</h2>
|
||||
<p>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.</p>
|
||||
<p>Blank limit fields inherit the global user defaults. Use <code>-1</code> 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 <code>0</code> also means unlimited.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form class="settings-form" action="/admin/users/{{.Data.UserEdit.ID}}/edit" method="post">
|
||||
@@ -89,10 +89,10 @@
|
||||
<h3 class="settings-section-title">Upload limits</h3>
|
||||
<label><span>Max upload size (MB)</span><input name="max_upload_mb" value="{{.Data.UserEdit.MaxUploadMB}}" placeholder="inherit"></label>
|
||||
<label><span>Daily upload cap (MB)</span><input name="daily_upload_mb" value="{{.Data.UserEdit.DailyUploadMB}}" placeholder="inherit"></label>
|
||||
<label><span>Max expiration (days)</span><input type="number" min="1" name="max_days" value="{{.Data.UserEdit.MaxDays}}" placeholder="inherit"></label>
|
||||
<label><span>Daily boxes</span><input type="number" min="1" name="daily_boxes" value="{{.Data.UserEdit.DailyBoxes}}" placeholder="inherit"></label>
|
||||
<label><span>Active boxes</span><input type="number" min="1" name="active_boxes" value="{{.Data.UserEdit.ActiveBoxes}}" placeholder="inherit"></label>
|
||||
<label><span>Short-window requests</span><input type="number" min="1" name="short_window_requests" value="{{.Data.UserEdit.ShortWindowRequests}}" placeholder="inherit"></label>
|
||||
<label><span>Max expiration (days)</span><input type="number" min="-1" name="max_days" value="{{.Data.UserEdit.MaxDays}}" placeholder="inherit"></label>
|
||||
<label><span>Daily boxes</span><input type="number" min="-1" name="daily_boxes" value="{{.Data.UserEdit.DailyBoxes}}" placeholder="inherit"></label>
|
||||
<label><span>Active boxes</span><input type="number" min="-1" name="active_boxes" value="{{.Data.UserEdit.ActiveBoxes}}" placeholder="inherit"></label>
|
||||
<label><span>Short-window requests</span><input type="number" min="-1" name="short_window_requests" value="{{.Data.UserEdit.ShortWindowRequests}}" placeholder="inherit"></label>
|
||||
</div>
|
||||
|
||||
<button class="button button-primary" type="submit">Save user</button>
|
||||
|
||||
Reference in New Issue
Block a user