Changeset 0.7.3 (#41)
This commit is contained in:
6
server/database/migrations/015_preset_avatars.sql
Normal file
6
server/database/migrations/015_preset_avatars.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- Avatar support for model presets.
|
||||
-- Users table already has avatar_url from 001_full_schema.sql.
|
||||
|
||||
ALTER TABLE model_presets ADD COLUMN IF NOT EXISTS avatar TEXT DEFAULT '';
|
||||
|
||||
COMMENT ON COLUMN model_presets.avatar IS 'Base64 data URI of preset avatar image (128x128 PNG), empty = use icon emoji';
|
||||
@@ -283,6 +283,7 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) {
|
||||
|
||||
var publicSettingKeys = map[string]bool{
|
||||
"banner": true,
|
||||
"branding": true,
|
||||
"user_providers_enabled": true,
|
||||
"registration_enabled": true,
|
||||
"registration_default_state": true,
|
||||
|
||||
@@ -435,6 +435,7 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
|
||||
IsPreset bool `json:"is_preset,omitempty"`
|
||||
PresetID string `json:"preset_id,omitempty"`
|
||||
PresetScope string `json:"preset_scope,omitempty"`
|
||||
PresetAvatar string `json:"preset_avatar,omitempty"`
|
||||
}
|
||||
|
||||
models := make([]enabledModel, 0)
|
||||
@@ -538,7 +539,7 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
|
||||
// ── 3. Active presets (global + user's personal + shared) ──
|
||||
presetRows, err := database.DB.Query(`
|
||||
SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id,
|
||||
mp.icon, mp.scope, mp.temperature, mp.max_tokens,
|
||||
mp.icon, mp.avatar, mp.scope, mp.temperature, mp.max_tokens,
|
||||
COALESCE(ac.provider, '') as provider, COALESCE(ac.name, '') as provider_name
|
||||
FROM model_presets mp
|
||||
LEFT JOIN api_configs ac ON mp.api_config_id = ac.id
|
||||
@@ -553,12 +554,12 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
|
||||
if err == nil {
|
||||
defer presetRows.Close()
|
||||
for presetRows.Next() {
|
||||
var presetID, name, description, baseModelID, icon, scope, provID, provName string
|
||||
var presetID, name, description, baseModelID, icon, avatar, scope, provID, provName string
|
||||
var apiConfigID *string
|
||||
var temp *float64
|
||||
var maxTok *int
|
||||
if err := presetRows.Scan(&presetID, &name, &description, &baseModelID, &apiConfigID,
|
||||
&icon, &scope, &temp, &maxTok, &provID, &provName); err != nil {
|
||||
&icon, &avatar, &scope, &temp, &maxTok, &provID, &provName); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -615,6 +616,7 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
|
||||
IsPreset: true,
|
||||
PresetID: presetID,
|
||||
PresetScope: scope,
|
||||
PresetAvatar: avatar,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ type userResponse struct {
|
||||
Email string `json:"email"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
Avatar *string `json:"avatar,omitempty"`
|
||||
}
|
||||
|
||||
// AuthHandler holds dependencies for auth endpoints.
|
||||
@@ -129,9 +130,9 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||
err = database.DB.QueryRow(`
|
||||
INSERT INTO users (username, email, password_hash, role, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, username, email, display_name, role
|
||||
RETURNING id, username, email, display_name, role, avatar_url
|
||||
`, req.Username, req.Email, string(hash), role, isActive).Scan(
|
||||
&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role,
|
||||
&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role, &user.Avatar,
|
||||
)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate key") {
|
||||
@@ -268,12 +269,12 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var isActive bool
|
||||
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT id, username, email, display_name, role, password_hash, is_active
|
||||
SELECT id, username, email, display_name, role, avatar_url, password_hash, is_active
|
||||
FROM users
|
||||
WHERE email = $1 OR username = $1
|
||||
`, req.Login).Scan(
|
||||
&user.ID, &user.Username, &user.Email, &user.DisplayName,
|
||||
&user.Role, &passwordHash, &isActive,
|
||||
&user.Role, &user.Avatar, &passwordHash, &isActive,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
@@ -352,10 +353,10 @@ func (h *AuthHandler) Refresh(c *gin.Context) {
|
||||
var user userResponse
|
||||
var isActive bool
|
||||
err = database.DB.QueryRow(`
|
||||
SELECT id, username, email, display_name, role, is_active
|
||||
SELECT id, username, email, display_name, role, avatar_url, is_active
|
||||
FROM users WHERE id = $1
|
||||
`, userID).Scan(
|
||||
&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role, &isActive,
|
||||
&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role, &user.Avatar, &isActive,
|
||||
)
|
||||
if err != nil || !isActive {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "account unavailable"})
|
||||
|
||||
245
server/handlers/avatar.go
Normal file
245
server/handlers/avatar.go
Normal file
@@ -0,0 +1,245 @@
|
||||
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 model_presets 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 model_presets 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"})
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func (h *PresetHandler) ListUserPresets(c *gin.Context) {
|
||||
SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id,
|
||||
mp.system_prompt, mp.temperature, mp.max_tokens, mp.tools_enabled,
|
||||
mp.scope, mp.team_id, mp.created_by, mp.is_shared, mp.is_active,
|
||||
mp.icon, mp.created_at, mp.updated_at,
|
||||
mp.icon, mp.avatar, mp.created_at, mp.updated_at,
|
||||
COALESCE(ac.name, '') as provider_name
|
||||
FROM model_presets mp
|
||||
LEFT JOIN api_configs ac ON mp.api_config_id = ac.id
|
||||
@@ -92,7 +92,7 @@ func (h *PresetHandler) ListUserPresets(c *gin.Context) {
|
||||
&p.ID, &p.Name, &p.Description, &p.BaseModelID, &p.APIConfigID,
|
||||
&p.SystemPrompt, &p.Temperature, &p.MaxTokens, &p.ToolsEnabled,
|
||||
&p.Scope, &p.TeamID, &p.CreatedBy, &p.IsShared, &p.IsActive,
|
||||
&p.Icon, &p.CreatedAt, &p.UpdatedAt, &p.ProviderName,
|
||||
&p.Icon, &p.Avatar, &p.CreatedAt, &p.UpdatedAt, &p.ProviderName,
|
||||
); err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -281,7 +281,7 @@ func (h *PresetHandler) ListAdminPresets(c *gin.Context) {
|
||||
SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id,
|
||||
mp.system_prompt, mp.temperature, mp.max_tokens, mp.tools_enabled,
|
||||
mp.scope, mp.team_id, mp.created_by, mp.is_shared, mp.is_active,
|
||||
mp.icon, mp.created_at, mp.updated_at,
|
||||
mp.icon, mp.avatar, mp.created_at, mp.updated_at,
|
||||
COALESCE(ac.name, '') as provider_name,
|
||||
COALESCE(u.username, '') as creator_name
|
||||
FROM model_presets mp
|
||||
@@ -307,7 +307,7 @@ func (h *PresetHandler) ListAdminPresets(c *gin.Context) {
|
||||
&p.ID, &p.Name, &p.Description, &p.BaseModelID, &p.APIConfigID,
|
||||
&p.SystemPrompt, &p.Temperature, &p.MaxTokens, &p.ToolsEnabled,
|
||||
&p.Scope, &p.TeamID, &p.CreatedBy, &p.IsShared, &p.IsActive,
|
||||
&p.Icon, &p.CreatedAt, &p.UpdatedAt, &p.ProviderName, &p.CreatorName,
|
||||
&p.Icon, &p.Avatar, &p.CreatedAt, &p.UpdatedAt, &p.ProviderName, &p.CreatorName,
|
||||
); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ type profileResponse struct {
|
||||
Email string `json:"email"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
Avatar *string `json:"avatar,omitempty"`
|
||||
Settings map[string]interface{} `json:"settings"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
@@ -50,11 +51,11 @@ func (h *SettingsHandler) GetProfile(c *gin.Context) {
|
||||
var p profileResponse
|
||||
var settingsRaw string
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT id, username, email, display_name, role, settings::text, created_at
|
||||
SELECT id, username, email, display_name, role, avatar_url, settings::text, created_at
|
||||
FROM users WHERE id = $1
|
||||
`, userID).Scan(
|
||||
&p.ID, &p.Username, &p.Email, &p.DisplayName, &p.Role,
|
||||
&settingsRaw, &p.CreatedAt,
|
||||
&p.Avatar, &settingsRaw, &p.CreatedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
|
||||
@@ -134,6 +134,8 @@ func main() {
|
||||
protected.GET("/profile", settings.GetProfile)
|
||||
protected.PUT("/profile", settings.UpdateProfile)
|
||||
protected.POST("/profile/password", settings.ChangePassword)
|
||||
protected.POST("/profile/avatar", settings.UploadAvatar)
|
||||
protected.DELETE("/profile/avatar", settings.DeleteAvatar)
|
||||
protected.GET("/settings", settings.GetSettings)
|
||||
protected.PUT("/settings", settings.UpdateSettings)
|
||||
|
||||
@@ -143,6 +145,8 @@ func main() {
|
||||
protected.POST("/presets", presets.CreateUserPreset)
|
||||
protected.PUT("/presets/:id", presets.UpdateUserPreset)
|
||||
protected.DELETE("/presets/:id", presets.DeleteUserPreset)
|
||||
protected.POST("/presets/:id/avatar", handlers.UploadPresetAvatar)
|
||||
protected.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar)
|
||||
|
||||
// Notes
|
||||
notes := handlers.NewNoteHandler()
|
||||
@@ -202,6 +206,8 @@ func main() {
|
||||
admin.POST("/presets", presetAdm.CreateAdminPreset)
|
||||
admin.PUT("/presets/:id", presetAdm.UpdateAdminPreset)
|
||||
admin.DELETE("/presets/:id", presetAdm.DeleteAdminPreset)
|
||||
admin.POST("/presets/:id/avatar", handlers.UploadPresetAvatar)
|
||||
admin.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -174,6 +174,7 @@ type ModelPreset struct {
|
||||
IsShared bool `json:"is_shared" db:"is_shared"`
|
||||
IsActive bool `json:"is_active" db:"is_active"`
|
||||
Icon string `json:"icon,omitempty" db:"icon"`
|
||||
Avatar string `json:"avatar,omitempty" db:"avatar"`
|
||||
}
|
||||
|
||||
// ── Settings ────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user