274 lines
9.4 KiB
TypeScript
274 lines
9.4 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 {
|
|
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<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 params = new URLSearchParams({ perPage: '500' });
|
|
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 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}"`);
|
|
}
|
|
|
|
// ─── Chapter index ────────────────────────────────────────────────────────────
|
|
|
|
export async function listChapterIdx(slug: string): Promise<ChapterIdx[]> {
|
|
return listAll<ChapterIdx>('chapters_idx', `slug="${slug}"`, '+number');
|
|
}
|
|
|
|
// ─── Reading progress ─────────────────────────────────────────────────────────
|
|
|
|
export async function getProgress(sessionId: string, slug: string): Promise<Progress | null> {
|
|
return listOne<Progress>('progress', `session_id="${sessionId}"&&slug="${slug}"`);
|
|
}
|
|
|
|
export async function allProgress(sessionId: string): Promise<Progress[]> {
|
|
return listAll<Progress>('progress', `session_id="${sessionId}"`, '-updated');
|
|
}
|
|
|
|
export async function setProgress(sessionId: string, slug: string, chapter: number): Promise<void> {
|
|
const existing = await listOne<Progress & { id: string }>(
|
|
'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<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>;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|