Commit Graph

9 Commits

Author SHA1 Message Date
H1K0 98de298e5b feat(backend): file-scoped content tokens for media URLs
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>
2026-06-15 17:53:10 +03:00
H1K0 99668ec0d8 feat(backend): trust reverse-proxy X-Forwarded-For for the client IP
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>
2026-06-15 14:51:44 +03:00
H1K0 384386a34e feat(backend): generate thumbnails/previews via vipsthumbnail
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>
2026-06-12 01:25:55 +03:00
H1K0 2d2a42d523 fix(backend): bound thumbnail generation and decode larger images
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>
2026-06-12 01:07:30 +03:00
H1K0 69650b6464 chore(project): set the default port to 42776
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>
2026-06-11 11:03:26 +03:00
H1K0 f5f7db6c2a feat(project): containerize as a single image serving SPA + API
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>
2026-06-11 10:52:27 +03:00
H1K0 fa2acca858 fix(backend): cap upload size to prevent memory exhaustion
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>
2026-06-10 14:07:34 +03:00
H1K0 9ea939ccf6 fix(backend): bootstrap admin from env instead of seeding admin/admin
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>
2026-06-10 14:01:48 +03:00
H1K0 8565bf9200 feat(backend): config, migrations embed, and server entrypoint
- 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>
2026-04-03 18:57:17 +03:00