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:
parent
1b38ce9d73
commit
55444ee55e
6 changed files with 62 additions and 204 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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] })),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
|
|
@ -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){
|
||||||
|
|
|
||||||
|
|
@ -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]))
|
|
||||||
Loading…
Reference in a new issue