All checks were successful
CI / Scraper / Test (pull_request) Successful in 14s
CI / UI / Build (pull_request) Successful in 17s
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Lint (pull_request) Successful in 21s
CI / Scraper / Docker Push (pull_request) Has been skipped
iOS CI / Build (pull_request) Successful in 8m22s
iOS CI / Test (pull_request) Successful in 12m35s
The sed-based collection id and fields extraction was greedy and broke on collections with multiple fields (grabbed the last field id instead of the top-level collection id → PATCH to wrong URL → 404). Rewrite ensure_field to use python3 for reliable JSON parsing. Also adds the missing heartbeat_at (date) field to scraping_tasks and audio_jobs which was never applied on the initial deploy because the bug prevented the PATCH.
346 lines
12 KiB
Bash
Executable File
346 lines
12 KiB
Bash
Executable File
#!/bin/sh
|
|
# pb-init.sh — idempotent PocketBase collection bootstrap
|
|
#
|
|
# Creates all collections required by libnovel. Safe to re-run: POST returns
|
|
# 400/422 when a collection already exists; both are treated as success.
|
|
#
|
|
# Required env vars (with defaults):
|
|
# POCKETBASE_URL http://pocketbase:8090
|
|
# POCKETBASE_ADMIN_EMAIL admin@libnovel.local
|
|
# POCKETBASE_ADMIN_PASSWORD changeme123
|
|
|
|
set -e
|
|
|
|
PB_URL="${POCKETBASE_URL:-http://pocketbase:8090}"
|
|
PB_EMAIL="${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
|
PB_PASSWORD="${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
|
|
|
log() { echo "[pb-init] $*"; }
|
|
|
|
# ─── 0. Ensure curl is available ─────────────────────────────────────────────
|
|
if ! command -v curl > /dev/null 2>&1; then
|
|
apk add --no-cache curl > /dev/null 2>&1
|
|
fi
|
|
|
|
# ─── 1. Wait for PocketBase to be ready ──────────────────────────────────────
|
|
log "waiting for PocketBase at $PB_URL ..."
|
|
until curl -sf "$PB_URL/api/health" > /dev/null 2>&1; do
|
|
sleep 2
|
|
done
|
|
log "PocketBase is up"
|
|
|
|
# ─── 2. Ensure the superuser exists, then authenticate ───────────────────────
|
|
#
|
|
# The muchobien/pocketbase image does NOT auto-create a superuser from env vars.
|
|
# On a fresh install PocketBase exposes a one-time install JWT in its log output
|
|
# at /pb_data/logs/ — but we can't read that from here.
|
|
#
|
|
# Strategy:
|
|
# a) Try to auth normally (works on subsequent runs once the account exists).
|
|
# b) If that returns 400/401, PocketBase is fresh. Use the install token
|
|
# obtained from the /_/ redirect Location header (PocketBase v0.23+).
|
|
|
|
log "ensuring superuser $PB_EMAIL exists ..."
|
|
|
|
# Try to get the install token from the /_/ redirect Location header.
|
|
LOCATION=$(curl -sf -o /dev/null -w "%{redirect_url}" "$PB_URL/_/" 2>/dev/null || true)
|
|
if echo "$LOCATION" | grep -q "pbinstal/"; then
|
|
INSTALL_TOKEN=$(echo "$LOCATION" | sed 's|.*pbinstal/||' | tr -d ' \r\n')
|
|
log "install token found — creating superuser via install endpoint"
|
|
curl -sf -X POST "$PB_URL/api/collections/_superusers/records" \
|
|
-H "Content-Type: application/json" \
|
|
-H "Authorization: Bearer $INSTALL_TOKEN" \
|
|
-d "{\"email\":\"$PB_EMAIL\",\"password\":\"$PB_PASSWORD\",\"passwordConfirm\":\"$PB_PASSWORD\"}" \
|
|
> /dev/null 2>&1 || true
|
|
log "superuser create attempted (may already exist)"
|
|
fi
|
|
|
|
# ─── 3. Authenticate and obtain a superuser token ────────────────────────────
|
|
log "authenticating as $PB_EMAIL ..."
|
|
AUTH_RESPONSE=$(curl -sf -X POST "$PB_URL/api/collections/_superusers/auth-with-password" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"identity\":\"$PB_EMAIL\",\"password\":\"$PB_PASSWORD\"}")
|
|
|
|
TOKEN=$(echo "$AUTH_RESPONSE" | sed 's/.*"token":"\([^"]*\)".*/\1/')
|
|
if [ -z "$TOKEN" ] || [ "$TOKEN" = "$AUTH_RESPONSE" ]; then
|
|
log "ERROR: failed to obtain auth token. Response: $AUTH_RESPONSE"
|
|
exit 1
|
|
fi
|
|
log "auth token obtained"
|
|
|
|
# ─── 4. Helpers ───────────────────────────────────────────────────────────────
|
|
|
|
create_collection() {
|
|
NAME="$1"
|
|
BODY="$2"
|
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
|
-X POST "$PB_URL/api/collections" \
|
|
-H "Content-Type: application/json" \
|
|
-H "Authorization: Bearer $TOKEN" \
|
|
-d "$BODY")
|
|
case "$STATUS" in
|
|
200|201) log "created collection: $NAME" ;;
|
|
400|422) log "collection already exists (skipped): $NAME" ;;
|
|
*) log "WARNING: unexpected status $STATUS for collection: $NAME" ;;
|
|
esac
|
|
}
|
|
|
|
# ensure_field COLLECTION FIELD_NAME FIELD_TYPE
|
|
#
|
|
# Checks whether FIELD_NAME exists in COLLECTION's schema. If it is missing,
|
|
# sends a PATCH with the full current fields list plus the new field appended.
|
|
ensure_field() {
|
|
COLL="$1"
|
|
FIELD_NAME="$2"
|
|
FIELD_TYPE="$3"
|
|
|
|
SCHEMA=$(curl -sf \
|
|
-H "Authorization: Bearer $TOKEN" \
|
|
"$PB_URL/api/collections/$COLL" 2>/dev/null)
|
|
|
|
# Use python3 to reliably parse the JSON schema.
|
|
PARSED=$(echo "$SCHEMA" | python3 -c "
|
|
import sys, json
|
|
try:
|
|
d = json.load(sys.stdin)
|
|
fields = d.get('fields', [])
|
|
exists = any(f.get('name') == '$FIELD_NAME' for f in fields)
|
|
print('exists=' + str(exists))
|
|
print('id=' + d.get('id', ''))
|
|
if not exists:
|
|
fields.append({'name': '$FIELD_NAME', 'type': '$FIELD_TYPE'})
|
|
print('fields=' + json.dumps(fields))
|
|
except Exception as e:
|
|
print('error=' + str(e))
|
|
" 2>/dev/null)
|
|
|
|
if echo "$PARSED" | grep -q "^exists=True"; then
|
|
log "field $COLL.$FIELD_NAME already exists — skipping"
|
|
return
|
|
fi
|
|
|
|
COLLECTION_ID=$(echo "$PARSED" | grep "^id=" | sed 's/^id=//')
|
|
if [ -z "$COLLECTION_ID" ]; then
|
|
log "WARNING: could not get id for collection $COLL — skipping ensure_field"
|
|
return
|
|
fi
|
|
|
|
NEW_FIELDS=$(echo "$PARSED" | grep "^fields=" | sed 's/^fields=//')
|
|
PATCH_BODY="{\"fields\":${NEW_FIELDS}}"
|
|
|
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
|
-X PATCH "$PB_URL/api/collections/$COLLECTION_ID" \
|
|
-H "Content-Type: application/json" \
|
|
-H "Authorization: Bearer $TOKEN" \
|
|
-d "$PATCH_BODY")
|
|
case "$STATUS" in
|
|
200|201) log "patched $COLL — added field: $FIELD_NAME ($FIELD_TYPE)" ;;
|
|
*) log "WARNING: patch returned $STATUS when adding $FIELD_NAME to $COLL" ;;
|
|
esac
|
|
}
|
|
|
|
# ─── 5. Create collections (idempotent — skips if already exist) ─────────────
|
|
|
|
create_collection "books" '{
|
|
"name": "books",
|
|
"type": "base",
|
|
"fields": [
|
|
{"name": "slug", "type": "text", "required": true},
|
|
{"name": "title", "type": "text", "required": true},
|
|
{"name": "author", "type": "text"},
|
|
{"name": "cover", "type": "text"},
|
|
{"name": "status", "type": "text"},
|
|
{"name": "genres", "type": "json"},
|
|
{"name": "summary", "type": "text"},
|
|
{"name": "total_chapters", "type": "number"},
|
|
{"name": "source_url", "type": "text"},
|
|
{"name": "ranking", "type": "number"},
|
|
{"name": "meta_updated", "type": "date"}
|
|
]
|
|
}'
|
|
|
|
create_collection "chapters_idx" '{
|
|
"name": "chapters_idx",
|
|
"type": "base",
|
|
"fields": [
|
|
{"name": "slug", "type": "text", "required": true},
|
|
{"name": "number", "type": "number", "required": true},
|
|
{"name": "title", "type": "text"},
|
|
{"name": "date_label", "type": "text"}
|
|
]
|
|
}'
|
|
|
|
create_collection "ranking" '{
|
|
"name": "ranking",
|
|
"type": "base",
|
|
"fields": [
|
|
{"name": "rank", "type": "number", "required": true},
|
|
{"name": "slug", "type": "text", "required": true},
|
|
{"name": "title", "type": "text"},
|
|
{"name": "author", "type": "text"},
|
|
{"name": "cover", "type": "text"},
|
|
{"name": "status", "type": "text"},
|
|
{"name": "genres", "type": "json"},
|
|
{"name": "source_url", "type": "text"},
|
|
{"name": "updated", "type": "date"}
|
|
]
|
|
}'
|
|
|
|
create_collection "progress" '{
|
|
"name": "progress",
|
|
"type": "base",
|
|
"fields": [
|
|
{"name": "session_id", "type": "text", "required": true},
|
|
{"name": "user_id", "type": "text"},
|
|
{"name": "slug", "type": "text", "required": true},
|
|
{"name": "chapter", "type": "number"},
|
|
{"name": "updated", "type": "date"}
|
|
]
|
|
}'
|
|
|
|
create_collection "audio_cache" '{
|
|
"name": "audio_cache",
|
|
"type": "base",
|
|
"fields": [
|
|
{"name": "cache_key", "type": "text", "required": true},
|
|
{"name": "filename", "type": "text"},
|
|
{"name": "updated", "type": "date"}
|
|
]
|
|
}'
|
|
|
|
create_collection "app_users" '{
|
|
"name": "app_users",
|
|
"type": "base",
|
|
"fields": [
|
|
{"name": "username", "type": "text", "required": true},
|
|
{"name": "password_hash", "type": "text", "required": true},
|
|
{"name": "role", "type": "text"},
|
|
{"name": "created", "type": "date"},
|
|
{"name": "avatar_url", "type": "text"}
|
|
]
|
|
}'
|
|
|
|
create_collection "user_settings" '{
|
|
"name": "user_settings",
|
|
"type": "base",
|
|
"fields": [
|
|
{"name": "session_id", "type": "text", "required": true},
|
|
{"name": "user_id", "type": "text"},
|
|
{"name": "auto_next", "type": "bool"},
|
|
{"name": "voice", "type": "text"},
|
|
{"name": "speed", "type": "number"},
|
|
{"name": "updated", "type": "date"}
|
|
]
|
|
}'
|
|
|
|
# ─── 6. Schema migrations (idempotent field additions) ───────────────────────
|
|
# Ensures fields added after initial deploy are present in existing instances.
|
|
|
|
ensure_field "progress" "user_id" "text"
|
|
ensure_field "progress" "audio_time" "number"
|
|
ensure_field "user_settings" "user_id" "text"
|
|
ensure_field "app_users" "avatar_url" "text"
|
|
|
|
create_collection "book_comments" '{
|
|
"name": "book_comments",
|
|
"type": "base",
|
|
"fields": [
|
|
{"name": "slug", "type": "text", "required": true},
|
|
{"name": "user_id", "type": "text"},
|
|
{"name": "username", "type": "text"},
|
|
{"name": "body", "type": "text", "required": true},
|
|
{"name": "upvotes", "type": "number"},
|
|
{"name": "downvotes", "type": "number"},
|
|
{"name": "created", "type": "date"},
|
|
{"name": "parent_id", "type": "text"}
|
|
]
|
|
}'
|
|
|
|
create_collection "comment_votes" '{
|
|
"name": "comment_votes",
|
|
"type": "base",
|
|
"fields": [
|
|
{"name": "comment_id", "type": "text", "required": true},
|
|
{"name": "user_id", "type": "text"},
|
|
{"name": "session_id", "type": "text", "required": true},
|
|
{"name": "vote", "type": "text", "required": true}
|
|
]
|
|
}'
|
|
|
|
create_collection "scraping_tasks" '{
|
|
"name": "scraping_tasks",
|
|
"type": "base",
|
|
"fields": [
|
|
{"name": "kind", "type": "text"},
|
|
{"name": "target_url", "type": "text"},
|
|
{"name": "from_chapter", "type": "number"},
|
|
{"name": "to_chapter", "type": "number"},
|
|
{"name": "worker_id", "type": "text"},
|
|
{"name": "status", "type": "text", "required": true},
|
|
{"name": "books_found", "type": "number"},
|
|
{"name": "chapters_scraped", "type": "number"},
|
|
{"name": "chapters_skipped", "type": "number"},
|
|
{"name": "errors", "type": "number"},
|
|
{"name": "error_message", "type": "text"},
|
|
{"name": "started", "type": "date"},
|
|
{"name": "finished", "type": "date"}
|
|
]
|
|
}'
|
|
|
|
create_collection "audio_jobs" '{
|
|
"name": "audio_jobs",
|
|
"type": "base",
|
|
"fields": [
|
|
{"name": "cache_key", "type": "text", "required": true},
|
|
{"name": "slug", "type": "text", "required": true},
|
|
{"name": "chapter", "type": "number", "required": true},
|
|
{"name": "voice", "type": "text"},
|
|
{"name": "worker_id", "type": "text"},
|
|
{"name": "status", "type": "text", "required": true},
|
|
{"name": "error_message", "type": "text"},
|
|
{"name": "started", "type": "date"},
|
|
{"name": "finished", "type": "date"}
|
|
]
|
|
}'
|
|
|
|
create_collection "user_library" '{
|
|
"name": "user_library",
|
|
"type": "base",
|
|
"fields": [
|
|
{"name": "session_id", "type": "text", "required": true},
|
|
{"name": "user_id", "type": "text"},
|
|
{"name": "slug", "type": "text", "required": true},
|
|
{"name": "saved_at", "type": "date"}
|
|
]
|
|
}'
|
|
|
|
create_collection "user_sessions" '{
|
|
"name": "user_sessions",
|
|
"type": "base",
|
|
"fields": [
|
|
{"name": "user_id", "type": "text", "required": true},
|
|
{"name": "session_id", "type": "text", "required": true},
|
|
{"name": "user_agent", "type": "text"},
|
|
{"name": "ip", "type": "text"},
|
|
{"name": "created_at", "type": "date"},
|
|
{"name": "last_seen", "type": "date"}
|
|
]
|
|
}'
|
|
|
|
create_collection "user_subscriptions" '{
|
|
"name": "user_subscriptions",
|
|
"type": "base",
|
|
"fields": [
|
|
{"name": "follower_id", "type": "text", "required": true},
|
|
{"name": "followee_id", "type": "text", "required": true},
|
|
{"name": "created", "type": "date"}
|
|
]
|
|
}'
|
|
|
|
# ─── 7. Post-initial-deploy field additions ───────────────────────────────────
|
|
# heartbeat_at is used by the backend runner to detect stale tasks.
|
|
ensure_field "scraping_tasks" "heartbeat_at" "date"
|
|
ensure_field "audio_jobs" "heartbeat_at" "date"
|
|
|
|
log "all collections ready"
|