From 0b65fd7f4929f63ff751b54f404f300b82914563 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 18:23:05 +0200 Subject: [PATCH] Family onboarding site: invite-gated signup, template config, /get/tv Distribution site served by PocketBase itself (pb_public/, same origin): - Landing (/): create-account CTA, TV sign-in steps, login-gated PC downloads via the existing update manifest + file tokens - Wizard (/onboarding.html): invite code -> account -> named default profile -> family template fetched and encrypted IN THE BROWSER under the new user's password (PBKDF2-210k + AES-GCM, byte-compatible with the app's AddonConfigCrypto - a wizard blob decrypts in Dart, verified) -> pushed as their addon_config ciphertext. Adult fields never included. - /get/tv: public direct download of the latest clean Android APK (Downloader-friendly; adult builds stay account-gated) Backend: - onboarding_template collection (admin-only, maintained via admin UI) - AMBER_INVITE_CODE env gates BOTH users creation (X-Amber-Invite header or ?invite=) and the template route (per-IP rate limit 10/5min); fail-closed when unset. Note: this closes the previously-open in-app registration too. - Dockerfile ships pb_public + --publicDir; compose passes the env var Contract + Coolify steps (second domain amber.petruzalekr.cz) in docs/onboarding-contract.md. Verified locally end-to-end against pocketbase 0.39.6 (real-browser wizard run + Dart decrypt interop). Co-Authored-By: Claude Fable 5 --- Dockerfile | 5 +- docker-compose.yml | 4 + docs/onboarding-contract.md | 107 +++++++ pb_hooks/onboarding.pb.js | 107 +++++++ .../1787500000_onboarding_template.js | 46 +++ pb_public/index.html | 161 ++++++++++ pb_public/onboarding.html | 295 ++++++++++++++++++ 7 files changed, 724 insertions(+), 1 deletion(-) create mode 100644 docs/onboarding-contract.md create mode 100644 pb_hooks/onboarding.pb.js create mode 100644 pb_migrations/1787500000_onboarding_template.js create mode 100644 pb_public/index.html create mode 100644 pb_public/onboarding.html diff --git a/Dockerfile b/Dockerfile index 00bc305..33fb91a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,6 +26,9 @@ COPY pb_migrations /pb_migrations # from --hooksDir on serve. COPY pb_hooks /pb_hooks +# Static distribution/onboarding site (landing + account wizard) served at /. +COPY pb_public /pb_public + # Persist DB, uploaded files, and any admin-created migrations here. Runs as root # so it can always write the volume Coolify attaches — Coolify's persistent mount # is root-owned, and a non-root user hitting it fails with SQLite "unable to open @@ -37,4 +40,4 @@ EXPOSE 8090 # Coolify terminates TLS at Traefik; PocketBase listens plain on 8090 behind it. ENTRYPOINT ["pocketbase"] -CMD ["serve", "--http=0.0.0.0:8090", "--dir=/pb_data", "--migrationsDir=/pb_migrations", "--hooksDir=/pb_hooks"] +CMD ["serve", "--http=0.0.0.0:8090", "--dir=/pb_data", "--migrationsDir=/pb_migrations", "--hooksDir=/pb_hooks", "--publicDir=/pb_public"] diff --git a/docker-compose.yml b/docker-compose.yml index 6859609..bd7ed62 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,10 @@ services: PB_VERSION: 0.39.6 ports: - "8090:8090" + environment: + # Family invite code (onboarding.pb.js). Unset → registration + template + # route fail closed. Locally: AMBER_INVITE_CODE=test docker compose up. + AMBER_INVITE_CODE: ${AMBER_INVITE_CODE:-} volumes: - pb_data:/pb_data restart: unless-stopped diff --git a/docs/onboarding-contract.md b/docs/onboarding-contract.md new file mode 100644 index 0000000..de26199 --- /dev/null +++ b/docs/onboarding-contract.md @@ -0,0 +1,107 @@ +# Family onboarding & distribution site + +The distribution site (landing + account wizard) lives in `pb_public/` and is +served by PocketBase itself at `/` — same origin as the API, no extra service. +It exists so a family member can be fully set up **from a browser**: create an +account, receive the family addon config (encrypted client-side), and log the +TV in via the existing device sign-in flow — without ever installing Amber on a +PC or typing addon URLs. + +## Pages + +| Path | What | +|--------------------|-------------------------------------------------------------| +| `/` | Landing: create-account CTA, TV sign-in steps, PC downloads (login-gated) | +| `/onboarding.html` | The wizard: invite + account + profile + template config | +| `/device` | Existing device-approve page (issue #12), linked from both | +| `/get/tv` | Public direct download of the latest **clean** Android APK | + +## Environment + +| Var | Meaning | +|---------------------|-----------------------------------------------------------| +| `AMBER_INVITE_CODE` | The single family invite code. **Unset → registration and the template route are disabled (fail closed).** Set it in Coolify. | + +## Routes (pb_hooks/onboarding.pb.js) + +### users create gate +Creating a `users` record requires the invite code — header `X-Amber-Invite` +(what the wizard sends) or `?invite=` query fallback. Superusers bypass. +**Note:** this also closes the previously-open in-app registration; the app's +register screen will refuse without a code until it grows an invite field +(family onboarding happens through the site, so this is acceptable). + +### GET /api/amber/onboarding-template?code=CODE +Returns the admin-maintained plaintext family config: + +```json +{ "addonUrl": "…", "czechAddonUrl": "…", "tmdbKey": "…" } +``` + +- `403` bad code · `429` after 10 bad codes / 5 min / IP · `404` no template + record · `503` env unset. +- **Deliberately no adult fields** — the template is family config by + definition (mirrors the #14/#20 child stripping). Adult stays per-account. +- Plaintext travels once over TLS to the wizard, which encrypts it under the + *new user's* password (PBKDF2-HMAC-SHA256 210k → AES-256-GCM, blob = + `base64(nonce[12] ‖ ct ‖ tag[16])`, `kdf = pbkdf2-sha256-210000`) and pushes + it as the account's `addon_config` row — the server still stores only + ciphertext per account. Interop with the app's `AddonConfigCrypto` is + verified (a wizard-produced blob decrypts in the app's Dart crypto). + +### GET /get/tv +Streams the newest `releases` row with `platform=android && variant=clean` as +`amber-tv.apk` (attachment). Public by design: the TV-sideload path (the +Downloader app needs a plain URL, a fresh TV has no account, and the APK is +useless without one). Adult builds remain account-gated, unchanged. + +## Template maintenance (admin) + +One record in the `onboarding_template` collection, edited via the PocketBase +admin UI (`/_/`). Fields: `addonUrl`, `czechAddonUrl`, `tmdbKey` (+ free-form +`note`). Same value shapes the app's Settings store (base URLs without +`/manifest.json`). The hook serves the newest record if several exist — keep +just one. No client can read the collection directly (all rules null). + +## Wizard flow (onboarding.html) + +1. `POST /api/collections/users/records` with `X-Amber-Invite` (email + + password). +2. `auth-with-password` → token. +3. `POST /api/collections/profiles/records` — the default profile, named by + the user ("Kdo se bude dívat?"). The app adopts the earliest-created + profile on first login (`CloudSyncService._ensureProfile`), so this *is* + their profile. +4. Template fetch → client-side encrypt → `POST /api/collections/addon_config/records` + (`profile`, `blob`, `salt` (fresh 16 B), `kdf`, `updatedAt`). +5. Success: TV instructions (`/get/tv` + "Přihlásit z jiného zařízení" → + `/device`), plus login-token-gated Windows/Linux download buttons (existing + `/api/update/manifest` + `/api/files/token`). + +Template application is optional (checkbox, default on); every failure past +account creation degrades gracefully — the account still works, config can be +entered in-app. + +## Coolify deploy + +1. DNS: `amber.petruzalekr.cz` → the server. +2. The service's **Domains** field becomes a comma-separated list: + `https://pb.petruzalekr.cz,https://amber.petruzalekr.cz` (save, don't + "Generate Domain", then restart). The site is host-agnostic (relative API + paths), so both domains work; hand the family the `amber.` one. +3. Env: add `AMBER_INVITE_CODE=` and redeploy. +4. Admin UI → `onboarding_template` → create the one record with the real + values. +5. Publish releases (`scripts/publish-release.sh`) so `/get/tv` and the + download buttons have something to serve. + +## Local verification (2026-07-22) + +Native `pocketbase serve` against the repo dirs + `AMBER_INVITE_CODE`: +signup refused without/with wrong code (Czech error) and accepted via header +and query · template route 403/200/429(10 per 5 min)/404 paths · full wizard +run in a real browser produced the user, the named profile, and an +`addon_config` blob that **decrypted with the app's Dart `AddonConfigCrypto`** +with every field intact (adult fields null/false) · `/get/tv` streamed a +published dummy APK byte-identical with the attachment header · landing's +download login handled the no-releases state. diff --git a/pb_hooks/onboarding.pb.js b/pb_hooks/onboarding.pb.js new file mode 100644 index 0000000..a6242fc --- /dev/null +++ b/pb_hooks/onboarding.pb.js @@ -0,0 +1,107 @@ +/// + +// Family onboarding: invite-gated registration + template config (distribution +// site). The instance is private — family only — so BOTH doors are keyed by a +// single invite code from the AMBER_INVITE_CODE env var (set in Coolify): +// +// 1. Creating a `users` record requires the code (header `X-Amber-Invite`, +// or `?invite=` as a fallback). Without it, registration is refused — this +// also closes the previously-open in-app register endpoint. Superusers +// (admin UI) bypass the gate. +// 2. GET /api/amber/onboarding-template?code=… returns the admin-maintained +// plaintext family config (onboarding_template collection) so the +// onboarding page can encrypt it client-side under the new user's +// password. Rate-limited per IP so the code can't be brute-forced. +// +// Fail-closed: with AMBER_INVITE_CODE unset, registration and the template +// route are both disabled (a private instance must not silently fall open). + +// ── users create gate ───────────────────────────────────────────────────────── +onRecordCreateRequest((e) => { + if (e.hasSuperuserAuth()) return e.next() + const code = $os.getenv("AMBER_INVITE_CODE") + const info = e.requestInfo() + // requestInfo() normalizes header keys to snake_case and exposes query params + // as a plain map (e.request.url.query() is NOT available on record-request + // events — it throws, which read as a generic 400). + const given = + ((info.headers["x_amber_invite"] || info.query["invite"] || "") + "").trim() + if (!code) { + throw new BadRequestError("Registrace je uzavřená (server nemá nastavený AMBER_INVITE_CODE).") + } + if (given !== code) { + throw new BadRequestError("Registrace vyžaduje platný kód pozvánky.") + } + e.next() +}, "users") + +// ── GET /api/amber/onboarding-template ──────────────────────────────────────── +// Query: ?code=. → { addonUrl, czechAddonUrl, tmdbKey } (values may be +// empty strings when the template record isn't filled yet). 403 on a bad code, +// 429 when rate-limited, 404 when no template record exists. +routerAdd("GET", "/api/amber/onboarding-template", (e) => { + const MAX_FAILS = 10 + const WINDOW_SECONDS = 300 + + const code = $os.getenv("AMBER_INVITE_CODE") + if (!code) return e.json(503, { error: "onboarding disabled" }) + + // Fixed-window per-IP limit, same shape as verify-pin's (app store — file + // scope state does not survive the isolated handler JSVMs). + const store = $app.store() + const key = "amber.onboardFails." + e.realIP() + const now = Math.floor(Date.now() / 1000) + let st = store.get(key) + if (st && st.resetAt <= now) st = null + if (st && st.count >= MAX_FAILS) { + return e.json(429, { error: "too many attempts", retryAfter: st.resetAt - now }) + } + + const given = (e.request.url.query().get("code") || "").trim() + if (given !== code) { + store.set(key, { + count: (st ? st.count : 0) + 1, + resetAt: st ? st.resetAt : now + WINDOW_SECONDS, + }) + return e.json(403, { error: "invalid invite code" }) + } + store.set(key, null) + + let rec = null + try { + const rows = $app.findRecordsByFilter("onboarding_template", "id != ''", "-updated", 1, 0) + if (rows && rows.length) rec = rows[0] + } catch (_) { /* collection empty */ } + if (!rec) return e.json(404, { error: "no template configured" }) + + return e.json(200, { + addonUrl: rec.getString("addonUrl"), + czechAddonUrl: rec.getString("czechAddonUrl"), + tmdbKey: rec.getString("tmdbKey"), + }) +}) + +// ── GET /get/tv ─────────────────────────────────────────────────────────────── +// Public direct download of the latest CLEAN Android APK — the TV-sideload path +// (the Downloader app needs a plain URL; a fresh TV has no account yet, and the +// APK alone is useless without one, so serving the clean build openly is fine). +// Adult builds stay account-gated (manifest + protected file), unchanged. +routerAdd("GET", "/get/tv", (e) => { + let rec = null + try { + const rows = $app.findRecordsByFilter( + "releases", + "platform = 'android' && variant = 'clean'", + "-buildNumber", + 1, 0 + ) + if (rows && rows.length) rec = rows[0] + } catch (_) { /* none published yet */ } + if (!rec) return e.json(404, { error: "no release published yet" }) + + const filename = rec.getString("file") + // Local storage layout: /storage///. + const path = $app.dataDir() + "/storage/" + rec.collection().id + "/" + rec.id + "/" + filename + e.response.header().set("Content-Disposition", 'attachment; filename="amber-tv.apk"') + return e.fileFS($os.dirFS($filepath.dir(path)), $filepath.base(path)) +}) diff --git a/pb_migrations/1787500000_onboarding_template.js b/pb_migrations/1787500000_onboarding_template.js new file mode 100644 index 0000000..0cd0dd6 --- /dev/null +++ b/pb_migrations/1787500000_onboarding_template.js @@ -0,0 +1,46 @@ +/// + +// Family onboarding template (distribution site). +// +// One admin-maintained record holding the *plaintext* addon config that new +// family accounts start from: the onboarding page (pb_public) fetches it +// through an invite-code-gated route, encrypts it in the browser under the new +// user's password, and pushes the result as their addon_config blob — so the +// server still only ever stores ciphertext per account, and family members get +// a working setup without typing addon URLs. +// +// Deliberately NO adult fields: the template is family config by definition +// (mirrors the #14/#20 child-profile stripping). Adult stays per-account, +// configured manually. +// +// All rules are null → superusers only. Clients never read this collection +// directly; the invite-gated hook (onboarding.pb.js) is the sole reader. +// Maintain the single record via the PocketBase admin UI. + +migrate((app) => { + const tpl = new Collection({ + type: "base", + name: "onboarding_template", + listRule: null, + viewRule: null, + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + // Same value shapes SettingsRepository stores (base URLs, no trailing + // /manifest.json). All optional so the record can be filled gradually. + { type: "text", name: "addonUrl", max: 2000 }, + { type: "text", name: "czechAddonUrl", max: 2000 }, + { type: "text", name: "tmdbKey", max: 200 }, + // Free-form admin note ("updated after TorBox key rotation" etc.) — + // never served to clients. + { type: "text", name: "note", max: 1000 }, + { type: "autodate", name: "updated", onCreate: true, onUpdate: true }, + ], + }) + app.save(tpl) +}, (app) => { + try { + app.delete(app.findCollectionByNameOrId("onboarding_template")) + } catch (_) { /* already gone */ } +}) diff --git a/pb_public/index.html b/pb_public/index.html new file mode 100644 index 0000000..2679a3f --- /dev/null +++ b/pb_public/index.html @@ -0,0 +1,161 @@ + + + + + + +Amber — filmy a seriály pro rodinu + + + +
+
+ +

Amber

+
+

Filmy, seriály a anime pro celou rodinu — česky, na televizi i na počítači.

+ +
+

Začínáš?

+

Průvodce ti vytvoří účet a rovnou nastaví vše potřebné. Budeš potřebovat + kód pozvánky od Richarda.

+ Vytvořit účet +
+ +
+

Přihlásit televizi

+
    +
  1. Na televizi nainstaluj Amber — v prohlížeči nebo v aplikaci + Downloader otevři /get/tv
  2. +
  3. Spusť Amber a zvol „Přihlásit z jiného zařízení“
  4. +
  5. Naskenuj QR kód telefonem, nebo pokračuj tlačítkem níže a opiš kód
  6. +
+
+ Zadat kód z televize +
+ +
+

Stáhnout do počítače

+

Přihlas se svým účtem — stažení je vázané na účet.

+
+ + + + +
+ +
+ +
+
+ +
Amber je soukromá rodinná aplikace.
+
+ + + + diff --git a/pb_public/onboarding.html b/pb_public/onboarding.html new file mode 100644 index 0000000..ce385e0 --- /dev/null +++ b/pb_public/onboarding.html @@ -0,0 +1,295 @@ + + + + + + +Amber — vytvořit účet + + + +
+

Vytvořit účet

+

Za pár kroků budeš mít Amber připravený ke sledování.

+ + +
+ + + + + + + + + + +
+ + +
+ +
+ + + + +
+ + + + +

Heslo se použije jen ve tvém prohlížeči k zašifrování nastavení + — na server se heslo ani klíč nikdy neposílají. Nastavení odemkneš přihlášením + v aplikaci.

+
+ + + +