feat: add upload policies, daily limits, and storage quotas
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m8s

- Add environment variables to configure anonymous uploads, daily upload caps, and default user storage limits.
- Update config loader to parse and validate the new settings.
- Implement backend logic to track daily usage and active storage per user.
- Update README and `.env.example` to document the new settings and admin panels.
This commit is contained in:
2026-05-30 17:23:20 +03:00
parent 9a3cb90b17
commit d77f164900
29 changed files with 1432 additions and 120 deletions

View File

@@ -7,6 +7,7 @@ import (
"net/http"
"strconv"
"strings"
"time"
"warpbox.dev/backend/libs/helpers"
"warpbox.dev/backend/libs/jobs"
@@ -16,10 +17,21 @@ import (
func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
user, loggedIn := a.currentUser(r)
isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin
if !isAdminUpload {
r.Body = http.MaxBytesReader(w, r.Body, a.uploadService.MaxUploadSize()*8)
settings, err := a.settingsService.UploadPolicy()
if err != nil {
a.logger.Error("failed to load upload policy", "source", "settings", "severity", "error", "code", 5005, "error", err.Error())
helpers.WriteJSONError(w, http.StatusInternalServerError, "upload policy could not be loaded")
return
}
parseLimit := a.uploadService.MaxUploadSize() * 8
if !loggedIn && !settings.AnonymousUploadsEnabled {
helpers.WriteJSONError(w, http.StatusForbidden, "anonymous uploads are disabled")
return
}
if !isAdminUpload {
r.Body = http.MaxBytesReader(w, r.Body, uploadParseLimit(settings, loggedIn, a.uploadService.MaxUploadSize()))
}
parseLimit := uploadParseLimit(settings, loggedIn, a.uploadService.MaxUploadSize())
if isAdminUpload {
parseLimit = 32 << 20
}
@@ -29,6 +41,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
}
files := uploadFiles(r)
totalBytes := totalUploadBytes(files)
var ownerID string
var collectionID string
if loggedIn {
@@ -39,6 +52,12 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
return
}
}
if !isAdminUpload {
if status, message := a.checkUploadPolicy(r, user, loggedIn, settings, files, totalBytes); message != "" {
helpers.WriteJSONError(w, status, message)
return
}
}
result, err := a.uploadService.CreateBox(files, services.UploadOptions{
MaxDays: parseInt(r.FormValue("max_days")),
MaxDownloads: parseInt(r.FormValue("max_downloads")),
@@ -53,6 +72,14 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
return
}
if !isAdminUpload {
if err := a.recordUploadUsage(r, user, loggedIn, totalBytes); err != nil {
a.logger.Warn("failed to record upload usage", "source", "quota", "severity", "warn", "code", 4402, "error", err.Error())
}
if err := a.settingsService.CleanupUsage(time.Now().UTC(), settings.UsageRetentionDays); err != nil {
a.logger.Warn("failed to cleanup upload usage", "source", "quota", "severity", "warn", "code", 4403, "error", err.Error())
}
}
jobs.GenerateThumbnailsForBoxAsync(a.uploadService, a.logger, result.BoxID)
if wantsJSON(r) {
@@ -65,6 +92,76 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintln(w, result.BoxURL)
}
func (a *App) checkUploadPolicy(r *http.Request, user services.User, loggedIn bool, settings services.UploadPolicySettings, files []*multipart.FileHeader, totalBytes int64) (int, string) {
if len(files) == 0 {
return 0, ""
}
now := time.Now().UTC()
if !loggedIn {
anonymousMaxBytes := services.MegabytesToBytes(settings.AnonymousMaxUploadMB)
for _, file := range files {
if file.Size > anonymousMaxBytes {
return http.StatusRequestEntityTooLarge, "file exceeds anonymous upload size limit"
}
}
usage, err := a.settingsService.UsageForIP(uploadClientIP(r), now)
if err != nil {
return http.StatusInternalServerError, "upload usage could not be checked"
}
if usage.UploadedBytes+totalBytes > services.MegabytesToBytes(settings.AnonymousDailyUploadMB) {
return http.StatusTooManyRequests, "anonymous daily upload limit reached"
}
return 0, ""
}
usage, err := a.settingsService.UsageForUser(user.ID, now)
if err != nil {
return http.StatusInternalServerError, "upload usage could not be checked"
}
if usage.UploadedBytes+totalBytes > services.MegabytesToBytes(settings.UserDailyUploadMB) {
return http.StatusTooManyRequests, "daily upload limit reached"
}
activeStorage, err := a.uploadService.UserActiveStorageUsed(user.ID)
if err != nil {
return http.StatusInternalServerError, "storage quota could not be checked"
}
quotaMB := settings.DefaultUserStorageMB
if user.StorageQuotaMB != nil {
quotaMB = *user.StorageQuotaMB
}
if activeStorage+totalBytes > services.MegabytesToBytes(quotaMB) {
return http.StatusRequestEntityTooLarge, "storage quota reached"
}
return 0, ""
}
func (a *App) recordUploadUsage(r *http.Request, user services.User, loggedIn bool, totalBytes int64) error {
now := time.Now().UTC()
if loggedIn {
return a.settingsService.AddUsage("user", user.ID, totalBytes, now)
}
return a.settingsService.AddUsage("ip", uploadClientIP(r), totalBytes, now)
}
func uploadParseLimit(settings services.UploadPolicySettings, loggedIn bool, fallback int64) int64 {
if loggedIn {
return fallback * 8
}
return services.MegabytesToBytes(settings.AnonymousMaxUploadMB) * 8
}
func uploadClientIP(r *http.Request) string {
return services.ClientIP(r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
}
func totalUploadBytes(files []*multipart.FileHeader) int64 {
var total int64
for _, file := range files {
total += file.Size
}
return total
}
func parseInt(value string) int {
if value == "" {
return 0