- Added announce_chapter (bool) to the user_settings create block - Added add_field migration line for existing installs - Also backfilled missing user_settings fields in the create block (theme, locale, font_family, font_size were already migrated but absent from the create definition) - Migrated live prod PocketBase (pb.libnovel.cc) — field confirmed present
366 lines
15 KiB
Bash
Executable File
366 lines
15 KiB
Bash
Executable File
#!/bin/sh
|
|
# pb-init-v3.sh — idempotent PocketBase bootstrap for the v3 stack.
|
|
#
|
|
# Safe to re-run: existing collections and fields are silently skipped.
|
|
#
|
|
# Env vars (defaults match docker-compose.yml):
|
|
# POCKETBASE_URL http://pocketbase:8090
|
|
# POCKETBASE_ADMIN_EMAIL admin@libnovel.local
|
|
# POCKETBASE_ADMIN_PASSWORD changeme123
|
|
|
|
set -e
|
|
|
|
PB="${POCKETBASE_URL:-http://pocketbase:8090}"
|
|
EMAIL="${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"
|
|
PASS="${POCKETBASE_ADMIN_PASSWORD:-changeme123}"
|
|
|
|
log() { printf '[pb-init] %s\n' "$*"; }
|
|
|
|
# ── 0. Ensure dependencies ────────────────────────────────────────────────────
|
|
command -v curl > /dev/null 2>&1 || apk add --no-cache curl > /dev/null 2>&1
|
|
command -v python3 > /dev/null 2>&1 || apk add --no-cache python3 > /dev/null 2>&1
|
|
|
|
# ── 1. Wait for PocketBase ────────────────────────────────────────────────────
|
|
log "waiting for PocketBase..."
|
|
until curl -sf "$PB/api/health" > /dev/null 2>&1; do sleep 2; done
|
|
log "PocketBase ready"
|
|
|
|
# ── 2. Bootstrap superuser (first-run only) ───────────────────────────────────
|
|
LOCATION=$(curl -sf -o /dev/null -w "%{redirect_url}" "$PB/_/" 2>/dev/null || true)
|
|
if echo "$LOCATION" | grep -q "pbinstal/"; then
|
|
TOKEN=$(echo "$LOCATION" | sed 's|.*pbinstal/||' | tr -d ' \r\n')
|
|
curl -sf -X POST "$PB/api/collections/_superusers/records" \
|
|
-H "Content-Type: application/json" \
|
|
-H "Authorization: Bearer $TOKEN" \
|
|
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASS\",\"passwordConfirm\":\"$PASS\"}" \
|
|
> /dev/null 2>&1 || true
|
|
log "superuser created"
|
|
fi
|
|
|
|
# ── 3. Authenticate ───────────────────────────────────────────────────────────
|
|
AUTH=$(curl -sf -X POST "$PB/api/collections/_superusers/auth-with-password" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"identity\":\"$EMAIL\",\"password\":\"$PASS\"}")
|
|
TOK=$(echo "$AUTH" | sed 's/.*"token":"\([^"]*\)".*/\1/')
|
|
[ -z "$TOK" ] || [ "$TOK" = "$AUTH" ] && { log "ERROR: auth failed"; exit 1; }
|
|
log "authenticated"
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
# create NAME BODY — POST collection; 400/422 = already exists, treated as ok.
|
|
create() {
|
|
NAME="$1"; BODY="$2"
|
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
|
-X POST "$PB/api/collections" \
|
|
-H "Content-Type: application/json" \
|
|
-H "Authorization: Bearer $TOK" \
|
|
-d "$BODY")
|
|
case "$STATUS" in
|
|
200|201) log "created: $NAME" ;;
|
|
400|422) log "exists (skip): $NAME" ;;
|
|
*) log "WARNING: $NAME returned $STATUS" ;;
|
|
esac
|
|
}
|
|
|
|
# add_index COLLECTION INDEX_NAME SQL_EXPR
|
|
# Fetches current schema, adds index if absent by name, PATCHes collection.
|
|
add_index() {
|
|
COLL="$1"; INAME="$2"; ISQL="$3"
|
|
SCHEMA=$(curl -sf -H "Authorization: Bearer $TOK" "$PB/api/collections/$COLL" 2>/dev/null)
|
|
PARSED=$(echo "$SCHEMA" | python3 -c "
|
|
import sys, json
|
|
d = json.load(sys.stdin)
|
|
indexes = d.get('indexes', [])
|
|
exists = any('$INAME' in idx for idx in indexes)
|
|
print('exists=' + str(exists))
|
|
print('id=' + d.get('id', ''))
|
|
if not exists:
|
|
indexes.append('$ISQL')
|
|
print('indexes=' + json.dumps(indexes))
|
|
" 2>/dev/null)
|
|
if echo "$PARSED" | grep -q "^exists=True"; then
|
|
log "index exists (skip): $COLL.$INAME"; return
|
|
fi
|
|
COLL_ID=$(echo "$PARSED" | grep "^id=" | sed 's/^id=//')
|
|
[ -z "$COLL_ID" ] && { log "WARNING: cannot resolve id for $COLL"; return; }
|
|
NEW_INDEXES=$(echo "$PARSED" | grep "^indexes=" | sed 's/^indexes=//')
|
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
|
-X PATCH "$PB/api/collections/$COLL_ID" \
|
|
-H "Content-Type: application/json" \
|
|
-H "Authorization: Bearer $TOK" \
|
|
-d "{\"indexes\":${NEW_INDEXES}}")
|
|
case "$STATUS" in
|
|
200|201) log "added index: $COLL.$INAME" ;;
|
|
*) log "WARNING: add_index $COLL.$INAME returned $STATUS" ;;
|
|
esac
|
|
}
|
|
|
|
# add_field COLLECTION FIELD_NAME FIELD_TYPE
|
|
# Fetches current schema, appends field if absent, PATCHes collection.
|
|
# Requires python3 for safe JSON manipulation.
|
|
add_field() {
|
|
COLL="$1"; FIELD="$2"; TYPE="$3"
|
|
SCHEMA=$(curl -sf -H "Authorization: Bearer $TOK" "$PB/api/collections/$COLL" 2>/dev/null)
|
|
# Check existence and extract collection id + fields via python3
|
|
PARSED=$(echo "$SCHEMA" | python3 -c "
|
|
import sys, json
|
|
d = json.load(sys.stdin)
|
|
fields = d.get('fields', [])
|
|
exists = any(f.get('name') == '$FIELD' for f in fields)
|
|
print('exists=' + str(exists))
|
|
print('id=' + d.get('id', ''))
|
|
if not exists:
|
|
fields.append({'name': '$FIELD', 'type': '$TYPE'})
|
|
print('fields=' + json.dumps(fields))
|
|
" 2>/dev/null)
|
|
if echo "$PARSED" | grep -q "^exists=True"; then
|
|
log "field exists (skip): $COLL.$FIELD"; return
|
|
fi
|
|
COLL_ID=$(echo "$PARSED" | grep "^id=" | sed 's/^id=//')
|
|
[ -z "$COLL_ID" ] && { log "WARNING: cannot resolve id for $COLL"; return; }
|
|
NEW_FIELDS=$(echo "$PARSED" | grep "^fields=" | sed 's/^fields=//')
|
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
|
-X PATCH "$PB/api/collections/$COLL_ID" \
|
|
-H "Content-Type: application/json" \
|
|
-H "Authorization: Bearer $TOK" \
|
|
-d "{\"fields\":${NEW_FIELDS}}")
|
|
case "$STATUS" in
|
|
200|201) log "added field: $COLL.$FIELD ($TYPE)" ;;
|
|
*) log "WARNING: add_field $COLL.$FIELD returned $STATUS" ;;
|
|
esac
|
|
}
|
|
|
|
# ── 4. Collections ────────────────────────────────────────────────────────────
|
|
|
|
create "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":"text"}
|
|
]}'
|
|
|
|
create "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":"created", "type":"date"}
|
|
]}'
|
|
|
|
create "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"}
|
|
]}'
|
|
|
|
create "progress" '{
|
|
"name":"progress","type":"base","fields":[
|
|
{"name":"session_id","type":"text", "required":true},
|
|
{"name":"slug", "type":"text", "required":true},
|
|
{"name":"chapter", "type":"number"},
|
|
{"name":"user_id", "type":"text"},
|
|
{"name":"audio_time","type":"number"},
|
|
{"name":"updated", "type":"text"}
|
|
]}'
|
|
|
|
create "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"},
|
|
{"name":"heartbeat_at", "type":"date"}
|
|
]}'
|
|
|
|
create "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"},
|
|
{"name":"heartbeat_at", "type":"date"}
|
|
]}'
|
|
|
|
create "app_users" '{
|
|
"name":"app_users","type":"base","fields":[
|
|
{"name":"username", "type":"text","required":true},
|
|
{"name":"password_hash", "type":"text"},
|
|
{"name":"role", "type":"text"},
|
|
{"name":"avatar_url", "type":"text"},
|
|
{"name":"created", "type":"text"},
|
|
{"name":"email", "type":"text"},
|
|
{"name":"email_verified", "type":"bool"},
|
|
{"name":"verification_token", "type":"text"},
|
|
{"name":"verification_token_exp","type":"text"},
|
|
{"name":"oauth_provider", "type":"text"},
|
|
{"name":"oauth_id", "type":"text"}
|
|
]}'
|
|
|
|
create "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":"device_fingerprint", "type":"text"},
|
|
{"name":"created_at", "type":"text"},
|
|
{"name":"last_seen", "type":"text"}
|
|
]}'
|
|
|
|
create "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":"text"}
|
|
]}'
|
|
|
|
create "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":"theme", "type":"text"},
|
|
{"name":"locale", "type":"text"},
|
|
{"name":"font_family", "type":"text"},
|
|
{"name":"font_size", "type":"number"},
|
|
{"name":"announce_chapter","type":"bool"},
|
|
{"name":"updated", "type":"text"}
|
|
]}'
|
|
|
|
create "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":"text"}
|
|
]}'
|
|
|
|
create "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"},
|
|
{"name":"upvotes", "type":"number"},
|
|
{"name":"downvotes","type":"number"},
|
|
{"name":"parent_id","type":"text"},
|
|
{"name":"created", "type":"text"}
|
|
]}'
|
|
|
|
create "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"},
|
|
{"name":"vote", "type":"text"}
|
|
]}'
|
|
|
|
create "translation_jobs" '{
|
|
"name":"translation_jobs","type":"base","fields":[
|
|
{"name":"cache_key", "type":"text", "required":true},
|
|
{"name":"slug", "type":"text", "required":true},
|
|
{"name":"chapter", "type":"number","required":true},
|
|
{"name":"lang", "type":"text", "required":true},
|
|
{"name":"worker_id", "type":"text"},
|
|
{"name":"status", "type":"text", "required":true},
|
|
{"name":"error_message","type":"text"},
|
|
{"name":"started", "type":"date"},
|
|
{"name":"finished", "type":"date"},
|
|
{"name":"heartbeat_at", "type":"date"}
|
|
]}'
|
|
|
|
create "ai_jobs" '{
|
|
"name":"ai_jobs","type":"base","fields":[
|
|
{"name":"kind", "type":"text", "required":true},
|
|
{"name":"slug", "type":"text"},
|
|
{"name":"status", "type":"text", "required":true},
|
|
{"name":"from_item", "type":"number"},
|
|
{"name":"to_item", "type":"number"},
|
|
{"name":"items_done", "type":"number"},
|
|
{"name":"items_total", "type":"number"},
|
|
{"name":"model", "type":"text"},
|
|
{"name":"payload", "type":"text"},
|
|
{"name":"error_message", "type":"text"},
|
|
{"name":"started", "type":"date"},
|
|
{"name":"finished", "type":"date"},
|
|
{"name":"heartbeat_at", "type":"date"}
|
|
]}'
|
|
|
|
create "discovery_votes" '{
|
|
"name":"discovery_votes","type":"base","fields":[
|
|
{"name":"session_id","type":"text","required":true},
|
|
{"name":"user_id", "type":"text"},
|
|
{"name":"slug", "type":"text","required":true},
|
|
{"name":"action", "type":"text","required":true}
|
|
]}'
|
|
|
|
create "book_ratings" '{
|
|
"name":"book_ratings","type":"base","fields":[
|
|
{"name":"session_id","type":"text", "required":true},
|
|
{"name":"user_id", "type":"text"},
|
|
{"name":"slug", "type":"text", "required":true},
|
|
{"name":"rating", "type":"number", "required":true}
|
|
]}'
|
|
|
|
# ── 5. Field migrations (idempotent — adds fields missing from older installs) ─
|
|
add_field "scraping_tasks" "heartbeat_at" "date"
|
|
add_field "audio_jobs" "heartbeat_at" "date"
|
|
add_field "progress" "user_id" "text"
|
|
add_field "progress" "audio_time" "number"
|
|
add_field "progress" "updated" "text"
|
|
add_field "books" "meta_updated" "text"
|
|
add_field "app_users" "email" "text"
|
|
add_field "app_users" "email_verified" "bool"
|
|
add_field "app_users" "verification_token" "text"
|
|
add_field "app_users" "verification_token_exp" "text"
|
|
add_field "app_users" "oauth_provider" "text"
|
|
add_field "app_users" "oauth_id" "text"
|
|
add_field "app_users" "polar_customer_id" "text"
|
|
add_field "app_users" "polar_subscription_id" "text"
|
|
add_field "user_library" "shelf" "text"
|
|
add_field "user_sessions" "device_fingerprint" "text"
|
|
add_field "chapters_idx" "created" "date"
|
|
add_field "user_settings" "theme" "text"
|
|
add_field "user_settings" "locale" "text"
|
|
add_field "user_settings" "font_family" "text"
|
|
add_field "user_settings" "font_size" "number"
|
|
add_field "user_settings" "announce_chapter" "bool"
|
|
|
|
# ── 6. Indexes ────────────────────────────────────────────────────────────────
|
|
add_index "chapters_idx" "idx_chapters_idx_slug_number" \
|
|
"CREATE UNIQUE INDEX idx_chapters_idx_slug_number ON chapters_idx (slug, number)"
|
|
add_index "chapters_idx" "idx_chapters_idx_created" \
|
|
"CREATE INDEX idx_chapters_idx_created ON chapters_idx (created)"
|
|
|
|
log "done"
|