Compare commits
2 commits
1b38ce9d73
...
271c423918
| Author | SHA1 | Date | |
|---|---|---|---|
| 271c423918 | |||
| 55444ee55e |
7 changed files with 91 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"))
|
||||||
|
|
|
||||||
29
pb_migrations/1793000000_releases_drop_adult_rule.js
Normal file
29
pb_migrations/1793000000_releases_drop_adult_rule.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
/// <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)
|
||||||
|
})
|
||||||
|
|
@ -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