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
+3
View File
@@ -19,6 +19,9 @@ type Repository interface {
AddIdentifier(ctx context.Context, userID, email, phone string) error
DeleteUser(ctx context.Context, userID string) error
UpdatePreferences(ctx context.Context, userID string, raw []byte) error
// SetAvatarURL stores the stable URL of the user's avatar; an empty value
// clears it.
SetAvatarURL(ctx context.Context, userID, url string) error
CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error
GetRefreshToken(ctx context.Context, hash string) (*models.RefreshToken, error)
RevokeRefreshToken(ctx context.Context, id string) error
+7
View File
@@ -88,6 +88,13 @@ func (s *SQLiteStore) UpdatePreferences(ctx context.Context, userID string, raw
`UPDATE users SET preferences = $2 WHERE id = $1`, userID, string(raw)))
}
// SetAvatarURL stores the stable URL of the user's avatar. An empty value
// clears the column.
func (s *SQLiteStore) SetAvatarURL(ctx context.Context, userID, url string) error {
return s.requireRow(s.db.ExecContext(ctx,
`UPDATE users SET avatar_url = NULLIF($2, '') WHERE id = $1`, userID, url))
}
func (s *SQLiteStore) requireRow(res sql.Result, err error) error {
if err != nil {
return err
+8
View File
@@ -105,6 +105,14 @@ func (s *Store) UpdatePreferences(ctx context.Context, userID string, raw []byte
return requireRow(tag, err)
}
// SetAvatarURL stores the stable URL of the user's avatar. An empty value
// clears the column.
func (s *Store) SetAvatarURL(ctx context.Context, userID, url string) error {
tag, err := s.pool.Exec(ctx,
`UPDATE users SET avatar_url = NULLIF($2, '') WHERE id = $1`, userID, url)
return requireRow(tag, err)
}
func (s *Store) CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error {
_, err := s.pool.Exec(ctx,
`INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at)