feat: add avatar upload support (MinIO bucket, PocketBase field, SvelteKit API, iOS ProfileView)
Some checks failed
CI / Scraper / Lint (push) Successful in 15s
CI / Scraper / Test (push) Successful in 19s
CI / UI / Build (push) Successful in 21s
CI / Scraper / Lint (pull_request) Successful in 14s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 1m32s
iOS CI / Test (pull_request) Has been skipped
CI / UI / Docker Push (push) Successful in 6m42s
CI / Scraper / Docker Push (push) Successful in 7m12s

This commit is contained in:
Admin
2026-03-10 16:08:38 +05:00
parent 3a2d113b1b
commit fb8f1dfe25
19 changed files with 2226 additions and 83 deletions

View File

@@ -95,6 +95,7 @@ func run(log *slog.Logger) error {
BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "libnovel-chapters"),
BucketAudio: envOr("MINIO_BUCKET_AUDIO", "libnovel-audio"),
BucketBrowse: envOr("MINIO_BUCKET_BROWSE", "libnovel-browse"),
BucketAvatars: envOr("MINIO_BUCKET_AVATARS", "libnovel-avatars"),
}
pbCfg := storage.PocketBaseConfig{
BaseURL: envOr("POCKETBASE_URL", "http://localhost:8090"),

View File

@@ -23,6 +23,7 @@ type MinioConfig struct {
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
@@ -59,7 +60,7 @@ func NewMinioClient(ctx context.Context, cfg MinioConfig) (*MinioClient, error)
}
mc := &MinioClient{c: c, pub: pub, cfg: cfg}
for _, bucket := range []string{cfg.BucketChapters, cfg.BucketAudio, cfg.BucketBrowse} {
for _, bucket := range []string{cfg.BucketChapters, cfg.BucketAudio, cfg.BucketBrowse, cfg.BucketAvatars} {
if bucket == "" {
continue
}
@@ -336,3 +337,61 @@ func sanitiseVoice(voice string) string {
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
}
// 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
}

View File

@@ -458,6 +458,8 @@ type migration struct {
var migrations = []migration{
// user_id was added to progress after initial deploy.
{"progress", "user_id", "text"},
// avatar_url stores the MinIO presign path for the user's profile picture.
{"app_users", "avatar_url", "text"},
}
// EnsureMigrations idempotently adds any fields that are missing from existing