From 00b20550c2091994c88757059f7529398c268847 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 01:54:25 +0200 Subject: [PATCH] Accounts are owner-created, and one page manages the whole account Registration is closed. The instance was gated by a single shared invite code in AMBER_INVITE_CODE -- a bearer secret, one string for everyone, forever, no record of who used it, and known to every family member who ever onboarded, so it leaked by design. An email whitelist was the obvious replacement and does not survive contact with the facts: it binds to a person only if the address is verified, and all five accounts on this instance are `verified = false`. For a household this size the owner creating each account in the admin UI is stronger than either and has nothing to leak. `users.createRule` is superuser-only now, and the hook refuses with a sentence a family member can read rather than a bare 403. The family code survives with a narrower job. It is no longer a key to the front door; it is what unlocks the *shared family credentials* during setup. Close friends get accounts too and bring their own (BYOC), so "has an account" must not mean "may have the family's sources" -- the template route now wants auth AND the code, keeping its rate limiter because the code is still a shared secret. pb_public/account.html replaces index/settings/onboarding with one page behind a persisted session: profiles (name, child flag, age ceiling, PIN), the 13 synced playback preferences per profile, sources, devices, downloads, account. The token persists in localStorage; the password never does, and anything needing it asks in the moment -- that is the honest split between "stay signed in" and "hold the key to your credentials in a browser tab". Sources can now be set up from your own service credentials. The page posts them straight to the addon's own /encode and builds TorBox's config token locally -- both addons send permissive CORS, so the credentials go from the family member's browser to the addon and never touch our server. prehraj.to is required because the addon's /encode returns 400 without it and decodeConfig rejects such a token, so "webshare only" is not currently possible; the copy says so rather than pretending. TorBox is optional and the copy is emphatic about what it is for. Two hazards handled rather than hoped over. Changing the password re-keys the addon vault, and the re-encrypt happens BEFORE the password PATCH: if it were after, a failure would leave a blob no device can read. And prefs writes carry `updatedAt`, the clock the app's LWW actually compares -- the server's `updated` is only the pull cursor -- and merge into the existing data rather than replacing it, because the app writes fields this page does not show yet. Also: `users.ratingDefault`, so the account-wide age ceiling stops being the one half of a parental control that does not sync. The app side ships with the next release. The other three device-local settings stay local on purpose, and `adult_enabled` most deliberately of all. Plan and reasoning: docs/plan-accounts-and-web.md in amber-app. --- pb_hooks/onboarding.pb.js | 79 +- pb_hooks/settings.pb.js | 6 - pb_hooks/web.pb.js | 19 + .../1789000000_owner_created_accounts.js | 38 + .../1789000001_users_rating_default.js | 39 + pb_public/account.html | 879 ++++++++++++++++++ 6 files changed, 1020 insertions(+), 40 deletions(-) delete mode 100644 pb_hooks/settings.pb.js create mode 100644 pb_hooks/web.pb.js create mode 100644 pb_migrations/1789000000_owner_created_accounts.js create mode 100644 pb_migrations/1789000001_users_rating_default.js create mode 100644 pb_public/account.html diff --git a/pb_hooks/onboarding.pb.js b/pb_hooks/onboarding.pb.js index a6242fc..48a781c 100644 --- a/pb_hooks/onboarding.pb.js +++ b/pb_hooks/onboarding.pb.js @@ -1,44 +1,55 @@ /// -// 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): +// Family onboarding: **accounts are created by the owner**, and the template +// config is served to whoever is already signed in. // -// 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. +// 1. Creating a `users` record is refused for everyone except a superuser. The +// owner makes accounts in the PocketBase admin UI and hands over a temporary +// password. There is no public signup and no invite string. +// 2. GET /api/amber/onboarding-template returns the admin-maintained plaintext +// family config, gated by auth AND the family code — family members use it, +// close friends bring their own credentials instead (BYOC). // -// Fail-closed: with AMBER_INVITE_CODE unset, registration and the template -// route are both disabled (a private instance must not silently fall open). +// **This replaced a single shared invite code** (AMBER_INVITE_CODE) that gated +// both doors. That code was a bearer secret: one string, the same for everyone, +// forever, with no record of who used it — and known to every family member who +// ever onboarded, so it leaked by design. An email whitelist was considered and +// rejected in the same breath: it only binds to a person if the address is +// verified, and no account on this instance is (`verified = false` on all of +// them). For a household this size, "the owner makes the account" is stronger +// than either and has nothing to leak. See docs/plan-accounts-and-web.md. +// +// AMBER_INVITE_CODE survives with a narrower job: it is the *family* code that +// unlocks the shared template at setup, not a key to the front door. // ── users create gate ───────────────────────────────────────────────────────── +// Superuser only. The collection's createRule says the same thing (see the +// migration), and both exist on purpose: the rule is the structural guarantee, +// this hook is what returns a sentence a human can read instead of a bare 403. 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() + throw new BadRequestError( + "Účty zakládá správce — registrace je uzavřená. Napiš Richardovi.") }, "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. +// Query: ?code=. → { addonUrl, czechAddonUrl, tmdbKey }. 403 on a +// bad code, 429 when rate-limited, 404 when no template record exists. +// +// **Auth AND the family code.** Two gates, because they answer different +// questions. Auth is now guaranteed — accounts are owner-created, so anyone +// reaching setup is signed in — but "has an account" must not mean "may have the +// family's shared credentials": close friends get accounts too, and they bring +// their own (BYOC). The code is what distinguishes *family* from *someone the +// owner also trusts with an account*. +// +// So AMBER_INVITE_CODE keeps its job and loses its old one. It is no longer a +// registration key — registration is owner-only — it is the family credential +// selector during setup. The env var keeps its name so no Coolify change is +// needed; the name is now a misnomer and can be renamed at leisure. +// +// Rate-limited per IP as before: the code is still a shared secret, and being +// behind auth narrows who can guess at it but does not make guessing free. routerAdd("GET", "/api/amber/onboarding-template", (e) => { const MAX_FAILS = 10 const WINDOW_SECONDS = 300 @@ -46,8 +57,8 @@ routerAdd("GET", "/api/amber/onboarding-template", (e) => { 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). + // Fixed-window per-IP limit (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) @@ -63,7 +74,7 @@ routerAdd("GET", "/api/amber/onboarding-template", (e) => { count: (st ? st.count : 0) + 1, resetAt: st ? st.resetAt : now + WINDOW_SECONDS, }) - return e.json(403, { error: "invalid invite code" }) + return e.json(403, { error: "invalid family code" }) } store.set(key, null) @@ -79,7 +90,7 @@ routerAdd("GET", "/api/amber/onboarding-template", (e) => { czechAddonUrl: rec.getString("czechAddonUrl"), tmdbKey: rec.getString("tmdbKey"), }) -}) +}, $apis.requireAuth("users")) // ── GET /get/tv ─────────────────────────────────────────────────────────────── // Public direct download of the latest CLEAN Android APK — the TV-sideload path diff --git a/pb_hooks/settings.pb.js b/pb_hooks/settings.pb.js deleted file mode 100644 index 3ee295f..0000000 --- a/pb_hooks/settings.pb.js +++ /dev/null @@ -1,6 +0,0 @@ -/// Pretty URL for the account settings editor: /settings → the static page. -/// The page itself (pb_public/settings.html) is a login → decrypt → edit → -/// re-encrypt flow for the client-encrypted addon_config; everything happens in -/// the browser against the normal collection API, so there's no server logic -/// here beyond this redirect. -routerAdd("GET", "/settings", (e) => e.redirect(302, "/settings.html")) diff --git a/pb_hooks/web.pb.js b/pb_hooks/web.pb.js new file mode 100644 index 0000000..179b8f5 --- /dev/null +++ b/pb_hooks/web.pb.js @@ -0,0 +1,19 @@ +/// + +// Pretty URLs for the family-facing web surfaces. +// +// One page now does what three did: `account.html` is sign-in, profiles, +// playback preferences, sources, devices and downloads behind a persisted +// session. `/settings` keeps working because it is in people's muscle memory and +// in older messages — it lands on the sources section of the new page. +// +// `/tv` is the short form of `/get/tv`, because that URL gets typed on a +// television remote inside the Downloader app, one character at a time. + +routerAdd("GET", "/account", (e) => e.redirect(302, "/account.html")) + +// Deep-link straight to sources; the page reads the hash to pick its tab. +routerAdd("GET", "/settings", (e) => e.redirect(302, "/account.html#addons")) + +// Typing this on a remote is the actual cost being optimised. +routerAdd("GET", "/tv", (e) => e.redirect(302, "/get/tv")) diff --git a/pb_migrations/1789000000_owner_created_accounts.js b/pb_migrations/1789000000_owner_created_accounts.js new file mode 100644 index 0000000..e7d601b --- /dev/null +++ b/pb_migrations/1789000000_owner_created_accounts.js @@ -0,0 +1,38 @@ +/// + +// Registration is closed: `users` can only be created by a superuser. +// +// WHY: the instance was gated by a single shared invite code in +// AMBER_INVITE_CODE, checked in a hook. That is a bearer secret — one string for +// everyone, forever, no record of who used it, and known to every family member +// who ever onboarded, so it leaked by design. An email whitelist was the obvious +// replacement and was rejected: it binds to a person only if the address is +// verified, and every account on this instance is `verified = false`. For a +// household of five, the owner creating each account in the admin UI is stronger +// than either and has nothing to leak at all. +// +// The hook in pb_hooks/onboarding.pb.js refuses the same thing with a readable +// Czech sentence. **Both exist deliberately**: a hook can be edited or fail to +// load, so the rule is the structural guarantee; the rule alone would return a +// bare 403 that tells a family member nothing. +// +// `nsfwEnabled` stays out of createRule's reach exactly as before — it was +// already `@request.body.nsfwEnabled:isset = false`, and with creation limited to +// superusers that condition is now redundant but harmless. Keeping it means the +// down migration restores the previous rule verbatim. +// +// Existing accounts are untouched: this changes creation only. Sign-in, sync and +// every other rule are unaffected. + +migrate( + (app) => { + const users = app.findCollectionByNameOrId("users") + users.createRule = null // superuser only + app.save(users) + }, + (app) => { + const users = app.findCollectionByNameOrId("users") + users.createRule = "@request.body.nsfwEnabled:isset = false" + app.save(users) + }, +) diff --git a/pb_migrations/1789000001_users_rating_default.js b/pb_migrations/1789000001_users_rating_default.js new file mode 100644 index 0000000..1814e33 --- /dev/null +++ b/pb_migrations/1789000001_users_rating_default.js @@ -0,0 +1,39 @@ +/// + +// Add `ratingDefault` to users: the account-wide content ceiling. +// +// WHY IT MOVES TO THE SERVER: the per-profile override (`profiles.maxRating`) +// has lived here since migration 1787000000, while the *account default* it falls +// back to stayed in each device's secure storage. So the same parental control was +// half synced and half not — set the default on the PC and the television still +// used whatever it had. It is also the one of the four device-local settings the +// owner asked to sync, precisely because it is a parental control rather than a +// per-device convenience (`adult_enabled` deliberately does NOT follow: a toggle +// propagating to the children's television is a safety change, not a nicety). +// +// Stores the RatingTier enum name — `kids` | `twelve` | `fifteen` | +// `unrestricted` — matching `RatingTier.fromName`/`.name` in the app, so the +// existing device-local value migrates by being pushed as-is. +// +// Nullable: every account predates this, and an absent value must read as "not +// set" so `RatingTier.unrestricted` remains the fallback rather than a null crash. +// The app's two-clock LWW decides who wins when a device and the server disagree. + +migrate( + (app) => { + const users = app.findCollectionByNameOrId("users") + users.fields.add( + new TextField({ + name: "ratingDefault", + required: false, + max: 20, + }), + ) + app.save(users) + }, + (app) => { + const users = app.findCollectionByNameOrId("users") + users.fields.removeByName("ratingDefault") + app.save(users) + }, +) diff --git a/pb_public/account.html b/pb_public/account.html new file mode 100644 index 0000000..c22af77 --- /dev/null +++ b/pb_public/account.html @@ -0,0 +1,879 @@ + + + + + + +Amber — můj účet + + + +
+ + +
+
+

Amber

+

Přihlas se a spravuj svůj účet, profily a nastavení. + Přihlášení zůstane uložené, takže příště to bude rovnou tady.

+ + + + + +
+

Účty zakládá Richard — registrace tu není. Když se nemůžeš + dostat dovnitř, napiš mu.

+
+ +
+

Nainstalovat na televizi

+
    +
  1. Na televizi otevři aplikaci Downloader.
  2. +
  3. Zadej amber.petruzalekr.cz/tv — + stáhne se instalace.
  4. +
  5. Spusť Amber → Přihlásit z jiného zařízení a načti QR kód + telefonem. Na televizi nic nepíšeš.
  6. +
+
+
+ + + +
+ + + +