/** * Server-side MinIO presign helper. * Calls the scraper 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'; 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'; // ─── Avatar helpers ─────────────────────────────────────────────────────────── function extFromMime(mime: string): string { if (mime.includes('png')) return 'png'; if (mime.includes('webp')) return 'webp'; if (mime.includes('gif')) return 'gif'; return 'jpg'; } /** * 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. */ 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}`); if (!res.ok) { const body = await res.text().catch(() => ''); throw new Error(`presign avatar upload failed: ${res.status} ${body}`); } const data = (await res.json()) as { upload_url: string; key: string }; return { uploadUrl: data.upload_url, key: data.key }; } /** * Returns a presigned GET URL for a user's avatar, rewritten to the public URL. * Returns null if no avatar exists. */ export async function presignAvatarUrl(userId: string): Promise { const res = await fetch(`${SCRAPER_URL}/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; } /** * 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). */ function rewriteHost(presignedUrl: string): string { try { const u = new URL(presignedUrl); const pub = new URL(MINIO_PUBLIC_URL); u.protocol = pub.protocol; u.hostname = pub.hostname; u.port = pub.port; return u.toString(); } catch { return presignedUrl; } } /** * Returns a presigned URL for a chapter markdown file. * URL is valid for ~15 minutes (set by the scraper). * * @param rewrite - if true, rewrites the MinIO host to PUBLIC_MINIO_PUBLIC_URL * (for browser use). Defaults to false — the server-side load function fetches * the URL directly from the internal MinIO endpoint. */ export async function presignChapter(slug: string, n: number, rewrite = false): Promise { 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 rewrite ? rewriteHost(data.url) : data.url; } /** * Returns a presigned URL for a voice sample audio file. * URL is valid for ~1 hour. The URL is returned to the browser for direct streaming. * Throws with { status: 404 } when the sample has not been generated yet. */ export async function presignVoiceSample(voice: string): Promise { log.debug('minio', 'presigning voice sample', { voice }); let res: Response; try { res = await fetch(`${SCRAPER_URL}/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`); } if (res.status === 404) { const err = new Error(`presign voice sample ${voice}: not found`) as Error & { status: number }; err.status = 404; throw err; } if (!res.ok) { const body = await res.text().catch(() => ''); log.error('minio', 'presign voice sample failed', { voice, status: res.status, body }); throw new Error(`presign voice sample ${voice}: ${res.status}`); } const data = (await res.json()) as { url: string }; log.debug('minio', 'presign voice sample ok', { voice }); return rewriteHost(data.url); } /** * Returns a presigned URL for an audio file. * URL is valid for ~1 hour. The URL is returned to the browser for direct streaming. * Throws with { status: 404 } when the audio object has not been generated yet. */ export async function presignAudio( slug: string, n: number, voice?: string ): Promise { const params = new URLSearchParams(); if (voice) params.set('voice', voice); const qs = params.toString() ? `?${params.toString()}` : ''; log.debug('minio', 'presigning audio', { slug, n, voice }); 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.status === 404) { // Audio hasn't been generated / uploaded yet — caller should surface this as 404. const err = new Error(`presign audio ${slug}/${n}: not found`) as Error & { status: number }; err.status = 404; throw err; } 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); }