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