diff --git a/docs/auto-update-contract.md b/docs/auto-update-contract.md index 283838d..5e90d23 100644 --- a/docs/auto-update-contract.md +++ b/docs/auto-update-contract.md @@ -1,7 +1,8 @@ # Auto-update — contract (issue #16) -The app checks for a newer build and installs it, receiving the **adult** or -**clean** variant according to its account's admin-set `nsfwEnabled` flag. +The app checks for a newer build and installs it. One variant ships, `clean`; +the server used to pick between that and an `adult` one from the account's +`nsfwEnabled` flag, and no longer does. Server pieces (this repo): - `pb_migrations/1786500000_releases.js` — the `releases` collection. @@ -16,7 +17,7 @@ manifest fetch), the desktop swap-installer, and the Android APK install channel | Field | Type | Notes | |---------------|--------|-------| | `platform` | select | `windows` \| `linux` \| `android`. | -| `variant` | select | `clean` \| `adult`. | +| `variant` | select | `clean` \| `adult`. Only `clean` is published now. The field stays because every installed app compares it against its own and refuses a mismatch **silently** — removing it would strand those installs with no error to see. | | `version` | text | Human semver shown to the user, e.g. `1.0.1`. | | `buildNumber` | number | Monotonic int; the comparator the updater actually uses. | | `file` | file | The artifact: windows = `.exe` installer, linux = `.zip` of the release bundle, android = `.apk`. Protected (see rules). | @@ -30,20 +31,20 @@ Unique index on `(platform, variant, buildNumber)`. **Access rules.** Read is gated: `@request.auth.id != '' && (variant = 'clean' || @request.auth.nsfwEnabled = true)` — any signed-in account reads **clean** rows; only an `nsfwEnabled` account reads -**adult** rows. The `file` field is **`protected: true`**, which is what gates the -bytes: a protected file is served only with a short-lived file token -(`POST /api/files/token`) whose grant **re-checks the view rule above** — so a -non-flagged account can't download an adult artifact (and without the flag the -file URL would be public regardless of the view rule). `create`/`update`/`delete` +**adult** rows. That rule is deliberately left in place: `adult` rows from before +the split are still in this collection, and the rule is what keeps them out of +reach. The `file` field is **`protected: true`**, which is what gates the bytes: +a protected file is served only with a short-lived file token +(`POST /api/files/token`) whose grant **re-checks the view rule above** (and +without the flag the file URL would be public regardless of the view rule). `create`/`update`/`delete` are **superuser-only** (null rules); publishing goes through the admin API. ## Endpoint ### `GET /api/update/manifest?platform=windows|linux|android` (auth: users) -Returns the latest build for the caller's platform. The **variant is chosen -server-side** from `nsfwEnabled` — the client cannot request adult. +Returns the latest build for the caller's platform, always `variant: "clean"`. -- No release for that platform/variant → `{ "available": false }`. +- No release for that platform → `{ "available": false }`. - Otherwise: ```json { @@ -81,16 +82,15 @@ bump `buildNumber` for each release. the zip. One artifact serves both jobs: people download and run it, and the app's updater runs the same file with `/SILENT`, which is why `DesktopInstaller` dispatches on the extension. The zip `release_windows.ps1` still builds beside it -is for the flavour check and for unpacking by hand — do not publish it. - -`check-flavor.py` cannot read a `.exe` (Inno compresses the payload) and refuses -rather than passing it. The Windows flavour gate is `Assert-Flavor` in -`release_windows.ps1`, which reads the staged folder before either artifact is -made from it; run it there, not here. +is for unpacking by hand — do not publish it. ## Gating summary -`nsfwEnabled` (admin-set on the `users` record) is the single source of truth: -the manifest hook reads it to pick the variant, and the collection rules enforce -it independently at read/download time. A no-flag account only ever sees and -downloads clean builds. +There is nothing left to gate between: one variant is published, and the manifest +hook returns it to everyone. + +`nsfwEnabled` still exists on the `users` record and the collection rules still +read it. Both stay because `adult` rows from before the split are still in the +collection, and those rules are what keeps them unreachable. Nothing writes the +flag any more: the manifest hook ignores it, `whoami` no longer returns it, and +the status dashboard's toggle is gone. diff --git a/pb_hooks/status.pb.js b/pb_hooks/status.pb.js index 1b6caf3..c2eb7a6 100644 --- a/pb_hooks/status.pb.js +++ b/pb_hooks/status.pb.js @@ -75,16 +75,20 @@ routerAdd("GET", "/api/status", (e) => { // ---- what the family is being served (local DB, always available) ------ const releases = safe("releases", () => { - // Newest build per platform+variant. Six rows in practice; a 200-row window - // is plenty and bounds the query regardless of history size. - const rows = $app.findRecordsByFilter("releases", "id != ''", "-buildNumber", 200, 0) + // Newest build per platform. Three rows in practice; a 200-row window is + // plenty and bounds the query regardless of history size. + // + // Filtered to variant='clean' rather than reading whatever is newest: rows + // from the 18+ flavour are still in this collection, and a leftover one + // could otherwise be reported as what the family is being served. + const rows = $app.findRecordsByFilter( + "releases", "variant = 'clean'", "-buildNumber", 200, 0) const newest = {} for (const r of rows) { - const key = r.getString("platform") + ":" + r.getString("variant") + const key = r.getString("platform") if (newest[key]) continue newest[key] = { platform: r.getString("platform"), - variant: r.getString("variant"), version: r.getString("version"), build: r.getInt("buildNumber"), size: r.getInt("size"), @@ -129,9 +133,7 @@ routerAdd("GET", "/api/status", (e) => { const accounts = safe("accounts", () => { const rows = $app.findRecordsByFilter("users", "id != ''", "", 500, 0) - let adult = 0 - for (const r of rows) if (r.getBool("nsfwEnabled")) adult++ - return { total: rows.length, adult: adult, clean: rows.length - adult } + return { total: rows.length } }) // ---- containers (Coolify) -------------------------------------------- @@ -243,16 +245,7 @@ routerAdd("GET", "/api/status/errors", (e) => { const ev = r.getString("event") || "" return kind === "error" || ev === "uncaught" || ev === "player_error" || ev === "exo_error" } - // Forward-compatible: works the moment the app starts sending a flavour. - const flavourOf = (r) => { - const top = r.getString("flavor") || r.getString("flavour") - if (top) return top - const meta = r.getString("meta") || "" - const m = /"flavou?r"\s*:\s*"([a-z]+)"/.exec(meta) - return m ? m[1] : "neznámá" - } - - const days_ = {}, byVersion = {}, byPlatform = {}, byFlavour = {}, byEvent = {} + const days_ = {}, byVersion = {}, byPlatform = {}, byEvent = {} // Pre-seed every day so the graph has no gaps — a missing bar and a zero bar // mean different things and must not look alike. for (let i = 0; i < days; i++) { @@ -272,7 +265,6 @@ routerAdd("GET", "/api/status/errors", (e) => { if (days_[day]) { days_[day].total++; if (err) days_[day].errors++ } bump(byVersion, r.getString("appVersion") || "?", err) bump(byPlatform, r.getString("platform") || "?", err) - bump(byFlavour, flavourOf(r), err) if (err) { const ev = r.getString("event") || "?" byEvent[ev] = (byEvent[ev] || 0) + 1 @@ -293,7 +285,6 @@ routerAdd("GET", "/api/status/errors", (e) => { days: Object.keys(days_).sort().map((k) => days_[k]), byVersion: list(byVersion), byPlatform: list(byPlatform), - byFlavour: list(byFlavour), topErrorEvents: Object.keys(byEvent).sort((a, b) => byEvent[b] - byEvent[a]) .slice(0, 8).map((k) => ({ key: k, errors: byEvent[k] })), }) diff --git a/pb_hooks/update.pb.js b/pb_hooks/update.pb.js index 7359751..3d2e447 100644 --- a/pb_hooks/update.pb.js +++ b/pb_hooks/update.pb.js @@ -4,13 +4,19 @@ // // GET /api/update/manifest?platform=windows|linux|android (auth: users) // -// Returns the latest published build for the caller's platform. The VARIANT is -// chosen server-side from the account's admin-set `nsfwEnabled` (adult) vs clean -// — the client never asks for adult. This is belt-and-suspenders on top of the -// `releases` collection rules, which already hide adult rows from non-flagged -// accounts. The download is PocketBase's native protected file endpoint: the app -// mints a short-lived file token (`POST /api/files/token`) and appends it to -// `downloadPath`, and the token grant re-checks the same gating rule. +// Returns the latest published build for the caller's platform. The download is +// PocketBase's native protected file endpoint: the app mints a short-lived file +// token (`POST /api/files/token`) and appends it to `downloadPath`, and the token +// grant re-checks the collection rule. +// +// `variant` used to be chosen here from the account's admin-set `nsfwEnabled`, +// picking an 18+ build over the clean one. Amber has no 18+ build any more, so it +// is a constant. +// +// It is NOT removed from the response, and must not be. Every Amber already +// installed compares this field against its own and refuses a mismatch SILENTLY +// — no error, no prompt, the update simply never appears. Drop it, or send +// anything but "clean", and those installs stop updating with nothing to see. // // NOTE: PocketBase runs each routerAdd handler in its own isolated JSVM, so the // small helpers live inside the handler. @@ -19,8 +25,8 @@ routerAdd("GET", "/api/update/manifest", (e) => { const platform = (e.request.url.query().get("platform") || "").toLowerCase().trim() if (ALLOWED.indexOf(platform) === -1) throw new BadRequestError("bad or missing platform") - // The account flag decides the channel; a client can't opt into adult. - const variant = e.auth.getBool("nsfwEnabled") ? "adult" : "clean" + // One build ships. See the note above for why this field survives at all. + const variant = "clean" let rec = null try { diff --git a/pb_hooks/whoami.pb.js b/pb_hooks/whoami.pb.js index 8499685..3569821 100644 --- a/pb_hooks/whoami.pb.js +++ b/pb_hooks/whoami.pb.js @@ -2,7 +2,7 @@ // Token introspection for amber-api (the facts service). // -// GET /api/amber/whoami (auth: users) → { id, nsfwEnabled } +// GET /api/amber/whoami (auth: users) → { id } // // WHY THIS EXISTS: amber-api has to know whether an inbound request carries a // valid Amber user token before it will spend an ffprobe on a caller-supplied @@ -13,13 +13,14 @@ // which `$apis.requireAuth("users")` does before this handler ever runs. // // So the handler body is deliberately trivial: reaching it *is* the answer. It -// returns the two facts amber-api needs and nothing else — no email, no -// username, no profile list. A token-introspection endpoint is a tempting place -// to leak account data, and amber-api has no business knowing any of it. +// returns the one fact amber-api needs and nothing else — no email, no username, +// no profile list. A token-introspection endpoint is a tempting place to leak +// account data, and amber-api has no business knowing any of it. // -// `nsfwEnabled` is included because it's the same admin-only flag that gates -// adult release channels, and a future amber-api surface may need to scope -// results by it. It is read-only here. +// It used to return `nsfwEnabled` too, on the reasoning that a future amber-api +// surface might scope results by it. No such surface was ever built, adult +// content is not something Amber serves, and an account flag nobody reads is +// exactly the kind of thing this endpoint should not be handing out. // // amber-api caches positives for ~10 minutes and negatives for ~30 seconds, so // this route sees roughly one request per device per 10 minutes — cheap enough @@ -29,8 +30,5 @@ // anything a handler needs must be declared INSIDE it — file-scope helpers throw // ReferenceError at request time. Nothing is hoisted here; keep it that way. routerAdd("GET", "/api/amber/whoami", (e) => { - return e.json(200, { - id: e.auth.id, - nsfwEnabled: e.auth.getBool("nsfwEnabled"), - }) + return e.json(200, { id: e.auth.id }) }, $apis.requireAuth("users")) diff --git a/pb_public/status.html b/pb_public/status.html index 160a036..3ddebe3 100644 --- a/pb_public/status.html +++ b/pb_public/status.html @@ -290,12 +290,12 @@ function panelReleases(rs){ esc(rs && rs.error || "—") + "
"; if (!rs.length) return 'nic zveřejněno
| platforma | varianta | verze | sha256 | velikost |
|---|---|---|---|---|
| platforma | verze | sha256 | velikost |
Buildy se nemusí shodovat mezi platformami — když se vydává jen ' +
"pro počítače, televize zůstane na starším čísle a nedostane výzvu k aktualizaci." +
@@ -345,8 +345,6 @@ function panelAccounts(a){
if (!a || a.error) return "";
return ' Nový účet se zakládá ve Správě.Účty
Historie je omezená retencí: client_logs se každou noc čistí ' + @@ -658,13 +655,8 @@ async function setNoticeActive(id, to){ function viewAdmin(users, tpls){ var rows = (users || []).map(function(u){ return "