2 Commits

Author SHA1 Message Date
830d2a885c refactor(ui): remaster settings and navigation layout
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m8s
- Update navigation labels from "My Account" to "Dashboard" and "Login" to "Sign in", updating tests accordingly.
- Redesign settings forms into structured sections with improved spacing and layout.
- Add CSS styles for tabs, small buttons, and responsive settings sections to enhance the user experience.
2026-05-30 18:17:13 +03:00
d77f164900 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.
2026-05-30 17:23:20 +03:00
31 changed files with 1953 additions and 194 deletions

View File

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

111
CLAUDE.md Normal file
View File

@@ -0,0 +1,111 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
**Go Executable:**
```bash
/home/linuxbrew/.linuxbrew/bin/go
```
**Run dev server:**
```bash
# First-time setup: copy the env template
cp scripts/env/dev.env.example scripts/env/dev.env
# Edit scripts/env/dev.env to set WARPBOX_ADMIN_TOKEN and other values, then:
./scripts/run/dev.sh
```
**Run directly (one-off):**
```bash
cd backend
go run ./cmd/warpbox
```
**Run all tests:**
```bash
cd backend
go test ./...
```
**Run a single test or package:**
```bash
cd backend
go test ./libs/services/... -run TestDeleteTokenVerification
go test ./libs/handlers/... -v
```
**Build:**
```bash
cd backend
go build ./cmd/warpbox
```
## Architecture
Warpbox is a self-hosted file-sharing app. All code lives under `backend/`. There is no frontend build step — the server renders Go templates and serves static assets directly.
### Startup flow
`cmd/warpbox/main.go``config.Load()``httpserver.New()``server.ListenAndServe()`
`httpserver.New()` wires everything together:
1. Creates `web.Renderer` (template engine)
2. Creates `UploadService` (opens bbolt DB, creates `files/` and `db/` dirs)
3. Creates `AuthService` (reuses same `*bbolt.DB`)
4. Creates `SettingsService` (reuses same `*bbolt.DB`)
5. Starts background jobs via `jobs.StartAll`
6. Creates `handlers.App` with all services
7. Registers all routes on a `http.ServeMux`
8. Wraps the mux in middleware chain: `Recoverer → RequestID → SecurityHeaders → Gzip → Logger`
### Services
The three services share a single bbolt database. Each owns distinct buckets:
| Service | Buckets |
|---|---|
| `UploadService` | `boxes` |
| `AuthService` | `users`, `user_emails`, `sessions`, `invites`, `collections` |
| `SettingsService` | `settings`, `usage` |
`UploadService` owns the DB handle. `AuthService` and `SettingsService` receive `uploadService.DB()`.
### Data model
- **Box** — one upload session. Has expiry, optional download limit, optional password (SHA-256 salted hash), optional owner (`OwnerID`), optional collection. Stored as JSON in the `boxes` bucket.
- **File** — belongs to a Box. Stored on disk as `data/files/{boxID}/@each@{fileID}.ext`. Thumbnails at `@thumb@{fileID}.jpg`.
- Box metadata is also written to `data/files/{boxID}/.warpbox.box.json` on every save.
- **User** passwords are hashed with argon2id. Session tokens are SHA-256 hashed before storage.
- **Delete tokens** for anonymous boxes are one-time random IDs, stored only as a SHA-256 hash.
### Handlers
`handlers.App` holds all three services plus config, logger, and renderer. `RegisterRoutes` maps every URL pattern. Handler files are split by concern: `upload.go`, `download.go`, `dashboard.go` (user `/app`), `admin.go`, `auth.go`, `manage.go`, `pages.go`.
### Upload policy enforcement
`SettingsService` stores per-day usage records keyed by `ip:{ip}:{date}` or `user:{userID}:{date}`. The upload handler (`handlers/upload.go`) checks these against `UploadPolicySettings` before accepting a multipart form. Admins bypass all per-upload and daily limits.
### Background jobs
`jobs.StartAll` launches goroutines for:
- **Cleanup** (`WARPBOX_CLEANUP_ENABLED`): deletes expired boxes and boxes that hit their download limit.
- **Thumbnails** (`WARPBOX_THUMBNAIL_ENABLED`): generates JPEG thumbnails for image/video files that don't have one yet.
### Configuration
All config comes from env vars via `config.Load()`. The dev script sources `scripts/env/dev.env`. `WARPBOX_BASE_URL` is required and must not be empty. Size values accept an optional `MB`/`Mb` suffix and support fractions (e.g. `0.5` = 512 KiB).
### Logging
Structured JSONL logs go to `data/logs/{YYYY-MM-DD}.log` via `log/slog`. Every log entry includes `source` (e.g. `"user-upload"`, `"admin"`) and `severity` fields. User-activity events include a numeric `code` field (e.g. `2001` = upload complete, `2101` = box deleted).
### Template rendering
`web.Renderer` parses all templates from `backend/templates/` at startup using `html/template`. Page data is passed as `web.PageData`. The base layout is `templates/layouts/base.html`. The current logged-in user is injected into every page render via `a.currentPublicUser(r)`.
## First-run bootstrap
On a fresh `data/` directory, visit `/register` to create the first admin account. After bootstrap, normal registration is closed. Admins create invite links from `/admin/users`. The `WARPBOX_ADMIN_TOKEN` env var provides emergency fallback access at `/admin/login`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -73,6 +73,9 @@ svg {
} }
.site-header { .site-header {
position: sticky;
top: 0;
z-index: 20;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: rgba(9, 9, 11, 0.84); background: rgba(9, 9, 11, 0.84);
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
@@ -241,6 +244,10 @@ h1 {
align-self: start; align-self: start;
display: grid; display: grid;
gap: 0.5rem; gap: 0.5rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: rgba(24, 24, 27, 0.58);
} }
.sidebar-link { .sidebar-link {
@@ -258,6 +265,29 @@ h1 {
color: var(--foreground); color: var(--foreground);
} }
.admin-shell .app-sidebar {
border-color: rgba(125, 211, 252, 0.28);
background: linear-gradient(180deg, rgba(8, 47, 73, 0.22), rgba(24, 24, 27, 0.58));
}
.admin-shell .sidebar-link.is-active {
border-color: rgba(125, 211, 252, 0.42);
background: rgba(14, 116, 144, 0.24);
}
.admin-shell .kicker {
color: #7dd3fc;
}
.sidebar-logout {
display: grid;
margin: 0.75rem 0 0;
}
.sidebar-logout .button {
width: 100%;
}
.collection-create { .collection-create {
display: grid; display: grid;
gap: 0.6rem; gap: 0.6rem;
@@ -270,6 +300,16 @@ h1 {
gap: 1rem; gap: 1rem;
} }
.settings-stack {
display: grid;
gap: 1rem;
max-width: 44rem;
}
.settings-panel {
box-shadow: none;
}
.compact-upload .drop-zone { .compact-upload .drop-zone {
min-height: 11rem; min-height: 11rem;
} }
@@ -295,6 +335,31 @@ h1 {
width: 10rem; width: 10rem;
} }
.settings-form {
display: grid;
gap: 1.5rem;
}
.settings-form-narrow {
grid-template-columns: minmax(0, 1fr);
gap: 0.9rem;
}
.settings-form label {
display: grid;
gap: 0.35rem;
color: var(--muted-foreground);
font-size: 0.82rem;
}
.settings-form .checkbox-field {
grid-column: 1 / -1;
}
.settings-form button {
justify-self: start;
}
.drop-zone { .drop-zone {
min-height: 19rem; min-height: 19rem;
display: grid; display: grid;
@@ -1158,7 +1223,9 @@ pre code {
@media (max-width: 720px) { @media (max-width: 720px) {
.nav-links { .nav-links {
display: none; display: inline-flex;
flex-wrap: wrap;
justify-content: flex-end;
} }
.upload-view, .upload-view,
@@ -1181,10 +1248,16 @@ pre code {
} }
.docs-grid, .docs-grid,
.field-grid { .field-grid,
.app-shell,
.settings-form {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.app-sidebar {
position: static;
}
.endpoint-list div { .endpoint-list div {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 0.25rem; gap: 0.25rem;
@@ -1219,4 +1292,239 @@ pre code {
.metric-grid { .metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.tabs-bar {
flex-direction: column;
align-items: stretch;
}
.settings-section {
grid-template-columns: 1fr;
}
.new-collection-body {
position: static;
width: 100%;
margin-top: 0.5rem;
box-shadow: none;
}
}
/* ── UX remaster ───────────────────────────────────────────── */
.button-sm {
min-height: 1.85rem;
padding: 0.3rem 0.65rem;
font-size: 0.8rem;
}
/* Tab navigation */
.tabs-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.tab-list {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.3rem;
}
.tab {
display: inline-flex;
align-items: center;
height: 2rem;
padding: 0 0.75rem;
border-radius: 999px;
border: 1px solid transparent;
color: var(--muted-foreground);
font-size: 0.84rem;
font-weight: 500;
text-decoration: none;
transition: background 120ms, color 120ms, border-color 120ms;
}
.tab:hover {
background: var(--muted);
color: var(--foreground);
}
.tab.is-active {
border-color: var(--border);
background: var(--muted);
color: var(--foreground);
font-weight: 650;
}
/* Sidebar structure */
.sidebar-sep {
height: 1px;
border: 0;
background: var(--border);
margin: 0.5rem 0;
}
.sidebar-nav {
display: grid;
gap: 0.25rem;
}
/* Inline row edit (details/summary in table cells) */
.row-edit {
margin-top: 0.35rem;
}
.row-edit > summary {
display: inline-flex;
align-items: center;
color: var(--muted-foreground);
font-size: 0.72rem;
cursor: pointer;
list-style: none;
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
opacity: 0.75;
}
.row-edit > summary::-webkit-details-marker { display: none; }
.row-edit[open] > summary {
opacity: 1;
}
.row-edit-form {
display: flex;
gap: 0.4rem;
align-items: center;
margin-top: 0.4rem;
}
.row-edit-form input,
.row-edit-form select {
width: auto;
flex: 1;
min-width: 8rem;
min-height: 1.9rem;
font-size: 0.8rem;
padding: 0.25rem 0.55rem;
}
/* Badge variants */
.badge-active {
background: rgba(134, 239, 172, 0.12);
color: #86efac;
}
.badge-disabled {
background: rgba(252, 165, 165, 0.1);
color: #fca5a5;
}
.badge-expired {
opacity: 0.55;
}
/* Collection create dropdown */
.new-collection-drop {
position: relative;
flex-shrink: 0;
}
.new-collection-drop > summary {
list-style: none;
cursor: pointer;
}
.new-collection-drop > summary::-webkit-details-marker { display: none; }
.new-collection-body {
position: absolute;
right: 0;
top: calc(100% + 0.5rem);
z-index: 10;
width: 15rem;
padding: 1rem;
background: color-mix(in srgb, var(--card) 97%, #000);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
display: grid;
gap: 0.65rem;
}
.new-collection-body label {
display: grid;
gap: 0.35rem;
color: var(--muted-foreground);
font-size: 0.82rem;
}
/* Copyable URL field */
.copy-field {
display: flex;
gap: 0.5rem;
align-items: center;
margin-top: 0.75rem;
}
.copy-field input {
flex: 1;
min-width: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 0.8rem;
color: var(--muted-foreground);
}
/* Settings sections */
.settings-section {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
}
.settings-section-title {
grid-column: 1 / -1;
margin: 0;
padding-bottom: 0.6rem;
border-bottom: 1px solid var(--border);
font-size: 0.875rem;
font-weight: 650;
color: var(--foreground);
}
.settings-section .checkbox-field {
grid-column: 1 / -1;
}
.settings-section label {
display: grid;
gap: 0.35rem;
color: var(--muted-foreground);
font-size: 0.82rem;
}
/* Quota form in admin users table */
.quota-form {
display: flex;
gap: 0.4rem;
align-items: center;
margin: 0;
}
.quota-form input {
width: 6.5rem;
min-width: 0;
}
/* Nav username indicator in header */
.nav-username {
max-width: 8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }

View File

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

View File

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

View File

@@ -1,16 +1,30 @@
{{define "admin.html"}}{{template "base" .}}{{end}} {{define "admin.html"}}{{template "base" .}}{{end}}
{{define "content"}} {{define "content"}}
<section class="admin-view" aria-labelledby="admin-title"> <section class="app-shell admin-shell" aria-labelledby="admin-title">
<aside class="app-sidebar">
<nav class="sidebar-nav">
<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>
</nav>
<hr class="sidebar-sep">
<nav class="sidebar-nav">
<a class="sidebar-link" href="/app">My Files</a>
</nav>
<hr class="sidebar-sep">
<form class="sidebar-logout" action="/admin/logout" method="post">
<button class="button button-outline" type="submit">Sign out</button>
</form>
</aside>
<div class="app-main">
<div class="admin-header"> <div class="admin-header">
<div> <div>
<p class="kicker">Operator console</p> <p class="kicker">Operator console</p>
<h1 id="admin-title">Admin overview</h1> <h1 id="admin-title">{{.Data.PageTitle}}</h1>
</div> </div>
<form action="/admin/logout" method="post">
<a class="button button-outline" href="/admin/users">Users</a>
<button class="button button-outline" type="submit">Logout</button>
</form>
</div> </div>
<div class="metric-grid"> <div class="metric-grid">
@@ -94,5 +108,6 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</section> </section>
{{end}} {{end}}

View File

@@ -0,0 +1,78 @@
{{define "admin_settings.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="app-shell admin-shell" aria-labelledby="admin-settings-title">
<aside class="app-sidebar">
<nav class="sidebar-nav">
<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>
</nav>
<hr class="sidebar-sep">
<nav class="sidebar-nav">
<a class="sidebar-link" href="/app">My Files</a>
</nav>
<hr class="sidebar-sep">
<form class="sidebar-logout" action="/admin/logout" method="post">
<button class="button button-outline" type="submit">Sign out</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>Admin users bypass all upload caps. Values are in megabytes.</p>
</div>
</div>
<form class="settings-form" action="/admin/settings" method="post">
<div class="settings-section">
<h3 class="settings-section-title">Anonymous uploads</h3>
<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>Max upload size (MB)</span>
<input name="anonymous_max_upload_mb" value="{{.Data.Settings.AnonymousMaxUploadMB}}" required>
</label>
<label>
<span>Daily cap per IP (MB)</span>
<input name="anonymous_daily_upload_mb" value="{{.Data.Settings.AnonymousDailyUploadMB}}" required>
</label>
</div>
<div class="settings-section">
<h3 class="settings-section-title">User limits</h3>
<label>
<span>Daily upload cap (MB)</span>
<input name="user_daily_upload_mb" value="{{.Data.Settings.UserDailyUploadMB}}" required>
</label>
<label>
<span>Default storage quota (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>
</div>
<button class="button button-primary" type="submit">Save settings</button>
</form>
</div>
</div>
</div>
</section>
{{end}}

View File

@@ -1,65 +1,110 @@
{{define "admin_users.html"}}{{template "base" .}}{{end}} {{define "admin_users.html"}}{{template "base" .}}{{end}}
{{define "content"}} {{define "content"}}
<section class="admin-view" aria-labelledby="admin-users-title"> <section class="app-shell admin-shell" aria-labelledby="admin-users-title">
<div class="admin-header"> <aside class="app-sidebar">
<div> <nav class="sidebar-nav">
<p class="kicker">Operator console</p> <a class="sidebar-link" href="/admin">Overview</a>
<h1 id="admin-users-title">Users</h1> <a class="sidebar-link" href="/admin/files">Files</a>
</div> <a class="sidebar-link is-active" href="/admin/users">Users</a>
<div class="result-actions"> <a class="sidebar-link" href="/admin/settings">Settings</a>
<a class="button button-outline" href="/admin">Overview</a> </nav>
<a class="button button-outline" href="/admin/files">Files</a> <hr class="sidebar-sep">
</div> <nav class="sidebar-nav">
</div> <a class="sidebar-link" href="/app">My Files</a>
</nav>
<hr class="sidebar-sep">
<form class="sidebar-logout" action="/admin/logout" method="post">
<button class="button button-outline" type="submit">Sign out</button>
</form>
</aside>
<div class="card admin-table-card"> <div class="app-main">
<div class="card-content"> <div class="admin-header">
<div class="table-header"> <div>
<div> <p class="kicker">Operator console</p>
<h2>Create invite</h2> <h1 id="admin-users-title">{{.Data.PageTitle}}</h1>
<p>Copy the generated link and send it manually. SMTP delivery comes later.</p>
</div>
</div> </div>
{{if .Data.LastInviteURL}}
<p class="manage-link"><span>Invite link:</span> <a href="{{.Data.LastInviteURL}}">{{.Data.LastInviteURL}}</a></p>
{{end}}
<form class="inline-controls" action="/admin/invites" method="post">
<label><span>Email</span><input type="email" name="email" required></label>
<label><span>Role</span><select name="role"><option value="user">User</option><option value="admin">Admin</option></select></label>
<button class="button button-primary" type="submit">Create invite</button>
</form>
</div> </div>
</div>
<div class="card admin-table-card"> <div class="card admin-table-card">
<div class="card-content"> <div class="card-content">
<div class="table-header"><h2>Users</h2><p>Disable accounts or create reset links.</p></div> <div class="table-header">
<div class="admin-table-wrap"> <div>
<table class="admin-table"> <h2>Create invite</h2>
<thead><tr><th>User</th><th>Email</th><th>Role</th><th>Status</th><th>Joined</th><th>Actions</th></tr></thead> <p>Copy the generated link and send it manually. SMTP delivery comes later.</p>
<tbody> </div>
{{range .Data.Users}} </div>
{{if .Data.LastInviteURL}}
<div class="copy-field">
<input type="text" value="{{.Data.LastInviteURL}}" readonly id="invite-url-field" aria-label="Invite link">
<button class="button button-outline button-sm" type="button"
onclick="navigator.clipboard.writeText(document.getElementById('invite-url-field').value).then(()=>{this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',2000)})">Copy</button>
</div>
{{end}}
<form class="inline-controls" action="/admin/invites" method="post">
<label><span>Email</span><input type="email" name="email" required></label>
<label><span>Role</span><select name="role"><option value="user">User</option><option value="admin">Admin</option></select></label>
<button class="button button-primary" type="submit">Create invite</button>
</form>
</div>
</div>
<div class="card admin-table-card">
<div class="card-content">
<div class="table-header">
<h2>Users</h2>
<p>Disable accounts or generate reset links.</p>
</div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr> <tr>
<td>{{.Username}}</td> <th>User</th>
<td>{{.Email}}</td> <th>Email</th>
<td>{{.Role}}</td> <th>Role</th>
<td><span class="badge">{{.Status}}</span></td> <th>Status</th>
<td>{{.CreatedAt}}</td> <th>Storage</th>
<td class="table-actions"> <th>Today</th>
{{if eq .Status "disabled"}} <th>Joined</th>
<form action="/admin/users/{{.ID}}/disable?disabled=false" method="post"><button class="button button-outline" type="submit">Reactivate</button></form> <th>Actions</th>
{{else}}
<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>
</td>
</tr> </tr>
{{else}} </thead>
<tr><td colspan="6">No users yet.</td></tr> <tbody>
{{end}} {{range .Data.Users}}
</tbody> <tr>
</table> <td>{{.Username}}</td>
<td>{{.Email}}</td>
<td>{{.Role}}</td>
<td><span class="badge {{if eq .Status "active"}}badge-active{{else}}badge-disabled{{end}}">{{.Status}}</span></td>
<td>{{.StorageUsed}} / {{.StorageQuota}}</td>
<td>{{.DailyUsed}}</td>
<td>{{.CreatedAt}}</td>
<td class="table-actions">
{{if eq .Status "disabled"}}
<form action="/admin/users/{{.ID}}/disable?disabled=false" method="post">
<button class="button button-outline button-sm" type="submit">Reactivate</button>
</form>
{{else}}
<form action="/admin/users/{{.ID}}/disable" method="post">
<button class="button button-danger button-sm" type="submit">Disable</button>
</form>
{{end}}
<form action="/admin/users/{{.ID}}/reset" method="post">
<button class="button button-outline button-sm" type="submit">Reset link</button>
</form>
<form class="quota-form" action="/admin/users/{{.ID}}/quota" method="post">
<input name="storage_quota_mb" placeholder="Quota MB" title="Override storage quota in MB (leave blank to clear override)">
<button class="button button-outline button-sm" type="submit">Set</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="8" class="muted-copy">No users yet.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -3,15 +3,14 @@
{{define "content"}} {{define "content"}}
<section class="app-shell" aria-labelledby="dashboard-title"> <section class="app-shell" aria-labelledby="dashboard-title">
<aside class="app-sidebar"> <aside class="app-sidebar">
<a class="sidebar-link is-active" href="/app">Dashboard</a> <nav class="sidebar-nav">
<a class="sidebar-link" href="/account/settings">Settings</a> <a class="sidebar-link is-active" href="/app">My Files</a>
{{if eq .Data.User.Role "admin"}}<a class="sidebar-link" href="/admin">Admin</a>{{end}} <a class="sidebar-link" href="/account/settings">Account</a>
<form class="collection-create" action="/app/collections" method="post"> {{if eq .Data.User.Role "admin"}}<a class="sidebar-link" href="/admin">Admin panel</a>{{end}}
<label> </nav>
<span>New collection</span> <hr class="sidebar-sep">
<input name="name" placeholder="Projects"> <form class="sidebar-logout" action="/logout" method="post">
</label> <button class="button button-outline" type="submit">Sign out</button>
<button class="button button-outline" type="submit">Create</button>
</form> </form>
</aside> </aside>
@@ -19,46 +18,91 @@
<div class="admin-header"> <div class="admin-header">
<div> <div>
<p class="kicker">Personal space</p> <p class="kicker">Personal space</p>
<h1 id="dashboard-title">My files</h1> <h1 id="dashboard-title">My Files</h1>
<p class="muted-copy">{{.Data.StorageUsed}} used · max file size {{.Data.MaxUploadSize}}</p> <p class="muted-copy">{{.Data.StorageUsed}} used · max file size {{.Data.MaxUploadSize}}</p>
</div> </div>
<a class="button button-primary" href="/">Upload files</a> <a class="button button-primary" href="/">Upload files</a>
</div> </div>
<div class="collection-tabs"> <div class="tabs-bar">
<a class="button {{if not .Data.Selected}}button-primary{{else}}button-outline{{end}}" href="/app">All</a> <div class="tab-list" role="tablist">
{{range .Data.Collections}} <a class="tab {{if not .Data.Selected}}is-active{{end}}" href="/app">All</a>
<a class="button {{if eq $.Data.Selected .ID}}button-primary{{else}}button-outline{{end}}" href="/app?collection={{.ID}}">{{.Name}}</a> {{range .Data.Collections}}
{{end}} <a class="tab {{if eq $.Data.Selected .ID}}is-active{{end}}" href="/app?collection={{.ID}}">{{.Name}}</a>
{{end}}
</div>
<details class="new-collection-drop">
<summary class="button button-outline button-sm">+ Collection</summary>
<div class="new-collection-body">
<form action="/app/collections" method="post">
<label>
<span>Name</span>
<input name="name" placeholder="e.g. Projects" required>
</label>
<button class="button button-primary button-sm" type="submit">Create</button>
</form>
</div>
</details>
</div> </div>
<div class="card admin-table-card"> <div class="card admin-table-card">
<div class="card-content"> <div class="card-content">
<div class="table-header"><h2>Owned boxes</h2><p>Collections organize boxes. Shared links remain unlisted.</p></div> <div class="table-header">
<div>
<h2>Boxes</h2>
<p>Collections organise boxes. Shared links remain unlisted.</p>
</div>
</div>
<div class="admin-table-wrap"> <div class="admin-table-wrap">
<table class="admin-table"> <table class="admin-table">
<thead><tr><th>Title</th><th>Collection</th><th>Files</th><th>Size</th><th>Created</th><th>Expires</th><th>Actions</th></tr></thead> <thead>
<tr>
<th>Title</th>
<th>Collection</th>
<th>Files</th>
<th>Size</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody> <tbody>
{{range .Data.Boxes}} {{range .Data.Boxes}}
<tr> <tr>
<td class="file-name">{{.Title}}</td> <td>
<td>{{if .CollectionName}}{{.CollectionName}}{{else}}Unsorted{{end}}</td> <div class="file-name">{{.Title}}</div>
<details class="row-edit">
<summary>Rename</summary>
<form action="/app/boxes/{{.ID}}/rename" method="post" class="row-edit-form">
<input name="title" placeholder="New title">
<button class="button button-outline button-sm" type="submit">Save</button>
</form>
</details>
</td>
<td>
<div>{{if .CollectionName}}{{.CollectionName}}{{else}}<span class="muted-copy"></span>{{end}}</div>
<details class="row-edit">
<summary>Move</summary>
<form action="/app/boxes/{{.ID}}/move" method="post" class="row-edit-form">
<select name="collection_id">
<option value="">Unsorted</option>
{{range $.Data.Collections}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
<button class="button button-outline button-sm" type="submit">Move</button>
</form>
</details>
</td>
<td>{{.FileCount}}</td> <td>{{.FileCount}}</td>
<td>{{.Size}}</td> <td>{{.Size}}</td>
<td>{{.CreatedAt}}</td>
<td>{{.ExpiresAt}}</td> <td>{{.ExpiresAt}}</td>
<td class="table-actions"> <td class="table-actions">
<a class="button button-outline" href="{{.URL}}" target="_blank" rel="noopener noreferrer">Open</a> <a class="button button-outline button-sm" href="{{.URL}}" target="_blank" rel="noopener noreferrer">Open</a>
<form action="/app/boxes/{{.ID}}/rename" method="post"><input class="compact-input" name="title" placeholder="Rename"><button class="button button-outline" type="submit">Save</button></form> <form action="/app/boxes/{{.ID}}/delete" method="post">
<form action="/app/boxes/{{.ID}}/move" method="post"> <button class="button button-danger button-sm" type="submit">Delete</button>
<select name="collection_id"><option value="">Unsorted</option>{{range $.Data.Collections}}<option value="{{.ID}}">{{.Name}}</option>{{end}}</select>
<button class="button button-outline" type="submit">Move</button>
</form> </form>
<form action="/app/boxes/{{.ID}}/delete" method="post"><button class="button button-danger" type="submit">Delete</button></form>
</td> </td>
</tr> </tr>
{{else}} {{else}}
<tr><td colspan="7">You have no boxes yet.</td></tr> <tr><td colspan="6" class="muted-copy">No boxes yet. Upload some files to get started.</td></tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>

View File

@@ -3,8 +3,13 @@
{{define "content"}} {{define "content"}}
<section class="upload-view" aria-labelledby="upload-title"> <section class="upload-view" aria-labelledby="upload-title">
<div class="hero-copy"> <div class="hero-copy">
{{if .CurrentUser}}
<h1 id="upload-title">Upload files.</h1>
<p>{{.Data.LimitSummary}}</p>
{{else}}
<h1 id="upload-title">Send a file. Get a link.</h1> <h1 id="upload-title">Send a file. Get a link.</h1>
<p>Anonymous, self-hosted transfers. No account required.</p> <p>Anonymous, self-hosted transfers. No account required.</p>
{{end}}
</div> </div>
<form class="upload-panel card" id="upload-form" action="/api/v1/upload" method="post" enctype="multipart/form-data"> <form class="upload-panel card" id="upload-form" action="/api/v1/upload" method="post" enctype="multipart/form-data">
@@ -15,7 +20,7 @@
</span> </span>
<span class="drop-title">Drop files to upload</span> <span class="drop-title">Drop files to upload</span>
<span class="drop-copy">or click to browse</span> <span class="drop-copy">or click to browse</span>
<span class="drop-meta">{{if .Data.IsAdmin}}Admin upload: no file size limit{{else}}Max file size: {{.Data.MaxUploadSize}}{{end}} · Links expire in 7 days</span> <span class="drop-meta">Max file size: {{.Data.MaxUploadSize}} · {{.Data.LimitSummary}}</span>
<input id="file-input" name="file" type="file" multiple> <input id="file-input" name="file" type="file" multiple>
</label> </label>

BIN
backend/warpbox Executable file

Binary file not shown.

View File

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