feat(ui): add server-side PocketBase client and session cookie hook
This commit is contained in:
4
ui/src/app.d.ts
vendored
4
ui/src/app.d.ts
vendored
@@ -3,7 +3,9 @@
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
interface Locals {
|
||||
sessionId: string;
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
|
||||
23
ui/src/hooks.server.ts
Normal file
23
ui/src/hooks.server.ts
Normal file
@@ -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);
|
||||
};
|
||||
169
ui/src/lib/server/pocketbase.ts
Normal file
169
ui/src/lib/server/pocketbase.ts
Normal file
@@ -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<string> {
|
||||
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<T>(path: string): Promise<T> {
|
||||
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<T>;
|
||||
}
|
||||
|
||||
async function pbPost(path: string, body: unknown): Promise<Response> {
|
||||
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<Response> {
|
||||
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<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[]> {
|
||||
return listAll<Book>('books', '', '+title');
|
||||
}
|
||||
|
||||
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) {
|
||||
await pbPatch(`/api/collections/progress/records/${existing.id}`, payload);
|
||||
} else {
|
||||
await pbPost('/api/collections/progress/records', payload);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user