package storage import ( "context" "fmt" "io" "net/url" "path" "strings" "time" minio "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" "github.com/libnovel/backend/internal/config" ) // minioClient wraps the official minio-go client with bucket names. type minioClient struct { client *minio.Client // internal — all read/write operations pubClient *minio.Client // presign-only — initialised against the public endpoint bucketChapters string bucketAudio string bucketAvatars string bucketBrowse string bucketTranslations string } func newMinioClient(cfg config.MinIO) (*minioClient, error) { creds := credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, "") internal, err := minio.New(cfg.Endpoint, &minio.Options{ Creds: creds, Secure: cfg.UseSSL, }) if err != nil { return nil, fmt.Errorf("minio: init internal client: %w", err) } // Presigned URLs must be signed with the hostname the browser will use // (PUBLIC_MINIO_PUBLIC_URL), because AWS Signature V4 includes the Host // header in the canonical request — a URL signed against "minio:9000" will // return SignatureDoesNotMatch when the browser fetches it from // "localhost:9000". // // However, minio-go normally makes a live BucketLocation HTTP call before // signing, which would fail from inside the container when the public // endpoint is externally-facing (e.g. "localhost:9000" is unreachable from // within Docker). We prevent this by: // 1. Setting Region: "us-east-1" — minio-go skips getBucketLocation when // the region is already known (bucket-cache.go:49). // 2. Setting BucketLookup: BucketLookupPath — forces path-style URLs // (e.g. host/bucket/key), matching MinIO's default behaviour and // avoiding any virtual-host DNS probing. // // When no public endpoint is configured (or it equals the internal one), // fall back to the internal client so presigning still works. publicEndpoint := cfg.PublicEndpoint if u, err2 := url.Parse(publicEndpoint); err2 == nil && u.Host != "" { publicEndpoint = u.Host // strip scheme so minio.New is happy } pubUseSSL := cfg.PublicUseSSL if publicEndpoint == "" || publicEndpoint == cfg.Endpoint { publicEndpoint = cfg.Endpoint pubUseSSL = cfg.UseSSL } pub, err := minio.New(publicEndpoint, &minio.Options{ Creds: creds, Secure: pubUseSSL, Region: "us-east-1", // skip live BucketLocation preflight BucketLookup: minio.BucketLookupPath, }) if err != nil { return nil, fmt.Errorf("minio: init public client: %w", err) } return &minioClient{ client: internal, pubClient: pub, bucketChapters: cfg.BucketChapters, bucketAudio: cfg.BucketAudio, bucketAvatars: cfg.BucketAvatars, bucketBrowse: cfg.BucketBrowse, bucketTranslations: cfg.BucketTranslations, }, nil } // ensureBuckets creates all required buckets if they don't already exist. func (m *minioClient) ensureBuckets(ctx context.Context) error { for _, bucket := range []string{m.bucketChapters, m.bucketAudio, m.bucketAvatars, m.bucketBrowse, m.bucketTranslations} { exists, err := m.client.BucketExists(ctx, bucket) if err != nil { return fmt.Errorf("minio: check bucket %q: %w", bucket, err) } if !exists { if err := m.client.MakeBucket(ctx, bucket, minio.MakeBucketOptions{}); err != nil { return fmt.Errorf("minio: create bucket %q: %w", bucket, err) } } } return nil } // ── Key helpers ─────────────────────────────────────────────────────────────── // ChapterObjectKey returns the MinIO object key for a chapter markdown file. // Format: {slug}/chapter-{n:06d}.md func ChapterObjectKey(slug string, n int) string { return fmt.Sprintf("%s/chapter-%06d.md", slug, n) } // AudioObjectKeyExt returns the MinIO object key for a cached audio file // with a custom extension (e.g. "mp3" or "wav"). // Format: {slug}/{n}/{voice}.{ext} func AudioObjectKeyExt(slug string, n int, voice, ext string) string { return fmt.Sprintf("%s/%d/%s.%s", slug, n, voice, ext) } // AudioObjectKey returns the MinIO object key for a cached MP3 audio file. // Format: {slug}/{n}/{voice}.mp3 func AudioObjectKey(slug string, n int, voice string) string { return AudioObjectKeyExt(slug, n, voice, "mp3") } // AvatarObjectKey returns the MinIO object key for a user avatar image. // Format: {userID}/{ext}.{ext} func AvatarObjectKey(userID, ext string) string { return fmt.Sprintf("%s/%s.%s", userID, ext, ext) } // CoverObjectKey returns the MinIO object key for a book cover image. // Format: covers/{slug}.jpg func CoverObjectKey(slug string) string { return fmt.Sprintf("covers/%s.jpg", slug) } // ChapterImageObjectKey returns the MinIO object key for a chapter illustration. // Format: chapter-images/{slug}/{n:06d}.jpg func ChapterImageObjectKey(slug string, n int) string { return fmt.Sprintf("chapter-images/%s/%06d.jpg", slug, n) } // TranslationObjectKey returns the MinIO object key for a translated chapter. // Format: {lang}/{slug}/{n:06d}.md func TranslationObjectKey(lang, slug string, n int) string { return fmt.Sprintf("%s/%s/%06d.md", lang, slug, n) } // chapterNumberFromKey extracts the chapter number from a MinIO object key. // e.g. "my-book/chapter-000042.md" → 42 func chapterNumberFromKey(key string) int { base := path.Base(key) base = strings.TrimPrefix(base, "chapter-") base = strings.TrimSuffix(base, ".md") var n int fmt.Sscanf(base, "%d", &n) return n } // ── Object operations ───────────────────────────────────────────────────────── func (m *minioClient) putObject(ctx context.Context, bucket, key, contentType string, data []byte) error { _, err := m.client.PutObject(ctx, bucket, key, strings.NewReader(string(data)), int64(len(data)), minio.PutObjectOptions{ContentType: contentType}, ) return err } // putObjectStream uploads from r with known size (or -1 for multipart). func (m *minioClient) putObjectStream(ctx context.Context, bucket, key, contentType string, r io.Reader, size int64) error { _, err := m.client.PutObject(ctx, bucket, key, r, size, minio.PutObjectOptions{ContentType: contentType}, ) return err } func (m *minioClient) getObject(ctx context.Context, bucket, key string) ([]byte, error) { obj, err := m.client.GetObject(ctx, bucket, key, minio.GetObjectOptions{}) if err != nil { return nil, err } defer obj.Close() return io.ReadAll(obj) } func (m *minioClient) objectExists(ctx context.Context, bucket, key string) bool { _, err := m.client.StatObject(ctx, bucket, key, minio.StatObjectOptions{}) return err == nil } func (m *minioClient) presignGet(ctx context.Context, bucket, key string, expires time.Duration) (string, error) { u, err := m.pubClient.PresignedGetObject(ctx, bucket, key, expires, nil) if err != nil { return "", fmt.Errorf("minio presign %s/%s: %w", bucket, key, err) } return u.String(), nil } func (m *minioClient) presignPut(ctx context.Context, bucket, key string, expires time.Duration) (string, error) { u, err := m.pubClient.PresignedPutObject(ctx, bucket, key, expires) if err != nil { return "", fmt.Errorf("minio presign PUT %s/%s: %w", bucket, key, err) } return u.String(), nil } func (m *minioClient) deleteObjects(ctx context.Context, bucket, prefix string) error { objCh := m.client.ListObjects(ctx, bucket, minio.ListObjectsOptions{Prefix: prefix}) for obj := range objCh { if obj.Err != nil { return obj.Err } if err := m.client.RemoveObject(ctx, bucket, obj.Key, minio.RemoveObjectOptions{}); err != nil { return err } } return nil } func (m *minioClient) listObjectKeys(ctx context.Context, bucket, prefix string) ([]string, error) { var keys []string for obj := range m.client.ListObjects(ctx, bucket, minio.ListObjectsOptions{Prefix: prefix}) { if obj.Err != nil { return nil, obj.Err } keys = append(keys, obj.Key) } return keys, nil } // ── Cover operations ────────────────────────────────────────────────────────── // putCover stores a raw cover image in the browse bucket under covers/{slug}.jpg. func (m *minioClient) putCover(ctx context.Context, key, contentType string, data []byte) error { return m.putObject(ctx, m.bucketBrowse, key, contentType, data) } // getCover retrieves a cover image. Returns (nil, "", false, nil) when the // object does not exist. func (m *minioClient) getCover(ctx context.Context, key string) ([]byte, bool, error) { if !m.objectExists(ctx, m.bucketBrowse, key) { return nil, false, nil } data, err := m.getObject(ctx, m.bucketBrowse, key) if err != nil { return nil, false, err } return data, true, nil } // coverExists returns true when the cover image object exists. func (m *minioClient) coverExists(ctx context.Context, key string) bool { return m.objectExists(ctx, m.bucketBrowse, key) } // coverContentType inspects the first bytes of data to determine if it is // a JPEG or PNG image. Falls back to "image/jpeg". func coverContentType(data []byte) string { if len(data) >= 4 { // PNG magic: 0x89 0x50 0x4E 0x47 if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 { return "image/png" } // WebP: starts with "RIFF" at 0..3 and "WEBP" at 8..11 if len(data) >= 12 && data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' && data[8] == 'W' && data[9] == 'E' && data[10] == 'B' && data[11] == 'P' { return "image/webp" } } return "image/jpeg" } // ── Chapter image operations ─────────────────────────────────────────────────── // putChapterImage stores a chapter illustration in the browse bucket. func (m *minioClient) putChapterImage(ctx context.Context, key, contentType string, data []byte) error { return m.putObject(ctx, m.bucketBrowse, key, contentType, data) } // getChapterImage retrieves a chapter illustration. Returns (nil, false, nil) // when the object does not exist. func (m *minioClient) getChapterImage(ctx context.Context, key string) ([]byte, bool, error) { if !m.objectExists(ctx, m.bucketBrowse, key) { return nil, false, nil } data, err := m.getObject(ctx, m.bucketBrowse, key) if err != nil { return nil, false, err } return data, true, nil } // chapterImageExists returns true when the chapter image object exists. func (m *minioClient) chapterImageExists(ctx context.Context, key string) bool { return m.objectExists(ctx, m.bucketBrowse, key) }