commit 24d59c3f91d4f4edda62334398258ee2c9dabc50 Author: Claude Date: Wed Jul 15 20:22:45 2026 +0200 feat: PocketBase backend + account/profile schema (epic #6, issue #9) Foundation for the user-accounts platform. Self-hosted PocketBase v0.39.6, version-pinned from the official release binary, schema-as-code so the collections auto-apply on boot and never drift from hand-clicking. Collections: users (auth, admin-only nsfwEnabled), profiles, watch_state, watchlist, prefs. Per-owner access rules traverse profile.user; nsfwEnabled is never client-writable (superuser-only). Relations cascadeDelete; unique indexes keep one resume/watchlist row per (profile,itemId) and one prefs per profile. Verified locally against v0.39.6: migration applies clean, and scripts/verify.py proves two users can't read/write each other's data and can't set their own nsfwEnabled (19/19 checks). Coolify deploy (domain/TLS, superuser, SMTP, backups) documented in README as the manual half. Co-Authored-By: Claude Opus 4.8 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5c897e4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +pb_data +*.md +docker-compose.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96bbd94 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# PocketBase runtime data (DB, uploaded files, logs) — never commit. +pb_data/ + +# Local env / secrets +.env +.env.local diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d2e9ad1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# myanime-backend — PocketBase, version-pinned from the official release. +# +# We download the official static binary from GitHub releases (rather than a +# third-party image) so the deploy is reproducible and easy to audit. +FROM alpine:3.20 AS fetch + +# Bump this to upgrade PocketBase. Keep it in sync with the README. +ARG PB_VERSION=0.39.6 +ARG TARGETARCH=amd64 + +RUN apk add --no-cache ca-certificates unzip wget \ + && wget -q "https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_${TARGETARCH}.zip" -O /tmp/pb.zip \ + && unzip /tmp/pb.zip -d /pb \ + && rm /tmp/pb.zip + +FROM alpine:3.20 + +RUN apk add --no-cache ca-certificates \ + && adduser -D -H -u 10001 pb + +COPY --from=fetch /pb/pocketbase /usr/local/bin/pocketbase + +# Schema-as-code: migrations are baked in and auto-applied on serve. +COPY pb_migrations /pb_migrations + +# Persist DB, uploaded files, and any admin-created migrations here. Create it +# owned by the runtime user so a fresh named volume inherits writable perms. +RUN mkdir -p /pb_data && chown pb /pb_data +VOLUME ["/pb_data"] + +EXPOSE 8090 +USER pb + +# Coolify terminates TLS at Traefik; PocketBase listens plain on 8090 behind it. +ENTRYPOINT ["pocketbase"] +CMD ["serve", "--http=0.0.0.0:8090", "--dir=/pb_data", "--migrationsDir=/pb_migrations"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c9b4fc8 --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# myanime-backend + +**PocketBase** backend for the myanime app — auth, database, realtime, file +storage and admin UI in a single Go binary. This is the foundation the account +features build on: cloud sync, codeless device sign-in, profiles, child +profiles, recommendations, and gated auto-update (epic +[#6](https://projects.petruzalekr.cz/richiexec/myanime-app/issues/6)). + +- **No E2E.** TLS in transit + at-rest on disk. The server is deliberately + readable so recommendations, the admin `nsfwEnabled` flag, and painless + password reset all work. +- **Schema as code.** The collections live in [`pb_migrations/`](pb_migrations/) + and auto-apply on boot — never hand-click the schema in the admin UI, or the + next deploy will drift. +- **Version-pinned.** The [`Dockerfile`](Dockerfile) downloads the official + PocketBase **v0.39.6** static binary from GitHub releases (no third-party + image). Bump `PB_VERSION` there and in `docker-compose.yml` to upgrade. + +## Data model + +``` +users ─┬─* profiles ─┬─* watch_state (resume points; 1 row per profile+item) + (auth)│ ├─* watchlist (1 row per profile+item) + │ └─1 prefs (playback/audio/subtitle blob) + └ nsfwEnabled (admin-only bool → gates adult vs clean build, issue #16) +``` + +| Collection | Type | Key fields | +|---------------|------|------------| +| `users` | auth | `username` (unique when set), `email`, `password`, **`nsfwEnabled`** (admin-only) | +| `profiles` | base | `user`→users, `name`, `avatar` (file), `isChild`, `pinHash` (hidden) | +| `watch_state` | base | `profile`→profiles, `itemId`, `itemType`, `position`, `duration`, `updatedAt` | +| `watchlist` | base | `profile`→profiles, `itemId`, `itemType`, `addedAt` | +| `prefs` | base | `profile`→profiles, `data` (json) | + +**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 +`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` row per profile. + +## Local run / verify + +```bash +docker compose up --build # http://localhost:8090 +# create the first superuser (one-time): +docker compose exec pocketbase pocketbase superuser upsert you@example.com 'a-strong-pass' --dir=/pb_data +# 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 +[`scripts/verify.py`](scripts/verify.py): + +```bash +python scripts/verify.py # expects the server on :8090 + the superuser above +``` + +## Deploy on Coolify + +The schema and image are automated; the steps below need your hands, Coolify +access and secrets (which never live in this repo). + +1. **Push this repo** to a remote Coolify can reach (e.g. GitHub, like + `myanime-pair-server`). +2. **New Resource → Dockerfile application**, point it at this repo. It listens + on `8090`. +3. **Persistent storage:** add a volume mounted at **`/pb_data`** (DB + uploaded + files). Without this you lose data on redeploy. +4. **Domain + TLS:** set the domain to `pb.petruzalekr.cz`; Coolify/Traefik + provisions the Let's Encrypt cert and terminates TLS in front of the plain + `:8090`. Force-HTTPS on. +5. **Bootstrap the superuser** once, from the container terminal: + `pocketbase superuser upsert you@example.com 'a-strong-pass' --dir=/pb_data` + (or open the printed `/_/#/pbinstall/...` link on first boot). **Lock down** + the admin UI — strong password; the `_superusers` collection is the only way + in. +6. **SMTP** (admin UI → *Settings → Mail settings*): point at your SMTP host so + verification / password-reset emails send. Send the test email to confirm. +7. **Automated backups** (admin UI → *Settings → Backups*): enable the schedule + and, ideally, S3 off-site upload. Coolify volume backups are a second layer. +8. **App wiring** (later issues, not here): point the app's API base at + `https://pb.petruzalekr.cz`. + +### Verify the deploy + +- Admin UI reachable over **HTTPS** only; HTTP redirects up. +- Create a test user + profile + a couple of rows via admin or REST; confirm a + second user can't read them (run `scripts/verify.py` against the live URL by + editing `BASE`). +- Trigger a password-reset email and confirm delivery. +- Confirm a backup ran and can be downloaded/restored. + +## Security + +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. + +## Upgrading PocketBase + +Bump `PB_VERSION` in the `Dockerfile` (and the compose arg), rebuild, redeploy. +Check the PocketBase release notes for migration-API or schema changes first, +and test locally with `docker compose up --build` before deploying. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6859609 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +# Local development / verification only. On Coolify, deploy the Dockerfile as an +# application resource (see README) — Coolify manages the domain, TLS and volume. +services: + pocketbase: + build: + context: . + args: + PB_VERSION: 0.39.6 + ports: + - "8090:8090" + volumes: + - pb_data:/pb_data + restart: unless-stopped + +volumes: + pb_data: diff --git a/pb_migrations/1752600000_init_accounts_schema.js b/pb_migrations/1752600000_init_accounts_schema.js new file mode 100644 index 0000000..79d107a --- /dev/null +++ b/pb_migrations/1752600000_init_accounts_schema.js @@ -0,0 +1,165 @@ +/// + +// Initial account + multi-profile schema for myanime-app (epic #6, issue #9). +// +// Ownership model: users 1─┬─* profiles ─┬─* watch_state +// │ ├─* watchlist +// │ └─1 prefs +// +// Access rules: a user only ever touches their own `users` row, their own +// profiles, and rows hanging off those profiles (`profile.user = auth.id`). +// `nsfwEnabled` is admin-only: users can't submit it (superusers bypass rules +// and set it from the admin UI). It gates adult vs clean builds in issue #16. + +migrate((app) => { + // ---- 1. Augment the default `users` auth collection -------------------- + const users = app.findCollectionByNameOrId("users") + + users.fields.add(new Field({ + type: "text", + name: "username", + max: 150, + presentable: true, + })) + users.fields.add(new Field({ + type: "bool", + name: "nsfwEnabled", + })) + + // Unique username, but the field is optional — only enforce when non-empty. + users.indexes.push( + "CREATE UNIQUE INDEX `idx_users_username` ON `users` (`username`) WHERE `username` != ''" + ) + + // Own record only; nsfwEnabled never accepted from a client on create/update. + users.listRule = "id = @request.auth.id" + users.viewRule = "id = @request.auth.id" + users.createRule = "@request.body.nsfwEnabled:isset = false" + users.updateRule = "id = @request.auth.id && @request.body.nsfwEnabled:isset = false" + users.deleteRule = "id = @request.auth.id" + + app.save(users) + + const usersId = users.id + + // ---- 2. profiles ------------------------------------------------------- + const profiles = new Collection({ + type: "base", + name: "profiles", + listRule: "@request.auth.id != '' && user = @request.auth.id", + viewRule: "@request.auth.id != '' && user = @request.auth.id", + createRule: "@request.auth.id != '' && user = @request.auth.id", + updateRule: "@request.auth.id != '' && user = @request.auth.id", + deleteRule: "@request.auth.id != '' && user = @request.auth.id", + fields: [ + { + type: "relation", + name: "user", + required: true, + collectionId: usersId, + cascadeDelete: true, + maxSelect: 1, + minSelect: 0, + }, + { type: "text", name: "name", required: true, max: 100, presentable: true }, + { + type: "file", + name: "avatar", + maxSelect: 1, + maxSize: 5242880, + mimeTypes: ["image/jpeg", "image/png", "image/webp", "image/gif"], + }, + { type: "bool", name: "isChild" }, + // PIN for child profiles; hidden so it never leaves the API. + { type: "text", name: "pinHash", hidden: true, max: 200 }, + { type: "autodate", name: "created", onCreate: true }, + { type: "autodate", name: "updated", onCreate: true, onUpdate: true }, + ], + }) + app.save(profiles) + const profilesId = profiles.id + + // Rows below all belong to a profile; ownership traverses profile.user. + const OWNS = "@request.auth.id != '' && profile.user = @request.auth.id" + + const profileRel = () => ({ + type: "relation", + name: "profile", + required: true, + collectionId: profilesId, + cascadeDelete: true, + maxSelect: 1, + minSelect: 0, + }) + + // ---- 3. watch_state (resume points) ------------------------------------ + const watchState = new Collection({ + type: "base", + name: "watch_state", + listRule: OWNS, viewRule: OWNS, createRule: OWNS, updateRule: OWNS, deleteRule: OWNS, + fields: [ + profileRel(), + { type: "text", name: "itemId", required: true, max: 100 }, + { type: "text", name: "itemType", required: true, max: 40 }, + { type: "number", name: "position", min: 0 }, + { type: "number", name: "duration", min: 0 }, + { type: "autodate", name: "updatedAt", onCreate: true, onUpdate: true }, + ], + indexes: [ + "CREATE UNIQUE INDEX `idx_watch_state_profile_item` ON `watch_state` (`profile`, `itemId`)", + ], + }) + app.save(watchState) + + // ---- 4. watchlist ------------------------------------------------------ + const watchlist = new Collection({ + type: "base", + name: "watchlist", + listRule: OWNS, viewRule: OWNS, createRule: OWNS, updateRule: OWNS, deleteRule: OWNS, + fields: [ + profileRel(), + { type: "text", name: "itemId", required: true, max: 100 }, + { type: "text", name: "itemType", required: true, max: 40 }, + { type: "autodate", name: "addedAt", onCreate: true }, + ], + indexes: [ + "CREATE UNIQUE INDEX `idx_watchlist_profile_item` ON `watchlist` (`profile`, `itemId`)", + ], + }) + app.save(watchlist) + + // ---- 5. prefs (one row per profile) ------------------------------------ + const prefs = new Collection({ + type: "base", + name: "prefs", + listRule: OWNS, viewRule: OWNS, createRule: OWNS, updateRule: OWNS, deleteRule: OWNS, + fields: [ + profileRel(), + // playback / audio / subtitle preferences blob. + { type: "json", name: "data", maxSize: 2000000 }, + { type: "autodate", name: "updated", onCreate: true, onUpdate: true }, + ], + indexes: [ + "CREATE UNIQUE INDEX `idx_prefs_profile` ON `prefs` (`profile`)", + ], + }) + app.save(prefs) +}, (app) => { + // ---- Down migration ---------------------------------------------------- + for (const name of ["prefs", "watchlist", "watch_state", "profiles"]) { + try { + app.delete(app.findCollectionByNameOrId(name)) + } catch (_) { /* already gone */ } + } + + const users = app.findCollectionByNameOrId("users") + users.indexes = users.indexes.filter((idx) => !idx.includes("idx_users_username")) + users.fields.removeByName("username") + users.fields.removeByName("nsfwEnabled") + users.createRule = "" + users.updateRule = "id = @request.auth.id" + users.listRule = "id = @request.auth.id" + users.viewRule = "id = @request.auth.id" + users.deleteRule = "id = @request.auth.id" + app.save(users) +}) diff --git a/scripts/verify.py b/scripts/verify.py new file mode 100644 index 0000000..88d34cb --- /dev/null +++ b/scripts/verify.py @@ -0,0 +1,125 @@ +"""End-to-end access-rule check for the myanime PocketBase schema. + +Run against a local `docker compose up --build` instance that has a superuser. +Overridable via env: PB_BASE, PB_ADMIN_EMAIL, PB_ADMIN_PASS. + + docker compose exec pocketbase pocketbase superuser upsert \\ + admin@myanime.local 'Sup3rSecret!123' --dir=/pb_data + 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 +if any check fails. +""" +import json, os, sys, urllib.request, urllib.error + +BASE = os.environ.get("PB_BASE", "http://localhost:8090") +ADMIN_EMAIL = os.environ.get("PB_ADMIN_EMAIL", "admin@myanime.local") +ADMIN_PASS = os.environ.get("PB_ADMIN_PASS", "Sup3rSecret!123") + +def req(method, path, token=None, body=None): + data = json.dumps(body).encode() if body is not None else None + r = urllib.request.Request(BASE + path, data=data, method=method) + r.add_header("Content-Type", "application/json") + if token: + r.add_header("Authorization", token) + try: + with urllib.request.urlopen(r) as resp: + return resp.status, json.loads(resp.read() or "{}") + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read() or "{}") + +def show(label, status, ok): + mark = "PASS" if ok else "FAIL" + print(f"[{mark}] {label} -> HTTP {status}") + return ok + +allpass = True +def check(label, status, ok): + global allpass + allpass = show(label, status, ok) and allpass + +# --- superuser --- +st, r = req("POST", "/api/collections/_superusers/auth-with-password", + body={"identity": ADMIN_EMAIL, "password": ADMIN_PASS}) +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 "")) + +for name in ("profiles", "watch_state", "watchlist", "prefs"): + st, c = req("GET", f"/api/collections/{name}", admin) + rules_owned = all("@request.auth.id" in (c[k] or "") for k in + ("listRule","viewRule","createRule","updateRule","deleteRule")) + check(f"{name} exists with per-owner rules on all 5 verbs", st, rules_owned) + +# unique indexes present +st, ws = req("GET", "/api/collections/watch_state", admin) +check("watch_state has unique (profile,itemId) index", st, + any("idx_watch_state_profile_item" in i for i in ws["indexes"])) + +# --- create two users (superuser create; nsfwEnabled set by admin) --- +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, + }) + 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) +st, ub = make_user("bob@example.com", "bob") +check("second user created", st, st == 200) + +def login(email): + st, r = req("POST", "/api/collections/users/auth-with-password", body={ + "identity": email, "password": "Passw0rd!23"}) + return r.get("token") + +alice = login("alice@example.com") +bob = login("bob@example.com") + +# --- alice creates a profile + rows --- +st, pa = req("POST", "/api/collections/profiles/records", alice, + {"user": ua["id"], "name": "Alice Main"}) +check("alice creates her own profile", st, st == 200) +st, wsrow = req("POST", "/api/collections/watch_state/records", alice, + {"profile": pa["id"], "itemId": "tt0111161", "itemType": "movie", + "position": 120, "duration": 8520}) +check("alice creates watch_state under her profile", st, st == 200) + +# --- ISOLATION: bob must not see or touch alice's data --- +st, r = req("GET", f"/api/collections/profiles/records/{pa['id']}", bob) +check("bob CANNOT view alice's profile (404)", st, st == 404) +st, r = req("GET", "/api/collections/watch_state/records", bob) +check("bob's watch_state list is empty", st, st == 200 and r.get("totalItems") == 0) +st, r = req("PATCH", f"/api/collections/watch_state/records/{wsrow['id']}", bob, + {"position": 999}) +check("bob CANNOT update alice's watch_state (404)", st, st == 404) + +# --- bob cannot create a profile owned by alice --- +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 --- +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)) +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) + +# --- 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, r = req("POST", "/api/collections/watch_state/records", alice, + {"profile": pb["id"], "itemId": "tt0068646", "itemType": "movie"}) +check("alice CANNOT attach a row to bob's profile", st, st in (400, 403, 404)) + +print("\n" + ("ALL CHECKS PASSED" if allpass else "SOME CHECKS FAILED")) +sys.exit(0 if allpass else 1)