- Admin layout: SVG icons, active highlight, divider between nav sections - Scrape page: status filter pills with counts, text + status combined search - Audio page: status filter pills, cancel jobs, retry failed jobs, mobile cards for cache tab - Translation page: status filter pills (incl. cancelled), cancel + retry jobs, mobile cancel/retry cards, i18n for all labels - AI Jobs page: fix concurrent cancel (Set instead of single slot), per-job cancel errors inline, full mobile card layout, i18n title/heading - Text-gen page: tagline editable input + copy, warnings copy, i18n title/heading - Book page: chapter cover Save button, audio monitor link, currentShelf pre-populated from server - pocketbase.ts: add getBookShelf(), shelf field on UserLibraryEntry - New API route: POST /api/admin/translation/bulk (proxy for translation retry) - i18n: 15 new admin_translation_*, admin_ai_jobs_*, admin_text_gen_* keys across all 5 locales
299 lines
11 KiB
Go
299 lines
11 KiB
Go
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)
|
|
}
|