From bf5774d8d0b27eb612d6debf1eef9a5e050294e7 Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 3 Mar 2026 14:34:48 +0500 Subject: [PATCH] feat(ui): add structured JSON logging to all server-side routes and lib Introduces a logger.ts module emitting slog-compatible JSON lines to stderr. Replaces silent catch blocks and console.error calls throughout minio.ts, pocketbase.ts, hooks.server.ts, login, books, browse, and all API routes so auth/registration failures, MinIO presign errors, and scraper proxy failures are now visible in container logs. --- ui/src/hooks.server.ts | 11 +++- ui/src/lib/server/logger.ts | 37 ++++++++++++ ui/src/lib/server/minio.ts | 33 +++++++++-- ui/src/lib/server/pocketbase.ts | 59 ++++++++++++++----- ui/src/routes/api/audio/[slug]/[n]/+server.ts | 3 + ui/src/routes/api/presign/audio/+server.ts | 2 + ui/src/routes/api/progress/+server.ts | 8 ++- ui/src/routes/api/scrape/+server.ts | 22 +++++-- ui/src/routes/books/+page.server.ts | 18 ++++-- ui/src/routes/books/[slug]/+page.server.ts | 21 +++++-- .../books/[slug]/chapters/[n]/+page.server.ts | 3 +- ui/src/routes/browse/+page.server.ts | 3 + ui/src/routes/login/+page.server.ts | 5 +- 13 files changed, 188 insertions(+), 37 deletions(-) create mode 100644 ui/src/lib/server/logger.ts diff --git a/ui/src/hooks.server.ts b/ui/src/hooks.server.ts index 5f500ee..e8ac57e 100644 --- a/ui/src/hooks.server.ts +++ b/ui/src/hooks.server.ts @@ -1,6 +1,7 @@ import type { Handle } from '@sveltejs/kit'; import { randomBytes, createHmac } from 'node:crypto'; import { env } from '$env/dynamic/private'; +import { log } from '$lib/server/logger'; const SESSION_COOKIE = 'libnovel_session'; const AUTH_COOKIE = 'libnovel_auth'; @@ -80,7 +81,15 @@ export const handle: Handle = async ({ event, resolve }) => { // Auth cookie → resolve logged-in user const authToken = event.cookies.get(AUTH_COOKIE); - event.locals.user = authToken ? parseAuthToken(authToken) : null; + if (authToken) { + const user = parseAuthToken(authToken); + if (!user) { + log.warn('auth', 'auth cookie present but failed to parse (malformed or tampered)'); + } + event.locals.user = user; + } else { + event.locals.user = null; + } return resolve(event); }; diff --git a/ui/src/lib/server/logger.ts b/ui/src/lib/server/logger.ts new file mode 100644 index 0000000..898559f --- /dev/null +++ b/ui/src/lib/server/logger.ts @@ -0,0 +1,37 @@ +/** + * Structured server-side logger. + * + * Emits JSON lines to stderr so they appear in container/process logs without + * polluting stdout (which Node's HTTP layer uses for responses). + * + * Format mirrors Go's log/slog default JSON output: + * {"time":"…","level":"ERROR","msg":"…","context":"pocketbase",...extra} + * + * Usage: + * import { log } from '$lib/server/logger'; + * log.error('pocketbase', 'auth failed', { status: 401, url }); + * log.warn('minio', 'presign slow', { slug, n, ms: elapsed }); + * log.info('auth', 'user registered', { username }); + */ + +type Level = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR'; +type Extra = Record; + +function emit(level: Level, context: string, msg: string, extra?: Extra): void { + const entry: Record = { + time: new Date().toISOString(), + level, + context, + msg, + ...extra + }; + // Write to stderr — never stdout + process.stderr.write(JSON.stringify(entry) + '\n'); +} + +export const log = { + debug: (context: string, msg: string, extra?: Extra) => emit('DEBUG', context, msg, extra), + info: (context: string, msg: string, extra?: Extra) => emit('INFO', context, msg, extra), + warn: (context: string, msg: string, extra?: Extra) => emit('WARN', context, msg, extra), + error: (context: string, msg: string, extra?: Extra) => emit('ERROR', context, msg, extra), +}; diff --git a/ui/src/lib/server/minio.ts b/ui/src/lib/server/minio.ts index 070b996..e1c276a 100644 --- a/ui/src/lib/server/minio.ts +++ b/ui/src/lib/server/minio.ts @@ -8,6 +8,7 @@ import { env } from '$env/dynamic/private'; import { env as pubEnv } from '$env/dynamic/public'; +import { log } from '$lib/server/logger'; const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; // Public MinIO URL — used to rewrite presigned URLs so the browser can reach MinIO directly. @@ -40,9 +41,21 @@ function rewriteHost(presignedUrl: string): string { * server-side (in a +page.server.ts load function) to fetch the markdown content. */ export async function presignChapter(slug: string, n: number): Promise { - const res = await fetch(`${SCRAPER_URL}/api/presign/chapter/${slug}/${n}`); - if (!res.ok) throw new Error(`presign chapter ${slug}/${n}: ${res.status}`); + log.debug('minio', 'presigning chapter', { slug, n }); + let res: Response; + try { + res = await fetch(`${SCRAPER_URL}/api/presign/chapter/${slug}/${n}`); + } catch (e) { + log.error('minio', 'presign chapter network error', { slug, n, err: String(e) }); + throw new Error(`presign chapter ${slug}/${n}: network error`); + } + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('minio', 'presign chapter failed', { slug, n, status: res.status, body }); + throw new Error(`presign chapter ${slug}/${n}: ${res.status}`); + } const data = (await res.json()) as { url: string }; + log.debug('minio', 'presign chapter ok', { slug, n }); return rewriteHost(data.url); } @@ -60,8 +73,20 @@ export async function presignAudio( if (voice) params.set('voice', voice); if (speed) params.set('speed', String(speed)); const qs = params.toString() ? `?${params.toString()}` : ''; - const res = await fetch(`${SCRAPER_URL}/api/presign/audio/${slug}/${n}${qs}`); - if (!res.ok) throw new Error(`presign audio ${slug}/${n}: ${res.status}`); + log.debug('minio', 'presigning audio', { slug, n, voice, speed }); + let res: Response; + try { + res = await fetch(`${SCRAPER_URL}/api/presign/audio/${slug}/${n}${qs}`); + } catch (e) { + log.error('minio', 'presign audio network error', { slug, n, err: String(e) }); + throw new Error(`presign audio ${slug}/${n}: network error`); + } + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('minio', 'presign audio failed', { slug, n, status: res.status, body }); + throw new Error(`presign audio ${slug}/${n}: ${res.status}`); + } const data = (await res.json()) as { url: string }; + log.debug('minio', 'presign audio ok', { slug, n }); return rewriteHost(data.url); } diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index 4eaf6b3..8a0f9c7 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -5,6 +5,7 @@ */ 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'; @@ -58,6 +59,8 @@ 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/admins/auth-with-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -65,12 +68,15 @@ async function getToken(): Promise { }); if (!res.ok) { - throw new Error(`PocketBase auth failed: ${res.status}`); + 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; } @@ -81,7 +87,11 @@ async function pbGet(path: string): Promise { const res = await fetch(`${PB_URL}${path}`, { headers: { Authorization: token } }); - if (!res.ok) throw new Error(`PocketBase GET ${path} failed: ${res.status}`); + 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; } @@ -103,10 +113,6 @@ async function pbPatch(path: string, body: unknown): Promise { }); } -function encodeFilter(filter: string) { - return encodeURIComponent(filter); -} - interface PBList { items: T[]; totalItems: number; @@ -170,9 +176,17 @@ export async function setProgress(sessionId: string, slug: string, chapter: numb }; if (existing) { - await pbPatch(`/api/collections/progress/records/${existing.id}`, payload); + 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 { - await pbPost('/api/collections/progress/records', payload); + 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 }); + } } } @@ -206,11 +220,14 @@ export async function getUserByUsername(username: string): Promise * 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/users/records', { username, password_hash, @@ -218,21 +235,33 @@ export async function createUser(username: string, password: string, role = 'use created: new Date().toISOString() }); if (!res.ok) { - const body = await res.text(); + 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 { +export async function loginUser(username: string, password: string): Promise { + log.debug('pocketbase', 'loginUser: lookup', { username }); const user = await getUserByUsername(username); - if (!user) return null; - if (!verifyPassword(password, user.password_hash)) return null; + 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; } diff --git a/ui/src/routes/api/audio/[slug]/[n]/+server.ts b/ui/src/routes/api/audio/[slug]/[n]/+server.ts index 3ceb2cb..517972c 100644 --- a/ui/src/routes/api/audio/[slug]/[n]/+server.ts +++ b/ui/src/routes/api/audio/[slug]/[n]/+server.ts @@ -1,6 +1,7 @@ import { error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { env } from '$env/dynamic/private'; +import { log } from '$lib/server/logger'; const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; @@ -35,6 +36,7 @@ export const POST: RequestHandler = async ({ params, request }) => { if (!scraperRes.ok) { const text = await scraperRes.text().catch(() => ''); + log.error('audio', 'scraper audio generation failed', { slug, chapter, status: scraperRes.status, body: text }); error(scraperRes.status as Parameters[0], text || 'Audio generation failed'); } @@ -78,6 +80,7 @@ export const GET: RequestHandler = async ({ params, url }) => { const scraperRes = await fetch(`${SCRAPER_URL}/api/audio-proxy/${slug}/${chapter}?${qs.toString()}`); if (!scraperRes.ok) { + log.error('audio', 'scraper audio proxy failed', { slug, chapter, status: scraperRes.status }); error(scraperRes.status as Parameters[0], 'Audio not found'); } diff --git a/ui/src/routes/api/presign/audio/+server.ts b/ui/src/routes/api/presign/audio/+server.ts index 5ba05f5..5cb3897 100644 --- a/ui/src/routes/api/presign/audio/+server.ts +++ b/ui/src/routes/api/presign/audio/+server.ts @@ -1,6 +1,7 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { presignAudio } from '$lib/server/minio'; +import { log } from '$lib/server/logger'; /** * GET /api/presign/audio?slug=...&n=...&voice=...&speed=... @@ -21,6 +22,7 @@ export const GET: RequestHandler = async ({ url }) => { const presignedUrl = await presignAudio(slug, n, voice, speed); return json({ url: presignedUrl }); } catch (e) { + log.error('presign', 'presign audio failed', { slug, n, err: String(e) }); error(500, `Could not get presigned URL: ${e}`); } }; diff --git a/ui/src/routes/api/progress/+server.ts b/ui/src/routes/api/progress/+server.ts index d499620..3c1cd03 100644 --- a/ui/src/routes/api/progress/+server.ts +++ b/ui/src/routes/api/progress/+server.ts @@ -1,6 +1,7 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { setProgress } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; /** * POST /api/progress @@ -14,6 +15,11 @@ export const POST: RequestHandler = async ({ request, locals }) => { error(400, 'Invalid body — expected { slug, chapter }'); } - await setProgress(locals.sessionId, body.slug, body.chapter); + try { + await setProgress(locals.sessionId, body.slug, body.chapter); + } catch (e) { + log.error('progress', 'setProgress failed', { slug: body.slug, chapter: body.chapter, err: String(e) }); + error(500, 'Failed to save progress'); + } return json({ ok: true }); }; diff --git a/ui/src/routes/api/scrape/+server.ts b/ui/src/routes/api/scrape/+server.ts index 0e06555..09327eb 100644 --- a/ui/src/routes/api/scrape/+server.ts +++ b/ui/src/routes/api/scrape/+server.ts @@ -18,6 +18,7 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { env } from '$env/dynamic/private'; +import { log } from '$lib/server/logger'; const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; @@ -39,11 +40,22 @@ export const POST: RequestHandler = async ({ request, locals }) => { const endpoint = isBookScrape ? '/scrape/book' : '/scrape'; const upstream = `${SCRAPER_URL}${endpoint}`; - const res = await fetch(upstream, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: isBookScrape ? JSON.stringify({ url: body.url }) : undefined - }); + let res: Response; + try { + res = await fetch(upstream, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: isBookScrape ? JSON.stringify({ url: body.url }) : undefined + }); + } catch (e) { + log.error('scrape', 'scraper proxy network error', { endpoint, err: String(e) }); + throw error(502, 'Could not reach scraper'); + } + + if (!res.ok && res.status >= 500) { + const text = await res.text().catch(() => ''); + log.error('scrape', 'scraper returned error', { endpoint, status: res.status, body: text }); + } const data = await res.json().catch(() => ({})); diff --git a/ui/src/routes/books/+page.server.ts b/ui/src/routes/books/+page.server.ts index 615b208..244a820 100644 --- a/ui/src/routes/books/+page.server.ts +++ b/ui/src/routes/books/+page.server.ts @@ -1,11 +1,21 @@ import type { PageServerLoad } from './$types'; import { listBooks, allProgress } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; export const load: PageServerLoad = async ({ locals }) => { - const [books, progressList] = await Promise.all([ - listBooks(), - allProgress(locals.sessionId) - ]); + let books: Awaited>; + let progressList: Awaited>; + + try { + [books, progressList] = await Promise.all([ + listBooks(), + allProgress(locals.sessionId) + ]); + } catch (e) { + log.error('books', 'failed to load books or progress', { err: String(e) }); + books = []; + progressList = []; + } // Build a quick lookup: slug → last chapter read const progressMap: Record = {}; diff --git a/ui/src/routes/books/[slug]/+page.server.ts b/ui/src/routes/books/[slug]/+page.server.ts index e6b0e4e..c068d2d 100644 --- a/ui/src/routes/books/[slug]/+page.server.ts +++ b/ui/src/routes/books/[slug]/+page.server.ts @@ -1,17 +1,28 @@ import { error } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; import { getBook, listChapterIdx, getProgress } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; export const load: PageServerLoad = async ({ params, locals }) => { const { slug } = params; - const [book, chapters, progress] = await Promise.all([ - getBook(slug), - listChapterIdx(slug), - getProgress(locals.sessionId, slug) - ]); + let book: Awaited>; + let chapters: Awaited>; + let progress: Awaited>; + + try { + [book, chapters, progress] = await Promise.all([ + getBook(slug), + listChapterIdx(slug), + getProgress(locals.sessionId, slug) + ]); + } catch (e) { + log.error('books', 'failed to load book page', { slug, err: String(e) }); + throw error(500, 'Failed to load book'); + } if (!book) { + log.warn('books', 'book not found', { slug }); error(404, `Book "${slug}" not found`); } diff --git a/ui/src/routes/books/[slug]/chapters/[n]/+page.server.ts b/ui/src/routes/books/[slug]/chapters/[n]/+page.server.ts index 3df478b..7a4d352 100644 --- a/ui/src/routes/books/[slug]/chapters/[n]/+page.server.ts +++ b/ui/src/routes/books/[slug]/chapters/[n]/+page.server.ts @@ -3,6 +3,7 @@ import { marked } from 'marked'; import type { PageServerLoad } from './$types'; import { getBook, listChapterIdx } from '$lib/server/pocketbase'; import { presignChapter } from '$lib/server/minio'; +import { log } from '$lib/server/logger'; export const load: PageServerLoad = async ({ params, locals }) => { const { slug } = params; @@ -28,7 +29,7 @@ export const load: PageServerLoad = async ({ params, locals }) => { html = await marked(markdown, { async: true }); } catch (e) { // Don't hard-fail — show empty content with error message - console.error('Failed to fetch chapter content:', e); + log.error('chapter', 'failed to fetch chapter content', { slug, n, err: String(e) }); } const prevChapter = chapters.find((c) => c.number === n - 1) ?? null; diff --git a/ui/src/routes/browse/+page.server.ts b/ui/src/routes/browse/+page.server.ts index d365d62..96c5ac5 100644 --- a/ui/src/routes/browse/+page.server.ts +++ b/ui/src/routes/browse/+page.server.ts @@ -1,6 +1,7 @@ import { error } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; import { env } from '$env/dynamic/private'; +import { log } from '$lib/server/logger'; const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; @@ -27,11 +28,13 @@ export const load: PageServerLoad = async ({ url, locals }) => { try { const res = await fetch(apiURL); if (!res.ok) { + log.error('browse', 'scraper browse returned error', { status: res.status, url: apiURL }); throw error(502, `Browse fetch failed: ${res.status}`); } data = await res.json(); } catch (e) { if (e instanceof Error && 'status' in e) throw e; + log.error('browse', 'scraper browse network error', { url: apiURL, err: String(e) }); throw error(502, 'Could not load browse page'); } diff --git a/ui/src/routes/login/+page.server.ts b/ui/src/routes/login/+page.server.ts index 3bde2d1..799f3f3 100644 --- a/ui/src/routes/login/+page.server.ts +++ b/ui/src/routes/login/+page.server.ts @@ -2,6 +2,7 @@ import { fail, redirect } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; import { loginUser, createUser } from '$lib/server/pocketbase'; import { createAuthToken } from '../../hooks.server'; +import { log } from '$lib/server/logger'; const AUTH_COOKIE = 'libnovel_auth'; const ONE_YEAR = 60 * 60 * 24 * 365; @@ -27,7 +28,8 @@ export const actions: Actions = { let user; try { user = await loginUser(username, password); - } catch { + } catch (err) { + log.error('auth', 'login unexpected error', { username, err: String(err) }); return fail(500, { action: 'login', error: 'An error occurred. Please try again.' }); } @@ -85,6 +87,7 @@ export const actions: Actions = { if (msg.includes('Username already taken')) { return fail(409, { action: 'register', error: 'That username is already taken.' }); } + log.error('auth', 'register unexpected error', { username, err: String(err) }); return fail(500, { action: 'register', error: 'An error occurred. Please try again.' }); }