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
This commit is contained in:
Admin
2026-03-04 01:15:46 +05:00
parent cabdd3ffdd
commit 1b05b6ebc6
4 changed files with 34 additions and 9 deletions

View File

@@ -136,6 +136,10 @@ services:
MINIO_USE_SSL: "false" MINIO_USE_SSL: "false"
MINIO_BUCKET_CHAPTERS: "${MINIO_BUCKET_CHAPTERS:-libnovel-chapters}" MINIO_BUCKET_CHAPTERS: "${MINIO_BUCKET_CHAPTERS:-libnovel-chapters}"
MINIO_BUCKET_AUDIO: "${MINIO_BUCKET_AUDIO:-libnovel-audio}" 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
POCKETBASE_URL: "http://pocketbase:8090" POCKETBASE_URL: "http://pocketbase:8090"
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}" POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL:-admin@libnovel.local}"

View File

@@ -99,9 +99,11 @@ func run(log *slog.Logger) error {
// ── Storage backends ──────────────────────────────────────────────────── // ── Storage backends ────────────────────────────────────────────────────
minioCfg := storage.MinioConfig{ minioCfg := storage.MinioConfig{
Endpoint: envOr("MINIO_ENDPOINT", "localhost:9000"), Endpoint: envOr("MINIO_ENDPOINT", "localhost:9000"),
PublicEndpoint: envOr("MINIO_PUBLIC_ENDPOINT", ""),
AccessKey: envOr("MINIO_ACCESS_KEY", "admin"), AccessKey: envOr("MINIO_ACCESS_KEY", "admin"),
SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"), SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"),
UseSSL: strings.ToLower(os.Getenv("MINIO_USE_SSL")) == "true", 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"), BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"),
BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"), BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"),
} }

View File

@@ -14,10 +14,12 @@ import (
// MinioConfig holds connection parameters for MinIO. // MinioConfig holds connection parameters for MinIO.
type MinioConfig struct { 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 AccessKey string
SecretKey string SecretKey string
UseSSL bool UseSSL bool
PublicUseSSL bool // TLS for the public endpoint (usually true in prod)
BucketChapters string // e.g. "libnovel-chapters" BucketChapters string // e.g. "libnovel-chapters"
BucketAudio string // e.g. "libnovel-audio" BucketAudio string // e.g. "libnovel-audio"
} }
@@ -25,7 +27,8 @@ type MinioConfig struct {
// MinioClient wraps a minio.Client and exposes object operations for // MinioClient wraps a minio.Client and exposes object operations for
// chapters and audio files. // chapters and audio files.
type MinioClient struct { 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 cfg MinioConfig
} }
@@ -40,7 +43,21 @@ func NewMinioClient(ctx context.Context, cfg MinioConfig) (*MinioClient, error)
return nil, fmt.Errorf("minio: new client: %w", err) 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} { for _, bucket := range []string{cfg.BucketChapters, cfg.BucketAudio} {
if err := mc.ensureBucket(ctx, bucket); err != nil { if err := mc.ensureBucket(ctx, bucket); err != nil {
return nil, err return nil, err
@@ -168,9 +185,8 @@ func (m *MinioClient) AudioExists(ctx context.Context, key string) bool {
// ─── Presigned URLs ─────────────────────────────────────────────────────────── // ─── Presigned URLs ───────────────────────────────────────────────────────────
// PresignChapter returns a presigned GET URL for a chapter object, valid for // PresignChapter returns a presigned GET URL for a chapter object signed with
// the given duration. The URL is signed with the MinIO credentials and can be // the internal endpoint — intended for server-side fetches only.
// fetched directly by the browser without authentication.
func (m *MinioClient) PresignChapter(ctx context.Context, slug string, vol, n int, expires time.Duration) (string, error) { func (m *MinioClient) PresignChapter(ctx context.Context, slug string, vol, n int, expires time.Duration) (string, error) {
key := chapterKey(slug, vol, n) key := chapterKey(slug, vol, n)
u, err := m.c.PresignedGetObject(ctx, m.cfg.BucketChapters, key, expires, nil) 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 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) { 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 { if err != nil {
return "", fmt.Errorf("minio: presign audio %s: %w", key, err) return "", fmt.Errorf("minio: presign audio %s: %w", key, err)
} }

View File

@@ -90,5 +90,7 @@ export async function presignAudio(
} }
const data = (await res.json()) as { url: string }; const data = (await res.json()) as { url: string };
log.debug('minio', 'presign audio ok', { slug, n }); 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;
} }