/** * 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 { id?: string; session_id: string; user_id?: string; slug: string; chapter: number; audio_time?: number; updated: string; } export interface UserSettings { id?: string; session_id: string; user_id?: string; auto_next: boolean; voice: string; speed: 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 perPage = 500; const params = new URLSearchParams({ perPage: String(perPage), page: '1' }); if (filter) params.set('filter', filter); if (sort) params.set('sort', sort); const first = await pbGet>( `/api/collections/${collection}/records?${params.toString()}` ); const items: T[] = first.items ?? []; const total = first.totalItems ?? 0; // Fetch remaining pages if there are more records than the first page holds. const totalPages = Math.ceil(total / perPage); for (let page = 2; page <= totalPages; page++) { params.set('page', String(page)); const data = await pbGet>( `/api/collections/${collection}/records?${params.toString()}` ); items.push(...(data.items ?? [])); } return items; } async function listN(collection: string, n: number, filter = '', sort = ''): Promise { const params = new URLSearchParams({ perPage: String(n) }); 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 countCollection(collection: string, filter = ''): Promise { const params = new URLSearchParams({ perPage: '1' }); if (filter) params.set('filter', filter); const data = await pbGet>( `/api/collections/${collection}/records?${params.toString()}` ); return (data as { totalItems: number }).totalItems ?? 0; } 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}"`); } export async function recentlyAddedBooks(limit = 6): Promise { return listN('books', limit, '', '-meta_updated'); } export async function recentlyUpdatedBooks(limit = 6): Promise { return listN('books', limit, '', '-meta_updated'); } export interface HomeStats { totalBooks: number; totalChapters: number; } export async function getHomeStats(): Promise { const [totalBooks, totalChapters] = await Promise.all([ countCollection('books'), countCollection('chapters_idx') ]); return { totalBooks, totalChapters }; } // ─── Chapter index ──────────────────────────────────────────────────────────── export async function listChapterIdx(slug: string): Promise { return listAll('chapters_idx', `slug="${slug}"`, '+number'); } // ─── Reading progress ───────────────────────────────────────────────────────── /** * Build the PocketBase filter string for a progress lookup. * When userId is set, keyed by user_id (portable across devices). * When only sessionId is set, keyed by session_id (anonymous). */ function progressFilter(sessionId: string, slug: string, userId?: string): string { if (userId) return `user_id="${userId}"&&slug="${slug}"`; return `session_id="${sessionId}"&&slug="${slug}"`; } function allProgressFilter(sessionId: string, userId?: string): string { if (userId) return `user_id="${userId}"`; return `session_id="${sessionId}"`; } export async function getProgress( sessionId: string, slug: string, userId?: string ): Promise { return listOne('progress', progressFilter(sessionId, slug, userId)); } export async function allProgress(sessionId: string, userId?: string): Promise { return listAll('progress', allProgressFilter(sessionId, userId), '-updated'); } export async function setProgress( sessionId: string, slug: string, chapter: number, userId?: string ): Promise { const existing = await listOne( 'progress', progressFilter(sessionId, slug, userId) ); const payload: Partial = { session_id: sessionId, slug, chapter, updated: new Date().toISOString() }; if (userId) payload.user_id = userId; 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 }); } } } /** * Merge anonymous session progress into a user account on login/register. * * For each book tracked under sessionId, upserts a user-keyed record keeping * whichever chapter is more recent (or higher if timestamps are equal). * This makes progress portable across devices for logged-in users. */ export async function mergeSessionProgress(sessionId: string, userId: string): Promise { let sessionRows: Progress[]; try { sessionRows = await allProgress(sessionId); } catch (e) { log.warn('pocketbase', 'mergeSessionProgress: failed to read session progress', { sessionId, err: String(e) }); return; } if (sessionRows.length === 0) return; for (const row of sessionRows) { try { const userRow = await listOne( 'progress', `user_id="${userId}"&&slug="${row.slug}"` ); // Keep the record with the more recent update (or higher chapter if timestamps match) const sessionTs = row.updated ? new Date(row.updated).getTime() : 0; const userTs = userRow?.updated ? new Date(userRow.updated).getTime() : 0; const shouldOverwrite = !userRow || sessionTs > userTs || (sessionTs === userTs && row.chapter > (userRow?.chapter ?? 0)); if (shouldOverwrite) { const payload: Partial = { session_id: sessionId, user_id: userId, slug: row.slug, chapter: row.chapter, updated: row.updated ?? new Date().toISOString() }; if (userRow) { await pbPatch(`/api/collections/progress/records/${userRow.id}`, payload); } else { await pbPost('/api/collections/progress/records', payload); } } } catch (e) { log.warn('pocketbase', 'mergeSessionProgress: failed to merge row', { slug: row.slug, err: String(e) }); } } log.info('pocketbase', 'mergeSessionProgress: done', { sessionId, userId, count: sessionRows.length }); } // ─── User library (saved books) ─────────────────────────────────────────────── export interface UserLibraryEntry { id?: string; session_id: string; user_id?: string; slug: string; saved_at: string; } function libraryFilter(sessionId: string, userId?: string): string { if (userId) return `user_id="${userId}"`; return `session_id="${sessionId}"`; } /** Returns all slugs the user has explicitly saved to their library. */ export async function getSavedSlugs(sessionId: string, userId?: string): Promise> { const rows = await listAll( 'user_library', libraryFilter(sessionId, userId) ); return new Set(rows.map((r) => r.slug)); } /** Returns whether a specific slug is saved. */ export async function isBookSaved( sessionId: string, slug: string, userId?: string ): Promise { const filter = userId ? `user_id="${userId}"&&slug="${slug}"` : `session_id="${sessionId}"&&slug="${slug}"`; const row = await listOne('user_library', filter); return row !== null; } /** Save a book to the user's library. No-op if already saved. */ export async function saveBook( sessionId: string, slug: string, userId?: string ): Promise { const alreadySaved = await isBookSaved(sessionId, slug, userId); if (alreadySaved) return; const payload: Partial = { session_id: sessionId, slug, saved_at: new Date().toISOString() }; if (userId) payload.user_id = userId; const res = await pbPost('/api/collections/user_library/records', payload); if (!res.ok) { const body = await res.text().catch(() => ''); log.error('pocketbase', 'saveBook POST failed', { slug, status: res.status, body }); } } /** Remove a book from the user's library. */ export async function unsaveBook( sessionId: string, slug: string, userId?: string ): Promise { const filter = userId ? `user_id="${userId}"&&slug="${slug}"` : `session_id="${sessionId}"&&slug="${slug}"`; const row = await listOne('user_library', filter); if (!row) return; const token = await getToken(); await fetch(`${PB_URL}/api/collections/user_library/records/${row.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } }); } // ─── 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; } /** * Change a user's password. Verifies the current password first. * Returns true on success, false if currentPassword is wrong. * Throws on unexpected errors. */ export async function changePassword( userId: string, currentPassword: string, newPassword: string ): Promise { // Fetch the user record directly by id to verify current password const token = await getToken(); const res = await fetch(`${PB_URL}/api/collections/app_users/records/${userId}`, { headers: { Authorization: `Bearer ${token}` } }); if (!res.ok) { const body = await res.text().catch(() => ''); log.error('pocketbase', 'changePassword: fetch user failed', { userId, status: res.status, body }); throw new Error(`Failed to fetch user: ${res.status}`); } const user = (await res.json()) as User; if (!verifyPassword(currentPassword, user.password_hash)) { log.warn('pocketbase', 'changePassword: wrong current password', { userId }); return false; } const newHash = hashPassword(newPassword); const patch = await pbPatch(`/api/collections/app_users/records/${userId}`, { password_hash: newHash }); if (!patch.ok) { const body = await patch.text().catch(() => ''); log.error('pocketbase', 'changePassword: PATCH failed', { userId, status: patch.status, body }); throw new Error(`Failed to update password: ${patch.status}`); } log.info('pocketbase', 'changePassword: success', { userId }); return true; } /** * 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; } // ─── User settings ──────────────────────────────────────────────────────────── function settingsFilter(sessionId: string, userId?: string): string { if (userId) return `user_id="${userId}"`; return `session_id="${sessionId}"`; } export async function getSettings( sessionId: string, userId?: string ): Promise { return listOne('user_settings', settingsFilter(sessionId, userId)); } export async function saveSettings( sessionId: string, settings: { autoNext: boolean; voice: string; speed: number }, userId?: string ): Promise { const existing = await listOne( 'user_settings', settingsFilter(sessionId, userId) ); const payload: Partial = { session_id: sessionId, auto_next: settings.autoNext, voice: settings.voice, speed: settings.speed, updated: new Date().toISOString() }; if (userId) payload.user_id = userId; if (existing) { const res = await pbPatch(`/api/collections/user_settings/records/${existing.id}`, payload); if (!res.ok) { const body = await res.text().catch(() => ''); log.error('pocketbase', 'saveSettings PATCH failed', { status: res.status, body }); } } else { const res = await pbPost('/api/collections/user_settings/records', payload); if (!res.ok) { const body = await res.text().catch(() => ''); log.error('pocketbase', 'saveSettings POST failed', { status: res.status, body }); } } } // ─── Audio time ─────────────────────────────────────────────────────────────── export async function setAudioTime( sessionId: string, slug: string, chapter: number, audioTime: number, userId?: string ): Promise { const existing = await listOne( 'progress', progressFilter(sessionId, slug, userId) ); if (!existing) { // No progress record yet — create one with audio_time const payload: Partial = { session_id: sessionId, slug, chapter, audio_time: audioTime, updated: new Date().toISOString() }; if (userId) payload.user_id = userId; const res = await pbPost('/api/collections/progress/records', payload); if (!res.ok) { const body = await res.text().catch(() => ''); log.error('pocketbase', 'setAudioTime POST failed', { slug, chapter, status: res.status, body }); } return; } const res = await pbPatch(`/api/collections/progress/records/${existing.id}`, { audio_time: audioTime, updated: new Date().toISOString() }); if (!res.ok) { const body = await res.text().catch(() => ''); log.error('pocketbase', 'setAudioTime PATCH failed', { slug, chapter, status: res.status, body }); } } // ─── Audio cache ────────────────────────────────────────────────────────────── export interface AudioCacheEntry { id: string; cache_key: string; filename: string; updated: string; } export async function listAudioCache(): Promise { return listAll('audio_cache', '', '-updated'); } // ─── Scraping tasks ─────────────────────────────────────────────────────────── export interface ScrapingTask { id: string; kind: string; target_url: string; status: string; books_found: number; chapters_scraped: number; chapters_skipped: number; errors: number; started: string; finished: string; error_message: string; } export async function listScrapingTasks(): Promise { return listAll('scraping_tasks', '', '-started'); } // ─── Audio jobs ─────────────────────────────────────────────────────────────── export interface AudioJob { id: string; cache_key: string; // "slug/chapter/voice" slug: string; chapter: number; voice: string; status: string; // "pending" | "generating" | "done" | "failed" error_message: string; started: string; finished: string; } export async function listAudioJobs(): Promise { return listAll('audio_jobs', '', '-started'); } export async function getAudioTime( sessionId: string, slug: string, chapter: number, userId?: string ): Promise { const row = await listOne('progress', progressFilter(sessionId, slug, userId)); if (!row || !row.audio_time) return null; return row.audio_time; } // ─── User sessions ──────────────────────────────────────────────────────────── export interface UserSession { id: string; user_id: string; session_id: string; // the auth session ID embedded in the token user_agent: string; ip: string; created_at: string; last_seen: string; } /** * Create a new session record on login. Returns the record ID. */ export async function createUserSession( userId: string, authSessionId: string, userAgent: string, ip: string ): Promise { const now = new Date().toISOString(); const res = await pbPost('/api/collections/user_sessions/records', { user_id: userId, session_id: authSessionId, user_agent: userAgent, ip, created_at: now, last_seen: now }); if (!res.ok) { const body = await res.text().catch(() => ''); log.error('pocketbase', 'createUserSession POST failed', { userId, status: res.status, body }); throw new Error(`Failed to create session: ${res.status}`); } const rec = (await res.json()) as { id: string }; return rec.id; } /** * Update last_seen on a session (best-effort, non-fatal if it fails). */ export async function touchUserSession(authSessionId: string): Promise { const row = await listOne( 'user_sessions', `session_id="${authSessionId}"` ); if (!row) return; const token = await getToken(); await fetch(`${PB_URL}/api/collections/user_sessions/records/${row.id}`, { method: 'PATCH', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ last_seen: new Date().toISOString() }) }); } /** * Check whether a session has been revoked (i.e., not present in DB). * Returns true if revoked/missing, false if valid. */ export async function isSessionRevoked(authSessionId: string): Promise { const row = await listOne('user_sessions', `session_id="${authSessionId}"`); return row === null; } /** * List all active sessions for a user. */ export async function listUserSessions(userId: string): Promise { return listAll('user_sessions', `user_id="${userId}"`, '-last_seen'); } /** * Revoke (delete) a specific session by its PocketBase record ID. * Only allows deletion if the session belongs to the given userId. */ export async function revokeUserSession(recordId: string, userId: string): Promise { // Verify ownership before deleting const token = await getToken(); const res = await fetch(`${PB_URL}/api/collections/user_sessions/records/${recordId}`, { headers: { Authorization: `Bearer ${token}` } }); if (!res.ok) return false; const rec = (await res.json()) as UserSession; if (rec.user_id !== userId) return false; const del = await fetch(`${PB_URL}/api/collections/user_sessions/records/${recordId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } }); return del.ok || del.status === 204; } /** * Revoke all sessions for a user (used on password change etc). */ export async function revokeAllUserSessions(userId: string): Promise { const sessions = await listUserSessions(userId); const token = await getToken(); await Promise.all( sessions.map((s) => fetch(`${PB_URL}/api/collections/user_sessions/records/${s.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } }).catch(() => {}) ) ); }