diff --git a/README.md b/README.md index 78c8115..fa10d1c 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,34 @@ against different PocketBase instances. For local dev, copy `.env.example` to ## Docker +Single container (external PocketBase): + ```sh docker build -t pokeshare . docker run -p 4321:4321 -e POCKETBASE_URL=https://pb.example.com pokeshare ``` +## Full stack (app + PocketBase) + +`docker-compose.yml` runs the app together with a PocketBase container. On +first boot, PocketBase applies `pocketbase/pb_migrations/`, which creates the +`users` profile fields, the `public_users` view, and the `mycards` and +`friendships` collections with their API rules — no manual setup needed. + +```sh +PB_SUPERUSER_EMAIL=admin@example.com PB_SUPERUSER_PASSWORD=changeme123 \ + docker compose up --build +``` + +- The app listens on `4321`; PocketBase listens on `8090` (internal network, + admin UI at `/_/` if you expose it). +- In Coolify, deploy as a **Docker Compose** resource and expose the `app` + service on your domain. Set `PB_SUPERUSER_*` in the environment variables. +- Accounts are created from the PocketBase admin UI (public registration is + disabled by the migration). +- To move existing data, use the PocketBase admin UI backups (Settings → + Backups) on the old instance and restore into `pb_data`. + ## Structure - `src/pages/` — routes (`/`, `/search`, `/mycards`, `/card/[id]`, diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..369634d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +# Full PokeShare stack: the Astro app and its PocketBase backend. +# In Coolify, deploy this file as a "Docker Compose" resource. +# The `app` service is the one to expose on your public domain. +# `pocketbase` stays on the internal network — expose it on a subdomain +# only if you want to reach the admin UI (/_/) from outside. +services: + pocketbase: + build: ./pocketbase + restart: unless-stopped + environment: + # Optional: create/update the admin account on boot (min 8 chars) + PB_SUPERUSER_EMAIL: ${PB_SUPERUSER_EMAIL:-} + PB_SUPERUSER_PASSWORD: ${PB_SUPERUSER_PASSWORD:-} + volumes: + - pb_data:/pb/pb_data + + app: + build: . + restart: unless-stopped + environment: + # Internal service URL — the browser never talks to PocketBase + # directly, everything goes through the Astro server. + POCKETBASE_URL: http://pocketbase:8090 + depends_on: + - pocketbase + +volumes: + pb_data: diff --git a/pocketbase/Dockerfile b/pocketbase/Dockerfile new file mode 100644 index 0000000..e79b877 --- /dev/null +++ b/pocketbase/Dockerfile @@ -0,0 +1,19 @@ +# PocketBase backend with auto-applied migrations (collections + rules). +# Migrations in ./pb_migrations run automatically when the server starts. +FROM alpine:3.21 + +ARG PB_VERSION=0.39.9 + +RUN apk add --no-cache ca-certificates curl unzip + +WORKDIR /pb +RUN curl -fsSL "https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip" -o pb.zip \ + && unzip pb.zip \ + && rm pb.zip + +COPY pb_migrations ./pb_migrations +COPY entrypoint.sh ./ +RUN chmod +x entrypoint.sh pocketbase + +EXPOSE 8090 +CMD ["./entrypoint.sh"] diff --git a/pocketbase/entrypoint.sh b/pocketbase/entrypoint.sh new file mode 100644 index 0000000..c0eb76b --- /dev/null +++ b/pocketbase/entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -e + +# Optionally create/update the admin (superuser) account on boot. +# Set PB_SUPERUSER_EMAIL and PB_SUPERUSER_PASSWORD as env vars to enable. +# The password must be at least 8 characters. +if [ -n "$PB_SUPERUSER_EMAIL" ] && [ -n "$PB_SUPERUSER_PASSWORD" ]; then + ./pocketbase superuser upsert "$PB_SUPERUSER_EMAIL" "$PB_SUPERUSER_PASSWORD" || true +fi + +# `serve` automatically applies pending migrations from ./pb_migrations +exec ./pocketbase serve --http=0.0.0.0:8090 diff --git a/pocketbase/pb_migrations/1700000000_init.js b/pocketbase/pb_migrations/1700000000_init.js new file mode 100644 index 0000000..3a0be48 --- /dev/null +++ b/pocketbase/pb_migrations/1700000000_init.js @@ -0,0 +1,119 @@ +/** + * Initial PokeShare schema: extends the default `users` auth collection and + * creates `public_users`, `mycards` and `friendships` with their API rules. + * Mirrors the production schema (pb_schema.json export). + * Applied automatically by `pocketbase serve` on first boot. + */ +migrate((app) => { + // --- users (auth collection): extra profile fields, self-only access --- + const users = app.findCollectionByNameOrId("users"); + users.fields.add( + new Text({ name: "username", required: true, min: 3, max: 20 }), + new Select({ + name: "theme_preference", + values: ["System", "Light", "Dark"], + maxSelect: 1, + }), + new Select({ + name: "visibility", + values: ["Public", "Friends", "Private"], + maxSelect: 1, + }), + ); + users.indexes.push( + "CREATE UNIQUE INDEX idx_users_username ON users (username COLLATE NOCASE)", + ); + users.listRule = "id = @request.auth.id"; + users.viewRule = "id = @request.auth.id"; + users.updateRule = "id = @request.auth.id"; + // No public registration — accounts are created from the admin UI (/_/) + users.createRule = null; + app.save(users); + + // --- public_users: read-only view used by the friend search --- + const publicUsers = new Collection({ + name: "public_users", + type: "view", + viewQuery: `SELECT id, username, name, visibility FROM users WHERE visibility != 'Private'`, + listRule: '@request.auth.id != ""', + viewRule: null, + }); + app.save(publicUsers); + + // --- mycards: one row per owned card --- + // listRule enforces collection visibility at the database level: + // owner, or anyone if the owner is Public, or accepted friends if Friends. + const mycards = new Collection({ + name: "mycards", + type: "base", + fields: [ + { name: "card_id", type: "text" }, + { name: "card_name", type: "text" }, + { + name: "user", + type: "relation", + collectionId: "_pb_users_auth_", + maxSelect: 1, + cascadeDelete: true, + }, + { name: "card_image", type: "url" }, + { name: "variant", type: "text" }, + { name: "quantity", type: "number" }, + { name: "set_name", type: "text" }, + { name: "notes", type: "text" }, + { name: "condition", type: "text" }, + ], + listRule: + "@request.auth.id = user || (user.visibility = 'Public') || (user.visibility = 'Friends' && ((@collection.friendships.requester = user && @collection.friendships.addressee = @request.auth.id && @collection.friendships.status = 'accepted') || (@collection.friendships.addressee = user && @collection.friendships.requester = @request.auth.id && @collection.friendships.status = 'accepted')))", + viewRule: + "@request.auth.id = user || (@collection.friendships.requester = user && @collection.friendships.addressee = @request.auth.id && @collection.friendships.status = 'accepted') || (@collection.friendships.addressee = user && @collection.friendships.requester = @request.auth.id && @collection.friendships.status = 'accepted')", + createRule: '@request.auth.id != "" && user = @request.auth.id', + updateRule: '@request.auth.id != "" && user = @request.auth.id', + deleteRule: '@request.auth.id != "" && user = @request.auth.id', + }); + app.save(mycards); + + // --- friendships --- + const friendships = new Collection({ + name: "friendships", + type: "base", + fields: [ + { + name: "requester", + type: "relation", + collectionId: "_pb_users_auth_", + maxSelect: 1, + cascadeDelete: true, + }, + { + name: "addressee", + type: "relation", + collectionId: "_pb_users_auth_", + maxSelect: 1, + cascadeDelete: true, + }, + { + name: "status", + type: "select", + values: ["accepted", "pending", "declined"], + maxSelect: 1, + }, + ], + listRule: "@request.auth.id = requester || @request.auth.id = addressee", + viewRule: "@request.auth.id = requester || @request.auth.id = addressee", + createRule: "@request.auth.id = requester && addressee != requester", + updateRule: "@request.auth.id = addressee || @request.auth.id = requester", + deleteRule: "@request.auth.id = addressee || @request.auth.id = requester", + }); + app.save(friendships); +}, (app) => { + // Down: drop the custom collections. The added `users` fields are left in + // place — removing them would destroy data and they are harmless. + for (const name of ["friendships", "mycards", "public_users"]) { + try { + app.delete(app.findCollectionByNameOrId(name)); + } catch { + // already missing + } + } +}); diff --git a/pocketbase/pb_schema.json b/pocketbase/pb_schema.json new file mode 100644 index 0000000..3b12885 --- /dev/null +++ b/pocketbase/pb_schema.json @@ -0,0 +1,349 @@ +[ + { + "id": "pbc_274306238", + "listRule": "@request.auth.id = requester || @request.auth.id = addressee", + "viewRule": "@request.auth.id = requester || @request.auth.id = addressee", + "createRule": "@request.auth.id = requester && addressee != requester", + "updateRule": "@request.auth.id = addressee || @request.auth.id = requester", + "deleteRule": "@request.auth.id = addressee || @request.auth.id = requester", + "name": "friendships", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation1820765950", + "maxSelect": 0, + "minSelect": 0, + "name": "requester", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation2602483783", + "maxSelect": 0, + "minSelect": 0, + "name": "addressee", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "help": "", + "hidden": false, + "id": "select2063623452", + "maxSelect": 0, + "name": "status", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "accepted", + "pending", + "declined" + ] + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_143883864", + "listRule": "@request.auth.id = user || (user.visibility = 'Public') || (user.visibility = 'Friends' && ((@collection.friendships.requester = user && @collection.friendships.addressee = @request.auth.id && @collection.friendships.status = 'accepted') || (@collection.friendships.addressee = user && @collection.friendships.requester = @request.auth.id && @collection.friendships.status = 'accepted')))", + "viewRule": "@request.auth.id = user || (@collection.friendships.requester = user && @collection.friendships.addressee = @request.auth.id && @collection.friendships.status = 'accepted') || (@collection.friendships.addressee = user && @collection.friendships.requester = @request.auth.id && @collection.friendships.status = 'accepted')", + "createRule": "@request.auth.id != \"\" && user = @request.auth.id", + "updateRule": "@request.auth.id != \"\" && user = @request.auth.id", + "deleteRule": "@request.auth.id != \"\" && user = @request.auth.id", + "name": "mycards", + "type": "base", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text1254922784", + "max": 0, + "min": 0, + "name": "card_id", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text2987424932", + "max": 0, + "min": 0, + "name": "card_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "_pb_users_auth_", + "help": "", + "hidden": false, + "id": "relation1653163849", + "maxSelect": 0, + "minSelect": 0, + "name": "user", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "exceptDomains": null, + "help": "", + "hidden": false, + "id": "url4245288345", + "name": "card_image", + "onlyDomains": null, + "presentable": false, + "required": false, + "system": false, + "type": "url" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text4047749037", + "max": 0, + "min": 0, + "name": "variant", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "number2683508278", + "max": null, + "min": null, + "name": "quantity", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3420757135", + "max": 0, + "min": 0, + "name": "set_name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text18589324", + "max": 0, + "min": 0, + "name": "notes", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text849699619", + "max": 0, + "min": 0, + "name": "condition", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "indexes": [], + "system": false + }, + { + "id": "pbc_83919930", + "listRule": "@request.auth.id != \"\"", + "viewRule": null, + "createRule": null, + "updateRule": null, + "deleteRule": null, + "name": "public_users", + "type": "view", + "fields": [ + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "text3208210256", + "max": 0, + "min": 0, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "_clone_EOl9", + "max": 20, + "min": 3, + "name": "username", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "autogeneratePattern": "", + "help": "", + "hidden": false, + "id": "_clone_0z4L", + "max": 255, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "help": "", + "hidden": false, + "id": "_clone_3D0a", + "maxSelect": 0, + "name": "visibility", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "Public", + "Friends", + "Private" + ] + } + ], + "indexes": [], + "system": false, + "viewQuery": "SELECT id, username, name, visibility FROM users WHERE visibility != 'Private'" + } +] \ No newline at end of file