diff --git a/README.md b/README.md index 8c6ad02..00fde28 100644 --- a/README.md +++ b/README.md @@ -27,12 +27,11 @@ users ─┬─* profiles ─┬─* watch_state (resume points; 1 row per pro │ ├─1 prefs (playback/audio/subtitle blob) │ └─1 addon_config (client-encrypted addon blob; ciphertext only) ├─* device_auth (codeless sign-in requests / approved devices, issue #12) - └ nsfwEnabled (admin-only bool, legacy: see below) ``` | Collection | Type | Key fields | |----------------|------|------------| -| `users` | auth | `username` (unique when set), `email`, `password`, **`nsfwEnabled`** (admin-only, legacy) | +| `users` | auth | `username` (unique when set), `email`, `password`, `ratingDefault` (account-wide content ceiling) | | `profiles` | base | `user`→users, `name`, `avatar` (file), `isChild`, `pinHash` (hidden) | | `watch_state` | base | `profile`→profiles, `itemId`, `itemType`, `position`, `duration`, `meta` (json), `updatedAt` (client), `updated` (server) | | `watchlist` | base | `profile`→profiles, `itemId`, `itemType`, `meta` (json), `addedAt` (client), `updated` (server), `deletedAt` (tombstone) | @@ -41,34 +40,22 @@ users ─┬─* profiles ─┬─* watch_state (resume points; 1 row per pro | `device_auth` | base | `code` (unique), `deviceName`, `status`, `user`→users, `expiresAt`, + hidden `authToken`/`keyCiphertext`/`pollSecret`/`devicePubKey` — codeless device sign-in (#12) | **Access rules.** A user only ever reads/writes their own `users` row, their own -`profiles`, and rows whose `profile.user` is them. `nsfwEnabled` is never -accepted from a client (`@request.body.nsfwEnabled:isset = false` on create and -update) — only a **superuser** sets it, from the admin UI. Relations +`profiles`, and rows whose `profile.user` is them. Accounts are created by a +superuser only (registration is closed, migration 1789000000). Relations `cascadeDelete`, so deleting a user removes their profiles and all child rows. Unique indexes keep one resume/watchlist row per `(profile, itemId)` and one `prefs`/`addon_config` row per profile. -### `nsfwEnabled` is legacy +### There is no 18+ anything here -There is **one Amber build**. The flag used to choose between an 18+ build and the -clean one; the 18+ section left Amber to become a separate app, and none of that -gating is left in this repo: +One Amber build ships. An account flag `nsfwEnabled` used to choose between an +18+ build and the clean one; the 18+ section left Amber to become a separate app, +and the flag was dropped in `pb_migrations/1797000000_drop_nsfw_enabled.js` along +with `adult` as a publishable variant. `scripts/verify.py` asserts it stays gone. -- `GET /api/update/manifest` (`pb_hooks/update.pb.js`) always serves variant - `clean`, whatever the flag says. The `variant` field stays in the response - because every installed Amber silently refuses a mismatch. -- The `releases` read rules no longer mention it - (`pb_migrations/1793000000_releases_drop_adult_rule.js`): any signed-in account - reads releases. -- `/api/amber/whoami` no longer returns it, and the status dashboard is gated on - superuser credentials, never on this flag. -- The Amber app neither reads nor sends it. - -What remains: the field is still on `users`, still admin-only by rule, and has -exactly one reader, the family web page (`pb_public/index.html`). For an account -with the flag set, that page shows an adult addon URL field on the sources tab -(saved inside the encrypted addon config) and mentions 18+ in the child-profile -label. Nothing else reads it. +`releases.variant` itself is **not** going anywhere: it is always `clean`, and +every installed Amber compares it against its own and refuses a mismatch +silently, so dropping the field would strand those installs with no error to see. ### Encrypted addon config (issue #20) @@ -140,8 +127,8 @@ docker compose exec pocketbase pocketbase superuser upsert you@example.com 'a-st # admin UI: http://localhost:8090/_/ ``` -The end-to-end access-rule check (two users can't see each other's data; a user -can't set their own `nsfwEnabled`; a superuser can) lives in +The end-to-end access-rule check (two users can't see each other's data; the +retired 18+ flag is gone from the schema and the rules) lives in [`scripts/verify.py`](scripts/verify.py): ```bash @@ -226,7 +213,7 @@ Only needed to rebuild the Coolify app. Secrets never live in this repo. Account/session tokens and the addon plaintext-credential "tokens" are **never logged or echoed** (see the app's `CLAUDE.md`). `pinHash` is a hidden field and -never leaves the API. `nsfwEnabled` is admin-only by rule, not just by UI. +never leaves the API. ## Upgrading PocketBase diff --git a/docs/auto-update-contract.md b/docs/auto-update-contract.md index 5e90d23..f808892 100644 --- a/docs/auto-update-contract.md +++ b/docs/auto-update-contract.md @@ -1,8 +1,8 @@ # Auto-update — contract (issue #16) -The app checks for a newer build and installs it. One variant ships, `clean`; -the server used to pick between that and an `adult` one from the account's -`nsfwEnabled` flag, and no longer does. +The app checks for a newer build and installs it. One variant ships, `clean`. +The server used to pick between that and an 18+ one from an account flag; that +flag and that build are both gone (`pb_migrations/1797000000_drop_nsfw_enabled.js`). Server pieces (this repo): - `pb_migrations/1786500000_releases.js` — the `releases` collection. @@ -17,7 +17,7 @@ manifest fetch), the desktop swap-installer, and the Android APK install channel | Field | Type | Notes | |---------------|--------|-------| | `platform` | select | `windows` \| `linux` \| `android`. | -| `variant` | select | `clean` \| `adult`. Only `clean` is published now. The field stays because every installed app compares it against its own and refuses a mismatch **silently** — removing it would strand those installs with no error to see. | +| `variant` | select | `clean`, the only value. The field stays because every installed app compares it against its own and refuses a mismatch **silently** — removing it would strand those installs with no error to see. | | `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: windows = `.exe` installer, linux = `.zip` of the release bundle, android = `.apk`. Protected (see rules). | @@ -28,12 +28,9 @@ manifest fetch), the desktop swap-installer, and the Android APK install channel 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. That rule is deliberately left in place: `adult` rows from before -the split are still in this collection, and the rule is what keeps them out of -reach. The `file` field is **`protected: true`**, which is what gates the bytes: +**Access rules.** Read needs a signed-in account (`@request.auth.id != ''`) and +nothing more: one variant is published and there is nothing left to gate between. +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** (and without the flag the file URL would be public regardless of the view rule). `create`/`update`/`delete` @@ -89,8 +86,6 @@ is for unpacking by hand — do not publish it. There is nothing left to gate between: one variant is published, and the manifest hook returns it to everyone. -`nsfwEnabled` still exists on the `users` record and the collection rules still -read it. Both stay because `adult` rows from before the split are still in the -collection, and those rules are what keeps them unreachable. Nothing writes the -flag any more: the manifest hook ignores it, `whoami` no longer returns it, and -the status dashboard's toggle is gone. +The 18+ flag is gone from `users` and `adult` is gone from `variant`'s allowed +values (`pb_migrations/1797000000_drop_nsfw_enabled.js`). The manifest hook +serves `clean` to every signed-in account. diff --git a/docs/onboarding-contract.md b/docs/onboarding-contract.md index 65888c8..2488a8d 100644 --- a/docs/onboarding-contract.md +++ b/docs/onboarding-contract.md @@ -40,8 +40,8 @@ Returns the admin-maintained plaintext family config: - `403` bad code · `429` after 10 bad codes / 5 min / IP · `404` no template record · `503` env unset. -- **Deliberately no adult fields** — the template is family config by - definition (mirrors the #14/#20 child stripping). Adult stays per-account. +- **Deliberately no 18+ fields** — the template is family config by + definition (mirrors the #14/#20 child stripping), and Amber has no 18+ content. - Plaintext travels once over TLS to the wizard, which encrypts it under the *new user's* password (PBKDF2-HMAC-SHA256 210k → AES-256-GCM, blob = `base64(nonce[12] ‖ ct ‖ tag[16])`, `kdf = pbkdf2-sha256-210000`) and pushes @@ -53,7 +53,7 @@ Returns the admin-maintained plaintext family config: Streams the newest `releases` row with `platform=android && variant=clean` as `amber-tv.apk` (attachment). Public by design: the TV-sideload path (the Downloader app needs a plain URL, a fresh TV has no account, and the APK is -useless without one). Adult builds remain account-gated, unchanged. +useless without one). One build ships, so there is nothing else to serve. ## Template maintenance (admin) @@ -102,7 +102,7 @@ signup refused without/with wrong code (Czech error) and accepted via header and query · template route 403/200/429(10 per 5 min)/404 paths · full wizard run in a real browser produced the user, the named profile, and an `addon_config` blob that **decrypted with the app's Dart `AddonConfigCrypto`** -with every field intact (adult fields null/false) · `/get/tv` streamed a +with every field intact · `/get/tv` streamed a published dummy APK byte-identical with the attachment header · landing's download login handled the no-releases state. diff --git a/pb_hooks/onboarding.pb.js b/pb_hooks/onboarding.pb.js index 56cd11e..98ed322 100644 --- a/pb_hooks/onboarding.pb.js +++ b/pb_hooks/onboarding.pb.js @@ -133,7 +133,7 @@ routerAdd("GET", "/api/amber/onboarding-template", (e) => { // Public direct download of the latest CLEAN Android APK — the TV-sideload path // (the Downloader app needs a plain URL; a fresh TV has no account yet, and the // APK alone is useless without one, so serving the clean build openly is fine). -// Adult builds stay account-gated (manifest + protected file), unchanged. +// One variant is published, so this is the whole set of Android artifacts. routerAdd("GET", "/get/tv", (e) => { let rec = null try { diff --git a/pb_hooks/status.pb.js b/pb_hooks/status.pb.js index 60fd29c..c41eff9 100644 --- a/pb_hooks/status.pb.js +++ b/pb_hooks/status.pb.js @@ -14,9 +14,9 @@ // // **Superuser-gated, not user-gated.** There is no "operator" flag on `users` and // inventing one would need a migration; the owner already has superuser -// credentials for /_/, so that is the honest gate. `nsfwEnabled` was considered -// and rejected — it is a content flag, and using it as an admin flag would mean -// any adult-enabled family member could read infrastructure logs. +// credentials for /_/, so that is the honest gate. A field on `users` was +// considered and rejected: a content or convenience flag pressed into service as +// an admin flag is how family members end up reading infrastructure logs. // // **This is the first hook in the project to make an outbound HTTP request.** // Everything before it read only PocketBase's own database (see the note in @@ -154,7 +154,7 @@ routerAdd("GET", "/api/status", (e) => { // Only Amber's own services; the same instance also hosts unrelated apps. const mine = [ "amber-backend", "amber-api", "stremio_prehrajto", "Stremio_Torbox", - "adult-stremio-addon", "myanime-pair-server", "stripchat-proxy", + "myanime-pair-server", ] const out = [] for (const a of apps) { @@ -220,9 +220,10 @@ routerAdd("GET", "/api/status", (e) => { // worst. Every version row therefore carries total, errors and errors/total. // // **Flavour is not recorded yet.** The app's TelemetryService sends appVersion, -// platform and device, but nothing carries clean-vs-adult. This reads a top-level -// `flavor` field first and then `meta.flavor`, so the day the app starts sending -// it this endpoint needs no change; until then the column honestly reads unknown. +// platform and device, but nothing carries the build's variant. This reads a +// top-level `flavor` field first and then `meta.flavor`, so the day the app +// starts sending it this endpoint needs no change; until then the column reads +// unknown. // // The window is capped by retention, not by choice: client_logs.pb.js trims to 14 // days on a nightly cron, so nothing older exists to graph. diff --git a/pb_hooks/update.pb.js b/pb_hooks/update.pb.js index 3d2e447..e8e3532 100644 --- a/pb_hooks/update.pb.js +++ b/pb_hooks/update.pb.js @@ -9,9 +9,9 @@ // token (`POST /api/files/token`) and appends it to `downloadPath`, and the token // grant re-checks the collection rule. // -// `variant` used to be chosen here from the account's admin-set `nsfwEnabled`, -// picking an 18+ build over the clean one. Amber has no 18+ build any more, so it -// is a constant. +// `variant` used to be chosen here from an account flag, picking an 18+ build +// over the clean one. Amber has no 18+ build any more and the flag is gone +// (migration 1797000000), so it is a constant. // // It is NOT removed from the response, and must not be. Every Amber already // installed compares this field against its own and refuses a mismatch SILENTLY diff --git a/pb_migrations/1797000000_drop_nsfw_enabled.js b/pb_migrations/1797000000_drop_nsfw_enabled.js new file mode 100644 index 0000000..be8aa28 --- /dev/null +++ b/pb_migrations/1797000000_drop_nsfw_enabled.js @@ -0,0 +1,57 @@ +/// + +// Remove the last of the 18+ scaffolding: the `nsfwEnabled` flag on `users`, the +// rule clause that guarded it, and `adult` as a publishable release variant. +// +// WHY NOW: nothing reads the flag any more. `/api/amber/whoami` stopped +// returning it, `GET /api/update/manifest` always serves `clean`, the `releases` +// read rule stopped mentioning it in migration 1793000000 (the nine 18+ rows, +// 437 MB, were deleted then), and the web settings page stopped showing an 18+ +// source field in this same change. What was left was a per-account flag nobody +// writes and nobody reads, sitting on the family's accounts and appearing in +// every schema dump, README table and access-rule review as if it still gated +// something. A flag that looks like a protection but protects nothing is worse +// than no flag: the next person to read the schema has to prove it is dead. +// +// `users.updateRule` loses `@request.body.nsfwEnabled:isset = false`. The clause +// was the structural guarantee that a signed-in person could not flip their own +// flag. With the field gone there is nothing to flip, and PocketBase rejects an +// unknown field on write anyway. `createRule` is already superuser-only (null, +// migration 1789000000), so it needs no change. +// +// `releases.variant` KEEPS EXISTING and keeps being required -- only `adult` +// leaves its allowed values. Every installed Amber compares the manifest's +// variant against its own and refuses a mismatch **silently**, so removing the +// field would strand every install with no error to see. Publishing stays +// exactly as it is, minus a choice nobody can make any more. +// +// The down migration restores all three, so an instance can be rolled back. It +// cannot restore the per-account VALUES of the flag: dropping a field drops its +// data. That is intended -- the owner asked for the 18+ leftovers to be gone, +// and the only account that had it set was a test account being retired with it. + +migrate( + (app) => { + const users = app.findCollectionByNameOrId("users") + users.updateRule = "id = @request.auth.id" + users.fields.removeByName("nsfwEnabled") + app.save(users) + + const releases = app.findCollectionByNameOrId("releases") + const variant = releases.fields.getByName("variant") + variant.values = ["clean"] + app.save(releases) + }, + (app) => { + const users = app.findCollectionByNameOrId("users") + users.fields.add(new BoolField({ name: "nsfwEnabled" })) + users.updateRule = + "id = @request.auth.id && @request.body.nsfwEnabled:isset = false" + app.save(users) + + const releases = app.findCollectionByNameOrId("releases") + const variant = releases.fields.getByName("variant") + variant.values = ["clean", "adult"] + app.save(releases) + }, +) diff --git a/pb_public/index.html b/pb_public/index.html index 9d9200b..6d18f19 100644 --- a/pb_public/index.html +++ b/pb_public/index.html @@ -393,11 +393,6 @@ -

Prázdné pole zdroj z účtu odebere.

@@ -632,16 +627,11 @@ async function enter(){ loadProviders(), loadConfigList()]); $("acEmail").value=(USER&&USER.email)||""; $("acRating").value=(USER&&USER.ratingDefault)||"unrestricted"; - // **Do not mention 18+ to an account that does not have it.** This label used to - // read "skryje anime a 18+" for everyone, which told most of the household that a - // section they have never seen and cannot reach exists at all. Three of the four - // accounts are `nsfwEnabled = false`, and for them the sentence was also simply - // untrue: there is no 18+ content to hide. The adult source field on the Zdroje - // tab has always been gated on the same flag; this label was the one place that - // was not. - $("pfChildLabel").textContent = (USER && USER.nsfwEnabled) - ? "Dětský profil (skryje anime a 18+, na odchod chce PIN)" - : "Dětský profil (skryje anime, na odchod chce PIN)"; + // Amber has no 18+ content, so this label does not mention any. It used to read + // "skryje anime a 18+" behind an account flag, which told the household that a + // section they have never seen and cannot reach exists at all. + $("pfChildLabel").textContent = + "Dětský profil (skryje anime, na odchod chce PIN)"; renderAddonGate(); renderStart(); // An explicit #hash always wins — a bookmark or a link must land where it says. @@ -1157,10 +1147,6 @@ async function loadAddons(){ $("adTorbox").value=cfg.addonUrl||""; $("adCzech").value=cfg.czechAddonUrl||""; $("adTmdb").value=cfg.tmdbKey||""; - if (USER && USER.nsfwEnabled){ - $("adAdultBox").className=""; - $("adAdult").value=cfg.adultAddonUrl||""; - } } $("acChange").disabled = !PASSWORD; // Unlocking or saving here is what finishes steps 4 and 5, so the flow has to @@ -1288,7 +1274,6 @@ $("adSave").onclick=async function(){ var next={ addonUrl:$("adTorbox").value.trim(), czechAddonUrl:$("adCzech").value.trim(), tmdbKey:$("adTmdb").value.trim() }; - if (USER && USER.nsfwEnabled) next.adultAddonUrl=$("adAdult").value.trim(); await saveConfig(next, $("adSave")); }; diff --git a/scripts/publish-release.sh b/scripts/publish-release.sh index a952244..f516d49 100755 --- a/scripts/publish-release.sh +++ b/scripts/publish-release.sh @@ -38,7 +38,7 @@ 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" +[[ "$VARIANT" =~ ^clean$ ]] || die "variant must be clean (18+ builds are gone)" [[ "$BUILD" =~ ^[0-9]+$ ]] || die "build must be an integer" [[ -f "$FILE" ]] || die "file not found: $FILE" diff --git a/scripts/verify.py b/scripts/verify.py index 8836c2e..4f0c108 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -8,7 +8,8 @@ Overridable via env: PB_BASE, PB_ADMIN_EMAIL, PB_ADMIN_PASS. python scripts/verify.py Creates test users/profiles/rows, then asserts users can't see or touch each -other's data and can't set their own admin-only `nsfwEnabled`. Exits non-zero +other's data. Also asserts the retired 18+ flag `nsfwEnabled` is gone from the +schema and from every rule, so it cannot come back unnoticed. Exits non-zero if any check fails. """ import json, os, sys, urllib.request, urllib.error @@ -47,11 +48,16 @@ admin = r["token"] # --- schema assertions --- st, users = req("GET", "/api/collections/users", admin) fnames = {f["name"] for f in users["fields"]} -check("users has username + nsfwEnabled", st, {"username", "nsfwEnabled"} <= fnames) -check("users.createRule blocks nsfwEnabled", - st, "nsfwEnabled:isset = false" in (users["createRule"] or "")) -check("users.updateRule blocks nsfwEnabled", - st, "nsfwEnabled:isset = false" in (users["updateRule"] or "")) +check("users has username", st, "username" in fnames) +# The 18+ flag is retired (migration 1797000000). A field nobody reads but that +# still sits on every account reads like a protection that is still doing +# something, so its absence is asserted rather than assumed. +check("users has NO nsfwEnabled field", st, "nsfwEnabled" not in fnames) +check("no users rule mentions nsfwEnabled", st, not any( + "nsfw" in (users[k] or "").lower() + for k in ("listRule", "viewRule", "createRule", "updateRule", "deleteRule"))) +check("users.updateRule is own-record-only", + st, (users["updateRule"] or "") == "id = @request.auth.id") for name in ("profiles", "watch_state", "watchlist", "prefs"): st, c = req("GET", f"/api/collections/{name}", admin) @@ -133,16 +139,16 @@ check("POST /api/device-auth/request is served (hook loaded)", st, st == 200) st, _ = req("GET", "/api/device-auth/poll?id=nope&secret=nope") check("GET /api/device-auth/poll is served", st, st in (200, 400, 403, 404)) -# --- create two users (superuser create; nsfwEnabled set by admin) --- +# --- create two users (superuser create; registration is closed) --- def make_user(email, uname): st, r = req("POST", "/api/collections/users/records", admin, { "email": email, "password": "Passw0rd!23", "passwordConfirm": "Passw0rd!23", - "username": uname, "nsfwEnabled": True, + "username": uname, }) return st, r st, ua = make_user("alice@example.com", "alice") -check("superuser CAN create user + set nsfwEnabled", st, st == 200 and ua.get("nsfwEnabled") is True) +check("superuser CAN create a user", st, st == 200 and ua.get("id")) st, ub = make_user("bob@example.com", "bob") check("second user created", st, st == 200) @@ -230,12 +236,12 @@ st, r = req("POST", "/api/collections/profiles/records", bob, {"user": ua["id"], "name": "sneaky"}) check("bob CANNOT create a profile owned by alice", st, st in (400, 403, 404)) -# --- alice cannot flip her own nsfwEnabled --- +# --- the retired 18+ flag cannot be reintroduced from a client --- st, r = req("PATCH", f"/api/collections/users/records/{ua['id']}", alice, - {"nsfwEnabled": False}) -check("alice CANNOT change her nsfwEnabled (rule blocks)", st, st in (400, 403, 404)) + {"nsfwEnabled": True}) st, me = req("GET", f"/api/collections/users/records/{ua['id']}", alice) -check("alice's nsfwEnabled still True after attempt", st, me.get("nsfwEnabled") is True) +check("alice's record carries no nsfwEnabled after trying to set it", + st, "nsfwEnabled" not in me) # --- alice cannot create watch_state under bob's (nonexistent-to-her) profile --- st, pb = req("POST", "/api/collections/profiles/records", bob, {"user": ub["id"], "name": "Bob Main"}) @@ -258,11 +264,9 @@ check("whoami: bogus token → 401", st, st == 401) st, r = req("GET", "/api/amber/whoami", alice) check("whoami: valid token → 200", st, st == 200) check("whoami returns the caller's own id", st, r.get("id") == ua["id"]) -check("whoami returns nsfwEnabled", st, r.get("nsfwEnabled") is True) # A token-introspection endpoint is a tempting place to leak account data; # amber-api has no business knowing the email, username or profile list. -check("whoami leaks nothing else", st, - set(r.keys()) == {"id", "nsfwEnabled"}) +check("whoami leaks nothing else", st, set(r.keys()) == {"id"}) # Per-caller, not "some authenticated user": a shared/cached answer here would # let amber-api attribute one device's requests to another account.