/// // 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 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 an admin-maintained plaintext // shared config, gated by auth AND a group code. There is one row per group // that the owner shares accounts with (family, friends), each with its own // credentials and its own code; anyone without a code brings their own // accounts instead, through the setup flow on the account page. // // **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 again: it is no longer a key to // the front door, and no longer the only group code either. Codes now live on the // template rows, and the env var is a fallback that opens the row flagged // `isDefault` so the move to several groups broke nobody mid-setup. // ── 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() 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 }. 403 for a code // that matches nothing, 429 when rate-limited. There is no 404: "no such code" and // "no row behind that code" are the same answer, because distinguishing them would // confirm which codes exist. // // **One row per group, each with its own code.** The family shares one set of paid // accounts; friends now get a second set that is deliberately not the family's. So // the code does not just gate the route, it *selects the row* — which is the part // that used to be missing (see the migration, and the comment at the lookup below). // // **Auth AND a code.** Two gates, because they answer different questions. Auth is // guaranteed — accounts are owner-created, so anyone reaching setup is signed in — // but "has an account" must not mean "may have somebody's shared credentials". The // code is what says which group's credentials, if any, this person is owed. A friend // with no code brings their own accounts instead, through the setup flow on the // account page. // // AMBER_INVITE_CODE is now only a fallback, opening the row flagged `isDefault`, so // that the change broke nobody mid-setup and needed no Coolify edit. Give the family // row its own `code` and the env var stops mattering. // // Rate-limited per IP as before, and per IP rather than per code on purpose: guessing // at one group's code must not earn a fresh budget by switching to another's. routerAdd("GET", "/api/amber/onboarding-template", (e) => { const MAX_FAILS = 10 const WINDOW_SECONDS = 300 // Fixed-window per-IP limit (app store — file scope state does not survive the // isolated handler JSVMs). Counted per IP rather than per code, so guessing at // one group's code does not get a fresh budget by switching to another's. 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 fail = () => { store.set(key, { count: (st ? st.count : 0) + 1, resetAt: st ? st.resetAt : now + WINDOW_SECONDS, }) return e.json(403, { error: "invalid code" }) } const given = (e.request.url.query().get("code") || "").trim() // An empty code can never match: the filter below requires a non-empty stored // code, and this returns before it. That is what keeps the route fail-closed on // an instance where nothing has been configured yet. if (!given) return fail() // **The code chooses the record.** It used to check the code and then take the // most recently updated row regardless, which was invisible with one row and // would have handed the family's credentials to friends with two. Bound as a // filter parameter, never concatenated, so a code cannot smuggle in filter // syntax of its own. let rec = null try { const rows = $app.findRecordsByFilter( "onboarding_template", "code != '' && code = {:code}", "-updated", 1, 0, { code: given }) if (rows && rows.length) rec = rows[0] } catch (_) { /* collection empty */ } // Fallback for the original single code, which lives in the environment and not // on any row. It opens the row flagged `isDefault` (the family), so nobody // halfway through setup is broken by this change and Coolify needs no edit. Once // that row carries its own `code`, the env var is redundant and can go. if (!rec) { const legacy = ($os.getenv("AMBER_INVITE_CODE") || "").trim() if (legacy && given === legacy) { try { const rows = $app.findRecordsByFilter( "onboarding_template", "isDefault = true", "-updated", 1, 0) if (rows && rows.length) rec = rows[0] } catch (_) { /* no default row */ } } } // A wrong code and a code with no row behind it are the same answer on purpose: // telling the difference would confirm which codes exist. if (!rec) return fail() store.set(key, null) return e.json(200, { addonUrl: rec.getString("addonUrl"), 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 // (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)) })