Auto-update backend (H1): releases collection + gated manifest #6
6 changed files with 308 additions and 0 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -4,3 +4,5 @@ pb_data/
|
||||||
# Local env / secrets
|
# Local env / secrets
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
# Local-only secrets (e.g. a minted superuser token for admin scripting)
|
||||||
|
*.local.token
|
||||||
|
|
|
||||||
85
docs/auto-update-contract.md
Normal file
85
docs/auto-update-contract.md
Normal file
|
|
@ -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/<recordId>/<filename>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Download.** The app mints a file token (`POST /api/files/token`, its user
|
||||||
|
auth) and GETs `{base}{downloadPath}?token=<fileToken>`, then verifies `sha256`
|
||||||
|
before installing.
|
||||||
|
|
||||||
|
## Publishing a build
|
||||||
|
|
||||||
|
Build the artifact, then (on the build machine):
|
||||||
|
```bash
|
||||||
|
PB_ADMIN_TOKEN=<superuser 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.
|
||||||
53
pb_hooks/update.pb.js
Normal file
53
pb_hooks/update.pb.js
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
|
||||||
|
// 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=<file token>`.
|
||||||
|
downloadPath: "/api/files/releases/" + rec.id + "/" + filename,
|
||||||
|
})
|
||||||
|
}, $apis.requireAuth("users"))
|
||||||
59
pb_migrations/1786500000_releases.js
Normal file
59
pb_migrations/1786500000_releases.js
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
|
||||||
|
// 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 */ }
|
||||||
|
})
|
||||||
25
pb_migrations/1786500001_releases_protect_file.js
Normal file
25
pb_migrations/1786500001_releases_protect_file.js
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
})
|
||||||
84
scripts/publish-release.sh
Executable file
84
scripts/publish-release.sh
Executable file
|
|
@ -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=<superuser 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
|
||||||
Loading…
Reference in a new issue