- backend: user_avatar_objects binding (PG+SQLite), GC preserves avatar blobs, avatar service (EXIF-stripping WebP 128/512), public avatar read route - web: avatar upload/crop on profile, round avatar in header/members/comments/author - android: avatar upload/crop, round avatar on profile and albums header - openspec: add-user-avatar proposal/design/specs/tasks
90 lines
2.6 KiB
Go
90 lines
2.6 KiB
Go
package avatar
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"image"
|
|
"image/color"
|
|
"image/jpeg"
|
|
"testing"
|
|
|
|
"github.com/gen2brain/webp"
|
|
)
|
|
|
|
// jpegImage builds a non-square JPEG so the square centre crop is observable.
|
|
func jpegImage(t *testing.T, w, h int) []byte {
|
|
t.Helper()
|
|
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
|
for x := 0; x < w; x++ {
|
|
for y := 0; y < h; y++ {
|
|
img.Set(x, y, color.RGBA{R: uint8(x % 256), G: uint8(y % 256), B: 128, A: 255})
|
|
}
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := jpeg.Encode(&buf, img, nil); err != nil {
|
|
t.Fatalf("encode jpeg: %v", err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// withEXIFSegment inserts an APP1 segment carrying an "Exif\x00\x00" marker.
|
|
// The payload stands in for real EXIF metadata (including GPS): the JPEG
|
|
// decoder ignores it, and re-encoding must drop it.
|
|
func withEXIFSegment(t *testing.T, jpegData []byte) []byte {
|
|
t.Helper()
|
|
payload := append([]byte("Exif\x00\x00"), 0x00, 0x00) // bogus TIFF header
|
|
segLen := len(payload) + 2
|
|
seg := []byte{0xFF, 0xE1, byte(segLen >> 8), byte(segLen & 0xFF)}
|
|
seg = append(seg, payload...)
|
|
if len(jpegData) < 2 {
|
|
t.Fatalf("not a jpeg")
|
|
}
|
|
out := append([]byte{}, jpegData[:2]...)
|
|
out = append(out, seg...)
|
|
out = append(out, jpegData[2:]...)
|
|
return out
|
|
}
|
|
|
|
func TestNormalizeSquaresAndStripsMetadata(t *testing.T) {
|
|
source := withEXIFSegment(t, jpegImage(t, 800, 400))
|
|
if !bytes.Contains(source, []byte("Exif")) {
|
|
t.Fatalf("test setup: source has no EXIF marker")
|
|
}
|
|
|
|
large, small, err := normalize(source)
|
|
if err != nil {
|
|
t.Fatalf("normalize: %v", err)
|
|
}
|
|
|
|
cfg, err := webp.DecodeConfig(bytes.NewReader(large))
|
|
if err != nil {
|
|
t.Fatalf("decode large: %v", err)
|
|
}
|
|
if cfg.Width != SizeLarge || cfg.Height != SizeLarge {
|
|
t.Fatalf("large = %dx%d, want %dx%d", cfg.Width, cfg.Height, SizeLarge, SizeLarge)
|
|
}
|
|
cfg, err = webp.DecodeConfig(bytes.NewReader(small))
|
|
if err != nil {
|
|
t.Fatalf("decode small: %v", err)
|
|
}
|
|
if cfg.Width != SizeSmall || cfg.Height != SizeSmall {
|
|
t.Fatalf("small = %dx%d, want %dx%d", cfg.Width, cfg.Height, SizeSmall, SizeSmall)
|
|
}
|
|
|
|
// Re-encoding must discard the source metadata.
|
|
for name, data := range map[string][]byte{"large": large, "small": small} {
|
|
if bytes.Contains(data, []byte("Exif")) {
|
|
t.Fatalf("%s WebP still contains an EXIF marker", name)
|
|
}
|
|
if _, err := webp.DecodeExif(bytes.NewReader(data)); !errors.Is(err, webp.ErrNoExif) {
|
|
t.Fatalf("%s metadata not stripped: %v", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNormalizeRejectsNonImage(t *testing.T) {
|
|
if _, _, err := normalize([]byte("definitely not an image")); !errors.Is(err, errInvalidImage) {
|
|
t.Fatalf("err = %v, want errInvalidImage", err)
|
|
}
|
|
}
|