feat: add MinIO presign endpoints to scraper API and SvelteKit presign helper

This commit is contained in:
Admin
2026-03-02 21:35:27 +05:00
parent cb4be0848f
commit 33e2a4dc01
5 changed files with 170 additions and 0 deletions

View File

@@ -115,6 +115,9 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
mux.HandleFunc("GET /api/progress", s.handleGetProgress) mux.HandleFunc("GET /api/progress", s.handleGetProgress)
mux.HandleFunc("POST /api/progress/{slug}", s.handleSetProgress) mux.HandleFunc("POST /api/progress/{slug}", s.handleSetProgress)
mux.HandleFunc("DELETE /api/progress/{slug}", s.handleDeleteProgress) mux.HandleFunc("DELETE /api/progress/{slug}", s.handleDeleteProgress)
// Presigned URL API (for SvelteKit UI)
mux.HandleFunc("GET /api/presign/chapter/{slug}/{n}", s.handlePresignChapter)
mux.HandleFunc("GET /api/presign/audio/{slug}/{n}", s.handlePresignAudio)
// UI routes // UI routes
mux.HandleFunc("GET /", s.handleHome) mux.HandleFunc("GET /", s.handleHome)
mux.HandleFunc("GET /scrape", s.handleScrape) mux.HandleFunc("GET /scrape", s.handleScrape)
@@ -526,6 +529,64 @@ func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(w, resp.Body) _, _ = io.Copy(w, resp.Body)
} }
// ─── Presigned URL handlers ───────────────────────────────────────────────────
// handlePresignChapter handles GET /api/presign/chapter/{slug}/{n}.
// Returns a short-lived presigned MinIO URL for the chapter markdown object.
// The SvelteKit server uses this to fetch chapter content server-side.
func (s *Server) handlePresignChapter(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 || slug == "" {
http.Error(w, `{"error":"invalid params"}`, http.StatusBadRequest)
return
}
url, err := s.store.PresignChapter(r.Context(), slug, n, 15*time.Minute)
if err != nil {
s.log.Error("presign chapter failed", "slug", slug, "n", n, "err", err)
http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"url": url})
}
// handlePresignAudio handles GET /api/presign/audio/{slug}/{n}.
// Returns a presigned MinIO URL for the audio object (if it has been generated).
// Query params: voice, speed (optional, defaults to server defaults).
func (s *Server) handlePresignAudio(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 || slug == "" {
http.Error(w, `{"error":"invalid params"}`, http.StatusBadRequest)
return
}
voice := r.URL.Query().Get("voice")
if voice == "" {
voice = s.kokoroVoice
}
speed := 1.0
if sv := r.URL.Query().Get("speed"); sv != "" {
if v, err := strconv.ParseFloat(sv, 64); err == nil && v > 0 {
speed = v
}
}
key := s.store.AudioObjectKey(slug, n, voice, speed)
url, err := s.store.PresignAudio(r.Context(), key, 1*time.Hour)
if err != nil {
s.log.Error("presign audio failed", "slug", slug, "n", n, "err", err)
http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"url": url})
}
func (s *Server) handleScrapeCatalogue(w http.ResponseWriter, r *http.Request) { func (s *Server) handleScrapeCatalogue(w http.ResponseWriter, r *http.Request) {
cfg := s.oCfg cfg := s.oCfg
cfg.SingleBookURL = "" // full catalogue cfg.SingleBookURL = "" // full catalogue

View File

@@ -222,6 +222,16 @@ func (h *HybridStore) AudioObjectKey(slug string, n int, voice string, speed flo
return AudioObjectKey(slug, n, voice, speed) return AudioObjectKey(slug, n, voice, speed)
} }
// ─── Presigned URLs ───────────────────────────────────────────────────────────
func (h *HybridStore) PresignChapter(ctx context.Context, slug string, n int, expires time.Duration) (string, error) {
return h.minio.PresignChapter(ctx, slug, 0, n, expires)
}
func (h *HybridStore) PresignAudio(ctx context.Context, key string, expires time.Duration) (string, error) {
return h.minio.PresignAudio(ctx, key, expires)
}
// ─── helpers ────────────────────────────────────────────────────────────────── // ─── helpers ──────────────────────────────────────────────────────────────────
func recToBookMeta(rec map[string]interface{}) scraper.BookMeta { func recToBookMeta(rec map[string]interface{}) scraper.BookMeta {

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io" "io"
"strings" "strings"
"time"
"github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials" "github.com/minio/minio-go/v7/pkg/credentials"
@@ -165,6 +166,29 @@ func (m *MinioClient) AudioExists(ctx context.Context, key string) bool {
return err == nil return err == nil
} }
// ─── Presigned URLs ───────────────────────────────────────────────────────────
// PresignChapter returns a presigned GET URL for a chapter object, valid for
// the given duration. The URL is signed with the MinIO credentials and can be
// fetched directly by the browser without authentication.
func (m *MinioClient) PresignChapter(ctx context.Context, slug string, vol, n int, expires time.Duration) (string, error) {
key := chapterKey(slug, vol, n)
u, err := m.c.PresignedGetObject(ctx, m.cfg.BucketChapters, key, expires, nil)
if err != nil {
return "", fmt.Errorf("minio: presign chapter %s: %w", key, err)
}
return u.String(), nil
}
// PresignAudio returns a presigned GET URL for an audio object.
func (m *MinioClient) PresignAudio(ctx context.Context, key string, expires time.Duration) (string, error) {
u, err := m.c.PresignedGetObject(ctx, m.cfg.BucketAudio, key, expires, nil)
if err != nil {
return "", fmt.Errorf("minio: presign audio %s: %w", key, err)
}
return u.String(), nil
}
// ─── helpers ────────────────────────────────────────────────────────────────── // ─── helpers ──────────────────────────────────────────────────────────────────
// sanitiseVoice converts a voice name to a filename-safe string. // sanitiseVoice converts a voice name to a filename-safe string.

View File

@@ -121,4 +121,12 @@ type Store interface {
// AudioObjectKey returns the MinIO object key for a cached audio file. // AudioObjectKey returns the MinIO object key for a cached audio file.
AudioObjectKey(slug string, n int, voice string, speed float64) string AudioObjectKey(slug string, n int, voice string, speed float64) string
// ── Presigned URLs ─────────────────────────────────────────────────────
// PresignChapter returns a presigned GET URL for a chapter markdown object.
PresignChapter(ctx context.Context, slug string, n int, expires time.Duration) (string, error)
// PresignAudio returns a presigned GET URL for an audio object.
PresignAudio(ctx context.Context, key string, expires time.Duration) (string, error)
} }

View File

@@ -0,0 +1,67 @@
/**
* 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';
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';
/**
* 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).
* The returned URL points to the public MinIO endpoint and can be used
* server-side (in a +page.server.ts load function) to fetch the markdown content.
*/
export async function presignChapter(slug: string, n: number): Promise<string> {
const res = await fetch(`${SCRAPER_URL}/api/presign/chapter/${slug}/${n}`);
if (!res.ok) throw new Error(`presign chapter ${slug}/${n}: ${res.status}`);
const data = (await res.json()) as { url: string };
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.
*/
export async function presignAudio(
slug: string,
n: number,
voice?: string,
speed?: number
): Promise<string> {
const params = new URLSearchParams();
if (voice) params.set('voice', voice);
if (speed) params.set('speed', String(speed));
const qs = params.toString() ? `?${params.toString()}` : '';
const res = await fetch(`${SCRAPER_URL}/api/presign/audio/${slug}/${n}${qs}`);
if (!res.ok) throw new Error(`presign audio ${slug}/${n}: ${res.status}`);
const data = (await res.json()) as { url: string };
return rewriteHost(data.url);
}