Distribution site served by PocketBase itself (pb_public/, same origin): - Landing (/): create-account CTA, TV sign-in steps, login-gated PC downloads via the existing update manifest + file tokens - Wizard (/onboarding.html): invite code -> account -> named default profile -> family template fetched and encrypted IN THE BROWSER under the new user's password (PBKDF2-210k + AES-GCM, byte-compatible with the app's AddonConfigCrypto - a wizard blob decrypts in Dart, verified) -> pushed as their addon_config ciphertext. Adult fields never included. - /get/tv: public direct download of the latest clean Android APK (Downloader-friendly; adult builds stay account-gated) Backend: - onboarding_template collection (admin-only, maintained via admin UI) - AMBER_INVITE_CODE env gates BOTH users creation (X-Amber-Invite header or ?invite=) and the template route (per-IP rate limit 10/5min); fail-closed when unset. Note: this closes the previously-open in-app registration too. - Dockerfile ships pb_public + --publicDir; compose passes the env var Contract + Coolify steps (second domain amber.petruzalekr.cz) in docs/onboarding-contract.md. Verified locally end-to-end against pocketbase 0.39.6 (real-browser wizard run + Dart decrypt interop). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
107 lines
4.9 KiB
JavaScript
107 lines
4.9 KiB
JavaScript
/// <reference path="../pb_data/types.d.ts" />
|
|
|
|
// 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=<invite>. → { 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: <dataDir>/storage/<collectionId>/<recordId>/<file>.
|
|
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))
|
|
})
|