diff --git a/scraper/internal/server/server.go b/scraper/internal/server/server.go index 1cf54a7..7af9ad9 100644 --- a/scraper/internal/server/server.go +++ b/scraper/internal/server/server.go @@ -115,6 +115,9 @@ func (s *Server) ListenAndServe(ctx context.Context) error { mux.HandleFunc("GET /api/progress", s.handleGetProgress) mux.HandleFunc("POST /api/progress/{slug}", s.handleSetProgress) 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 mux.HandleFunc("GET /", s.handleHome) 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) } +// ─── 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) { cfg := s.oCfg cfg.SingleBookURL = "" // full catalogue diff --git a/scraper/internal/storage/hybrid.go b/scraper/internal/storage/hybrid.go index 31184da..6e4f443 100644 --- a/scraper/internal/storage/hybrid.go +++ b/scraper/internal/storage/hybrid.go @@ -222,6 +222,16 @@ func (h *HybridStore) AudioObjectKey(slug string, n int, voice string, speed flo 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 ────────────────────────────────────────────────────────────────── func recToBookMeta(rec map[string]interface{}) scraper.BookMeta { diff --git a/scraper/internal/storage/minio.go b/scraper/internal/storage/minio.go index 932011a..6090974 100644 --- a/scraper/internal/storage/minio.go +++ b/scraper/internal/storage/minio.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "strings" + "time" "github.com/minio/minio-go/v7" "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 } +// ─── 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 ────────────────────────────────────────────────────────────────── // sanitiseVoice converts a voice name to a filename-safe string. diff --git a/scraper/internal/storage/store.go b/scraper/internal/storage/store.go index 6868594..c23573c 100644 --- a/scraper/internal/storage/store.go +++ b/scraper/internal/storage/store.go @@ -121,4 +121,12 @@ type Store interface { // AudioObjectKey returns the MinIO object key for a cached audio file. 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) } diff --git a/ui/src/lib/server/minio.ts b/ui/src/lib/server/minio.ts new file mode 100644 index 0000000..070b996 --- /dev/null +++ b/ui/src/lib/server/minio.ts @@ -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 { + 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 { + 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); +}