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": "" } // 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"}) }