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:
@@ -0,0 +1,80 @@
|
||||
package avatar
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"galo/internal/auth"
|
||||
"galo/internal/httpx"
|
||||
"galo/internal/photos"
|
||||
)
|
||||
|
||||
// avatarCacheControl is safe to cache aggressively because the URL carries an
|
||||
// opaque per-generation version segment: replacing the avatar changes the URL.
|
||||
const avatarCacheControl = "private, max-age=31536000, immutable"
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
|
||||
|
||||
// Upload installs or replaces the authenticated user's avatar from a multipart
|
||||
// "file" field and returns the updated self profile.
|
||||
func (h *Handler) Upload(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.svc.MaxUploadBytes())
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
httpx.RespondError(c, httpx.NewError(http.StatusRequestEntityTooLarge, "file_too_large",
|
||||
"avatar exceeds the maximum upload size"))
|
||||
return
|
||||
}
|
||||
httpx.RespondError(c, httpx.BadRequest("invalid_request", "file is required"))
|
||||
return
|
||||
}
|
||||
f, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
u, err := h.svc.SetAvatar(c.Request.Context(), auth.UserID(c), f)
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"user": u.Self()})
|
||||
}
|
||||
|
||||
// Remove resets the avatar to the default placeholder and returns the updated
|
||||
// self profile.
|
||||
func (h *Handler) Remove(c *gin.Context) {
|
||||
u, err := h.svc.RemoveAvatar(c.Request.Context(), auth.UserID(c))
|
||||
if err != nil {
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"user": u.Self()})
|
||||
}
|
||||
|
||||
// Serve streams the user's avatar. The version segment only busts caches; the
|
||||
// current generation is always served.
|
||||
func (h *Handler) Serve(c *gin.Context) {
|
||||
large := c.Query("size") == "large"
|
||||
data, err := h.svc.AvatarImage(c.Request.Context(), c.Param("userID"), large)
|
||||
if err != nil {
|
||||
if errors.Is(err, photos.ErrObjectNotFound) {
|
||||
httpx.RespondError(c, httpx.NotFound("avatar_not_found", "avatar not found"))
|
||||
return
|
||||
}
|
||||
httpx.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", avatarCacheControl)
|
||||
c.Data(http.StatusOK, "image/webp", data)
|
||||
}
|
||||
Reference in New Issue
Block a user