feat(profile): add round avatar with upload and cropping

- 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
This commit is contained in:
iu
2026-10-12 16:16:47 +03:00
parent 657a04309e
commit 04617d1cdb
49 changed files with 2362 additions and 21 deletions
+56
View File
@@ -0,0 +1,56 @@
// Package avatar manages a user's profile photo: it normalises an uploaded
// image into square WebP variants and keeps them as journaled objects bound to
// the user, so the object ledger's garbage collection preserves them.
package avatar
import (
"bytes"
"errors"
"image"
"github.com/disintegration/imaging"
"github.com/gen2brain/webp"
)
// Canonical avatar sizes. The small variant serves lists and headers; the large
// one serves the profile page.
const (
SizeLarge = 512
SizeSmall = 128
)
// maxUploadBytes caps an avatar upload. Avatars are small, so a generous
// general image limit would let a client stream needlessly.
const maxUploadBytes = 10 << 20
var errInvalidImage = errors.New("invalid image")
// normalize decodes an uploaded image and produces two square WebP variants.
// Decoding honours the EXIF orientation; re-encoding discards all metadata
// (including GPS), so the stored avatar carries none of the original's.
func normalize(data []byte) (large, small []byte, err error) {
img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true))
if err != nil {
return nil, nil, errInvalidImage
}
large, err = fillWebP(img, SizeLarge)
if err != nil {
return nil, nil, err
}
small, err = fillWebP(img, SizeSmall)
if err != nil {
return nil, nil, err
}
return large, small, nil
}
// fillWebP centre-crops the image to a square of the given size and encodes it
// as WebP.
func fillWebP(img image.Image, size int) ([]byte, error) {
square := imaging.Fill(img, size, size, imaging.Center, imaging.Lanczos)
var buf bytes.Buffer
if err := webp.Encode(&buf, square, webp.Options{Quality: 82}); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
+80
View File
@@ -0,0 +1,80 @@
package avatar
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"galo/internal/auth"
"galo/internal/httpx"
"galo/internal/photos"
)
// avatarCacheControl is safe to cache aggressively because the URL carries an
// opaque per-generation version segment: replacing the avatar changes the URL.
const avatarCacheControl = "private, max-age=31536000, immutable"
type Handler struct {
svc *Service
}
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
// Upload installs or replaces the authenticated user's avatar from a multipart
// "file" field and returns the updated self profile.
func (h *Handler) Upload(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.svc.MaxUploadBytes())
fileHeader, err := c.FormFile("file")
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
httpx.RespondError(c, httpx.NewError(http.StatusRequestEntityTooLarge, "file_too_large",
"avatar exceeds the maximum upload size"))
return
}
httpx.RespondError(c, httpx.BadRequest("invalid_request", "file is required"))
return
}
f, err := fileHeader.Open()
if err != nil {
httpx.RespondError(c, err)
return
}
defer f.Close()
u, err := h.svc.SetAvatar(c.Request.Context(), auth.UserID(c), f)
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"user": u.Self()})
}
// Remove resets the avatar to the default placeholder and returns the updated
// self profile.
func (h *Handler) Remove(c *gin.Context) {
u, err := h.svc.RemoveAvatar(c.Request.Context(), auth.UserID(c))
if err != nil {
httpx.RespondError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"user": u.Self()})
}
// Serve streams the user's avatar. The version segment only busts caches; the
// current generation is always served.
func (h *Handler) Serve(c *gin.Context) {
large := c.Query("size") == "large"
data, err := h.svc.AvatarImage(c.Request.Context(), c.Param("userID"), large)
if err != nil {
if errors.Is(err, photos.ErrObjectNotFound) {
httpx.RespondError(c, httpx.NotFound("avatar_not_found", "avatar not found"))
return
}
httpx.RespondError(c, err)
return
}
c.Header("Cache-Control", avatarCacheControl)
c.Data(http.StatusOK, "image/webp", data)
}
+89
View File
@@ -0,0 +1,89 @@
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)
}
}
+172
View File
@@ -0,0 +1,172 @@
package avatar
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"galo/internal/auth"
"galo/internal/httpx"
"galo/internal/models"
"galo/internal/photos"
"galo/internal/storage"
)
// ObjectStore is the slice of the object ledger the avatar service depends on.
// It is satisfied by the photos repository.
type ObjectStore interface {
CreateObject(ctx context.Context, o *photos.Object) error
ActivateObject(ctx context.Context, id string) error
DeleteObjectRow(ctx context.Context, id string) error
BindUserAvatar(ctx context.Context, userID, objectID, role string) error
UserAvatarObject(ctx context.Context, userID, role string) (*photos.Object, error)
ClearUserAvatar(ctx context.Context, userID string) error
}
// UserStore reads the account and stores the avatar's stable URL.
type UserStore interface {
GetUserByID(ctx context.Context, id string) (*models.User, error)
SetAvatarURL(ctx context.Context, userID, url string) error
}
// Service installs, replaces and removes a user's avatar.
type Service struct {
objects ObjectStore
users UserStore
storage storage.Backend
maxBytes int64
}
func NewService(objects ObjectStore, users UserStore, st storage.Backend) *Service {
return &Service{objects: objects, users: users, storage: st, maxBytes: maxUploadBytes}
}
// SetMaxUploadBytes overrides the per-file avatar size limit.
func (s *Service) SetMaxUploadBytes(n int64) {
if n > 0 {
s.maxBytes = n
}
}
// MaxUploadBytes returns the request body cap for an avatar upload.
func (s *Service) MaxUploadBytes() int64 { return s.maxBytes }
// SetAvatar normalises the uploaded image, stores both variants as journaled
// objects and rebinds them to the user. The previous avatar's blobs become
// unreferenced and are reclaimed by garbage collection.
func (s *Service) SetAvatar(ctx context.Context, userID string, r io.Reader) (*models.User, error) {
data, err := io.ReadAll(io.LimitReader(r, s.maxBytes+1))
if err != nil {
return nil, err
}
if int64(len(data)) > s.maxBytes {
return nil, httpx.NewError(http.StatusRequestEntityTooLarge, "file_too_large",
"avatar exceeds the maximum upload size")
}
large, small, err := normalize(data)
if err != nil {
return nil, httpx.BadRequest("invalid_image", "file is not a decodable image")
}
largeObj, err := s.putObject(ctx, photos.RoleAvatar, SizeLarge, large)
if err != nil {
return nil, err
}
smallObj, err := s.putObject(ctx, photos.RoleAvatarSmall, SizeSmall, small)
if err != nil {
_ = s.objects.DeleteObjectRow(ctx, largeObj.ID)
return nil, err
}
if err := s.objects.BindUserAvatar(ctx, userID, largeObj.ID, photos.RoleAvatar); err != nil {
s.rollback(ctx, userID, false, largeObj, smallObj)
return nil, err
}
if err := s.objects.BindUserAvatar(ctx, userID, smallObj.ID, photos.RoleAvatarSmall); err != nil {
s.rollback(ctx, userID, true, largeObj, smallObj)
return nil, err
}
if err := s.users.SetAvatarURL(ctx, userID, avatarURL(userID, largeObj.ID)); err != nil {
s.rollback(ctx, userID, true, largeObj, smallObj)
return nil, err
}
return s.users.GetUserByID(ctx, userID)
}
// RemoveAvatar clears the binding and the stored URL. The blobs are reclaimed
// by garbage collection.
func (s *Service) RemoveAvatar(ctx context.Context, userID string) (*models.User, error) {
if err := s.objects.ClearUserAvatar(ctx, userID); err != nil {
return nil, err
}
if err := s.users.SetAvatarURL(ctx, userID, ""); err != nil {
return nil, err
}
return s.users.GetUserByID(ctx, userID)
}
// AvatarImage returns the stored bytes of the user's avatar for the requested
// variant. It reports photos.ErrObjectNotFound when the user has no avatar.
func (s *Service) AvatarImage(ctx context.Context, userID string, large bool) ([]byte, error) {
role := photos.RoleAvatarSmall
if large {
role = photos.RoleAvatar
}
obj, err := s.objects.UserAvatarObject(ctx, userID, role)
if err != nil {
return nil, err
}
return s.storage.Get(ctx, obj.StorageKey)
}
// putObject records a pending object row, stores the bytes and activates it. A
// failure after the row exists removes it so no orphan is left behind.
func (s *Service) putObject(ctx context.Context, role string, size int, data []byte) (*photos.Object, error) {
id := auth.NewID()
key := photos.ObjectKey(id, role, ".webp")
obj := &photos.Object{
ID: id,
StorageKey: key,
MimeType: "image/webp",
Size: int64(len(data)),
Width: size,
Height: size,
Checksum: checksum(data),
}
if err := s.objects.CreateObject(ctx, obj); err != nil {
return nil, err
}
if err := s.storage.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "image/webp"); err != nil {
_ = s.objects.DeleteObjectRow(ctx, id)
return nil, err
}
if err := s.objects.ActivateObject(ctx, id); err != nil {
_ = s.objects.DeleteObjectRow(ctx, id)
return nil, err
}
return obj, nil
}
// rollback discards freshly created objects after a failed install. When a new
// binding was already written it is cleared first, so the object rows can be
// removed without violating the binding's foreign key.
func (s *Service) rollback(ctx context.Context, userID string, bound bool, objs ...*photos.Object) {
if bound {
_ = s.objects.ClearUserAvatar(ctx, userID)
}
for _, o := range objs {
_ = s.objects.DeleteObjectRow(ctx, o.ID)
}
}
func avatarURL(userID, version string) string {
return "/api/avatars/" + userID + "/" + version
}
func checksum(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
+223
View File
@@ -0,0 +1,223 @@
package avatar
import (
"bytes"
"context"
"errors"
"io"
"io/fs"
"path/filepath"
"testing"
"time"
assets "galo"
"galo/internal/auth"
"galo/internal/db"
"galo/internal/httpx"
"galo/internal/models"
"galo/internal/photos"
"galo/internal/storage"
"galo/internal/store"
)
type stack struct {
svc *Service
repos store.Repos
blob storage.Backend
userID string
}
func newStack(t *testing.T) *stack {
t.Helper()
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
if err != nil {
t.Fatalf("migrations fs: %v", err)
}
path := filepath.Join(t.TempDir(), "meta.db")
if err := db.MigrateSQLite(path, migFS); err != nil {
t.Fatalf("migrate: %v", err)
}
database, err := db.OpenSQLite(path)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { database.Close() })
repos := store.NewSQLite(database)
signer, err := storage.NewSigner("secret", time.Hour, 10*time.Minute)
if err != nil {
t.Fatalf("signer: %v", err)
}
blob, err := storage.NewFS(t.TempDir(), signer)
if err != nil {
t.Fatalf("fs: %v", err)
}
u := &models.User{ID: auth.NewID(), Email: auth.NewID()[:10] + "@test.local", PasswordHash: "x", Name: "Tester"}
if err := repos.Auth.CreateUser(context.Background(), u); err != nil {
t.Fatalf("create user: %v", err)
}
return &stack{svc: NewService(repos.Photos, repos.Auth, blob), repos: repos, blob: blob, userID: u.ID}
}
func (s *stack) objectKeys(t *testing.T) int {
t.Helper()
keys, err := s.repos.Photos.AllStorageKeys(context.Background())
if err != nil {
t.Fatalf("all storage keys: %v", err)
}
return len(keys)
}
func errCode(t *testing.T, err error) string {
t.Helper()
var appErr *httpx.AppError
if !errors.As(err, &appErr) {
t.Fatalf("expected AppError, got %v", err)
}
return appErr.Code
}
func TestSetAvatarBindsBothVariants(t *testing.T) {
ctx := context.Background()
st := newStack(t)
before := st.objectKeys(t)
updated, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader(jpegImage(t, 400, 400)))
if err != nil {
t.Fatalf("set avatar: %v", err)
}
if updated.AvatarURL == "" || !bytes.Contains([]byte(updated.AvatarURL), []byte(st.userID)) {
t.Fatalf("avatar_url not set: %q", updated.AvatarURL)
}
if st.objectKeys(t) != before+2 {
t.Fatalf("object count = %d, want %d", st.objectKeys(t), before+2)
}
large, err := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatar)
if err != nil || large.Width != SizeLarge || large.Height != SizeLarge {
t.Fatalf("large variant: %v %+v", err, large)
}
small, err := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatarSmall)
if err != nil || small.Width != SizeSmall || small.Height != SizeSmall {
t.Fatalf("small variant: %v %+v", err, small)
}
data, err := st.svc.AvatarImage(ctx, st.userID, false)
if err != nil || len(data) == 0 {
t.Fatalf("serve small: %v len=%d", err, len(data))
}
}
func TestSetAvatarReplacementReclaimsOldObjects(t *testing.T) {
ctx := context.Background()
st := newStack(t)
if _, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader(jpegImage(t, 300, 300))); err != nil {
t.Fatalf("first avatar: %v", err)
}
oldLarge, _ := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatar)
oldSmall, _ := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatarSmall)
if _, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader(jpegImage(t, 200, 200))); err != nil {
t.Fatalf("replace avatar: %v", err)
}
claimed, err := st.repos.Photos.ClaimGarbage(ctx, 10)
if err != nil {
t.Fatalf("claim garbage: %v", err)
}
got := map[string]bool{}
for _, o := range claimed {
got[o.ID] = true
}
if !got[oldLarge.ID] || !got[oldSmall.ID] {
t.Fatalf("superseded objects not collected: %+v", got)
}
// The current avatar keeps its objects.
newLarge, _ := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatar)
if got[newLarge.ID] {
t.Fatalf("current avatar object was collected")
}
}
func TestRemoveAvatarClearsAndReclaims(t *testing.T) {
ctx := context.Background()
st := newStack(t)
if _, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader(jpegImage(t, 100, 100))); err != nil {
t.Fatalf("set avatar: %v", err)
}
large, _ := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatar)
updated, err := st.svc.RemoveAvatar(ctx, st.userID)
if err != nil {
t.Fatalf("remove avatar: %v", err)
}
if updated.AvatarURL != "" {
t.Fatalf("avatar_url not cleared: %q", updated.AvatarURL)
}
if _, err := st.repos.Photos.UserAvatarObject(ctx, st.userID, photos.RoleAvatar); !errors.Is(err, photos.ErrObjectNotFound) {
t.Fatalf("avatar still bound: %v", err)
}
claimed, _ := st.repos.Photos.ClaimGarbage(ctx, 10)
found := false
for _, o := range claimed {
if o.ID == large.ID {
found = true
}
}
if !found {
t.Fatalf("removed avatar object not collected: %+v", claimed)
}
}
func TestSetAvatarRejectsNonImage(t *testing.T) {
ctx := context.Background()
st := newStack(t)
if _, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader([]byte("not an image"))); errCode(t, err) != "invalid_image" {
t.Fatalf("code = %v, want invalid_image", err)
}
if st.objectKeys(t) != 0 {
t.Fatalf("objects created for an invalid image")
}
}
func TestSetAvatarRejectsOversize(t *testing.T) {
ctx := context.Background()
st := newStack(t)
st.svc.SetMaxUploadBytes(1024)
if _, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader(make([]byte, 2048))); errCode(t, err) != "file_too_large" {
t.Fatalf("code = %v, want file_too_large", err)
}
if st.objectKeys(t) != 0 {
t.Fatalf("objects created for an oversize upload")
}
}
// failingStorage delegates to a real backend but fails every Put, to exercise
// the rollback that must not leave orphaned object rows.
type failingStorage struct {
storage.Backend
}
func (failingStorage) Put(context.Context, string, io.Reader, int64, string) error {
return errors.New("put failed")
}
func TestSetAvatarStorageFailureLeavesNoObjects(t *testing.T) {
ctx := context.Background()
st := newStack(t)
st.svc = NewService(st.repos.Photos, st.repos.Auth, failingStorage{st.blob})
if _, err := st.svc.SetAvatar(ctx, st.userID, bytes.NewReader(jpegImage(t, 100, 100))); err == nil {
t.Fatalf("expected storage failure")
}
if st.objectKeys(t) != 0 {
t.Fatalf("orphaned object rows after storage failure")
}
}