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

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`.
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

View File

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

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

View File

@@ -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
}
@@ -40,6 +44,9 @@ type adminUserView struct {
Email string
Role string
Status string
StorageUsed string
StorageQuota string
DailyUsed string
CreatedAt string
}
@@ -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,
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,
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,
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"
}

View File

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

View File

@@ -15,18 +15,27 @@ type App struct {
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,
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)

View File

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

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",
Description: "Your Warpbox personal file space.",
CurrentUser: a.authService.PublicUser(user),

View File

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

View File

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

View File

@@ -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) + "."
}

View File

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

View File

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

View File

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

View File

@@ -54,6 +54,7 @@ type User struct {
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"`
}
@@ -64,6 +65,7 @@ type PublicUser struct {
Email string
Role string
Status string
StorageQuotaMB *float64
CreatedAt time.Time
}
@@ -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 {
@@ -460,6 +475,7 @@ func (s *AuthService) PublicUser(user User) PublicUser {
Email: user.Email,
Role: user.Role,
Status: user.Status,
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) {
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
}

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,35 @@
{{define "account.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="auth-view" aria-labelledby="account-title">
<div class="card auth-card">
<div class="card-content">
<section class="app-shell" aria-labelledby="account-title">
<aside class="app-sidebar">
<a class="sidebar-link" href="/app">My files</a>
<a class="sidebar-link is-active" href="/account/settings">Account settings</a>
{{if eq .Data.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>
</aside>
<div class="app-main">
<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>
<form class="stack-form" action="/account/password" method="post">
</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>
@@ -15,5 +37,7 @@
<p class="muted-copy">Public forgot-password is deferred until SMTP support is added. Admins can generate reset links.</p>
</div>
</div>
</div>
</div>
</section>
{{end}}

View File

@@ -1,16 +1,24 @@
{{define "admin.html"}}{{template "base" .}}{{end}}
{{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>
<p class="kicker">Operator console</p>
<h1 id="admin-title">Admin overview</h1>
<h1 id="admin-title">{{.Data.PageTitle}}</h1>
</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 class="metric-grid">
@@ -94,5 +102,6 @@
</div>
</div>
</div>
</div>
</section>
{{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 "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>
<p class="kicker">Operator console</p>
<h1 id="admin-users-title">Users</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>
<h1 id="admin-users-title">{{.Data.PageTitle}}</h1>
</div>
</div>
@@ -37,7 +45,7 @@
<div class="table-header"><h2>Users</h2><p>Disable accounts or create reset links.</p></div>
<div class="admin-table-wrap">
<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>
{{range .Data.Users}}
<tr>
@@ -45,6 +53,8 @@
<td>{{.Email}}</td>
<td>{{.Role}}</td>
<td><span class="badge">{{.Status}}</span></td>
<td>{{.StorageUsed}} / {{.StorageQuota}}</td>
<td>{{.DailyUsed}}</td>
<td>{{.CreatedAt}}</td>
<td class="table-actions">
{{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>
{{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}}/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>
</tr>
{{else}}
<tr><td colspan="6">No users yet.</td></tr>
<tr><td colspan="8">No users yet.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
</div>
</section>
{{end}}

View File

@@ -6,6 +6,9 @@
<a class="sidebar-link is-active" href="/app">Dashboard</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}}
<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">
<label>
<span>New collection</span>

View File

@@ -15,7 +15,7 @@
</span>
<span class="drop-title">Drop files to upload</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>
</label>

View File

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