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
+14
View File
@@ -14,6 +14,7 @@ import (
assets "galo"
"galo/internal/albums"
"galo/internal/auth"
"galo/internal/avatar"
"galo/internal/config"
"galo/internal/httpx"
"galo/internal/members"
@@ -38,6 +39,8 @@ type Server struct {
auth *auth.Service
avatar *avatar.Service
membersSvc *members.Service
photos *photos.Service
@@ -71,6 +74,8 @@ func New(cfg *config.Config, repos store.Repos, ping func(ctx context.Context) e
photosSvc.SetTempDir(cfg.Video.TempDir)
photosWorker.SetVideo(videoTools, cfg.Video.TranscodeConcurrency)
avatarSvc := avatar.NewService(repos.Photos, repos.Auth, st)
socialSvc := social.NewService(repos.Social, albumsSvc, repos.Photos, hub, notifSvc)
shareSvc := sharelinks.NewService(repos.ShareLinks, albumsSvc)
@@ -83,6 +88,7 @@ func New(cfg *config.Config, repos store.Repos, ping func(ctx context.Context) e
hub: hub,
albums: albumsSvc,
auth: authSvc,
avatar: avatarSvc,
membersSvc: membersSvc,
photos: photosSvc,
worker: photosWorker,
@@ -106,6 +112,7 @@ func (s *Server) Router() *gin.Engine {
authHandler := auth.NewHandler(s.auth)
albumsHandler := albums.NewHandler(s.albums)
avatarHandler := avatar.NewHandler(s.avatar)
membersHandler := members.NewHandler(s.membersSvc)
photosHandler := photos.NewHandler(s.photos)
socialHandler := social.NewHandler(s.social)
@@ -116,6 +123,11 @@ func (s *Server) Router() *gin.Engine {
api.GET("/health", s.health)
api.GET("/objects/*key", s.serveObject)
api.HEAD("/objects/*key", s.serveObject)
// Avatars are read by <img> tags and image loaders, which cannot send an
// Authorization header, so the read route is public. Upload and removal
// stay behind authentication. The URL carries an opaque per-generation
// version segment, so the path is not guessable from a user id alone.
api.GET("/avatars/:userID/:version", avatarHandler.Serve)
api.POST("/auth/register", authHandler.Register)
api.POST("/auth/login", authHandler.Login)
@@ -134,6 +146,8 @@ func (s *Server) Router() *gin.Engine {
authed.GET("/auth/me", authHandler.Me)
authed.PATCH("/auth/me", authHandler.UpdateMe)
authed.POST("/auth/me/avatar", avatarHandler.Upload)
authed.DELETE("/auth/me/avatar", avatarHandler.Remove)
authed.POST("/auth/me/password", authHandler.ChangePassword)
authed.POST("/auth/me/identifiers", authHandler.AddIdentifier)
authed.DELETE("/auth/me", authHandler.DeleteMe)