Compare commits

..

No commits in common. "271c4239184c9540baeffde75ca10781197ef2fc" and "1b38ce9d7391ac43fca3069fb4bc05543f5732d6" have entirely different histories.

7 changed files with 204 additions and 91 deletions

View file

@ -1,8 +1,7 @@
# Auto-update — contract (issue #16)
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.
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.
Server pieces (this repo):
- `pb_migrations/1786500000_releases.js` — the `releases` collection.
@ -17,7 +16,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`. 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. |
| `variant` | select | `clean` \| `adult`. |
| `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). |
@ -31,20 +30,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. 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`
**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`
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, always `variant: "clean"`.
Returns the latest build for the caller's platform. The **variant is chosen
server-side** from `nsfwEnabled` — the client cannot request adult.
- No release for that platform → `{ "available": false }`.
- No release for that platform/variant`{ "available": false }`.
- Otherwise:
```json
{
@ -82,15 +81,16 @@ 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 unpacking by hand — do not publish 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.
## Gating summary
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.
`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.

View file

@ -75,20 +75,16 @@ routerAdd("GET", "/api/status", (e) => {
// ---- what the family is being served (local DB, always available) ------
const releases = safe("releases", () => {
// 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)
// 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)
const newest = {}
for (const r of rows) {
const key = r.getString("platform")
const key = r.getString("platform") + ":" + r.getString("variant")
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"),
@ -133,7 +129,9 @@ routerAdd("GET", "/api/status", (e) => {
const accounts = safe("accounts", () => {
const rows = $app.findRecordsByFilter("users", "id != ''", "", 500, 0)
return { total: rows.length }
let adult = 0
for (const r of rows) if (r.getBool("nsfwEnabled")) adult++
return { total: rows.length, adult: adult, clean: rows.length - adult }
})
// ---- containers (Coolify) --------------------------------------------
@ -245,7 +243,16 @@ routerAdd("GET", "/api/status/errors", (e) => {
const ev = r.getString("event") || ""
return kind === "error" || ev === "uncaught" || ev === "player_error" || ev === "exo_error"
}
const days_ = {}, byVersion = {}, byPlatform = {}, byEvent = {}
// 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 = {}
// 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++) {
@ -265,6 +272,7 @@ 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
@ -285,6 +293,7 @@ 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] })),
})

View file

@ -4,19 +4,13 @@
//
// GET /api/update/manifest?platform=windows|linux|android (auth: users)
//
// 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.
// 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.
//
// NOTE: PocketBase runs each routerAdd handler in its own isolated JSVM, so the
// small helpers live inside the handler.
@ -25,8 +19,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")
// One build ships. See the note above for why this field survives at all.
const variant = "clean"
// The account flag decides the channel; a client can't opt into adult.
const variant = e.auth.getBool("nsfwEnabled") ? "adult" : "clean"
let rec = null
try {

View file

@ -2,7 +2,7 @@
// Token introspection for amber-api (the facts service).
//
// GET /api/amber/whoami (auth: users) → { id }
// GET /api/amber/whoami (auth: users) → { id, nsfwEnabled }
//
// 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,14 +13,13 @@
// 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 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.
// 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.
//
// 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.
// `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.
//
// 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
@ -30,5 +29,8 @@
// 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 })
return e.json(200, {
id: e.auth.id,
nsfwEnabled: e.auth.getBool("nsfwEnabled"),
})
}, $apis.requireAuth("users"))

View file

@ -1,29 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
// The `releases` read rule stops mentioning nsfwEnabled.
//
// It used to read:
// @request.auth.id != '' && (variant = 'clean' || @request.auth.nsfwEnabled = true)
// — any signed-in account could read clean rows, and only an nsfwEnabled account
// could read the 18+ ones. That clause was the thing keeping 18+ artifacts out of
// reach of the family.
//
// Amber has no 18+ build any more and the nine rows that carried one (three
// versions across three platforms, 437 MB) were deleted before this migration.
// With nothing left for the clause to hide, it is a condition on a flag nothing
// writes, which reads like a protection that is still doing something.
//
// `variant` itself stays on the collection and in the manifest: every installed
// Amber compares that field against its own and refuses a mismatch silently.
migrate((app) => {
const c = app.findCollectionByNameOrId("releases")
c.listRule = "@request.auth.id != ''"
c.viewRule = "@request.auth.id != ''"
app.save(c)
}, (app) => {
const c = app.findCollectionByNameOrId("releases")
const rule = "@request.auth.id != '' && (variant = 'clean' || @request.auth.nsfwEnabled = true)"
c.listRule = rule
c.viewRule = rule
app.save(c)
})

View file

@ -290,12 +290,12 @@ function panelReleases(rs){
esc(rs && rs.error || "—") + "</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){
return "<tr><td>" + esc(r.platform) +
return "<tr><td>" + esc(r.platform) + "</td><td>" + esc(r.variant) +
"</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>";
}).join("");
return '<section class="wide"><h2>Vydání — co se rozdává</h2><table>' +
"<tr><th>platforma</th><th>verze</th><th>sha256</th><th class='num'>velikost</th></tr>" +
"<tr><th>platforma</th><th>varianta</th><th>verze</th><th>sha256</th><th class='num'>velikost</th></tr>" +
body + "</table>" +
'<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." +
@ -345,6 +345,8 @@ function panelAccounts(a){
if (!a || a.error) return "";
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">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>';
}
@ -406,6 +408,7 @@ function panelErrors(d){
(d.truncated ? " (zkráceno)" : "") + "</span></div>" +
tbl("Podle vydání — podíl chyb, ne počet", d.byVersion, true) +
tbl("Podle systému", d.byPlatform, true) +
tbl("Podle varianty", d.byFlavour, true) +
tbl("Nejčastější chyby", (d.topErrorEvents || []).map(function(x){
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í ' +
@ -655,8 +658,13 @@ async function setNoticeActive(id, to){
function viewAdmin(users, tpls){
var rows = (users || []).map(function(u){
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(String(u.created).substring(0, 10)) + "</td></tr>";
"<td class='mono'>" + esc(String(u.created).substring(0, 10)) + "</td>" +
'<td><button data-nsfw="' + esc(u.id) + '" data-to="' +
(u.nsfwEnabled ? "0" : "1") + '">' +
(u.nsfwEnabled ? "zakázat 18+" : "povolit 18+") + "</button></td></tr>";
}).join("");
return '<section class="wide"><h2>Nový účet</h2>' +
@ -666,6 +674,9 @@ function viewAdmin(users, tpls){
'<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" ' +
'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>' +
'<select id="nGroup" style="width:100%">' +
'<option value="">žádné — zdroje si zadá sám</option>' +
@ -688,7 +699,7 @@ function viewAdmin(users, tpls){
"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">' +
"<tr><th>e-mail</th><th>strop</th><th>vznik</th></tr>" +
"<tr><th>e-mail</th><th>varianta</th><th>strop</th><th>vznik</th><th></th></tr>" +
(rows || "<tr><td colspan=5 class='note'>nic</td></tr>") + "</table>" +
'<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 " +
@ -941,6 +952,7 @@ async function createAccount(){
try {
var body = {
email: email, password: pass, passwordConfirm: pass,
nsfwEnabled: el("nNsfw").checked,
// Verified up front: the family never goes through an e-mail confirmation,
// and an unverified account would just be a trap nobody could clear.
verified: true,
@ -981,20 +993,38 @@ async function createAccount(){
}
}
out.innerHTML = '<div class="okbox"><b>Hotovo.</b> Účet <code>' + esc(email) +
"</code> je založený." + extra + " Předej heslo, tady už ho znovu neuvidíš.</div>";
"</code> je založený" + (el("nNsfw").checked ? " s 18+" : " jako clean") +
"." + extra + " Předej heslo, tady už ho znovu neuvidíš.</div>";
el("nEmail").value = ""; el("nPass").value = ""; el("nName").value = "";
el("nRating").value = "";
el("nNsfw").checked = false; el("nRating").value = "";
load();
} catch (e) {
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(){
Array.prototype.forEach.call(el("main").querySelectorAll("button[data-log]"), function(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("nSave")) el("nSave").onclick = saveNotice;
Array.prototype.forEach.call(el("main").querySelectorAll("button[data-notice]"), function(b){

107
scripts/check-flavor.py Executable file
View file

@ -0,0 +1,107 @@
#!/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]))