From 1b05b6ebc691e577abc9660ccfa15e70725f5243 Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 4 Mar 2026 01:15:46 +0500 Subject: [PATCH] fix(audio): sign presigned audio URLs with public MinIO endpoint Add a second MinIO client (pub) initialized with MINIO_PUBLIC_ENDPOINT so presigned audio URLs are signed against the public hostname from the start, rather than signed internally and then rewritten. This avoids AWS4 signature mismatch (SignatureDoesNotMatch 403) that occurred when the signed host was substituted after signing. - storage/minio.go: add PublicEndpoint/PublicUseSSL to MinioConfig; add pub client field; NewMinioClient creates pub client when public endpoint differs; PresignAudio uses pub, PresignChapter keeps internal client - cmd/scraper/main.go: wire MINIO_PUBLIC_ENDPOINT and MINIO_PUBLIC_USE_SSL env vars - docker-compose.yml: expose MINIO_PUBLIC_ENDPOINT and MINIO_PUBLIC_USE_SSL to scraper service - ui/src/lib/server/minio.ts: remove rewriteHost() call from presignAudio --- docker-compose.yml | 4 ++++ scraper/cmd/scraper/main.go | 2 ++ scraper/internal/storage/minio.go | 33 +++++++++++++++++++++++-------- ui/src/lib/server/minio.ts | 4 +++- 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 81b17e9..ac0c63b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -136,6 +136,10 @@ services: MINIO_USE_SSL: "false" MINIO_BUCKET_CHAPTERS: "${MINIO_BUCKET_CHAPTERS:-libnovel-chapters}" MINIO_BUCKET_AUDIO: "${MINIO_BUCKET_AUDIO:-libnovel-audio}" + # Public endpoint used to sign presigned audio URLs so browsers can reach them. + # Leave empty to use MINIO_ENDPOINT (fine for local dev). + MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-}" + MINIO_PUBLIC_USE_SSL: "${MINIO_PUBLIC_USE_SSL:-true}" # PocketBase POCKETBASE_URL: "http://pocketbase:8090" POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" diff --git a/scraper/cmd/scraper/main.go b/scraper/cmd/scraper/main.go index 0c81738..baee1fa 100644 --- a/scraper/cmd/scraper/main.go +++ b/scraper/cmd/scraper/main.go @@ -99,9 +99,11 @@ func run(log *slog.Logger) error { // ── Storage backends ──────────────────────────────────────────────────── minioCfg := storage.MinioConfig{ Endpoint: envOr("MINIO_ENDPOINT", "localhost:9000"), + PublicEndpoint: envOr("MINIO_PUBLIC_ENDPOINT", ""), AccessKey: envOr("MINIO_ACCESS_KEY", "admin"), SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"), UseSSL: strings.ToLower(os.Getenv("MINIO_USE_SSL")) == "true", + PublicUseSSL: strings.ToLower(os.Getenv("MINIO_PUBLIC_USE_SSL")) != "false", BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"), BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"), } diff --git a/scraper/internal/storage/minio.go b/scraper/internal/storage/minio.go index 6090974..83ec2c1 100644 --- a/scraper/internal/storage/minio.go +++ b/scraper/internal/storage/minio.go @@ -14,10 +14,12 @@ import ( // MinioConfig holds connection parameters for MinIO. type MinioConfig struct { - Endpoint string // e.g. "minio:9000" + Endpoint string // e.g. "minio:9000" — internal address used for all operations + PublicEndpoint string // e.g. "minio.kalekber.cc" — used to sign presigned URLs so browsers can reach them; leave empty to use Endpoint AccessKey string SecretKey string UseSSL bool + PublicUseSSL bool // TLS for the public endpoint (usually true in prod) BucketChapters string // e.g. "libnovel-chapters" BucketAudio string // e.g. "libnovel-audio" } @@ -25,7 +27,8 @@ type MinioConfig struct { // MinioClient wraps a minio.Client and exposes object operations for // chapters and audio files. type MinioClient struct { - c *minio.Client + c *minio.Client // internal client — used for all read/write operations + pub *minio.Client // public client — used only for generating presigned URLs cfg MinioConfig } @@ -40,7 +43,21 @@ func NewMinioClient(ctx context.Context, cfg MinioConfig) (*MinioClient, error) return nil, fmt.Errorf("minio: new client: %w", err) } - mc := &MinioClient{c: c, cfg: cfg} + // Public client: signs presigned URLs with the public hostname so browsers + // can fetch them directly. Falls back to the internal client if no public + // endpoint is configured. + pub := c + if cfg.PublicEndpoint != "" && cfg.PublicEndpoint != cfg.Endpoint { + pub, err = minio.New(cfg.PublicEndpoint, &minio.Options{ + Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), + Secure: cfg.PublicUseSSL, + }) + if err != nil { + return nil, fmt.Errorf("minio: new public client: %w", err) + } + } + + mc := &MinioClient{c: c, pub: pub, cfg: cfg} for _, bucket := range []string{cfg.BucketChapters, cfg.BucketAudio} { if err := mc.ensureBucket(ctx, bucket); err != nil { return nil, err @@ -168,9 +185,8 @@ func (m *MinioClient) AudioExists(ctx context.Context, key string) bool { // ─── 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. +// PresignChapter returns a presigned GET URL for a chapter object signed with +// the internal endpoint — intended for server-side fetches only. 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) @@ -180,9 +196,10 @@ func (m *MinioClient) PresignChapter(ctx context.Context, slug string, vol, n in return u.String(), nil } -// PresignAudio returns a presigned GET URL for an audio object. +// PresignAudio returns a presigned GET URL for an audio object signed with +// the public endpoint so the browser can fetch it directly. 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) + u, err := m.pub.PresignedGetObject(ctx, m.cfg.BucketAudio, key, expires, nil) if err != nil { return "", fmt.Errorf("minio: presign audio %s: %w", key, err) } diff --git a/ui/src/lib/server/minio.ts b/ui/src/lib/server/minio.ts index 338fbf1..87de996 100644 --- a/ui/src/lib/server/minio.ts +++ b/ui/src/lib/server/minio.ts @@ -90,5 +90,7 @@ export async function presignAudio( } const data = (await res.json()) as { url: string }; log.debug('minio', 'presign audio ok', { slug, n }); - return rewriteHost(data.url); + // The scraper now signs audio URLs with the public endpoint directly, + // so no host rewrite is needed here. + return data.url; }