- 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
173 lines
5.5 KiB
Go
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[:])
|
|
}
|