Nothing read `users.nsfwEnabled` any more. `/api/amber/whoami` stopped returning it, `GET /api/update/manifest` always answers `clean`, and the `releases` read rule stopped mentioning it in migration 1793000000, when the nine 18+ rows (437 MB) were deleted. What was left was a per-account flag nobody writes and nobody reads, sitting on the family's accounts and showing up in every schema dump and access-rule review as if it still gated something. A flag that looks like a protection but protects nothing costs the next reader the work of proving it dead. One live reader did remain: pb_public/index.html showed a "Zdroj 18+" source field and wrote `adultAddonUrl` into the encrypted addon config for a flagged account, and picked a different child-profile label for one. Both are gone, so the page offers the same three sources to everyone. Migration 1797000000 drops the field, rewrites users.updateRule to `id = @request.auth.id` (the clause it loses guarded a field that no longer exists), and removes `adult` from releases.variant's allowed values. Checked before writing it: 15 release rows, every one `clean`; three accounts carried the flag (the owner's, one family member's, the retired test account); the separate 18+ app has no accounts and no PocketBase, so nothing outside Amber reads it. `variant` itself stays and stays required: every installed Amber compares it against its own build and refuses a mismatch SILENTLY, so dropping the field would strand those installs with no error to see. publish-release.sh now accepts only `clean`. The status dashboard stops listing `adult-stremio-addon` and `stripchat-proxy`. Both still run on the VPS; they are the owner's, not Amber's, and Amber never reached them. scripts/verify.py asserts the flag stays gone (field absent, no rule mentions it, updateRule own-record-only). Run against a local PocketBase built from origin/main it failed on exactly those three checks; with this migration applied, ALL CHECKS PASSED. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
155 lines
7.9 KiB
JavaScript
155 lines
7.9 KiB
JavaScript
/// <reference path="../pb_data/types.d.ts" />
|
|
|
|
// 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=<group 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).
|
|
// One variant is published, so this is the whole set of Android artifacts.
|
|
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))
|
|
})
|