diff --git a/core/artwork/uploader.go b/core/artwork/uploader.go index 76526af3c..90e10b555 100644 --- a/core/artwork/uploader.go +++ b/core/artwork/uploader.go @@ -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 { diff --git a/core/artwork/uploader_test.go b/core/artwork/uploader_test.go index 44f5ede26..d7c4f466a 100644 --- a/core/artwork/uploader_test.go +++ b/core/artwork/uploader_test.go @@ -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() +}