Auto-update backend (H1): releases collection + gated manifest #6

Merged
richiexec merged 2 commits from feature/auto-update into main 2026-07-20 11:50:31 +00:00
4 changed files with 38 additions and 6 deletions
Showing only changes of commit 6be486b275 - Show all commits

2
.gitignore vendored
View file

@ -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

View file

@ -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

View file

@ -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 },

View 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)
})