///
// 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))
})