/** * Server-side PocketBase client. * Uses admin credentials — never import this from client-side code. * All methods talk directly to PocketBase REST API. */ import { env } from '$env/dynamic/private'; import { log } from '$lib/server/logger'; const PB_URL = env.POCKETBASE_URL ?? 'http://localhost:8090'; const PB_EMAIL = env.POCKETBASE_ADMIN_EMAIL ?? 'admin@libnovel.local'; const PB_PASSWORD = env.POCKETBASE_ADMIN_PASSWORD ?? 'changeme123'; // ─── Types ──────────────────────────────────────────────────────────────────── export interface Book { id: string; slug: string; title: string; author: string; cover: string; status: string; genres: string[] | string; summary: string; total_chapters: number; source_url: string; ranking: number; meta_updated: string; } export interface ChapterIdx { id: string; slug: string; number: number; title: string; date_label: string; } export interface Progress { session_id: string; slug: string; chapter: number; updated: string; } export interface User { id: string; username: string; password_hash: string; role: string; created: string; } // ─── Auth token cache ───────────────────────────────────────────────────────── let _token = ''; let _tokenExp = 0; async function getToken(): Promise { if (_token && Date.now() < _tokenExp) return _token; log.debug('pocketbase', 'authenticating with admin credentials', { url: PB_URL, email: PB_EMAIL }); const res = await fetch(`${PB_URL}/api/collections/_superusers/auth-with-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }) }); if (!res.ok) { const body = await res.text().catch(() => ''); log.error('pocketbase', 'admin auth failed', { status: res.status, url: PB_URL, body }); throw new Error(`PocketBase auth failed: ${res.status} — ${body}`); } const data = await res.json(); _token = data.token as string; _tokenExp = Date.now() + 12 * 60 * 60 * 1000; // 12 hours log.info('pocketbase', 'admin auth token refreshed', { url: PB_URL }); return _token; } // ─── Generic helpers ────────────────────────────────────────────────────────── async function pbGet(path: string): Promise { const token = await getToken(); const res = await fetch(`${PB_URL}${path}`, { headers: { Authorization: `Bearer ${token}` } }); if (!res.ok) { const body = await res.text().catch(() => ''); log.error('pocketbase', 'GET failed', { path, status: res.status, body }); throw new Error(`PocketBase GET ${path} failed: ${res.status} — ${body}`); } return res.json() as Promise; } async function pbPost(path: string, body: unknown): Promise { const token = await getToken(); return fetch(`${PB_URL}${path}`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); } async function pbPatch(path: string, body: unknown): Promise { const token = await getToken(); return fetch(`${PB_URL}${path}`, { method: 'PATCH', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); } interface PBList { items: T[]; totalItems: number; } async function listAll(collection: string, filter = '', sort = ''): Promise { const params = new URLSearchParams({ perPage: '500' }); if (filter) params.set('filter', filter); if (sort) params.set('sort', sort); const data = await pbGet>( `/api/collections/${collection}/records?${params.toString()}` ); return data.items ?? []; } async function listOne(collection: string, filter: string): Promise { const params = new URLSearchParams({ perPage: '1', filter }); const data = await pbGet>( `/api/collections/${collection}/records?${params.toString()}` ); return data.items[0] ?? null; } // ─── Books ──────────────────────────────────────────────────────────────────── export async function listBooks(): Promise { const books = await listAll('books', '', '+title'); const nullTitles = books.filter((b) => b.title == null).length; if (nullTitles > 0) { log.warn('pocketbase', 'listBooks: books with null title', { count: nullTitles, total: books.length }); } log.debug('pocketbase', 'listBooks', { total: books.length, nullTitles }); return books; } export async function getBook(slug: string): Promise { return listOne('books', `slug="${slug}"`); } // ─── Chapter index ──────────────────────────────────────────────────────────── export async function listChapterIdx(slug: string): Promise { return listAll('chapters_idx', `slug="${slug}"`, '+number'); } // ─── Reading progress ───────────────────────────────────────────────────────── export async function getProgress(sessionId: string, slug: string): Promise { return listOne('progress', `session_id="${sessionId}"&&slug="${slug}"`); } export async function allProgress(sessionId: string): Promise { return listAll('progress', `session_id="${sessionId}"`, '-updated'); } export async function setProgress(sessionId: string, slug: string, chapter: number): Promise { const existing = await listOne( 'progress', `session_id="${sessionId}"&&slug="${slug}"` ); const payload = { session_id: sessionId, slug, chapter, updated: new Date().toISOString() }; if (existing) { const res = await pbPatch(`/api/collections/progress/records/${existing.id}`, payload); if (!res.ok) { const body = await res.text().catch(() => ''); log.error('pocketbase', 'setProgress PATCH failed', { slug, chapter, status: res.status, body }); } } else { const res = await pbPost('/api/collections/progress/records', payload); if (!res.ok) { const body = await res.text().catch(() => ''); log.error('pocketbase', 'setProgress POST failed', { slug, chapter, status: res.status, body }); } } } // ─── Users ──────────────────────────────────────────────────────────────────── import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto'; function hashPassword(password: string): string { const salt = randomBytes(16).toString('hex'); const hash = scryptSync(password, salt, 64).toString('hex'); return `${salt}:${hash}`; } function verifyPassword(password: string, stored: string): boolean { const [salt, hash] = stored.split(':'); if (!salt || !hash) return false; const derived = scryptSync(password, salt, 64); const hashBuf = Buffer.from(hash, 'hex'); if (derived.length !== hashBuf.length) return false; return timingSafeEqual(derived, hashBuf); } /** * Look up a user by username. Returns null if not found. */ export async function getUserByUsername(username: string): Promise { return listOne('app_users', `username="${username.replace(/"/g, '\\"')}"`); } /** * Create a new user with a hashed password. Throws if username already exists. */ export async function createUser(username: string, password: string, role = 'user'): Promise { log.info('pocketbase', 'createUser: checking for existing username', { username }); const existing = await getUserByUsername(username); if (existing) { log.warn('pocketbase', 'createUser: username already taken', { username }); throw new Error('Username already taken'); } const password_hash = hashPassword(password); log.info('pocketbase', 'createUser: inserting new user', { username, role }); const res = await pbPost('/api/collections/app_users/records', { username, password_hash, role, created: new Date().toISOString() }); if (!res.ok) { const body = await res.text().catch(() => ''); log.error('pocketbase', 'createUser: PocketBase rejected record', { username, status: res.status, body }); throw new Error(`Failed to create user: ${res.status} ${body}`); } log.info('pocketbase', 'createUser: user created', { username, role }); return res.json() as Promise; } /** * Verify username + password. Returns the user on success, null on failure. */ export async function loginUser(username: string, password: string): Promise { log.debug('pocketbase', 'loginUser: lookup', { username }); const user = await getUserByUsername(username); if (!user) { log.warn('pocketbase', 'loginUser: username not found', { username }); return null; } const ok = verifyPassword(password, user.password_hash); if (!ok) { log.warn('pocketbase', 'loginUser: wrong password', { username }); return null; } log.info('pocketbase', 'loginUser: success', { username, role: user.role }); return user; }