Files
libnovel/ui-v2/src/lib/server/minio.ts
Admin 5825b859b7
All checks were successful
Release / Scraper / Test (push) Successful in 10s
Release / UI / Build (push) Successful in 26s
Release / v2 / Build ui-v2 (push) Successful in 17s
Release / Scraper / Docker (push) Successful in 47s
Release / UI / Docker (push) Successful in 56s
CI / Scraper / Lint (pull_request) Successful in 7s
CI / Scraper / Test (pull_request) Successful in 8s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
Release / v2 / Docker / ui-v2 (push) Successful in 56s
Release / v2 / Test backend (push) Successful in 4m35s
iOS CI / Build (pull_request) Successful in 4m28s
Release / v2 / Docker / backend (push) Successful in 1m29s
Release / v2 / Docker / runner (push) Successful in 1m39s
iOS CI / Test (pull_request) Successful in 9m51s
feat: add v2 stack (backend, runner, ui-v2) with release workflow
- backend/: Go API server and runner binaries with PocketBase + MinIO storage
- ui-v2/: SvelteKit frontend rewrite
- docker-compose-new.yml: compose file for the v2 stack
- .gitea/workflows/release-v2.yaml: CI/CD for backend, runner, and ui-v2 Docker Hub images
- scripts/pb-init.sh: migrate from wget to curl, add superuser bootstrap for fresh installs
- .env.example: document DOCKER_BUILDKIT=1 for Colima users
2026-03-15 19:32:40 +05:00

186 lines
7.4 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 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;
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);
}