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 new file mode 100644 index 0000000..e548b7b --- /dev/null +++ b/docs/auto-update-contract.md @@ -0,0 +1,85 @@ +# 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. 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. 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..98237a9 --- /dev/null +++ b/pb_migrations/1786500000_releases.js @@ -0,0 +1,59 @@ +/// + +// 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. + // `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 }, + { 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/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) +}) 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