From 785a621c499ebc2fc35881597b4720575aeec787 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 11:36:03 +0200 Subject: [PATCH 1/2] Auto-update backend: releases collection + gated manifest (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the server side of auto-update (issue #16, was epic #6's [H]). - pb_migrations/1786500000_releases.js: a superuser-only 'releases' collection (platform, variant, version, buildNumber, file, sha256, size, notes). Read is gated in the rules — any signed-in account sees clean builds; only an nsfwEnabled account sees adult ones — so PocketBase's native protected-file serving hands adult bytes only to flagged accounts (no custom streaming). - pb_hooks/update.pb.js: GET /api/update/manifest?platform=… (auth). Picks the variant server-side from the caller's nsfwEnabled (adult) vs clean — the client can't request adult — and returns the latest build's version/buildNumber/notes/ sha256/size + the protected downloadPath. - scripts/publish-release.sh: uploads a built artifact as a superuser (computes sha256 + size, multipart POST). Token or email+password via env. - docs/auto-update-contract.md: the collection, endpoint, download flow, gating. Migration + hook + script syntax-checked. Live verification pends deploying this to the PB (collection auto-applies on boot, hook loads from pb_hooks/). --- docs/auto-update-contract.md | 84 ++++++++++++++++++++++++++++ pb_hooks/update.pb.js | 53 ++++++++++++++++++ pb_migrations/1786500000_releases.js | 55 ++++++++++++++++++ scripts/publish-release.sh | 84 ++++++++++++++++++++++++++++ 4 files changed, 276 insertions(+) create mode 100644 docs/auto-update-contract.md create mode 100644 pb_hooks/update.pb.js create mode 100644 pb_migrations/1786500000_releases.js create mode 100755 scripts/publish-release.sh diff --git a/docs/auto-update-contract.md b/docs/auto-update-contract.md new file mode 100644 index 0000000..271b476 --- /dev/null +++ b/docs/auto-update-contract.md @@ -0,0 +1,84 @@ +# Auto-update — contract (issue #16) + +The app checks for a newer build and installs it, receiving the **adult** or +**clean** variant according to its account's admin-set `nsfwEnabled` flag. + +Server pieces (this repo): +- `pb_migrations/1786500000_releases.js` — the `releases` collection. +- `pb_hooks/update.pb.js` — the `GET /api/update/manifest` route. +- `scripts/publish-release.sh` — uploads a built artifact as a superuser. + +Client pieces (amber-app): `lib/data/update/update_service.dart` (version check + +manifest fetch), the desktop swap-installer, and the Android APK install channel. + +## `releases` collection + +| Field | Type | Notes | +|---------------|--------|-------| +| `platform` | select | `windows` \| `linux` \| `android`. | +| `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: desktop = `.zip` of the release bundle, android = `.apk`. Protected (see rules). | +| `sha256` | text | Lowercase hex SHA-256 of the artifact; verified before install. | +| `size` | number | Bytes. | +| `notes` | text | Optional release notes (shown in the update prompt). | +| `created`/`updated` | autodate | | + +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. Because the view rule needs auth, the attached `file` is served +as a **protected file**: downloading needs a short-lived file token +(`POST /api/files/token`), and minting/using it re-checks the same rule — so +adult bytes never reach a non-flagged account. `create`/`update`/`delete` are +**superuser-only** (null rules); publishing goes through the admin API. + +## Endpoint + +### `GET /api/update/manifest?platform=windows|linux|android` (auth: users) +Returns the latest build for the caller's platform. The **variant is chosen +server-side** from `nsfwEnabled` — the client cannot request adult. + +- No release for that platform/variant → `{ "available": false }`. +- Otherwise: + ```json + { + "available": true, + "platform": "linux", + "variant": "clean", + "version": "1.0.1", + "buildNumber": 2, + "notes": "…", + "sha256": "<64 hex>", + "size": 12345678, + "filename": "amber-linux-clean.zip", + "downloadPath": "/api/files/releases//" + } + ``` + +**Download.** The app mints a file token (`POST /api/files/token`, its user +auth) and GETs `{base}{downloadPath}?token=`, then verifies `sha256` +before installing. + +## Publishing a build + +Build the artifact, then (on the build machine): +```bash +PB_ADMIN_TOKEN= ./scripts/publish-release.sh \ + --platform linux --variant clean --version 1.0.1 --build 2 \ + --file build/amber-linux-clean.zip --notes "What changed" +``` +`PB_ADMIN_EMAIL` + `PB_ADMIN_PASSWORD` work instead of a token. The script +computes the SHA-256 + size and uploads via the superuser REST API. Re-publishing +the same `(platform, variant, buildNumber)` is rejected by the unique index — +bump `buildNumber` for each release. + +## Gating summary + +`nsfwEnabled` (admin-set on the `users` record) is the single source of truth: +the manifest hook reads it to pick the variant, and the collection rules enforce +it independently at read/download time. A no-flag account only ever sees and +downloads clean builds. diff --git a/pb_hooks/update.pb.js b/pb_hooks/update.pb.js new file mode 100644 index 0000000..7359751 --- /dev/null +++ b/pb_hooks/update.pb.js @@ -0,0 +1,53 @@ +/// + +// Auto-update manifest (epic #6, issue #16). +// +// GET /api/update/manifest?platform=windows|linux|android (auth: users) +// +// Returns the latest published build for the caller's platform. The VARIANT is +// chosen server-side from the account's admin-set `nsfwEnabled` (adult) vs clean +// — the client never asks for adult. This is belt-and-suspenders on top of the +// `releases` collection rules, which already hide adult rows from non-flagged +// accounts. The download is PocketBase's native protected file endpoint: the app +// mints a short-lived file token (`POST /api/files/token`) and appends it to +// `downloadPath`, and the token grant re-checks the same gating rule. +// +// NOTE: PocketBase runs each routerAdd handler in its own isolated JSVM, so the +// small helpers live inside the handler. +routerAdd("GET", "/api/update/manifest", (e) => { + const ALLOWED = ["windows", "linux", "android"] + const platform = (e.request.url.query().get("platform") || "").toLowerCase().trim() + if (ALLOWED.indexOf(platform) === -1) throw new BadRequestError("bad or missing platform") + + // The account flag decides the channel; a client can't opt into adult. + const variant = e.auth.getBool("nsfwEnabled") ? "adult" : "clean" + + let rec = null + try { + const rows = $app.findRecordsByFilter( + "releases", + "platform = {:p} && variant = {:v}", + "-buildNumber", + 1, 0, + { p: platform, v: variant } + ) + if (rows && rows.length) rec = rows[0] + } catch (_) { /* no matching release */ } + + if (!rec) return e.json(200, { available: false }) + + const filename = rec.getString("file") + return e.json(200, { + available: true, + platform: platform, + variant: variant, + version: rec.getString("version"), + buildNumber: rec.getInt("buildNumber"), + notes: rec.getString("notes"), + sha256: rec.getString("sha256"), + size: rec.getInt("size"), + filename: filename, + // Native protected-file path; the app appends `?token=`. + downloadPath: "/api/files/releases/" + rec.id + "/" + filename, + }) +}, $apis.requireAuth("users")) diff --git a/pb_migrations/1786500000_releases.js b/pb_migrations/1786500000_releases.js new file mode 100644 index 0000000..884eccb --- /dev/null +++ b/pb_migrations/1786500000_releases.js @@ -0,0 +1,55 @@ +/// + +// Auto-update release catalog (epic #6, issue #16). +// +// One row per published build, keyed by (platform, variant, buildNumber). The +// app's updater asks `/api/update/manifest` which build is latest for its +// platform; the server picks the VARIANT from the caller's admin-set +// `nsfwEnabled` (adult) vs clean — the client never chooses. +// +// Gating lives in the access rules: any signed-in user can read CLEAN releases; +// only an `nsfwEnabled` account can read ADULT ones. Because the view rule needs +// auth, PocketBase serves the attached file as a *protected* file (a short-lived +// file token is required, and minting one re-checks the same rule) — so adult +// bytes only ever reach flagged accounts, with no custom file streaming. Writes +// are superuser-only (publish goes through the admin API; see scripts/publish). + +migrate((app) => { + const releases = new Collection({ + type: "base", + name: "releases", + // Read: signed-in; adult rows only for nsfwEnabled accounts. The file + // inherits this (protected → download needs a file token that re-checks it). + listRule: "@request.auth.id != '' && (variant = 'clean' || @request.auth.nsfwEnabled = true)", + viewRule: "@request.auth.id != '' && (variant = 'clean' || @request.auth.nsfwEnabled = true)", + // Publish / edit / remove: superuser only (null → not exposed to clients). + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + { type: "select", name: "platform", required: true, maxSelect: 1, values: ["windows", "linux", "android"] }, + { type: "select", name: "variant", required: true, maxSelect: 1, values: ["clean", "adult"] }, + // Human semver shown to the user, e.g. "1.0.1". buildNumber is the + // monotonic comparator the updater actually compares against. + { type: "text", name: "version", required: true, max: 40 }, + { type: "number", name: "buildNumber", required: true, min: 1, onlyInt: true }, + // The artifact: desktop = a .zip of the release bundle, android = the .apk. + { type: "file", name: "file", required: true, maxSelect: 1, maxSize: 524288000 }, + // Lowercase hex SHA-256 of the artifact; the app verifies it before install. + { type: "text", name: "sha256", required: true, min: 64, max: 64 }, + { type: "number", name: "size", required: true, min: 0, onlyInt: true }, + { type: "text", name: "notes", required: false, max: 4000 }, + { type: "autodate", name: "created", onCreate: true }, + { type: "autodate", name: "updated", onCreate: true, onUpdate: true }, + ], + indexes: [ + "CREATE UNIQUE INDEX `idx_releases_pvb` ON `releases` (`platform`, `variant`, `buildNumber`)", + ], + }) + app.save(releases) +}, (app) => { + // ---- Down migration ---------------------------------------------------- + try { + app.delete(app.findCollectionByNameOrId("releases")) + } catch (_) { /* already gone */ } +}) diff --git a/scripts/publish-release.sh b/scripts/publish-release.sh new file mode 100755 index 0000000..a952244 --- /dev/null +++ b/scripts/publish-release.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Publish a built artifact into the `releases` collection (issue #16). +# +# The collection is superuser-only, so this authenticates as a PocketBase +# superuser and uploads the artifact + metadata via the admin REST API. Run it +# on the machine that built the artifact (it computes the SHA-256 + size that the +# app verifies before installing). +# +# Auth (pick one, via env — never pass secrets on the command line): +# PB_ADMIN_TOKEN= # preferred; nothing sensitive stored +# PB_ADMIN_EMAIL + PB_ADMIN_PASSWORD # script exchanges them for a token +# +# Usage: +# PB_ADMIN_TOKEN=... ./scripts/publish-release.sh \ +# --platform linux --variant clean --version 1.0.1 --build 2 \ +# --file build/amber-linux-clean.zip --notes "Bug fixes" +# +# --pb-url defaults to https://pb.petruzalekr.cz (override for a local/stage PB). +set -euo pipefail + +PB_URL="https://pb.petruzalekr.cz" +PLATFORM="" VARIANT="" VERSION="" BUILD="" FILE="" NOTES="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --platform) PLATFORM="$2"; shift 2 ;; + --variant) VARIANT="$2"; shift 2 ;; + --version) VERSION="$2"; shift 2 ;; + --build) BUILD="$2"; shift 2 ;; + --file) FILE="$2"; shift 2 ;; + --notes) NOTES="$2"; shift 2 ;; + --pb-url) PB_URL="$2"; shift 2 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done + +die() { echo "error: $*" >&2; exit 1; } +[[ -n "$PLATFORM" && -n "$VARIANT" && -n "$VERSION" && -n "$BUILD" && -n "$FILE" ]] \ + || die "need --platform --variant --version --build --file" +[[ "$PLATFORM" =~ ^(windows|linux|android)$ ]] || die "platform must be windows|linux|android" +[[ "$VARIANT" =~ ^(clean|adult)$ ]] || die "variant must be clean|adult" +[[ "$BUILD" =~ ^[0-9]+$ ]] || die "build must be an integer" +[[ -f "$FILE" ]] || die "file not found: $FILE" + +# ---- auth: reuse a provided token, else exchange email+password for one ------- +TOKEN="${PB_ADMIN_TOKEN:-}" +if [[ -z "$TOKEN" ]]; then + [[ -n "${PB_ADMIN_EMAIL:-}" && -n "${PB_ADMIN_PASSWORD:-}" ]] \ + || die "set PB_ADMIN_TOKEN, or PB_ADMIN_EMAIL + PB_ADMIN_PASSWORD" + TOKEN=$(curl -sf -X POST "$PB_URL/api/collections/_superusers/auth-with-password" \ + -H 'Content-Type: application/json' \ + -d "{\"identity\":\"$PB_ADMIN_EMAIL\",\"password\":\"$PB_ADMIN_PASSWORD\"}" \ + | grep -oE '"token":"[^"]+"' | head -1 | sed 's/"token":"//;s/"//') \ + || die "superuser auth failed" + [[ -n "$TOKEN" ]] || die "superuser auth returned no token" +fi + +SHA=$(sha256sum "$FILE" | cut -d' ' -f1) +SIZE=$(stat -c%s "$FILE") +echo "publishing: $PLATFORM/$VARIANT v$VERSION (build $BUILD)" +echo " file: $FILE size: $SIZE sha256: $SHA" + +# ---- multipart upload to the releases collection ----------------------------- +HTTP=$(curl -s -o /tmp/publish_resp.json -w '%{http_code}' \ + -X POST "$PB_URL/api/collections/releases/records" \ + -H "Authorization: $TOKEN" \ + -F "platform=$PLATFORM" \ + -F "variant=$VARIANT" \ + -F "version=$VERSION" \ + -F "buildNumber=$BUILD" \ + -F "sha256=$SHA" \ + -F "size=$SIZE" \ + -F "notes=$NOTES" \ + -F "file=@$FILE") + +if [[ "$HTTP" == "200" ]]; then + echo "✓ published (record $(grep -oE '"id":"[^"]+"' /tmp/publish_resp.json | head -1 | sed 's/"id":"//;s/"//'))" + rm -f /tmp/publish_resp.json +else + echo "✗ publish failed (HTTP $HTTP):" >&2 + cat /tmp/publish_resp.json >&2; echo >&2 + rm -f /tmp/publish_resp.json + exit 1 +fi From 6be486b275fe43ce05f4d4028f975cd6c038c860 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 11:48:07 +0200 Subject: [PATCH 2/2] Fix auto-update file gating: mark releases.file protected (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The E2E caught a real leak: a non-nsfw (even anonymous) account could download an adult artifact. In PocketBase, file protection is a per-FIELD flag, not derived from the collection view rule — the original migration left releases.file unprotected, so its URL was public despite the gated read rules. - Add protected:true to the file field (correct for fresh installs). - 1786500001_releases_protect_file.js: alter the field on the already-deployed instance (applied migrations don't re-run, so the fix needs its own migration). - Doc: correct the gating explanation (protection is the field flag; the file token grant then re-checks the view rule). With this, a protected file needs a file token whose grant re-checks the view rule, so a clean account is denied the adult artifact. --- .gitignore | 2 ++ docs/auto-update-contract.md | 11 ++++---- pb_migrations/1786500000_releases.js | 6 ++++- .../1786500001_releases_protect_file.js | 25 +++++++++++++++++++ 4 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 pb_migrations/1786500001_releases_protect_file.js diff --git a/.gitignore b/.gitignore index 96bbd94..55824e6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ pb_data/ # Local env / secrets .env .env.local +# Local-only secrets (e.g. a minted superuser token for admin scripting) +*.local.token diff --git a/docs/auto-update-contract.md b/docs/auto-update-contract.md index 271b476..e548b7b 100644 --- a/docs/auto-update-contract.md +++ b/docs/auto-update-contract.md @@ -30,11 +30,12 @@ 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. Because the view rule needs auth, the attached `file` is served -as a **protected file**: downloading needs a short-lived file token -(`POST /api/files/token`), and minting/using it re-checks the same rule — so -adult bytes never reach a non-flagged account. `create`/`update`/`delete` are -**superuser-only** (null rules); publishing goes through the admin API. +**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 diff --git a/pb_migrations/1786500000_releases.js b/pb_migrations/1786500000_releases.js index 884eccb..98237a9 100644 --- a/pb_migrations/1786500000_releases.js +++ b/pb_migrations/1786500000_releases.js @@ -34,7 +34,11 @@ migrate((app) => { { type: "text", name: "version", required: true, max: 40 }, { type: "number", name: "buildNumber", required: true, min: 1, onlyInt: true }, // The artifact: desktop = a .zip of the release bundle, android = the .apk. - { type: "file", name: "file", required: true, maxSelect: 1, maxSize: 524288000 }, + // `protected` is what actually gates the bytes: a protected file is served + // only with a short-lived file token whose grant re-checks the view rule + // below (so a non-nsfw account can't fetch an adult artifact). WITHOUT this + // flag the file URL is public regardless of the view rule. + { type: "file", name: "file", required: true, maxSelect: 1, maxSize: 524288000, protected: true }, // Lowercase hex SHA-256 of the artifact; the app verifies it before install. { type: "text", name: "sha256", required: true, min: 64, max: 64 }, { type: "number", name: "size", required: true, min: 0, onlyInt: true }, diff --git a/pb_migrations/1786500001_releases_protect_file.js b/pb_migrations/1786500001_releases_protect_file.js new file mode 100644 index 0000000..bf92a70 --- /dev/null +++ b/pb_migrations/1786500001_releases_protect_file.js @@ -0,0 +1,25 @@ +/// + +// Fix gating on the releases `file` field (issue #16). +// +// The original migration created the file field without `protected: true`, which +// left the artifact URL PUBLIC — a non-nsfw account (and even an anonymous +// request) could download the adult build, defeating the whole point of the +// gating rules. Protection in PocketBase is a per-field flag, not derived from +// the collection view rule: with it on, the file is served only via a +// short-lived file token whose grant re-checks the view rule. +// +// This alters the field on already-deployed instances; a fresh install gets it +// correct straight from 1786500000. + +migrate((app) => { + const c = app.findCollectionByNameOrId("releases") + const f = c.fields.getByName("file") + f.protected = true + app.save(c) +}, (app) => { + const c = app.findCollectionByNameOrId("releases") + const f = c.fields.getByName("file") + f.protected = false + app.save(c) +})