diff --git a/README.md b/README.md index 7fa82a7..f713b40 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,9 @@ The server serves the SPA from `frontend/dist` in production. All non-`/api/*`, non-`/assets/*` requests fall through to `index.html` so client-side routing still works. -## Container (podman) +## Deployment + +### Container (podman) The provided `Containerfile` builds a single image that serves API + frontend and persists the SQLite database in `/app/data` (one volume). @@ -124,7 +126,107 @@ podman run --replace --name vinterliste \ ``` The container exposes `/api/health` for healthchecks and bakes the build date / -git revision into both OCI labels and `/etc/build-info`. +git revision into both OCI labels and `/etc/build-info`. Use `podman run +--replace ...` for redeploys — it's atomic and avoids the "container exists" +race. + +### Environment variables + +| Variable | Default | Notes | +|--------------------|------------------------|---------------------------------------------------------------| +| `PORT` | `3000` | TCP port the server listens on. | +| `NODE_ENV` | (unset) | Set to `production` to serve `frontend/dist` from the API. | +| `VINTERLISTE_DB` | `data/vinterliste.db` | Path to the SQLite file. Override for an external volume. | +| `PUBLIC_BASE_URL` | (derived from request) | Override the absolute URL used in OpenGraph `og:url` tags. | + +There are no secrets to set. Auth verifiers and DEK wraps live in the SQLite +file; session tokens are generated per process and stored server-side, not +signed. + +### TLS termination + +The app speaks plain HTTP — terminate TLS at a reverse proxy (Caddy, nginx, +Traefik). The session cookie is marked `Secure` when the request was HTTPS +(`X-Forwarded-Proto: https`), so make sure the proxy sets that header. + +Sample Caddyfile: + +```caddyfile +vinterliste.example.org { + encode zstd gzip + reverse_proxy localhost:3000 +} +``` + +Caddy auto-provisions a Let's Encrypt cert. Other proxies need the cert +configured manually. + +### Backup and restore + +The SQLite database is the entire app state — user accounts, DEK wraps, +activity ciphertexts, sessions, the lot. Backing it up while the server is +running is safe because of WAL mode: + +```bash +# Atomic backup using SQLite's built-in copy +sqlite3 data/vinterliste.db ".backup '/path/to/backup/vinterliste-$(date +%F).db'" + +# Or via the container's volume +podman exec vinterliste sqlite3 /app/data/vinterliste.db \ + ".backup '/app/data/backup-$(date +%F).db'" +``` + +Plain file copy of the `.db` works too if the server is stopped first. With WAL +files (`.db-wal`, `.db-shm`) present, copy all three or use `.backup`. + +To restore: replace the file on disk and restart the server. There are no +out-of-band caches. + +### Healthcheck + +`GET /api/health` returns `{ ok: true, build: { revision, built_at } }` with +HTTP 200. Hook your monitoring or `HEALTHCHECK` directive at this endpoint. + +### Upgrading + +1. Build a new image with current `BUILD_DATE` and `GIT_REVISION` args. +2. `podman run --replace` — schema migrations are idempotent + (`CREATE TABLE IF NOT EXISTS …` and `ensureColumn(...)` add new columns + without touching existing data). +3. Verify `/api/health` returns the new `revision`. +4. The `activities` table's CHECK constraint includes all visibility values; + the `friends` visibility added later is migrated in via + `ensureActivitiesCheckIncludesFriends()` (table copy-drop-rename) on + first boot if needed. Take a backup beforehand the first time you upgrade + past a CHECK-constraint change. + +### Emergency password reset (CLI) + +If an admin has lost access (forgotten password, lost recovery code, etc.) and +can't recover via the UI, the server box has a CLI tool: + +```bash +# Inside the container: +podman exec -it vinterliste bun run reset-password admin@example.org + +# Or on the host if you're running the server directly: +bun run reset-password admin@example.org +``` + +It asks one question first: **do you still have this user's recovery code?** + +- **Yes → recovery mode.** Behaves exactly like the in-app recovery flow: + unwraps the existing DEK with the recovery code, re-wraps it with the new + password. No data is lost. The recovery code stays valid afterwards. +- **No → nuke mode.** Generates a brand-new DEK + new recovery code and + prints the new code to stdout (write it down — it's shown once). The + user's **private activities are deleted** because their ciphertext was + encrypted with the now-unrecoverable old DEK. Public, semi, friends-only + activities, plus hearts / bookmarks / "gjort" marks, are kept. + +Both modes invalidate every existing session for the user, matching the +hygiene of the in-app `/auth/recovery-complete` endpoint. The CLI requires +direct DB access — there is no network exposure of this code path. ## Registration: open, invite-only, or both diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 3534856..324a90b 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -3,6 +3,7 @@ import { ready } from './lib/crypto'; import { api, ApiError } from './lib/api'; import { session, setSessionUserOnly } from './lib/session.svelte'; + import { goBack } from './lib/navigate'; import { logout } from './lib/auth'; import Login from './components/Login.svelte'; import Signup from './components/Signup.svelte'; @@ -138,7 +139,18 @@ // No session — fine. } - applyRoute(route); + // Cold-load redirect: a logged-in user landing on the public landing + // probably wants their own dashboard, not the marketing-y "what is this" + // page. We only redirect on this initial mount — not on every + // applyRoute call — so browser-back from /hjem to / still lets the + // explicit navigation through (no loop, and the wordmark intentionally + // sends logged-in users to /hjem instead of / anyway). + if (route.view === 'public-home' && session.user) { + pushUrl('/hjem'); + view = 'home'; + } else { + applyRoute(route); + } }); function applyRoute(route: Route) { @@ -164,22 +176,20 @@ } } - function leaveTag() { - // Same logic as leavePersonvern — back to wherever they were. - if (session.user) goHome(); - else goPublicHome(); - } - function goPersonvern() { pushUrl('/personvern'); view = 'personvern'; } - function leavePersonvern() { - // Send the visitor wherever they "would have been" — landing if logged out, - // dashboard if logged in. Either is more useful than staying on the doc page. - if (session.user) goHome(); - else goPublicHome(); + /** + * Back-button handler for sub-views (permalink, tag page, personvern, + * public list). Uses real browser history so the user returns to + * wherever they came from in the SPA — /hjem, /etiketter/foo, + * /aktivitet/bar, anywhere. Falls back to /hjem (or / when anonymous) + * on cold-loads where there's no prior history entry. + */ + function backToCallerOrHome() { + goBack(session.user ? '/hjem' : '/'); } function onAuthed() { @@ -207,7 +217,8 @@