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
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user