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