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