Files
ds-galo/backend/internal/avatar/handlers.go
T
iu 04617d1cdb 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
2026-10-12 16:16:47 +03:00

81 lines
2.2 KiB
Go

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)
}