chore: migrate to v3, Doppler secrets, clean up legacy code
Some checks failed
CI / v3 / Check ui (pull_request) Failing after 15s
CI / v3 / Test backend (pull_request) Failing after 16s
CI / v3 / Docker / backend (pull_request) Has been skipped
CI / v3 / Docker / runner (pull_request) Has been skipped
CI / v3 / Docker / ui (pull_request) Has been skipped

- Remove all pre-v3 code: scraper, ui-v2, backend v1, ios v1+v2, legacy CI workflows
- Flatten v3/ contents to repo root
- Add Doppler secrets management (project=libnovel, config=prd)
- Add justfile with doppler run wrappers for all docker compose commands
- Strip hardcoded env fallbacks from docker-compose.yml
- Add minimal README.md
- Clean up .gitignore
This commit is contained in:
Admin
2026-03-23 17:21:12 +05:00
parent 1118392811
commit 59e8cdb19a
522 changed files with 5259 additions and 80365 deletions

View File

@@ -0,0 +1,71 @@
/**
* Shared types and helpers for the /api/catalogue backend response.
*
* Imported by both:
* - src/routes/catalogue/+page.server.ts (SSR page load)
* - src/routes/api/catalogue-page/+server.ts (infinite-scroll proxy)
*/
/** Shape of a single book as returned by GET /api/catalogue on the Go backend. */
export interface CatalogueBook {
slug: string;
title: string;
author: string;
cover: string;
status: string;
genres: string[];
summary: string;
total_chapters: number;
source_url: string;
ranking: number;
rating: number;
}
/** Facets returned alongside catalogue results for dynamic filter options. */
export interface CatalogueFacets {
genres: string[];
statuses: string[];
}
/** Full response shape from GET /api/catalogue. */
export interface CatalogueResponse {
books: CatalogueBook[];
page: number;
limit: number;
total: number;
has_next: boolean;
facets?: CatalogueFacets;
}
/** Normalised book shape consumed by the catalogue UI. */
export interface NovelListing {
slug: string;
title: string;
cover: string;
rank: string;
rating: string;
chapters: string;
url: string;
// enriched fields
author?: string;
status?: string;
genres?: string[];
source_url?: string;
}
/** Convert a raw CatalogueBook into the UI NovelListing shape. */
export function bookToListing(book: CatalogueBook): NovelListing {
return {
slug: book.slug,
title: book.title,
cover: book.cover,
rank: book.ranking > 0 ? `#${book.ranking}` : '',
rating: book.rating > 0 ? String(book.rating) : '',
chapters: book.total_chapters > 0 ? `${book.total_chapters} chapters` : '',
url: book.source_url ?? '',
author: book.author,
status: book.status,
genres: book.genres ?? [],
source_url: book.source_url
};
}

View File

@@ -1,16 +1,15 @@
/**
* Server-side MinIO presign helper.
* Calls the scraper API to get presigned URLs, then optionally rewrites
* Calls the backend API to get presigned URLs, then optionally rewrites
* the MinIO host to the public-facing URL for browser use.
*
* Never import this from client-side code.
*/
import { env } from '$env/dynamic/private';
import { env as pubEnv } from '$env/dynamic/public';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
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.
// In docker-compose this would differ from the internal endpoint.
const MINIO_PUBLIC_URL = pubEnv.PUBLIC_MINIO_PUBLIC_URL ?? 'http://localhost:9000';
@@ -27,11 +26,11 @@ function extFromMime(mime: string): string {
/**
* Returns a short-lived presigned PUT URL for uploading an avatar directly to MinIO,
* along with the object key to record in PocketBase after upload completes.
* Routed through the Go scraper which holds MinIO credentials.
* Routed through the Go backend which holds MinIO credentials.
*/
export async function presignAvatarUploadUrl(userId: string, mimeType: string): Promise<{ uploadUrl: string; key: string }> {
const ext = extFromMime(mimeType);
const res = await fetch(`${SCRAPER_URL}/api/presign/avatar-upload/${encodeURIComponent(userId)}?ext=${ext}`);
const res = await backendFetch(`/api/presign/avatar-upload/${encodeURIComponent(userId)}?ext=${ext}`);
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`presign avatar upload failed: ${res.status} ${body}`);
@@ -45,26 +44,43 @@ export async function presignAvatarUploadUrl(userId: string, mimeType: string):
* Returns null if no avatar exists.
*/
export async function presignAvatarUrl(userId: string): Promise<string | null> {
const res = await fetch(`${SCRAPER_URL}/api/presign/avatar/${encodeURIComponent(userId)}`);
const res = await backendFetch(`/api/presign/avatar/${encodeURIComponent(userId)}`);
if (res.status === 404) return null;
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`presign avatar failed: ${res.status} ${body}`);
}
const data = (await res.json()) as { url: string };
return data.url ?? null;
return data.url ? rewriteHost(data.url) : null;
}
/**
* Rewrites the MinIO host in a presigned URL to the public-facing URL.
* The presigned URL is signed against the internal endpoint (e.g. minio:9000),
* but the browser needs the public URL (e.g. localhost:9000 in dev, or a CDN in prod).
* Rewriting the host preserves all query params (signature, expiry, etc).
*
* The Go backend presigns URLs against its internal endpoint (e.g. minio:9000)
* when PUBLIC_MINIO_PUBLIC_URL is not set or equals the internal endpoint.
* In that case the browser must reach MinIO via the public URL (e.g.
* localhost:9000 in dev), so we swap the origin.
*
* NOTE: AWS Signature V4 DOES include the Host header in the canonical request
* (via X-Amz-SignedHeaders=host). Rewriting the host here would break the
* signature. This function is therefore only a no-op safety net — in
* production the Go backend is configured with MINIO_PUBLIC_ENDPOINT equal to
* the externally-reachable hostname, so presigned URLs already carry the right
* host and no rewrite is needed.
*
* For local dev: MINIO_PUBLIC_ENDPOINT=http://localhost:9000 and the backend
* presigns with localhost:9000 (the public client), so this rewrite is again
* a no-op (origins already match).
*/
function rewriteHost(presignedUrl: string): string {
try {
const u = new URL(presignedUrl);
const pub = new URL(MINIO_PUBLIC_URL);
// No-op if already pointing at the right origin.
if (u.protocol === pub.protocol && u.hostname === pub.hostname && u.port === pub.port) {
return presignedUrl;
}
u.protocol = pub.protocol;
u.hostname = pub.hostname;
u.port = pub.port;
@@ -86,7 +102,7 @@ export async function presignChapter(slug: string, n: number, rewrite = false):
log.debug('minio', 'presigning chapter', { slug, n });
let res: Response;
try {
res = await fetch(`${SCRAPER_URL}/api/presign/chapter/${slug}/${n}`);
res = await backendFetch(`/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`);
@@ -110,7 +126,7 @@ export async function presignVoiceSample(voice: string): Promise<string> {
log.debug('minio', 'presigning voice sample', { voice });
let res: Response;
try {
res = await fetch(`${SCRAPER_URL}/api/presign/voice-sample/${encodeURIComponent(voice)}`);
res = await backendFetch(`/api/presign/voice-sample/${encodeURIComponent(voice)}`);
} catch (e) {
log.error('minio', 'presign voice sample network error', { voice, err: String(e) });
throw new Error(`presign voice sample ${voice}: network error`);
@@ -146,7 +162,7 @@ export async function presignAudio(
log.debug('minio', 'presigning audio', { slug, n, voice });
let res: Response;
try {
res = await fetch(`${SCRAPER_URL}/api/presign/audio/${slug}/${n}${qs}`);
res = await backendFetch(`/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`);

View File

@@ -46,7 +46,7 @@ export interface Progress {
updated: string;
}
export interface UserSettings {
export interface PBUserSettings {
id?: string;
session_id: string;
user_id?: string;
@@ -212,10 +212,6 @@ export async function recentlyAddedBooks(limit = 6): Promise<Book[]> {
return listN<Book>('books', limit, '', '-meta_updated');
}
export async function recentlyUpdatedBooks(limit = 6): Promise<Book[]> {
return listN<Book>('books', limit, '', '-meta_updated');
}
export interface HomeStats {
totalBooks: number;
totalChapters: number;
@@ -587,8 +583,8 @@ function settingsFilter(sessionId: string, userId?: string): string {
export async function getSettings(
sessionId: string,
userId?: string
): Promise<UserSettings | null> {
return listOne<UserSettings>('user_settings', settingsFilter(sessionId, userId));
): Promise<PBUserSettings | null> {
return listOne<PBUserSettings>('user_settings', settingsFilter(sessionId, userId));
}
export async function saveSettings(
@@ -596,12 +592,12 @@ export async function saveSettings(
settings: { autoNext: boolean; voice: string; speed: number },
userId?: string
): Promise<void> {
const existing = await listOne<UserSettings & { id: string }>(
const existing = await listOne<PBUserSettings & { id: string }>(
'user_settings',
settingsFilter(sessionId, userId)
);
const payload: Partial<UserSettings> = {
const payload: Partial<PBUserSettings> = {
session_id: sessionId,
auto_next: settings.autoNext,
voice: settings.voice,
@@ -666,6 +662,8 @@ export async function setAudioTime(
}
// ─── 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;
@@ -675,7 +673,13 @@ export interface AudioCacheEntry {
}
export async function listAudioCache(): Promise<AudioCacheEntry[]> {
return listAll<AudioCacheEntry>('audio_cache', '', '-updated');
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 ───────────────────────────────────────────────────────────
@@ -688,6 +692,8 @@ export interface ScrapingTask {
books_found: number;
chapters_scraped: number;
chapters_skipped: number;
from_chapter: number;
to_chapter: number;
errors: number;
started: string;
finished: string;
@@ -698,6 +704,10 @@ 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 {
@@ -854,7 +864,7 @@ export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Pr
// ─── Comments ─────────────────────────────────────────────────────────────────
export interface BookComment {
export interface PBBookComment {
id: string;
slug: string;
user_id: string;
@@ -885,7 +895,7 @@ export type CommentSort = 'top' | 'new';
export async function listComments(
slug: string,
sort: CommentSort = 'new'
): Promise<BookComment[]> {
): Promise<PBBookComment[]> {
const token = await getToken();
const slugEsc = slug.replace(/"/g, '\\"');
// Only top-level comments (parent_id is empty or missing)
@@ -900,7 +910,7 @@ export async function listComments(
);
if (!res.ok) return [];
const data = await res.json();
let items = (data.items ?? []) as BookComment[];
let items = (data.items ?? []) as PBBookComment[];
if (sort === 'top') {
items = items.sort((a, b) => {
const scoreB = (b.upvotes ?? 0) - (b.downvotes ?? 0);
@@ -917,7 +927,7 @@ export async function listComments(
* 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<BookComment[]> {
export async function listReplies(parentId: string): Promise<PBBookComment[]> {
const token = await getToken();
const filter = encodeURIComponent(`parent_id="${parentId.replace(/"/g, '\\"')}"`);
const res = await fetch(
@@ -926,7 +936,7 @@ export async function listReplies(parentId: string): Promise<BookComment[]> {
);
if (!res.ok) return [];
const data = await res.json();
return (data.items ?? []) as BookComment[];
return (data.items ?? []) as PBBookComment[];
}
/**
@@ -939,7 +949,7 @@ export async function createComment(
userId: string | undefined,
username: string,
parentId?: string
): Promise<BookComment> {
): Promise<PBBookComment> {
const token = await getToken();
const res = await fetch(`${PB_URL}/api/collections/book_comments/records`, {
method: 'POST',
@@ -959,7 +969,7 @@ export async function createComment(
const text = await res.text().catch(() => '');
throw new Error(`createComment failed: ${res.status} ${text}`);
}
return res.json() as Promise<BookComment>;
return res.json() as Promise<PBBookComment>;
}
/**
@@ -975,7 +985,7 @@ export async function deleteComment(commentId: string, userId: string): Promise<
headers: { Authorization: `Bearer ${token}` }
});
if (!getRes.ok) throw new Error(`Comment not found: ${commentId}`);
const comment = (await getRes.json()) as BookComment;
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
@@ -986,7 +996,7 @@ export async function deleteComment(commentId: string, userId: string): Promise<
);
if (repliesRes.ok) {
const repliesData = await repliesRes.json();
const replies = (repliesData.items ?? []) as BookComment[];
const replies = (repliesData.items ?? []) as PBBookComment[];
await Promise.all(
replies.map((r) =>
fetch(`${PB_URL}/api/collections/book_comments/records/${r.id}`, {
@@ -1039,7 +1049,7 @@ export async function voteComment(
vote: 'up' | 'down',
sessionId: string,
userId?: string
): Promise<BookComment> {
): Promise<PBBookComment> {
const token = await getToken();
// Fetch current comment
@@ -1047,7 +1057,7 @@ export async function voteComment(
headers: { Authorization: `Bearer ${token}` }
});
if (!commentRes.ok) throw new Error(`Comment not found: ${commentId}`);
const comment = (await commentRes.json()) as BookComment;
const comment = (await commentRes.json()) as PBBookComment;
const existing = await getCommentVote(commentId, sessionId, userId);
@@ -1090,7 +1100,7 @@ export async function voteComment(
})
});
if (!patchRes.ok) throw new Error(`Failed to update vote counts on comment ${commentId}`);
return patchRes.json() as Promise<BookComment>;
return patchRes.json() as Promise<PBBookComment>;
}
/**

View File

@@ -0,0 +1,118 @@
/**
* Valkey-backed presign URL cache (v3).
*
* Replaces the in-process Map from v2. All presign URLs are stored in Valkey
* (Redis-compatible) with native TTL, so:
* - Cache survives UI process restarts.
* - Cache is shared across multiple UI replicas (if scaled horizontally).
* - No manual sweep timer needed — Valkey expires entries automatically.
*
* MinIO presigned audio URLs are valid for 1 hour (set by the backend).
* We cache them for 50 minutes so the browser always gets a URL with at
* least 10 minutes of remaining validity.
*
* Voice-sample URLs use the same cache with key "sample:<voice>".
*
* Connection:
* VALKEY_URL env var (default: redis://valkey:6379)
* ioredis handles reconnection automatically.
*/
import Redis from 'ioredis';
const AUDIO_TTL_S = 50 * 60; // 50 minutes in seconds (Valkey TTL is in seconds)
// Lazily-initialised singleton client.
let _client: Redis | null = null;
function client(): Redis {
if (!_client) {
const url = process.env.VALKEY_URL ?? 'redis://valkey:6379';
_client = new Redis(url, {
// Reconnect automatically with exponential backoff (ioredis default).
// lazyConnect: false means the connection is established immediately.
lazyConnect: false,
// Log connection errors to stderr; do not crash the process.
enableOfflineQueue: true,
maxRetriesPerRequest: 2,
});
_client.on('error', (err: Error) => {
console.error('[presignCache] Valkey error:', err.message);
});
}
return _client;
}
// ── Key helpers ───────────────────────────────────────────────────────────────
/** Cache key for a chapter audio presigned URL. */
export function audioKey(slug: string, n: number, voice: string): string {
return `audio:${slug}:${n}:${voice}`;
}
/** Cache key for a voice-sample presigned URL. */
export function sampleKey(voice: string): string {
return `sample:${voice}`;
}
// ── Public API ────────────────────────────────────────────────────────────────
/** Return the cached URL for key, or null if absent / expired. */
export async function get(key: string): Promise<string | null> {
try {
return await client().get(key);
} catch (err) {
console.error('[presignCache] get error:', err);
return null;
}
}
/** Store a presigned URL under key for ttlSeconds seconds. */
export async function set(key: string, url: string, ttlSeconds = AUDIO_TTL_S): Promise<void> {
try {
await client().set(key, url, 'EX', ttlSeconds);
} catch (err) {
console.error('[presignCache] set error:', err);
}
}
/** Invalidate a specific key (e.g. after audio generation to force refresh). */
export async function invalidate(key: string): Promise<void> {
try {
await client().del(key);
} catch (err) {
console.error('[presignCache] invalidate error:', err);
}
}
/**
* Disconnect from Valkey — called on graceful shutdown.
* ioredis queues commands during reconnects; calling quit() drains the queue
* and closes the connection cleanly.
*/
export async function drain(): Promise<void> {
if (_client) {
try {
await _client.quit();
} catch {
// ignore — process is exiting anyway
}
_client = null;
}
}
/**
* Returns the approximate number of keys matching the libnovel presign prefix.
* Used for health/debug only — not called in the hot path.
*/
export async function size(): Promise<number> {
try {
// DBSIZE returns the total key count in the current DB.
// For a precise count of just our keys, use SCAN with a pattern.
const keys = await client().keys('audio:*');
const sampleKeys = await client().keys('sample:*');
return keys.length + sampleKeys.length;
} catch {
return -1;
}
}

View File

@@ -0,0 +1,60 @@
/**
* Backend API helper.
*
* Centralises the BACKEND_URL constant and provides a thin fetch wrapper that:
* - Resolves paths relative to BACKEND_API_URL.
* - Throws 502 on network errors (unreachable backend).
* - Re-throws SvelteKit `error()` objects so callers can still short-circuit.
* - Passes a RequestInit through verbatim so callers keep full control.
*
* Import only from server-side modules (`+server.ts`, `*.server.ts`).
*/
import { error } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
export const BACKEND_URL = env.BACKEND_API_URL ?? 'http://localhost:8080';
/**
* Fetch a path on the backend, throwing a 502 on network failures.
*
* The `path` must start with `/` (e.g. `/api/voices`).
*
* SvelteKit `error()` exceptions are always re-thrown so callers can
* short-circuit correctly inside their own catch blocks.
*/
export async function backendFetch(path: string, init?: RequestInit): Promise<Response> {
try {
return await fetch(`${BACKEND_URL}${path}`, init);
} catch (e) {
// Re-throw SvelteKit HTTP errors so they propagate to the framework.
if (e instanceof Error && 'status' in e) throw e;
throw error(502, 'Could not reach backend');
}
}
// ─── Response types ───────────────────────────────────────────────────────────
/**
* Metadata shape returned inside the 200 response from GET /api/book-preview/{slug}.
* Used in both the SSR page load and the API proxy to avoid duplicating the inline type.
*/
export interface BookPreviewMeta {
slug: string;
title: string;
author: string;
cover: string;
status: string;
genres: string[];
summary: string;
total_chapters: number;
source_url: string;
}
/** Full 200 response from GET /api/book-preview/{slug}. */
export interface BookPreviewResponse {
in_lib: boolean;
meta: BookPreviewMeta;
chapters: { number: number; title: string; date?: string }[];
}