Baseline: wave 1A server complete, wave 1B Android scaffold + design system green
assembleDebug, testDebugUnitTest, and recordPaparazziDebug all pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016bThmkmyUUdqQpy3MXFFe5
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
# Superuser credentials for the dev instance — never commit these.
|
||||
.dev-credentials
|
||||
|
||||
# The PocketBase binary is platform-specific and large; fetch/build it
|
||||
# per docs/SPEC.md and deploy/README.md instead of vendoring it in git.
|
||||
pocketbase
|
||||
pocketbase.exe
|
||||
|
||||
# Runtime data — the whole point of pb_migrations/ is that this is
|
||||
# reproducible from scratch; the actual database/uploads are not source.
|
||||
pb_data/
|
||||
|
||||
# Local backup artifacts produced by deploy/backup.sh.
|
||||
deploy/backups/
|
||||
@@ -0,0 +1,56 @@
|
||||
## v0.40.2
|
||||
|
||||
- Return an error when filter params fallback fails to json serialize and optimized params replacement to execute in a single pass.
|
||||
|
||||
- Fixed collection index parsing error for indexes with missing name.
|
||||
|
||||
- Minor UI autocomplete optimizations _(prefix match, autocomplete debounce, etc.)_.
|
||||
|
||||
- Fixed linter warnings and comment typos.
|
||||
|
||||
- Bumped goja and its related dependencies _(regex unescaped dash error fix and base64 optimizations)_.
|
||||
|
||||
- Bumped the min Go GitHub action version to 1.27.1 as it includes some [minor `database/sql` and `enconding/json/v2` bug fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.27.1).
|
||||
|
||||
|
||||
## v0.40.1
|
||||
|
||||
- Fixes for some reported regressions related to the `encoding/json/v2` update:
|
||||
- allow mangling invalid UTF8 characters when serializing json data ([#7814](https://github.com/pocketbase/pocketbase/issues/7814))
|
||||
- fixed OAuth2 providers config merge incorrectly replacing the entire slice ([#7815](https://github.com/pocketbase/pocketbase/issues/7815))
|
||||
|
||||
|
||||
## v0.40.0
|
||||
|
||||
- Propagate console command errors and recovered panics to `app.Start()` so that the program can exit with non-zero code while still ensuring that `app.OnTerminate` hook was triggered _(responsible for the app graceful shutdown handling)_.
|
||||
_⚠️ Note that this could be a slight breaking change in case you are chaining PocketBase commands and relied on the previous `0` exit status for `Command.RunE` returned errors._
|
||||
_Or in other words, if you have `./pocketbase invalid && someothercommand` and previously relied that `someothercommand` will be always executed then this is no longer the case and you'll have to adjust it or replace `&&` with `;`._
|
||||
|
||||
- Added quotes around the default `Content-Disposition` serving filename in case custom name with special characters is provided.
|
||||
|
||||
- Added `Cross-Origin-Opener-Policy:same-origin` to the default security response headers.
|
||||
_This is an extra precaution to prevent tab-nabbing in case custom UI plugins use `target="_blank"` without `rel="noopener"`._
|
||||
|
||||
- Added `Record.GetInt64(field)` helper (note that the serializable max safe integer of the `number` field is ~2^53-1).
|
||||
|
||||
- Added `Store.Keys()` method that returns a slice with all of the store keys.
|
||||
|
||||
- Added new `DELETE /api/logs` endpoint and UI control to delete all logs without changing the `maxDays` retention setting.
|
||||
|
||||
- Added new log settings option to limit the max `Log.Data` size that will be saved in the database (default to ~16KB).
|
||||
_This is an extra precaution for the cases when logging user supplied data without validating it beforehand._
|
||||
_If the resulting `Log.Data` json is above the limit, it is truncated to the last valid decoded character and an extra `"__pb_truncated__":true` log data entry will be added.`_
|
||||
_Additionally, for just in case the log message is also truncated at max 8k characters._
|
||||
|
||||
- Added new `filesystem` low-level helper methods:
|
||||
- `filesystem.NewWriter(key, opts)` to allow direct file create from an `io.Reader` value.
|
||||
- `filesystem.OnNewWriter()` hook to allow listening for new/to-be-created files _(it is not exposed in `core.App` instance for now to avoid introducing breaking changes)_.
|
||||
- `filesystem.OnDelete()` hook to allow listening for deleted files _(it is not exposed in `core.App` instance for now to avoid introducing breaking changes)_.
|
||||
|
||||
- Optimized backups to no longer transaction lock the database during backup generation ([#7799](https://github.com/pocketbase/pocketbase/discussions/7799#discussioncomment-18108244)).
|
||||
|
||||
- Updated `modernc.org/sqlite` to 1.57.0 and registered by default the new `_defensive=1` DSN query parameter to enable [SQLite's defensive mode](https://sqlite.org/c3ref/c_dbconfig_defensive.html#sqlitedbconfigdefensive).
|
||||
|
||||
- Bumped the min Go version to 1.27.0 and migrated to the new `encoding/json/v2` package.
|
||||
_⚠️ Please note that Go 1.27.0 retrofitted `encoding/json` to use the v2 package under the hood but unfortunately is not fully backward compatible._
|
||||
_I recommend to not push blindly an update on production and to test your PocketBase application first locally to see if everything works correctly._
|
||||
@@ -0,0 +1,17 @@
|
||||
The MIT License (MIT)
|
||||
Copyright (c) 2022 - present, Gani Georgiev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||
and associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
||||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or
|
||||
substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
||||
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Bookshelf server
|
||||
|
||||
PocketBase backend for the Bookshelf app — see `../docs/SPEC.md` for the
|
||||
authoritative data model and API rules. This directory contains everything
|
||||
needed to provision and run it.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
server/
|
||||
pocketbase PocketBase v0.40.2 binary (gitignored — platform-specific)
|
||||
pb_data/ SQLite databases + uploaded files (gitignored — runtime state)
|
||||
pb_migrations/ Schema history, auto-captured — a fresh instance replays
|
||||
these on first boot and ends up with the full schema
|
||||
pb_hooks/ JS hook that closes a PocketBase quirk (see below)
|
||||
setup-schema.sh Idempotent schema/rules provisioning (curl + jq only)
|
||||
create-user.sh Superuser-driven account creation (no public sign-up)
|
||||
deploy/ systemd unit, Docker, backups, remote-access guidance
|
||||
```
|
||||
|
||||
## Quickstart (local dev)
|
||||
|
||||
PocketBase must already be running (see `deploy/README.md` for how to run
|
||||
it as a proper service; for a quick local check you can just run the binary
|
||||
directly: `./pocketbase serve`).
|
||||
|
||||
```sh
|
||||
# 1. Provision the schema + API rules (safe to re-run).
|
||||
./setup-schema.sh http://127.0.0.1:8090 <superuser-email> <superuser-password>
|
||||
|
||||
# 2. Create app accounts — there is no self-registration.
|
||||
./create-user.sh you@example.com "a strong password" "Your Name"
|
||||
```
|
||||
|
||||
Both scripts also read `PB_URL`/`PB_EMAIL`/`PB_PASS` from the environment,
|
||||
or fall back to `.dev-credentials` (gitignored, dev-instance-only — see
|
||||
`.dev-credentials` in this directory if present) if no args are given.
|
||||
|
||||
## Schema summary
|
||||
|
||||
Three collections — `bookcases`, `shelves`, `books` — each requiring
|
||||
authentication for every action (list/view/create/update/delete). Deletion
|
||||
in the app is always a soft `deleted` flag (tombstone), never an actual
|
||||
record delete, so sync can propagate it — see `SyncEngine` in the Android
|
||||
app and `docs/SPEC.md`'s Sync design section.
|
||||
|
||||
The built-in `users` collection is locked down: `createRule = null` means
|
||||
**only a superuser can create an account** (via `create-user.sh`); regular
|
||||
users can view any user (needed to resolve the `added_by` relation) and
|
||||
update only their own record.
|
||||
|
||||
## The pb_hooks quirk
|
||||
|
||||
PocketBase's declarative list/search rule acts as a row-level SQL filter,
|
||||
not a hard gate: an unauthenticated request against a collection whose
|
||||
`listRule` requires auth still gets **`200 OK` with an empty result**,
|
||||
rather than an error — because the rule can't be cleanly separated into
|
||||
"deny the whole request" vs. "just don't return matching rows" (see
|
||||
[pocketbase/pocketbase#6492](https://github.com/pocketbase/pocketbase/discussions/6492)).
|
||||
No private data ever leaks this way, but a `200` is still the wrong signal
|
||||
for "you're not allowed here." `pb_hooks/main.pb.js` adds a small
|
||||
`onRecordsListRequest` hook that turns that into a `403` for the three app
|
||||
collections. It's plain JS, auto-loaded by the stock `pocketbase` binary —
|
||||
no recompilation, no framework — so it ships and deploys exactly like
|
||||
`pb_migrations/`.
|
||||
|
||||
## Verifying a deployment
|
||||
|
||||
```sh
|
||||
# Anonymous requests must be rejected (4xx) for all three collections:
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8090/api/collections/books/records
|
||||
# -> 403
|
||||
|
||||
# Self-registration must be rejected:
|
||||
curl -s -X POST http://127.0.0.1:8090/api/collections/users/records \
|
||||
-H 'Content-Type: application/json' -d '{"email":"x@x.com","password":"password123","passwordConfirm":"password123"}'
|
||||
# -> 403 "Only superusers can perform this action."
|
||||
```
|
||||
|
||||
See `deploy/README.md` for running the server long-term, reaching it from
|
||||
outside your home network, and backups.
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# create-user.sh — superuser-driven account creation for Bookshelf.
|
||||
#
|
||||
# There is no self-registration path (users.createRule = null, see
|
||||
# setup-schema.sh), so the only way to create an app account is for the
|
||||
# server owner to run this script as the superuser. Intended for the two
|
||||
# household accounts (owner + spouse), but works for any number of users.
|
||||
#
|
||||
# Usage:
|
||||
# ./create-user.sh <email> <password> [name]
|
||||
# PB_URL=https://bookshelf.example.com ./create-user.sh alice@example.com "correct horse battery staple" Alice
|
||||
#
|
||||
# PB_URL, PB_EMAIL (superuser), PB_PASS (superuser) come from env, or fall
|
||||
# back to ./.dev-credentials, same as setup-schema.sh.
|
||||
|
||||
set -u -o pipefail
|
||||
|
||||
NEW_EMAIL="${1:-}"
|
||||
NEW_PASS="${2:-}"
|
||||
NEW_NAME="${3:-}"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CREDS_FILE="$SCRIPT_DIR/.dev-credentials"
|
||||
|
||||
log() { printf '%s\n' "$*" >&2; }
|
||||
ok() { printf '\033[32m✓\033[0m %s\n' "$*" >&2; }
|
||||
fail() { printf '\033[31m✗ ERROR:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
command -v curl >/dev/null 2>&1 || fail "curl is required but not installed."
|
||||
command -v jq >/dev/null 2>&1 || fail "jq is required but not installed."
|
||||
|
||||
if [[ -z "$NEW_EMAIL" || -z "$NEW_PASS" ]]; then
|
||||
fail "Usage: $0 <email> <password> [name]"
|
||||
fi
|
||||
if [[ ${#NEW_PASS} -lt 8 ]]; then
|
||||
fail "Password must be at least 8 characters (PocketBase minimum)."
|
||||
fi
|
||||
|
||||
PB_URL="${PB_URL:-http://127.0.0.1:8090}"
|
||||
PB_URL="${PB_URL%/}"
|
||||
PB_EMAIL="${PB_EMAIL:-}"
|
||||
PB_PASS="${PB_PASS:-}"
|
||||
|
||||
if [[ -z "$PB_EMAIL" || -z "$PB_PASS" ]]; then
|
||||
if [[ -f "$CREDS_FILE" ]]; then
|
||||
# shellcheck disable=SC1090
|
||||
source "$CREDS_FILE"
|
||||
PB_EMAIL="${PB_EMAIL:-${PB_SUPERUSER_EMAIL:-}}"
|
||||
PB_PASS="${PB_PASS:-${PB_SUPERUSER_PASS:-}}"
|
||||
fi
|
||||
fi
|
||||
[[ -n "$PB_EMAIL" ]] || fail "No superuser email given. Set PB_EMAIL or provide $CREDS_FILE."
|
||||
[[ -n "$PB_PASS" ]] || fail "No superuser password given. Set PB_PASS or provide $CREDS_FILE."
|
||||
|
||||
log "Creating Bookshelf account on $PB_URL"
|
||||
log " Email: $NEW_EMAIL"
|
||||
|
||||
AUTH_RESP="$(curl -s -w '\n%{http_code}' -X POST "$PB_URL/api/collections/_superusers/auth-with-password" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg identity "$PB_EMAIL" --arg password "$PB_PASS" '{identity:$identity, password:$password}')")"
|
||||
AUTH_BODY="$(printf '%s' "$AUTH_RESP" | sed '$d')"
|
||||
AUTH_CODE="$(printf '%s' "$AUTH_RESP" | tail -n1)"
|
||||
[[ "$AUTH_CODE" == "200" ]] || fail "Superuser login failed (HTTP $AUTH_CODE): $AUTH_BODY"
|
||||
TOKEN="$(printf '%s' "$AUTH_BODY" | jq -r '.token')"
|
||||
|
||||
PAYLOAD="$(jq -n \
|
||||
--arg email "$NEW_EMAIL" \
|
||||
--arg password "$NEW_PASS" \
|
||||
--arg name "$NEW_NAME" \
|
||||
'{email:$email, password:$password, passwordConfirm:$password, name:$name, emailVisibility:true, verified:true}')"
|
||||
|
||||
CREATE_RESP="$(curl -s -w '\n%{http_code}' -X POST "$PB_URL/api/collections/users/records" \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d "$PAYLOAD")"
|
||||
CREATE_BODY="$(printf '%s' "$CREATE_RESP" | sed '$d')"
|
||||
CREATE_CODE="$(printf '%s' "$CREATE_RESP" | tail -n1)"
|
||||
|
||||
if [[ "$CREATE_CODE" != "200" ]]; then
|
||||
fail "Failed to create user (HTTP $CREATE_CODE): $CREATE_BODY"
|
||||
fi
|
||||
|
||||
USER_ID="$(printf '%s' "$CREATE_BODY" | jq -r '.id')"
|
||||
ok "Created user '$NEW_EMAIL' (id: $USER_ID)"
|
||||
@@ -0,0 +1,30 @@
|
||||
# Bookshelf PocketBase server — container image.
|
||||
#
|
||||
# The pocketbase binary is already vendored into ./server (this repo does not
|
||||
# gitignore it out of the image build context — see server/.gitignore, which
|
||||
# only excludes it from *git*). If you'd rather fetch it fresh, replace the
|
||||
# COPY below with a curl of the official release for your architecture from
|
||||
# https://github.com/pocketbase/pocketbase/releases (match the version
|
||||
# already pinned in server/pocketbase --version, currently v0.40.2).
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /pb
|
||||
|
||||
COPY pocketbase /pb/pocketbase
|
||||
COPY pb_migrations /pb/pb_migrations
|
||||
COPY pb_hooks /pb/pb_hooks
|
||||
RUN chmod +x /pb/pocketbase
|
||||
|
||||
# pb_data is a volume — see docker-compose.yml. Do not bake data into the
|
||||
# image; it must survive container recreation.
|
||||
VOLUME /pb/pb_data
|
||||
|
||||
EXPOSE 8090
|
||||
|
||||
ENTRYPOINT ["/pb/pocketbase"]
|
||||
CMD ["serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data", "--migrationsDir=/pb/pb_migrations", "--hooksDir=/pb/pb_hooks"]
|
||||
@@ -0,0 +1,156 @@
|
||||
# Deploying Bookshelf's PocketBase server
|
||||
|
||||
This assumes a spare always-on machine at home (a mini PC, NUC, Raspberry Pi,
|
||||
or an old laptop) running Linux. Pick **one** of the two run methods below —
|
||||
systemd or Docker — not both.
|
||||
|
||||
## 0. Get the files onto the server
|
||||
|
||||
Copy the whole `server/` directory (minus `.dev-credentials`, which is
|
||||
gitignored and dev-only) to the target machine, e.g.:
|
||||
|
||||
```sh
|
||||
rsync -av --exclude .dev-credentials ~/bookshelf/server/ youruser@homeserver:/opt/bookshelf/
|
||||
```
|
||||
|
||||
The `pocketbase` binary is architecture-specific — if your home server isn't
|
||||
the same CPU architecture as wherever you built/downloaded it, grab the
|
||||
matching build from https://github.com/pocketbase/pocketbase/releases
|
||||
(this project is pinned to **v0.40.2**) and drop it in as `/opt/bookshelf/pocketbase`.
|
||||
|
||||
## 1. Run it — Option A: systemd (recommended for a bare-metal/VM host)
|
||||
|
||||
1. Create a dedicated unprivileged user:
|
||||
```sh
|
||||
sudo useradd --system --home /opt/bookshelf --shell /usr/sbin/nologin bookshelf
|
||||
sudo chown -R bookshelf:bookshelf /opt/bookshelf
|
||||
```
|
||||
2. Install the unit:
|
||||
```sh
|
||||
sudo cp deploy/bookshelf.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now bookshelf
|
||||
```
|
||||
3. Check it's up: `systemctl status bookshelf` and `curl http://127.0.0.1:8090/api/health`.
|
||||
4. Logs: `journalctl -u bookshelf -f`.
|
||||
|
||||
Note the unit binds PocketBase to `127.0.0.1:8090` only — it is **not**
|
||||
reachable from other machines yet. That's intentional; see step 4.
|
||||
|
||||
## 2. Run it — Option B: Docker
|
||||
|
||||
```sh
|
||||
cd deploy
|
||||
docker compose up -d --build
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
This also only publishes to `127.0.0.1:8090` on the host, for the same
|
||||
reason. Data persists in the `bookshelf_pb_data` named volume regardless of
|
||||
container restarts/rebuilds.
|
||||
|
||||
## 3. Provision the schema and accounts
|
||||
|
||||
Once the server is up and answering on `127.0.0.1:8090`, run the setup
|
||||
scripts from `server/` (one directory up from here):
|
||||
|
||||
```sh
|
||||
cd /opt/bookshelf
|
||||
|
||||
# If this is a brand-new PocketBase data dir, it prints a one-time setup URL
|
||||
# on first launch (see `journalctl -u bookshelf` or `docker compose logs`) —
|
||||
# open that in a browser first to create your superuser account.
|
||||
|
||||
./setup-schema.sh http://127.0.0.1:8090 <superuser-email> <superuser-password>
|
||||
|
||||
# Then create the household accounts — there is no public sign-up:
|
||||
./create-user.sh owner@example.com "a strong password" "Owner Name"
|
||||
./create-user.sh spouse@example.com "a different strong password" "Spouse Name"
|
||||
```
|
||||
|
||||
`setup-schema.sh` is idempotent — safe to re-run any time (e.g. after
|
||||
pulling an updated `server/` if the schema ever changes).
|
||||
|
||||
## 4. Reach it from outside your home network
|
||||
|
||||
The app needs a URL it can hit from anywhere your phone goes — not just your
|
||||
home wifi. Two real options:
|
||||
|
||||
### Tailscale (recommended)
|
||||
|
||||
Install Tailscale on the home server and on your phone, join both to the
|
||||
same tailnet. The server gets a stable `100.x.y.z` address (or a
|
||||
MagicDNS name like `homeserver.your-tailnet.ts.net`) reachable from
|
||||
anywhere, encrypted end-to-end, with **no ports opened on your router**.
|
||||
|
||||
- **Pros:** essentially zero attack surface (nothing is exposed to the
|
||||
public internet at all — not even a login page), no TLS cert management,
|
||||
works behind CGNAT, five-minute setup.
|
||||
- **Cons:** both devices need the Tailscale app installed and signed in;
|
||||
if Tailscale's coordination service has an outage, *new* connections may
|
||||
be briefly unable to establish (existing ones keep working) — acceptable
|
||||
for a two-person home library, not for something needing five-nines.
|
||||
- Point the app's server-URL field at `http://100.x.y.z:8090` (Tailscale's
|
||||
encryption makes plain HTTP tolerable *inside the tailnet*, but see the
|
||||
HTTPS note below — using `https://` via Tailscale Serve, next, is easy
|
||||
enough to just do).
|
||||
- Even better: use [`tailscale serve`](https://tailscale.com/kb/1312/serve)
|
||||
to get automatic HTTPS with a real cert on your tailnet domain, so the
|
||||
app can just always use `https://`:
|
||||
```sh
|
||||
sudo tailscale serve --bg 8090
|
||||
```
|
||||
|
||||
### Port-forward + Caddy (reverse proxy with TLS)
|
||||
|
||||
Forward a port on your router to the home server, and run
|
||||
[Caddy](https://caddyserver.com/) in front of PocketBase to terminate TLS
|
||||
with an automatic Let's Encrypt certificate.
|
||||
|
||||
- **Pros:** works with any client, no extra app/agent needed on the phone,
|
||||
a real public HTTPS URL.
|
||||
- **Cons:** you're now running an internet-facing service from your home —
|
||||
bugs in PocketBase, Caddy, or your router's firmware are now a real
|
||||
attack surface; you need a domain name (or dynamic-DNS if your ISP gives
|
||||
you a changing IP) for Let's Encrypt's HTTP-01/TLS-ALPN-01 challenge to
|
||||
work; residential ISPs sometimes block inbound 80/443 or use CGNAT,
|
||||
which breaks this approach entirely (Tailscale sidesteps that).
|
||||
|
||||
Minimal Caddyfile:
|
||||
```
|
||||
bookshelf.yourdomain.com {
|
||||
reverse_proxy 127.0.0.1:8090
|
||||
}
|
||||
```
|
||||
Caddy handles the certificate automatically. Point your router's port
|
||||
forward at the Caddy host's 443, and the app's server-URL field at
|
||||
`https://bookshelf.yourdomain.com`.
|
||||
|
||||
### HTTPS is not optional
|
||||
|
||||
The app sends the account password on every login. **Never** point the app
|
||||
at a plain `http://` URL that crosses the public internet or an untrusted
|
||||
network — only plain HTTP over Tailscale (which is already
|
||||
end-to-end-encrypted at the network layer) is acceptable, and even there,
|
||||
prefer `tailscale serve` for a real cert. If you go the port-forward route,
|
||||
Caddy above gets you HTTPS for free — don't skip it.
|
||||
|
||||
## 5. Back up regularly
|
||||
|
||||
```sh
|
||||
./deploy/backup.sh
|
||||
```
|
||||
|
||||
See the comments at the top of `backup.sh` for what it does (SQLite-safe
|
||||
snapshot + uploaded covers, tarball, prune old ones) and the restore
|
||||
procedure. Wire it into cron:
|
||||
|
||||
```sh
|
||||
crontab -e
|
||||
# add:
|
||||
0 3 * * * /opt/bookshelf/deploy/backup.sh >> /var/log/bookshelf-backup.log 2>&1
|
||||
```
|
||||
|
||||
Consider also copying the resulting tarballs off-box (another machine, a
|
||||
USB drive, cloud storage) — a backup that lives on the same disk as the
|
||||
data it's backing up doesn't protect against disk failure.
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# backup.sh — SQLite-safe backup of a running PocketBase pb_data directory.
|
||||
#
|
||||
# PocketBase's data.db and auxiliary.db are SQLite databases opened in WAL
|
||||
# mode. Copying the .db files directly with `cp` while the server is running
|
||||
# can grab an inconsistent snapshot if a checkpoint happens mid-copy. Using
|
||||
# sqlite3's `.backup` command (or PocketBase's own "backups" API) takes a
|
||||
# consistent snapshot safely without stopping the server.
|
||||
#
|
||||
# This script uses `sqlite3 .backup`, which is the simplest option that needs
|
||||
# no PocketBase superuser credentials. It backs up pb_data (including
|
||||
# uploaded files) into timestamped tarballs and prunes old ones.
|
||||
#
|
||||
# Usage:
|
||||
# ./backup.sh [PB_DATA_DIR] [BACKUP_DIR]
|
||||
# PB_DATA_DIR defaults to ../pb_data (relative to this script)
|
||||
# BACKUP_DIR defaults to ./backups (relative to this script)
|
||||
#
|
||||
# Suggested cron (nightly at 3am, keep last 14):
|
||||
# 0 3 * * * /opt/bookshelf/deploy/backup.sh >> /var/log/bookshelf-backup.log 2>&1
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PB_DATA_DIR="${1:-$SCRIPT_DIR/../pb_data}"
|
||||
BACKUP_DIR="${2:-$SCRIPT_DIR/backups}"
|
||||
KEEP_LAST="${KEEP_LAST:-14}"
|
||||
|
||||
log() { printf '[%s] %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$*"; }
|
||||
fail() { log "ERROR: $*"; exit 1; }
|
||||
|
||||
command -v sqlite3 >/dev/null 2>&1 || fail "sqlite3 is required (apt install sqlite3 / apk add sqlite)."
|
||||
[[ -d "$PB_DATA_DIR" ]] || fail "pb_data directory not found: $PB_DATA_DIR"
|
||||
|
||||
TIMESTAMP="$(date -u '+%Y%m%d_%H%M%S')"
|
||||
WORKDIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORKDIR"' EXIT
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
log "Backing up pb_data from $PB_DATA_DIR"
|
||||
|
||||
# Consistent SQLite snapshots via the .backup command (safe for a live,
|
||||
# WAL-mode database — it's the same mechanism `sqlite3 db.sqlite .backup`
|
||||
# uses, which briefly locks for the copy but never corrupts).
|
||||
for db in data.db auxiliary.db; do
|
||||
src="$PB_DATA_DIR/$db"
|
||||
if [[ -f "$src" ]]; then
|
||||
log " snapshotting $db"
|
||||
sqlite3 "$src" ".backup '$WORKDIR/$db'"
|
||||
fi
|
||||
done
|
||||
|
||||
# Everything else under pb_data that isn't a sqlite file/WAL/SHM (i.e.
|
||||
# uploaded covers under storage/) gets copied as-is — these are immutable
|
||||
# blobs once written, so a plain copy is safe.
|
||||
log " copying uploaded files (storage/)"
|
||||
if [[ -d "$PB_DATA_DIR/storage" ]]; then
|
||||
cp -a "$PB_DATA_DIR/storage" "$WORKDIR/storage"
|
||||
fi
|
||||
|
||||
ARCHIVE="$BACKUP_DIR/bookshelf-backup-$TIMESTAMP.tar.gz"
|
||||
tar -czf "$ARCHIVE" -C "$WORKDIR" .
|
||||
log "Wrote $ARCHIVE ($(du -h "$ARCHIVE" | cut -f1))"
|
||||
|
||||
# Prune old backups, keep the most recent $KEEP_LAST.
|
||||
mapfile -t old < <(ls -1t "$BACKUP_DIR"/bookshelf-backup-*.tar.gz 2>/dev/null | tail -n +$((KEEP_LAST + 1)))
|
||||
if [[ ${#old[@]} -gt 0 ]]; then
|
||||
log "Pruning ${#old[@]} backup(s) older than the last $KEEP_LAST"
|
||||
rm -f "${old[@]}"
|
||||
fi
|
||||
|
||||
log "Done."
|
||||
|
||||
# --- Restore ---------------------------------------------------------------
|
||||
# 1. Stop the server: sudo systemctl stop bookshelf
|
||||
# 2. Move aside the live data: mv /opt/bookshelf/pb_data /opt/bookshelf/pb_data.bak
|
||||
# 3. Extract the chosen backup: mkdir /opt/bookshelf/pb_data && \
|
||||
# tar -xzf bookshelf-backup-TIMESTAMP.tar.gz -C /opt/bookshelf/pb_data
|
||||
# 4. Fix ownership: sudo chown -R bookshelf:bookshelf /opt/bookshelf/pb_data
|
||||
# 5. Start the server again: sudo systemctl start bookshelf
|
||||
# 6. Once you've confirmed it's healthy, delete pb_data.bak.
|
||||
@@ -0,0 +1,36 @@
|
||||
[Unit]
|
||||
Description=Bookshelf PocketBase server
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
# Adjust to your setup: a dedicated, unprivileged system account.
|
||||
# Create it once with: sudo useradd --system --home /opt/bookshelf --shell /usr/sbin/nologin bookshelf
|
||||
User=bookshelf
|
||||
Group=bookshelf
|
||||
|
||||
# All paths below assume the whole ~/bookshelf/server tree (pocketbase binary,
|
||||
# pb_data/, pb_migrations/, pb_hooks/) is deployed to /opt/bookshelf.
|
||||
# Adjust WorkingDirectory/ExecStart if you deploy elsewhere.
|
||||
WorkingDirectory=/opt/bookshelf
|
||||
ExecStart=/opt/bookshelf/pocketbase serve \
|
||||
--http=127.0.0.1:8090 \
|
||||
--dir=/opt/bookshelf/pb_data \
|
||||
--migrationsDir=/opt/bookshelf/pb_migrations \
|
||||
--hooksDir=/opt/bookshelf/pb_hooks
|
||||
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# Hardening (safe defaults for a single-purpose service; loosen only if
|
||||
# something concrete breaks).
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/opt/bookshelf/pb_data
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,34 @@
|
||||
# Bookshelf PocketBase server — Docker Compose deployment.
|
||||
#
|
||||
# Build context is the parent `server/` directory (so the Dockerfile can see
|
||||
# pocketbase, pb_migrations/, pb_hooks/). Run compose from server/deploy/:
|
||||
#
|
||||
# cd server/deploy && docker compose up -d
|
||||
#
|
||||
# Data lives in the named volume `bookshelf_pb_data`, independent of the
|
||||
# container lifecycle — `docker compose down` (without -v) never touches it.
|
||||
#
|
||||
# The port is published on 127.0.0.1 only, matching bookshelf.service: this
|
||||
# container is meant to sit behind Tailscale or a Caddy reverse proxy on the
|
||||
# same host, never exposed directly to the internet (see deploy/README.md).
|
||||
|
||||
services:
|
||||
pocketbase:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
container_name: bookshelf-pocketbase
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:8090:8090"
|
||||
volumes:
|
||||
- bookshelf_pb_data:/pb/pb_data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:8090/api/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
bookshelf_pb_data:
|
||||
name: bookshelf_pb_data
|
||||
@@ -0,0 +1,21 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// PocketBase's declarative API rules act as row-level filters for the
|
||||
// "list"/"search" action: an unsatisfiable listRule (e.g. requiring auth)
|
||||
// still returns 200 with an empty result set rather than an error, because
|
||||
// the rule is just a SQL WHERE clause under the hood. See
|
||||
// https://github.com/pocketbase/pocketbase/discussions/6492
|
||||
//
|
||||
// Bookshelf is a private, two-person library — no anonymous caller should
|
||||
// ever get a 200 back from these endpoints, even an empty one, since some
|
||||
// HTTP/JS clients treat "200 with []" as a successful, allowed request.
|
||||
// This hook makes that explicit: anonymous list/search requests against the
|
||||
// three app collections are rejected with 403, matching create/update/
|
||||
// view/delete (which already 400/404 for unauthenticated callers via the
|
||||
// declarative rules alone).
|
||||
onRecordsListRequest((e) => {
|
||||
if (!e.auth) {
|
||||
throw new ForbiddenError("Authentication required.");
|
||||
}
|
||||
e.next();
|
||||
}, "bookcases", "shelves", "books");
|
||||
@@ -0,0 +1,113 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = new Collection({
|
||||
"createRule": "@request.auth.id != \"\"",
|
||||
"deleteRule": "@request.auth.id != \"\"",
|
||||
"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": "text1579384326",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "name",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text3485334036",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "note",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "number1177347317",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "position",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "bool3946532403",
|
||||
"name": "deleted",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"id": "pbc_1015602688",
|
||||
"indexes": [
|
||||
"CREATE INDEX `idx_bookcases_updated` ON `bookcases` (`updated`)"
|
||||
],
|
||||
"listRule": "@request.auth.id != \"\"",
|
||||
"name": "bookcases",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": "@request.auth.id != \"\"",
|
||||
"viewRule": "@request.auth.id != \"\""
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_1015602688");
|
||||
|
||||
return app.delete(collection);
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = new Collection({
|
||||
"createRule": "@request.auth.id != \"\"",
|
||||
"deleteRule": "@request.auth.id != \"\"",
|
||||
"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": "pbc_1015602688",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "relation3290364264",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "bookcase",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text245846248",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "label",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "number1177347317",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "position",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "bool3946532403",
|
||||
"name": "deleted",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"id": "pbc_2495675413",
|
||||
"indexes": [
|
||||
"CREATE INDEX `idx_shelves_updated` ON `shelves` (`updated`)"
|
||||
],
|
||||
"listRule": "@request.auth.id != \"\"",
|
||||
"name": "shelves",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": "@request.auth.id != \"\"",
|
||||
"viewRule": "@request.auth.id != \"\""
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_2495675413");
|
||||
|
||||
return app.delete(collection);
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("_pb_users_auth_")
|
||||
|
||||
// update collection data
|
||||
unmarshal({
|
||||
"createRule": null,
|
||||
"deleteRule": null,
|
||||
"listRule": "@request.auth.id != \"\"",
|
||||
"viewRule": "@request.auth.id != \"\""
|
||||
}, collection)
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("_pb_users_auth_")
|
||||
|
||||
// update collection data
|
||||
unmarshal({
|
||||
"createRule": "",
|
||||
"deleteRule": "id = @request.auth.id",
|
||||
"listRule": "id = @request.auth.id",
|
||||
"viewRule": "id = @request.auth.id"
|
||||
}, collection)
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
@@ -0,0 +1,281 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = new Collection({
|
||||
"createRule": "@request.auth.id != \"\"",
|
||||
"deleteRule": "@request.auth.id != \"\"",
|
||||
"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": "text724990059",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "title",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text1367709617",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "subtitle",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "json2383161937",
|
||||
"maxSize": 0,
|
||||
"name": "authors",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text2325797416",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "isbn13",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text329878418",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "isbn10",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text2632504646",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "publisher",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text2651326123",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "published_date",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "number3814243252",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "page_count",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text1843675174",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "description",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "file2366146245",
|
||||
"maxSelect": 1,
|
||||
"maxSize": 0,
|
||||
"mimeTypes": [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif"
|
||||
],
|
||||
"name": "cover",
|
||||
"presentable": false,
|
||||
"protected": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"thumbs": [
|
||||
"100x150",
|
||||
"300x450"
|
||||
],
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text2395634672",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "cover_source_url",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "pbc_2495675413",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "relation2772917219",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "shelf",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "text18589324",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "notes",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "relation1771793327",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "added_by",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"help": "",
|
||||
"hidden": false,
|
||||
"id": "bool3946532403",
|
||||
"name": "deleted",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"id": "pbc_2170393721",
|
||||
"indexes": [
|
||||
"CREATE INDEX `idx_books_isbn13` ON `books` (`isbn13`)",
|
||||
"CREATE INDEX `idx_books_updated` ON `books` (`updated`)"
|
||||
],
|
||||
"listRule": "@request.auth.id != \"\"",
|
||||
"name": "books",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": "@request.auth.id != \"\"",
|
||||
"viewRule": "@request.auth.id != \"\""
|
||||
});
|
||||
|
||||
return app.save(collection);
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_2170393721");
|
||||
|
||||
return app.delete(collection);
|
||||
})
|
||||
Executable
+337
@@ -0,0 +1,337 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# setup-schema.sh — idempotent PocketBase schema provisioning for Bookshelf.
|
||||
#
|
||||
# Creates/updates the `bookcases`, `shelves`, `books` collections exactly per
|
||||
# docs/SPEC.md, sets every API rule (including locking down self-registration
|
||||
# on `users`), and creates the required indexes. Safe to re-run any number of
|
||||
# times against the same instance — it diffs against what already exists.
|
||||
#
|
||||
# Requires: bash, curl, jq (no other tooling; portable to any Linux box).
|
||||
#
|
||||
# Usage:
|
||||
# ./setup-schema.sh [PB_URL] [PB_EMAIL] [PB_PASS]
|
||||
# PB_URL=http://127.0.0.1:8090 PB_EMAIL=admin@example.com PB_PASS=secret ./setup-schema.sh
|
||||
#
|
||||
# Args take priority over env vars if both are given.
|
||||
|
||||
set -u -o pipefail
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PB_URL="${1:-${PB_URL:-http://127.0.0.1:8090}}"
|
||||
PB_EMAIL="${2:-${PB_EMAIL:-}}"
|
||||
PB_PASS="${3:-${PB_PASS:-}}"
|
||||
|
||||
# Strip any trailing slash so URL-joining below is predictable.
|
||||
PB_URL="${PB_URL%/}"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CREDS_FILE="$SCRIPT_DIR/.dev-credentials"
|
||||
|
||||
log() { printf '%s\n' "$*" >&2; }
|
||||
info() { log " $*"; }
|
||||
ok() { printf '\033[32m✓\033[0m %s\n' "$*" >&2; }
|
||||
fail() { printf '\033[31m✗ ERROR:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preflight
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
command -v curl >/dev/null 2>&1 || fail "curl is required but not installed."
|
||||
command -v jq >/dev/null 2>&1 || fail "jq is required but not installed."
|
||||
|
||||
if [[ -z "$PB_EMAIL" || -z "$PB_PASS" ]]; then
|
||||
if [[ -f "$CREDS_FILE" ]]; then
|
||||
info "No PB_EMAIL/PB_PASS given — loading $CREDS_FILE"
|
||||
# shellcheck disable=SC1090
|
||||
source "$CREDS_FILE"
|
||||
PB_EMAIL="${PB_EMAIL:-${PB_SUPERUSER_EMAIL:-}}"
|
||||
PB_PASS="${PB_PASS:-${PB_SUPERUSER_PASS:-}}"
|
||||
fi
|
||||
fi
|
||||
|
||||
[[ -n "$PB_EMAIL" ]] || fail "No superuser email given. Pass it as arg 2, set PB_EMAIL, or provide $CREDS_FILE."
|
||||
[[ -n "$PB_PASS" ]] || fail "No superuser password given. Pass it as arg 3, set PB_PASS, or provide $CREDS_FILE."
|
||||
|
||||
log "Bookshelf schema setup"
|
||||
log " Target: $PB_URL"
|
||||
log " Superuser: $PB_EMAIL"
|
||||
log ""
|
||||
|
||||
# Reachability check with an actionable error.
|
||||
if ! curl -sf -o /dev/null --connect-timeout 5 "$PB_URL/api/health"; then
|
||||
fail "Cannot reach $PB_URL/api/health — is PocketBase running there? Check the URL and that the server is up."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
AUTH_RESP="$(curl -s -w '\n%{http_code}' -X POST "$PB_URL/api/collections/_superusers/auth-with-password" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg identity "$PB_EMAIL" --arg password "$PB_PASS" '{identity:$identity, password:$password}')")"
|
||||
AUTH_BODY="$(printf '%s' "$AUTH_RESP" | sed '$d')"
|
||||
AUTH_CODE="$(printf '%s' "$AUTH_RESP" | tail -n1)"
|
||||
|
||||
if [[ "$AUTH_CODE" != "200" ]]; then
|
||||
fail "Superuser login failed (HTTP $AUTH_CODE). Response: $AUTH_BODY
|
||||
Check PB_EMAIL/PB_PASS (or $CREDS_FILE) and that this account is a _superusers record."
|
||||
fi
|
||||
|
||||
TOKEN="$(printf '%s' "$AUTH_BODY" | jq -r '.token')"
|
||||
[[ -n "$TOKEN" && "$TOKEN" != "null" ]] || fail "Login succeeded but no token was returned. Response: $AUTH_BODY"
|
||||
ok "Authenticated as superuser"
|
||||
|
||||
AUTH_HEADER="Authorization: Bearer $TOKEN"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Both helpers below print "<http_code>\n<body>" to stdout. Callers must NOT
|
||||
# rely on a global variable being set as a side effect of a function called
|
||||
# inside a command substitution — that runs in a subshell, so any such global
|
||||
# assignment is invisible to the caller once the subshell exits. Encoding the
|
||||
# code into the captured output itself sidesteps that entirely.
|
||||
|
||||
# api_get PATH -> prints "<code>\n<body>"
|
||||
api_get() {
|
||||
local path="$1"
|
||||
curl -s -w '\n%{http_code}' -H "$AUTH_HEADER" "$PB_URL$path" | _reorder_code_first
|
||||
}
|
||||
|
||||
# api_send METHOD PATH JSON_BODY -> prints "<code>\n<body>"
|
||||
api_send() {
|
||||
local method="$1" path="$2" body="$3"
|
||||
curl -s -w '\n%{http_code}' -X "$method" "$PB_URL$path" \
|
||||
-H "$AUTH_HEADER" -H "Content-Type: application/json" -d "$body" | _reorder_code_first
|
||||
}
|
||||
|
||||
# Reads "<body...>\n<code>" (curl's -w appends code as the final line) and
|
||||
# re-emits it as "<code>\n<body...>" so callers can cleanly read one line for
|
||||
# the code and the rest for the body.
|
||||
_reorder_code_first() {
|
||||
local all code body
|
||||
all="$(cat)"
|
||||
code="$(printf '%s' "$all" | tail -n1)"
|
||||
body="$(printf '%s' "$all" | sed '$d')"
|
||||
printf '%s\n%s' "$code" "$body"
|
||||
}
|
||||
|
||||
# split_response VAR_PREFIX RESPONSE — sets ${VAR_PREFIX}_CODE and ${VAR_PREFIX}_BODY
|
||||
# from a "<code>\n<body>" string, in the *caller's* shell (no subshell).
|
||||
split_response() {
|
||||
local prefix="$1" resp="$2"
|
||||
printf -v "${prefix}_CODE" '%s' "$(printf '%s' "$resp" | head -n1)"
|
||||
printf -v "${prefix}_BODY" '%s' "$(printf '%s' "$resp" | tail -n +2)"
|
||||
}
|
||||
|
||||
# get_collection_json NAME -> prints collection JSON, or empty string if 404
|
||||
get_collection_json() {
|
||||
local name="$1" resp
|
||||
resp="$(api_get "/api/collections/$name")"
|
||||
split_response GC "$resp"
|
||||
if [[ "$GC_CODE" == "200" ]]; then
|
||||
printf '%s' "$GC_BODY"
|
||||
elif [[ "$GC_CODE" == "404" ]]; then
|
||||
printf ''
|
||||
else
|
||||
fail "Unexpected response fetching collection '$name' (HTTP $GC_CODE): $GC_BODY"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Field merging
|
||||
#
|
||||
# PocketBase's collection PATCH/POST replaces the *entire* fields array with
|
||||
# whatever is sent — any field omitted from the payload is dropped. To stay
|
||||
# idempotent (and to keep each field's existing internal id stable across
|
||||
# re-runs, which matters for indexes and relations), we merge our desired
|
||||
# field definitions over whatever fields already exist by matching on name:
|
||||
# existing fields get their "id" preserved and properties overwritten; new
|
||||
# fields are appended with no id (PocketBase assigns one on save).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# merge_fields EXISTING_FIELDS_JSON DESIRED_FIELDS_JSON -> merged fields array
|
||||
merge_fields() {
|
||||
local existing="$1" desired="$2"
|
||||
jq -n --argjson existing "$existing" --argjson desired "$desired" '
|
||||
($existing // []) as $ex
|
||||
| ($desired // []) as $des
|
||||
| ($ex | map({(.name): .id}) | add // {}) as $idByName
|
||||
| ($ex | map(select(.name == "id"))) as $idField
|
||||
| $idField + ($des | map(
|
||||
. as $f
|
||||
| if ($idByName[$f.name] != null)
|
||||
then $f + {id: $idByName[$f.name]}
|
||||
else $f
|
||||
end
|
||||
))
|
||||
'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Collection definitions (per SPEC.md) — filled in after bookcases/shelves
|
||||
# ids are known, since relation fields need the target collectionId.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
AUTH_RULE='@request.auth.id != ""'
|
||||
|
||||
apply_collection() {
|
||||
local name="$1" type="$2" desired_fields="$3" indexes_json="$4"
|
||||
local existing existing_id existing_fields merged payload method path resp
|
||||
|
||||
existing="$(get_collection_json "$name")"
|
||||
|
||||
if [[ -z "$existing" ]]; then
|
||||
info "Creating collection '$name'..."
|
||||
method="POST"
|
||||
path="/api/collections"
|
||||
existing_fields="[]"
|
||||
existing_id=""
|
||||
else
|
||||
info "Updating collection '$name'..."
|
||||
method="PATCH"
|
||||
path="/api/collections/$name"
|
||||
existing_fields="$(printf '%s' "$existing" | jq -c '.fields')"
|
||||
existing_id="$(printf '%s' "$existing" | jq -r '.id')"
|
||||
fi
|
||||
|
||||
merged="$(merge_fields "$existing_fields" "$desired_fields")"
|
||||
|
||||
payload="$(jq -n \
|
||||
--arg name "$name" \
|
||||
--arg type "$type" \
|
||||
--argjson fields "$merged" \
|
||||
--argjson indexes "$indexes_json" \
|
||||
--arg rule "$AUTH_RULE" \
|
||||
'{
|
||||
name: $name,
|
||||
type: $type,
|
||||
fields: $fields,
|
||||
indexes: $indexes,
|
||||
listRule: $rule,
|
||||
viewRule: $rule,
|
||||
createRule: $rule,
|
||||
updateRule: $rule,
|
||||
deleteRule: $rule
|
||||
}')"
|
||||
|
||||
resp="$(api_send "$method" "$path" "$payload")"
|
||||
split_response AC "$resp"
|
||||
if [[ "$AC_CODE" != "200" ]]; then
|
||||
fail "Failed to save collection '$name' (HTTP $AC_CODE): $AC_BODY"
|
||||
fi
|
||||
|
||||
ok "Collection '$name' ready (rules = auth-required)"
|
||||
# Return the collection id via global for callers that need it.
|
||||
COLLECTION_ID="$(printf '%s' "$AC_BODY" | jq -r '.id')"
|
||||
|
||||
# PocketBase auto-writes a pb_migrations/*.js snapshot per collection save,
|
||||
# named "<unix_seconds>_<action>_<name>.js". Migrations with the same
|
||||
# second-resolution timestamp are replayed in filename (alphabetical) order
|
||||
# on a fresh instance, NOT creation order — so e.g. "created_books" can run
|
||||
# before "created_shelves" even though shelves must exist first for the
|
||||
# relation field. A 1s pause between collection saves keeps timestamps
|
||||
# strictly increasing so a fresh instance replays them in the right order.
|
||||
sleep 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1) bookcases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BOOKCASES_FIELDS='[
|
||||
{"name":"name","type":"text","required":true},
|
||||
{"name":"note","type":"text","required":false},
|
||||
{"name":"position","type":"number","required":false},
|
||||
{"name":"deleted","type":"bool","required":false},
|
||||
{"name":"created","type":"autodate","onCreate":true,"onUpdate":false},
|
||||
{"name":"updated","type":"autodate","onCreate":true,"onUpdate":true}
|
||||
]'
|
||||
BOOKCASES_INDEXES='["CREATE INDEX `idx_bookcases_updated` ON `bookcases` (`updated`)"]'
|
||||
|
||||
apply_collection "bookcases" "base" "$BOOKCASES_FIELDS" "$BOOKCASES_INDEXES"
|
||||
BOOKCASES_ID="$COLLECTION_ID"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2) shelves (relation -> bookcases)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SHELVES_FIELDS="$(jq -n --arg bcid "$BOOKCASES_ID" '[
|
||||
{"name":"bookcase","type":"relation","required":true,"collectionId":$bcid,"maxSelect":1,"cascadeDelete":false},
|
||||
{"name":"label","type":"text","required":true},
|
||||
{"name":"position","type":"number","required":false},
|
||||
{"name":"deleted","type":"bool","required":false},
|
||||
{"name":"created","type":"autodate","onCreate":true,"onUpdate":false},
|
||||
{"name":"updated","type":"autodate","onCreate":true,"onUpdate":true}
|
||||
]')"
|
||||
SHELVES_INDEXES='["CREATE INDEX `idx_shelves_updated` ON `shelves` (`updated`)"]'
|
||||
|
||||
apply_collection "shelves" "base" "$SHELVES_FIELDS" "$SHELVES_INDEXES"
|
||||
SHELVES_ID="$COLLECTION_ID"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3) books (relation -> shelves, relation -> users, file cover)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
USERS_JSON="$(get_collection_json "users")"
|
||||
[[ -n "$USERS_JSON" ]] || fail "The built-in 'users' collection was not found — is this a fresh/corrupt PocketBase data dir?"
|
||||
USERS_ID="$(printf '%s' "$USERS_JSON" | jq -r '.id')"
|
||||
|
||||
BOOKS_FIELDS="$(jq -n --arg shid "$SHELVES_ID" --arg usid "$USERS_ID" '[
|
||||
{"name":"title","type":"text","required":true},
|
||||
{"name":"subtitle","type":"text","required":false},
|
||||
{"name":"authors","type":"json","required":false},
|
||||
{"name":"isbn13","type":"text","required":false},
|
||||
{"name":"isbn10","type":"text","required":false},
|
||||
{"name":"publisher","type":"text","required":false},
|
||||
{"name":"published_date","type":"text","required":false},
|
||||
{"name":"page_count","type":"number","required":false},
|
||||
{"name":"description","type":"text","required":false},
|
||||
{"name":"cover","type":"file","required":false,"maxSelect":1,
|
||||
"mimeTypes":["image/jpeg","image/png","image/webp","image/gif"],
|
||||
"thumbs":["100x150","300x450"]},
|
||||
{"name":"cover_source_url","type":"text","required":false},
|
||||
{"name":"shelf","type":"relation","required":false,"collectionId":$shid,"maxSelect":1,"cascadeDelete":false},
|
||||
{"name":"notes","type":"text","required":false},
|
||||
{"name":"added_by","type":"relation","required":false,"collectionId":$usid,"maxSelect":1,"cascadeDelete":false},
|
||||
{"name":"deleted","type":"bool","required":false},
|
||||
{"name":"created","type":"autodate","onCreate":true,"onUpdate":false},
|
||||
{"name":"updated","type":"autodate","onCreate":true,"onUpdate":true}
|
||||
]')"
|
||||
BOOKS_INDEXES='[
|
||||
"CREATE INDEX `idx_books_isbn13` ON `books` (`isbn13`)",
|
||||
"CREATE INDEX `idx_books_updated` ON `books` (`updated`)"
|
||||
]'
|
||||
|
||||
apply_collection "books" "base" "$BOOKS_FIELDS" "$BOOKS_INDEXES"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4) Lock down `users` — no self-registration, per SPEC.
|
||||
# createRule = null (superuser-only), list/view = auth-required,
|
||||
# update = only your own record, delete = null (superuser-only).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
info "Locking down 'users' collection (no self-registration)..."
|
||||
USERS_PATCH_PAYLOAD='{
|
||||
"listRule": "@request.auth.id != \"\"",
|
||||
"viewRule": "@request.auth.id != \"\"",
|
||||
"createRule": null,
|
||||
"updateRule": "id = @request.auth.id",
|
||||
"deleteRule": null
|
||||
}'
|
||||
resp="$(api_send "PATCH" "/api/collections/users" "$USERS_PATCH_PAYLOAD")"
|
||||
split_response UP "$resp"
|
||||
if [[ "$UP_CODE" != "200" ]]; then
|
||||
fail "Failed to lock down 'users' collection (HTTP $UP_CODE): $UP_BODY"
|
||||
fi
|
||||
ok "'users' collection locked down (createRule=null, deleteRule=null)"
|
||||
|
||||
log ""
|
||||
ok "Schema setup complete."
|
||||
log ""
|
||||
log "Next: create accounts with ./create-user.sh (there is no self-registration)."
|
||||
Reference in New Issue
Block a user