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