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:
@@ -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[:])
|
||||
}
|
||||
Reference in New Issue
Block a user