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
@@ -49,6 +49,7 @@ func Run(t *testing.T, r Repos) {
{"Social", testSocial},
{"Photos", testPhotos},
{"Objects", testObjects},
{"UserAvatar", testUserAvatar},
{"VariantJobKinds", testVariantJobKinds},
{"Settings", testSettings},
}
@@ -648,6 +649,79 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
}
}
func testUserAvatar(t *testing.T, ctx context.Context, r Repos) {
owner := seedUser(t, ctx, r, "Owner")
obj := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/webp", State: photos.ObjectPending}
if err := r.Photos.CreateObject(ctx, obj); err != nil {
t.Fatalf("create avatar object: %v", err)
}
if err := r.Photos.BindUserAvatar(ctx, owner.ID, obj.ID, photos.RoleAvatar); !errors.Is(err, photos.ErrObjectNotReady) {
t.Fatalf("bind pending avatar err = %v, want ErrObjectNotReady", err)
}
if err := r.Photos.ActivateObject(ctx, obj.ID); err != nil {
t.Fatalf("activate avatar object: %v", err)
}
if err := r.Photos.BindUserAvatar(ctx, owner.ID, obj.ID, photos.RoleAvatar); err != nil {
t.Fatalf("bind avatar: %v", err)
}
got, err := r.Photos.UserAvatarObject(ctx, owner.ID, photos.RoleAvatar)
if err != nil || got.ID != obj.ID || got.StorageKey != obj.StorageKey {
t.Fatalf("avatar object: %v %+v", err, got)
}
// Roles are independent: no small variant was bound.
if _, err := r.Photos.UserAvatarObject(ctx, owner.ID, photos.RoleAvatarSmall); !errors.Is(err, photos.ErrObjectNotFound) {
t.Fatalf("unbound small role err = %v, want ErrObjectNotFound", err)
}
// A bound avatar blob survives garbage collection.
claimed, err := r.Photos.ClaimGarbage(ctx, 10)
if err != nil {
t.Fatalf("claim garbage: %v", err)
}
if containsObject(claimed, obj.ID) {
t.Fatalf("avatar object collected while bound: %+v", claimed)
}
// Replacing the binding (a new generation) makes the old blob collectible.
replacement := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/webp", State: photos.ObjectPending}
if err := r.Photos.CreateObject(ctx, replacement); err != nil {
t.Fatalf("create replacement: %v", err)
}
if err := r.Photos.ActivateObject(ctx, replacement.ID); err != nil {
t.Fatalf("activate replacement: %v", err)
}
if err := r.Photos.BindUserAvatar(ctx, owner.ID, replacement.ID, photos.RoleAvatar); err != nil {
t.Fatalf("rebind avatar: %v", err)
}
claimed, _ = r.Photos.ClaimGarbage(ctx, 10)
if !containsObject(claimed, obj.ID) {
t.Fatalf("superseded avatar object not collected: %+v", claimed)
}
// Clearing removes every binding so the current blob is collected too.
if err := r.Photos.ClearUserAvatar(ctx, owner.ID); err != nil {
t.Fatalf("clear avatar: %v", err)
}
if _, err := r.Photos.UserAvatarObject(ctx, owner.ID, photos.RoleAvatar); !errors.Is(err, photos.ErrObjectNotFound) {
t.Fatalf("avatar still bound after clear: %v", err)
}
claimed, _ = r.Photos.ClaimGarbage(ctx, 10)
if !containsObject(claimed, replacement.ID) {
t.Fatalf("cleared avatar object not collected: %+v", claimed)
}
}
func containsObject(list []photos.Object, id string) bool {
for _, o := range list {
if o.ID == id {
return true
}
}
return false
}
func testVariantJobKinds(t *testing.T, ctx context.Context, r Repos) {
owner := seedUser(t, ctx, r, "Owner")
a := seedAlbum(t, ctx, r, owner.ID)
@@ -44,7 +44,7 @@ func TestSQLiteContract(t *testing.T) {
Settings: settings.NewSQLiteStore(database),
Reset: func(ctx context.Context) error {
tables := []string{
"variant_jobs", "photo_objects", "objects", "photo_tags", "tags",
"variant_jobs", "photo_objects", "user_avatar_objects", "objects", "photo_tags", "tags",
"refresh_tokens", "share_links", "notifications", "likes", "comments",
"invites", "album_members", "photos", "albums", "users", "app_settings",
}