diff --git a/ui/src/app.d.ts b/ui/src/app.d.ts index da08e6d..acf043c 100644 --- a/ui/src/app.d.ts +++ b/ui/src/app.d.ts @@ -3,7 +3,9 @@ declare global { namespace App { // interface Error {} - // interface Locals {} + interface Locals { + sessionId: string; + } // interface PageData {} // interface PageState {} // interface Platform {} diff --git a/ui/src/hooks.server.ts b/ui/src/hooks.server.ts new file mode 100644 index 0000000..dfe3870 --- /dev/null +++ b/ui/src/hooks.server.ts @@ -0,0 +1,23 @@ +import type { Handle } from '@sveltejs/kit'; +import { randomBytes } from 'node:crypto'; + +const SESSION_COOKIE = 'libnovel_session'; +const ONE_YEAR = 60 * 60 * 24 * 365; + +export const handle: Handle = async ({ event, resolve }) => { + let sessionId = event.cookies.get(SESSION_COOKIE); + + if (!sessionId) { + sessionId = randomBytes(16).toString('hex'); + event.cookies.set(SESSION_COOKIE, sessionId, { + path: '/', + httpOnly: true, + sameSite: 'lax', + maxAge: ONE_YEAR + }); + } + + event.locals.sessionId = sessionId; + + return resolve(event); +}; diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts new file mode 100644 index 0000000..c45e66c --- /dev/null +++ b/ui/src/lib/server/pocketbase.ts @@ -0,0 +1,169 @@ +/** + * 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'; + +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; +} + +// ─── Auth token cache ───────────────────────────────────────────────────────── + +let _token = ''; +let _tokenExp = 0; + +async function getToken(): Promise { + if (_token && Date.now() < _tokenExp) return _token; + + const res = await fetch(`${PB_URL}/api/admins/auth-with-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }) + }); + + if (!res.ok) { + throw new Error(`PocketBase auth failed: ${res.status}`); + } + + const data = await res.json(); + _token = data.token as string; + _tokenExp = Date.now() + 12 * 60 * 60 * 1000; // 12 hours + return _token; +} + +// ─── Generic helpers ────────────────────────────────────────────────────────── + +async function pbGet(path: string): Promise { + const token = await getToken(); + const res = await fetch(`${PB_URL}${path}`, { + headers: { Authorization: token } + }); + if (!res.ok) throw new Error(`PocketBase GET ${path} failed: ${res.status}`); + 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: 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: token, 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); +} + +function encodeFilter(filter: string) { + return encodeURIComponent(filter); +} + +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 { + return listAll('books', '', '+title'); +} + +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) { + await pbPatch(`/api/collections/progress/records/${existing.id}`, payload); + } else { + await pbPost('/api/collections/progress/records', payload); + } +}