Some checks failed
CI / Scraper / Lint (push) Successful in 11s
CI / Scraper / Lint (pull_request) Successful in 9s
CI / Scraper / Test (push) Successful in 19s
CI / UI / Build (push) Successful in 21s
CI / Scraper / Test (pull_request) Successful in 9s
CI / UI / Build (pull_request) Successful in 27s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 47s
CI / UI / Docker Push (pull_request) Has been skipped
Release / UI / Build (push) Successful in 21s
Release / UI / Docker (push) Successful in 5m1s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
CI / UI / Docker Push (push) Failing after 9m10s
iOS CI / Build (pull_request) Failing after 4m3s
iOS CI / Test (pull_request) Has been skipped
- Go scraper: add PresignAvatarUploadURL/PresignAvatarURL/DeleteAvatar to
Store interface, implement on HybridStore+MinioClient, register
GET /api/presign/avatar-upload/{userId} and /api/presign/avatar/{userId}
- SvelteKit: replace direct AWS S3 SDK in minio.ts with presign calls to
the Go scraper; rewrite avatar +server.ts (POST=presign, PATCH=record key)
- iOS: rewrite uploadAvatar() as 3-step presigned PUT flow; refactor
chip components into shared ChipButton in CommonViews.swift
169 lines
6.6 KiB
TypeScript
169 lines
6.6 KiB
TypeScript
/**
|
|
* 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<string | null> {
|
|
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<string> {
|
|
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<string> {
|
|
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<string> {
|
|
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);
|
|
}
|