Drop the 18+ account flag, its rule clause and the web page's 18+ field
Nothing read `users.nsfwEnabled` any more. `/api/amber/whoami` stopped returning it, `GET /api/update/manifest` always answers `clean`, and the `releases` read rule stopped mentioning it in migration 1793000000, when the nine 18+ rows (437 MB) were deleted. What was left was a per-account flag nobody writes and nobody reads, sitting on the family's accounts and showing up in every schema dump and access-rule review as if it still gated something. A flag that looks like a protection but protects nothing costs the next reader the work of proving it dead. One live reader did remain: pb_public/index.html showed a "Zdroj 18+" source field and wrote `adultAddonUrl` into the encrypted addon config for a flagged account, and picked a different child-profile label for one. Both are gone, so the page offers the same three sources to everyone. Migration 1797000000 drops the field, rewrites users.updateRule to `id = @request.auth.id` (the clause it loses guarded a field that no longer exists), and removes `adult` from releases.variant's allowed values. Checked before writing it: 15 release rows, every one `clean`; three accounts carried the flag (the owner's, one family member's, the retired test account); the separate 18+ app has no accounts and no PocketBase, so nothing outside Amber reads it. `variant` itself stays and stays required: every installed Amber compares it against its own build and refuses a mismatch SILENTLY, so dropping the field would strand those installs with no error to see. publish-release.sh now accepts only `clean`. The status dashboard stops listing `adult-stremio-addon` and `stripchat-proxy`. Both still run on the VPS; they are the owner's, not Amber's, and Amber never reached them. scripts/verify.py asserts the flag stays gone (field absent, no rule mentions it, updateRule own-record-only). Run against a local PocketBase built from origin/main it failed on exactly those three checks; with this migration applied, ALL CHECKS PASSED. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
580f43d19d
commit
fb9abaae17
10 changed files with 123 additions and 94 deletions
41
README.md
41
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
57
pb_migrations/1797000000_drop_nsfw_enabled.js
Normal file
57
pb_migrations/1797000000_drop_nsfw_enabled.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// 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)
|
||||
},
|
||||
)
|
||||
|
|
@ -393,11 +393,6 @@
|
|||
<input id="adCzech" type="text" spellcheck="false" placeholder="https://…">
|
||||
<label for="adTmdb">TMDB klíč</label>
|
||||
<input id="adTmdb" type="text" spellcheck="false">
|
||||
<div id="adAdultBox" class="hidden">
|
||||
<hr class="sep">
|
||||
<label for="adAdult">Zdroj 18+</label>
|
||||
<input id="adAdult" type="text" spellcheck="false" placeholder="https://…">
|
||||
</div>
|
||||
<p class="hint">Prázdné pole zdroj z účtu odebere.</p>
|
||||
<button id="adSave" class="btn primary">Uložit zdroje</button>
|
||||
<button id="adRedo" class="link">Nastavit znovu od začátku</button>
|
||||
|
|
@ -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"));
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue