246 lines
6.5 KiB
Go
246 lines
6.5 KiB
Go
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/database"
|
||
)
|
||
|
||
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 = database.DB.Exec(
|
||
`UPDATE users SET avatar_url = $1, updated_at = NOW() WHERE id = $2`,
|
||
dataURI, userID,
|
||
)
|
||
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 := database.DB.Exec(
|
||
`UPDATE users SET avatar_url = NULL, updated_at = NOW() WHERE id = $1`,
|
||
userID,
|
||
)
|
||
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),
|
||
}
|
||
}
|
||
|
||
// ── Preset Avatar Upload ────────────────────
|
||
// POST /api/v1/presets/:id/avatar (user) or /api/v1/admin/presets/:id/avatar (admin)
|
||
func UploadPresetAvatar(c *gin.Context) {
|
||
presetID := 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())
|
||
|
||
result, err := database.DB.Exec(
|
||
`UPDATE personas SET avatar = $1, updated_at = NOW() WHERE id = $2`,
|
||
dataURI, presetID,
|
||
)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save avatar"})
|
||
return
|
||
}
|
||
rows, _ := result.RowsAffected()
|
||
if rows == 0 {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{"avatar": dataURI})
|
||
}
|
||
|
||
// DeletePresetAvatar clears a preset's avatar.
|
||
func DeletePresetAvatar(c *gin.Context) {
|
||
presetID := c.Param("id")
|
||
|
||
result, err := database.DB.Exec(
|
||
`UPDATE personas SET avatar = '', updated_at = NOW() WHERE id = $1`,
|
||
presetID,
|
||
)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to remove avatar"})
|
||
return
|
||
}
|
||
rows, _ := result.RowsAffected()
|
||
if rows == 0 {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{"message": "avatar removed"})
|
||
}
|