feat(artwork): add SetAvatar to the image uploader

This commit is contained in:
Deluan 2026-09-07 19:15:54 -04:00
commit cac170781b
2 changed files with 89 additions and 0 deletions

View file

@ -1,6 +1,7 @@
package artwork
import (
"bytes"
"context"
"fmt"
"io"
@ -31,6 +32,9 @@ func parseSize(value, fallback string) int64 {
// Uploader stores a user-uploaded entity image and invalidates that entity's artwork state.
type Uploader interface {
SetImage(ctx context.Context, entityType string, entityID string, name string, oldPath string, reader io.Reader, ext string) (filename string, err error)
// SetAvatar resizes a user avatar to consts.MaxAvatarSize (aspect ratio preserved,
// no square padding) and stores it under the avatar folder.
SetAvatar(ctx context.Context, userID, username, oldPath string, reader io.Reader, ext string) (filename string, err error)
RemoveImage(ctx context.Context, path string) error
// EnqueueArtwork re-resolves the item's artwork. Call it AFTER persisting the new
// filename, or the worker resolves the old one.
@ -77,6 +81,22 @@ func (s *uploader) SetImage(ctx context.Context, entityType string, entityID str
return filename, nil
}
func (s *uploader) SetAvatar(ctx context.Context, userID, username, oldPath string, reader io.Reader, ext string) (string, error) {
data, err := io.ReadAll(io.LimitReader(reader, MaxImageUploadSize()))
if err != nil {
return "", fmt.Errorf("reading avatar: %w", err)
}
// square=false: square padding would put transparent bars around a non-square photo.
resized, _, err := resizeStaticImage(data, consts.MaxAvatarSize, false)
if err != nil {
return "", fmt.Errorf("resizing avatar: %w", err)
}
if resized == nil {
resized = bytes.NewReader(data) // already within bounds: resizeStaticImage returns nil, not the original
}
return s.SetImage(ctx, consts.EntityUser, userID, username, oldPath, resized, ext)
}
func (s *uploader) EnqueueArtwork(ctx context.Context, entityType, id string) {
kind, ok := uploadEntityKind[entityType]
if !ok {

View file

@ -1,7 +1,10 @@
package artwork
import (
"bytes"
"context"
"image"
"image/png"
"os"
"path/filepath"
"strings"
@ -93,6 +96,65 @@ var _ = Describe("Uploader", func() {
})
})
Describe("SetAvatar", func() {
It("writes into the avatar folder, named after id and username", func() {
ctx := context.Background()
big := makePNG(1024, 1024)
filename, err := svc.SetAvatar(ctx, "u1", "deluan", "", bytes.NewReader(big), ".png")
Expect(err).ToNot(HaveOccurred())
Expect(filename).To(Equal("u1_deluan.png"))
Expect(filepath.Join(tmpDir, "avatar", filename)).To(BeAnExistingFile())
})
It("shrinks a large image to MaxAvatarSize, preserving aspect ratio", func() {
ctx := context.Background()
big := makePNG(1024, 768)
filename, err := svc.SetAvatar(ctx, "u1", "deluan", "", bytes.NewReader(big), ".png")
Expect(err).ToNot(HaveOccurred())
f, err := os.Open(filepath.Join(tmpDir, "avatar", filename))
Expect(err).ToNot(HaveOccurred())
defer f.Close()
cfg, _, err := image.DecodeConfig(f)
Expect(err).ToNot(HaveOccurred())
Expect(cfg.Width).To(Equal(consts.MaxAvatarSize))
Expect(cfg.Height).To(Equal(384)) // aspect ratio kept, not padded to a square
})
It("keeps a small image intact instead of writing an empty file", func() {
ctx := context.Background()
small := makePNG(64, 64)
filename, err := svc.SetAvatar(ctx, "u1", "deluan", "", bytes.NewReader(small), ".png")
Expect(err).ToNot(HaveOccurred())
data, err := os.ReadFile(filepath.Join(tmpDir, "avatar", filename))
Expect(err).ToNot(HaveOccurred())
Expect(data).ToNot(BeEmpty())
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
Expect(err).ToNot(HaveOccurred())
Expect(cfg.Width).To(Equal(64))
Expect(cfg.Height).To(Equal(64))
})
It("removes the previous file", func() {
ctx := context.Background()
old := filepath.Join(tmpDir, "avatar", "u1_old.png")
Expect(os.MkdirAll(filepath.Dir(old), 0755)).To(Succeed())
Expect(os.WriteFile(old, []byte("x"), 0600)).To(Succeed())
_, err := svc.SetAvatar(ctx, "u1", "deluan", old, bytes.NewReader(makePNG(64, 64)), ".png")
Expect(err).ToNot(HaveOccurred())
Expect(old).ToNot(BeAnExistingFile())
})
It("rejects a body that is not a decodable image", func() {
ctx := context.Background()
_, err := svc.SetAvatar(ctx, "u1", "deluan", "", strings.NewReader("not an image"), ".png")
Expect(err).To(HaveOccurred())
})
})
Describe("EnqueueArtwork", func() {
It("clears artwork state and enqueues a Bump", func() {
ctx := context.Background()
@ -172,3 +234,10 @@ var _ = Describe("MaxImageUploadSize", func() {
Expect(MaxImageUploadSize()).To(Equal(int64(52_428_800)))
})
})
func makePNG(w, h int) []byte {
img := image.NewRGBA(image.Rect(0, 0, w, h))
var buf bytes.Buffer
Expect(png.Encode(&buf, img)).To(Succeed())
return buf.Bytes()
}