Files
ds-galo/backend/internal/avatar/service.go
T
iu 04617d1cdb 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
2026-10-12 16:16:47 +03:00

173 lines
5.5 KiB
Go

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[:])
}