package storage import ( "bytes" "context" "fmt" "io" "strings" "time" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" ) // MinioConfig holds connection parameters for MinIO. type MinioConfig struct { 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" BucketBrowse string // e.g. "libnovel-browse" BucketAvatars string // e.g. "libnovel-avatars" } // MinioClient wraps a minio.Client and exposes object operations for // chapters and audio files. type MinioClient struct { c *minio.Client // internal client — used for all read/write operations pub *minio.Client // public client — used only for generating presigned URLs cfg MinioConfig } // NewMinioClient creates a connected MinIO client and ensures the required // buckets exist. func NewMinioClient(ctx context.Context, cfg MinioConfig) (*MinioClient, error) { c, err := minio.New(cfg.Endpoint, &minio.Options{ Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""), Secure: cfg.UseSSL, }) if err != nil { return nil, fmt.Errorf("minio: new client: %w", err) } // 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, cfg.BucketBrowse, cfg.BucketAvatars} { if bucket == "" { continue } if err := mc.ensureBucket(ctx, bucket); err != nil { return nil, err } } return mc, nil } // ensureBucket creates a bucket if it does not exist. func (m *MinioClient) ensureBucket(ctx context.Context, bucket string) error { exists, err := m.c.BucketExists(ctx, bucket) if err != nil { return fmt.Errorf("minio: bucket exists %q: %w", bucket, err) } if !exists { if err := m.c.MakeBucket(ctx, bucket, minio.MakeBucketOptions{}); err != nil { return fmt.Errorf("minio: make bucket %q: %w", bucket, err) } } return nil } // ─── Chapter objects ────────────────────────────────────────────────────────── // chapterKey returns the MinIO object key for a chapter. // Layout: {slug}/vol-{vol}/{lo}-{hi}/chapter-{n}.md func chapterKey(slug string, vol, n int) string { const chaptersPerFolder = 50 lo := ((n-1)/chaptersPerFolder)*chaptersPerFolder + 1 hi := lo + chaptersPerFolder - 1 return fmt.Sprintf("%s/vol-%d/%d-%d/chapter-%d.md", slug, vol, lo, hi, n) } // PutChapter stores chapter markdown in MinIO. func (m *MinioClient) PutChapter(ctx context.Context, slug string, vol, n int, content string) error { key := chapterKey(slug, vol, n) data := []byte(content) _, err := m.c.PutObject(ctx, m.cfg.BucketChapters, key, bytes.NewReader(data), int64(len(data)), minio.PutObjectOptions{ContentType: "text/markdown; charset=utf-8"}) if err != nil { return fmt.Errorf("minio: put chapter %s: %w", key, err) } return nil } // GetChapter retrieves chapter markdown from MinIO. func (m *MinioClient) GetChapter(ctx context.Context, slug string, vol, n int) (string, error) { key := chapterKey(slug, vol, n) obj, err := m.c.GetObject(ctx, m.cfg.BucketChapters, key, minio.GetObjectOptions{}) if err != nil { return "", fmt.Errorf("minio: get chapter %s: %w", key, err) } defer obj.Close() data, err := io.ReadAll(obj) if err != nil { return "", fmt.Errorf("minio: read chapter %s: %w", key, err) } return string(data), nil } // ChapterExists returns true if the object for this chapter is present. func (m *MinioClient) ChapterExists(ctx context.Context, slug string, vol, n int) bool { key := chapterKey(slug, vol, n) _, err := m.c.StatObject(ctx, m.cfg.BucketChapters, key, minio.StatObjectOptions{}) return err == nil } // ListChapterKeys returns all object keys under slug/ in the chapters bucket, // sorted lexicographically (MinIO returns them in order). func (m *MinioClient) ListChapterKeys(ctx context.Context, slug string) ([]string, error) { prefix := slug + "/" var keys []string for obj := range m.c.ListObjects(ctx, m.cfg.BucketChapters, minio.ListObjectsOptions{Prefix: prefix, Recursive: true}) { if obj.Err != nil { return nil, fmt.Errorf("minio: list chapters %s: %w", slug, obj.Err) } keys = append(keys, obj.Key) } return keys, nil } // CountChapters returns the number of chapter objects for a slug. func (m *MinioClient) CountChapters(ctx context.Context, slug string) int { keys, _ := m.ListChapterKeys(ctx, slug) return len(keys) } // ─── Audio objects ──────────────────────────────────────────────────────────── // AudioObjectKey returns the MinIO key for a cached audio file. // Key: {slug}/ch{n}-{voice}.mp3 func AudioObjectKey(slug string, n int, voice string) string { safe := sanitiseVoice(voice) return fmt.Sprintf("%s/ch%d-%s.mp3", slug, n, safe) } // PutAudio stores an audio file in the audio bucket. func (m *MinioClient) PutAudio(ctx context.Context, key string, data []byte) error { _, err := m.c.PutObject(ctx, m.cfg.BucketAudio, key, bytes.NewReader(data), int64(len(data)), minio.PutObjectOptions{ContentType: "audio/mpeg"}) if err != nil { return fmt.Errorf("minio: put audio %s: %w", key, err) } return nil } // GetAudio retrieves audio bytes from the audio bucket. func (m *MinioClient) GetAudio(ctx context.Context, key string) ([]byte, error) { obj, err := m.c.GetObject(ctx, m.cfg.BucketAudio, key, minio.GetObjectOptions{}) if err != nil { return nil, fmt.Errorf("minio: get audio %s: %w", key, err) } defer obj.Close() return io.ReadAll(obj) } // AudioExists returns true if the audio object is present in the bucket. func (m *MinioClient) AudioExists(ctx context.Context, key string) bool { _, err := m.c.StatObject(ctx, m.cfg.BucketAudio, key, minio.StatObjectOptions{}) return err == nil } // ─── Presigned URLs ─────────────────────────────────────────────────────────── // 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) 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 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.pub.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 } // ─── Browse page snapshots ──────────────────────────────────────────────────── // // New bucket layout (libnovel-browse): // // {domain}/html/page-{n}.html — SingleFile HTML snapshot // {domain}/assets/book-covers/{slug}.jpg — downloaded cover image // // The domain segment is derived from the source URL hostname // (e.g. "novelfire.net"). This makes the bucket self-describing and // extensible to multiple sources. // BrowseHTMLKey returns the MinIO object key for a SingleFile HTML snapshot. // Layout: {domain}/html/page-{n}.html // This uses the default (popular/all/all) filter combination. func BrowseHTMLKey(domain string, page int) string { return fmt.Sprintf("%s/html/page-%d.html", domain, page) } // BrowseFilteredHTMLKey returns the MinIO object key for a browse page snapshot // that includes filter parameters (sort, genre, status) in the key so that // different filter combinations are cached independently. // Layout: {domain}/html/{sort}-{genre}-{status}/page-{n}.html // Falls back to BrowseHTMLKey when all filters are at their default values // (sort=popular, genre=all, status=all) for cache compatibility. func BrowseFilteredHTMLKey(domain string, page int, sort, genre, status string) string { if (sort == "" || sort == "popular") && (genre == "" || genre == "all") && (status == "" || status == "all") { return BrowseHTMLKey(domain, page) } if sort == "" { sort = "popular" } if genre == "" { genre = "all" } if status == "" { status = "all" } return fmt.Sprintf("%s/html/%s-%s-%s/page-%d.html", domain, sort, genre, status, page) } // BrowseCoverKey returns the MinIO object key for a cached book cover image. // Layout: {domain}/assets/book-covers/{slug}.jpg func BrowseCoverKey(domain, slug string) string { return fmt.Sprintf("%s/assets/book-covers/%s.jpg", domain, slug) } // PutBrowsePage stores a SingleFile HTML snapshot in the browse bucket. func (m *MinioClient) PutBrowsePage(ctx context.Context, key, html string) error { data := []byte(html) _, err := m.c.PutObject(ctx, m.cfg.BucketBrowse, key, bytes.NewReader(data), int64(len(data)), minio.PutObjectOptions{ContentType: "text/html; charset=utf-8"}) if err != nil { return fmt.Errorf("minio: put browse page %s: %w", key, err) } return nil } // GetBrowsePage retrieves a SingleFile HTML snapshot from the browse bucket. // Returns ("", false, nil) when the object does not exist. func (m *MinioClient) GetBrowsePage(ctx context.Context, key string) (string, bool, error) { obj, err := m.c.GetObject(ctx, m.cfg.BucketBrowse, key, minio.GetObjectOptions{}) if err != nil { return "", false, fmt.Errorf("minio: get browse page %s: %w", key, err) } defer obj.Close() // Check whether the object actually exists by inspecting the Stat. if _, statErr := obj.Stat(); statErr != nil { return "", false, nil // not found } data, err := io.ReadAll(obj) if err != nil { return "", false, fmt.Errorf("minio: read browse page %s: %w", key, err) } return string(data), true, nil } // BrowsePageExists returns true if a snapshot object is present in the browse bucket. func (m *MinioClient) BrowsePageExists(ctx context.Context, key string) bool { _, err := m.c.StatObject(ctx, m.cfg.BucketBrowse, key, minio.StatObjectOptions{}) return err == nil } // PutBrowseAsset stores a binary asset (e.g. a cover image) in the browse bucket. // contentType should be the MIME type, e.g. "image/jpeg". func (m *MinioClient) PutBrowseAsset(ctx context.Context, key string, data []byte, contentType string) error { _, err := m.c.PutObject(ctx, m.cfg.BucketBrowse, key, bytes.NewReader(data), int64(len(data)), minio.PutObjectOptions{ContentType: contentType}) if err != nil { return fmt.Errorf("minio: put browse asset %s: %w", key, err) } return nil } // GetBrowseAsset retrieves a binary asset from the browse bucket. // Returns (nil, false, nil) when the object does not exist. func (m *MinioClient) GetBrowseAsset(ctx context.Context, key string) ([]byte, string, bool, error) { obj, err := m.c.GetObject(ctx, m.cfg.BucketBrowse, key, minio.GetObjectOptions{}) if err != nil { return nil, "", false, fmt.Errorf("minio: get browse asset %s: %w", key, err) } defer obj.Close() info, statErr := obj.Stat() if statErr != nil { return nil, "", false, nil // not found } data, err := io.ReadAll(obj) if err != nil { return nil, "", false, fmt.Errorf("minio: read browse asset %s: %w", key, err) } return data, info.ContentType, true, nil } // ─── helpers ────────────────────────────────────────────────────────────────── // sanitiseVoice converts a voice name to a filename-safe string. func sanitiseVoice(voice string) string { return strings.Map(func(r rune) rune { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' { return r } return '_' }, voice) } // ─── Avatar objects ─────────────────────────────────────────────────────────── // avatarKey returns the MinIO object key for a user avatar. // Layout: avatars/{userId}.{ext} func avatarKey(userID, ext string) string { return fmt.Sprintf("avatars/%s.%s", userID, ext) } // PutAvatar stores an avatar image in the avatars bucket. // ext should be "jpg", "png", or "webp". func (m *MinioClient) PutAvatar(ctx context.Context, userID, ext string, data []byte, contentType string) error { if m.cfg.BucketAvatars == "" { return fmt.Errorf("minio: avatars bucket not configured") } key := avatarKey(userID, ext) _, err := m.c.PutObject(ctx, m.cfg.BucketAvatars, key, bytes.NewReader(data), int64(len(data)), minio.PutObjectOptions{ContentType: contentType}) if err != nil { return fmt.Errorf("minio: put avatar %s: %w", key, err) } return nil } // PresignAvatarUploadURL returns a presigned PUT URL for uploading an avatar image // directly to MinIO from the client. Signed with the public endpoint so iOS/browser // can PUT bytes straight to MinIO without routing through the server. // ext should be "jpg", "png", or "webp". Expires in 15 minutes. func (m *MinioClient) PresignAvatarUploadURL(ctx context.Context, userID, ext string) (string, string, error) { if m.cfg.BucketAvatars == "" { return "", "", fmt.Errorf("minio: avatars bucket not configured") } key := avatarKey(userID, ext) u, err := m.pub.PresignedPutObject(ctx, m.cfg.BucketAvatars, key, 15*time.Minute) if err != nil { return "", "", fmt.Errorf("minio: presign avatar upload %s: %w", key, err) } return u.String(), key, nil } // PresignAvatarURL returns a presigned GET URL for a user avatar. // Returns ("", false, nil) when no avatar exists for the given userID. func (m *MinioClient) PresignAvatarURL(ctx context.Context, userID string) (string, bool, error) { if m.cfg.BucketAvatars == "" { return "", false, nil } // Try common extensions in order of preference. for _, ext := range []string{"jpg", "png", "webp", "gif"} { key := avatarKey(userID, ext) _, statErr := m.c.StatObject(ctx, m.cfg.BucketAvatars, key, minio.StatObjectOptions{}) if statErr != nil { continue } u, err := m.pub.PresignedGetObject(ctx, m.cfg.BucketAvatars, key, 24*time.Hour, nil) if err != nil { return "", false, fmt.Errorf("minio: presign avatar %s: %w", key, err) } return u.String(), true, nil } return "", false, nil } // DeleteAvatar removes any existing avatar for the given userID (all extensions). func (m *MinioClient) DeleteAvatar(ctx context.Context, userID string) error { if m.cfg.BucketAvatars == "" { return nil } for _, ext := range []string{"jpg", "png", "webp", "gif"} { key := avatarKey(userID, ext) _ = m.c.RemoveObject(ctx, m.cfg.BucketAvatars, key, minio.RemoveObjectOptions{}) } return nil }