initial commit
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
---
|
||||
name: pocketbase-setup
|
||||
description: Deploy PocketBase (Firebase alternative) on a self-hosted Linux server — Docker, nginx reverse proxy with SSL, auth providers, and port conflict resolution.
|
||||
triggers:
|
||||
- "PocketBase"
|
||||
- "Firebase alternative"
|
||||
- "self-hosted auth"
|
||||
- "backend as a service"
|
||||
- "Google OAuth self-hosted"
|
||||
related_skills:
|
||||
- firebase-to-pocketbase-migration
|
||||
---
|
||||
|
||||
# PocketBase Self-Hosted Deployment
|
||||
|
||||
PocketBase is a single-binary Firebase alternative — auth (email/password + OAuth2), database, file storage, and realtime subscriptions. Deploy it in Docker behind nginx with existing SSL certs.
|
||||
|
||||
## Docker Setup
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
pocketbase:
|
||||
image: ghcr.io/muchobien/pocketbase:latest
|
||||
container_name: pocketbase
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:8091:8090"
|
||||
volumes:
|
||||
- ./pb_data:/pb_data # ⚠️ /pb_data, NOT /pb/pb_data — entrypoint uses --dir=/pb_data
|
||||
- ./pb_public:/pb_public # ⚠️ /pb_public, NOT /usr/local/bin/pb_public — entrypoint uses --publicDir=/pb_public
|
||||
healthcheck:
|
||||
test: wget --no-verbose --tries=1 --spider http://localhost:8090/api/health || exit 1
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
```
|
||||
|
||||
**Mount path pitfall:** The entrypoint command is `pocketbase serve --dir=/pb_data --publicDir=/pb_public`. Mounting to `/pb/pb_data` or `/usr/local/bin/pb_public` (the binary's default) silently fails — PocketBase writes to an empty volume that survives `down`, and superusers/auth look like they vanished.
|
||||
|
||||
**Restart doesn't pick up compose changes:** After editing `docker-compose.yml`, `docker compose restart` reuses the old container config. Always use `docker compose down && docker compose up -d` to pick up new volumes, ports, or env vars.
|
||||
|
||||
**Port conflict pitfall:** Choose an INTERNAL host port carefully. On our server, port 8090 was taken by Pi-hole (`0.0.0.0:8090->80/tcp`). Mapping `127.0.0.1:8091:8090` avoids the conflict — nginx proxies to `:8091`, PocketBase listens on `:8090` internally. Always check with `ss -tlnp | grep :8090` before deploying.
|
||||
|
||||
## nginx Reverse Proxy (with WebSocket)
|
||||
|
||||
PocketBase needs WebSocket support for realtime subscriptions. Use an unused SSL port:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 3447 ssl;
|
||||
server_name grajmedia.duckdns.org;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/grajmedia.duckdns.org/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/grajmedia.duckdns.org/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8091;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
```
|
||||
|
||||
Symlink into `sites-enabled/`, run `nginx -t && systemctl reload nginx`.
|
||||
|
||||
**Port conflict pitfall:** nginx binds to the SSL port on 0.0.0.0. Docker must NOT also expose that same port (even on 127.0.0.1) — nginx will fail to bind with "Address already in use". Docker gets an internal port (e.g., 8091), nginx owns the SSL port (e.g., 3447).
|
||||
|
||||
## Superuser Password Recovery
|
||||
|
||||
If the superuser password is lost and admin API authentication fails with `"Failed to authenticate."` (HTTP 400), the password can be reset directly via the SQLite database:
|
||||
|
||||
```bash
|
||||
cd /path/to/pocketbase
|
||||
docker compose stop pocketbase
|
||||
|
||||
# Remove stale WAL/SHM files and make DB writable
|
||||
rm -f pb_data/data.db-shm pb_data/data.db-wal
|
||||
chmod 666 pb_data/data.db
|
||||
|
||||
# Generate a bcrypt hash for the new password
|
||||
python3 -c "
|
||||
import bcrypt, sqlite3
|
||||
hashed = bcrypt.hashpw(b'NewPassword123!', bcrypt.gensalt(rounds=10)).decode()
|
||||
conn = sqlite3.connect('pb_data/data.db')
|
||||
conn.execute('UPDATE _superusers SET password=? WHERE email=?', (hashed, 'admin@example.com'))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print('Password updated')
|
||||
"
|
||||
|
||||
# Restore permissions
|
||||
chmod 644 pb_data/data.db
|
||||
|
||||
# Start PocketBase
|
||||
docker compose start pocketbase
|
||||
```
|
||||
|
||||
**Note:** Python's `bcrypt` library uses the `$2b$` prefix while PocketBase generates `$2a$` — both are accepted by PocketBase v0.23+.
|
||||
|
||||
After recovery, verify:
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:8091/api/collections/_superusers/auth-with-password \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"identity":"admin@example.com","password":"NewPassword123!"}' \
|
||||
| python3 -c "import sys,json; d=json.load(sys.stdin); print('Token OK' if 'token' in d else 'FAILED')"
|
||||
```
|
||||
|
||||
Create the admin account via CLI (faster than the UI):
|
||||
|
||||
```bash
|
||||
docker exec pocketbase /usr/local/bin/pocketbase superuser upsert admin@example.com 'Str0ngPass!' --dir=/pb_data
|
||||
```
|
||||
|
||||
The `--dir=/pb_data` flag is **critical** — without it, the `upsert` command writes to a different default directory and auth against the running server fails silently.
|
||||
|
||||
After admin is created, authenticate programmatically via:
|
||||
```bash
|
||||
# v0.22+ endpoint — NOT /api/admins/auth-with-password
|
||||
curl -s -X POST http://127.0.0.1:8091/api/collections/_superusers/auth-with-password \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"identity":"admin@example.com","password":"..."}'
|
||||
```
|
||||
|
||||
The old `/api/admins/auth-with-password` endpoint was removed in PocketBase v0.22+.
|
||||
|
||||
## Auth Provider Configuration
|
||||
|
||||
The `users` auth collection uses the internal system ID `_pb_users_auth_` — NOT the name `users`. All updates use `PATCH`, not `POST`.
|
||||
|
||||
### Email/Password (built-in)
|
||||
|
||||
Enable via API:
|
||||
```bash
|
||||
TOKEN=*** -s -X POST http://127.0.0.1:8091/api/collections/_superusers/auth-with-password \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"identity":"admin@example.com","password":"..."}' | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
|
||||
|
||||
curl -s -X PATCH http://127.0.0.1:8091/api/collections/_pb_users_auth_ \
|
||||
-H "Authorization: $TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"passwordAuth":{"enabled":true,"minLength":8,"identityFields":["email"]}}'
|
||||
```
|
||||
|
||||
### Google OAuth
|
||||
|
||||
1. Create a project in [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. APIs & Services → Credentials → Create OAuth 2.0 Client ID → Web application
|
||||
3. Add redirect URI: `https://<domain>:<port>/api/oauth2-redirect`
|
||||
4. Enable via API:
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH http://127.0.0.1:8091/api/collections/_pb_users_auth_ \
|
||||
-H "Authorization: $TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"oauth2":{"enabled":true,"providers":{"google":{"enabled":true,"clientId":"YOUR_ID","clientSecret":"YOUR_SECRET"}}}}'
|
||||
```
|
||||
|
||||
PocketBase handles the OAuth flow itself. No custom callback page needed — the SDK's `authWithOAuth2()` works after the provider is configured.
|
||||
|
||||
### Collection rules (API access control)
|
||||
|
||||
```bash
|
||||
curl -s -X PATCH http://127.0.0.1:8091/api/collections/_pb_users_auth_ \
|
||||
-H "Authorization: $TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"listRule":"","viewRule":"","createRule":"",
|
||||
"updateRule":"id = @request.auth.id",
|
||||
"deleteRule":"id = @request.auth.id"
|
||||
}'
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
### Firebase Migration
|
||||
|
||||
If you're migrating an existing Firebase app, see the `firebase-to-pocketbase-migration` skill — it provides a Firebase-compatible adapter (`pocketbase.js`) that lets you swap one import per file without rewriting your app, plus a same-origin nginx config to eliminate CORS.
|
||||
|
||||
### Same-Origin App Hosting
|
||||
|
||||
When deploying a JavaScript app (Flutter Web, React, vanilla JS) that uses PocketBase, the cleanest approach is same-origin nginx: serve the static app AND proxy `/api/` to PocketBase from the same server block. The app calls `new PocketBase(window.location.origin)` and all API calls go to `/api/collections/...` on the same port — zero CORS configuration needed.
|
||||
|
||||
Use `templates/same-origin-nginx.conf` from `firebase-to-pocketbase-migration` as the template. Replace the generic PocketBase proxy with:
|
||||
```nginx
|
||||
root /path/to/your/app;
|
||||
index index.html;
|
||||
|
||||
location / { try_files $uri $uri/ /index.html; }
|
||||
location /api/ { proxy_pass http://127.0.0.1:8091; }
|
||||
location /_/ { proxy_pass http://127.0.0.1:8091; }
|
||||
```
|
||||
|
||||
|
||||
### Static login page
|
||||
A ready-to-use login page with Google OAuth + email/password is at `templates/login.html`. Copy it to `pb_public/index.html` — PocketBase serves it at the root `/`. It uses the PocketBase JS SDK from CDN and initializes with `new PocketBase(window.location.origin)`. No build step needed.
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl -s http://127.0.0.1:8091/api/health
|
||||
|
||||
# Via nginx (external URL)
|
||||
curl -sk --resolve <domain>:<port>:127.0.0.1 https://<domain>:<port>/api/health
|
||||
```
|
||||
|
||||
## Production Hardening
|
||||
|
||||
### Log Rotation (Docker json-file driver)
|
||||
|
||||
By default the PB container uses `json-file` logs with no size cap. Add a `logging` block to `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
```
|
||||
|
||||
Then recreate: `docker compose up -d --force-recreate`. Verify: `docker inspect pocketbase --format '{{.HostConfig.LogConfig.Type}} {{json .HostConfig.LogConfig.Config}}'`.
|
||||
|
||||
**Pitfall:** `docker update --log-driver` does NOT support changing the log driver on a running container. You must recreate via `docker compose up -d --force-recreate`.
|
||||
|
||||
### Daily DB Backup Cron
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# /home/ray/docker/pocketbase/backup.sh
|
||||
PB_CID=$(docker ps --filter "name=pocketbase" --format "{{.ID}}")
|
||||
STAMP=$(date +%Y%m%d)
|
||||
docker exec "$PB_CID" cp /pb_data/data.db "/pb_data/data.db.backup-$STAMP"
|
||||
docker cp "$PB_CID:/pb_data/data.db.backup-$STAMP" "/backups/spq/"
|
||||
docker exec "$PB_CID" rm "/pb_data/data.db.backup-$STAMP"
|
||||
find /backups/spq/ -name "data.db.backup-*" -mtime +14 -delete
|
||||
```
|
||||
|
||||
Crontab (root): `0 3 * * * /home/ray/docker/pocketbase/backup.sh >> /var/log/spq/backup.log 2>&1`
|
||||
|
||||
The script dynamically finds the container ID (it changes on recreate), copies the DB to `/backups/spq/`, and purges backups older than 14 days.
|
||||
|
||||
### Structured nginx Access Logs (JSON)
|
||||
|
||||
Add a `log_format` to the `http {}` block in `/etc/nginx/nginx.conf`:
|
||||
|
||||
```nginx
|
||||
log_format json_combined escape=json '{ "time":"$time_iso8601", "remote":"$remote_addr", "method":"$request_method", "uri":"$uri", "status":$status, "bytes":$body_bytes_sent, "rt":$request_time }';
|
||||
```
|
||||
|
||||
Then in the site config: `access_log /var/log/spq/spq.access.log json_combined;`
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Mount paths must match entrypoint flags.** The image runs `serve --dir=/pb_data --publicDir=/pb_public`. Mount to `/pb_data` (not `/pb/pb_data`) and `/pb_public` (not `/usr/local/bin/pb_public`). Wrong paths cause silent data loss — database writes go to an empty volume.
|
||||
- **`docker compose restart` ignores compose file changes.** After editing volumes, ports, or env vars, use `docker compose down && docker compose up -d`. `restart` reuses the old container config.
|
||||
- **`pocketbase superuser upsert` needs `--dir` flag.** Default CWD is not `/pb_data`. Always pass `--dir=/pb_data` when running inside the container.
|
||||
- **Auth API is `_superusers`, not `admins`.** PocketBase v0.22+ uses `POST /api/collections/_superusers/auth-with-password`. The old `/api/admins/auth-with-password` returns 404.
|
||||
- **Users collection is `_pb_users_auth_` internally.** Use `PATCH /api/collections/_pb_users_auth_` (NOT `POST /api/collections/users`). POST to `users` works for initial creation; subsequent config changes need PATCH to the system ID.
|
||||
- **Port conflicts are easy to miss.** Pi-hole often uses 8090 (host:container 80 mapping). Always `ss -tlnp | grep :8090` before deploying. Map to a different host port (e.g., 8091) and proxy via nginx.
|
||||
- **nginx needs WebSocket headers.** Without `Upgrade` and `Connection` headers, PocketBase realtime silently fails.
|
||||
- **Docker volume persists across rebuilds.** `docker compose down` doesn't remove `./pb_data/`. Safe to rebuild; admin account survives.
|
||||
- **Admin auth leaks into same-origin apps.** Logging into the PocketBase Admin UI (`/_/`) stores the superuser token in localStorage for the origin. Any JavaScript app on the same origin that uses `pb.authStore.isValid` will see a valid session and auto-authenticate as admin. Fix: in the PocketBase SDK adapter, check `model.collectionName === 'users'` before returning a user. Reject `_superusers` model types. This prevents admin sessions from bypassing the app's login page.
|
||||
|
||||
- **Cached user tokens also bypass login.** After fixing the admin auth leak, the login page may still be skipped. The PocketBase SDK auto-restores ANY valid token from localStorage at initialization — including tokens from a previous `test@` or real user login. The adapter's admin filtering only rejects `_superusers`; a valid `users` collection token passes right through. When the user reports the login page not appearing after the admin fix, they likely have a stale user token. Fix: instruct them to clear site data (DevTools → Application → Clear storage), or add a manual sign-out that calls `pb.authStore.clear()`. Incognito mode is a quick test.
|
||||
- **Shell token redaction.** When using `TOKEN=*** ... | python3 ...)` in bash, the token value may be redacted as `***` by secret filtering. Use `execute_code` with Python's `urllib` to pass tokens between API calls safely.
|
||||
|
||||
- **Collection schema API key changed in v0.23+.** PocketBase v0.22 and earlier used `"schema"` as the key for field definitions in API calls. v0.23+ changed this to `"fields"`. When scripting collection creation, check your version first: `docker exec pocketbase /usr/local/bin/pocketbase --version`. If ≥0.23, use `"fields"` not `"schema"`. The `firebase-to-pocketbase-migration` skill covers both formats.
|
||||
|
||||
- **PocketBase silently drops undefined fields.** Creating/updating records with fields not defined in the collection schema returns HTTP 200 but stores nothing for those fields — no error, no warning. Define all fields before any CRUD operations. See `references/add-fields-to-collection.md` for the PATCH pattern to add fields to existing collections.
|
||||
|
||||
- **`created`/`updated` system fields not present on collections created via API.** When collections are created programmatically (e.g., via `createCollection` or migration scripts), PocketBase may omit the `created` and `updated` autodate system fields. Any query using `sort: '-created'` or `sort: '-updated'` returns HTTP 400 `"Something went wrong while processing your request."` — a misleading error with no field name. **Diagnosis:** test with `sort: '-id'` first. If that works, the collection is missing system fields. **Fix:** either add the fields via admin or use `sort: '-id'` which is always available. Check ALL pages that query the collection — Dashboard, FinancialDashboard, RepairOrders, Invoices were all affected in one project by this single missing field.
|
||||
|
||||
- **SPA same-origin nginx with `/pb` SDK prefix.** When a React/Vite SPA uses PocketBase SDK with `PB_URL = '/pb'` (common default), the nginx config must handle BOTH SPA fallback AND the `/pb/` proxy. Standard pattern:
|
||||
```nginx
|
||||
root /path/to/spa/dist;
|
||||
index index.html;
|
||||
|
||||
# SPA — all client-side routes serve index.html
|
||||
location / { try_files $uri $uri/ /index.html; }
|
||||
|
||||
# PocketBase SDK proxy — SDK calls /pb/api/collections/...
|
||||
location /pb/ { proxy_pass http://127.0.0.1:8091/; }
|
||||
|
||||
# Also keep direct /api/ proxy for non-SDK clients
|
||||
location /api/ { proxy_pass http://127.0.0.1:8091/api/; }
|
||||
```
|
||||
Without the `/pb/` location, PocketBase SDK calls fail because the SPA fallback returns `index.html` instead of proxying to PocketBase.
|
||||
|
||||
- **User sign-up via PocketBase SDK.** The `pb.collection('users').create()` method accepts `{email, password, passwordConfirm, name, emailVisibility: true}`. PocketBase queues a verification email automatically when SMTP is configured in admin. Without SMTP, accounts are created but `verified: false` — users can still log in if the collection's auth rules allow it. Test sign-up through the nginx proxy, not just direct API calls — the `/pb/` location must be configured for the SDK to reach PocketBase.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Adding Fields to an Existing PocketBase Collection
|
||||
|
||||
PocketBase silently drops fields not in the collection schema — no HTTP error, no warning, just data loss. If your app writes fields that PocketBase ignores, you need to add them to the schema.
|
||||
|
||||
## Detection
|
||||
|
||||
Signs that PocketBase is dropping fields:
|
||||
- Code writes a field (e.g., `recommendation`, `explanation`) via `addDoc()` or `updateDoc()`
|
||||
- The record is created/updated with HTTP 200
|
||||
- But re-reading the record shows the field as `undefined`/missing
|
||||
- The field works fine in Firestore but not PocketBase
|
||||
|
||||
## Fix: PATCH the Collection Schema
|
||||
|
||||
Adding fields requires sending the COMPLETE fields array (existing + new) via PATCH:
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""Add fields to existing PocketBase collection"""
|
||||
import json, urllib.request, ssl
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
BASE = "http://localhost:8091"
|
||||
COLLECTION_ID = "pbc_863811952" # from GET /api/collections
|
||||
|
||||
# Step 1: Auth as superuser
|
||||
auth_resp = json.loads(urllib.request.urlopen(
|
||||
urllib.request.Request(f"{BASE}/api/collections/_superusers/auth-with-password",
|
||||
data=json.dumps({"identity": "admin@example.com", "password": "..."}).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST"), context=ctx).read())
|
||||
token = auth_resp["token"]
|
||||
|
||||
# Step 2: Get current fields
|
||||
collections = json.loads(urllib.request.urlopen(
|
||||
urllib.request.Request(f"{BASE}/api/collections",
|
||||
headers={"Authorization": f"Bearer {token}"}), context=ctx).read())
|
||||
svc = next(c for c in collections["items"] if c["id"] == COLLECTION_ID)
|
||||
existing_fields = svc["fields"]
|
||||
|
||||
# Step 3: Build new fields to add
|
||||
new_fields = [
|
||||
{"autogeneratePattern":"","hidden":False,"id":"text_new_1","max":0,"min":0,
|
||||
"name":"explanation","pattern":"","presentable":False,"primaryKey":False,
|
||||
"required":False,"system":False,"type":"text"},
|
||||
{"autogeneratePattern":"","hidden":False,"id":"text_new_2","max":0,"min":0,
|
||||
"name":"recommendation","pattern":"","presentable":False,"primaryKey":False,
|
||||
"required":False,"system":False,"type":"text"},
|
||||
]
|
||||
|
||||
# Step 4: PATCH with complete fields array (existing + new)
|
||||
all_fields = existing_fields + new_fields
|
||||
result = json.loads(urllib.request.urlopen(
|
||||
urllib.request.Request(f"{BASE}/api/collections/{COLLECTION_ID}",
|
||||
data=json.dumps({"fields": all_fields}).encode(),
|
||||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||
method="PATCH"), context=ctx).read())
|
||||
print(f"Updated fields: {[f['name'] for f in result['fields']]}")
|
||||
```
|
||||
|
||||
## Critical Requirements
|
||||
|
||||
1. **Send ALL fields, not just new ones.** PATCH replaces the entire `fields` array. Sending only the new fields will DELETE all existing fields and break the collection.
|
||||
|
||||
2. **Each field needs a unique `id` string.** Use descriptive strings like `text_exp_001` — they just need to be unique within the collection.
|
||||
|
||||
3. **Field types matter.** `text` for strings, `number` for numbers, `bool` for booleans. Use `text` for most fields to be safe.
|
||||
|
||||
4. **No `system:true` for custom fields.** Only the built-in `id` field should have `system:true`.
|
||||
|
||||
## Never Do This
|
||||
|
||||
- **Never edit the SQLite database directly.** Use the REST API. Direct SQLite edits can corrupt field ID tracking and break the collection irreversibly.
|
||||
- **Never use POST to update an existing collection.** POST creates a new collection; PATCH updates an existing one.
|
||||
- **Don't forget to include the `id` field.** The system `id` field must remain in the array.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Adding Fields to PocketBase Collection Schema
|
||||
|
||||
PocketBase silently drops fields not defined in the collection schema — HTTP 200, no error, data just disappears. When you add new fields to the data model, you MUST also add them to the PocketBase schema via API.
|
||||
|
||||
## The problem
|
||||
|
||||
Services saved with `recommendation` and `explanation` fields appeared to save successfully, but the fields were missing when read back. Root cause: the `services` collection schema only had `name`, `price`, `description`, `category`, `duration` — unknown fields were silently stripped on create/update.
|
||||
|
||||
## Detection
|
||||
|
||||
Query the collection schema to see current fields:
|
||||
|
||||
```python
|
||||
# Auth + list collections
|
||||
auth = api("POST", "/api/collections/_superusers/auth-with-password", {
|
||||
"identity": "admin@example.com",
|
||||
"password": "password"
|
||||
})
|
||||
token = auth["token"]
|
||||
cols = api("GET", "/api/collections", token=token)
|
||||
svc = next(c for c in cols["items"] if c["name"] == "services")
|
||||
print([f["name"] for f in svc["fields"]])
|
||||
# ['id', 'userId', 'name', 'description', 'price', 'category', ...]
|
||||
```
|
||||
|
||||
## Fix: PATCH the collection
|
||||
|
||||
Add missing fields by PATCHing the complete fields array (including existing fields):
|
||||
|
||||
```python
|
||||
existing_fields = svc["fields"]
|
||||
new_fields = [
|
||||
{"autogeneratePattern":"","hidden":False,"id":"text_rec_new","max":0,"min":0,
|
||||
"name":"recommendation","pattern":"","presentable":False,"primaryKey":False,
|
||||
"required":False,"system":False,"type":"text"},
|
||||
{"autogeneratePattern":"","hidden":False,"id":"text_exp_new","max":0,"min":0,
|
||||
"name":"explanation","pattern":"","presentable":False,"primaryKey":False,
|
||||
"required":False,"system":False,"type":"text"},
|
||||
]
|
||||
result = api("PATCH", f"/api/collections/{svc['id']}",
|
||||
{"fields": existing_fields + new_fields}, token=token)
|
||||
```
|
||||
|
||||
**Important**: You must send the COMPLETE fields array, not just the new fields. PocketBase replaces the entire schema. Each field needs a unique `id` string — use descriptive names like `text_rec_001` or UUIDs.
|
||||
|
||||
## Full script
|
||||
|
||||
See `scripts/pb_add_fields.py` for a reusable script that auths, lists collections, and adds specified fields.
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add fields to PocketBase collection schema. PocketBase silently drops fields
|
||||
not in schema — you MUST add them via API when extending the data model.
|
||||
|
||||
Usage: Edit FIELDS_TO_ADD and COLLECTION_NAME below, then run.
|
||||
"""
|
||||
import json, urllib.request, ssl
|
||||
|
||||
# === CONFIG ===
|
||||
PB_URL = "http://localhost:8091"
|
||||
SUPERUSER_EMAIL = "admin@example.com"
|
||||
SUPERUSER_PASSWORD = "password"
|
||||
COLLECTION_NAME = "services" # Change this
|
||||
FIELDS_TO_ADD = [
|
||||
# Example: add a text field
|
||||
{"name": "recommendation", "type": "text"},
|
||||
{"name": "explanation", "type": "text"},
|
||||
# For other field types, see PocketBase API docs
|
||||
# {"name": "duration", "type": "number", "onlyInt": False},
|
||||
]
|
||||
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
def api(method, path, data=None, token=None):
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
body = json.dumps(data).encode() if data else None
|
||||
req = urllib.request.Request(f"{PB_URL}{path}", data=body, headers=headers, method=method)
|
||||
resp = urllib.request.urlopen(req, context=ctx)
|
||||
return json.loads(resp.read())
|
||||
|
||||
# Step 1: Auth
|
||||
auth_resp = api("POST", "/api/collections/_superusers/auth-with-password", {
|
||||
"identity": SUPERUSER_EMAIL,
|
||||
"password": SUPERUSER_PASSWORD
|
||||
})
|
||||
token = auth_resp["token"]
|
||||
|
||||
# Step 2: Get collection to see current fields
|
||||
collections = api("GET", "/api/collections", token=token)
|
||||
try:
|
||||
col = next(c for c in collections["items"] if c["name"] == COLLECTION_NAME)
|
||||
except StopIteration:
|
||||
print(f"Collection '{COLLECTION_NAME}' not found. Available:",
|
||||
[c["name"] for c in collections["items"]])
|
||||
exit(1)
|
||||
|
||||
print(f"Collection: {col['name']} (id: {col['id']})")
|
||||
print(f"Current fields: {[f['name'] for f in col['fields']]}")
|
||||
|
||||
# Step 3: Build new fields with unique IDs
|
||||
existing_fields = col["fields"]
|
||||
existing_names = {f["name"] for f in existing_fields}
|
||||
new_field_defs = []
|
||||
|
||||
for f in FIELDS_TO_ADD:
|
||||
if f["name"] in existing_names:
|
||||
print(f"Field '{f['name']}' already exists, skipping")
|
||||
continue
|
||||
|
||||
field_type = f.get("type", "text")
|
||||
field_def = {
|
||||
"autogeneratePattern": "",
|
||||
"hidden": False,
|
||||
"id": f"field_{f['name']}_{len(existing_fields) + len(new_field_defs)}",
|
||||
"max": f.get("max", 0),
|
||||
"min": f.get("min", 0),
|
||||
"name": f["name"],
|
||||
"pattern": f.get("pattern", ""),
|
||||
"presentable": False,
|
||||
"primaryKey": False,
|
||||
"required": f.get("required", False),
|
||||
"system": False,
|
||||
"type": field_type,
|
||||
}
|
||||
if field_type == "number":
|
||||
field_def["onlyInt"] = f.get("onlyInt", False)
|
||||
new_field_defs.append(field_def)
|
||||
|
||||
if not new_field_defs:
|
||||
print("No new fields to add.")
|
||||
exit(0)
|
||||
|
||||
# Step 4: PATCH with complete fields array
|
||||
all_fields = existing_fields + new_field_defs
|
||||
result = api("PATCH", f"/api/collections/{col['id']}", {"fields": all_fields}, token=token)
|
||||
print(f"Updated fields: {[f['name'] for f in result['fields']]}")
|
||||
print(f"✅ Added {len(new_field_defs)} field(s) to {COLLECTION_NAME}")
|
||||
@@ -0,0 +1,77 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login — grajmedia</title>
|
||||
<script src="https://unpkg.com/pocketbase@0.21.5/dist/pocketbase.umd.js"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0d1117; color: #e6edf3;
|
||||
display: flex; align-items: center; justify-content: center; min-height: 100vh;
|
||||
}
|
||||
.card { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 40px; width: 100%; max-width: 400px; }
|
||||
.card h1 { font-size: 24px; margin-bottom: 8px; text-align: center; }
|
||||
.card p.subtitle { color: #8b949e; text-align: center; margin-bottom: 28px; font-size: 14px; }
|
||||
.divider { display: flex; align-items: center; margin: 24px 0; color: #484f58; font-size: 13px; }
|
||||
.divider::before, .divider::after { content: ''; flex: 1; border-bottom: 1px solid #30363d; }
|
||||
.divider span { padding: 0 16px; }
|
||||
.btn { display: block; width: 100%; padding: 12px; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.2s; text-align: center; }
|
||||
.btn-google { background: #fff; color: #24292f; border-color: #d0d7de; }
|
||||
.btn-google:hover { background: #f3f4f6; }
|
||||
.btn-primary { background: #238636; color: #fff; border-color: #2ea043; margin-top: 16px; }
|
||||
.btn-primary:hover { background: #2ea043; }
|
||||
.field { margin-bottom: 16px; }
|
||||
.field label { display: block; font-size: 13px; color: #8b949e; margin-bottom: 6px; }
|
||||
.field input { width: 100%; padding: 10px 12px; border-radius: 6px; border: 1px solid #30363d; background: #0d1117; color: #e6edf3; font-size: 14px; }
|
||||
.field input:focus { outline: none; border-color: #58a6ff; box-shadow: 0 0 0 2px rgba(88,166,255,0.3); }
|
||||
.error { background: #490202; border: 1px solid #f85149; color: #f85149; padding: 10px; border-radius: 6px; margin-bottom: 16px; font-size: 13px; display: none; }
|
||||
.error.show { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div id="login-form">
|
||||
<h1>🔐 grajmedia</h1>
|
||||
<p class="subtitle">Sign in to continue</p>
|
||||
<div class="error" id="error"></div>
|
||||
|
||||
<!-- Google OAuth -->
|
||||
<button class="btn btn-google" onclick="loginGoogle()">Sign in with Google</button>
|
||||
<div class="divider"><span>or</span></div>
|
||||
|
||||
<!-- Email / Password -->
|
||||
<div class="field"><label>Email</label><input type="email" id="email" placeholder="you@example.com"></div>
|
||||
<div class="field"><label>Password</label><input type="password" id="password" placeholder="••••••••"></div>
|
||||
<button class="btn btn-primary" onclick="loginPassword()">Sign in with Email</button>
|
||||
<div style="text-align:center;margin-top:20px;">
|
||||
<a href="#" style="color:#58a6ff;font-size:13px;" onclick="showRegister()">Create account</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const pb = new PocketBase(window.location.origin);
|
||||
|
||||
function showError(msg) { const e = document.getElementById('error'); e.textContent = msg; e.classList.add('show'); setTimeout(() => e.classList.remove('show'), 5000); }
|
||||
|
||||
async function loginGoogle() {
|
||||
try {
|
||||
await pb.collection('users').authWithOAuth2({ provider: 'google' });
|
||||
window.location.href = '/index.html';
|
||||
} catch (err) { showError('Google login failed: ' + err.message); }
|
||||
}
|
||||
|
||||
async function loginPassword() {
|
||||
try {
|
||||
const email = document.getElementById('email').value;
|
||||
const password = document.getElementById('password').value;
|
||||
await pb.collection('users').authWithPassword(email, password);
|
||||
window.location.href = '/index.html';
|
||||
} catch (err) { showError(err.message); }
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user