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

@@ -10,6 +10,12 @@ WARPBOX_CLEANUP_EVERY=1h
WARPBOX_THUMBNAIL_ENABLED=true WARPBOX_THUMBNAIL_ENABLED=true
WARPBOX_THUMBNAIL_EVERY=1m WARPBOX_THUMBNAIL_EVERY=1m
WARPBOX_MAX_UPLOAD_SIZE_MB=16384 WARPBOX_MAX_UPLOAD_SIZE_MB=16384
WARPBOX_ANONYMOUS_UPLOADS_ENABLED=true
WARPBOX_ANONYMOUS_MAX_UPLOAD_MB=512
WARPBOX_ANONYMOUS_DAILY_UPLOAD_MB=2048
WARPBOX_USER_DAILY_UPLOAD_MB=8192
WARPBOX_DEFAULT_USER_STORAGE_MB=51200
WARPBOX_USAGE_RETENTION_DAYS=30
WARPBOX_READ_TIMEOUT=15s WARPBOX_READ_TIMEOUT=15s
WARPBOX_WRITE_TIMEOUT=60s WARPBOX_WRITE_TIMEOUT=60s
WARPBOX_IDLE_TIMEOUT=120s WARPBOX_IDLE_TIMEOUT=120s

View File

@@ -13,6 +13,16 @@ The default server listens on `:8080`.
Upload size limits are configured in megabytes through `WARPBOX_MAX_UPLOAD_SIZE_MB`. Upload size limits are configured in megabytes through `WARPBOX_MAX_UPLOAD_SIZE_MB`.
Fractions are supported, so `0.5Mb` is 512 KiB and `1.5Mb` is 1536 KiB. Fractions are supported, so `0.5Mb` is 512 KiB and `1.5Mb` is 1536 KiB.
Upload policy defaults are also configured in megabytes and can later be changed from
`/admin/settings`:
- `WARPBOX_ANONYMOUS_UPLOADS_ENABLED=true`
- `WARPBOX_ANONYMOUS_MAX_UPLOAD_MB=512`
- `WARPBOX_ANONYMOUS_DAILY_UPLOAD_MB=2048`
- `WARPBOX_USER_DAILY_UPLOAD_MB=8192`
- `WARPBOX_DEFAULT_USER_STORAGE_MB=51200`
- `WARPBOX_USAGE_RETENTION_DAYS=30`
Runtime data is configured with `WARPBOX_DATA_DIR` and defaults to `./data` in the dev environment. Runtime data is configured with `WARPBOX_DATA_DIR` and defaults to `./data` in the dev environment.
The dev script resolves that path from the repository root. The dev script resolves that path from the repository root.
@@ -113,6 +123,9 @@ from `examples/sharex/warpbox-anonymous.sxcu`; update `RequestURL` to match your
stored with owner and optional collection metadata. stored with owner and optional collection metadata.
- Admin users are exempt from the global max upload size on the homepage upload flow. Future - Admin users are exempt from the global max upload size on the homepage upload flow. Future
per-user quotas should apply to this same upload path rather than creating a second uploader. per-user quotas should apply to this same upload path rather than creating a second uploader.
- `/admin/settings` controls anonymous uploads, anonymous max upload size, daily upload caps, default
user storage quota, and usage retention.
- `/admin/users` shows storage/daily usage and lets admins set per-user storage quota overrides.
- Anonymous uploads, ShareX uploads, unlisted public box links, password protection, expiry, delete - Anonymous uploads, ShareX uploads, unlisted public box links, password protection, expiry, delete
tokens, thumbnails, and cleanup continue to work as before. tokens, thumbnails, and cleanup continue to work as before.
@@ -127,6 +140,8 @@ Warpbox keeps local runtime data under the configured data directory:
- `data/files/{box_id}/@thumb@{file_id}.jpg` - generated previews where available. - `data/files/{box_id}/@thumb@{file_id}.jpg` - generated previews where available.
- `data/db/warpbox.bbolt` - bbolt metadata database for boxes and file records. - `data/db/warpbox.bbolt` - bbolt metadata database for boxes and file records.
- `data/db/warpbox.bbolt` also stores users, sessions, invites, and collections. - `data/db/warpbox.bbolt` also stores users, sessions, invites, and collections.
- `data/db/warpbox.bbolt` stores upload policy settings and daily usage records keyed by plain IP
for anonymous uploads and user ID for signed-in uploads.
- `data/logs/{YYYY-MM-DD}.log` - JSONL logs, one event per line. - `data/logs/{YYYY-MM-DD}.log` - JSONL logs, one event per line.
## Static Asset Policy ## Static Asset Policy

View File

@@ -28,6 +28,16 @@ type Config struct {
ThumbnailEnabled bool ThumbnailEnabled bool
ThumbnailEvery time.Duration ThumbnailEvery time.Duration
MaxUploadSize int64 MaxUploadSize int64
DefaultSettings SettingsDefaults
}
type SettingsDefaults struct {
AnonymousUploadsEnabled bool
AnonymousMaxUploadMB float64
AnonymousDailyUploadMB float64
UserDailyUploadMB float64
DefaultUserStorageMB float64
UsageRetentionDays int
} }
func Load() (Config, error) { func Load() (Config, error) {
@@ -49,6 +59,14 @@ func Load() (Config, error) {
ThumbnailEnabled: envBool("WARPBOX_THUMBNAIL_ENABLED", true), ThumbnailEnabled: envBool("WARPBOX_THUMBNAIL_ENABLED", true),
ThumbnailEvery: envDuration("WARPBOX_THUMBNAIL_EVERY", time.Minute), ThumbnailEvery: envDuration("WARPBOX_THUMBNAIL_EVERY", time.Minute),
MaxUploadSize: envMegabytes("WARPBOX_MAX_UPLOAD_SIZE_MB", 2048), // 2 GiB default. 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),
DefaultUserStorageMB: envMegabytesFloat("WARPBOX_DEFAULT_USER_STORAGE_MB", 51200),
UsageRetentionDays: envInt("WARPBOX_USAGE_RETENTION_DAYS", 30),
},
} }
if cfg.BaseURL == "" { if cfg.BaseURL == "" {
@@ -57,6 +75,13 @@ func Load() (Config, error) {
if cfg.MaxUploadSize <= 0 { if cfg.MaxUploadSize <= 0 {
return Config{}, fmt.Errorf("WARPBOX_MAX_UPLOAD_SIZE_MB must be positive") 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 ||
cfg.DefaultSettings.DefaultUserStorageMB <= 0 ||
cfg.DefaultSettings.UsageRetentionDays <= 0 {
return Config{}, fmt.Errorf("upload policy settings must be positive")
}
return cfg, nil return cfg, nil
} }
@@ -109,6 +134,19 @@ func envBool(key string, fallback bool) bool {
return parsed return parsed
} }
func envInt(key string, fallback int) int {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return parsed
}
func envMegabytes(key string, fallback float64) int64 { func envMegabytes(key string, fallback float64) int64 {
value := strings.TrimSpace(os.Getenv(key)) value := strings.TrimSpace(os.Getenv(key))
if value == "" { if value == "" {
@@ -122,7 +160,27 @@ func envMegabytes(key string, fallback float64) int64 {
return parsed return parsed
} }
func envMegabytesFloat(key string, fallback float64) float64 {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
parsed, err := parseMegabytesFloat(value)
if err != nil {
return fallback
}
return parsed
}
func parseMegabytes(value string) (int64, error) { func parseMegabytes(value string) (int64, error) {
sizeMB, err := parseMegabytesFloat(value)
if err != nil {
return 0, err
}
return megabytesToBytes(sizeMB), nil
}
func parseMegabytesFloat(value string) (float64, error) {
normalized := strings.TrimSpace(value) normalized := strings.TrimSpace(value)
normalized = strings.TrimSuffix(normalized, "MB") normalized = strings.TrimSuffix(normalized, "MB")
normalized = strings.TrimSuffix(normalized, "Mb") normalized = strings.TrimSuffix(normalized, "Mb")
@@ -137,7 +195,7 @@ func parseMegabytes(value string) (int64, error) {
return 0, fmt.Errorf("megabyte value must be positive") return 0, fmt.Errorf("megabyte value must be positive")
} }
return megabytesToBytes(sizeMB), nil return sizeMB, nil
} }
func megabytesToBytes(sizeMB float64) int64 { func megabytesToBytes(sizeMB float64) int64 {

View File

@@ -139,6 +139,276 @@ func TestAdminUploadBypassesMaxUploadSize(t *testing.T) {
} }
} }
func TestAnonymousUploadDisabled(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
policy := testPolicy(t, app)
policy.AnonymousUploadsEnabled = false
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
request := multipartUploadRequest(t, "/api/v1/upload", "file", "note.txt", "hello")
request.Header.Set("Accept", "application/json")
response := httptest.NewRecorder()
app.Upload(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403, body = %s", response.Code, response.Body.String())
}
}
func TestAnonymousUploadLimits(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
policy := testPolicy(t, app)
policy.AnonymousMaxUploadMB = 1
policy.AnonymousDailyUploadMB = 0.001
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
large := multipartUploadRequest(t, "/api/v1/upload", "file", "large.txt", strings.Repeat("x", 2*1024*1024))
large.Header.Set("Accept", "application/json")
large.RemoteAddr = "192.0.2.10:1234"
largeResponse := httptest.NewRecorder()
app.Upload(largeResponse, large)
if largeResponse.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("large status = %d, body = %s", largeResponse.Code, largeResponse.Body.String())
}
daily := multipartUploadRequest(t, "/api/v1/upload", "file", "note.txt", strings.Repeat("x", 2048))
daily.Header.Set("Accept", "application/json")
daily.RemoteAddr = "192.0.2.10:1234"
dailyResponse := httptest.NewRecorder()
app.Upload(dailyResponse, daily)
if dailyResponse.Code != http.StatusTooManyRequests {
t.Fatalf("daily status = %d, body = %s", dailyResponse.Code, dailyResponse.Body.String())
}
}
func TestSignedInUploadQuotaAndOverride(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
user, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
invite, err := app.authService.CreateInvite("user@example.test", services.UserRoleUser, user.ID, 0)
if err != nil {
t.Fatalf("CreateInvite returned error: %v", err)
}
normal, err := app.authService.AcceptInvite(invite.Token, "user", "password123")
if err != nil {
t.Fatalf("AcceptInvite returned error: %v", err)
}
_, token, err := app.authService.Login(normal.Email, "password123")
if err != nil {
t.Fatalf("Login returned error: %v", err)
}
policy := testPolicy(t, app)
policy.DefaultUserStorageMB = 0.001
policy.UserDailyUploadMB = 8
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
request := multipartUploadRequest(t, "/api/v1/upload", "file", "quota.txt", strings.Repeat("x", 2048))
request.Header.Set("Accept", "application/json")
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
response := httptest.NewRecorder()
app.Upload(response, request)
if response.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("quota status = %d, body = %s", response.Code, response.Body.String())
}
override := 10.0
if err := app.authService.SetUserStorageQuota(normal.ID, &override); err != nil {
t.Fatalf("SetUserStorageQuota returned error: %v", err)
}
request = multipartUploadRequest(t, "/api/v1/upload", "file", "quota.txt", strings.Repeat("x", 2048))
request.Header.Set("Accept", "application/json")
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
response = httptest.NewRecorder()
app.Upload(response, request)
if response.Code != http.StatusCreated {
t.Fatalf("override status = %d, body = %s", response.Code, response.Body.String())
}
}
func TestSignedInDailyCap(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
admin, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
invite, err := app.authService.CreateInvite("user@example.test", services.UserRoleUser, admin.ID, 0)
if err != nil {
t.Fatalf("CreateInvite returned error: %v", err)
}
user, err := app.authService.AcceptInvite(invite.Token, "user", "password123")
if err != nil {
t.Fatalf("AcceptInvite returned error: %v", err)
}
_, token, err := app.authService.Login(user.Email, "password123")
if err != nil {
t.Fatalf("Login returned error: %v", err)
}
policy := testPolicy(t, app)
policy.UserDailyUploadMB = 0.001
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
request := multipartUploadRequest(t, "/api/v1/upload", "file", "daily.txt", strings.Repeat("x", 2048))
request.Header.Set("Accept", "application/json")
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
response := httptest.NewRecorder()
app.Upload(response, request)
if response.Code != http.StatusTooManyRequests {
t.Fatalf("daily status = %d, body = %s", response.Code, response.Body.String())
}
}
func TestAdminSettingsPostChangesUploadEnforcement(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
_, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
_, token, err := app.authService.Login("admin@example.test", "password123")
if err != nil {
t.Fatalf("Login returned error: %v", err)
}
settingsForm := strings.NewReader("anonymous_max_upload_mb=512&anonymous_daily_upload_mb=2048&user_daily_upload_mb=8192&default_user_storage_mb=51200&usage_retention_days=30")
settingsRequest := httptest.NewRequest(http.MethodPost, "/admin/settings", settingsForm)
settingsRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
settingsRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
settingsResponse := httptest.NewRecorder()
app.AdminSettingsPost(settingsResponse, settingsRequest)
if settingsResponse.Code != http.StatusSeeOther {
t.Fatalf("AdminSettingsPost status = %d, body = %s", settingsResponse.Code, settingsResponse.Body.String())
}
uploadRequest := multipartUploadRequest(t, "/api/v1/upload", "file", "note.txt", "hello")
uploadRequest.Header.Set("Accept", "application/json")
uploadResponse := httptest.NewRecorder()
app.Upload(uploadResponse, uploadRequest)
if uploadResponse.Code != http.StatusForbidden {
t.Fatalf("upload status = %d, want 403, body = %s", uploadResponse.Code, uploadResponse.Body.String())
}
}
func TestAdminUserQuotaPostChangesEnforcement(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
admin, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
invite, err := app.authService.CreateInvite("user@example.test", services.UserRoleUser, admin.ID, 0)
if err != nil {
t.Fatalf("CreateInvite returned error: %v", err)
}
user, err := app.authService.AcceptInvite(invite.Token, "user", "password123")
if err != nil {
t.Fatalf("AcceptInvite returned error: %v", err)
}
_, adminToken, err := app.authService.Login(admin.Email, "password123")
if err != nil {
t.Fatalf("admin Login returned error: %v", err)
}
quotaRequest := httptest.NewRequest(http.MethodPost, "/admin/users/"+user.ID+"/quota", strings.NewReader("storage_quota_mb=0.001"))
quotaRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
quotaRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
quotaRequest.SetPathValue("userID", user.ID)
quotaResponse := httptest.NewRecorder()
app.AdminUpdateUserQuota(quotaResponse, quotaRequest)
if quotaResponse.Code != http.StatusSeeOther {
t.Fatalf("AdminUpdateUserQuota status = %d, body = %s", quotaResponse.Code, quotaResponse.Body.String())
}
_, userToken, err := app.authService.Login(user.Email, "password123")
if err != nil {
t.Fatalf("user Login returned error: %v", err)
}
uploadRequest := multipartUploadRequest(t, "/api/v1/upload", "file", "quota.txt", strings.Repeat("x", 2048))
uploadRequest.Header.Set("Accept", "application/json")
uploadRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: userToken})
uploadResponse := httptest.NewRecorder()
app.Upload(uploadResponse, uploadRequest)
if uploadResponse.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("upload status = %d, want 413, body = %s", uploadResponse.Code, uploadResponse.Body.String())
}
}
func TestHomeReflectsUploadPolicySettings(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
policy := testPolicy(t, app)
policy.AnonymousMaxUploadMB = 123
policy.AnonymousDailyUploadMB = 456
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/", nil)
response := httptest.NewRecorder()
app.Home(response, request)
if response.Code != http.StatusOK {
t.Fatalf("Home status = %d", response.Code)
}
body := response.Body.String()
if !strings.Contains(body, "Max file size: 123 MB") || !strings.Contains(body, "456 MB") {
t.Fatalf("home did not reflect policy settings: %s", body)
}
}
func TestAPIDocsHeaderReflectsLoggedInUser(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
_, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
_, token, err := app.authService.Login("admin@example.test", "password123")
if err != nil {
t.Fatalf("Login returned error: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/api", nil)
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
response := httptest.NewRecorder()
app.APIDocs(response, request)
if response.Code != http.StatusOK {
t.Fatalf("APIDocs status = %d", response.Code)
}
body := response.Body.String()
header := body[:strings.Index(body, "<main")]
if !strings.Contains(header, "My Account") || strings.Contains(header, ">Login<") || strings.Contains(header, "Health") {
t.Fatalf("api header did not reflect logged-in state: %s", body)
}
}
func TestAPIDocsHeaderReflectsLoggedOutUser(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
request := httptest.NewRequest(http.MethodGet, "/api", nil)
response := httptest.NewRecorder()
app.APIDocs(response, request)
if response.Code != http.StatusOK {
t.Fatalf("APIDocs status = %d", response.Code)
}
body := response.Body.String()
header := body[:strings.Index(body, "<main")]
if !strings.Contains(header, ">Login<") || !strings.Contains(header, ">API<") || strings.Contains(header, "Health") || strings.Contains(header, "My Account") {
t.Fatalf("api header did not reflect logged-out state: %s", body)
}
}
func createOwnedBoxThroughApp(t *testing.T, app *App, userID string) services.UploadResult { func createOwnedBoxThroughApp(t *testing.T, app *App, userID string) services.UploadResult {
t.Helper() t.Helper()
user, err := app.authService.UserByID(userID) user, err := app.authService.UserByID(userID)
@@ -163,3 +433,12 @@ func createOwnedBoxThroughApp(t *testing.T, app *App, userID string) services.Up
} }
return payload return payload
} }
func testPolicy(t *testing.T, app *App) services.UploadPolicySettings {
t.Helper()
policy, err := app.settingsService.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy returned error: %v", err)
}
return policy
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/hex" "encoding/hex"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"time" "time"
"warpbox.dev/backend/libs/services" "warpbox.dev/backend/libs/services"
@@ -17,6 +18,9 @@ type adminPageData struct {
Stats services.AdminStats Stats services.AdminStats
Boxes []adminBoxView Boxes []adminBoxView
Users []adminUserView Users []adminUserView
Settings services.UploadPolicySettings
Section string
PageTitle string
LastInviteURL string LastInviteURL string
Error string Error string
} }
@@ -35,12 +39,15 @@ type adminBoxView struct {
} }
type adminUserView struct { type adminUserView struct {
ID string ID string
Username string Username string
Email string Email string
Role string Role string
Status string Status string
CreatedAt string StorageUsed string
StorageQuota string
DailyUsed string
CreatedAt string
} }
func (a *App) AdminLogin(w http.ResponseWriter, r *http.Request) { func (a *App) AdminLogin(w http.ResponseWriter, r *http.Request) {
@@ -48,17 +55,17 @@ func (a *App) AdminLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin", http.StatusSeeOther) http.Redirect(w, r, "/admin", http.StatusSeeOther)
return return
} }
a.renderAdminLogin(w, http.StatusOK, "") a.renderAdminLogin(w, r, http.StatusOK, "")
} }
func (a *App) AdminLoginPost(w http.ResponseWriter, r *http.Request) { func (a *App) AdminLoginPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
a.renderAdminLogin(w, http.StatusBadRequest, "Unable to read login form.") a.renderAdminLogin(w, r, http.StatusBadRequest, "Unable to read login form.")
return return
} }
if a.cfg.AdminToken == "" || r.FormValue("token") != a.cfg.AdminToken { if a.cfg.AdminToken == "" || r.FormValue("token") != a.cfg.AdminToken {
a.logger.Warn("admin login failed", "source", "admin", "severity", "warn", "code", 4301) a.logger.Warn("admin login failed", "source", "admin", "severity", "warn", "code", 4301)
a.renderAdminLogin(w, http.StatusUnauthorized, "Invalid admin token.") a.renderAdminLogin(w, r, http.StatusUnauthorized, "Invalid admin token.")
return return
} }
@@ -104,13 +111,15 @@ func (a *App) AdminDashboard(w http.ResponseWriter, r *http.Request) {
return return
} }
a.renderer.Render(w, http.StatusOK, "admin.html", web.PageData{ a.renderPage(w, r, http.StatusOK, "admin.html", web.PageData{
Title: "Admin overview", Title: "Admin overview",
Description: "Warpbox admin overview.", Description: "Warpbox admin overview.",
CurrentUser: a.currentPublicUser(r), CurrentUser: a.currentPublicUser(r),
Data: adminPageData{ Data: adminPageData{
Stats: stats, Stats: stats,
Boxes: boxes, Boxes: boxes,
Section: "overview",
PageTitle: "Admin overview",
}, },
}) })
} }
@@ -131,13 +140,15 @@ func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) {
return return
} }
a.renderer.Render(w, http.StatusOK, "admin.html", web.PageData{ a.renderPage(w, r, http.StatusOK, "admin.html", web.PageData{
Title: "Admin files", Title: "Admin files",
Description: "Manage Warpbox uploads.", Description: "Manage Warpbox uploads.",
CurrentUser: a.currentPublicUser(r), CurrentUser: a.currentPublicUser(r),
Data: adminPageData{ Data: adminPageData{
Stats: stats, Stats: stats,
Boxes: boxes, Boxes: boxes,
Section: "files",
PageTitle: "Admin files",
}, },
}) })
} }
@@ -157,28 +168,129 @@ func (a *App) AdminUsers(w http.ResponseWriter, r *http.Request) {
return return
} }
rows := make([]adminUserView, 0, len(users)) rows := make([]adminUserView, 0, len(users))
settings, err := a.settingsService.UploadPolicy()
if err != nil {
http.Error(w, "unable to load settings", http.StatusInternalServerError)
return
}
for _, user := range users { for _, user := range users {
storageUsed, _ := a.uploadService.UserActiveStorageUsed(user.ID)
usage, _ := a.settingsService.UsageForUser(user.ID, time.Now().UTC())
quotaMB := settings.DefaultUserStorageMB
if user.StorageQuotaMB != nil {
quotaMB = *user.StorageQuotaMB
}
rows = append(rows, adminUserView{ rows = append(rows, adminUserView{
ID: user.ID, ID: user.ID,
Username: user.Username, Username: user.Username,
Email: user.Email, Email: user.Email,
Role: user.Role, Role: user.Role,
Status: user.Status, Status: user.Status,
CreatedAt: user.CreatedAt.Format("Jan 2 15:04"), StorageUsed: services.FormatMegabytesFromBytes(storageUsed),
StorageQuota: formatMB(quotaMB),
DailyUsed: services.FormatMegabytesFromBytes(usage.UploadedBytes),
CreatedAt: user.CreatedAt.Format("Jan 2 15:04"),
}) })
} }
a.renderer.Render(w, http.StatusOK, "admin_users.html", web.PageData{ a.renderPage(w, r, http.StatusOK, "admin_users.html", web.PageData{
Title: "Admin users", Title: "Admin users",
Description: "Manage Warpbox users and invites.", Description: "Manage Warpbox users and invites.",
CurrentUser: a.currentPublicUser(r), CurrentUser: a.currentPublicUser(r),
Data: adminPageData{ Data: adminPageData{
Stats: stats, Stats: stats,
Users: rows, Users: rows,
Section: "users",
PageTitle: "Users",
LastInviteURL: r.URL.Query().Get("invite"), LastInviteURL: r.URL.Query().Get("invite"),
}, },
}) })
} }
func (a *App) AdminSettings(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
settings, err := a.settingsService.UploadPolicy()
if err != nil {
http.Error(w, "unable to load settings", http.StatusInternalServerError)
return
}
a.renderPage(w, r, http.StatusOK, "admin_settings.html", web.PageData{
Title: "Admin settings",
Description: "Manage Warpbox upload policy.",
CurrentUser: a.currentPublicUser(r),
Data: adminPageData{
Settings: settings,
Section: "settings",
PageTitle: "Settings",
},
})
}
func (a *App) AdminSettingsPost(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
return
}
settings := services.UploadPolicySettings{
AnonymousUploadsEnabled: r.FormValue("anonymous_uploads_enabled") == "on",
UsageRetentionDays: parsePositiveInt(r.FormValue("usage_retention_days")),
}
var err error
if settings.AnonymousMaxUploadMB, err = services.ParseMegabytesValue(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 {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if settings.UserDailyUploadMB, err = services.ParseMegabytesValue(r.FormValue("user_daily_upload_mb")); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if settings.DefaultUserStorageMB, err = services.ParseMegabytesValue(r.FormValue("default_user_storage_mb")); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if settings.UsageRetentionDays <= 0 {
http.Error(w, "usage retention days must be positive", http.StatusBadRequest)
return
}
if err := a.settingsService.UpdateUploadPolicy(settings); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
}
func (a *App) AdminUpdateUserQuota(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
return
}
var quota *float64
if r.FormValue("storage_quota_mb") != "" {
parsed, err := services.ParseMegabytesValue(r.FormValue("storage_quota_mb"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
quota = &parsed
}
if err := a.authService.SetUserStorageQuota(r.PathValue("userID"), quota); err != nil {
http.Error(w, "unable to update quota", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
}
func (a *App) AdminCreateInvite(w http.ResponseWriter, r *http.Request) { func (a *App) AdminCreateInvite(w http.ResponseWriter, r *http.Request) {
admin, ok := a.requireAdminUser(w, r) admin, ok := a.requireAdminUser(w, r)
if !ok { if !ok {
@@ -263,8 +375,8 @@ func (a *App) AdminViewBox(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/d/"+box.ID, http.StatusSeeOther) http.Redirect(w, r, "/d/"+box.ID, http.StatusSeeOther)
} }
func (a *App) renderAdminLogin(w http.ResponseWriter, status int, message string) { func (a *App) renderAdminLogin(w http.ResponseWriter, r *http.Request, status int, message string) {
a.renderer.Render(w, status, "admin_login.html", web.PageData{ a.renderPage(w, r, status, "admin_login.html", web.PageData{
Title: "Admin login", Title: "Admin login",
Description: "Sign in to the Warpbox admin console.", Description: "Sign in to the Warpbox admin console.",
Data: adminPageData{ Data: adminPageData{
@@ -350,3 +462,15 @@ func adminCookieValue(token string) string {
sum := sha256.Sum256([]byte("warpbox-admin:" + token)) sum := sha256.Sum256([]byte("warpbox-admin:" + token))
return hex.EncodeToString(sum[:]) return hex.EncodeToString(sum[:])
} }
func parsePositiveInt(value string) int {
parsed, err := strconv.Atoi(value)
if err != nil {
return 0
}
return parsed
}
func formatMB(value float64) string {
return strconv.FormatFloat(value, 'f', -1, 64) + " MB"
}

View File

@@ -20,7 +20,7 @@ type apiDocsData struct {
} }
func (a *App) APIDocs(w http.ResponseWriter, r *http.Request) { func (a *App) APIDocs(w http.ResponseWriter, r *http.Request) {
a.renderer.Render(w, http.StatusOK, "api.html", web.PageData{ a.renderPage(w, r, http.StatusOK, "api.html", web.PageData{
Title: "API documentation", Title: "API documentation",
Description: "Curl and ShareX upload examples for Warpbox.", Description: "Curl and ShareX upload examples for Warpbox.",
Data: apiDocsData{ Data: apiDocsData{

View File

@@ -10,23 +10,32 @@ import (
) )
type App struct { type App struct {
cfg config.Config cfg config.Config
logger *slog.Logger logger *slog.Logger
renderer *web.Renderer renderer *web.Renderer
uploadService *services.UploadService uploadService *services.UploadService
authService *services.AuthService authService *services.AuthService
settingsService *services.SettingsService
} }
func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService) *App { func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService, authService *services.AuthService, settingsService *services.SettingsService) *App {
return &App{ return &App{
cfg: cfg, cfg: cfg,
logger: logger, logger: logger,
renderer: renderer, renderer: renderer,
uploadService: uploadService, uploadService: uploadService,
authService: authService, authService: authService,
settingsService: settingsService,
} }
} }
func (a *App) renderPage(w http.ResponseWriter, r *http.Request, status int, page string, data web.PageData) {
if data.CurrentUser == nil {
data.CurrentUser = a.currentPublicUser(r)
}
a.renderer.Render(w, status, page, data)
}
func (a *App) RegisterRoutes(mux *http.ServeMux) { func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /", a.Home) mux.HandleFunc("GET /", a.Home)
mux.HandleFunc("GET /api", a.APIDocs) mux.HandleFunc("GET /api", a.APIDocs)
@@ -50,9 +59,12 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /admin", a.AdminDashboard) mux.HandleFunc("GET /admin", a.AdminDashboard)
mux.HandleFunc("GET /admin/files", a.AdminFiles) mux.HandleFunc("GET /admin/files", a.AdminFiles)
mux.HandleFunc("GET /admin/users", a.AdminUsers) mux.HandleFunc("GET /admin/users", a.AdminUsers)
mux.HandleFunc("GET /admin/settings", a.AdminSettings)
mux.HandleFunc("POST /admin/settings", a.AdminSettingsPost)
mux.HandleFunc("POST /admin/invites", a.AdminCreateInvite) mux.HandleFunc("POST /admin/invites", a.AdminCreateInvite)
mux.HandleFunc("POST /admin/users/{userID}/disable", a.AdminDisableUser) mux.HandleFunc("POST /admin/users/{userID}/disable", a.AdminDisableUser)
mux.HandleFunc("POST /admin/users/{userID}/reset", a.AdminResetUser) mux.HandleFunc("POST /admin/users/{userID}/reset", a.AdminResetUser)
mux.HandleFunc("POST /admin/users/{userID}/quota", a.AdminUpdateUserQuota)
mux.HandleFunc("GET /admin/boxes/{boxID}/view", a.AdminViewBox) mux.HandleFunc("GET /admin/boxes/{boxID}/view", a.AdminViewBox)
mux.HandleFunc("POST /admin/boxes/{boxID}/delete", a.AdminDeleteBox) mux.HandleFunc("POST /admin/boxes/{boxID}/delete", a.AdminDeleteBox)
mux.HandleFunc("GET /d/{boxID}", a.DownloadPage) mux.HandleFunc("GET /d/{boxID}", a.DownloadPage)

View File

@@ -29,17 +29,17 @@ func (a *App) Register(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/login", http.StatusSeeOther) http.Redirect(w, r, "/login", http.StatusSeeOther)
return return
} }
a.renderAuth(w, http.StatusOK, authPageData{Mode: "register"}) a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "register"})
} }
func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) { func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
a.renderAuth(w, http.StatusBadRequest, authPageData{Mode: "register", Error: "Unable to read form."}) a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "register", Error: "Unable to read form."})
return return
} }
user, err := a.authService.CreateBootstrapUser(r.FormValue("username"), r.FormValue("email"), r.FormValue("password")) user, err := a.authService.CreateBootstrapUser(r.FormValue("username"), r.FormValue("email"), r.FormValue("password"))
if err != nil { if err != nil {
a.renderAuth(w, http.StatusBadRequest, authPageData{Mode: "register", Error: err.Error()}) a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "register", Error: err.Error()})
return return
} }
a.logger.Info("first admin created", "source", "auth", "severity", "user_activity", "code", 2401, "user_id", user.ID) a.logger.Info("first admin created", "source", "auth", "severity", "user_activity", "code", 2401, "user_id", user.ID)
@@ -51,12 +51,12 @@ func (a *App) Login(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/app", http.StatusSeeOther) http.Redirect(w, r, "/app", http.StatusSeeOther)
return return
} }
a.renderAuth(w, http.StatusOK, authPageData{Mode: "login", ReturnPath: r.URL.Query().Get("next")}) a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "login", ReturnPath: r.URL.Query().Get("next")})
} }
func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) { func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
a.renderAuth(w, http.StatusBadRequest, authPageData{Mode: "login", Error: "Unable to read form."}) a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "login", Error: "Unable to read form."})
return return
} }
next := r.FormValue("next") next := r.FormValue("next")
@@ -66,7 +66,7 @@ func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
user, token, err := a.authService.Login(r.FormValue("email"), r.FormValue("password")) user, token, err := a.authService.Login(r.FormValue("email"), r.FormValue("password"))
if err != nil { if err != nil {
a.logger.Warn("login failed", "source", "auth", "severity", "warn", "code", 4401, "email", r.FormValue("email")) a.logger.Warn("login failed", "source", "auth", "severity", "warn", "code", 4401, "email", r.FormValue("email"))
a.renderAuth(w, http.StatusUnauthorized, authPageData{Mode: "login", Error: "Invalid email or password.", ReturnPath: next}) a.renderAuth(w, r, http.StatusUnauthorized, authPageData{Mode: "login", Error: "Invalid email or password.", ReturnPath: next})
return return
} }
a.setUserSessionCookie(w, r, token) a.setUserSessionCookie(w, r, token)
@@ -85,26 +85,26 @@ func (a *App) Logout(w http.ResponseWriter, r *http.Request) {
func (a *App) Invite(w http.ResponseWriter, r *http.Request) { func (a *App) Invite(w http.ResponseWriter, r *http.Request) {
invite, err := a.authService.InviteByToken(r.PathValue("token")) invite, err := a.authService.InviteByToken(r.PathValue("token"))
if err != nil || invite.UsedAt != nil || time.Now().UTC().After(invite.ExpiresAt) { if err != nil || invite.UsedAt != nil || time.Now().UTC().After(invite.ExpiresAt) {
a.renderAuth(w, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."}) a.renderAuth(w, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."})
return return
} }
a.renderAuth(w, http.StatusOK, authPageData{Mode: "invite", Token: r.PathValue("token"), Email: invite.Email, IsReset: invite.UserID != ""}) a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "invite", Token: r.PathValue("token"), Email: invite.Email, IsReset: invite.UserID != ""})
} }
func (a *App) InvitePost(w http.ResponseWriter, r *http.Request) { func (a *App) InvitePost(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token") token := r.PathValue("token")
invite, err := a.authService.InviteByToken(token) invite, err := a.authService.InviteByToken(token)
if err != nil { if err != nil {
a.renderAuth(w, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."}) a.renderAuth(w, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."})
return return
} }
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
a.renderAuth(w, http.StatusBadRequest, authPageData{Mode: "invite", Token: token, Email: invite.Email, IsReset: invite.UserID != "", Error: "Unable to read form."}) a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "invite", Token: token, Email: invite.Email, IsReset: invite.UserID != "", Error: "Unable to read form."})
return return
} }
user, err := a.authService.AcceptInvite(token, r.FormValue("username"), r.FormValue("password")) user, err := a.authService.AcceptInvite(token, r.FormValue("username"), r.FormValue("password"))
if err != nil { if err != nil {
a.renderAuth(w, http.StatusBadRequest, authPageData{Mode: "invite", Token: token, Email: invite.Email, IsReset: invite.UserID != "", Error: err.Error()}) a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "invite", Token: token, Email: invite.Email, IsReset: invite.UserID != "", Error: err.Error()})
return return
} }
a.logger.Info("invite accepted", "source", "auth", "severity", "user_activity", "code", 2403, "user_id", user.ID) a.logger.Info("invite accepted", "source", "auth", "severity", "user_activity", "code", 2403, "user_id", user.ID)
@@ -116,7 +116,7 @@ func (a *App) AccountSettings(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
a.renderer.Render(w, http.StatusOK, "account.html", web.PageData{ a.renderPage(w, r, http.StatusOK, "account.html", web.PageData{
Title: "Account settings", Title: "Account settings",
Description: "Manage your Warpbox account.", Description: "Manage your Warpbox account.",
CurrentUser: a.authService.PublicUser(user), CurrentUser: a.authService.PublicUser(user),
@@ -144,8 +144,8 @@ func (a *App) ChangePassword(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/account/settings", http.StatusSeeOther) http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
} }
func (a *App) renderAuth(w http.ResponseWriter, status int, data authPageData) { func (a *App) renderAuth(w http.ResponseWriter, r *http.Request, status int, data authPageData) {
a.renderer.Render(w, status, "auth.html", web.PageData{ a.renderPage(w, r, status, "auth.html", web.PageData{
Title: "Account", Title: "Account",
Description: "Sign in to Warpbox.", Description: "Sign in to Warpbox.",
Data: data, Data: data,

View File

@@ -87,7 +87,7 @@ func (a *App) Dashboard(w http.ResponseWriter, r *http.Request) {
}) })
} }
a.renderer.Render(w, http.StatusOK, "dashboard.html", web.PageData{ a.renderPage(w, r, http.StatusOK, "dashboard.html", web.PageData{
Title: "My files", Title: "My files",
Description: "Your Warpbox personal file space.", Description: "Your Warpbox personal file space.",
CurrentUser: a.authService.PublicUser(user), CurrentUser: a.authService.PublicUser(user),

View File

@@ -55,7 +55,7 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := a.uploadService.CanDownload(box); err != nil { if err := a.uploadService.CanDownload(box); err != nil {
a.renderer.Render(w, http.StatusForbidden, "download.html", web.PageData{ a.renderPage(w, r, http.StatusForbidden, "download.html", web.PageData{
Title: "Download unavailable", Title: "Download unavailable",
Description: "This Warpbox link is no longer available.", Description: "This Warpbox link is no longer available.",
Data: downloadPageData{ Data: downloadPageData{
@@ -74,7 +74,7 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) {
} }
} }
a.renderer.Render(w, http.StatusOK, "download.html", web.PageData{ a.renderPage(w, r, http.StatusOK, "download.html", web.PageData{
Title: "Download files", Title: "Download files",
Description: "Download files shared through Warpbox.", Description: "Download files shared through Warpbox.",
Data: downloadPageData{ Data: downloadPageData{
@@ -107,7 +107,7 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) {
imageURL = absoluteURL(r, "/static/img/file-placeholder.webp") imageURL = absoluteURL(r, "/static/img/file-placeholder.webp")
} }
a.renderer.Render(w, http.StatusOK, "preview.html", web.PageData{ a.renderPage(w, r, http.StatusOK, "preview.html", web.PageData{
Title: title, Title: title,
Description: description, Description: description,
ImageURL: imageURL, ImageURL: imageURL,

View File

@@ -26,7 +26,7 @@ func (a *App) ManageBox(w http.ResponseWriter, r *http.Request) {
return return
} }
a.renderer.Render(w, http.StatusOK, "manage.html", web.PageData{ a.renderPage(w, r, http.StatusOK, "manage.html", web.PageData{
Title: "Manage upload", Title: "Manage upload",
Description: "Delete this anonymous Warpbox upload.", Description: "Delete this anonymous Warpbox upload.",
Data: a.managePageData(box, r.PathValue("token")), Data: a.managePageData(box, r.PathValue("token")),
@@ -48,7 +48,7 @@ func (a *App) ManageDeleteBox(w http.ResponseWriter, r *http.Request) {
} }
func (a *App) ManageDeleted(w http.ResponseWriter, r *http.Request) { func (a *App) ManageDeleted(w http.ResponseWriter, r *http.Request) {
a.renderer.Render(w, http.StatusOK, "manage_deleted.html", web.PageData{ a.renderPage(w, r, http.StatusOK, "manage_deleted.html", web.PageData{
Title: "Upload deleted", Title: "Upload deleted",
Description: "This Warpbox upload has been deleted.", Description: "This Warpbox upload has been deleted.",
Data: boxView{ID: r.PathValue("boxID")}, Data: boxView{ID: r.PathValue("boxID")},

View File

@@ -9,15 +9,21 @@ import (
type homeData struct { type homeData struct {
MaxUploadSize string MaxUploadSize string
LimitSummary string
Collections []collectionView Collections []collectionView
IsAdmin bool IsAdmin bool
AnonymousOpen bool
} }
func (a *App) Home(w http.ResponseWriter, r *http.Request) { func (a *App) Home(w http.ResponseWriter, r *http.Request) {
currentUser := a.currentPublicUser(r) currentUser := a.currentPublicUser(r)
var collections []collectionView var collections []collectionView
var isAdmin bool var isAdmin bool
if user, ok := a.currentUser(r); ok { var user services.User
var loggedIn bool
if current, ok := a.currentUser(r); ok {
user = current
loggedIn = true
isAdmin = user.Role == services.UserRoleAdmin isAdmin = user.Role == services.UserRoleAdmin
userCollections, err := a.authService.ListCollections(user.ID) userCollections, err := a.authService.ListCollections(user.ID)
if err == nil { if err == nil {
@@ -27,14 +33,39 @@ func (a *App) Home(w http.ResponseWriter, r *http.Request) {
} }
} }
} }
a.renderer.Render(w, http.StatusOK, "home.html", web.PageData{ settings, err := a.settingsService.UploadPolicy()
if err != nil {
http.Error(w, "unable to load upload policy", http.StatusInternalServerError)
return
}
maxUploadSize, limitSummary := a.homeUploadPolicyLabels(settings, user, loggedIn, isAdmin)
a.renderPage(w, r, http.StatusOK, "home.html", web.PageData{
Title: "Upload your files", Title: "Upload your files",
Description: "Upload and share files through a self-hosted Warpbox instance.", Description: "Upload and share files through a self-hosted Warpbox instance.",
CurrentUser: currentUser, CurrentUser: currentUser,
Data: homeData{ Data: homeData{
MaxUploadSize: a.uploadService.MaxUploadSizeLabel(), MaxUploadSize: maxUploadSize,
LimitSummary: limitSummary,
Collections: collections, Collections: collections,
IsAdmin: isAdmin, IsAdmin: isAdmin,
AnonymousOpen: settings.AnonymousUploadsEnabled,
}, },
}) })
} }
func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, user services.User, loggedIn, isAdmin bool) (string, string) {
if isAdmin {
return "No file size limit", "Admin uploads bypass storage and daily caps."
}
if !loggedIn {
if !settings.AnonymousUploadsEnabled {
return "Anonymous uploads disabled", "Sign in to upload files."
}
return services.FormatMegabytesLabel(settings.AnonymousMaxUploadMB), "Daily anonymous cap: " + services.FormatMegabytesLabel(settings.AnonymousDailyUploadMB) + " per IP."
}
quotaMB := settings.DefaultUserStorageMB
if user.StorageQuotaMB != nil {
quotaMB = *user.StorageQuotaMB
}
return a.uploadService.MaxUploadSizeLabel(), "Daily cap: " + services.FormatMegabytesLabel(settings.UserDailyUploadMB) + " · Storage quota: " + services.FormatMegabytesLabel(quotaMB) + "."
}

View File

@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
"warpbox.dev/backend/libs/helpers" "warpbox.dev/backend/libs/helpers"
"warpbox.dev/backend/libs/jobs" "warpbox.dev/backend/libs/jobs"
@@ -16,10 +17,21 @@ import (
func (a *App) Upload(w http.ResponseWriter, r *http.Request) { func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
user, loggedIn := a.currentUser(r) user, loggedIn := a.currentUser(r)
isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin
if !isAdminUpload { settings, err := a.settingsService.UploadPolicy()
r.Body = http.MaxBytesReader(w, r.Body, a.uploadService.MaxUploadSize()*8) 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 { if isAdminUpload {
parseLimit = 32 << 20 parseLimit = 32 << 20
} }
@@ -29,6 +41,7 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
} }
files := uploadFiles(r) files := uploadFiles(r)
totalBytes := totalUploadBytes(files)
var ownerID string var ownerID string
var collectionID string var collectionID string
if loggedIn { if loggedIn {
@@ -39,6 +52,12 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
return 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{ result, err := a.uploadService.CreateBox(files, services.UploadOptions{
MaxDays: parseInt(r.FormValue("max_days")), MaxDays: parseInt(r.FormValue("max_days")),
MaxDownloads: parseInt(r.FormValue("max_downloads")), 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()) helpers.WriteJSONError(w, http.StatusBadRequest, err.Error())
return 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) jobs.GenerateThumbnailsForBoxAsync(a.uploadService, a.logger, result.BoxID)
if wantsJSON(r) { if wantsJSON(r) {
@@ -65,6 +92,76 @@ func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintln(w, result.BoxURL) _, _ = 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 { func parseInt(value string) int {
if value == "" { if value == "" {
return 0 return 0

View File

@@ -184,6 +184,14 @@ func newTestApp(t *testing.T) (*App, func()) {
StaticDir: staticDir, StaticDir: staticDir,
TemplateDir: templateDir, TemplateDir: templateDir,
MaxUploadSize: 1024 * 1024, MaxUploadSize: 1024 * 1024,
DefaultSettings: config.SettingsDefaults{
AnonymousUploadsEnabled: true,
AnonymousMaxUploadMB: 1,
AnonymousDailyUploadMB: 8,
UserDailyUploadMB: 8,
DefaultUserStorageMB: 16,
UsageRetentionDays: 30,
},
} }
service, err := services.NewUploadService(cfg.MaxUploadSize, cfg.DataDir, cfg.BaseURL, logger) service, err := services.NewUploadService(cfg.MaxUploadSize, cfg.DataDir, cfg.BaseURL, logger)
if err != nil { if err != nil {
@@ -199,7 +207,12 @@ func newTestApp(t *testing.T) (*App, func()) {
service.Close() service.Close()
t.Fatalf("NewAuthService returned error: %v", err) t.Fatalf("NewAuthService returned error: %v", err)
} }
return NewApp(cfg, logger, renderer, service, authService), func() { settingsService, err := services.NewSettingsService(service.DB(), cfg.DefaultSettings)
if err != nil {
service.Close()
t.Fatalf("NewSettingsService returned error: %v", err)
}
return NewApp(cfg, logger, renderer, service, authService, settingsService), func() {
if err := service.Close(); err != nil { if err := service.Close(); err != nil {
t.Fatalf("Close returned error: %v", err) t.Fatalf("Close returned error: %v", err)
} }

View File

@@ -27,8 +27,13 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) {
uploadService.Close() uploadService.Close()
return nil, err return nil, err
} }
settingsService, err := services.NewSettingsService(uploadService.DB(), cfg.DefaultSettings)
if err != nil {
uploadService.Close()
return nil, err
}
stopJobs := jobs.StartAll(cfg, logger, uploadService) stopJobs := jobs.StartAll(cfg, logger, uploadService)
app := handlers.NewApp(cfg, logger, renderer, uploadService, authService) app := handlers.NewApp(cfg, logger, renderer, uploadService, authService, settingsService)
router := http.NewServeMux() router := http.NewServeMux()
app.RegisterRoutes(router) app.RegisterRoutes(router)

View File

@@ -48,23 +48,25 @@ type AuthService struct {
} }
type User struct { type User struct {
ID string `json:"id"` ID string `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Email string `json:"email"` Email string `json:"email"`
PasswordHash string `json:"passwordHash"` PasswordHash string `json:"passwordHash"`
Role string `json:"role"` Role string `json:"role"`
Status string `json:"status"` Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"` StorageQuotaMB *float64 `json:"storageQuotaMb,omitempty"`
UpdatedAt time.Time `json:"updatedAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
} }
type PublicUser struct { type PublicUser struct {
ID string ID string
Username string Username string
Email string Email string
Role string Role string
Status string Status string
CreatedAt time.Time StorageQuotaMB *float64
CreatedAt time.Time
} }
type Session struct { type Session struct {
@@ -366,6 +368,19 @@ func (s *AuthService) SetPassword(userID, password string) error {
return s.saveUser(user) return s.saveUser(user)
} }
func (s *AuthService) SetUserStorageQuota(userID string, quotaMB *float64) error {
if quotaMB != nil && *quotaMB <= 0 {
return fmt.Errorf("storage quota must be positive")
}
user, err := s.UserByID(userID)
if err != nil {
return err
}
user.StorageQuotaMB = quotaMB
user.UpdatedAt = time.Now().UTC()
return s.saveUser(user)
}
func (s *AuthService) UserByID(id string) (User, error) { func (s *AuthService) UserByID(id string) (User, error) {
var user User var user User
err := s.db.View(func(tx *bbolt.Tx) error { err := s.db.View(func(tx *bbolt.Tx) error {
@@ -455,12 +470,13 @@ func (s *AuthService) CollectionByID(id string) (Collection, error) {
func (s *AuthService) PublicUser(user User) PublicUser { func (s *AuthService) PublicUser(user User) PublicUser {
return PublicUser{ return PublicUser{
ID: user.ID, ID: user.ID,
Username: user.Username, Username: user.Username,
Email: user.Email, Email: user.Email,
Role: user.Role, Role: user.Role,
Status: user.Status, Status: user.Status,
CreatedAt: user.CreatedAt, StorageQuotaMB: user.StorageQuotaMB,
CreatedAt: user.CreatedAt,
} }
} }

View File

@@ -0,0 +1,245 @@
package services
import (
"encoding/json"
"fmt"
"net"
"strconv"
"strings"
"time"
"go.etcd.io/bbolt"
"warpbox.dev/backend/libs/config"
)
var (
settingsBucket = []byte("settings")
usageBucket = []byte("usage")
)
var settingsKey = []byte("upload_policy")
type UploadPolicySettings struct {
AnonymousUploadsEnabled bool `json:"anonymousUploadsEnabled"`
AnonymousMaxUploadMB float64 `json:"anonymousMaxUploadMb"`
AnonymousDailyUploadMB float64 `json:"anonymousDailyUploadMb"`
UserDailyUploadMB float64 `json:"userDailyUploadMb"`
DefaultUserStorageMB float64 `json:"defaultUserStorageMb"`
UsageRetentionDays int `json:"usageRetentionDays"`
}
type UsageRecord struct {
Key string `json:"key"`
SubjectType string `json:"subjectType"`
Subject string `json:"subject"`
Date string `json:"date"`
UploadedBytes int64 `json:"uploadedBytes"`
UpdatedAt time.Time `json:"updatedAt"`
}
type SettingsService struct {
db *bbolt.DB
defaults UploadPolicySettings
}
func NewSettingsService(db *bbolt.DB, defaults config.SettingsDefaults) (*SettingsService, error) {
service := &SettingsService{
db: db,
defaults: UploadPolicySettings{
AnonymousUploadsEnabled: defaults.AnonymousUploadsEnabled,
AnonymousMaxUploadMB: defaults.AnonymousMaxUploadMB,
AnonymousDailyUploadMB: defaults.AnonymousDailyUploadMB,
UserDailyUploadMB: defaults.UserDailyUploadMB,
DefaultUserStorageMB: defaults.DefaultUserStorageMB,
UsageRetentionDays: defaults.UsageRetentionDays,
},
}
if err := service.validate(service.defaults); err != nil {
return nil, err
}
err := db.Update(func(tx *bbolt.Tx) error {
for _, bucket := range [][]byte{settingsBucket, usageBucket} {
if _, err := tx.CreateBucketIfNotExists(bucket); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
return service, nil
}
func (s *SettingsService) UploadPolicy() (UploadPolicySettings, error) {
settings := s.defaults
err := s.db.View(func(tx *bbolt.Tx) error {
data := tx.Bucket(settingsBucket).Get(settingsKey)
if data == nil {
return nil
}
return json.Unmarshal(data, &settings)
})
if err != nil {
return UploadPolicySettings{}, err
}
if err := s.validate(settings); err != nil {
return UploadPolicySettings{}, err
}
return settings, nil
}
func (s *SettingsService) UpdateUploadPolicy(settings UploadPolicySettings) error {
if err := s.validate(settings); err != nil {
return err
}
data, err := json.Marshal(settings)
if err != nil {
return err
}
return s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(settingsBucket).Put(settingsKey, data)
})
}
func (s *SettingsService) Usage(subjectType, subject string, now time.Time) (UsageRecord, error) {
key := usageKey(subjectType, subject, now)
var record UsageRecord
err := s.db.View(func(tx *bbolt.Tx) error {
data := tx.Bucket(usageBucket).Get([]byte(key))
if data == nil {
record = UsageRecord{Key: key, SubjectType: subjectType, Subject: subject, Date: usageDate(now)}
return nil
}
return json.Unmarshal(data, &record)
})
return record, err
}
func (s *SettingsService) AddUsage(subjectType, subject string, bytes int64, now time.Time) error {
if bytes <= 0 {
return nil
}
key := usageKey(subjectType, subject, now)
return s.db.Update(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(usageBucket)
record := UsageRecord{Key: key, SubjectType: subjectType, Subject: subject, Date: usageDate(now)}
data := bucket.Get([]byte(key))
if data != nil {
if err := json.Unmarshal(data, &record); err != nil {
return err
}
}
record.UploadedBytes += bytes
record.UpdatedAt = now.UTC()
next, err := json.Marshal(record)
if err != nil {
return err
}
return bucket.Put([]byte(key), next)
})
}
func (s *SettingsService) CleanupUsage(now time.Time, retentionDays int) error {
if retentionDays <= 0 {
return fmt.Errorf("usage retention days must be positive")
}
cutoff := now.UTC().AddDate(0, 0, -retentionDays)
return s.db.Update(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(usageBucket)
return bucket.ForEach(func(key, value []byte) error {
var record UsageRecord
if err := json.Unmarshal(value, &record); err != nil {
return err
}
date, err := time.Parse("2006-01-02", record.Date)
if err != nil || date.Before(cutoff) {
return bucket.Delete(key)
}
return nil
})
})
}
func (s *SettingsService) UsageForUser(userID string, now time.Time) (UsageRecord, error) {
return s.Usage("user", userID, now)
}
func (s *SettingsService) UsageForIP(ip string, now time.Time) (UsageRecord, error) {
return s.Usage("ip", ip, now)
}
func (s *SettingsService) validate(settings UploadPolicySettings) error {
if settings.AnonymousMaxUploadMB <= 0 {
return fmt.Errorf("anonymous max upload must be positive")
}
if settings.AnonymousDailyUploadMB <= 0 {
return fmt.Errorf("anonymous daily upload must be positive")
}
if settings.UserDailyUploadMB <= 0 {
return fmt.Errorf("user daily upload must be positive")
}
if settings.DefaultUserStorageMB <= 0 {
return fmt.Errorf("default user storage must be positive")
}
if settings.UsageRetentionDays <= 0 {
return fmt.Errorf("usage retention days must be positive")
}
return nil
}
func ParseMegabytesValue(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)
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0, err
}
if parsed <= 0 {
return 0, fmt.Errorf("megabyte value must be positive")
}
return parsed, nil
}
func MegabytesToBytes(value float64) int64 {
return int64(value * 1024 * 1024)
}
func FormatMegabytesFromBytes(value int64) string {
mb := float64(value) / 1024 / 1024
return FormatMegabytesLabel(mb)
}
func FormatMegabytesLabel(value float64) string {
return strconv.FormatFloat(value, 'f', -1, 64) + " MB"
}
func usageKey(subjectType, subject string, now time.Time) string {
return subjectType + ":" + subject + ":" + usageDate(now)
}
func usageDate(now time.Time) string {
return now.UTC().Format("2006-01-02")
}
func ClientIP(remoteAddr, forwardedFor string) string {
if forwardedFor != "" {
parts := strings.Split(forwardedFor, ",")
if ip := strings.TrimSpace(parts[0]); ip != "" {
return ip
}
}
host := remoteAddr
if strings.Contains(remoteAddr, ":") {
if splitHost, _, err := net.SplitHostPort(remoteAddr); err == nil {
host = splitHost
}
}
return host
}

View File

@@ -0,0 +1,173 @@
package services
import (
"log/slog"
"path/filepath"
"testing"
"time"
"warpbox.dev/backend/libs/config"
)
func TestSettingsLoadDefaultsAndOverrides(t *testing.T) {
settings := newTestSettingsService(t)
policy, err := settings.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy returned error: %v", err)
}
if !policy.AnonymousUploadsEnabled || policy.AnonymousMaxUploadMB != 512 {
t.Fatalf("default policy = %+v", policy)
}
policy.AnonymousUploadsEnabled = false
policy.UserDailyUploadMB = 123
if err := settings.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
next, err := settings.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy returned error: %v", err)
}
if next.AnonymousUploadsEnabled || next.UserDailyUploadMB != 123 {
t.Fatalf("override policy = %+v", next)
}
}
func TestSettingsUseNewEnvDefaultsUntilSaved(t *testing.T) {
root := t.TempDir()
upload, err := NewUploadService(1024*1024, filepath.Join(root, "data"), "http://example.test", slog.Default())
if err != nil {
t.Fatalf("NewUploadService returned error: %v", err)
}
defer upload.Close()
first, err := NewSettingsService(upload.DB(), config.SettingsDefaults{
AnonymousUploadsEnabled: true,
AnonymousMaxUploadMB: 111,
AnonymousDailyUploadMB: 222,
UserDailyUploadMB: 333,
DefaultUserStorageMB: 444,
UsageRetentionDays: 30,
})
if err != nil {
t.Fatalf("NewSettingsService first returned error: %v", err)
}
firstPolicy, err := first.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy first returned error: %v", err)
}
if firstPolicy.AnonymousMaxUploadMB != 111 {
t.Fatalf("first AnonymousMaxUploadMB = %v, want 111", firstPolicy.AnonymousMaxUploadMB)
}
second, err := NewSettingsService(upload.DB(), config.SettingsDefaults{
AnonymousUploadsEnabled: true,
AnonymousMaxUploadMB: 555,
AnonymousDailyUploadMB: 666,
UserDailyUploadMB: 777,
DefaultUserStorageMB: 888,
UsageRetentionDays: 30,
})
if err != nil {
t.Fatalf("NewSettingsService second returned error: %v", err)
}
secondPolicy, err := second.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy second returned error: %v", err)
}
if secondPolicy.AnonymousMaxUploadMB != 555 {
t.Fatalf("second AnonymousMaxUploadMB = %v, want 555", secondPolicy.AnonymousMaxUploadMB)
}
if err := second.UpdateUploadPolicy(secondPolicy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
third, err := NewSettingsService(upload.DB(), config.SettingsDefaults{
AnonymousUploadsEnabled: true,
AnonymousMaxUploadMB: 999,
AnonymousDailyUploadMB: 999,
UserDailyUploadMB: 999,
DefaultUserStorageMB: 999,
UsageRetentionDays: 30,
})
if err != nil {
t.Fatalf("NewSettingsService third returned error: %v", err)
}
thirdPolicy, err := third.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy third returned error: %v", err)
}
if thirdPolicy.AnonymousMaxUploadMB != 555 {
t.Fatalf("third AnonymousMaxUploadMB = %v, want persisted 555", thirdPolicy.AnonymousMaxUploadMB)
}
}
func TestSettingsRejectInvalidMegabytes(t *testing.T) {
if _, err := ParseMegabytesValue("0"); err == nil {
t.Fatalf("ParseMegabytesValue accepted zero")
}
settings := newTestSettingsService(t)
policy, err := settings.UploadPolicy()
if err != nil {
t.Fatalf("UploadPolicy returned error: %v", err)
}
policy.DefaultUserStorageMB = -1
if err := settings.UpdateUploadPolicy(policy); err == nil {
t.Fatalf("UpdateUploadPolicy accepted negative storage")
}
}
func TestDailyUsageAndCleanup(t *testing.T) {
settings := newTestSettingsService(t)
now := time.Date(2026, 5, 30, 12, 0, 0, 0, time.UTC)
if err := settings.AddUsage("ip", "127.0.0.1", 1024, now); err != nil {
t.Fatalf("AddUsage returned error: %v", err)
}
if err := settings.AddUsage("ip", "127.0.0.1", 2048, now); err != nil {
t.Fatalf("AddUsage returned error: %v", err)
}
usage, err := settings.UsageForIP("127.0.0.1", now)
if err != nil {
t.Fatalf("UsageForIP returned error: %v", err)
}
if usage.UploadedBytes != 3072 {
t.Fatalf("UploadedBytes = %d, want 3072", usage.UploadedBytes)
}
if err := settings.CleanupUsage(now.AddDate(0, 0, 31), 30); err != nil {
t.Fatalf("CleanupUsage returned error: %v", err)
}
usage, err = settings.UsageForIP("127.0.0.1", now)
if err != nil {
t.Fatalf("UsageForIP returned error: %v", err)
}
if usage.UploadedBytes != 0 {
t.Fatalf("UploadedBytes after cleanup = %d, want 0", usage.UploadedBytes)
}
}
func newTestSettingsService(t *testing.T) *SettingsService {
t.Helper()
root := t.TempDir()
upload, err := NewUploadService(1024*1024, filepath.Join(root, "data"), "http://example.test", slog.Default())
if err != nil {
t.Fatalf("NewUploadService returned error: %v", err)
}
t.Cleanup(func() {
if err := upload.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
})
settings, err := NewSettingsService(upload.DB(), config.SettingsDefaults{
AnonymousUploadsEnabled: true,
AnonymousMaxUploadMB: 512,
AnonymousDailyUploadMB: 2048,
UserDailyUploadMB: 8192,
DefaultUserStorageMB: 51200,
UsageRetentionDays: 30,
})
if err != nil {
t.Fatalf("NewSettingsService returned error: %v", err)
}
return settings
}

View File

@@ -384,15 +384,27 @@ func (s *UploadService) UserBoxes(userID string, collectionNames map[string]stri
} }
func (s *UploadService) UserStorageUsed(userID string) (int64, error) { func (s *UploadService) UserStorageUsed(userID string) (int64, error) {
return s.userStorageUsed(userID, false)
}
func (s *UploadService) UserActiveStorageUsed(userID string) (int64, error) {
return s.userStorageUsed(userID, true)
}
func (s *UploadService) userStorageUsed(userID string, activeOnly bool) (int64, error) {
boxes, err := s.ListBoxes(0) boxes, err := s.ListBoxes(0)
if err != nil { if err != nil {
return 0, err return 0, err
} }
var total int64 var total int64
now := time.Now().UTC()
for _, box := range boxes { for _, box := range boxes {
if box.OwnerID != userID { if box.OwnerID != userID {
continue continue
} }
if activeOnly && !box.ExpiresAt.After(now) {
continue
}
for _, file := range box.Files { for _, file := range box.Files {
total += file.Size total += file.Size
} }

View File

@@ -10,6 +10,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
) )
func TestDeleteTokenVerification(t *testing.T) { func TestDeleteTokenVerification(t *testing.T) {
@@ -59,6 +60,39 @@ func TestDeleteBoxWithTokenRemovesMetadataAndFiles(t *testing.T) {
} }
} }
func TestUserActiveStorageUsedIgnoresExpiredBoxes(t *testing.T) {
service := newTestUploadService(t)
active, err := service.CreateBox(testFileHeaders(t, "file", "active.txt", "active"), UploadOptions{MaxDays: 1, OwnerID: "user-1"})
if err != nil {
t.Fatalf("CreateBox active returned error: %v", err)
}
expired, err := service.CreateBox(testFileHeaders(t, "file", "expired.txt", "expired"), UploadOptions{MaxDays: 1, OwnerID: "user-1"})
if err != nil {
t.Fatalf("CreateBox expired returned error: %v", err)
}
expiredBox, err := service.GetBox(expired.BoxID)
if err != nil {
t.Fatalf("GetBox returned error: %v", err)
}
expiredBox.ExpiresAt = time.Now().UTC().Add(-time.Hour)
if err := service.SaveBox(expiredBox); err != nil {
t.Fatalf("SaveBox returned error: %v", err)
}
activeBox, err := service.GetBox(active.BoxID)
if err != nil {
t.Fatalf("GetBox active returned error: %v", err)
}
want := activeBox.Files[0].Size
got, err := service.UserActiveStorageUsed("user-1")
if err != nil {
t.Fatalf("UserActiveStorageUsed returned error: %v", err)
}
if got != want {
t.Fatalf("UserActiveStorageUsed = %d, want %d", got, want)
}
}
func newTestUploadService(t *testing.T) *UploadService { func newTestUploadService(t *testing.T) *UploadService {
t.Helper() t.Helper()
service, err := NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil))) service, err := NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil)))

View File

@@ -73,6 +73,9 @@ svg {
} }
.site-header { .site-header {
position: sticky;
top: 0;
z-index: 20;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: rgba(9, 9, 11, 0.84); background: rgba(9, 9, 11, 0.84);
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
@@ -241,6 +244,10 @@ h1 {
align-self: start; align-self: start;
display: grid; display: grid;
gap: 0.5rem; gap: 0.5rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: rgba(24, 24, 27, 0.58);
} }
.sidebar-link { .sidebar-link {
@@ -258,6 +265,29 @@ h1 {
color: var(--foreground); color: var(--foreground);
} }
.admin-shell .app-sidebar {
border-color: rgba(125, 211, 252, 0.28);
background: linear-gradient(180deg, rgba(8, 47, 73, 0.22), rgba(24, 24, 27, 0.58));
}
.admin-shell .sidebar-link.is-active {
border-color: rgba(125, 211, 252, 0.42);
background: rgba(14, 116, 144, 0.24);
}
.admin-shell .kicker {
color: #7dd3fc;
}
.sidebar-logout {
display: grid;
margin: 0.75rem 0 0;
}
.sidebar-logout .button {
width: 100%;
}
.collection-create { .collection-create {
display: grid; display: grid;
gap: 0.6rem; gap: 0.6rem;
@@ -270,6 +300,16 @@ h1 {
gap: 1rem; gap: 1rem;
} }
.settings-stack {
display: grid;
gap: 1rem;
max-width: 44rem;
}
.settings-panel {
box-shadow: none;
}
.compact-upload .drop-zone { .compact-upload .drop-zone {
min-height: 11rem; min-height: 11rem;
} }
@@ -295,6 +335,31 @@ h1 {
width: 10rem; width: 10rem;
} }
.settings-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
}
.settings-form-narrow {
grid-template-columns: minmax(0, 1fr);
}
.settings-form label {
display: grid;
gap: 0.35rem;
color: var(--muted-foreground);
font-size: 0.82rem;
}
.settings-form .checkbox-field {
grid-column: 1 / -1;
}
.settings-form button {
justify-self: start;
}
.drop-zone { .drop-zone {
min-height: 19rem; min-height: 19rem;
display: grid; display: grid;
@@ -1158,7 +1223,9 @@ pre code {
@media (max-width: 720px) { @media (max-width: 720px) {
.nav-links { .nav-links {
display: none; display: inline-flex;
flex-wrap: wrap;
justify-content: flex-end;
} }
.upload-view, .upload-view,
@@ -1181,10 +1248,16 @@ pre code {
} }
.docs-grid, .docs-grid,
.field-grid { .field-grid,
.app-shell,
.settings-form {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.app-sidebar {
position: static;
}
.endpoint-list div { .endpoint-list div {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 0.25rem; gap: 0.25rem;

View File

@@ -28,14 +28,12 @@
</a> </a>
<div class="nav-links"> <div class="nav-links">
{{if .CurrentUser}} {{if .CurrentUser}}
<a class="button button-ghost" href="/app">My files</a> <a class="button button-ghost" href="/api">API</a>
<a class="button button-ghost" href="/account/settings">Account</a> <a class="button button-outline" href="/account/settings">My Account</a>
<form action="/logout" method="post" class="inline-form"><button class="button button-outline" type="submit">Logout</button></form>
{{else}} {{else}}
<a class="button button-ghost" href="/login">Login</a> <a class="button button-ghost" href="/login">Login</a>
{{end}}
<a class="button button-ghost" href="/api">API</a> <a class="button button-ghost" href="/api">API</a>
<a class="button button-outline" href="/healthz">Health</a> {{end}}
</div> </div>
</nav> </nav>
</header> </header>
@@ -46,7 +44,7 @@
<footer class="site-footer"> <footer class="site-footer">
<span>{{.AppName}} · {{.CurrentYear}} · self-hosted</span> <span>{{.AppName}} · {{.CurrentYear}} · self-hosted</span>
<span class="footer-links"><a href="/">Upload</a>{{if .CurrentUser}}<a href="/app">My files</a>{{end}}<a href="/healthz">Health</a></span> <span class="footer-links">{{if .CurrentUser}}<a href="/api">API</a><a href="/account/settings">My Account</a>{{else}}<a href="/login">Login</a><a href="/api">API</a>{{end}}</span>
</footer> </footer>
</body> </body>
</html> </html>

View File

@@ -1,18 +1,42 @@
{{define "account.html"}}{{template "base" .}}{{end}} {{define "account.html"}}{{template "base" .}}{{end}}
{{define "content"}} {{define "content"}}
<section class="auth-view" aria-labelledby="account-title"> <section class="app-shell" aria-labelledby="account-title">
<div class="card auth-card"> <aside class="app-sidebar">
<div class="card-content"> <a class="sidebar-link" href="/app">My files</a>
<p class="kicker">Account</p> <a class="sidebar-link is-active" href="/account/settings">Account settings</a>
<h1 id="account-title">Settings</h1> {{if eq .Data.Role "admin"}}<a class="sidebar-link" href="/admin">Admin</a>{{end}}
<p class="muted-copy">{{.Data.Email}} · {{.Data.Role}}</p> <form class="sidebar-logout" action="/logout" method="post">
<form class="stack-form" action="/account/password" method="post"> <button class="button button-outline" type="submit">Logout</button>
<label><span>Current password</span><input type="password" name="current_password" autocomplete="current-password" required></label> </form>
<label><span>New password</span><input type="password" name="new_password" autocomplete="new-password" minlength="8" required></label> </aside>
<button class="button button-primary" type="submit">Update password</button>
</form> <div class="app-main">
<p class="muted-copy">Public forgot-password is deferred until SMTP support is added. Admins can generate reset links.</p> <div class="admin-header">
<div>
<p class="kicker">Account</p>
<h1 id="account-title">Settings</h1>
<p class="muted-copy">{{.Data.Email}} · {{.Data.Role}}</p>
</div>
</div>
<div class="settings-stack">
<div class="card settings-panel">
<div class="card-content">
<div class="table-header">
<div>
<h2>Password</h2>
<p>Update the password for your account.</p>
</div>
</div>
<form class="settings-form settings-form-narrow" action="/account/password" method="post">
<label><span>Current password</span><input type="password" name="current_password" autocomplete="current-password" required></label>
<label><span>New password</span><input type="password" name="new_password" autocomplete="new-password" minlength="8" required></label>
<button class="button button-primary" type="submit">Update password</button>
</form>
<p class="muted-copy">Public forgot-password is deferred until SMTP support is added. Admins can generate reset links.</p>
</div>
</div>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -1,16 +1,24 @@
{{define "admin.html"}}{{template "base" .}}{{end}} {{define "admin.html"}}{{template "base" .}}{{end}}
{{define "content"}} {{define "content"}}
<section class="admin-view" aria-labelledby="admin-title"> <section class="app-shell admin-shell" aria-labelledby="admin-title">
<aside class="app-sidebar">
<a class="sidebar-link {{if eq .Data.Section "overview"}}is-active{{end}}" href="/admin">Overview</a>
<a class="sidebar-link {{if eq .Data.Section "files"}}is-active{{end}}" href="/admin/files">Files</a>
<a class="sidebar-link" href="/admin/users">Users</a>
<a class="sidebar-link" href="/admin/settings">Settings</a>
<a class="sidebar-link" href="/app">My files</a>
<form class="sidebar-logout" action="/admin/logout" method="post">
<button class="button button-outline" type="submit">Logout</button>
</form>
</aside>
<div class="app-main">
<div class="admin-header"> <div class="admin-header">
<div> <div>
<p class="kicker">Operator console</p> <p class="kicker">Operator console</p>
<h1 id="admin-title">Admin overview</h1> <h1 id="admin-title">{{.Data.PageTitle}}</h1>
</div> </div>
<form action="/admin/logout" method="post">
<a class="button button-outline" href="/admin/users">Users</a>
<button class="button button-outline" type="submit">Logout</button>
</form>
</div> </div>
<div class="metric-grid"> <div class="metric-grid">
@@ -94,5 +102,6 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</section> </section>
{{end}} {{end}}

View File

@@ -0,0 +1,64 @@
{{define "admin_settings.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="app-shell admin-shell" aria-labelledby="admin-settings-title">
<aside class="app-sidebar">
<a class="sidebar-link" href="/admin">Overview</a>
<a class="sidebar-link" href="/admin/files">Files</a>
<a class="sidebar-link" href="/admin/users">Users</a>
<a class="sidebar-link is-active" href="/admin/settings">Settings</a>
<a class="sidebar-link" href="/app">My files</a>
<form class="sidebar-logout" action="/admin/logout" method="post">
<button class="button button-outline" type="submit">Logout</button>
</form>
</aside>
<div class="app-main">
<div class="admin-header">
<div>
<p class="kicker">Operator console</p>
<h1 id="admin-settings-title">{{.Data.PageTitle}}</h1>
</div>
</div>
<div class="card admin-table-card">
<div class="card-content">
<div class="table-header">
<div>
<h2>Upload policy</h2>
<p>Values are stored in megabytes. Admin users bypass these upload caps.</p>
</div>
</div>
<form class="settings-form" action="/admin/settings" method="post">
<label class="checkbox-field">
<input type="checkbox" name="anonymous_uploads_enabled" {{if .Data.Settings.AnonymousUploadsEnabled}}checked{{end}}>
<span>Allow anonymous uploads</span>
</label>
<label>
<span>Anonymous max upload MB</span>
<input name="anonymous_max_upload_mb" value="{{.Data.Settings.AnonymousMaxUploadMB}}" required>
</label>
<label>
<span>Anonymous daily upload MB per IP</span>
<input name="anonymous_daily_upload_mb" value="{{.Data.Settings.AnonymousDailyUploadMB}}" required>
</label>
<label>
<span>User daily upload MB</span>
<input name="user_daily_upload_mb" value="{{.Data.Settings.UserDailyUploadMB}}" required>
</label>
<label>
<span>Default user storage MB</span>
<input name="default_user_storage_mb" value="{{.Data.Settings.DefaultUserStorageMB}}" required>
</label>
<label>
<span>Usage retention days</span>
<input type="number" name="usage_retention_days" min="1" value="{{.Data.Settings.UsageRetentionDays}}" required>
</label>
<button class="button button-primary" type="submit">Save settings</button>
</form>
</div>
</div>
</div>
</section>
{{end}}

View File

@@ -1,15 +1,23 @@
{{define "admin_users.html"}}{{template "base" .}}{{end}} {{define "admin_users.html"}}{{template "base" .}}{{end}}
{{define "content"}} {{define "content"}}
<section class="admin-view" aria-labelledby="admin-users-title"> <section class="app-shell admin-shell" aria-labelledby="admin-users-title">
<aside class="app-sidebar">
<a class="sidebar-link" href="/admin">Overview</a>
<a class="sidebar-link" href="/admin/files">Files</a>
<a class="sidebar-link is-active" href="/admin/users">Users</a>
<a class="sidebar-link" href="/admin/settings">Settings</a>
<a class="sidebar-link" href="/app">My files</a>
<form class="sidebar-logout" action="/admin/logout" method="post">
<button class="button button-outline" type="submit">Logout</button>
</form>
</aside>
<div class="app-main">
<div class="admin-header"> <div class="admin-header">
<div> <div>
<p class="kicker">Operator console</p> <p class="kicker">Operator console</p>
<h1 id="admin-users-title">Users</h1> <h1 id="admin-users-title">{{.Data.PageTitle}}</h1>
</div>
<div class="result-actions">
<a class="button button-outline" href="/admin">Overview</a>
<a class="button button-outline" href="/admin/files">Files</a>
</div> </div>
</div> </div>
@@ -37,7 +45,7 @@
<div class="table-header"><h2>Users</h2><p>Disable accounts or create reset links.</p></div> <div class="table-header"><h2>Users</h2><p>Disable accounts or create reset links.</p></div>
<div class="admin-table-wrap"> <div class="admin-table-wrap">
<table class="admin-table"> <table class="admin-table">
<thead><tr><th>User</th><th>Email</th><th>Role</th><th>Status</th><th>Joined</th><th>Actions</th></tr></thead> <thead><tr><th>User</th><th>Email</th><th>Role</th><th>Status</th><th>Storage</th><th>Today</th><th>Joined</th><th>Actions</th></tr></thead>
<tbody> <tbody>
{{range .Data.Users}} {{range .Data.Users}}
<tr> <tr>
@@ -45,6 +53,8 @@
<td>{{.Email}}</td> <td>{{.Email}}</td>
<td>{{.Role}}</td> <td>{{.Role}}</td>
<td><span class="badge">{{.Status}}</span></td> <td><span class="badge">{{.Status}}</span></td>
<td>{{.StorageUsed}} / {{.StorageQuota}}</td>
<td>{{.DailyUsed}}</td>
<td>{{.CreatedAt}}</td> <td>{{.CreatedAt}}</td>
<td class="table-actions"> <td class="table-actions">
{{if eq .Status "disabled"}} {{if eq .Status "disabled"}}
@@ -53,15 +63,20 @@
<form action="/admin/users/{{.ID}}/disable" method="post"><button class="button button-danger" type="submit">Disable</button></form> <form action="/admin/users/{{.ID}}/disable" method="post"><button class="button button-danger" type="submit">Disable</button></form>
{{end}} {{end}}
<form action="/admin/users/{{.ID}}/reset" method="post"><button class="button button-outline" type="submit">Reset link</button></form> <form action="/admin/users/{{.ID}}/reset" method="post"><button class="button button-outline" type="submit">Reset link</button></form>
<form action="/admin/users/{{.ID}}/quota" method="post">
<input class="compact-input" name="storage_quota_mb" placeholder="Quota MB">
<button class="button button-outline" type="submit">Quota</button>
</form>
</td> </td>
</tr> </tr>
{{else}} {{else}}
<tr><td colspan="6">No users yet.</td></tr> <tr><td colspan="8">No users yet.</td></tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
</div>
</section> </section>
{{end}} {{end}}

View File

@@ -6,6 +6,9 @@
<a class="sidebar-link is-active" href="/app">Dashboard</a> <a class="sidebar-link is-active" href="/app">Dashboard</a>
<a class="sidebar-link" href="/account/settings">Settings</a> <a class="sidebar-link" href="/account/settings">Settings</a>
{{if eq .Data.User.Role "admin"}}<a class="sidebar-link" href="/admin">Admin</a>{{end}} {{if eq .Data.User.Role "admin"}}<a class="sidebar-link" href="/admin">Admin</a>{{end}}
<form class="sidebar-logout" action="/logout" method="post">
<button class="button button-outline" type="submit">Logout</button>
</form>
<form class="collection-create" action="/app/collections" method="post"> <form class="collection-create" action="/app/collections" method="post">
<label> <label>
<span>New collection</span> <span>New collection</span>

View File

@@ -15,7 +15,7 @@
</span> </span>
<span class="drop-title">Drop files to upload</span> <span class="drop-title">Drop files to upload</span>
<span class="drop-copy">or click to browse</span> <span class="drop-copy">or click to browse</span>
<span class="drop-meta">{{if .Data.IsAdmin}}Admin upload: no file size limit{{else}}Max file size: {{.Data.MaxUploadSize}}{{end}} · Links expire in 7 days</span> <span class="drop-meta">Max file size: {{.Data.MaxUploadSize}} · {{.Data.LimitSummary}}</span>
<input id="file-input" name="file" type="file" multiple> <input id="file-input" name="file" type="file" multiple>
</label> </label>

View File

@@ -10,6 +10,12 @@ WARPBOX_CLEANUP_EVERY=1h
WARPBOX_THUMBNAIL_ENABLED=true WARPBOX_THUMBNAIL_ENABLED=true
WARPBOX_THUMBNAIL_EVERY=1m WARPBOX_THUMBNAIL_EVERY=1m
WARPBOX_MAX_UPLOAD_SIZE_MB=16384 WARPBOX_MAX_UPLOAD_SIZE_MB=16384
WARPBOX_ANONYMOUS_UPLOADS_ENABLED=true
WARPBOX_ANONYMOUS_MAX_UPLOAD_MB=512
WARPBOX_ANONYMOUS_DAILY_UPLOAD_MB=2048
WARPBOX_USER_DAILY_UPLOAD_MB=8192
WARPBOX_DEFAULT_USER_STORAGE_MB=51200
WARPBOX_USAGE_RETENTION_DAYS=30
WARPBOX_READ_TIMEOUT=15s WARPBOX_READ_TIMEOUT=15s
WARPBOX_WRITE_TIMEOUT=60s WARPBOX_WRITE_TIMEOUT=60s
WARPBOX_IDLE_TIMEOUT=120s WARPBOX_IDLE_TIMEOUT=120s