From d77f16490013c659d32b62f2214d181422765683 Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Sat, 30 May 2026 17:23:20 +0300 Subject: [PATCH] feat: add upload policies, daily limits, and storage quotas - 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. --- .env.example | 6 + README.md | 15 ++ backend/libs/config/config.go | 60 ++++- backend/libs/handlers/accounts_test.go | 279 ++++++++++++++++++++ backend/libs/handlers/admin.go | 172 ++++++++++-- backend/libs/handlers/api_docs.go | 2 +- backend/libs/handlers/app.go | 34 ++- backend/libs/handlers/auth.go | 28 +- backend/libs/handlers/dashboard.go | 2 +- backend/libs/handlers/download.go | 6 +- backend/libs/handlers/manage.go | 4 +- backend/libs/handlers/pages.go | 37 ++- backend/libs/handlers/upload.go | 103 +++++++- backend/libs/handlers/upload_stage3_test.go | 15 +- backend/libs/httpserver/server.go | 7 +- backend/libs/services/auth.go | 56 ++-- backend/libs/services/settings.go | 245 +++++++++++++++++ backend/libs/services/settings_test.go | 173 ++++++++++++ backend/libs/services/upload.go | 12 + backend/libs/services/upload_test.go | 34 +++ backend/static/css/app.css | 77 +++++- backend/templates/layouts/base.html | 10 +- backend/templates/pages/account.html | 48 +++- backend/templates/pages/admin.html | 21 +- backend/templates/pages/admin_settings.html | 64 +++++ backend/templates/pages/admin_users.html | 31 ++- backend/templates/pages/dashboard.html | 3 + backend/templates/pages/home.html | 2 +- scripts/env/dev.env.example | 6 + 29 files changed, 1432 insertions(+), 120 deletions(-) create mode 100644 backend/libs/services/settings.go create mode 100644 backend/libs/services/settings_test.go create mode 100644 backend/templates/pages/admin_settings.html diff --git a/.env.example b/.env.example index a1ea731..12a654c 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,12 @@ WARPBOX_CLEANUP_EVERY=1h WARPBOX_THUMBNAIL_ENABLED=true WARPBOX_THUMBNAIL_EVERY=1m 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_WRITE_TIMEOUT=60s WARPBOX_IDLE_TIMEOUT=120s diff --git a/README.md b/README.md index dcb8627..593f54b 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,16 @@ The default server listens on `:8080`. 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. +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. 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. - 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. +- `/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 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/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` 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. ## Static Asset Policy diff --git a/backend/libs/config/config.go b/backend/libs/config/config.go index 82bc304..1d68827 100644 --- a/backend/libs/config/config.go +++ b/backend/libs/config/config.go @@ -28,6 +28,16 @@ type Config struct { ThumbnailEnabled bool ThumbnailEvery time.Duration MaxUploadSize int64 + DefaultSettings SettingsDefaults +} + +type SettingsDefaults struct { + AnonymousUploadsEnabled bool + AnonymousMaxUploadMB float64 + AnonymousDailyUploadMB float64 + UserDailyUploadMB float64 + DefaultUserStorageMB float64 + UsageRetentionDays int } func Load() (Config, error) { @@ -49,6 +59,14 @@ func Load() (Config, error) { ThumbnailEnabled: envBool("WARPBOX_THUMBNAIL_ENABLED", true), ThumbnailEvery: envDuration("WARPBOX_THUMBNAIL_EVERY", time.Minute), 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 == "" { @@ -57,6 +75,13 @@ func Load() (Config, error) { if cfg.MaxUploadSize <= 0 { 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 } @@ -109,6 +134,19 @@ func envBool(key string, fallback bool) bool { 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 { value := strings.TrimSpace(os.Getenv(key)) if value == "" { @@ -122,7 +160,27 @@ func envMegabytes(key string, fallback float64) int64 { 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) { + 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.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 megabytesToBytes(sizeMB), nil + return sizeMB, nil } func megabytesToBytes(sizeMB float64) int64 { diff --git a/backend/libs/handlers/accounts_test.go b/backend/libs/handlers/accounts_test.go index b1cea16..0b9fdc5 100644 --- a/backend/libs/handlers/accounts_test.go +++ b/backend/libs/handlers/accounts_test.go @@ -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, "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, "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 { t.Helper() user, err := app.authService.UserByID(userID) @@ -163,3 +433,12 @@ func createOwnedBoxThroughApp(t *testing.T, app *App, userID string) services.Up } 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 +} diff --git a/backend/libs/handlers/admin.go b/backend/libs/handlers/admin.go index f2dbb6f..c10024a 100644 --- a/backend/libs/handlers/admin.go +++ b/backend/libs/handlers/admin.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "net/http" "net/url" + "strconv" "time" "warpbox.dev/backend/libs/services" @@ -17,6 +18,9 @@ type adminPageData struct { Stats services.AdminStats Boxes []adminBoxView Users []adminUserView + Settings services.UploadPolicySettings + Section string + PageTitle string LastInviteURL string Error string } @@ -35,12 +39,15 @@ type adminBoxView struct { } type adminUserView struct { - ID string - Username string - Email string - Role string - Status string - CreatedAt string + ID string + Username string + Email string + Role string + Status string + StorageUsed string + StorageQuota string + DailyUsed string + CreatedAt string } 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) return } - a.renderAdminLogin(w, http.StatusOK, "") + a.renderAdminLogin(w, r, http.StatusOK, "") } func (a *App) AdminLoginPost(w http.ResponseWriter, r *http.Request) { 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 } if a.cfg.AdminToken == "" || r.FormValue("token") != a.cfg.AdminToken { 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 } @@ -104,13 +111,15 @@ func (a *App) AdminDashboard(w http.ResponseWriter, r *http.Request) { return } - a.renderer.Render(w, http.StatusOK, "admin.html", web.PageData{ + a.renderPage(w, r, http.StatusOK, "admin.html", web.PageData{ Title: "Admin overview", Description: "Warpbox admin overview.", CurrentUser: a.currentPublicUser(r), Data: adminPageData{ - Stats: stats, - Boxes: boxes, + Stats: stats, + Boxes: boxes, + Section: "overview", + PageTitle: "Admin overview", }, }) } @@ -131,13 +140,15 @@ func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) { return } - a.renderer.Render(w, http.StatusOK, "admin.html", web.PageData{ + a.renderPage(w, r, http.StatusOK, "admin.html", web.PageData{ Title: "Admin files", Description: "Manage Warpbox uploads.", CurrentUser: a.currentPublicUser(r), Data: adminPageData{ - Stats: stats, - Boxes: boxes, + Stats: stats, + Boxes: boxes, + Section: "files", + PageTitle: "Admin files", }, }) } @@ -157,28 +168,129 @@ func (a *App) AdminUsers(w http.ResponseWriter, r *http.Request) { return } 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 { + 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{ - ID: user.ID, - Username: user.Username, - Email: user.Email, - Role: user.Role, - Status: user.Status, - CreatedAt: user.CreatedAt.Format("Jan 2 15:04"), + ID: user.ID, + Username: user.Username, + Email: user.Email, + Role: user.Role, + Status: user.Status, + 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", Description: "Manage Warpbox users and invites.", CurrentUser: a.currentPublicUser(r), Data: adminPageData{ Stats: stats, Users: rows, + Section: "users", + PageTitle: "Users", 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) { admin, ok := a.requireAdminUser(w, r) 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) } -func (a *App) renderAdminLogin(w http.ResponseWriter, status int, message string) { - a.renderer.Render(w, status, "admin_login.html", web.PageData{ +func (a *App) renderAdminLogin(w http.ResponseWriter, r *http.Request, status int, message string) { + a.renderPage(w, r, status, "admin_login.html", web.PageData{ Title: "Admin login", Description: "Sign in to the Warpbox admin console.", Data: adminPageData{ @@ -350,3 +462,15 @@ func adminCookieValue(token string) string { sum := sha256.Sum256([]byte("warpbox-admin:" + token)) 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" +} diff --git a/backend/libs/handlers/api_docs.go b/backend/libs/handlers/api_docs.go index 1e19fda..07c3af6 100644 --- a/backend/libs/handlers/api_docs.go +++ b/backend/libs/handlers/api_docs.go @@ -20,7 +20,7 @@ type apiDocsData struct { } 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", Description: "Curl and ShareX upload examples for Warpbox.", Data: apiDocsData{ diff --git a/backend/libs/handlers/app.go b/backend/libs/handlers/app.go index beb778a..f995181 100644 --- a/backend/libs/handlers/app.go +++ b/backend/libs/handlers/app.go @@ -10,23 +10,32 @@ import ( ) type App struct { - cfg config.Config - logger *slog.Logger - renderer *web.Renderer - uploadService *services.UploadService - authService *services.AuthService + cfg config.Config + logger *slog.Logger + renderer *web.Renderer + uploadService *services.UploadService + 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{ - cfg: cfg, - logger: logger, - renderer: renderer, - uploadService: uploadService, - authService: authService, + cfg: cfg, + logger: logger, + renderer: renderer, + uploadService: uploadService, + 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) { mux.HandleFunc("GET /", a.Home) 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/files", a.AdminFiles) 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/users/{userID}/disable", a.AdminDisableUser) 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("POST /admin/boxes/{boxID}/delete", a.AdminDeleteBox) mux.HandleFunc("GET /d/{boxID}", a.DownloadPage) diff --git a/backend/libs/handlers/auth.go b/backend/libs/handlers/auth.go index 8746fa2..66cdc7c 100644 --- a/backend/libs/handlers/auth.go +++ b/backend/libs/handlers/auth.go @@ -29,17 +29,17 @@ func (a *App) Register(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/login", http.StatusSeeOther) 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) { 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 } user, err := a.authService.CreateBootstrapUser(r.FormValue("username"), r.FormValue("email"), r.FormValue("password")) 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 } 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) 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) { 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 } 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")) if err != nil { 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 } 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) { invite, err := a.authService.InviteByToken(r.PathValue("token")) 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 } - 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) { token := r.PathValue("token") invite, err := a.authService.InviteByToken(token) 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 } 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 } user, err := a.authService.AcceptInvite(token, r.FormValue("username"), r.FormValue("password")) 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 } 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 { return } - a.renderer.Render(w, http.StatusOK, "account.html", web.PageData{ + a.renderPage(w, r, http.StatusOK, "account.html", web.PageData{ Title: "Account settings", Description: "Manage your Warpbox account.", 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) } -func (a *App) renderAuth(w http.ResponseWriter, status int, data authPageData) { - a.renderer.Render(w, status, "auth.html", web.PageData{ +func (a *App) renderAuth(w http.ResponseWriter, r *http.Request, status int, data authPageData) { + a.renderPage(w, r, status, "auth.html", web.PageData{ Title: "Account", Description: "Sign in to Warpbox.", Data: data, diff --git a/backend/libs/handlers/dashboard.go b/backend/libs/handlers/dashboard.go index a4d8d34..406e1db 100644 --- a/backend/libs/handlers/dashboard.go +++ b/backend/libs/handlers/dashboard.go @@ -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", Description: "Your Warpbox personal file space.", CurrentUser: a.authService.PublicUser(user), diff --git a/backend/libs/handlers/download.go b/backend/libs/handlers/download.go index 954afce..bf7b9ad 100644 --- a/backend/libs/handlers/download.go +++ b/backend/libs/handlers/download.go @@ -55,7 +55,7 @@ func (a *App) DownloadPage(w http.ResponseWriter, r *http.Request) { return } 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", Description: "This Warpbox link is no longer available.", 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", Description: "Download files shared through Warpbox.", Data: downloadPageData{ @@ -107,7 +107,7 @@ func (a *App) DownloadFile(w http.ResponseWriter, r *http.Request) { 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, Description: description, ImageURL: imageURL, diff --git a/backend/libs/handlers/manage.go b/backend/libs/handlers/manage.go index dd5703f..50ea622 100644 --- a/backend/libs/handlers/manage.go +++ b/backend/libs/handlers/manage.go @@ -26,7 +26,7 @@ func (a *App) ManageBox(w http.ResponseWriter, r *http.Request) { return } - a.renderer.Render(w, http.StatusOK, "manage.html", web.PageData{ + a.renderPage(w, r, http.StatusOK, "manage.html", web.PageData{ Title: "Manage upload", Description: "Delete this anonymous Warpbox upload.", 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) { - 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", Description: "This Warpbox upload has been deleted.", Data: boxView{ID: r.PathValue("boxID")}, diff --git a/backend/libs/handlers/pages.go b/backend/libs/handlers/pages.go index e3dbd03..9aabba0 100644 --- a/backend/libs/handlers/pages.go +++ b/backend/libs/handlers/pages.go @@ -9,15 +9,21 @@ import ( type homeData struct { MaxUploadSize string + LimitSummary string Collections []collectionView IsAdmin bool + AnonymousOpen bool } func (a *App) Home(w http.ResponseWriter, r *http.Request) { currentUser := a.currentPublicUser(r) var collections []collectionView 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 userCollections, err := a.authService.ListCollections(user.ID) 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", Description: "Upload and share files through a self-hosted Warpbox instance.", CurrentUser: currentUser, Data: homeData{ - MaxUploadSize: a.uploadService.MaxUploadSizeLabel(), + MaxUploadSize: maxUploadSize, + LimitSummary: limitSummary, Collections: collections, 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) + "." +} diff --git a/backend/libs/handlers/upload.go b/backend/libs/handlers/upload.go index f59f209..46bc754 100644 --- a/backend/libs/handlers/upload.go +++ b/backend/libs/handlers/upload.go @@ -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 diff --git a/backend/libs/handlers/upload_stage3_test.go b/backend/libs/handlers/upload_stage3_test.go index 44b2a73..ec201e2 100644 --- a/backend/libs/handlers/upload_stage3_test.go +++ b/backend/libs/handlers/upload_stage3_test.go @@ -184,6 +184,14 @@ func newTestApp(t *testing.T) (*App, func()) { StaticDir: staticDir, TemplateDir: templateDir, 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) if err != nil { @@ -199,7 +207,12 @@ func newTestApp(t *testing.T) (*App, func()) { service.Close() 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 { t.Fatalf("Close returned error: %v", err) } diff --git a/backend/libs/httpserver/server.go b/backend/libs/httpserver/server.go index 3489122..3982a1a 100644 --- a/backend/libs/httpserver/server.go +++ b/backend/libs/httpserver/server.go @@ -27,8 +27,13 @@ func New(cfg config.Config, logger *slog.Logger) (*http.Server, error) { uploadService.Close() 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) - app := handlers.NewApp(cfg, logger, renderer, uploadService, authService) + app := handlers.NewApp(cfg, logger, renderer, uploadService, authService, settingsService) router := http.NewServeMux() app.RegisterRoutes(router) diff --git a/backend/libs/services/auth.go b/backend/libs/services/auth.go index ed62cd8..13ee71c 100644 --- a/backend/libs/services/auth.go +++ b/backend/libs/services/auth.go @@ -48,23 +48,25 @@ type AuthService struct { } type User struct { - ID string `json:"id"` - Username string `json:"username"` - Email string `json:"email"` - PasswordHash string `json:"passwordHash"` - Role string `json:"role"` - Status string `json:"status"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + PasswordHash string `json:"passwordHash"` + Role string `json:"role"` + Status string `json:"status"` + StorageQuotaMB *float64 `json:"storageQuotaMb,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } type PublicUser struct { - ID string - Username string - Email string - Role string - Status string - CreatedAt time.Time + ID string + Username string + Email string + Role string + Status string + StorageQuotaMB *float64 + CreatedAt time.Time } type Session struct { @@ -366,6 +368,19 @@ func (s *AuthService) SetPassword(userID, password string) error { 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) { var user User 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 { return PublicUser{ - ID: user.ID, - Username: user.Username, - Email: user.Email, - Role: user.Role, - Status: user.Status, - CreatedAt: user.CreatedAt, + ID: user.ID, + Username: user.Username, + Email: user.Email, + Role: user.Role, + Status: user.Status, + StorageQuotaMB: user.StorageQuotaMB, + CreatedAt: user.CreatedAt, } } diff --git a/backend/libs/services/settings.go b/backend/libs/services/settings.go new file mode 100644 index 0000000..fff6069 --- /dev/null +++ b/backend/libs/services/settings.go @@ -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 +} diff --git a/backend/libs/services/settings_test.go b/backend/libs/services/settings_test.go new file mode 100644 index 0000000..136c782 --- /dev/null +++ b/backend/libs/services/settings_test.go @@ -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 +} diff --git a/backend/libs/services/upload.go b/backend/libs/services/upload.go index 43f23f9..d4ff523 100644 --- a/backend/libs/services/upload.go +++ b/backend/libs/services/upload.go @@ -384,15 +384,27 @@ func (s *UploadService) UserBoxes(userID string, collectionNames map[string]stri } 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) if err != nil { return 0, err } var total int64 + now := time.Now().UTC() for _, box := range boxes { if box.OwnerID != userID { continue } + if activeOnly && !box.ExpiresAt.After(now) { + continue + } for _, file := range box.Files { total += file.Size } diff --git a/backend/libs/services/upload_test.go b/backend/libs/services/upload_test.go index 9c1374f..63664d6 100644 --- a/backend/libs/services/upload_test.go +++ b/backend/libs/services/upload_test.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) 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 { t.Helper() service, err := NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil))) diff --git a/backend/static/css/app.css b/backend/static/css/app.css index 1842d91..551eb1a 100644 --- a/backend/static/css/app.css +++ b/backend/static/css/app.css @@ -73,6 +73,9 @@ svg { } .site-header { + position: sticky; + top: 0; + z-index: 20; border-bottom: 1px solid var(--border); background: rgba(9, 9, 11, 0.84); backdrop-filter: blur(14px); @@ -241,6 +244,10 @@ h1 { align-self: start; display: grid; gap: 0.5rem; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: rgba(24, 24, 27, 0.58); } .sidebar-link { @@ -258,6 +265,29 @@ h1 { 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 { display: grid; gap: 0.6rem; @@ -270,6 +300,16 @@ h1 { gap: 1rem; } +.settings-stack { + display: grid; + gap: 1rem; + max-width: 44rem; +} + +.settings-panel { + box-shadow: none; +} + .compact-upload .drop-zone { min-height: 11rem; } @@ -295,6 +335,31 @@ h1 { 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 { min-height: 19rem; display: grid; @@ -1158,7 +1223,9 @@ pre code { @media (max-width: 720px) { .nav-links { - display: none; + display: inline-flex; + flex-wrap: wrap; + justify-content: flex-end; } .upload-view, @@ -1181,10 +1248,16 @@ pre code { } .docs-grid, - .field-grid { + .field-grid, + .app-shell, + .settings-form { grid-template-columns: 1fr; } + .app-sidebar { + position: static; + } + .endpoint-list div { grid-template-columns: 1fr; gap: 0.25rem; diff --git a/backend/templates/layouts/base.html b/backend/templates/layouts/base.html index c6edacb..5235354 100644 --- a/backend/templates/layouts/base.html +++ b/backend/templates/layouts/base.html @@ -28,14 +28,12 @@ @@ -46,7 +44,7 @@ diff --git a/backend/templates/pages/account.html b/backend/templates/pages/account.html index 88c3fbe..f6947c1 100644 --- a/backend/templates/pages/account.html +++ b/backend/templates/pages/account.html @@ -1,18 +1,42 @@ {{define "account.html"}}{{template "base" .}}{{end}} {{define "content"}} -
-
-
-

Account

-

Settings

-

{{.Data.Email}} · {{.Data.Role}}

-
- - - -
-

Public forgot-password is deferred until SMTP support is added. Admins can generate reset links.

+
+ + +
+
+
+

Account

+

Settings

+

{{.Data.Email}} · {{.Data.Role}}

+
+
+ +
+
+
+
+
+

Password

+

Update the password for your account.

+
+
+
+ + + +
+

Public forgot-password is deferred until SMTP support is added. Admins can generate reset links.

+
+
diff --git a/backend/templates/pages/admin.html b/backend/templates/pages/admin.html index 483024e..18401d2 100644 --- a/backend/templates/pages/admin.html +++ b/backend/templates/pages/admin.html @@ -1,16 +1,24 @@ {{define "admin.html"}}{{template "base" .}}{{end}} {{define "content"}} -
+
+ + +

Operator console

-

Admin overview

+

{{.Data.PageTitle}}

-
- Users - -
@@ -94,5 +102,6 @@
+
{{end}} diff --git a/backend/templates/pages/admin_settings.html b/backend/templates/pages/admin_settings.html new file mode 100644 index 0000000..eb0ef00 --- /dev/null +++ b/backend/templates/pages/admin_settings.html @@ -0,0 +1,64 @@ +{{define "admin_settings.html"}}{{template "base" .}}{{end}} + +{{define "content"}} +
+ + +
+
+
+

Operator console

+

{{.Data.PageTitle}}

+
+
+ +
+
+
+
+

Upload policy

+

Values are stored in megabytes. Admin users bypass these upload caps.

+
+
+ +
+ + + + + + + +
+
+
+
+
+{{end}} diff --git a/backend/templates/pages/admin_users.html b/backend/templates/pages/admin_users.html index aeba61b..6e25569 100644 --- a/backend/templates/pages/admin_users.html +++ b/backend/templates/pages/admin_users.html @@ -1,15 +1,23 @@ {{define "admin_users.html"}}{{template "base" .}}{{end}} {{define "content"}} -
+
+ + +

Operator console

-

Users

-
-
- Overview - Files +

{{.Data.PageTitle}}

@@ -37,7 +45,7 @@

Users

Disable accounts or create reset links.

- + {{range .Data.Users}} @@ -45,6 +53,8 @@ + + {{else}} - + {{end}}
UserEmailRoleStatusJoinedActions
UserEmailRoleStatusStorageTodayJoinedActions
{{.Email}} {{.Role}} {{.Status}}{{.StorageUsed}} / {{.StorageQuota}}{{.DailyUsed}} {{.CreatedAt}} {{if eq .Status "disabled"}} @@ -53,15 +63,20 @@
{{end}}
+
+ + +
No users yet.
No users yet.
+
{{end}} diff --git a/backend/templates/pages/dashboard.html b/backend/templates/pages/dashboard.html index 915ddb0..a142496 100644 --- a/backend/templates/pages/dashboard.html +++ b/backend/templates/pages/dashboard.html @@ -6,6 +6,9 @@ Dashboard Settings {{if eq .Data.User.Role "admin"}}Admin{{end}} +
diff --git a/scripts/env/dev.env.example b/scripts/env/dev.env.example index a1ea731..12a654c 100644 --- a/scripts/env/dev.env.example +++ b/scripts/env/dev.env.example @@ -10,6 +10,12 @@ WARPBOX_CLEANUP_EVERY=1h WARPBOX_THUMBNAIL_ENABLED=true WARPBOX_THUMBNAIL_EVERY=1m 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_WRITE_TIMEOUT=60s WARPBOX_IDLE_TIMEOUT=120s