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
+62
View File
@@ -25,6 +25,13 @@ const (
RolePlayback = "playback"
)
// Avatar roles as bound to a user. The small variant serves lists and headers;
// the large one serves the profile page.
const (
RoleAvatar = "avatar"
RoleAvatarSmall = "avatar_small"
)
var (
// ErrObjectNotFound is returned when an object row does not exist.
ErrObjectNotFound = errors.New("object not found")
@@ -120,6 +127,60 @@ func (s *Store) BindObject(ctx context.Context, photoID, objectID, role string)
return tx.Commit(ctx)
}
// BindUserAvatar attaches a ready object as the user's avatar under the given
// role. It mirrors BindObject: the object row is locked and refused unless it is
// ready, so an object being garbage-collected can never be bound.
func (s *Store) BindUserAvatar(ctx context.Context, userID, objectID, role string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
var state string
if err := tx.QueryRow(ctx, `SELECT state FROM objects WHERE id = $1 FOR UPDATE`, objectID).Scan(&state); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrObjectNotFound
}
return err
}
if state != ObjectReady {
return ErrObjectNotReady
}
if _, err := tx.Exec(ctx,
`INSERT INTO user_avatar_objects (user_id, object_id, role) VALUES ($1, $2, $3)
ON CONFLICT (user_id, role) DO UPDATE SET object_id = EXCLUDED.object_id`,
userID, objectID, role); err != nil {
return err
}
return tx.Commit(ctx)
}
// UserAvatarObject returns the object currently bound as the user's avatar for
// the given role.
func (s *Store) UserAvatarObject(ctx context.Context, userID, role string) (*Object, error) {
var o Object
err := s.pool.QueryRow(ctx,
`SELECT o.id, o.storage_key, o.mime_type, o.size, o.width, o.height, o.checksum, o.state, o.created_at
FROM user_avatar_objects uao JOIN objects o ON o.id = uao.object_id
WHERE uao.user_id = $1 AND uao.role = $2`, userID, role).
Scan(&o.ID, &o.StorageKey, &o.MimeType, &o.Size, &o.Width, &o.Height, &o.Checksum, &o.State, &o.CreatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrObjectNotFound
}
return nil, err
}
return &o, nil
}
// ClearUserAvatar removes every avatar binding of the user. The blobs become
// unreferenced and are reclaimed by garbage collection.
func (s *Store) ClearUserAvatar(ctx context.Context, userID string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM user_avatar_objects WHERE user_id = $1`, userID)
return err
}
// PhotoObjectIDs returns the object ids currently bound to a photo.
func (s *Store) PhotoObjectIDs(ctx context.Context, photoID string) ([]string, error) {
rows, err := s.pool.Query(ctx, `SELECT object_id FROM photo_objects WHERE photo_id = $1`, photoID)
@@ -199,6 +260,7 @@ func (s *Store) ClaimGarbage(ctx context.Context, limit int) ([]Object, error) {
`SELECT o.id, o.storage_key FROM objects o
WHERE o.state = ANY($1)
AND NOT EXISTS (SELECT 1 FROM photo_objects po WHERE po.object_id = o.id)
AND NOT EXISTS (SELECT 1 FROM user_avatar_objects uao WHERE uao.object_id = o.id)
ORDER BY o.created_at
LIMIT $2
FOR UPDATE SKIP LOCKED`, []string{ObjectReady, ObjectDeleting}, limit)