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:
@@ -15,3 +15,8 @@ func objectKey(objectID, role, ext string) string {
|
||||
}
|
||||
return fmt.Sprintf("objects/%s/%s%s", objectID, role, ext)
|
||||
}
|
||||
|
||||
// ObjectKey exposes the opaque storage-key format for callers outside the
|
||||
// photos package that need to create their own journaled objects (for example
|
||||
// user avatars). The key stays entity-free; only the caller knows the binding.
|
||||
func ObjectKey(objectID, role, ext string) string { return objectKey(objectID, role, ext) }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -49,6 +49,11 @@ type Repository interface {
|
||||
MarkObjectDeleting(ctx context.Context, id string) error
|
||||
DeleteObjectRow(ctx context.Context, id string) error
|
||||
BindObject(ctx context.Context, photoID, objectID, role string) error
|
||||
// BindUserAvatar, UserAvatarObject and ClearUserAvatar manage the object
|
||||
// binding that backs a user's profile avatar.
|
||||
BindUserAvatar(ctx context.Context, userID, objectID, role string) error
|
||||
UserAvatarObject(ctx context.Context, userID, role string) (*Object, error)
|
||||
ClearUserAvatar(ctx context.Context, userID string) error
|
||||
PhotoObjectIDs(ctx context.Context, photoID string) ([]string, error)
|
||||
ObjectRefCount(ctx context.Context, objectID string) (int, error)
|
||||
OriginalObject(ctx context.Context, photoID string) (*Object, error)
|
||||
|
||||
@@ -89,6 +89,62 @@ func (s *SQLiteStore) BindObject(ctx context.Context, photoID, objectID, role st
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// BindUserAvatar attaches a ready object as the user's avatar under the given
|
||||
// role. The write lock of the immediate transaction serializes it against
|
||||
// garbage collection.
|
||||
func (s *SQLiteStore) BindUserAvatar(ctx context.Context, userID, objectID, role string) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var state string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT state FROM objects WHERE id = $1`, objectID).Scan(&state); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrObjectNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if state != ObjectReady {
|
||||
return ErrObjectNotReady
|
||||
}
|
||||
if _, err := tx.ExecContext(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()
|
||||
}
|
||||
|
||||
// UserAvatarObject returns the object currently bound as the user's avatar for
|
||||
// the given role.
|
||||
func (s *SQLiteStore) UserAvatarObject(ctx context.Context, userID, role string) (*Object, error) {
|
||||
var o Object
|
||||
var createdAt string
|
||||
err := s.db.QueryRowContext(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, &createdAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrObjectNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
o.CreatedAt = sqliteutil.ParseTime(createdAt)
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
// ClearUserAvatar removes every avatar binding of the user. The blobs become
|
||||
// unreferenced and are reclaimed by garbage collection.
|
||||
func (s *SQLiteStore) ClearUserAvatar(ctx context.Context, userID string) error {
|
||||
_, err := s.db.ExecContext(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 *SQLiteStore) PhotoObjectIDs(ctx context.Context, photoID string) ([]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT object_id FROM photo_objects WHERE photo_id = $1`, photoID)
|
||||
@@ -169,6 +225,7 @@ func (s *SQLiteStore) ClaimGarbage(ctx context.Context, limit int) ([]Object, er
|
||||
`SELECT o.id, o.storage_key FROM objects o
|
||||
WHERE o.state IN ('ready', 'deleting')
|
||||
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 $1`, limit)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user