Stop choosing an update variant, and drop the 18+ controls

Amber ships one build. The manifest hook returned `adult` or `clean`
according to the account's nsfwEnabled flag; it now returns `clean` to
everyone.

The `variant` field itself stays in the response, and that is the
important part: every Amber already installed compares it against its
own and refuses a mismatch SILENTLY - no error, no prompt, the update
simply never appears. Removing the field, or sending anything else,
would strand every one of those installs with nothing to see.

whoami stops returning nsfwEnabled. It was there in case some future
amber-api surface wanted to scope results by it; none was built, and an
account flag nobody reads is what a token-introspection endpoint should
not be handing out. amber-api's copy goes with it - it was parsed into
the user object and never once branched on.

The dashboard loses the 18+ account tile, the variant column, the
per-flavour error table, the 18+ pill, the per-user enable/disable
button and the new-account checkbox. A control that sets a flag nothing
reads is worse than no control.

Releases are now read as newest-per-platform filtered to variant='clean',
rather than newest-per-platform-and-variant. Leftover 18+ rows are still
in the collection and would otherwise have been reported as what the
family is being served.

Those rows and the collection rule that hides them are deliberately left
alone: the rule is what keeps them unreachable, and deleting published
artifacts is not something to do as a side effect of a cleanup.

check-flavor.py is gone. It proved which of two builds an artifact was,
by a marker compiled into the Dart snapshot, because a clean-named
Windows installer once carried the 18+ payload. With one build there is
nothing to tell apart.
This commit is contained in:
Claude 2026-08-30 04:09:10 +02:00
parent 1b38ce9d73
commit 55444ee55e
6 changed files with 62 additions and 204 deletions

View file

@ -1,7 +1,8 @@
# Auto-update — contract (issue #16) # Auto-update — contract (issue #16)
The app checks for a newer build and installs it, receiving the **adult** or The app checks for a newer build and installs it. One variant ships, `clean`;
**clean** variant according to its account's admin-set `nsfwEnabled` flag. 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): Server pieces (this repo):
- `pb_migrations/1786500000_releases.js` — the `releases` collection. - `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 | | Field | Type | Notes |
|---------------|--------|-------| |---------------|--------|-------|
| `platform` | select | `windows` \| `linux` \| `android`. | | `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`. | | `version` | text | Human semver shown to the user, e.g. `1.0.1`. |
| `buildNumber` | number | Monotonic int; the comparator the updater actually uses. | | `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). | | `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: **Access rules.** Read is gated:
`@request.auth.id != '' && (variant = 'clean' || @request.auth.nsfwEnabled = true)` `@request.auth.id != '' && (variant = 'clean' || @request.auth.nsfwEnabled = true)`
— any signed-in account reads **clean** rows; only an `nsfwEnabled` account reads — 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 **adult** rows. That rule is deliberately left in place: `adult` rows from before
bytes: a protected file is served only with a short-lived file token the split are still in this collection, and the rule is what keeps them out of
(`POST /api/files/token`) whose grant **re-checks the view rule above** — so a reach. The `file` field is **`protected: true`**, which is what gates the bytes:
non-flagged account can't download an adult artifact (and without the flag the a protected file is served only with a short-lived file token
file URL would be public regardless of the view rule). `create`/`update`/`delete` (`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. are **superuser-only** (null rules); publishing goes through the admin API.
## Endpoint ## Endpoint
### `GET /api/update/manifest?platform=windows|linux|android` (auth: users) ### `GET /api/update/manifest?platform=windows|linux|android` (auth: users)
Returns the latest build for the caller's platform. The **variant is chosen Returns the latest build for the caller's platform, always `variant: "clean"`.
server-side** from `nsfwEnabled` — the client cannot request adult.
- No release for that platform/variant`{ "available": false }`. - No release for that platform → `{ "available": false }`.
- Otherwise: - Otherwise:
```json ```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 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` updater runs the same file with `/SILENT`, which is why `DesktopInstaller`
dispatches on the extension. The zip `release_windows.ps1` still builds beside it 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. is 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.
## Gating summary ## Gating summary
`nsfwEnabled` (admin-set on the `users` record) is the single source of truth: There is nothing left to gate between: one variant is published, and the manifest
the manifest hook reads it to pick the variant, and the collection rules enforce hook returns it to everyone.
it independently at read/download time. A no-flag account only ever sees and
downloads clean builds. `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.

View file

@ -75,16 +75,20 @@ routerAdd("GET", "/api/status", (e) => {
// ---- what the family is being served (local DB, always available) ------ // ---- what the family is being served (local DB, always available) ------
const releases = safe("releases", () => { const releases = safe("releases", () => {
// Newest build per platform+variant. Six rows in practice; a 200-row window // Newest build per platform. Three rows in practice; a 200-row window is
// is plenty and bounds the query regardless of history size. // plenty and bounds the query regardless of history size.
const rows = $app.findRecordsByFilter("releases", "id != ''", "-buildNumber", 200, 0) //
// 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 = {} const newest = {}
for (const r of rows) { for (const r of rows) {
const key = r.getString("platform") + ":" + r.getString("variant") const key = r.getString("platform")
if (newest[key]) continue if (newest[key]) continue
newest[key] = { newest[key] = {
platform: r.getString("platform"), platform: r.getString("platform"),
variant: r.getString("variant"),
version: r.getString("version"), version: r.getString("version"),
build: r.getInt("buildNumber"), build: r.getInt("buildNumber"),
size: r.getInt("size"), size: r.getInt("size"),
@ -129,9 +133,7 @@ routerAdd("GET", "/api/status", (e) => {
const accounts = safe("accounts", () => { const accounts = safe("accounts", () => {
const rows = $app.findRecordsByFilter("users", "id != ''", "", 500, 0) const rows = $app.findRecordsByFilter("users", "id != ''", "", 500, 0)
let adult = 0 return { total: rows.length }
for (const r of rows) if (r.getBool("nsfwEnabled")) adult++
return { total: rows.length, adult: adult, clean: rows.length - adult }
}) })
// ---- containers (Coolify) -------------------------------------------- // ---- containers (Coolify) --------------------------------------------
@ -243,16 +245,7 @@ routerAdd("GET", "/api/status/errors", (e) => {
const ev = r.getString("event") || "" const ev = r.getString("event") || ""
return kind === "error" || ev === "uncaught" || ev === "player_error" || ev === "exo_error" return kind === "error" || ev === "uncaught" || ev === "player_error" || ev === "exo_error"
} }
// Forward-compatible: works the moment the app starts sending a flavour. const days_ = {}, byVersion = {}, byPlatform = {}, byEvent = {}
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 = {}
// Pre-seed every day so the graph has no gaps — a missing bar and a zero bar // 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. // mean different things and must not look alike.
for (let i = 0; i < days; i++) { 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++ } if (days_[day]) { days_[day].total++; if (err) days_[day].errors++ }
bump(byVersion, r.getString("appVersion") || "?", err) bump(byVersion, r.getString("appVersion") || "?", err)
bump(byPlatform, r.getString("platform") || "?", err) bump(byPlatform, r.getString("platform") || "?", err)
bump(byFlavour, flavourOf(r), err)
if (err) { if (err) {
const ev = r.getString("event") || "?" const ev = r.getString("event") || "?"
byEvent[ev] = (byEvent[ev] || 0) + 1 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]), days: Object.keys(days_).sort().map((k) => days_[k]),
byVersion: list(byVersion), byVersion: list(byVersion),
byPlatform: list(byPlatform), byPlatform: list(byPlatform),
byFlavour: list(byFlavour),
topErrorEvents: Object.keys(byEvent).sort((a, b) => byEvent[b] - byEvent[a]) topErrorEvents: Object.keys(byEvent).sort((a, b) => byEvent[b] - byEvent[a])
.slice(0, 8).map((k) => ({ key: k, errors: byEvent[k] })), .slice(0, 8).map((k) => ({ key: k, errors: byEvent[k] })),
}) })

View file

@ -4,13 +4,19 @@
// //
// GET /api/update/manifest?platform=windows|linux|android (auth: users) // GET /api/update/manifest?platform=windows|linux|android (auth: users)
// //
// Returns the latest published build for the caller's platform. The VARIANT is // Returns the latest published build for the caller's platform. The download is
// chosen server-side from the account's admin-set `nsfwEnabled` (adult) vs clean // PocketBase's native protected file endpoint: the app mints a short-lived file
// — the client never asks for adult. This is belt-and-suspenders on top of the // token (`POST /api/files/token`) and appends it to `downloadPath`, and the token
// `releases` collection rules, which already hide adult rows from non-flagged // grant re-checks the collection rule.
// 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 // `variant` used to be chosen here from the account's admin-set `nsfwEnabled`,
// `downloadPath`, and the token grant re-checks the same gating rule. // 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 // NOTE: PocketBase runs each routerAdd handler in its own isolated JSVM, so the
// small helpers live inside the handler. // 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() const platform = (e.request.url.query().get("platform") || "").toLowerCase().trim()
if (ALLOWED.indexOf(platform) === -1) throw new BadRequestError("bad or missing platform") 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. // One build ships. See the note above for why this field survives at all.
const variant = e.auth.getBool("nsfwEnabled") ? "adult" : "clean" const variant = "clean"
let rec = null let rec = null
try { try {

View file

@ -2,7 +2,7 @@
// Token introspection for amber-api (the facts service). // 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 // 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 // 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. // which `$apis.requireAuth("users")` does before this handler ever runs.
// //
// So the handler body is deliberately trivial: reaching it *is* the answer. It // 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 // returns the one fact amber-api needs and nothing else — no email, no username,
// username, no profile list. A token-introspection endpoint is a tempting place // no profile list. A token-introspection endpoint is a tempting place to leak
// to leak account data, and amber-api has no business knowing any of it. // 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 // It used to return `nsfwEnabled` too, on the reasoning that a future amber-api
// adult release channels, and a future amber-api surface may need to scope // surface might scope results by it. No such surface was ever built, adult
// results by it. It is read-only here. // 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 // 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 // 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 // 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. // ReferenceError at request time. Nothing is hoisted here; keep it that way.
routerAdd("GET", "/api/amber/whoami", (e) => { routerAdd("GET", "/api/amber/whoami", (e) => {
return e.json(200, { return e.json(200, { id: e.auth.id })
id: e.auth.id,
nsfwEnabled: e.auth.getBool("nsfwEnabled"),
})
}, $apis.requireAuth("users")) }, $apis.requireAuth("users"))

View file

@ -290,12 +290,12 @@ function panelReleases(rs){
esc(rs && rs.error || "—") + "</p></section>"; esc(rs && rs.error || "—") + "</p></section>";
if (!rs.length) return '<section><h2>Vydání</h2><p class="note">nic zveřejněno</p></section>'; if (!rs.length) return '<section><h2>Vydání</h2><p class="note">nic zveřejněno</p></section>';
var body = rs.map(function(r){ var body = rs.map(function(r){
return "<tr><td>" + esc(r.platform) + "</td><td>" + esc(r.variant) + return "<tr><td>" + esc(r.platform) +
"</td><td class='mono'>" + esc(r.version) + "+" + r.build + "</td><td class='mono'>" + esc(r.version) + "+" + r.build +
"</td><td class='mono'>" + esc(r.sha8) + "</td><td class='num'>" + bytes(r.size) + "</td></tr>"; "</td><td class='mono'>" + esc(r.sha8) + "</td><td class='num'>" + bytes(r.size) + "</td></tr>";
}).join(""); }).join("");
return '<section class="wide"><h2>Vydání — co se rozdává</h2><table>' + return '<section class="wide"><h2>Vydání — co se rozdává</h2><table>' +
"<tr><th>platforma</th><th>varianta</th><th>verze</th><th>sha256</th><th class='num'>velikost</th></tr>" + "<tr><th>platforma</th><th>verze</th><th>sha256</th><th class='num'>velikost</th></tr>" +
body + "</table>" + body + "</table>" +
'<p class="note">Buildy se nemusí shodovat mezi platformami — když se vydává jen ' + '<p class="note">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." + "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 ""; if (!a || a.error) return "";
return '<section><h2>Účty</h2><div class="kv">' + return '<section><h2>Účty</h2><div class="kv">' +
'<div><div class="lbl">celkem</div><div class="big">' + a.total + "</div></div>" + '<div><div class="lbl">celkem</div><div class="big">' + a.total + "</div></div>" +
'<div><div class="lbl">clean</div><div class="big">' + a.clean + "</div></div>" +
'<div><div class="lbl">18+</div><div class="big">' + a.adult + "</div></div>" +
'</div><p class="note">Nový účet se zakládá ve <b>Správě</b>.</p></section>'; '</div><p class="note">Nový účet se zakládá ve <b>Správě</b>.</p></section>';
} }
@ -408,7 +406,6 @@ function panelErrors(d){
(d.truncated ? " (zkráceno)" : "") + "</span></div>" + (d.truncated ? " (zkráceno)" : "") + "</span></div>" +
tbl("Podle vydání — podíl chyb, ne počet", d.byVersion, true) + tbl("Podle vydání — podíl chyb, ne počet", d.byVersion, true) +
tbl("Podle systému", d.byPlatform, true) + tbl("Podle systému", d.byPlatform, true) +
tbl("Podle varianty", d.byFlavour, true) +
tbl("Nejčastější chyby", (d.topErrorEvents || []).map(function(x){ tbl("Nejčastější chyby", (d.topErrorEvents || []).map(function(x){
return { key: x.key, total: x.errors, errors: x.errors }; }), false) + return { key: x.key, total: x.errors, errors: x.errors }; }), false) +
'<p class="note">Historie je omezená retencí: client_logs se každou noc čistí ' + '<p class="note">Historie je omezená retencí: client_logs se každou noc čistí ' +
@ -658,13 +655,8 @@ async function setNoticeActive(id, to){
function viewAdmin(users, tpls){ function viewAdmin(users, tpls){
var rows = (users || []).map(function(u){ var rows = (users || []).map(function(u){
return "<tr><td>" + esc(u.email || u.username || u.id) + "</td>" + return "<tr><td>" + esc(u.email || u.username || u.id) + "</td>" +
"<td>" + (u.nsfwEnabled ? '<span class="pill wn">18+</span>'
: '<span class="pill ok">clean</span>') + "</td>" +
"<td class='mono'>" + esc(u.ratingDefault || "—") + "</td>" + "<td class='mono'>" + esc(u.ratingDefault || "—") + "</td>" +
"<td class='mono'>" + esc(String(u.created).substring(0, 10)) + "</td>" + "<td class='mono'>" + esc(String(u.created).substring(0, 10)) + "</td></tr>";
'<td><button data-nsfw="' + esc(u.id) + '" data-to="' +
(u.nsfwEnabled ? "0" : "1") + '">' +
(u.nsfwEnabled ? "zakázat 18+" : "povolit 18+") + "</button></td></tr>";
}).join(""); }).join("");
return '<section class="wide"><h2>Nový účet</h2>' + return '<section class="wide"><h2>Nový účet</h2>' +
@ -674,9 +666,6 @@ function viewAdmin(users, tpls){
'<div><div class="lbl">jméno (nepovinné)</div><input id="nName" autocomplete="off"></div>' + '<div><div class="lbl">jméno (nepovinné)</div><input id="nName" autocomplete="off"></div>' +
'<div><div class="lbl">heslo</div><input id="nPass" type="text" autocomplete="off" ' + '<div><div class="lbl">heslo</div><input id="nPass" type="text" autocomplete="off" ' +
'placeholder="nejméně 8 znaků"></div>' + 'placeholder="nejméně 8 znaků"></div>' +
'<label class="row span" style="color:var(--muted);font-size:13px">' +
'<input type="checkbox" id="nNsfw" style="width:auto;accent-color:var(--accent)"> ' +
"povolit 18+ (jinak dostane clean build)</label>" +
'<div class="span"><div class="lbl">sdílené nastavení</div>' + '<div class="span"><div class="lbl">sdílené nastavení</div>' +
'<select id="nGroup" style="width:100%">' + '<select id="nGroup" style="width:100%">' +
'<option value="">žádné — zdroje si zadá sám</option>' + '<option value="">žádné — zdroje si zadá sám</option>' +
@ -699,7 +688,7 @@ function viewAdmin(users, tpls){
"tohle jde přes superuživatele, kterým jsi právě přihlášený.</p></section>" + "tohle jde přes superuživatele, kterým jsi právě přihlášený.</p></section>" +
'<section class="wide"><h2>Účty</h2><table class="accts">' + '<section class="wide"><h2>Účty</h2><table class="accts">' +
"<tr><th>e-mail</th><th>varianta</th><th>strop</th><th>vznik</th><th></th></tr>" + "<tr><th>e-mail</th><th>strop</th><th>vznik</th></tr>" +
(rows || "<tr><td colspan=5 class='note'>nic</td></tr>") + "</table>" + (rows || "<tr><td colspan=5 class='note'>nic</td></tr>") + "</table>" +
'<p class="note">Varianta rozhoduje, jaký build dostane přes automatickou ' + '<p class="note">Varianta rozhoduje, jaký build dostane přes automatickou ' +
"aktualizaci — server ji vybírá podle tohohle příznaku, klient si ji nemůže " + "aktualizaci — server ji vybírá podle tohohle příznaku, klient si ji nemůže " +
@ -952,7 +941,6 @@ async function createAccount(){
try { try {
var body = { var body = {
email: email, password: pass, passwordConfirm: pass, email: email, password: pass, passwordConfirm: pass,
nsfwEnabled: el("nNsfw").checked,
// Verified up front: the family never goes through an e-mail confirmation, // Verified up front: the family never goes through an e-mail confirmation,
// and an unverified account would just be a trap nobody could clear. // and an unverified account would just be a trap nobody could clear.
verified: true, verified: true,
@ -993,38 +981,20 @@ async function createAccount(){
} }
} }
out.innerHTML = '<div class="okbox"><b>Hotovo.</b> Účet <code>' + esc(email) + out.innerHTML = '<div class="okbox"><b>Hotovo.</b> Účet <code>' + esc(email) +
"</code> je založený" + (el("nNsfw").checked ? " s 18+" : " jako clean") + "</code> je založený." + extra + " Předej heslo, tady už ho znovu neuvidíš.</div>";
"." + extra + " Předej heslo, tady už ho znovu neuvidíš.</div>";
el("nEmail").value = ""; el("nPass").value = ""; el("nName").value = ""; el("nEmail").value = ""; el("nPass").value = ""; el("nName").value = "";
el("nNsfw").checked = false; el("nRating").value = ""; el("nRating").value = "";
load(); load();
} catch (e) { } catch (e) {
out.innerHTML = '<div class="warnbox">nepodařilo se: ' + esc(e.message) + "</div>"; out.innerHTML = '<div class="warnbox">nepodařilo se: ' + esc(e.message) + "</div>";
} }
} }
async function setNsfw(id, to){
try {
var r = await fetch("/api/collections/users/records/" + encodeURIComponent(id), {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: tok },
body: JSON.stringify({ nsfwEnabled: to === "1" }),
});
if (!r.ok) throw new Error("HTTP " + r.status);
load();
} catch (e) {
el("main").insertAdjacentHTML("afterbegin",
'<section class="wide"><div class="warnbox">' + esc(e.message) + "</div></section>");
}
}
function wire(){ function wire(){
Array.prototype.forEach.call(el("main").querySelectorAll("button[data-log]"), function(b){ Array.prototype.forEach.call(el("main").querySelectorAll("button[data-log]"), function(b){
b.onclick = function(){ showLog(b.getAttribute("data-log"), b); }; b.onclick = function(){ showLog(b.getAttribute("data-log"), b); };
}); });
Array.prototype.forEach.call(el("main").querySelectorAll("button[data-nsfw]"), function(b){
b.onclick = function(){ setNsfw(b.getAttribute("data-nsfw"), b.getAttribute("data-to")); };
});
if (el("createBtn")) el("createBtn").onclick = createAccount; if (el("createBtn")) el("createBtn").onclick = createAccount;
if (el("nSave")) el("nSave").onclick = saveNotice; if (el("nSave")) el("nSave").onclick = saveNotice;
Array.prototype.forEach.call(el("main").querySelectorAll("button[data-notice]"), function(b){ Array.prototype.forEach.call(el("main").querySelectorAll("button[data-notice]"), function(b){

View file

@ -1,107 +0,0 @@
#!/usr/bin/env python3
"""Verify a built artifact's flavour without installing or launching it.
check-flavor.py <artifact> <expected: adult|clean>
Exits non-zero unless the artifact contains exactly one `AMBER_FLAVOR::` marker
and it matches. Handles an `.apk`, a Linux bundle `.zip`, a Windows bundle `.zip`
and an unpacked build **directory** it finds the Dart AOT snapshot inside each.
A Windows `.exe` installer cannot be checked here and is refused rather than
passed: Inno LZMA-compresses the payload, so the marker only exists in a form
nothing can grep. That is why the release script gates the staged folder before
packaging instead of gating the artifact afterwards.
**Why this exists.** The clean flavour is what anonymous downloads and every
child profile receive, so publishing an adult build into a clean slot is the
worst mistake this release process can make and until now the only way to tell
the two apart offline was to install the APK on a television and look at the tab
row. That made a content-safety gate depend on a TV being awake and on adb still
being authorised, which is exactly how it gets skipped "just this once".
Nothing cheaper worked. Class names such as `AdultBrowse` survive in **both**
snapshots whatever the dart-define, and the `'adult'`/`'clean'` strings the
updater compares sit inside a function body, so both literals ship in both
builds. The two APKs are also routinely **byte-identical in size** (zip
alignment absorbs the difference), so size proves nothing either. `config.dart`
therefore compiles in a deliberate const-folded marker; see [kFlavorMarker].
"""
import os
import re
import sys
import zipfile
MARKER = re.compile(rb'AMBER_FLAVOR::(adult|clean)')
# Where the Dart AOT snapshot lives, per artifact kind.
SNAPSHOTS = (
'lib/arm64-v8a/libapp.so', # apk
'lib/armeabi-v7a/libapp.so', # apk, 32-bit
'lib/libapp.so', # linux bundle zip
'data/app.so', # windows bundle zip
)
def markers_in(blob: bytes) -> set:
return {m.group(1).decode() for m in MARKER.finditer(blob)}
def main(path: str, expected: str) -> int:
if expected not in ('adult', 'clean'):
print(f'error: expected must be adult|clean, got {expected!r}')
return 2
if path.lower().endswith('.exe'):
# Say why rather than throwing a BadZipFile, and fail rather than pass:
# a checker that prints something reassuring about an artifact it never
# read is worse than no checker.
print(f'FAIL {path}: an Inno installer LZMA-compresses its payload, so '
'the marker is not readable here.\n'
' The gate for a .exe is Assert-Flavor in '
'amber-app/scripts/release_windows.ps1, which reads the staged\n'
' folder BEFORE packaging. Point this script at that folder, '
'or at the .zip built beside the installer.')
return 1
found = set()
checked = []
if os.path.isdir(path):
# The staged build folder, which is where the truth actually lives.
for candidate in SNAPSHOTS:
f = os.path.join(path, candidate.replace('/', os.sep))
if os.path.isfile(f):
checked.append(candidate)
with open(f, 'rb') as fh:
found |= markers_in(fh.read())
else:
with zipfile.ZipFile(path) as z:
names = set(z.namelist())
for candidate in SNAPSHOTS:
if candidate in names:
checked.append(candidate)
found |= markers_in(z.read(candidate))
if not checked:
# Better to fail loudly than to pass an artifact nothing was read from.
print(f'FAIL {path}: no Dart snapshot found (looked for {SNAPSHOTS})')
return 1
if not found:
print(f'FAIL {path}: no AMBER_FLAVOR marker in {checked}'
'built before the marker existed?')
return 1
if len(found) > 1:
print(f'FAIL {path}: ambiguous, contains {sorted(found)}')
return 1
actual = found.pop()
if actual != expected:
print(f'FAIL {path}: is {actual!r}, expected {expected!r}')
return 1
print(f'ok {path}: {actual} (from {", ".join(checked)})')
return 0
if __name__ == '__main__':
if len(sys.argv) != 3:
raise SystemExit(__doc__)
raise SystemExit(main(sys.argv[1], sys.argv[2]))