diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1359f70 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,111 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +**Go Executable:** +```bash +/home/linuxbrew/.linuxbrew/bin/go +``` + +**Run dev server:** +```bash +# First-time setup: copy the env template +cp scripts/env/dev.env.example scripts/env/dev.env +# Edit scripts/env/dev.env to set WARPBOX_ADMIN_TOKEN and other values, then: +./scripts/run/dev.sh +``` + +**Run directly (one-off):** +```bash +cd backend +go run ./cmd/warpbox +``` + +**Run all tests:** +```bash +cd backend +go test ./... +``` + +**Run a single test or package:** +```bash +cd backend +go test ./libs/services/... -run TestDeleteTokenVerification +go test ./libs/handlers/... -v +``` + +**Build:** +```bash +cd backend +go build ./cmd/warpbox +``` + +## Architecture + +Warpbox is a self-hosted file-sharing app. All code lives under `backend/`. There is no frontend build step — the server renders Go templates and serves static assets directly. + +### Startup flow + +`cmd/warpbox/main.go` → `config.Load()` → `httpserver.New()` → `server.ListenAndServe()` + +`httpserver.New()` wires everything together: +1. Creates `web.Renderer` (template engine) +2. Creates `UploadService` (opens bbolt DB, creates `files/` and `db/` dirs) +3. Creates `AuthService` (reuses same `*bbolt.DB`) +4. Creates `SettingsService` (reuses same `*bbolt.DB`) +5. Starts background jobs via `jobs.StartAll` +6. Creates `handlers.App` with all services +7. Registers all routes on a `http.ServeMux` +8. Wraps the mux in middleware chain: `Recoverer → RequestID → SecurityHeaders → Gzip → Logger` + +### Services + +The three services share a single bbolt database. Each owns distinct buckets: + +| Service | Buckets | +|---|---| +| `UploadService` | `boxes` | +| `AuthService` | `users`, `user_emails`, `sessions`, `invites`, `collections` | +| `SettingsService` | `settings`, `usage` | + +`UploadService` owns the DB handle. `AuthService` and `SettingsService` receive `uploadService.DB()`. + +### Data model + +- **Box** — one upload session. Has expiry, optional download limit, optional password (SHA-256 salted hash), optional owner (`OwnerID`), optional collection. Stored as JSON in the `boxes` bucket. +- **File** — belongs to a Box. Stored on disk as `data/files/{boxID}/@each@{fileID}.ext`. Thumbnails at `@thumb@{fileID}.jpg`. +- Box metadata is also written to `data/files/{boxID}/.warpbox.box.json` on every save. +- **User** passwords are hashed with argon2id. Session tokens are SHA-256 hashed before storage. +- **Delete tokens** for anonymous boxes are one-time random IDs, stored only as a SHA-256 hash. + +### Handlers + +`handlers.App` holds all three services plus config, logger, and renderer. `RegisterRoutes` maps every URL pattern. Handler files are split by concern: `upload.go`, `download.go`, `dashboard.go` (user `/app`), `admin.go`, `auth.go`, `manage.go`, `pages.go`. + +### Upload policy enforcement + +`SettingsService` stores per-day usage records keyed by `ip:{ip}:{date}` or `user:{userID}:{date}`. The upload handler (`handlers/upload.go`) checks these against `UploadPolicySettings` before accepting a multipart form. Admins bypass all per-upload and daily limits. + +### Background jobs + +`jobs.StartAll` launches goroutines for: +- **Cleanup** (`WARPBOX_CLEANUP_ENABLED`): deletes expired boxes and boxes that hit their download limit. +- **Thumbnails** (`WARPBOX_THUMBNAIL_ENABLED`): generates JPEG thumbnails for image/video files that don't have one yet. + +### Configuration + +All config comes from env vars via `config.Load()`. The dev script sources `scripts/env/dev.env`. `WARPBOX_BASE_URL` is required and must not be empty. Size values accept an optional `MB`/`Mb` suffix and support fractions (e.g. `0.5` = 512 KiB). + +### Logging + +Structured JSONL logs go to `data/logs/{YYYY-MM-DD}.log` via `log/slog`. Every log entry includes `source` (e.g. `"user-upload"`, `"admin"`) and `severity` fields. User-activity events include a numeric `code` field (e.g. `2001` = upload complete, `2101` = box deleted). + +### Template rendering + +`web.Renderer` parses all templates from `backend/templates/` at startup using `html/template`. Page data is passed as `web.PageData`. The base layout is `templates/layouts/base.html`. The current logged-in user is injected into every page render via `a.currentPublicUser(r)`. + +## First-run bootstrap + +On a fresh `data/` directory, visit `/register` to create the first admin account. After bootstrap, normal registration is closed. Admins create invite links from `/admin/users`. The `WARPBOX_ADMIN_TOKEN` env var provides emergency fallback access at `/admin/login`. diff --git a/backend/libs/handlers/accounts_test.go b/backend/libs/handlers/accounts_test.go index 0b9fdc5..8ed0182 100644 --- a/backend/libs/handlers/accounts_test.go +++ b/backend/libs/handlers/accounts_test.go @@ -387,7 +387,7 @@ func TestAPIDocsHeaderReflectsLoggedInUser(t *testing.T) { } body := response.Body.String() header := body[:strings.Index(body, "Login<") || strings.Contains(header, "Health") { + if !strings.Contains(header, "Dashboard") || strings.Contains(header, "Sign in") || strings.Contains(header, "Health") { t.Fatalf("api header did not reflect logged-in state: %s", body) } } @@ -404,7 +404,7 @@ func TestAPIDocsHeaderReflectsLoggedOutUser(t *testing.T) { } body := response.Body.String() header := body[:strings.Index(body, "Login<") || !strings.Contains(header, ">API<") || strings.Contains(header, "Health") || strings.Contains(header, "My Account") { + if !strings.Contains(header, "Sign in") || !strings.Contains(header, ">API<") || strings.Contains(header, "Health") || strings.Contains(header, "Dashboard") { t.Fatalf("api header did not reflect logged-out state: %s", body) } } diff --git a/backend/static/css/app.css b/backend/static/css/app.css index 551eb1a..6e9b1f6 100644 --- a/backend/static/css/app.css +++ b/backend/static/css/app.css @@ -337,12 +337,12 @@ h1 { .settings-form { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.9rem; + gap: 1.5rem; } .settings-form-narrow { grid-template-columns: minmax(0, 1fr); + gap: 0.9rem; } .settings-form label { @@ -1292,4 +1292,239 @@ pre code { .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .tabs-bar { + flex-direction: column; + align-items: stretch; + } + + .settings-section { + grid-template-columns: 1fr; + } + + .new-collection-body { + position: static; + width: 100%; + margin-top: 0.5rem; + box-shadow: none; + } +} + +/* ── UX remaster ───────────────────────────────────────────── */ + +.button-sm { + min-height: 1.85rem; + padding: 0.3rem 0.65rem; + font-size: 0.8rem; +} + +/* Tab navigation */ +.tabs-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; +} + +.tab-list { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.3rem; +} + +.tab { + display: inline-flex; + align-items: center; + height: 2rem; + padding: 0 0.75rem; + border-radius: 999px; + border: 1px solid transparent; + color: var(--muted-foreground); + font-size: 0.84rem; + font-weight: 500; + text-decoration: none; + transition: background 120ms, color 120ms, border-color 120ms; +} + +.tab:hover { + background: var(--muted); + color: var(--foreground); +} + +.tab.is-active { + border-color: var(--border); + background: var(--muted); + color: var(--foreground); + font-weight: 650; +} + +/* Sidebar structure */ +.sidebar-sep { + height: 1px; + border: 0; + background: var(--border); + margin: 0.5rem 0; +} + +.sidebar-nav { + display: grid; + gap: 0.25rem; +} + +/* Inline row edit (details/summary in table cells) */ +.row-edit { + margin-top: 0.35rem; +} + +.row-edit > summary { + display: inline-flex; + align-items: center; + color: var(--muted-foreground); + font-size: 0.72rem; + cursor: pointer; + list-style: none; + text-decoration: underline; + text-decoration-style: dotted; + text-underline-offset: 2px; + opacity: 0.75; +} + +.row-edit > summary::-webkit-details-marker { display: none; } + +.row-edit[open] > summary { + opacity: 1; +} + +.row-edit-form { + display: flex; + gap: 0.4rem; + align-items: center; + margin-top: 0.4rem; +} + +.row-edit-form input, +.row-edit-form select { + width: auto; + flex: 1; + min-width: 8rem; + min-height: 1.9rem; + font-size: 0.8rem; + padding: 0.25rem 0.55rem; +} + +/* Badge variants */ +.badge-active { + background: rgba(134, 239, 172, 0.12); + color: #86efac; +} + +.badge-disabled { + background: rgba(252, 165, 165, 0.1); + color: #fca5a5; +} + +.badge-expired { + opacity: 0.55; +} + +/* Collection create dropdown */ +.new-collection-drop { + position: relative; + flex-shrink: 0; +} + +.new-collection-drop > summary { + list-style: none; + cursor: pointer; +} + +.new-collection-drop > summary::-webkit-details-marker { display: none; } + +.new-collection-body { + position: absolute; + right: 0; + top: calc(100% + 0.5rem); + z-index: 10; + width: 15rem; + padding: 1rem; + background: color-mix(in srgb, var(--card) 97%, #000); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + display: grid; + gap: 0.65rem; +} + +.new-collection-body label { + display: grid; + gap: 0.35rem; + color: var(--muted-foreground); + font-size: 0.82rem; +} + +/* Copyable URL field */ +.copy-field { + display: flex; + gap: 0.5rem; + align-items: center; + margin-top: 0.75rem; +} + +.copy-field input { + flex: 1; + min-width: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + font-size: 0.8rem; + color: var(--muted-foreground); +} + +/* Settings sections */ +.settings-section { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.9rem; +} + +.settings-section-title { + grid-column: 1 / -1; + margin: 0; + padding-bottom: 0.6rem; + border-bottom: 1px solid var(--border); + font-size: 0.875rem; + font-weight: 650; + color: var(--foreground); +} + +.settings-section .checkbox-field { + grid-column: 1 / -1; +} + +.settings-section label { + display: grid; + gap: 0.35rem; + color: var(--muted-foreground); + font-size: 0.82rem; +} + +/* Quota form in admin users table */ +.quota-form { + display: flex; + gap: 0.4rem; + align-items: center; + margin: 0; +} + +.quota-form input { + width: 6.5rem; + min-width: 0; +} + +/* Nav username indicator in header */ +.nav-username { + max-width: 8rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } diff --git a/backend/templates/layouts/base.html b/backend/templates/layouts/base.html index 5235354..c96ddb7 100644 --- a/backend/templates/layouts/base.html +++ b/backend/templates/layouts/base.html @@ -28,11 +28,13 @@ @@ -44,7 +46,7 @@ diff --git a/backend/templates/pages/account.html b/backend/templates/pages/account.html index f6947c1..9ee3154 100644 --- a/backend/templates/pages/account.html +++ b/backend/templates/pages/account.html @@ -3,11 +3,14 @@ {{define "content"}}
diff --git a/backend/templates/pages/admin.html b/backend/templates/pages/admin.html index 18401d2..2669b81 100644 --- a/backend/templates/pages/admin.html +++ b/backend/templates/pages/admin.html @@ -3,13 +3,19 @@ {{define "content"}}
diff --git a/backend/templates/pages/admin_settings.html b/backend/templates/pages/admin_settings.html index eb0ef00..a346b0a 100644 --- a/backend/templates/pages/admin_settings.html +++ b/backend/templates/pages/admin_settings.html @@ -3,62 +3,76 @@ {{define "content"}}
-
-
-

Operator console

-

{{.Data.PageTitle}}

-
-
- -
-
-
-
-

Upload policy

-

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

-
+
+
+

Operator console

+

{{.Data.PageTitle}}

+
+
+ +
+
+
+
+

Upload policy

+

Admin users bypass all upload caps. Values are in megabytes.

+
+
+ +
+
+

Anonymous uploads

+ + + +
+ +
+

User limits

+ + + +
+ + +
- -
- - - - - - - -
-
{{end}} diff --git a/backend/templates/pages/admin_users.html b/backend/templates/pages/admin_users.html index 6e25569..d266372 100644 --- a/backend/templates/pages/admin_users.html +++ b/backend/templates/pages/admin_users.html @@ -3,80 +3,110 @@ {{define "content"}}
-
-
-

Operator console

-

{{.Data.PageTitle}}

+
+
+

Operator console

+

{{.Data.PageTitle}}

+
-
-
-
-
-
-

Create invite

-

Copy the generated link and send it manually. SMTP delivery comes later.

+
+
+
+
+

Create invite

+

Copy the generated link and send it manually. SMTP delivery comes later.

+
+
+ {{if .Data.LastInviteURL}} +
+ + +
+ {{end}} +
+ + + +
+
+
+ +
+
+
+

Users

+

Disable accounts or generate reset links.

+
+
+ + + + + + + + + + + + + + + {{range .Data.Users}} + + + + + + + + + + + {{else}} + + {{end}} + +
UserEmailRoleStatusStorageTodayJoinedActions
{{.Username}}{{.Email}}{{.Role}}{{.Status}}{{.StorageUsed}} / {{.StorageQuota}}{{.DailyUsed}}{{.CreatedAt}} + {{if eq .Status "disabled"}} +
+ +
+ {{else}} +
+ +
+ {{end}} +
+ +
+
+ + +
+
No users yet.
- {{if .Data.LastInviteURL}} - - {{end}} -
- - - -
- -
-
-

Users

Disable accounts or create reset links.

-
- - - - {{range .Data.Users}} - - - - - - - - - - - {{else}} - - {{end}} - -
UserEmailRoleStatusStorageTodayJoinedActions
{{.Username}}{{.Email}}{{.Role}}{{.Status}}{{.StorageUsed}} / {{.StorageQuota}}{{.DailyUsed}}{{.CreatedAt}} - {{if eq .Status "disabled"}} -
- {{else}} -
- {{end}} -
-
- - -
-
No users yet.
-
-
-
-
{{end}} diff --git a/backend/templates/pages/dashboard.html b/backend/templates/pages/dashboard.html index a142496..e75e972 100644 --- a/backend/templates/pages/dashboard.html +++ b/backend/templates/pages/dashboard.html @@ -3,18 +3,14 @@ {{define "content"}}
@@ -22,46 +18,91 @@

Personal space

-

My files

+

My Files

{{.Data.StorageUsed}} used · max file size {{.Data.MaxUploadSize}}

Upload files
-
- All - {{range .Data.Collections}} - {{.Name}} - {{end}} +
+
+ All + {{range .Data.Collections}} + {{.Name}} + {{end}} +
+
+ + Collection +
+
+ + +
+
+
-

Owned boxes

Collections organize boxes. Shared links remain unlisted.

+
+
+

Boxes

+

Collections organise boxes. Shared links remain unlisted.

+
+
- + + + + + + + + + + {{range .Data.Boxes}} - - + + - {{else}} - + {{end}}
TitleCollectionFilesSizeCreatedExpiresActions
TitleCollectionFilesSizeExpiresActions
{{.Title}}{{if .CollectionName}}{{.CollectionName}}{{else}}Unsorted{{end}} +
{{.Title}}
+
+ Rename +
+ + +
+
+
+
{{if .CollectionName}}{{.CollectionName}}{{else}}{{end}}
+
+ Move +
+ + +
+
+
{{.FileCount}} {{.Size}}{{.CreatedAt}} {{.ExpiresAt}} - Open -
-
- - + Open + +
-
You have no boxes yet.
No boxes yet. Upload some files to get started.
diff --git a/backend/templates/pages/home.html b/backend/templates/pages/home.html index 73812e6..ec84653 100644 --- a/backend/templates/pages/home.html +++ b/backend/templates/pages/home.html @@ -3,8 +3,13 @@ {{define "content"}}
+ {{if .CurrentUser}} +

Upload files.

+

{{.Data.LimitSummary}}

+ {{else}}

Send a file. Get a link.

Anonymous, self-hosted transfers. No account required.

+ {{end}}
diff --git a/backend/warpbox b/backend/warpbox new file mode 100755 index 0000000..d289430 Binary files /dev/null and b/backend/warpbox differ