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. +
+
+
+ + + +
+ + + +