Files
libnovel/ui/src/lib/server/pocketbase.ts
Admin 4a7009989c
Some checks failed
CI / Test backend (pull_request) Successful in 19s
Release / Test backend (push) Successful in 18s
CI / Check ui (pull_request) Successful in 41s
Release / Check ui (push) Successful in 21s
CI / Docker / backend (pull_request) Successful in 1m43s
CI / Docker / runner (pull_request) Successful in 1m28s
Release / Docker / backend (push) Successful in 1m40s
CI / Docker / caddy (pull_request) Successful in 6m45s
Release / Docker / runner (push) Successful in 1m48s
Release / Docker / caddy (push) Successful in 7m12s
CI / Docker / ui (pull_request) Successful in 1m20s
Release / Docker / ui (push) Successful in 1m19s
Release / Gitea Release (push) Failing after 2s
feat(auth): replace email/password registration with OAuth2 (Google + GitHub)
- New /auth/[provider] route: generates state cookie, redirects to provider
- New /auth/[provider]/callback: exchanges code, fetches profile, auto-creates
  or links account, sets auth cookie
- pocketbase.ts: add oauth_provider/oauth_id to User; new getUserByOAuth(),
  createOAuthUser(), linkOAuthToUser() helpers; loginUser() drops email_verified gate
- pb-init-v3.sh: add oauth_provider + oauth_id fields (schema + migration)
- docker-compose.yml: GOOGLE/GITHUB client ID/secret env vars (replaces SMTP vars)
- Login page: two OAuth buttons (Google, GitHub) — register form removed
- /verify-email route and email.ts removed (provider handles email verification)
- /api/auth/register returns 410 (OAuth-only from now on)
2026-03-24 22:01:51 +05:00

1486 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 PBUserSettings {
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;
avatar_url?: string;
email?: string;
email_verified?: boolean;
verification_token?: string;
verification_token_exp?: string;
oauth_provider?: string;
oauth_id?: 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)
});
}
async function pbDelete(path: string): Promise<Response> {
const token = await getToken();
return fetch(`${PB_URL}${path}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
});
}
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 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 });
}
}
}
/**
* Delete progress entry for a specific book (removes from library/continue reading).
*/
export async function deleteProgress(
sessionId: string,
slug: string,
userId?: string
): Promise<void> {
const existing = await listOne<Progress & { id: string }>(
'progress',
progressFilter(sessionId, slug, userId)
);
if (!existing) {
log.debug('pocketbase', 'deleteProgress: no record found', { sessionId, slug, userId });
return;
}
const res = await pbDelete(`/api/collections/progress/records/${existing.id}`);
if (!res.ok) {
const body = await res.text().catch(() => '');
log.error('pocketbase', 'deleteProgress failed', {
slug,
id: existing.id,
status: res.status,
body
});
throw new Error(`Failed to delete progress: ${res.status}`);
}
log.info('pocketbase', 'deleteProgress success', { slug, id: existing.id });
}
/**
* 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, '\\"')}"`);
}
/**
* Look up a user by email. Returns null if not found.
*/
export async function getUserByEmail(email: string): Promise<User | null> {
return listOne<User>('app_users', `email="${email.replace(/"/g, '\\"')}"`);
}
/**
* Look up a user by OAuth provider + provider user ID. Returns null if not found.
*/
export async function getUserByOAuth(provider: string, oauthId: string): Promise<User | null> {
return listOne<User>(
'app_users',
`oauth_provider="${provider.replace(/"/g, '\\"')}"&&oauth_id="${oauthId.replace(/"/g, '\\"')}"`
);
}
/**
* Create a new user via OAuth (no password). email_verified is true since the
* provider already verified it. Throws on DB errors.
*/
export async function createOAuthUser(
username: string,
email: string,
provider: string,
oauthId: string,
avatarUrl?: string,
role = 'user'
): Promise<User> {
log.info('pocketbase', 'createOAuthUser', { username, email, provider });
const res = await pbPost('/api/collections/app_users/records', {
username,
password_hash: '',
role,
email,
email_verified: true,
oauth_provider: provider,
oauth_id: oauthId,
avatar_url: avatarUrl ?? '',
created: new Date().toISOString()
});
if (!res.ok) {
const body = await res.text().catch(() => '');
log.error('pocketbase', 'createOAuthUser: PocketBase rejected record', {
username,
status: res.status,
body
});
throw new Error(`Failed to create OAuth user: ${res.status} ${body}`);
}
return res.json() as Promise<User>;
}
/**
* Link an OAuth provider to an existing user account.
*/
export async function linkOAuthToUser(
userId: string,
provider: string,
oauthId: string
): Promise<void> {
const res = await pbPatch(`/api/collections/app_users/records/${userId}`, {
oauth_provider: provider,
oauth_id: oauthId,
email_verified: true
});
if (!res.ok) {
const body = await res.text().catch(() => '');
log.error('pocketbase', 'linkOAuthToUser: PATCH failed', { userId, status: res.status, body });
throw new Error(`Failed to link OAuth: ${res.status}`);
}
}
/**
* Look up a user by verification token. Returns null if not found.
* @deprecated Email verification removed — kept only for migration safety.
*/
export async function getUserByVerificationToken(token: string): Promise<User | null> {
return listOne<User>('app_users', `verification_token="${token.replace(/"/g, '\\"')}"`);
}
/**
* Create a new user with a hashed password. Throws if username already exists.
* Stores email + verification token but does NOT log the user in.
*/
export async function createUser(
username: string,
password: string,
email: 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 existingEmail = await getUserByEmail(email);
if (existingEmail) {
log.warn('pocketbase', 'createUser: email already in use', { email });
throw new Error('Email already in use');
}
const password_hash = hashPassword(password);
const verification_token = randomBytes(32).toString('hex');
const verification_token_exp = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
log.info('pocketbase', 'createUser: inserting new user', { username, email, role });
const res = await pbPost('/api/collections/app_users/records', {
username,
password_hash,
role,
email,
email_verified: false,
verification_token,
verification_token_exp,
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>;
}
/**
* Mark a user's email as verified and clear the verification token.
*/
export async function verifyUserEmail(userId: string): Promise<void> {
const res = await pbPatch(`/api/collections/app_users/records/${userId}`, {
email_verified: true,
verification_token: '',
verification_token_exp: ''
});
if (!res.ok) {
const body = await res.text().catch(() => '');
log.error('pocketbase', 'verifyUserEmail: PATCH failed', { userId, status: res.status, body });
throw new Error(`Failed to verify email: ${res.status}`);
}
log.info('pocketbase', 'verifyUserEmail: success', { userId });
}
/**
* 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.
* Only used for legacy accounts that still have a password_hash.
*/
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;
}
if (!user.password_hash) {
log.warn('pocketbase', 'loginUser: account has no password (OAuth-only)', { 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<PBUserSettings | null> {
return listOne<PBUserSettings>('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<PBUserSettings & { id: string }>(
'user_settings',
settingsFilter(sessionId, userId)
);
const payload: Partial<PBUserSettings> = {
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 ──────────────────────────────────────────────────────────────
// There is no separate audio_cache collection — completed audio jobs in the
// audio_jobs collection serve as the cache record. We project them here.
export interface AudioCacheEntry {
id: string;
cache_key: string;
filename: string;
updated: string;
}
export async function listAudioCache(): Promise<AudioCacheEntry[]> {
const jobs = await listAll<AudioJob>('audio_jobs', 'status="done"', '-finished');
return jobs.map((j) => ({
id: j.id,
cache_key: j.cache_key,
filename: `${j.cache_key}.mp3`,
updated: j.finished
}));
}
// ─── Scraping tasks ───────────────────────────────────────────────────────────
export interface ScrapingTask {
id: string;
kind: string;
target_url: string;
status: string;
books_found: number;
chapters_scraped: number;
chapters_skipped: number;
from_chapter: number;
to_chapter: number;
errors: number;
started: string;
finished: string;
error_message: string;
}
export async function listScrapingTasks(): Promise<ScrapingTask[]> {
return listAll<ScrapingTask>('scraping_tasks', '', '-started');
}
export async function getScrapingTask(id: string): Promise<ScrapingTask | null> {
return listOne<ScrapingTask>('scraping_tasks', `id="${id}"`);
}
// ─── 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(() => {})
)
);
}
/**
* Update the avatar_url field for a user record.
*/
export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Promise<void> {
const token = await getToken();
const res = await fetch(`${PB_URL}/api/collections/app_users/records/${userId}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ avatar_url: avatarUrl })
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`updateUserAvatarUrl failed: ${res.status} ${body}`);
}
}
// ─── Comments ─────────────────────────────────────────────────────────────────
export interface PBBookComment {
id: string;
slug: string;
user_id: string;
username: string;
body: string;
upvotes: number;
downvotes: number;
created: string;
parent_id?: string; // empty / absent = top-level; set = reply
}
export interface CommentVote {
id: string;
comment_id: string;
user_id: string;
session_id: string;
vote: 'up' | 'down';
}
export type CommentSort = 'top' | 'new';
/**
* List top-level comments for a book.
* sort='top' → by net score (upvotes downvotes) desc, then newest
* sort='new' → newest first (default)
* Replies (parent_id != "") are NOT included — fetch them separately.
*/
export async function listComments(
slug: string,
sort: CommentSort = 'new'
): Promise<PBBookComment[]> {
const token = await getToken();
const slugEsc = slug.replace(/"/g, '\\"');
// Only top-level comments (parent_id is empty or missing)
const filter = encodeURIComponent(`slug="${slugEsc}"&&(parent_id=""||parent_id=null)`);
// PocketBase sorts: for 'top' we still fetch all and re-sort in JS because
// PocketBase doesn't support computed sort fields. For 'new' we push the
// sort down to the DB so large result sets are still paged correctly.
const pbSort = sort === 'new' ? '&sort=-created' : '&sort=-created';
const res = await fetch(
`${PB_URL}/api/collections/book_comments/records?filter=${filter}${pbSort}&perPage=200`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) return [];
const data = await res.json();
let items = (data.items ?? []) as PBBookComment[];
if (sort === 'top') {
items = items.sort((a, b) => {
const scoreB = (b.upvotes ?? 0) - (b.downvotes ?? 0);
const scoreA = (a.upvotes ?? 0) - (a.downvotes ?? 0);
if (scoreB !== scoreA) return scoreB - scoreA;
// tie-break: newest first
return new Date(b.created).getTime() - new Date(a.created).getTime();
});
}
return items;
}
/**
* List replies (1-level deep) for a single parent comment.
* Always sorted oldest-first so the conversation reads naturally.
*/
export async function listReplies(parentId: string): Promise<PBBookComment[]> {
const token = await getToken();
const filter = encodeURIComponent(`parent_id="${parentId.replace(/"/g, '\\"')}"`);
const res = await fetch(
`${PB_URL}/api/collections/book_comments/records?filter=${filter}&sort=created&perPage=100`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) return [];
const data = await res.json();
return (data.items ?? []) as PBBookComment[];
}
/**
* Create a new comment. Returns the created record.
* Pass parentId to create a reply; omit / pass undefined for a top-level comment.
*/
export async function createComment(
slug: string,
body: string,
userId: string | undefined,
username: string,
parentId?: string
): Promise<PBBookComment> {
const token = await getToken();
const res = await fetch(`${PB_URL}/api/collections/book_comments/records`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
slug,
body,
user_id: userId ?? '',
username,
upvotes: 0,
downvotes: 0,
parent_id: parentId ?? '',
created: new Date().toISOString()
})
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`createComment failed: ${res.status} ${text}`);
}
return res.json() as Promise<PBBookComment>;
}
/**
* Delete a comment (and optionally its replies) by ID.
* Only the comment owner (matched by userId) may delete.
* Throws if the comment doesn't exist or the user doesn't own it.
*/
export async function deleteComment(commentId: string, userId: string): Promise<void> {
const token = await getToken();
// Fetch the comment to verify ownership
const getRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
headers: { Authorization: `Bearer ${token}` }
});
if (!getRes.ok) throw new Error(`Comment not found: ${commentId}`);
const comment = (await getRes.json()) as PBBookComment;
if (comment.user_id !== userId) throw new Error('Not authorized to delete this comment');
// Delete any replies first
const repliesFilter = encodeURIComponent(`parent_id="${commentId.replace(/"/g, '\\"')}"`);
const repliesRes = await fetch(
`${PB_URL}/api/collections/book_comments/records?filter=${repliesFilter}&perPage=100`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (repliesRes.ok) {
const repliesData = await repliesRes.json();
const replies = (repliesData.items ?? []) as PBBookComment[];
await Promise.all(
replies.map((r) =>
fetch(`${PB_URL}/api/collections/book_comments/records/${r.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
})
)
);
}
// Delete the comment itself
const delRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
});
if (!delRes.ok) throw new Error(`deleteComment failed: ${delRes.status}`);
}
/**
* Get an existing vote by this voter (identified by user_id or session_id) on a comment.
*/
export async function getCommentVote(
commentId: string,
sessionId: string,
userId?: string
): Promise<CommentVote | null> {
const token = await getToken();
const voterFilter = userId
? `comment_id="${commentId}"&&user_id="${userId}"`
: `comment_id="${commentId}"&&session_id="${sessionId}"`;
const res = await fetch(
`${PB_URL}/api/collections/comment_votes/records?filter=${encodeURIComponent(voterFilter)}&perPage=1`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) return null;
const data = await res.json();
const items = (data.items ?? []) as CommentVote[];
return items[0] ?? null;
}
/**
* Cast or change a vote on a comment. Handles:
* - New vote: creates vote record, increments counter.
* - Same vote again: removes it (toggle off), decrements counter.
* - Changed vote: updates record, adjusts both counters.
* Returns the updated comment.
*/
export async function voteComment(
commentId: string,
vote: 'up' | 'down',
sessionId: string,
userId?: string
): Promise<PBBookComment> {
const token = await getToken();
// Fetch current comment
const commentRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
headers: { Authorization: `Bearer ${token}` }
});
if (!commentRes.ok) throw new Error(`Comment not found: ${commentId}`);
const comment = (await commentRes.json()) as PBBookComment;
const existing = await getCommentVote(commentId, sessionId, userId);
let upDelta = 0;
let downDelta = 0;
if (!existing) {
// New vote
await fetch(`${PB_URL}/api/collections/comment_votes/records`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ comment_id: commentId, user_id: userId ?? '', session_id: sessionId, vote })
});
vote === 'up' ? upDelta++ : downDelta++;
} else if (existing.vote === vote) {
// Toggle off — remove vote
await fetch(`${PB_URL}/api/collections/comment_votes/records/${existing.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
});
vote === 'up' ? upDelta-- : downDelta--;
} else {
// Changed vote
await fetch(`${PB_URL}/api/collections/comment_votes/records/${existing.id}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ vote })
});
if (vote === 'up') { upDelta++; downDelta--; }
else { upDelta--; downDelta++; }
}
// Patch comment counters
const patchRes = await fetch(`${PB_URL}/api/collections/book_comments/records/${commentId}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
upvotes: Math.max(0, (comment.upvotes ?? 0) + upDelta),
downvotes: Math.max(0, (comment.downvotes ?? 0) + downDelta)
})
});
if (!patchRes.ok) throw new Error(`Failed to update vote counts on comment ${commentId}`);
return patchRes.json() as Promise<PBBookComment>;
}
/**
* Fetch votes cast by this session/user, keyed by comment_id.
* Returns a map of commentId → 'up' | 'down'.
*/
export async function getMyVotes(
commentIds: string[],
sessionId: string,
userId?: string
): Promise<Record<string, 'up' | 'down'>> {
if (commentIds.length === 0) return {};
const token = await getToken();
const idFilter = commentIds.map((id) => `comment_id="${id}"`).join('||');
const voterPart = userId ? `user_id="${userId}"` : `session_id="${sessionId}"`;
const filter = encodeURIComponent(`(${idFilter})&&${voterPart}`);
const res = await fetch(
`${PB_URL}/api/collections/comment_votes/records?filter=${filter}&perPage=200`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) return {};
const data = await res.json();
const map: Record<string, 'up' | 'down'> = {};
for (const v of (data.items ?? []) as CommentVote[]) {
map[v.comment_id] = v.vote as 'up' | 'down';
}
return map;
}
// ─── User subscriptions ───────────────────────────────────────────────────────
export interface UserSubscription {
id: string;
follower_id: string;
followee_id: string;
created: string;
}
/**
* Returns the subscription record if follower_id follows followee_id, else null.
*/
export async function getSubscription(
followerId: string,
followeeId: string
): Promise<UserSubscription | null> {
const filter = encodeURIComponent(`follower_id="${followerId}"&&followee_id="${followeeId}"`);
const res = await pbGet<{ items: UserSubscription[]; totalItems: number }>(
`/api/collections/user_subscriptions/records?filter=${filter}&perPage=1`
).catch(() => null);
return res?.items?.[0] ?? null;
}
/**
* Subscribe follower_id to followee_id. No-ops if already subscribed.
* Returns the subscription record.
*/
export async function subscribe(followerId: string, followeeId: string): Promise<void> {
const existing = await getSubscription(followerId, followeeId);
if (existing) return;
const res = await pbPost('/api/collections/user_subscriptions/records', {
follower_id: followerId,
followee_id: followeeId,
created: new Date().toISOString()
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Failed to subscribe: ${res.status}${body}`);
}
}
/**
* Unsubscribe follower_id from followee_id. No-ops if not subscribed.
*/
export async function unsubscribe(followerId: string, followeeId: string): Promise<void> {
const existing = await getSubscription(followerId, followeeId);
if (!existing) return;
const token = await getToken();
await fetch(`${PB_URL}/api/collections/user_subscriptions/records/${existing.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
});
}
/**
* Returns the list of user IDs that followerId is subscribed to.
*/
export async function getFollowingIds(followerId: string): Promise<string[]> {
const items = await listAll<UserSubscription>(
'user_subscriptions',
`follower_id="${followerId}"`,
'-created'
).catch(() => [] as UserSubscription[]);
return items.map((s) => s.followee_id);
}
/**
* Returns the count of subscribers (followers) for a given user.
*/
export async function getFollowerCount(followeeId: string): Promise<number> {
return countCollection('user_subscriptions', `followee_id="${followeeId}"`).catch(() => 0);
}
/**
* Returns the count of accounts a user is following.
*/
export async function getFollowingCount(followerId: string): Promise<number> {
return countCollection('user_subscriptions', `follower_id="${followerId}"`).catch(() => 0);
}
/**
* Public profile data for a user.
*/
export interface PublicProfile {
id: string;
username: string;
avatar_url?: string;
created: string;
followerCount: number;
followingCount: number;
}
/**
* Returns a user's public profile (no sensitive fields) by username.
*/
export async function getPublicProfile(username: string): Promise<PublicProfile | null> {
const user = await getUserByUsername(username);
if (!user) return null;
const [followerCount, followingCount] = await Promise.all([
getFollowerCount(user.id),
getFollowingCount(user.id)
]);
return {
id: user.id,
username: user.username,
avatar_url: user.avatar_url,
created: user.created,
followerCount,
followingCount
};
}
/**
* Returns a user's public library: books they have saved or are reading.
* Only includes books with progress or explicit saves (user_library).
*/
export async function getUserPublicLibrary(
userId: string
): Promise<Array<{ book: Book; chapter: number | null; saved: boolean }>> {
const [allBooks, progressList, savedEntries] = await Promise.all([
listBooks(),
listAll<Progress>('progress', `user_id="${userId}"`, '-updated').catch(() => [] as Progress[]),
listAll<{ id: string; slug: string; saved_at: string }>(
'user_library',
`user_id="${userId}"`,
'-saved_at'
).catch(() => [] as { id: string; slug: string; saved_at: string }[])
]);
const bookMap = new Map<string, Book>(allBooks.map((b) => [b.slug, b]));
const result: Array<{ book: Book; chapter: number | null; saved: boolean }> = [];
const seen = new Set<string>();
// Books with progress first (most recently read)
for (const p of progressList) {
const book = bookMap.get(p.slug);
if (!book || seen.has(p.slug)) continue;
seen.add(p.slug);
result.push({ book, chapter: p.chapter, saved: false });
}
// Saved-only books next
for (const e of savedEntries) {
const book = bookMap.get(e.slug);
if (!book || seen.has(e.slug)) continue;
seen.add(e.slug);
result.push({ book, chapter: null, saved: true });
}
// Mark saved flag for books that are both in progress AND saved
const savedSlugs = new Set(savedEntries.map((e) => e.slug));
return result.map((r) => ({ ...r, saved: savedSlugs.has(r.book.slug) }));
}
/**
* Returns the currently-reading books (books with progress, not completed)
* for a given user ID.
*/
export async function getUserCurrentlyReading(
userId: string
): Promise<Array<{ book: Book; chapter: number }>> {
const [allBooks, progressList] = await Promise.all([
listBooks(),
listAll<Progress>('progress', `user_id="${userId}"`, '-updated').catch(() => [] as Progress[])
]);
const bookMap = new Map<string, Book>(allBooks.map((b) => [b.slug, b]));
return progressList
.filter((p) => {
const book = bookMap.get(p.slug);
return book && p.chapter > 0 && p.chapter < book.total_chapters;
})
.slice(0, 10)
.map((p) => ({ book: bookMap.get(p.slug)!, chapter: p.chapter }));
}
/**
* Returns recently-updated books from ALL users that followerId is subscribed to.
* Deduplicates across followed users; sorts by most recently updated.
*/
export async function getSubscriptionFeed(
followerId: string,
limit = 12
): Promise<Array<{ book: Book; readerUsername: string }>> {
const followingIds = await getFollowingIds(followerId);
if (followingIds.length === 0) return [];
// Fetch all users we follow (for display names)
const token = await getToken();
const userFetches = followingIds.map((id) =>
fetch(`${PB_URL}/api/collections/app_users/records/${id}`, {
headers: { Authorization: `Bearer ${token}` }
})
.then((r) => (r.ok ? (r.json() as Promise<User>) : null))
.catch(() => null)
);
const users = (await Promise.all(userFetches)).filter(Boolean) as User[];
const userMap = new Map<string, User>(users.map((u) => [u.id, u]));
// Fetch progress for each followed user
const progressFetches = followingIds.map((id) =>
listAll<Progress>('progress', `user_id="${id}"`, '-updated').catch(() => [] as Progress[])
);
const allProgressArrays = await Promise.all(progressFetches);
const allBooks = await listBooks();
const bookMap = new Map<string, Book>(allBooks.map((b) => [b.slug, b]));
// Merge: per slug take the most-recent progress entry
const seen = new Set<string>();
const feed: Array<{ book: Book; readerUsername: string; updated: string }> = [];
for (let i = 0; i < followingIds.length; i++) {
const uid = followingIds[i];
const username = userMap.get(uid)?.username ?? 'unknown';
for (const p of allProgressArrays[i]) {
if (seen.has(p.slug)) continue;
const book = bookMap.get(p.slug);
if (!book) continue;
seen.add(p.slug);
feed.push({ book, readerUsername: username, updated: p.updated });
}
}
// Sort by most recently read across all followed users
feed.sort((a, b) => b.updated.localeCompare(a.updated));
return feed.slice(0, limit).map(({ book, readerUsername }) => ({ book, readerUsername }));
}