This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/avatar.go
2026-03-17 16:28:47 +00:00

229 lines
6.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
"bytes"
"encoding/base64"
"image"
"image/color"
"image/png"
"net/http"
"strings"
// Register decoders so image.Decode works for common formats
_ "image/gif"
_ "image/jpeg"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
const avatarSize = 128
const maxUploadBytes = 2 * 1024 * 1024 // 2MB raw input limit
type uploadAvatarRequest struct {
Image string `json:"image" binding:"required"` // base64 data URI or raw base64
}
// ── Upload Avatar ───────────────────────────
// POST /api/v1/profile/avatar
// Accepts { "image": "data:image/png;base64,..." } or { "image": "<raw base64>" }
// Decodes, resizes to 128×128 PNG, stores as data URI.
func (h *SettingsHandler) UploadAvatar(c *gin.Context) {
userID := getUserID(c)
var req uploadAvatarRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing image field"})
return
}
// Strip data URI prefix if present
b64 := req.Image
if idx := strings.Index(b64, ","); idx >= 0 {
b64 = b64[idx+1:]
}
// Decode base64
raw, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid base64 encoding"})
return
}
if len(raw) > maxUploadBytes {
c.JSON(http.StatusBadRequest, gin.H{"error": "image too large (max 2MB)"})
return
}
// Decode image (supports PNG, JPEG, GIF via registered decoders)
src, _, err := image.Decode(bytes.NewReader(raw))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported image format"})
return
}
// Resize to 128×128
resized := resizeBilinear(src, avatarSize, avatarSize)
// Encode to PNG
var buf bytes.Buffer
if err := png.Encode(&buf, resized); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode avatar"})
return
}
// Build data URI
dataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
// Store in DB
err = h.stores.Users.Update(c.Request.Context(), userID, map[string]interface{}{"avatar_url": dataURI})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save avatar"})
return
}
c.JSON(http.StatusOK, gin.H{"avatar": dataURI})
}
// ── Delete Avatar ───────────────────────────
// DELETE /api/v1/profile/avatar
func (h *SettingsHandler) DeleteAvatar(c *gin.Context) {
userID := getUserID(c)
err := h.stores.Users.Update(c.Request.Context(), userID, map[string]interface{}{"avatar_url": nil})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove avatar"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "avatar removed"})
}
// ── Bilinear Resize ─────────────────────────
// Pure stdlib bilinear interpolation. Quality is fine for 128×128 avatars.
func resizeBilinear(src image.Image, w, h int) *image.RGBA {
dst := image.NewRGBA(image.Rect(0, 0, w, h))
sb := src.Bounds()
sw := float64(sb.Dx())
sh := float64(sb.Dy())
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
// Map destination pixel to source coordinates
sx := (float64(x) + 0.5) * sw / float64(w) - 0.5
sy := (float64(y) + 0.5) * sh / float64(h) - 0.5
x0 := int(sx)
y0 := int(sy)
xf := sx - float64(x0)
yf := sy - float64(y0)
// Clamp
if x0 < sb.Min.X { x0 = sb.Min.X }
if y0 < sb.Min.Y { y0 = sb.Min.Y }
x1 := x0 + 1
y1 := y0 + 1
if x1 >= sb.Max.X { x1 = sb.Max.X - 1 }
if y1 >= sb.Max.Y { y1 = sb.Max.Y - 1 }
// Sample 4 neighbors
c00 := src.At(x0, y0)
c10 := src.At(x1, y0)
c01 := src.At(x0, y1)
c11 := src.At(x1, y1)
dst.Set(x, y, bilinearMix(c00, c10, c01, c11, xf, yf))
}
}
return dst
}
func bilinearMix(c00, c10, c01, c11 color.Color, xf, yf float64) color.Color {
r00, g00, b00, a00 := c00.RGBA()
r10, g10, b10, a10 := c10.RGBA()
r01, g01, b01, a01 := c01.RGBA()
r11, g11, b11, a11 := c11.RGBA()
mix := func(v00, v10, v01, v11 uint32) uint8 {
top := float64(v00)*(1-xf) + float64(v10)*xf
bot := float64(v01)*(1-xf) + float64(v11)*xf
return uint8((top*(1-yf) + bot*yf) / 256)
}
return color.RGBA{
R: mix(r00, r10, r01, r11),
G: mix(g00, g10, g01, g11),
B: mix(b00, b10, b01, b11),
A: mix(a00, a10, a01, a11),
}
}
// ── Persona Avatar Upload ────────────────────
// POST /api/v1/personas/:id/avatar (user) or /api/v1/admin/personas/:id/avatar (admin)
// UploadPersonaAvatar uploads and resizes a persona avatar.
// v0.29.0: accepts PersonaStore instead of using database.DB directly.
func UploadPersonaAvatar(personas store.PersonaStore, c *gin.Context) {
personaID := c.Param("id")
var req uploadAvatarRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing image field"})
return
}
b64 := req.Image
if idx := strings.Index(b64, ","); idx >= 0 {
b64 = b64[idx+1:]
}
raw, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid base64 encoding"})
return
}
if len(raw) > maxUploadBytes {
c.JSON(http.StatusBadRequest, gin.H{"error": "image too large (max 2MB)"})
return
}
src, _, err := image.Decode(bytes.NewReader(raw))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported image format"})
return
}
resized := resizeBilinear(src, avatarSize, avatarSize)
var buf bytes.Buffer
if err := png.Encode(&buf, resized); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode avatar"})
return
}
dataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
err = personas.Update(c.Request.Context(), personaID, models.PersonaPatch{Avatar: &dataURI})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save avatar"})
return
}
c.JSON(http.StatusOK, gin.H{"avatar": dataURI})
}
// DeletePersonaAvatar clears a persona's avatar.
// v0.29.0: accepts PersonaStore instead of using database.DB directly.
func DeletePersonaAvatar(personas store.PersonaStore, c *gin.Context) {
personaID := c.Param("id")
empty := ""
err := personas.Update(c.Request.Context(), personaID, models.PersonaPatch{Avatar: &empty})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove avatar"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "avatar removed"})
}