Every duration in the config is a token TTL (access, refresh, content). A zero
or negative value mints already-expired tokens — no login, no media playback —
and previously loaded silently. parseDuration now rejects <= 0 with a clear
error, so misconfiguration fails fast at startup instead of mysteriously at
runtime. The AuthService itself stays permissive (it's constructed directly in
tests with arbitrary TTLs); config load is the gate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opening an original by URL (?access_token=) baked in the 15-minute access
token, so a long video opened in a new tab stopped streaming once that token
expired mid-playback: the access token can't be refreshed in an already-opened
tab, and its next Range request 401'd.
Add a content token: a signed, single-file capability (typ=content, fid claim)
with its own longer TTL (CONTENT_TOKEN_TTL, default 6h) and — crucially — no
session id, so it survives refresh rotation and outlives the short access TTL.
POST /files/:id/content-token mints one after the same view-ACL check content
serving does; GET /files/:id/content now runs under content-aware auth that
accepts either a normal access token or a content token scoped to that file.
View permission is still enforced against the token's user, so the token only
changes when a file may be read by URL, never which files. It's a bearer
capability for that one file until expiry, hence the bounded, configurable TTL.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The auth rate limiter keys on c.ClientIP(), but the router was built with
gin.New() and never called SetTrustedProxies — so Gin trusted all proxies by
default. Behind a host reverse proxy that meant the limiter either bucketed
every request under the proxy's IP, or (with the port reachable directly) could
be bypassed by a forged X-Forwarded-For.
NewRouter now takes a trusted-proxy list and configures SetTrustedProxies,
returning an error on an invalid list so misconfiguration fails fast at startup.
The list comes from a new TRUSTED_PROXIES config (CSV of CIDRs/IPs), defaulting
to loopback plus the Docker bridge ranges a host proxy reaches the container
through.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Use vipsthumbnail as the primary still-image path for both thumbnails
and previews (shared serveGenerated), falling back to the pure-Go
imaging pipeline when vips isn't on PATH. vips shrinks on load (e.g.
JPEG DCT scaling), so a 200+ Mpx photo is resized in a fraction of the
memory and CPU of a full in-process decode and no longer exceeds the
decode cap — the source that previously got only a placeholder now gets
a real thumbnail and preview. The output JPEG is written straight to the
cache (atomic temp→rename), fit within the target box and never upscaled.
The in-process pixel cap now guards only the pure-Go fallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Thumbnails/previews are generated lazily per request with no concurrency
limit, and the imaging resize already fans out across every core — so
scrolling to a handful of large images spawned that many all-core,
hundreds-of-MB decodes at once and pegged the server. Add a generation
semaphore (THUMB_CONCURRENCY, default = half the CPUs) so only a bounded
number run at a time; queued requests wait and re-check the cache.
Also raise the decode cap from 64 Mpx to a configurable ~300 Mpx default
(THUMB_MAX_PIXELS) so genuinely large photos (e.g. 13000×17000 ≈ 221
Mpx) get a real thumbnail instead of falling back to a placeholder.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make 42776 the project's default listen port everywhere :8080 was the default:
.env.example, the Go config fallback, the Dockerfile (ENV/EXPOSE/healthcheck),
and the docs example. 42776 is the sum of the Unicode code points of 七夕
(七 U+4E03 = 19971, 夕 U+5915 = 22805).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a multi-stage Dockerfile that builds the SvelteKit SPA (adapter-static,
no Node runtime in the final image) and the Go server, then ships an Alpine
runtime that serves both the static frontend and the API on one port.
- Stage 1 (node): npm ci + build → static SPA (index.html, _app, fonts, sw)
- Stage 2 (golang): CGO_ENABLED=0 static binary (image processing is pure Go)
- Stage 3 (alpine): + ffmpeg for video thumbnails, non-root user, /data volume,
healthcheck on /health; secrets passed at runtime, not baked in
To serve the SPA on the API port, the Go server now optionally hosts static
files behind a new STATIC_DIR env var: a request maps to a real file when one
exists, otherwise falls back to index.html for client-side routes; unknown
/api/ paths still return JSON 404. Empty STATIC_DIR (local dev) keeps the API
standalone while Vite serves the UI. Cache-Control is tuned to adapter-static
output (immutable hashed assets, no-cache service worker) and .webmanifest is
registered so nosniff doesn't reject the PWA manifest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Upload and Replace buffered the entire request body into memory with no
size limit, so a few large uploads could OOM the server. The file
handler now wraps the request body in http.MaxBytesReader and rejects any
file larger than MAX_UPLOAD_BYTES (default 500 MiB) before it is buffered.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
007_seed_data.sql shipped a fixed admin account whose bcrypt hash decodes
to the password "admin", giving every deployment the same known
credentials. The seed row is removed; UserService.EnsureAdmin now creates
the administrator on startup from ADMIN_USERNAME / ADMIN_PASSWORD. It is
idempotent and never overwrites an existing password, so an operator who
rotates the admin password keeps it across restarts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- internal/config: typed Config struct loaded from env vars via godotenv;
all fields from docs (listen addr, JWT, DB, storage, thumbs, import)
- migrations/embed.go: embed FS so goose SQL files are baked into the binary
- cmd/server/main.go: load config → connect pgxpool → goose migrations
(embedded) → Gin server with GET /health returning 200 OK
- .env.example: documents all required and optional env vars
- go.mod: bump to Go 1.26, add gin/pgx/goose/godotenv as direct deps
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>