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 prefs (playback/audio/subtitle blob)
|
||||||
│ └─1 addon_config (client-encrypted addon blob; ciphertext only)
|
│ └─1 addon_config (client-encrypted addon blob; ciphertext only)
|
||||||
├─* device_auth (codeless sign-in requests / approved devices, issue #12)
|
├─* device_auth (codeless sign-in requests / approved devices, issue #12)
|
||||||
└ nsfwEnabled (admin-only bool, legacy: see below)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
| Collection | Type | Key fields |
|
| 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) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `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
|
**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
|
`profiles`, and rows whose `profile.user` is them. Accounts are created by a
|
||||||
accepted from a client (`@request.body.nsfwEnabled:isset = false` on create and
|
superuser only (registration is closed, migration 1789000000). Relations
|
||||||
update) — only a **superuser** sets it, from the admin UI. Relations
|
|
||||||
`cascadeDelete`, so deleting a user removes their profiles and all child rows.
|
`cascadeDelete`, so deleting a user removes their profiles and all child rows.
|
||||||
Unique indexes keep one resume/watchlist row per `(profile, itemId)` and one
|
Unique indexes keep one resume/watchlist row per `(profile, itemId)` and one
|
||||||
`prefs`/`addon_config` row per profile.
|
`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
|
One Amber build ships. An account flag `nsfwEnabled` used to choose between an
|
||||||
clean one; the 18+ section left Amber to become a separate app, and none of that
|
18+ build and the clean one; the 18+ section left Amber to become a separate app,
|
||||||
gating is left in this repo:
|
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
|
`releases.variant` itself is **not** going anywhere: it is always `clean`, and
|
||||||
`clean`, whatever the flag says. The `variant` field stays in the response
|
every installed Amber compares it against its own and refuses a mismatch
|
||||||
because every installed Amber silently refuses a mismatch.
|
silently, so dropping the field would strand those installs with no error to see.
|
||||||
- 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.
|
|
||||||
|
|
||||||
### Encrypted addon config (issue #20)
|
### 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/_/
|
# admin UI: http://localhost:8090/_/
|
||||||
```
|
```
|
||||||
|
|
||||||
The end-to-end access-rule check (two users can't see each other's data; a user
|
The end-to-end access-rule check (two users can't see each other's data; the
|
||||||
can't set their own `nsfwEnabled`; a superuser can) lives in
|
retired 18+ flag is gone from the schema and the rules) lives in
|
||||||
[`scripts/verify.py`](scripts/verify.py):
|
[`scripts/verify.py`](scripts/verify.py):
|
||||||
|
|
||||||
```bash
|
```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
|
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
|
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
|
## Upgrading PocketBase
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Auto-update — contract (issue #16)
|
# Auto-update — contract (issue #16)
|
||||||
|
|
||||||
The app checks for a newer build and installs it. One variant ships, `clean`;
|
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
|
The server used to pick between that and an 18+ one from an account flag; that
|
||||||
`nsfwEnabled` flag, and no longer does.
|
flag and that build are both gone (`pb_migrations/1797000000_drop_nsfw_enabled.js`).
|
||||||
|
|
||||||
Server pieces (this repo):
|
Server pieces (this repo):
|
||||||
- `pb_migrations/1786500000_releases.js` — the `releases` collection.
|
- `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 |
|
| Field | Type | Notes |
|
||||||
|---------------|--------|-------|
|
|---------------|--------|-------|
|
||||||
| `platform` | select | `windows` \| `linux` \| `android`. |
|
| `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`. |
|
| `version` | text | Human semver shown to the user, e.g. `1.0.1`. |
|
||||||
| `buildNumber` | number | Monotonic int; the comparator the updater actually uses. |
|
| `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). |
|
| `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)`.
|
Unique index on `(platform, variant, buildNumber)`.
|
||||||
|
|
||||||
**Access rules.** Read is gated:
|
**Access rules.** Read needs a signed-in account (`@request.auth.id != ''`) and
|
||||||
`@request.auth.id != '' && (variant = 'clean' || @request.auth.nsfwEnabled = true)`
|
nothing more: one variant is published and there is nothing left to gate between.
|
||||||
— any signed-in account reads **clean** rows; only an `nsfwEnabled` account reads
|
The `file` field is **`protected: true`**, which is what gates the bytes:
|
||||||
**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:
|
|
||||||
a protected file is served only with a short-lived file token
|
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
|
(`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`
|
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
|
There is nothing left to gate between: one variant is published, and the manifest
|
||||||
hook returns it to everyone.
|
hook returns it to everyone.
|
||||||
|
|
||||||
`nsfwEnabled` still exists on the `users` record and the collection rules still
|
The 18+ flag is gone from `users` and `adult` is gone from `variant`'s allowed
|
||||||
read it. Both stay because `adult` rows from before the split are still in the
|
values (`pb_migrations/1797000000_drop_nsfw_enabled.js`). The manifest hook
|
||||||
collection, and those rules are what keeps them unreachable. Nothing writes the
|
serves `clean` to every signed-in account.
|
||||||
flag any more: the manifest hook ignores it, `whoami` no longer returns it, and
|
|
||||||
the status dashboard's toggle is gone.
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
- `403` bad code · `429` after 10 bad codes / 5 min / IP · `404` no template
|
||||||
record · `503` env unset.
|
record · `503` env unset.
|
||||||
- **Deliberately no adult fields** — the template is family config by
|
- **Deliberately no 18+ fields** — the template is family config by
|
||||||
definition (mirrors the #14/#20 child stripping). Adult stays per-account.
|
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
|
- 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 =
|
*new user's* password (PBKDF2-HMAC-SHA256 210k → AES-256-GCM, blob =
|
||||||
`base64(nonce[12] ‖ ct ‖ tag[16])`, `kdf = pbkdf2-sha256-210000`) and pushes
|
`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
|
Streams the newest `releases` row with `platform=android && variant=clean` as
|
||||||
`amber-tv.apk` (attachment). Public by design: the TV-sideload path (the
|
`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
|
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)
|
## 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
|
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
|
run in a real browser produced the user, the named profile, and an
|
||||||
`addon_config` blob that **decrypted with the app's Dart `AddonConfigCrypto`**
|
`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
|
published dummy APK byte-identical with the attachment header · landing's
|
||||||
download login handled the no-releases state.
|
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
|
// 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
|
// (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).
|
// 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) => {
|
routerAdd("GET", "/get/tv", (e) => {
|
||||||
let rec = null
|
let rec = null
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,9 @@
|
||||||
//
|
//
|
||||||
// **Superuser-gated, not user-gated.** There is no "operator" flag on `users` and
|
// **Superuser-gated, not user-gated.** There is no "operator" flag on `users` and
|
||||||
// inventing one would need a migration; the owner already has superuser
|
// inventing one would need a migration; the owner already has superuser
|
||||||
// credentials for /_/, so that is the honest gate. `nsfwEnabled` was considered
|
// credentials for /_/, so that is the honest gate. A field on `users` was
|
||||||
// and rejected — it is a content flag, and using it as an admin flag would mean
|
// considered and rejected: a content or convenience flag pressed into service as
|
||||||
// any adult-enabled family member could read infrastructure logs.
|
// 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.**
|
// **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
|
// 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.
|
// Only Amber's own services; the same instance also hosts unrelated apps.
|
||||||
const mine = [
|
const mine = [
|
||||||
"amber-backend", "amber-api", "stremio_prehrajto", "Stremio_Torbox",
|
"amber-backend", "amber-api", "stremio_prehrajto", "Stremio_Torbox",
|
||||||
"adult-stremio-addon", "myanime-pair-server", "stripchat-proxy",
|
"myanime-pair-server",
|
||||||
]
|
]
|
||||||
const out = []
|
const out = []
|
||||||
for (const a of apps) {
|
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.
|
// worst. Every version row therefore carries total, errors and errors/total.
|
||||||
//
|
//
|
||||||
// **Flavour is not recorded yet.** The app's TelemetryService sends appVersion,
|
// **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
|
// platform and device, but nothing carries the build's variant. This reads a
|
||||||
// `flavor` field first and then `meta.flavor`, so the day the app starts sending
|
// top-level `flavor` field first and then `meta.flavor`, so the day the app
|
||||||
// it this endpoint needs no change; until then the column honestly reads unknown.
|
// 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
|
// 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.
|
// 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
|
// token (`POST /api/files/token`) and appends it to `downloadPath`, and the token
|
||||||
// grant re-checks the collection rule.
|
// grant re-checks the collection rule.
|
||||||
//
|
//
|
||||||
// `variant` used to be chosen here from the account's admin-set `nsfwEnabled`,
|
// `variant` used to be chosen here from an account flag, picking an 18+ build
|
||||||
// picking an 18+ build over the clean one. Amber has no 18+ build any more, so it
|
// over the clean one. Amber has no 18+ build any more and the flag is gone
|
||||||
// is a constant.
|
// (migration 1797000000), so it is a constant.
|
||||||
//
|
//
|
||||||
// It is NOT removed from the response, and must not be. Every Amber already
|
// 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
|
// 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://…">
|
<input id="adCzech" type="text" spellcheck="false" placeholder="https://…">
|
||||||
<label for="adTmdb">TMDB klíč</label>
|
<label for="adTmdb">TMDB klíč</label>
|
||||||
<input id="adTmdb" type="text" spellcheck="false">
|
<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>
|
<p class="hint">Prázdné pole zdroj z účtu odebere.</p>
|
||||||
<button id="adSave" class="btn primary">Uložit zdroje</button>
|
<button id="adSave" class="btn primary">Uložit zdroje</button>
|
||||||
<button id="adRedo" class="link">Nastavit znovu od začátku</button>
|
<button id="adRedo" class="link">Nastavit znovu od začátku</button>
|
||||||
|
|
@ -632,16 +627,11 @@ async function enter(){
|
||||||
loadProviders(), loadConfigList()]);
|
loadProviders(), loadConfigList()]);
|
||||||
$("acEmail").value=(USER&&USER.email)||"";
|
$("acEmail").value=(USER&&USER.email)||"";
|
||||||
$("acRating").value=(USER&&USER.ratingDefault)||"unrestricted";
|
$("acRating").value=(USER&&USER.ratingDefault)||"unrestricted";
|
||||||
// **Do not mention 18+ to an account that does not have it.** This label used to
|
// Amber has no 18+ content, so this label does not mention any. It used to read
|
||||||
// read "skryje anime a 18+" for everyone, which told most of the household that a
|
// "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. Three of the four
|
// section they have never seen and cannot reach exists at all.
|
||||||
// accounts are `nsfwEnabled = false`, and for them the sentence was also simply
|
$("pfChildLabel").textContent =
|
||||||
// untrue: there is no 18+ content to hide. The adult source field on the Zdroje
|
"Dětský profil (skryje anime, na odchod chce PIN)";
|
||||||
// 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)";
|
|
||||||
renderAddonGate();
|
renderAddonGate();
|
||||||
renderStart();
|
renderStart();
|
||||||
// An explicit #hash always wins — a bookmark or a link must land where it says.
|
// 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||"";
|
$("adTorbox").value=cfg.addonUrl||"";
|
||||||
$("adCzech").value=cfg.czechAddonUrl||"";
|
$("adCzech").value=cfg.czechAddonUrl||"";
|
||||||
$("adTmdb").value=cfg.tmdbKey||"";
|
$("adTmdb").value=cfg.tmdbKey||"";
|
||||||
if (USER && USER.nsfwEnabled){
|
|
||||||
$("adAdultBox").className="";
|
|
||||||
$("adAdult").value=cfg.adultAddonUrl||"";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
$("acChange").disabled = !PASSWORD;
|
$("acChange").disabled = !PASSWORD;
|
||||||
// Unlocking or saving here is what finishes steps 4 and 5, so the flow has to
|
// 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(),
|
var next={ addonUrl:$("adTorbox").value.trim(),
|
||||||
czechAddonUrl:$("adCzech").value.trim(),
|
czechAddonUrl:$("adCzech").value.trim(),
|
||||||
tmdbKey:$("adTmdb").value.trim() };
|
tmdbKey:$("adTmdb").value.trim() };
|
||||||
if (USER && USER.nsfwEnabled) next.adultAddonUrl=$("adAdult").value.trim();
|
|
||||||
await saveConfig(next, $("adSave"));
|
await saveConfig(next, $("adSave"));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ die() { echo "error: $*" >&2; exit 1; }
|
||||||
[[ -n "$PLATFORM" && -n "$VARIANT" && -n "$VERSION" && -n "$BUILD" && -n "$FILE" ]] \
|
[[ -n "$PLATFORM" && -n "$VARIANT" && -n "$VERSION" && -n "$BUILD" && -n "$FILE" ]] \
|
||||||
|| die "need --platform --variant --version --build --file"
|
|| die "need --platform --variant --version --build --file"
|
||||||
[[ "$PLATFORM" =~ ^(windows|linux|android)$ ]] || die "platform must be windows|linux|android"
|
[[ "$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"
|
[[ "$BUILD" =~ ^[0-9]+$ ]] || die "build must be an integer"
|
||||||
[[ -f "$FILE" ]] || die "file not found: $FILE"
|
[[ -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
|
python scripts/verify.py
|
||||||
|
|
||||||
Creates test users/profiles/rows, then asserts users can't see or touch each
|
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.
|
if any check fails.
|
||||||
"""
|
"""
|
||||||
import json, os, sys, urllib.request, urllib.error
|
import json, os, sys, urllib.request, urllib.error
|
||||||
|
|
@ -47,11 +48,16 @@ admin = r["token"]
|
||||||
# --- schema assertions ---
|
# --- schema assertions ---
|
||||||
st, users = req("GET", "/api/collections/users", admin)
|
st, users = req("GET", "/api/collections/users", admin)
|
||||||
fnames = {f["name"] for f in users["fields"]}
|
fnames = {f["name"] for f in users["fields"]}
|
||||||
check("users has username + nsfwEnabled", st, {"username", "nsfwEnabled"} <= fnames)
|
check("users has username", st, "username" in fnames)
|
||||||
check("users.createRule blocks nsfwEnabled",
|
# The 18+ flag is retired (migration 1797000000). A field nobody reads but that
|
||||||
st, "nsfwEnabled:isset = false" in (users["createRule"] or ""))
|
# still sits on every account reads like a protection that is still doing
|
||||||
check("users.updateRule blocks nsfwEnabled",
|
# something, so its absence is asserted rather than assumed.
|
||||||
st, "nsfwEnabled:isset = false" in (users["updateRule"] or ""))
|
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"):
|
for name in ("profiles", "watch_state", "watchlist", "prefs"):
|
||||||
st, c = req("GET", f"/api/collections/{name}", admin)
|
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")
|
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))
|
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):
|
def make_user(email, uname):
|
||||||
st, r = req("POST", "/api/collections/users/records", admin, {
|
st, r = req("POST", "/api/collections/users/records", admin, {
|
||||||
"email": email, "password": "Passw0rd!23", "passwordConfirm": "Passw0rd!23",
|
"email": email, "password": "Passw0rd!23", "passwordConfirm": "Passw0rd!23",
|
||||||
"username": uname, "nsfwEnabled": True,
|
"username": uname,
|
||||||
})
|
})
|
||||||
return st, r
|
return st, r
|
||||||
|
|
||||||
st, ua = make_user("alice@example.com", "alice")
|
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")
|
st, ub = make_user("bob@example.com", "bob")
|
||||||
check("second user created", st, st == 200)
|
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"})
|
{"user": ua["id"], "name": "sneaky"})
|
||||||
check("bob CANNOT create a profile owned by alice", st, st in (400, 403, 404))
|
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,
|
st, r = req("PATCH", f"/api/collections/users/records/{ua['id']}", alice,
|
||||||
{"nsfwEnabled": False})
|
{"nsfwEnabled": True})
|
||||||
check("alice CANNOT change her nsfwEnabled (rule blocks)", st, st in (400, 403, 404))
|
|
||||||
st, me = req("GET", f"/api/collections/users/records/{ua['id']}", alice)
|
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 ---
|
# --- 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"})
|
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)
|
st, r = req("GET", "/api/amber/whoami", alice)
|
||||||
check("whoami: valid token → 200", st, st == 200)
|
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 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;
|
# A token-introspection endpoint is a tempting place to leak account data;
|
||||||
# amber-api has no business knowing the email, username or profile list.
|
# amber-api has no business knowing the email, username or profile list.
|
||||||
check("whoami leaks nothing else", st,
|
check("whoami leaks nothing else", st, set(r.keys()) == {"id"})
|
||||||
set(r.keys()) == {"id", "nsfwEnabled"})
|
|
||||||
|
|
||||||
# Per-caller, not "some authenticated user": a shared/cached answer here would
|
# Per-caller, not "some authenticated user": a shared/cached answer here would
|
||||||
# let amber-api attribute one device's requests to another account.
|
# let amber-api attribute one device's requests to another account.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue