Files
libnovel/ui/src/lib/server/pocketbase.ts
Admin c6536d5b9f
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 0s
CI / Scraper / Test (pull_request) Failing after 6s
CI / UI / Build (pull_request) Failing after 6s
CI / Scraper / Lint (pull_request) Failing after 11s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
Add /admin/audio-jobs page for audio generation job history
New page mirrors the scrape tasks pattern: loads audio_jobs from
PocketBase via a new listAudioJobs() helper, shows a filterable table
(slug, chapter, voice, status, started, duration, error), and live-polls
every 3s while any job is pending or generating. Also fixes the
/admin/audio nav active-state check (was startsWith, now exact match)
to prevent it from matching /admin/audio-jobs.
2026-03-07 20:20:48 +05:00

797 lines
26 KiB
TypeScript

/**
* 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<string> {
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<T>(path: string): Promise<T> {
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<T>;
}
async function pbPost(path: string, body: unknown): Promise<Response> {
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<Response> {
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<T> {
items: T[];
totalItems: number;
}
async function listAll<T>(collection: string, filter = '', sort = ''): Promise<T[]> {
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<PBList<T>>(
`/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<PBList<T>>(
`/api/collections/${collection}/records?${params.toString()}`
);
items.push(...(data.items ?? []));
}
return items;
}
async function listN<T>(collection: string, n: number, filter = '', sort = ''): Promise<T[]> {
const params = new URLSearchParams({ perPage: String(n) });
if (filter) params.set('filter', filter);
if (sort) params.set('sort', sort);
const data = await pbGet<PBList<T>>(
`/api/collections/${collection}/records?${params.toString()}`
);
return data.items ?? [];
}
async function countCollection(collection: string, filter = ''): Promise<number> {
const params = new URLSearchParams({ perPage: '1' });
if (filter) params.set('filter', filter);
const data = await pbGet<PBList<unknown>>(
`/api/collections/${collection}/records?${params.toString()}`
);
return (data as { totalItems: number }).totalItems ?? 0;
}
async function listOne<T>(collection: string, filter: string): Promise<T | null> {
const params = new URLSearchParams({ perPage: '1', filter });
const data = await pbGet<PBList<T>>(
`/api/collections/${collection}/records?${params.toString()}`
);
return data.items[0] ?? null;
}
// ─── Books ────────────────────────────────────────────────────────────────────
export async function listBooks(): Promise<Book[]> {
const books = await listAll<Book>('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<Book | null> {
return listOne<Book>('books', `slug="${slug}"`);
}
export async function recentlyAddedBooks(limit = 6): Promise<Book[]> {
return listN<Book>('books', limit, '', '-meta_updated');
}
export async function recentlyUpdatedBooks(limit = 6): Promise<Book[]> {
return listN<Book>('books', limit, '', '-meta_updated');
}
export interface HomeStats {
totalBooks: number;
totalChapters: number;
}
export async function getHomeStats(): Promise<HomeStats> {
const [totalBooks, totalChapters] = await Promise.all([
countCollection('books'),
countCollection('chapters_idx')
]);
return { totalBooks, totalChapters };
}
// ─── Chapter index ────────────────────────────────────────────────────────────
export async function listChapterIdx(slug: string): Promise<ChapterIdx[]> {
return listAll<ChapterIdx>('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<Progress | null> {
return listOne<Progress>('progress', progressFilter(sessionId, slug, userId));
}
export async function allProgress(sessionId: string, userId?: string): Promise<Progress[]> {
return listAll<Progress>('progress', allProgressFilter(sessionId, userId), '-updated');
}
export async function setProgress(
sessionId: string,
slug: string,
chapter: number,
userId?: string
): Promise<void> {
const existing = await listOne<Progress & { id: string }>(
'progress',
progressFilter(sessionId, slug, userId)
);
const payload: Partial<Progress> = {
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<void> {
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 & { id: string }>(
'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<Progress> = {
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<Set<string>> {
const rows = await listAll<UserLibraryEntry>(
'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<boolean> {
const filter = userId
? `user_id="${userId}"&&slug="${slug}"`
: `session_id="${sessionId}"&&slug="${slug}"`;
const row = await listOne<UserLibraryEntry>('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<void> {
const alreadySaved = await isBookSaved(sessionId, slug, userId);
if (alreadySaved) return;
const payload: Partial<UserLibraryEntry> = {
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<void> {
const filter = userId
? `user_id="${userId}"&&slug="${slug}"`
: `session_id="${sessionId}"&&slug="${slug}"`;
const row = await listOne<UserLibraryEntry & { id: string }>('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<User | null> {
return listOne<User>('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<User> {
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<User>;
}
/**
* 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<boolean> {
// 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<User | null> {
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<UserSettings | null> {
return listOne<UserSettings>('user_settings', settingsFilter(sessionId, userId));
}
export async function saveSettings(
sessionId: string,
settings: { autoNext: boolean; voice: string; speed: number },
userId?: string
): Promise<void> {
const existing = await listOne<UserSettings & { id: string }>(
'user_settings',
settingsFilter(sessionId, userId)
);
const payload: Partial<UserSettings> = {
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<void> {
const existing = await listOne<Progress & { id: string }>(
'progress',
progressFilter(sessionId, slug, userId)
);
if (!existing) {
// No progress record yet — create one with audio_time
const payload: Partial<Progress> = {
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<AudioCacheEntry[]> {
return listAll<AudioCacheEntry>('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<ScrapingTask[]> {
return listAll<ScrapingTask>('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<AudioJob[]> {
return listAll<AudioJob>('audio_jobs', '', '-started');
}
export async function getAudioTime(
sessionId: string,
slug: string,
chapter: number,
userId?: string
): Promise<number | null> {
const row = await listOne<Progress>('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<string> {
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<void> {
const row = await listOne<UserSession & { id: string }>(
'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<boolean> {
const row = await listOne<UserSession>('user_sessions', `session_id="${authSessionId}"`);
return row === null;
}
/**
* List all active sessions for a user.
*/
export async function listUserSessions(userId: string): Promise<UserSession[]> {
return listAll<UserSession>('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<boolean> {
// 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<void> {
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(() => {})
)
);
}