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; }