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.
59 lines
3.2 KiB
JavaScript
59 lines
3.2 KiB
JavaScript
/// <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 */ }
|
|
})
|