All checks were successful
CI / Scraper / Test (push) Successful in 10s
CI / Scraper / Lint (push) Successful in 12s
Release / Scraper / Test (push) Successful in 20s
CI / Scraper / Lint (pull_request) Successful in 11s
Release / UI / Build (push) Successful in 18s
CI / Scraper / Test (pull_request) Successful in 18s
CI / UI / Build (pull_request) Successful in 24s
CI / Scraper / Docker Push (push) Successful in 40s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
Release / UI / Docker (push) Successful in 37s
Release / Scraper / Docker (push) Successful in 54s
iOS CI / Build (pull_request) Successful in 3m14s
iOS CI / Test (pull_request) Successful in 13m12s
421 lines
16 KiB
Go
421 lines
16 KiB
Go
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) {
|
|
// minio-go expects a bare "host:port" endpoint — strip any scheme prefix that
|
|
// callers may accidentally include (e.g. "https://minio.example.com").
|
|
cfg.Endpoint = strings.TrimPrefix(strings.TrimPrefix(cfg.Endpoint, "https://"), "http://")
|
|
if cfg.PublicEndpoint != "" {
|
|
cfg.PublicEndpoint = strings.TrimPrefix(strings.TrimPrefix(cfg.PublicEndpoint, "https://"), "http://")
|
|
}
|
|
|
|
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
|
|
}
|