2 Commits

Author SHA1 Message Date
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
9a3cb90b17 feat(accounts): implement user accounts, sessions, and dashboards
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m8s
Introduce Stage 4 features to support multi-user accounts, cookie-based web sessions, and personal dashboards.

Changes include:
- Adding `/register` to bootstrap the first admin account and `/login`/`/logout` for session management.
- Creating a personal dashboard (`/app`) to display owned boxes, storage usage, and upload history.
- Implementing admin user management (`/admin/users`) for generating invite links and managing user states.
- Updating the bbolt database schema to store users, sessions, invites, and collections.
- Adding `golang.org/x/crypto` for password hashing and introducing unit tests for account handlers.
2026-05-30 15:42:35 +03:00
34 changed files with 3304 additions and 57 deletions

View File

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

View File

@@ -13,6 +13,16 @@ The default server listens on `:8080`.
Upload size limits are configured in megabytes through `WARPBOX_MAX_UPLOAD_SIZE_MB`. Upload size limits are configured in megabytes through `WARPBOX_MAX_UPLOAD_SIZE_MB`.
Fractions are supported, so `0.5Mb` is 512 KiB and `1.5Mb` is 1536 KiB. Fractions are supported, so `0.5Mb` is 512 KiB and `1.5Mb` is 1536 KiB.
Upload policy defaults are also configured in megabytes and can later be changed from
`/admin/settings`:
- `WARPBOX_ANONYMOUS_UPLOADS_ENABLED=true`
- `WARPBOX_ANONYMOUS_MAX_UPLOAD_MB=512`
- `WARPBOX_ANONYMOUS_DAILY_UPLOAD_MB=2048`
- `WARPBOX_USER_DAILY_UPLOAD_MB=8192`
- `WARPBOX_DEFAULT_USER_STORAGE_MB=51200`
- `WARPBOX_USAGE_RETENTION_DAYS=30`
Runtime data is configured with `WARPBOX_DATA_DIR` and defaults to `./data` in the dev environment. Runtime data is configured with `WARPBOX_DATA_DIR` and defaults to `./data` in the dev environment.
The dev script resolves that path from the repository root. The dev script resolves that path from the repository root.
@@ -20,7 +30,12 @@ Background jobs are enabled with `WARPBOX_JOBS_ENABLED=true`. Individual jobs ca
`WARPBOX_CLEANUP_ENABLED` and `WARPBOX_THUMBNAIL_ENABLED`, and their schedules are configured with `WARPBOX_CLEANUP_ENABLED` and `WARPBOX_THUMBNAIL_ENABLED`, and their schedules are configured with
`WARPBOX_CLEANUP_EVERY` and `WARPBOX_THUMBNAIL_EVERY`. `WARPBOX_CLEANUP_EVERY` and `WARPBOX_THUMBNAIL_EVERY`.
The basic admin console is available at `/admin`. Set `WARPBOX_ADMIN_TOKEN` and use that value to sign in. On a fresh data directory, visit `/register` to create the first account. That first user becomes
the instance admin and normal registration closes after bootstrap. Admins can create copyable invite
links from `/admin/users`.
The env admin token still exists as emergency fallback access. Set `WARPBOX_ADMIN_TOKEN` and use it
at `/admin/login` if you need to recover access without a user session.
For one-off Go commands, run them from the backend module: For one-off Go commands, run them from the backend module:
@@ -97,6 +112,26 @@ curl -F sharex=@./screenshot.png \
The upload endpoint accepts multipart fields named `file` and `sharex`. ShareX users can start The upload endpoint accepts multipart fields named `file` and `sharex`. ShareX users can start
from `examples/sharex/warpbox-anonymous.sxcu`; update `RequestURL` to match your instance URL. from `examples/sharex/warpbox-anonymous.sxcu`; update `RequestURL` to match your instance URL.
## Stage 4 Accounts + Personal Boxes
- `/register` bootstraps the first admin account only when no users exist.
- `/login` and `/logout` provide cookie-based web sessions.
- `/app` is the personal dashboard for logged-in users, showing owned boxes, storage usage, upload
history, and flat collections. Uploading still happens from the homepage.
- `/admin/users` lets admins create invite links, disable/reactivate users, and generate reset links.
- Logged-in browser uploads from `/` still use `POST /api/v1/upload`, but the resulting box is
stored with owner and optional collection metadata.
- Admin users are exempt from the global max upload size on the homepage upload flow. Future
per-user quotas should apply to this same upload path rather than creating a second uploader.
- `/admin/settings` controls anonymous uploads, anonymous max upload size, daily upload caps, default
user storage quota, and usage retention.
- `/admin/users` shows storage/daily usage and lets admins set per-user storage quota overrides.
- Anonymous uploads, ShareX uploads, unlisted public box links, password protection, expiry, delete
tokens, thumbnails, and cleanup continue to work as before.
Email delivery is intentionally deferred. Invite and reset links are copyable today; future SMTP
support will power public forgot-password and optional email delivery.
## Runtime Data ## Runtime Data
Warpbox keeps local runtime data under the configured data directory: Warpbox keeps local runtime data under the configured data directory:
@@ -104,6 +139,9 @@ Warpbox keeps local runtime data under the configured data directory:
- `data/files/{box_id}/@each@{file_id}.ext` - uploaded file contents. - `data/files/{box_id}/@each@{file_id}.ext` - uploaded file contents.
- `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` 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

@@ -4,7 +4,8 @@ go 1.26
require ( require (
go.etcd.io/bbolt v1.4.3 go.etcd.io/bbolt v1.4.3
golang.org/x/crypto v0.33.0
golang.org/x/image v0.41.0 golang.org/x/image v0.41.0
) )
require golang.org/x/sys v0.29.0 // indirect require golang.org/x/sys v0.30.0 // indirect

View File

@@ -6,11 +6,13 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

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

@@ -0,0 +1,444 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"warpbox.dev/backend/libs/services"
)
func TestLoggedInUploadStoresOwnerAndAnonymousUploadDoesNot(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
user, err := app.authService.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
_, token, err := app.authService.Login("daniel@example.test", "password123")
if err != nil {
t.Fatalf("Login returned error: %v", err)
}
request := multipartUploadRequest(t, "/api/v1/upload", "file", "owned.txt", "owned")
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("owned upload status = %d, body = %s", response.Code, response.Body.String())
}
var ownedPayload services.UploadResult
if err := json.Unmarshal(response.Body.Bytes(), &ownedPayload); err != nil {
t.Fatalf("json.Unmarshal owned returned error: %v", err)
}
ownedBox, err := app.uploadService.GetBox(ownedPayload.BoxID)
if err != nil {
t.Fatalf("GetBox owned returned error: %v", err)
}
if ownedBox.OwnerID != user.ID {
t.Fatalf("owned OwnerID = %q, want %q", ownedBox.OwnerID, user.ID)
}
owned := uploadThroughApp(t, app)
anonymous, err := app.uploadService.GetBox(owned.BoxID)
if err != nil {
t.Fatalf("GetBox anonymous returned error: %v", err)
}
if anonymous.OwnerID != "" {
t.Fatalf("anonymous OwnerID = %q, want empty", anonymous.OwnerID)
}
boxes, err := app.uploadService.ListBoxes(0)
if err != nil {
t.Fatalf("ListBoxes returned error: %v", err)
}
foundOwned := false
for _, box := range boxes {
if box.OwnerID == user.ID {
foundOwned = true
}
}
if !foundOwned {
t.Fatalf("logged-in upload did not store owner id %q", user.ID)
}
}
func TestInviteHandlerCreatesUserAndMarksInviteUsed(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("friend@example.test", services.UserRoleUser, admin.ID, 0)
if err != nil {
t.Fatalf("CreateInvite returned error: %v", err)
}
request := httptest.NewRequest(http.MethodPost, "/invite/"+invite.Token, strings.NewReader("username=friend&password=password123"))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.SetPathValue("token", invite.Token)
response := httptest.NewRecorder()
app.InvitePost(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("InvitePost status = %d, body = %s", response.Code, response.Body.String())
}
if _, err := app.authService.AcceptInvite(invite.Token, "friend", "password123"); err == nil {
t.Fatalf("invite token remained reusable")
}
}
func TestNonOwnerCannotManageOwnedBox(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
owner, err := app.authService.CreateBootstrapUser("owner", "owner@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
invite, err := app.authService.CreateInvite("other@example.test", services.UserRoleUser, owner.ID, 0)
if err != nil {
t.Fatalf("CreateInvite returned error: %v", err)
}
other, err := app.authService.AcceptInvite(invite.Token, "other", "password123")
if err != nil {
t.Fatalf("AcceptInvite returned error: %v", err)
}
result := createOwnedBoxThroughApp(t, app, owner.ID)
if err := app.uploadService.RenameOwnedBox(result.BoxID, other.ID, "stolen"); err == nil {
t.Fatalf("RenameOwnedBox allowed non-owner")
}
}
func TestAdminUploadBypassesMaxUploadSize(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 := multipartUploadRequest(t, "/api/v1/upload", "file", "large.txt", strings.Repeat("x", int(app.uploadService.MaxUploadSize())+1))
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("admin upload status = %d, body = %s", response.Code, response.Body.String())
}
}
func TestAnonymousUploadDisabled(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
policy := testPolicy(t, app)
policy.AnonymousUploadsEnabled = false
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
request := multipartUploadRequest(t, "/api/v1/upload", "file", "note.txt", "hello")
request.Header.Set("Accept", "application/json")
response := httptest.NewRecorder()
app.Upload(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403, body = %s", response.Code, response.Body.String())
}
}
func TestAnonymousUploadLimits(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
policy := testPolicy(t, app)
policy.AnonymousMaxUploadMB = 1
policy.AnonymousDailyUploadMB = 0.001
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
large := multipartUploadRequest(t, "/api/v1/upload", "file", "large.txt", strings.Repeat("x", 2*1024*1024))
large.Header.Set("Accept", "application/json")
large.RemoteAddr = "192.0.2.10:1234"
largeResponse := httptest.NewRecorder()
app.Upload(largeResponse, large)
if largeResponse.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("large status = %d, body = %s", largeResponse.Code, largeResponse.Body.String())
}
daily := multipartUploadRequest(t, "/api/v1/upload", "file", "note.txt", strings.Repeat("x", 2048))
daily.Header.Set("Accept", "application/json")
daily.RemoteAddr = "192.0.2.10:1234"
dailyResponse := httptest.NewRecorder()
app.Upload(dailyResponse, daily)
if dailyResponse.Code != http.StatusTooManyRequests {
t.Fatalf("daily status = %d, body = %s", dailyResponse.Code, dailyResponse.Body.String())
}
}
func TestSignedInUploadQuotaAndOverride(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
user, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
invite, err := app.authService.CreateInvite("user@example.test", services.UserRoleUser, user.ID, 0)
if err != nil {
t.Fatalf("CreateInvite returned error: %v", err)
}
normal, err := app.authService.AcceptInvite(invite.Token, "user", "password123")
if err != nil {
t.Fatalf("AcceptInvite returned error: %v", err)
}
_, token, err := app.authService.Login(normal.Email, "password123")
if err != nil {
t.Fatalf("Login returned error: %v", err)
}
policy := testPolicy(t, app)
policy.DefaultUserStorageMB = 0.001
policy.UserDailyUploadMB = 8
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
request := multipartUploadRequest(t, "/api/v1/upload", "file", "quota.txt", strings.Repeat("x", 2048))
request.Header.Set("Accept", "application/json")
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
response := httptest.NewRecorder()
app.Upload(response, request)
if response.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("quota status = %d, body = %s", response.Code, response.Body.String())
}
override := 10.0
if err := app.authService.SetUserStorageQuota(normal.ID, &override); err != nil {
t.Fatalf("SetUserStorageQuota returned error: %v", err)
}
request = multipartUploadRequest(t, "/api/v1/upload", "file", "quota.txt", strings.Repeat("x", 2048))
request.Header.Set("Accept", "application/json")
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
response = httptest.NewRecorder()
app.Upload(response, request)
if response.Code != http.StatusCreated {
t.Fatalf("override status = %d, body = %s", response.Code, response.Body.String())
}
}
func TestSignedInDailyCap(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
admin, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
invite, err := app.authService.CreateInvite("user@example.test", services.UserRoleUser, admin.ID, 0)
if err != nil {
t.Fatalf("CreateInvite returned error: %v", err)
}
user, err := app.authService.AcceptInvite(invite.Token, "user", "password123")
if err != nil {
t.Fatalf("AcceptInvite returned error: %v", err)
}
_, token, err := app.authService.Login(user.Email, "password123")
if err != nil {
t.Fatalf("Login returned error: %v", err)
}
policy := testPolicy(t, app)
policy.UserDailyUploadMB = 0.001
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
request := multipartUploadRequest(t, "/api/v1/upload", "file", "daily.txt", strings.Repeat("x", 2048))
request.Header.Set("Accept", "application/json")
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
response := httptest.NewRecorder()
app.Upload(response, request)
if response.Code != http.StatusTooManyRequests {
t.Fatalf("daily status = %d, body = %s", response.Code, response.Body.String())
}
}
func TestAdminSettingsPostChangesUploadEnforcement(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
_, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
_, token, err := app.authService.Login("admin@example.test", "password123")
if err != nil {
t.Fatalf("Login returned error: %v", err)
}
settingsForm := strings.NewReader("anonymous_max_upload_mb=512&anonymous_daily_upload_mb=2048&user_daily_upload_mb=8192&default_user_storage_mb=51200&usage_retention_days=30")
settingsRequest := httptest.NewRequest(http.MethodPost, "/admin/settings", settingsForm)
settingsRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
settingsRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
settingsResponse := httptest.NewRecorder()
app.AdminSettingsPost(settingsResponse, settingsRequest)
if settingsResponse.Code != http.StatusSeeOther {
t.Fatalf("AdminSettingsPost status = %d, body = %s", settingsResponse.Code, settingsResponse.Body.String())
}
uploadRequest := multipartUploadRequest(t, "/api/v1/upload", "file", "note.txt", "hello")
uploadRequest.Header.Set("Accept", "application/json")
uploadResponse := httptest.NewRecorder()
app.Upload(uploadResponse, uploadRequest)
if uploadResponse.Code != http.StatusForbidden {
t.Fatalf("upload status = %d, want 403, body = %s", uploadResponse.Code, uploadResponse.Body.String())
}
}
func TestAdminUserQuotaPostChangesEnforcement(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
admin, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
invite, err := app.authService.CreateInvite("user@example.test", services.UserRoleUser, admin.ID, 0)
if err != nil {
t.Fatalf("CreateInvite returned error: %v", err)
}
user, err := app.authService.AcceptInvite(invite.Token, "user", "password123")
if err != nil {
t.Fatalf("AcceptInvite returned error: %v", err)
}
_, adminToken, err := app.authService.Login(admin.Email, "password123")
if err != nil {
t.Fatalf("admin Login returned error: %v", err)
}
quotaRequest := httptest.NewRequest(http.MethodPost, "/admin/users/"+user.ID+"/quota", strings.NewReader("storage_quota_mb=0.001"))
quotaRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
quotaRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: adminToken})
quotaRequest.SetPathValue("userID", user.ID)
quotaResponse := httptest.NewRecorder()
app.AdminUpdateUserQuota(quotaResponse, quotaRequest)
if quotaResponse.Code != http.StatusSeeOther {
t.Fatalf("AdminUpdateUserQuota status = %d, body = %s", quotaResponse.Code, quotaResponse.Body.String())
}
_, userToken, err := app.authService.Login(user.Email, "password123")
if err != nil {
t.Fatalf("user Login returned error: %v", err)
}
uploadRequest := multipartUploadRequest(t, "/api/v1/upload", "file", "quota.txt", strings.Repeat("x", 2048))
uploadRequest.Header.Set("Accept", "application/json")
uploadRequest.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: userToken})
uploadResponse := httptest.NewRecorder()
app.Upload(uploadResponse, uploadRequest)
if uploadResponse.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("upload status = %d, want 413, body = %s", uploadResponse.Code, uploadResponse.Body.String())
}
}
func TestHomeReflectsUploadPolicySettings(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
policy := testPolicy(t, app)
policy.AnonymousMaxUploadMB = 123
policy.AnonymousDailyUploadMB = 456
if err := app.settingsService.UpdateUploadPolicy(policy); err != nil {
t.Fatalf("UpdateUploadPolicy returned error: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/", nil)
response := httptest.NewRecorder()
app.Home(response, request)
if response.Code != http.StatusOK {
t.Fatalf("Home status = %d", response.Code)
}
body := response.Body.String()
if !strings.Contains(body, "Max file size: 123 MB") || !strings.Contains(body, "456 MB") {
t.Fatalf("home did not reflect policy settings: %s", body)
}
}
func TestAPIDocsHeaderReflectsLoggedInUser(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
_, err := app.authService.CreateBootstrapUser("admin", "admin@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
_, token, err := app.authService.Login("admin@example.test", "password123")
if err != nil {
t.Fatalf("Login returned error: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/api", nil)
request.AddCookie(&http.Cookie{Name: userSessionCookieName, Value: token})
response := httptest.NewRecorder()
app.APIDocs(response, request)
if response.Code != http.StatusOK {
t.Fatalf("APIDocs status = %d", response.Code)
}
body := response.Body.String()
header := body[:strings.Index(body, "<main")]
if !strings.Contains(header, "My Account") || strings.Contains(header, ">Login<") || strings.Contains(header, "Health") {
t.Fatalf("api header did not reflect logged-in state: %s", body)
}
}
func TestAPIDocsHeaderReflectsLoggedOutUser(t *testing.T) {
app, cleanup := newTestApp(t)
defer cleanup()
request := httptest.NewRequest(http.MethodGet, "/api", nil)
response := httptest.NewRecorder()
app.APIDocs(response, request)
if response.Code != http.StatusOK {
t.Fatalf("APIDocs status = %d", response.Code)
}
body := response.Body.String()
header := body[:strings.Index(body, "<main")]
if !strings.Contains(header, ">Login<") || !strings.Contains(header, ">API<") || strings.Contains(header, "Health") || strings.Contains(header, "My Account") {
t.Fatalf("api header did not reflect logged-out state: %s", body)
}
}
func createOwnedBoxThroughApp(t *testing.T, app *App, userID string) services.UploadResult {
t.Helper()
user, err := app.authService.UserByID(userID)
if err != nil {
t.Fatalf("UserByID returned error: %v", err)
}
_, token, err := app.authService.Login(user.Email, "password123")
if err != nil {
t.Fatalf("Login returned error: %v", err)
}
request := multipartUploadRequest(t, "/api/v1/upload", "file", "owned.txt", "owned")
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("upload status = %d, body = %s", response.Code, response.Body.String())
}
var payload services.UploadResult
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal returned error: %v", err)
}
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

@@ -4,6 +4,8 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"net/http" "net/http"
"net/url"
"strconv"
"time" "time"
"warpbox.dev/backend/libs/services" "warpbox.dev/backend/libs/services"
@@ -15,11 +17,17 @@ const adminCookieName = "warpbox_admin"
type adminPageData struct { type adminPageData struct {
Stats services.AdminStats Stats services.AdminStats
Boxes []adminBoxView Boxes []adminBoxView
Users []adminUserView
Settings services.UploadPolicySettings
Section string
PageTitle string
LastInviteURL string
Error string Error string
} }
type adminBoxView struct { type adminBoxView struct {
ID string ID string
Owner string
CreatedAt string CreatedAt string
ExpiresAt string ExpiresAt string
FileCount int FileCount int
@@ -30,22 +38,34 @@ type adminBoxView struct {
Expired bool Expired bool
} }
type adminUserView struct {
ID string
Username string
Email string
Role string
Status string
StorageUsed string
StorageQuota string
DailyUsed string
CreatedAt string
}
func (a *App) AdminLogin(w http.ResponseWriter, r *http.Request) { func (a *App) AdminLogin(w http.ResponseWriter, r *http.Request) {
if a.isAdmin(r) { if a.isAdmin(r) {
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
} }
@@ -63,6 +83,7 @@ func (a *App) AdminLoginPost(w http.ResponseWriter, r *http.Request) {
} }
func (a *App) AdminLogout(w http.ResponseWriter, r *http.Request) { func (a *App) AdminLogout(w http.ResponseWriter, r *http.Request) {
a.clearUserSessionCookie(w)
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: adminCookieName, Name: adminCookieName,
Value: "", Value: "",
@@ -90,12 +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),
Data: adminPageData{ Data: adminPageData{
Stats: stats, Stats: stats,
Boxes: boxes, Boxes: boxes,
Section: "overview",
PageTitle: "Admin overview",
}, },
}) })
} }
@@ -116,16 +140,200 @@ 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),
Data: adminPageData{ Data: adminPageData{
Stats: stats, Stats: stats,
Boxes: boxes, Boxes: boxes,
Section: "files",
PageTitle: "Admin files",
}, },
}) })
} }
func (a *App) AdminUsers(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
stats, err := a.uploadService.AdminStats()
if err != nil {
http.Error(w, "unable to load admin stats", http.StatusInternalServerError)
return
}
users, err := a.authService.ListUsers()
if err != nil {
http.Error(w, "unable to load users", http.StatusInternalServerError)
return
}
rows := make([]adminUserView, 0, len(users))
settings, err := a.settingsService.UploadPolicy()
if err != nil {
http.Error(w, "unable to load settings", http.StatusInternalServerError)
return
}
for _, user := range users {
storageUsed, _ := a.uploadService.UserActiveStorageUsed(user.ID)
usage, _ := a.settingsService.UsageForUser(user.ID, time.Now().UTC())
quotaMB := settings.DefaultUserStorageMB
if user.StorageQuotaMB != nil {
quotaMB = *user.StorageQuotaMB
}
rows = append(rows, adminUserView{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Role: user.Role,
Status: user.Status,
StorageUsed: services.FormatMegabytesFromBytes(storageUsed),
StorageQuota: formatMB(quotaMB),
DailyUsed: services.FormatMegabytesFromBytes(usage.UploadedBytes),
CreatedAt: user.CreatedAt.Format("Jan 2 15:04"),
})
}
a.renderPage(w, r, http.StatusOK, "admin_users.html", web.PageData{
Title: "Admin users",
Description: "Manage Warpbox users and invites.",
CurrentUser: a.currentPublicUser(r),
Data: adminPageData{
Stats: stats,
Users: rows,
Section: "users",
PageTitle: "Users",
LastInviteURL: r.URL.Query().Get("invite"),
},
})
}
func (a *App) AdminSettings(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
settings, err := a.settingsService.UploadPolicy()
if err != nil {
http.Error(w, "unable to load settings", http.StatusInternalServerError)
return
}
a.renderPage(w, r, http.StatusOK, "admin_settings.html", web.PageData{
Title: "Admin settings",
Description: "Manage Warpbox upload policy.",
CurrentUser: a.currentPublicUser(r),
Data: adminPageData{
Settings: settings,
Section: "settings",
PageTitle: "Settings",
},
})
}
func (a *App) AdminSettingsPost(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
return
}
settings := services.UploadPolicySettings{
AnonymousUploadsEnabled: r.FormValue("anonymous_uploads_enabled") == "on",
UsageRetentionDays: parsePositiveInt(r.FormValue("usage_retention_days")),
}
var err error
if settings.AnonymousMaxUploadMB, err = services.ParseMegabytesValue(r.FormValue("anonymous_max_upload_mb")); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if settings.AnonymousDailyUploadMB, err = services.ParseMegabytesValue(r.FormValue("anonymous_daily_upload_mb")); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if settings.UserDailyUploadMB, err = services.ParseMegabytesValue(r.FormValue("user_daily_upload_mb")); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if settings.DefaultUserStorageMB, err = services.ParseMegabytesValue(r.FormValue("default_user_storage_mb")); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if settings.UsageRetentionDays <= 0 {
http.Error(w, "usage retention days must be positive", http.StatusBadRequest)
return
}
if err := a.settingsService.UpdateUploadPolicy(settings); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.Redirect(w, r, "/admin/settings", http.StatusSeeOther)
}
func (a *App) AdminUpdateUserQuota(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
return
}
var quota *float64
if r.FormValue("storage_quota_mb") != "" {
parsed, err := services.ParseMegabytesValue(r.FormValue("storage_quota_mb"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
quota = &parsed
}
if err := a.authService.SetUserStorageQuota(r.PathValue("userID"), quota); err != nil {
http.Error(w, "unable to update quota", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
}
func (a *App) AdminCreateInvite(w http.ResponseWriter, r *http.Request) {
admin, ok := a.requireAdminUser(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
return
}
result, err := a.authService.CreateInvite(r.FormValue("email"), r.FormValue("role"), admin.ID, 7*24*time.Hour)
if err != nil {
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
return
}
a.logger.Info("invite created", "source", "admin", "severity", "user_activity", "code", 2404, "admin_id", admin.ID)
http.Redirect(w, r, "/admin/users?invite="+url.QueryEscape(result.URL), http.StatusSeeOther)
}
func (a *App) AdminDisableUser(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) {
return
}
disabled := r.URL.Query().Get("disabled") != "false"
if err := a.authService.DisableUser(r.PathValue("userID"), disabled); err != nil {
http.Error(w, "unable to update user", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
}
func (a *App) AdminResetUser(w http.ResponseWriter, r *http.Request) {
admin, ok := a.requireAdminUser(w, r)
if !ok {
return
}
result, err := a.authService.CreatePasswordResetInvite(r.PathValue("userID"), admin.ID)
if err != nil {
http.Error(w, "unable to create reset link", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/users?invite="+url.QueryEscape(result.URL), http.StatusSeeOther)
}
func (a *App) AdminDeleteBox(w http.ResponseWriter, r *http.Request) { func (a *App) AdminDeleteBox(w http.ResponseWriter, r *http.Request) {
if !a.requireAdmin(w, r) { if !a.requireAdmin(w, r) {
return return
@@ -167,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{
@@ -185,8 +393,17 @@ func (a *App) adminBoxes(limit int) ([]adminBoxView, error) {
rows := make([]adminBoxView, 0, len(boxes)) rows := make([]adminBoxView, 0, len(boxes))
for _, box := range boxes { for _, box := range boxes {
owner := "Anonymous"
if box.OwnerID != "" {
if user, err := a.authService.UserByID(box.OwnerID); err == nil {
owner = user.Email
} else {
owner = "User"
}
}
rows = append(rows, adminBoxView{ rows = append(rows, adminBoxView{
ID: box.ID, ID: box.ID,
Owner: owner,
CreatedAt: box.CreatedAt.Format("Jan 2 15:04"), CreatedAt: box.CreatedAt.Format("Jan 2 15:04"),
ExpiresAt: box.ExpiresAt.Format("Jan 2 15:04"), ExpiresAt: box.ExpiresAt.Format("Jan 2 15:04"),
FileCount: box.FileCount, FileCount: box.FileCount,
@@ -209,6 +426,9 @@ func (a *App) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
} }
func (a *App) isAdmin(r *http.Request) bool { func (a *App) isAdmin(r *http.Request) bool {
if user, ok := a.currentUser(r); ok && user.Role == services.UserRoleAdmin {
return true
}
if a.cfg.AdminToken == "" { if a.cfg.AdminToken == "" {
return false return false
} }
@@ -219,7 +439,38 @@ func (a *App) isAdmin(r *http.Request) bool {
return cookie.Value == adminCookieValue(a.cfg.AdminToken) return cookie.Value == adminCookieValue(a.cfg.AdminToken)
} }
func (a *App) requireAdminUser(w http.ResponseWriter, r *http.Request) (services.User, bool) {
user, ok := a.currentUser(r)
if ok && user.Role == services.UserRoleAdmin {
return user, true
}
if a.cfg.AdminToken != "" && a.isAdmin(r) {
return services.User{ID: "env-admin", Role: services.UserRoleAdmin, Status: services.UserStatusActive}, true
}
http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
return services.User{}, false
}
func (a *App) currentPublicUser(r *http.Request) any {
if user, ok := a.currentUser(r); ok {
return a.authService.PublicUser(user)
}
return nil
}
func adminCookieValue(token string) string { 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

@@ -14,25 +14,57 @@ type App struct {
logger *slog.Logger logger *slog.Logger
renderer *web.Renderer renderer *web.Renderer
uploadService *services.UploadService uploadService *services.UploadService
authService *services.AuthService
settingsService *services.SettingsService
} }
func NewApp(cfg config.Config, logger *slog.Logger, renderer *web.Renderer, uploadService *services.UploadService) *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,
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)
mux.HandleFunc("GET /register", a.Register)
mux.HandleFunc("POST /register", a.RegisterPost)
mux.HandleFunc("GET /login", a.Login)
mux.HandleFunc("POST /login", a.LoginPost)
mux.HandleFunc("POST /logout", a.Logout)
mux.HandleFunc("GET /invite/{token}", a.Invite)
mux.HandleFunc("POST /invite/{token}", a.InvitePost)
mux.HandleFunc("GET /app", a.Dashboard)
mux.HandleFunc("POST /app/collections", a.CreateCollection)
mux.HandleFunc("POST /app/boxes/{boxID}/rename", a.RenameUserBox)
mux.HandleFunc("POST /app/boxes/{boxID}/move", a.MoveUserBox)
mux.HandleFunc("POST /app/boxes/{boxID}/delete", a.DeleteUserBox)
mux.HandleFunc("GET /account/settings", a.AccountSettings)
mux.HandleFunc("POST /account/password", a.ChangePassword)
mux.HandleFunc("GET /admin/login", a.AdminLogin) mux.HandleFunc("GET /admin/login", a.AdminLogin)
mux.HandleFunc("POST /admin/login", a.AdminLoginPost) mux.HandleFunc("POST /admin/login", a.AdminLoginPost)
mux.HandleFunc("POST /admin/logout", a.AdminLogout) mux.HandleFunc("POST /admin/logout", a.AdminLogout)
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/settings", a.AdminSettings)
mux.HandleFunc("POST /admin/settings", a.AdminSettingsPost)
mux.HandleFunc("POST /admin/invites", a.AdminCreateInvite)
mux.HandleFunc("POST /admin/users/{userID}/disable", a.AdminDisableUser)
mux.HandleFunc("POST /admin/users/{userID}/reset", a.AdminResetUser)
mux.HandleFunc("POST /admin/users/{userID}/quota", a.AdminUpdateUserQuota)
mux.HandleFunc("GET /admin/boxes/{boxID}/view", a.AdminViewBox) mux.HandleFunc("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

@@ -0,0 +1,211 @@
package handlers
import (
"net/http"
"time"
"warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web"
)
const userSessionCookieName = "warpbox_session"
type authPageData struct {
Mode string
Token string
Email string
IsReset bool
Error string
ReturnPath string
}
func (a *App) Register(w http.ResponseWriter, r *http.Request) {
available, err := a.authService.BootstrapAvailable()
if err != nil {
http.Error(w, "unable to check registration", http.StatusInternalServerError)
return
}
if !available {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "register"})
}
func (a *App) RegisterPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "register", Error: "Unable to read form."})
return
}
user, err := a.authService.CreateBootstrapUser(r.FormValue("username"), r.FormValue("email"), r.FormValue("password"))
if err != nil {
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "register", Error: err.Error()})
return
}
a.logger.Info("first admin created", "source", "auth", "severity", "user_activity", "code", 2401, "user_id", user.ID)
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
}
func (a *App) Login(w http.ResponseWriter, r *http.Request) {
if _, ok := a.currentUser(r); ok {
http.Redirect(w, r, "/app", http.StatusSeeOther)
return
}
a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "login", ReturnPath: r.URL.Query().Get("next")})
}
func (a *App) LoginPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "login", Error: "Unable to read form."})
return
}
next := r.FormValue("next")
if next == "" {
next = "/app"
}
user, token, err := a.authService.Login(r.FormValue("email"), r.FormValue("password"))
if err != nil {
a.logger.Warn("login failed", "source", "auth", "severity", "warn", "code", 4401, "email", r.FormValue("email"))
a.renderAuth(w, r, http.StatusUnauthorized, authPageData{Mode: "login", Error: "Invalid email or password.", ReturnPath: next})
return
}
a.setUserSessionCookie(w, r, token)
a.logger.Info("user login", "source", "auth", "severity", "user_activity", "code", 2402, "user_id", user.ID)
http.Redirect(w, r, safeReturnPath(next), http.StatusSeeOther)
}
func (a *App) Logout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(userSessionCookieName); err == nil {
_ = a.authService.Logout(cookie.Value)
}
a.clearUserSessionCookie(w)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (a *App) Invite(w http.ResponseWriter, r *http.Request) {
invite, err := a.authService.InviteByToken(r.PathValue("token"))
if err != nil || invite.UsedAt != nil || time.Now().UTC().After(invite.ExpiresAt) {
a.renderAuth(w, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."})
return
}
a.renderAuth(w, r, http.StatusOK, authPageData{Mode: "invite", Token: r.PathValue("token"), Email: invite.Email, IsReset: invite.UserID != ""})
}
func (a *App) InvitePost(w http.ResponseWriter, r *http.Request) {
token := r.PathValue("token")
invite, err := a.authService.InviteByToken(token)
if err != nil {
a.renderAuth(w, r, http.StatusNotFound, authPageData{Mode: "invite", Error: "This invite is invalid or expired."})
return
}
if err := r.ParseForm(); err != nil {
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "invite", Token: token, Email: invite.Email, IsReset: invite.UserID != "", Error: "Unable to read form."})
return
}
user, err := a.authService.AcceptInvite(token, r.FormValue("username"), r.FormValue("password"))
if err != nil {
a.renderAuth(w, r, http.StatusBadRequest, authPageData{Mode: "invite", Token: token, Email: invite.Email, IsReset: invite.UserID != "", Error: err.Error()})
return
}
a.logger.Info("invite accepted", "source", "auth", "severity", "user_activity", "code", 2403, "user_id", user.ID)
a.loginAndRedirect(w, r, user.Email, r.FormValue("password"), "/app")
}
func (a *App) AccountSettings(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r)
if !ok {
return
}
a.renderPage(w, r, http.StatusOK, "account.html", web.PageData{
Title: "Account settings",
Description: "Manage your Warpbox account.",
CurrentUser: a.authService.PublicUser(user),
Data: user,
})
}
func (a *App) ChangePassword(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
return
}
if !services.VerifyPasswordHash(user.PasswordHash, r.FormValue("current_password")) {
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
return
}
if err := a.authService.SetPassword(user.ID, r.FormValue("new_password")); err != nil {
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
return
}
http.Redirect(w, r, "/account/settings", http.StatusSeeOther)
}
func (a *App) renderAuth(w http.ResponseWriter, r *http.Request, status int, data authPageData) {
a.renderPage(w, r, status, "auth.html", web.PageData{
Title: "Account",
Description: "Sign in to Warpbox.",
Data: data,
})
}
func (a *App) loginAndRedirect(w http.ResponseWriter, r *http.Request, email, password, path string) {
_, token, err := a.authService.Login(email, password)
if err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
a.setUserSessionCookie(w, r, token)
http.Redirect(w, r, path, http.StatusSeeOther)
}
func (a *App) currentUser(r *http.Request) (services.User, bool) {
cookie, err := r.Cookie(userSessionCookieName)
if err != nil {
return services.User{}, false
}
user, _, err := a.authService.UserForSession(cookie.Value)
return user, err == nil
}
func (a *App) requireUser(w http.ResponseWriter, r *http.Request) (services.User, bool) {
user, ok := a.currentUser(r)
if ok {
return user, true
}
http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
return services.User{}, false
}
func (a *App) setUserSessionCookie(w http.ResponseWriter, r *http.Request, token string) {
http.SetCookie(w, &http.Cookie{
Name: userSessionCookieName,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil,
Expires: time.Now().Add(30 * 24 * time.Hour),
})
}
func (a *App) clearUserSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: userSessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
func safeReturnPath(path string) string {
if path == "" || path[0] != '/' || len(path) > 1 && path[1] == '/' {
return "/app"
}
return path
}

View File

@@ -0,0 +1,179 @@
package handlers
import (
"fmt"
"net/http"
"os"
"warpbox.dev/backend/libs/helpers"
"warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web"
)
type dashboardData struct {
User services.PublicUser
Collections []collectionView
Boxes []userBoxView
StorageUsed string
MaxUploadSize string
Selected string
LastInviteURL string
}
type collectionView struct {
ID string
Name string
}
type userBoxView struct {
ID string
Title string
CollectionID string
CollectionName string
FileCount int
Size string
CreatedAt string
ExpiresAt string
URL string
}
func (a *App) Dashboard(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r)
if !ok {
return
}
collections, err := a.authService.ListCollections(user.ID)
if err != nil {
http.Error(w, "unable to load collections", http.StatusInternalServerError)
return
}
collectionNames := map[string]string{}
collectionViews := make([]collectionView, 0, len(collections))
for _, collection := range collections {
collectionNames[collection.ID] = collection.Name
collectionViews = append(collectionViews, collectionView{ID: collection.ID, Name: collection.Name})
}
boxes, err := a.uploadService.UserBoxes(user.ID, collectionNames)
if err != nil {
http.Error(w, "unable to load boxes", http.StatusInternalServerError)
return
}
storageUsed, err := a.uploadService.UserStorageUsed(user.ID)
if err != nil {
http.Error(w, "unable to load storage usage", http.StatusInternalServerError)
return
}
selected := r.URL.Query().Get("collection")
boxViews := make([]userBoxView, 0, len(boxes))
for _, row := range boxes {
if selected != "" && row.Box.CollectionID != selected {
continue
}
title := row.Box.Title
if title == "" {
title = fmt.Sprintf("%d file upload", len(row.Box.Files))
}
boxViews = append(boxViews, userBoxView{
ID: row.Box.ID,
Title: title,
CollectionID: row.Box.CollectionID,
CollectionName: row.CollectionName,
FileCount: len(row.Box.Files),
Size: row.TotalSizeLabel,
CreatedAt: row.Box.CreatedAt.Format("Jan 2 15:04"),
ExpiresAt: row.Box.ExpiresAt.Format("Jan 2 15:04"),
URL: "/d/" + row.Box.ID,
})
}
a.renderPage(w, r, http.StatusOK, "dashboard.html", web.PageData{
Title: "My files",
Description: "Your Warpbox personal file space.",
CurrentUser: a.authService.PublicUser(user),
Data: dashboardData{
User: a.authService.PublicUser(user),
Collections: collectionViews,
Boxes: boxViews,
StorageUsed: helpers.FormatBytes(storageUsed),
MaxUploadSize: a.uploadService.MaxUploadSizeLabel(),
Selected: selected,
},
})
}
func (a *App) CreateCollection(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/app", http.StatusSeeOther)
return
}
if _, err := a.authService.CreateCollection(user.ID, r.FormValue("name")); err != nil {
a.logger.Warn("collection create failed", "source", "user_activity", "severity", "warn", "code", 4410, "user_id", user.ID, "error", err.Error())
}
http.Redirect(w, r, "/app", http.StatusSeeOther)
}
func (a *App) RenameUserBox(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/app", http.StatusSeeOther)
return
}
if err := a.uploadService.RenameOwnedBox(r.PathValue("boxID"), user.ID, r.FormValue("title")); err != nil {
a.handleUserBoxError(w, r, err)
return
}
http.Redirect(w, r, "/app", http.StatusSeeOther)
}
func (a *App) MoveUserBox(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/app", http.StatusSeeOther)
return
}
collectionID := r.FormValue("collection_id")
if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
http.Error(w, "collection not found", http.StatusForbidden)
return
}
if err := a.uploadService.MoveOwnedBox(r.PathValue("boxID"), user.ID, collectionID); err != nil {
a.handleUserBoxError(w, r, err)
return
}
http.Redirect(w, r, "/app", http.StatusSeeOther)
}
func (a *App) DeleteUserBox(w http.ResponseWriter, r *http.Request) {
user, ok := a.requireUser(w, r)
if !ok {
return
}
if err := a.uploadService.DeleteOwnedBox(r.PathValue("boxID"), user.ID); err != nil {
a.handleUserBoxError(w, r, err)
return
}
http.Redirect(w, r, "/app", http.StatusSeeOther)
}
func (a *App) handleUserBoxError(w http.ResponseWriter, r *http.Request, err error) {
if os.IsPermission(err) {
http.Error(w, "not allowed", http.StatusForbidden)
return
}
if os.IsNotExist(err) {
http.NotFound(w, r)
return
}
http.Error(w, "unable to update box", http.StatusInternalServerError)
}

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

@@ -3,19 +3,69 @@ package handlers
import ( import (
"net/http" "net/http"
"warpbox.dev/backend/libs/services"
"warpbox.dev/backend/libs/web" "warpbox.dev/backend/libs/web"
) )
type homeData struct { type homeData struct {
MaxUploadSize string MaxUploadSize string
LimitSummary string
Collections []collectionView
IsAdmin bool
AnonymousOpen bool
} }
func (a *App) Home(w http.ResponseWriter, r *http.Request) { func (a *App) Home(w http.ResponseWriter, r *http.Request) {
a.renderer.Render(w, http.StatusOK, "home.html", web.PageData{ currentUser := a.currentPublicUser(r)
var collections []collectionView
var isAdmin bool
var user services.User
var loggedIn bool
if current, ok := a.currentUser(r); ok {
user = current
loggedIn = true
isAdmin = user.Role == services.UserRoleAdmin
userCollections, err := a.authService.ListCollections(user.ID)
if err == nil {
collections = make([]collectionView, 0, len(userCollections))
for _, collection := range userCollections {
collections = append(collections, collectionView{ID: collection.ID, Name: collection.Name})
}
}
}
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,
Data: homeData{ Data: homeData{
MaxUploadSize: a.uploadService.MaxUploadSizeLabel(), MaxUploadSize: maxUploadSize,
LimitSummary: limitSummary,
Collections: collections,
IsAdmin: isAdmin,
AnonymousOpen: settings.AnonymousUploadsEnabled,
}, },
}) })
} }
func (a *App) homeUploadPolicyLabels(settings services.UploadPolicySettings, user services.User, loggedIn, isAdmin bool) (string, string) {
if isAdmin {
return "No file size limit", "Admin uploads bypass storage and daily caps."
}
if !loggedIn {
if !settings.AnonymousUploadsEnabled {
return "Anonymous uploads disabled", "Sign in to upload files."
}
return services.FormatMegabytesLabel(settings.AnonymousMaxUploadMB), "Daily anonymous cap: " + services.FormatMegabytesLabel(settings.AnonymousDailyUploadMB) + " per IP."
}
quotaMB := settings.DefaultUserStorageMB
if user.StorageQuotaMB != nil {
quotaMB = *user.StorageQuotaMB
}
return a.uploadService.MaxUploadSizeLabel(), "Daily cap: " + services.FormatMegabytesLabel(settings.UserDailyUploadMB) + " · Storage quota: " + services.FormatMegabytesLabel(quotaMB) + "."
}

View File

@@ -7,6 +7,7 @@ import (
"net/http" "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"
@@ -14,24 +15,71 @@ import (
) )
func (a *App) Upload(w http.ResponseWriter, r *http.Request) { func (a *App) Upload(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, a.uploadService.MaxUploadSize()*8) user, loggedIn := a.currentUser(r)
if err := r.ParseMultipartForm(a.uploadService.MaxUploadSize() * 8); err != nil { isAdminUpload := loggedIn && user.Role == services.UserRoleAdmin
settings, err := a.settingsService.UploadPolicy()
if err != nil {
a.logger.Error("failed to load upload policy", "source", "settings", "severity", "error", "code", 5005, "error", err.Error())
helpers.WriteJSONError(w, http.StatusInternalServerError, "upload policy could not be loaded")
return
}
if !loggedIn && !settings.AnonymousUploadsEnabled {
helpers.WriteJSONError(w, http.StatusForbidden, "anonymous uploads are disabled")
return
}
if !isAdminUpload {
r.Body = http.MaxBytesReader(w, r.Body, uploadParseLimit(settings, loggedIn, a.uploadService.MaxUploadSize()))
}
parseLimit := uploadParseLimit(settings, loggedIn, a.uploadService.MaxUploadSize())
if isAdminUpload {
parseLimit = 32 << 20
}
if err := r.ParseMultipartForm(parseLimit); err != nil {
helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read") helpers.WriteJSONError(w, http.StatusBadRequest, "upload form could not be read")
return return
} }
files := uploadFiles(r) files := uploadFiles(r)
totalBytes := totalUploadBytes(files)
var ownerID string
var collectionID string
if loggedIn {
ownerID = user.ID
collectionID = r.FormValue("collection_id")
if !a.authService.CollectionOwnedBy(collectionID, user.ID) {
helpers.WriteJSONError(w, http.StatusForbidden, "collection not found")
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")),
Password: r.FormValue("password"), Password: r.FormValue("password"),
ObfuscateMetadata: r.FormValue("obfuscate_metadata") == "on", ObfuscateMetadata: r.FormValue("obfuscate_metadata") == "on",
OwnerID: ownerID,
CollectionID: collectionID,
SkipSizeLimit: isAdminUpload,
}) })
if err != nil { if err != nil {
a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "error", err.Error()) a.logger.Warn("upload failed", "source", "user-upload", "severity", "warn", "code", 4001, "error", err.Error())
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) {
@@ -44,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 {
@@ -194,7 +202,17 @@ func newTestApp(t *testing.T) (*App, func()) {
service.Close() service.Close()
t.Fatalf("NewRenderer returned error: %v", err) t.Fatalf("NewRenderer returned error: %v", err)
} }
return NewApp(cfg, logger, renderer, service), func() { authService, err := services.NewAuthService(service.DB(), cfg.BaseURL)
if err != nil {
service.Close()
t.Fatalf("NewAuthService returned error: %v", err)
}
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

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

View File

@@ -0,0 +1,595 @@
package services
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/mail"
"os"
"sort"
"strings"
"time"
"go.etcd.io/bbolt"
"golang.org/x/crypto/argon2"
)
var (
usersBucket = []byte("users")
userEmailsBucket = []byte("user_emails")
sessionsBucket = []byte("sessions")
invitesBucket = []byte("invites")
collectionsBucket = []byte("collections")
)
const (
UserRoleAdmin = "admin"
UserRoleUser = "user"
UserStatusActive = "active"
UserStatusDisabled = "disabled"
)
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrRegistrationClosed = errors.New("registration is closed")
ErrInviteInvalid = errors.New("invite is invalid")
ErrUserDisabled = errors.New("user is disabled")
)
type AuthService struct {
db *bbolt.DB
baseURL string
}
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
PasswordHash string `json:"passwordHash"`
Role string `json:"role"`
Status string `json:"status"`
StorageQuotaMB *float64 `json:"storageQuotaMb,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type PublicUser struct {
ID string
Username string
Email string
Role string
Status string
StorageQuotaMB *float64
CreatedAt time.Time
}
type Session struct {
ID string `json:"id"`
UserID string `json:"userId"`
TokenHash string `json:"tokenHash"`
ExpiresAt time.Time `json:"expiresAt"`
CreatedAt time.Time `json:"createdAt"`
}
type Invite struct {
ID string `json:"id"`
UserID string `json:"userId,omitempty"`
Email string `json:"email"`
Role string `json:"role"`
TokenHash string `json:"tokenHash"`
CreatedBy string `json:"createdBy"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt time.Time `json:"expiresAt"`
UsedAt *time.Time `json:"usedAt,omitempty"`
UsedByUserID string `json:"usedByUserId,omitempty"`
}
type Collection struct {
ID string `json:"id"`
UserID string `json:"userId"`
Name string `json:"name"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type InviteResult struct {
Invite Invite
URL string
Token string
}
func NewAuthService(db *bbolt.DB, baseURL string) (*AuthService, error) {
service := &AuthService{db: db, baseURL: strings.TrimRight(baseURL, "/")}
err := db.Update(func(tx *bbolt.Tx) error {
for _, bucket := range [][]byte{usersBucket, userEmailsBucket, sessionsBucket, invitesBucket, collectionsBucket} {
if _, err := tx.CreateBucketIfNotExists(bucket); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
return service, nil
}
func (s *AuthService) BootstrapAvailable() (bool, error) {
count := 0
err := s.db.View(func(tx *bbolt.Tx) error {
return tx.Bucket(usersBucket).ForEach(func(_, _ []byte) error {
count++
return nil
})
})
return count == 0, err
}
func (s *AuthService) CreateBootstrapUser(username, email, password string) (User, error) {
available, err := s.BootstrapAvailable()
if err != nil {
return User{}, err
}
if !available {
return User{}, ErrRegistrationClosed
}
return s.createUser(username, email, password, UserRoleAdmin)
}
func (s *AuthService) Login(email, password string) (User, string, error) {
user, err := s.UserByEmail(email)
if err != nil {
return User{}, "", ErrInvalidCredentials
}
if user.Status != UserStatusActive {
return User{}, "", ErrUserDisabled
}
if !VerifyPasswordHash(user.PasswordHash, password) {
return User{}, "", ErrInvalidCredentials
}
token := randomID(32)
session := Session{
ID: randomID(12),
UserID: user.ID,
TokenHash: tokenHash(token),
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(30 * 24 * time.Hour),
}
err = s.db.Update(func(tx *bbolt.Tx) error {
data, err := json.Marshal(session)
if err != nil {
return err
}
return tx.Bucket(sessionsBucket).Put([]byte(session.ID), data)
})
return user, session.ID + "." + token, err
}
func (s *AuthService) UserForSession(raw string) (User, Session, error) {
sessionID, token, ok := strings.Cut(raw, ".")
if !ok || sessionID == "" || token == "" {
return User{}, Session{}, os.ErrNotExist
}
var session Session
err := s.db.View(func(tx *bbolt.Tx) error {
data := tx.Bucket(sessionsBucket).Get([]byte(sessionID))
if data == nil {
return os.ErrNotExist
}
return json.Unmarshal(data, &session)
})
if err != nil {
return User{}, Session{}, err
}
if time.Now().UTC().After(session.ExpiresAt) || subtle.ConstantTimeCompare([]byte(tokenHash(token)), []byte(session.TokenHash)) != 1 {
return User{}, Session{}, os.ErrPermission
}
user, err := s.UserByID(session.UserID)
if err != nil {
return User{}, Session{}, err
}
if user.Status != UserStatusActive {
return User{}, Session{}, ErrUserDisabled
}
return user, session, nil
}
func (s *AuthService) Logout(raw string) error {
sessionID, _, ok := strings.Cut(raw, ".")
if !ok || sessionID == "" {
return nil
}
return s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(sessionsBucket).Delete([]byte(sessionID))
})
}
func (s *AuthService) CreateInvite(email, role, createdBy string, expiresIn time.Duration) (InviteResult, error) {
email, err := normalizeEmail(email)
if err != nil {
return InviteResult{}, err
}
if role == "" {
role = UserRoleUser
}
if role != UserRoleAdmin && role != UserRoleUser {
role = UserRoleUser
}
if expiresIn <= 0 {
expiresIn = 7 * 24 * time.Hour
}
token := randomID(32)
invite := Invite{
ID: randomID(12),
Email: email,
Role: role,
TokenHash: tokenHash(token),
CreatedBy: createdBy,
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(expiresIn),
}
err = s.saveInvite(invite)
if err != nil {
return InviteResult{}, err
}
return InviteResult{
Invite: invite,
Token: token,
URL: fmt.Sprintf("%s/invite/%s", s.baseURL, token),
}, nil
}
func (s *AuthService) AcceptInvite(token, username, password string) (User, error) {
invite, err := s.InviteByToken(token)
if err != nil {
return User{}, err
}
if invite.UsedAt != nil || time.Now().UTC().After(invite.ExpiresAt) {
return User{}, ErrInviteInvalid
}
var user User
if invite.UserID != "" {
user, err = s.UserByID(invite.UserID)
if err != nil {
return User{}, err
}
if err := s.SetPassword(user.ID, password); err != nil {
return User{}, err
}
user, _ = s.UserByID(user.ID)
} else {
user, err = s.createUser(username, invite.Email, password, invite.Role)
if err != nil {
return User{}, err
}
}
now := time.Now().UTC()
invite.UsedAt = &now
invite.UsedByUserID = user.ID
if err := s.saveInvite(invite); err != nil {
return User{}, err
}
return user, nil
}
func (s *AuthService) InviteByToken(token string) (Invite, error) {
hash := tokenHash(token)
var match Invite
err := s.db.View(func(tx *bbolt.Tx) error {
return tx.Bucket(invitesBucket).ForEach(func(_, value []byte) error {
var invite Invite
if err := json.Unmarshal(value, &invite); err != nil {
return err
}
if subtle.ConstantTimeCompare([]byte(hash), []byte(invite.TokenHash)) == 1 {
match = invite
}
return nil
})
})
if err != nil {
return Invite{}, err
}
if match.ID == "" {
return Invite{}, ErrInviteInvalid
}
return match, nil
}
func (s *AuthService) CreatePasswordResetInvite(userID, createdBy string) (InviteResult, error) {
user, err := s.UserByID(userID)
if err != nil {
return InviteResult{}, err
}
result, err := s.CreateInvite(user.Email, user.Role, createdBy, 24*time.Hour)
if err != nil {
return InviteResult{}, err
}
result.Invite.UserID = user.ID
if err := s.saveInvite(result.Invite); err != nil {
return InviteResult{}, err
}
return result, nil
}
func (s *AuthService) ListUsers() ([]User, error) {
users := make([]User, 0)
err := s.db.View(func(tx *bbolt.Tx) error {
return tx.Bucket(usersBucket).ForEach(func(_, value []byte) error {
var user User
if err := json.Unmarshal(value, &user); err != nil {
return err
}
users = append(users, user)
return nil
})
})
sort.Slice(users, func(i, j int) bool {
return users[i].CreatedAt.After(users[j].CreatedAt)
})
return users, err
}
func (s *AuthService) DisableUser(userID string, disabled bool) error {
user, err := s.UserByID(userID)
if err != nil {
return err
}
if disabled {
user.Status = UserStatusDisabled
} else {
user.Status = UserStatusActive
}
user.UpdatedAt = time.Now().UTC()
return s.saveUser(user)
}
func (s *AuthService) SetPassword(userID, password string) error {
if len(password) < 8 {
return fmt.Errorf("password must be at least 8 characters")
}
user, err := s.UserByID(userID)
if err != nil {
return err
}
user.PasswordHash = HashPassword(password)
user.UpdatedAt = time.Now().UTC()
return s.saveUser(user)
}
func (s *AuthService) SetUserStorageQuota(userID string, quotaMB *float64) error {
if quotaMB != nil && *quotaMB <= 0 {
return fmt.Errorf("storage quota must be positive")
}
user, err := s.UserByID(userID)
if err != nil {
return err
}
user.StorageQuotaMB = quotaMB
user.UpdatedAt = time.Now().UTC()
return s.saveUser(user)
}
func (s *AuthService) UserByID(id string) (User, error) {
var user User
err := s.db.View(func(tx *bbolt.Tx) error {
data := tx.Bucket(usersBucket).Get([]byte(id))
if data == nil {
return os.ErrNotExist
}
return json.Unmarshal(data, &user)
})
return user, err
}
func (s *AuthService) UserByEmail(email string) (User, error) {
email, err := normalizeEmail(email)
if err != nil {
return User{}, err
}
var userID string
err = s.db.View(func(tx *bbolt.Tx) error {
data := tx.Bucket(userEmailsBucket).Get([]byte(email))
if data == nil {
return os.ErrNotExist
}
userID = string(data)
return nil
})
if err != nil {
return User{}, err
}
return s.UserByID(userID)
}
func (s *AuthService) CreateCollection(userID, name string) (Collection, error) {
name = strings.TrimSpace(name)
if name == "" {
return Collection{}, fmt.Errorf("collection name is required")
}
collection := Collection{
ID: randomID(10),
UserID: userID,
Name: name,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
return collection, s.saveCollection(collection)
}
func (s *AuthService) ListCollections(userID string) ([]Collection, error) {
collections := make([]Collection, 0)
err := s.db.View(func(tx *bbolt.Tx) error {
return tx.Bucket(collectionsBucket).ForEach(func(_, value []byte) error {
var collection Collection
if err := json.Unmarshal(value, &collection); err != nil {
return err
}
if collection.UserID == userID {
collections = append(collections, collection)
}
return nil
})
})
sort.Slice(collections, func(i, j int) bool {
return strings.ToLower(collections[i].Name) < strings.ToLower(collections[j].Name)
})
return collections, err
}
func (s *AuthService) CollectionOwnedBy(collectionID, userID string) bool {
if collectionID == "" {
return true
}
collection, err := s.CollectionByID(collectionID)
return err == nil && collection.UserID == userID
}
func (s *AuthService) CollectionByID(id string) (Collection, error) {
var collection Collection
err := s.db.View(func(tx *bbolt.Tx) error {
data := tx.Bucket(collectionsBucket).Get([]byte(id))
if data == nil {
return os.ErrNotExist
}
return json.Unmarshal(data, &collection)
})
return collection, err
}
func (s *AuthService) PublicUser(user User) PublicUser {
return PublicUser{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Role: user.Role,
Status: user.Status,
StorageQuotaMB: user.StorageQuotaMB,
CreatedAt: user.CreatedAt,
}
}
func (s *AuthService) createUser(username, email, password, role string) (User, error) {
username = strings.TrimSpace(username)
if username == "" {
return User{}, fmt.Errorf("username is required")
}
email, err := normalizeEmail(email)
if err != nil {
return User{}, err
}
if len(password) < 8 {
return User{}, fmt.Errorf("password must be at least 8 characters")
}
if role != UserRoleAdmin && role != UserRoleUser {
role = UserRoleUser
}
now := time.Now().UTC()
user := User{
ID: randomID(12),
Username: username,
Email: email,
PasswordHash: HashPassword(password),
Role: role,
Status: UserStatusActive,
CreatedAt: now,
UpdatedAt: now,
}
return user, s.db.Update(func(tx *bbolt.Tx) error {
if existing := tx.Bucket(userEmailsBucket).Get([]byte(email)); existing != nil {
return fmt.Errorf("email is already registered")
}
data, err := json.Marshal(user)
if err != nil {
return err
}
if err := tx.Bucket(usersBucket).Put([]byte(user.ID), data); err != nil {
return err
}
return tx.Bucket(userEmailsBucket).Put([]byte(email), []byte(user.ID))
})
}
func (s *AuthService) saveUser(user User) error {
data, err := json.Marshal(user)
if err != nil {
return err
}
return s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(usersBucket).Put([]byte(user.ID), data)
})
}
func (s *AuthService) saveInvite(invite Invite) error {
data, err := json.Marshal(invite)
if err != nil {
return err
}
return s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(invitesBucket).Put([]byte(invite.ID), data)
})
}
func (s *AuthService) saveCollection(collection Collection) error {
data, err := json.Marshal(collection)
if err != nil {
return err
}
return s.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(collectionsBucket).Put([]byte(collection.ID), data)
})
}
func normalizeEmail(email string) (string, error) {
email = strings.ToLower(strings.TrimSpace(email))
if email == "" {
return "", fmt.Errorf("email is required")
}
if _, err := mail.ParseAddress(email); err != nil {
return "", fmt.Errorf("email is invalid")
}
return email, nil
}
func tokenHash(token string) string {
sum := sha256.Sum256([]byte("warpbox-session:" + token))
return hex.EncodeToString(sum[:])
}
func HashPassword(password string) string {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
salt = []byte(randomID(16))[:16]
}
hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)
return "argon2id$v=19$m=65536,t=1,p=4$" + base64.RawStdEncoding.EncodeToString(salt) + "$" + base64.RawStdEncoding.EncodeToString(hash)
}
func VerifyPasswordHash(encoded, password string) bool {
parts := strings.Split(encoded, "$")
if len(parts) != 5 || parts[0] != "argon2id" {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[3])
if err != nil {
return false
}
expected, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false
}
actual := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, uint32(len(expected)))
return subtle.ConstantTimeCompare(actual, expected) == 1
}

View File

@@ -0,0 +1,123 @@
package services
import (
"log/slog"
"path/filepath"
"testing"
"time"
)
func TestPasswordHashVerification(t *testing.T) {
hash := HashPassword("correct-horse")
if !VerifyPasswordHash(hash, "correct-horse") {
t.Fatalf("VerifyPasswordHash rejected the correct password")
}
if VerifyPasswordHash(hash, "wrong-password") {
t.Fatalf("VerifyPasswordHash accepted the wrong password")
}
}
func TestBootstrapCreatesAdminAndClosesRegistration(t *testing.T) {
auth := newTestAuthService(t)
available, err := auth.BootstrapAvailable()
if err != nil {
t.Fatalf("BootstrapAvailable returned error: %v", err)
}
if !available {
t.Fatalf("BootstrapAvailable = false, want true")
}
user, err := auth.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
if user.Role != UserRoleAdmin {
t.Fatalf("role = %q, want admin", user.Role)
}
if _, err := auth.CreateBootstrapUser("other", "other@example.test", "password123"); err == nil {
t.Fatalf("second bootstrap unexpectedly succeeded")
}
}
func TestLoginSessionAndDisabledUser(t *testing.T) {
auth := newTestAuthService(t)
user, err := auth.CreateBootstrapUser("daniel", "daniel@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
if _, _, err := auth.Login("daniel@example.test", "wrong"); err == nil {
t.Fatalf("Login accepted wrong password")
}
_, token, err := auth.Login("daniel@example.test", "password123")
if err != nil {
t.Fatalf("Login returned error: %v", err)
}
sessionUser, _, err := auth.UserForSession(token)
if err != nil {
t.Fatalf("UserForSession returned error: %v", err)
}
if sessionUser.ID != user.ID {
t.Fatalf("session user = %q, want %q", sessionUser.ID, user.ID)
}
if err := auth.DisableUser(user.ID, true); err != nil {
t.Fatalf("DisableUser returned error: %v", err)
}
if _, _, err := auth.UserForSession(token); err == nil {
t.Fatalf("disabled user session still resolved")
}
}
func TestInviteAcceptsOnceAndResetChangesPassword(t *testing.T) {
auth := newTestAuthService(t)
admin, err := auth.CreateBootstrapUser("admin", "admin@example.test", "password123")
if err != nil {
t.Fatalf("CreateBootstrapUser returned error: %v", err)
}
invite, err := auth.CreateInvite("friend@example.test", UserRoleUser, admin.ID, time.Hour)
if err != nil {
t.Fatalf("CreateInvite returned error: %v", err)
}
user, err := auth.AcceptInvite(invite.Token, "friend", "password123")
if err != nil {
t.Fatalf("AcceptInvite returned error: %v", err)
}
if user.Email != "friend@example.test" {
t.Fatalf("email = %q, want friend@example.test", user.Email)
}
if _, err := auth.AcceptInvite(invite.Token, "friend", "password123"); err == nil {
t.Fatalf("AcceptInvite allowed token reuse")
}
reset, err := auth.CreatePasswordResetInvite(user.ID, admin.ID)
if err != nil {
t.Fatalf("CreatePasswordResetInvite returned error: %v", err)
}
if _, err := auth.AcceptInvite(reset.Token, "", "newpassword123"); err != nil {
t.Fatalf("AcceptInvite reset returned error: %v", err)
}
if _, _, err := auth.Login("friend@example.test", "newpassword123"); err != nil {
t.Fatalf("Login with reset password returned error: %v", err)
}
}
func newTestAuthService(t *testing.T) *AuthService {
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)
}
})
auth, err := NewAuthService(upload.DB(), "http://example.test")
if err != nil {
t.Fatalf("NewAuthService returned error: %v", err)
}
return auth
}

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

@@ -14,6 +14,7 @@ import (
"mime/multipart" "mime/multipart"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"time" "time"
@@ -37,10 +38,16 @@ type UploadOptions struct {
MaxDownloads int MaxDownloads int
Password string Password string
ObfuscateMetadata bool ObfuscateMetadata bool
OwnerID string
CollectionID string
SkipSizeLimit bool
} }
type Box struct { type Box struct {
ID string `json:"id"` ID string `json:"id"`
OwnerID string `json:"ownerId,omitempty"`
CollectionID string `json:"collectionId,omitempty"`
Title string `json:"title,omitempty"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
ExpiresAt time.Time `json:"expiresAt"` ExpiresAt time.Time `json:"expiresAt"`
MaxDownloads int `json:"maxDownloads"` MaxDownloads int `json:"maxDownloads"`
@@ -93,6 +100,7 @@ type AdminStats struct {
type AdminBox struct { type AdminBox struct {
ID string ID string
OwnerID string
CreatedAt time.Time CreatedAt time.Time
ExpiresAt time.Time ExpiresAt time.Time
FileCount int FileCount int
@@ -104,6 +112,12 @@ type AdminBox struct {
Expired bool Expired bool
} }
type UserBox struct {
Box Box
CollectionName string
TotalSizeLabel string
}
func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog.Logger) (*UploadService, error) { func NewUploadService(maxUploadSize int64, dataDir, baseURL string, logger *slog.Logger) (*UploadService, error) {
filesDir := filepath.Join(dataDir, "files") filesDir := filepath.Join(dataDir, "files")
dbDir := filepath.Join(dataDir, "db") dbDir := filepath.Join(dataDir, "db")
@@ -141,6 +155,10 @@ func (s *UploadService) Close() error {
return s.db.Close() return s.db.Close()
} }
func (s *UploadService) DB() *bbolt.DB {
return s.db
}
func (s *UploadService) MaxUploadSize() int64 { func (s *UploadService) MaxUploadSize() int64 {
return s.maxUploadSize return s.maxUploadSize
} }
@@ -166,6 +184,8 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
box := Box{ box := Box{
ID: randomID(10), ID: randomID(10),
OwnerID: strings.TrimSpace(opts.OwnerID),
CollectionID: strings.TrimSpace(opts.CollectionID),
CreatedAt: time.Now().UTC(), CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(time.Duration(opts.MaxDays) * 24 * time.Hour), ExpiresAt: time.Now().UTC().Add(time.Duration(opts.MaxDays) * 24 * time.Hour),
MaxDownloads: opts.MaxDownloads, MaxDownloads: opts.MaxDownloads,
@@ -186,9 +206,16 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
} }
for _, header := range files { for _, header := range files {
if !opts.SkipSizeLimit {
if err := s.ValidateSize(header.Size); err != nil { if err := s.ValidateSize(header.Size); err != nil {
return UploadResult{}, err return UploadResult{}, err
} }
}
maxSize := s.maxUploadSize
if opts.SkipSizeLimit {
maxSize = 0
}
file, err := header.Open() file, err := header.Open()
if err != nil { if err != nil {
@@ -203,7 +230,7 @@ func (s *UploadService) CreateBox(files []*multipart.FileHeader, opts UploadOpti
contentType = "application/octet-stream" contentType = "application/octet-stream"
} }
if err := writeUploadedFile(storedPath, file, s.maxUploadSize); err != nil { if err := writeUploadedFile(storedPath, file, maxSize); err != nil {
file.Close() file.Close()
return UploadResult{}, err return UploadResult{}, err
} }
@@ -314,6 +341,7 @@ func (s *UploadService) AdminBoxes(limit int) ([]AdminBox, error) {
} }
rows = append(rows, AdminBox{ rows = append(rows, AdminBox{
ID: box.ID, ID: box.ID,
OwnerID: box.OwnerID,
CreatedAt: box.CreatedAt, CreatedAt: box.CreatedAt,
ExpiresAt: box.ExpiresAt, ExpiresAt: box.ExpiresAt,
FileCount: len(box.Files), FileCount: len(box.Files),
@@ -328,6 +356,97 @@ func (s *UploadService) AdminBoxes(limit int) ([]AdminBox, error) {
return rows, nil return rows, nil
} }
func (s *UploadService) UserBoxes(userID string, collectionNames map[string]string) ([]UserBox, error) {
boxes, err := s.ListBoxes(0)
if err != nil {
return nil, err
}
rows := make([]UserBox, 0)
for _, box := range boxes {
if box.OwnerID != userID {
continue
}
var size int64
for _, file := range box.Files {
size += file.Size
}
rows = append(rows, UserBox{
Box: box,
CollectionName: collectionNames[box.CollectionID],
TotalSizeLabel: helpers.FormatBytes(size),
})
}
sort.Slice(rows, func(i, j int) bool {
return rows[i].Box.CreatedAt.After(rows[j].Box.CreatedAt)
})
return rows, nil
}
func (s *UploadService) UserStorageUsed(userID string) (int64, error) {
return s.userStorageUsed(userID, false)
}
func (s *UploadService) UserActiveStorageUsed(userID string) (int64, error) {
return s.userStorageUsed(userID, true)
}
func (s *UploadService) userStorageUsed(userID string, activeOnly bool) (int64, error) {
boxes, err := s.ListBoxes(0)
if err != nil {
return 0, err
}
var total int64
now := time.Now().UTC()
for _, box := range boxes {
if box.OwnerID != userID {
continue
}
if activeOnly && !box.ExpiresAt.After(now) {
continue
}
for _, file := range box.Files {
total += file.Size
}
}
return total, nil
}
func (s *UploadService) RenameOwnedBox(boxID, userID, title string) error {
box, err := s.GetBox(boxID)
if err != nil {
return err
}
if box.OwnerID != userID {
return os.ErrPermission
}
box.Title = strings.TrimSpace(title)
return s.SaveBox(box)
}
func (s *UploadService) MoveOwnedBox(boxID, userID, collectionID string) error {
box, err := s.GetBox(boxID)
if err != nil {
return err
}
if box.OwnerID != userID {
return os.ErrPermission
}
box.CollectionID = strings.TrimSpace(collectionID)
return s.SaveBox(box)
}
func (s *UploadService) DeleteOwnedBox(boxID, userID string) error {
box, err := s.GetBox(boxID)
if err != nil {
return err
}
if box.OwnerID != userID {
return os.ErrPermission
}
return s.DeleteBoxWithSource(boxID, "user-delete")
}
func (s *UploadService) DeleteBox(boxID string) error { func (s *UploadService) DeleteBox(boxID string) error {
return s.DeleteBoxWithSource(boxID, "admin") return s.DeleteBoxWithSource(boxID, "admin")
} }
@@ -518,12 +637,17 @@ func writeUploadedFile(path string, source multipart.File, maxSize int64) error
} }
defer target.Close() defer target.Close()
written, err := io.Copy(target, io.LimitReader(source, maxSize+1)) var written int64
if maxSize <= 0 {
written, err = io.Copy(target, source)
} else {
written, err = io.Copy(target, io.LimitReader(source, maxSize+1))
}
if err != nil { if err != nil {
os.Remove(path) os.Remove(path)
return err return err
} }
if written > maxSize { if maxSize > 0 && written > maxSize {
os.Remove(path) os.Remove(path)
return fmt.Errorf("file exceeds max upload size") return fmt.Errorf("file exceeds max upload 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

@@ -20,6 +20,7 @@ type PageData struct {
Description string Description string
ImageURL string ImageURL string
CurrentYear int CurrentYear int
CurrentUser any
Data any Data any
} }

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);
@@ -90,12 +93,17 @@ svg {
.brand, .brand,
.nav-links, .nav-links,
.footer-links { .footer-links,
.inline-form {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
} }
.inline-form {
margin: 0;
}
.brand { .brand {
font-weight: 650; font-weight: 650;
text-decoration: none; text-decoration: none;
@@ -171,6 +179,187 @@ h1 {
padding: 1.5rem; padding: 1.5rem;
} }
.auth-view {
width: min(28rem, calc(100% - 2rem));
min-height: calc(100vh - 7.25rem);
margin: 0 auto;
padding: 3rem 0;
display: grid;
place-items: center;
}
.auth-card {
box-shadow: var(--shadow);
}
.kicker {
margin: 0 0 0.5rem;
color: var(--muted-foreground);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.muted-copy,
.auth-alt {
color: var(--muted-foreground);
font-size: 0.9rem;
line-height: 1.5;
}
.stack-form {
display: grid;
gap: 0.9rem;
margin-top: 1rem;
}
.stack-form label,
.inline-controls label,
.collection-create label {
display: grid;
gap: 0.35rem;
color: var(--muted-foreground);
font-size: 0.82rem;
}
.form-error {
margin: 0;
color: #fca5a5;
font-size: 0.86rem;
}
.app-shell {
width: min(86rem, calc(100% - 2rem));
margin: 0 auto;
padding: 2rem 0;
display: grid;
grid-template-columns: 14rem minmax(0, 1fr);
gap: 1.5rem;
}
.app-sidebar {
position: sticky;
top: 5rem;
align-self: start;
display: grid;
gap: 0.5rem;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: rgba(24, 24, 27, 0.58);
}
.sidebar-link {
padding: 0.62rem 0.75rem;
border: 1px solid transparent;
border-radius: var(--radius);
color: var(--muted-foreground);
text-decoration: none;
}
.sidebar-link:hover,
.sidebar-link.is-active {
border-color: var(--border);
background: var(--muted);
color: var(--foreground);
}
.admin-shell .app-sidebar {
border-color: rgba(125, 211, 252, 0.28);
background: linear-gradient(180deg, rgba(8, 47, 73, 0.22), rgba(24, 24, 27, 0.58));
}
.admin-shell .sidebar-link.is-active {
border-color: rgba(125, 211, 252, 0.42);
background: rgba(14, 116, 144, 0.24);
}
.admin-shell .kicker {
color: #7dd3fc;
}
.sidebar-logout {
display: grid;
margin: 0.75rem 0 0;
}
.sidebar-logout .button {
width: 100%;
}
.collection-create {
display: grid;
gap: 0.6rem;
margin-top: 1rem;
}
.app-main {
min-width: 0;
display: grid;
gap: 1rem;
}
.settings-stack {
display: grid;
gap: 1rem;
max-width: 44rem;
}
.settings-panel {
box-shadow: none;
}
.compact-upload .drop-zone {
min-height: 11rem;
}
.dashboard-options {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.collection-tabs,
.inline-controls {
display: flex;
align-items: end;
flex-wrap: wrap;
gap: 0.65rem;
}
.inline-controls input,
.inline-controls select {
min-width: 15rem;
}
.compact-input {
width: 10rem;
}
.settings-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
}
.settings-form-narrow {
grid-template-columns: minmax(0, 1fr);
}
.settings-form label {
display: grid;
gap: 0.35rem;
color: var(--muted-foreground);
font-size: 0.82rem;
}
.settings-form .checkbox-field {
grid-column: 1 / -1;
}
.settings-form button {
justify-self: start;
}
.drop-zone { .drop-zone {
min-height: 19rem; min-height: 19rem;
display: grid; display: grid;
@@ -1034,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,
@@ -1057,10 +1248,16 @@ pre code {
} }
.docs-grid, .docs-grid,
.field-grid { .field-grid,
.app-shell,
.settings-form {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.app-sidebar {
position: static;
}
.endpoint-list div { .endpoint-list div {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 0.25rem; gap: 0.25rem;

View File

@@ -27,8 +27,13 @@
<span>{{.AppName}}</span> <span>{{.AppName}}</span>
</a> </a>
<div class="nav-links"> <div class="nav-links">
{{if .CurrentUser}}
<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">My Account</a>
{{else}}
<a class="button button-ghost" href="/login">Login</a>
<a class="button button-ghost" href="/api">API</a>
{{end}}
</div> </div>
</nav> </nav>
</header> </header>
@@ -39,7 +44,7 @@
<footer class="site-footer"> <footer class="site-footer">
<span>{{.AppName}} · {{.CurrentYear}} · self-hosted</span> <span>{{.AppName}} · {{.CurrentYear}} · self-hosted</span>
<span class="footer-links"><a href="/">Upload</a><a href="/healthz">Health</a></span> <span class="footer-links">{{if .CurrentUser}}<a href="/api">API</a><a href="/account/settings">My Account</a>{{else}}<a href="/login">Login</a><a href="/api">API</a>{{end}}</span>
</footer> </footer>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,43 @@
{{define "account.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="app-shell" aria-labelledby="account-title">
<aside class="app-sidebar">
<a class="sidebar-link" href="/app">My files</a>
<a class="sidebar-link is-active" href="/account/settings">Account settings</a>
{{if eq .Data.Role "admin"}}<a class="sidebar-link" href="/admin">Admin</a>{{end}}
<form class="sidebar-logout" action="/logout" method="post">
<button class="button button-outline" type="submit">Logout</button>
</form>
</aside>
<div class="app-main">
<div class="admin-header">
<div>
<p class="kicker">Account</p>
<h1 id="account-title">Settings</h1>
<p class="muted-copy">{{.Data.Email}} · {{.Data.Role}}</p>
</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>
</section>
{{end}}

View File

@@ -1,15 +1,24 @@
{{define "admin.html"}}{{template "base" .}}{{end}} {{define "admin.html"}}{{template "base" .}}{{end}}
{{define "content"}} {{define "content"}}
<section class="admin-view" aria-labelledby="admin-title"> <section class="app-shell admin-shell" aria-labelledby="admin-title">
<aside class="app-sidebar">
<a class="sidebar-link {{if eq .Data.Section "overview"}}is-active{{end}}" href="/admin">Overview</a>
<a class="sidebar-link {{if eq .Data.Section "files"}}is-active{{end}}" href="/admin/files">Files</a>
<a class="sidebar-link" href="/admin/users">Users</a>
<a class="sidebar-link" href="/admin/settings">Settings</a>
<a class="sidebar-link" href="/app">My files</a>
<form class="sidebar-logout" action="/admin/logout" method="post">
<button class="button button-outline" type="submit">Logout</button>
</form>
</aside>
<div class="app-main">
<div class="admin-header"> <div class="admin-header">
<div> <div>
<p class="kicker">Operator console</p> <p class="kicker">Operator console</p>
<h1 id="admin-title">Admin overview</h1> <h1 id="admin-title">{{.Data.PageTitle}}</h1>
</div> </div>
<form action="/admin/logout" method="post">
<button class="button button-outline" type="submit">Logout</button>
</form>
</div> </div>
<div class="metric-grid"> <div class="metric-grid">
@@ -54,6 +63,7 @@
<thead> <thead>
<tr> <tr>
<th>Box</th> <th>Box</th>
<th>Owner</th>
<th>Files</th> <th>Files</th>
<th>Size</th> <th>Size</th>
<th>Downloads</th> <th>Downloads</th>
@@ -67,6 +77,7 @@
{{range .Data.Boxes}} {{range .Data.Boxes}}
<tr> <tr>
<td><code>{{.ID}}</code></td> <td><code>{{.ID}}</code></td>
<td>{{.Owner}}</td>
<td>{{.FileCount}}</td> <td>{{.FileCount}}</td>
<td>{{.TotalSizeLabel}}</td> <td>{{.TotalSizeLabel}}</td>
<td>{{.DownloadCount}}{{if .MaxDownloads}} / {{.MaxDownloads}}{{end}}</td> <td>{{.DownloadCount}}{{if .MaxDownloads}} / {{.MaxDownloads}}{{end}}</td>
@@ -84,12 +95,13 @@
</td> </td>
</tr> </tr>
{{else}} {{else}}
<tr><td colspan="8">No uploads yet.</td></tr> <tr><td colspan="9">No uploads yet.</td></tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
</div>
</section> </section>
{{end}} {{end}}

View File

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

View File

@@ -0,0 +1,82 @@
{{define "admin_users.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="app-shell admin-shell" aria-labelledby="admin-users-title">
<aside class="app-sidebar">
<a class="sidebar-link" href="/admin">Overview</a>
<a class="sidebar-link" href="/admin/files">Files</a>
<a class="sidebar-link is-active" href="/admin/users">Users</a>
<a class="sidebar-link" href="/admin/settings">Settings</a>
<a class="sidebar-link" href="/app">My files</a>
<form class="sidebar-logout" action="/admin/logout" method="post">
<button class="button button-outline" type="submit">Logout</button>
</form>
</aside>
<div class="app-main">
<div class="admin-header">
<div>
<p class="kicker">Operator console</p>
<h1 id="admin-users-title">{{.Data.PageTitle}}</h1>
</div>
</div>
<div class="card admin-table-card">
<div class="card-content">
<div class="table-header">
<div>
<h2>Create invite</h2>
<p>Copy the generated link and send it manually. SMTP delivery comes later.</p>
</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 class="card admin-table-card">
<div class="card-content">
<div class="table-header"><h2>Users</h2><p>Disable accounts or create reset links.</p></div>
<div class="admin-table-wrap">
<table class="admin-table">
<thead><tr><th>User</th><th>Email</th><th>Role</th><th>Status</th><th>Storage</th><th>Today</th><th>Joined</th><th>Actions</th></tr></thead>
<tbody>
{{range .Data.Users}}
<tr>
<td>{{.Username}}</td>
<td>{{.Email}}</td>
<td>{{.Role}}</td>
<td><span class="badge">{{.Status}}</span></td>
<td>{{.StorageUsed}} / {{.StorageQuota}}</td>
<td>{{.DailyUsed}}</td>
<td>{{.CreatedAt}}</td>
<td class="table-actions">
{{if eq .Status "disabled"}}
<form action="/admin/users/{{.ID}}/disable?disabled=false" method="post"><button class="button button-outline" type="submit">Reactivate</button></form>
{{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>
<form action="/admin/users/{{.ID}}/quota" method="post">
<input class="compact-input" name="storage_quota_mb" placeholder="Quota MB">
<button class="button button-outline" type="submit">Quota</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="8">No users yet.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
</div>
</section>
{{end}}

View File

@@ -0,0 +1,43 @@
{{define "auth.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="auth-view" aria-labelledby="auth-title">
<div class="card auth-card">
<div class="card-content">
{{if eq .Data.Mode "register"}}
<p class="kicker">Instance bootstrap</p>
<h1 id="auth-title">Create the admin account</h1>
<p class="muted-copy">The first user becomes the instance admin. Registration closes after this account is created.</p>
<form class="stack-form" action="/register" method="post">
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
<label><span>Username</span><input name="username" autocomplete="username" required></label>
<label><span>Email</span><input type="email" name="email" autocomplete="email" required></label>
<label><span>Password</span><input type="password" name="password" autocomplete="new-password" minlength="8" required></label>
<button class="button button-primary" type="submit">Create admin</button>
</form>
{{else if eq .Data.Mode "invite"}}
<p class="kicker">{{if .Data.IsReset}}Password reset{{else}}Invite{{end}}</p>
<h1 id="auth-title">{{if .Data.IsReset}}Choose a new password{{else}}Create your account{{end}}</h1>
{{if .Data.Email}}<p class="muted-copy">{{.Data.Email}}</p>{{end}}
<form class="stack-form" action="/invite/{{.Data.Token}}" method="post">
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
{{if not .Data.IsReset}}<label><span>Username</span><input name="username" autocomplete="username" required></label>{{end}}
<label><span>Password</span><input type="password" name="password" autocomplete="new-password" minlength="8" required></label>
<button class="button button-primary" type="submit">Accept invite</button>
</form>
{{else}}
<p class="kicker">Account</p>
<h1 id="auth-title">Sign in</h1>
<form class="stack-form" action="/login" method="post">
{{if .Data.Error}}<p class="form-error">{{.Data.Error}}</p>{{end}}
<input type="hidden" name="next" value="{{.Data.ReturnPath}}">
<label><span>Email</span><input type="email" name="email" autocomplete="email" required></label>
<label><span>Password</span><input type="password" name="password" autocomplete="current-password" required></label>
<button class="button button-primary" type="submit">Sign in</button>
</form>
{{end}}
<p class="auth-alt"><a href="/">Back to upload</a></p>
</div>
</div>
</section>
{{end}}

View File

@@ -0,0 +1,73 @@
{{define "dashboard.html"}}{{template "base" .}}{{end}}
{{define "content"}}
<section class="app-shell" aria-labelledby="dashboard-title">
<aside class="app-sidebar">
<a class="sidebar-link is-active" href="/app">Dashboard</a>
<a class="sidebar-link" href="/account/settings">Settings</a>
{{if eq .Data.User.Role "admin"}}<a class="sidebar-link" href="/admin">Admin</a>{{end}}
<form class="sidebar-logout" action="/logout" method="post">
<button class="button button-outline" type="submit">Logout</button>
</form>
<form class="collection-create" action="/app/collections" method="post">
<label>
<span>New collection</span>
<input name="name" placeholder="Projects">
</label>
<button class="button button-outline" type="submit">Create</button>
</form>
</aside>
<div class="app-main">
<div class="admin-header">
<div>
<p class="kicker">Personal space</p>
<h1 id="dashboard-title">My files</h1>
<p class="muted-copy">{{.Data.StorageUsed}} used · max file size {{.Data.MaxUploadSize}}</p>
</div>
<a class="button button-primary" href="/">Upload files</a>
</div>
<div class="collection-tabs">
<a class="button {{if not .Data.Selected}}button-primary{{else}}button-outline{{end}}" href="/app">All</a>
{{range .Data.Collections}}
<a class="button {{if eq $.Data.Selected .ID}}button-primary{{else}}button-outline{{end}}" href="/app?collection={{.ID}}">{{.Name}}</a>
{{end}}
</div>
<div class="card admin-table-card">
<div class="card-content">
<div class="table-header"><h2>Owned boxes</h2><p>Collections organize boxes. Shared links remain unlisted.</p></div>
<div class="admin-table-wrap">
<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>
<tbody>
{{range .Data.Boxes}}
<tr>
<td class="file-name">{{.Title}}</td>
<td>{{if .CollectionName}}{{.CollectionName}}{{else}}Unsorted{{end}}</td>
<td>{{.FileCount}}</td>
<td>{{.Size}}</td>
<td>{{.CreatedAt}}</td>
<td>{{.ExpiresAt}}</td>
<td class="table-actions">
<a class="button button-outline" 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}}/move" method="post">
<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 action="/app/boxes/{{.ID}}/delete" method="post"><button class="button button-danger" type="submit">Delete</button></form>
</td>
</tr>
{{else}}
<tr><td colspan="7">You have no boxes yet.</td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
</div>
</section>
{{end}}

View File

@@ -15,7 +15,7 @@
</span> </span>
<span class="drop-title">Drop files to upload</span> <span class="drop-title">Drop files to upload</span>
<span class="drop-copy">or click to browse</span> <span class="drop-copy">or click to browse</span>
<span class="drop-meta">Max file size: {{.Data.MaxUploadSize}} · 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>
@@ -25,6 +25,15 @@
Advanced options Advanced options
</summary> </summary>
<div class="option-grid"> <div class="option-grid">
{{if .CurrentUser}}
<label>
<span>Collection</span>
<select name="collection_id">
<option value="">Unsorted</option>
{{range .Data.Collections}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
</label>
{{end}}
<label> <label>
<span>Expires in</span> <span>Expires in</span>
<select name="max_days"> <select name="max_days">

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