feat: add MinIO presign endpoints to scraper API and SvelteKit presign helper
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
67
ui/src/lib/server/minio.ts
Normal file
67
ui/src/lib/server/minio.ts
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user