560 lines
17 KiB
Go
560 lines
17 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
|
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
|
)
|
|
|
|
type AdminHandler struct {
|
|
stores store.Stores
|
|
}
|
|
|
|
func NewAdminHandler(s store.Stores) *AdminHandler {
|
|
return &AdminHandler{stores: s}
|
|
}
|
|
|
|
// ── User Management ─────────────────────────
|
|
|
|
func (h *AdminHandler) ListUsers(c *gin.Context) {
|
|
opts := store.DefaultListOptions()
|
|
|
|
// Accept both limit/offset and page/per_page conventions
|
|
if limit, _ := strconv.Atoi(c.Query("limit")); limit > 0 {
|
|
opts.Limit = limit
|
|
}
|
|
if offset, _ := strconv.Atoi(c.Query("offset")); offset > 0 {
|
|
opts.Offset = offset
|
|
}
|
|
if perPage, _ := strconv.Atoi(c.Query("per_page")); perPage > 0 {
|
|
opts.Limit = perPage
|
|
}
|
|
if page, _ := strconv.Atoi(c.Query("page")); page > 1 {
|
|
opts.Offset = (page - 1) * opts.Limit
|
|
}
|
|
|
|
users, total, err := h.stores.Users.List(c.Request.Context(), opts)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list users"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"users": users, "total": total})
|
|
}
|
|
|
|
func (h *AdminHandler) CreateUser(c *gin.Context) {
|
|
var req struct {
|
|
Username string `json:"username" binding:"required"`
|
|
Email string `json:"email" binding:"required"`
|
|
Password string `json:"password" binding:"required,min=8"`
|
|
Role string `json:"role"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
|
|
role := models.UserRoleUser
|
|
if req.Role == models.UserRoleAdmin {
|
|
role = models.UserRoleAdmin
|
|
}
|
|
|
|
user := &models.User{
|
|
Username: strings.ToLower(req.Username),
|
|
Email: strings.ToLower(req.Email),
|
|
PasswordHash: string(hash),
|
|
Role: role,
|
|
IsActive: true,
|
|
}
|
|
|
|
if err := h.stores.Users.Create(c.Request.Context(), user); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"})
|
|
return
|
|
}
|
|
|
|
h.auditLog(c, "user.create", "user", user.ID, nil)
|
|
c.JSON(http.StatusCreated, user)
|
|
}
|
|
|
|
func (h *AdminHandler) UpdateUserRole(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var req struct {
|
|
Role string `json:"role" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if err := h.stores.Users.Update(c.Request.Context(), id, map[string]interface{}{"role": req.Role}); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update role"})
|
|
return
|
|
}
|
|
|
|
h.auditLog(c, "user.role_change", "user", id, gin.H{"role": req.Role})
|
|
c.JSON(http.StatusOK, gin.H{"message": "role updated"})
|
|
}
|
|
|
|
func (h *AdminHandler) ToggleUserActive(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var req struct {
|
|
IsActive bool `json:"is_active"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if err := h.stores.Users.SetActive(c.Request.Context(), id, req.IsActive); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update status"})
|
|
return
|
|
}
|
|
|
|
h.auditLog(c, "user.active_change", "user", id, gin.H{"is_active": req.IsActive})
|
|
c.JSON(http.StatusOK, gin.H{"message": "user status updated"})
|
|
}
|
|
|
|
func (h *AdminHandler) ResetPassword(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var req struct {
|
|
Password string `json:"password" binding:"required,min=8"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
|
|
if err := h.stores.Users.Update(c.Request.Context(), id, map[string]interface{}{"password_hash": string(hash)}); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reset password"})
|
|
return
|
|
}
|
|
|
|
h.auditLog(c, "user.password_reset", "user", id, nil)
|
|
c.JSON(http.StatusOK, gin.H{"message": "password reset"})
|
|
}
|
|
|
|
func (h *AdminHandler) DeleteUser(c *gin.Context) {
|
|
id := c.Param("id")
|
|
if err := h.stores.Users.Delete(c.Request.Context(), id); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"})
|
|
return
|
|
}
|
|
h.auditLog(c, "user.delete", "user", id, nil)
|
|
c.JSON(http.StatusOK, gin.H{"message": "user deleted"})
|
|
}
|
|
|
|
// ── Global Settings ─────────────────────────
|
|
|
|
func (h *AdminHandler) ListGlobalSettings(c *gin.Context) {
|
|
settings, err := h.stores.GlobalConfig.GetAll(c.Request.Context())
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load settings"})
|
|
return
|
|
}
|
|
// Also include policies
|
|
policies, _ := h.stores.Policies.GetAll(c.Request.Context())
|
|
c.JSON(http.StatusOK, gin.H{"settings": settings, "policies": policies})
|
|
}
|
|
|
|
func (h *AdminHandler) GetGlobalSetting(c *gin.Context) {
|
|
key := c.Param("key")
|
|
val, err := h.stores.GlobalConfig.Get(c.Request.Context(), key)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "setting not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"key": key, "value": val})
|
|
}
|
|
|
|
func (h *AdminHandler) UpdateGlobalSetting(c *gin.Context) {
|
|
key := c.Param("key")
|
|
userID, _ := c.Get("user_id")
|
|
uid := userID.(string)
|
|
|
|
var req struct {
|
|
Value json.RawMessage `json:"value"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Determine if this is a policy (string value) or a global config (JSON value)
|
|
var strVal string
|
|
if err := json.Unmarshal(req.Value, &strVal); err == nil {
|
|
// String value → try as policy first
|
|
if _, ok := models.PolicyDefaults[key]; ok {
|
|
if err := h.stores.Policies.Set(c.Request.Context(), key, strVal, uid); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update policy"})
|
|
return
|
|
}
|
|
h.auditLog(c, "policy.update", "policy", key, gin.H{"value": strVal})
|
|
c.JSON(http.StatusOK, gin.H{"message": "policy updated"})
|
|
return
|
|
}
|
|
}
|
|
|
|
// JSON value → global config
|
|
var jsonVal models.JSONMap
|
|
json.Unmarshal(req.Value, &jsonVal)
|
|
if err := h.stores.GlobalConfig.Set(c.Request.Context(), key, jsonVal, uid); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update setting"})
|
|
return
|
|
}
|
|
|
|
h.auditLog(c, "settings.update", "global_settings", key, nil)
|
|
c.JSON(http.StatusOK, gin.H{"message": "setting updated"})
|
|
}
|
|
|
|
func (h *AdminHandler) PublicSettings(c *gin.Context) {
|
|
// Banner config, branding, etc. — safe subset for non-admin users
|
|
banner, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "banner")
|
|
branding, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "branding")
|
|
policies, _ := h.stores.Policies.GetAll(c.Request.Context())
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"banner": banner,
|
|
"branding": branding,
|
|
"policies": gin.H{
|
|
"allow_registration": policies["allow_registration"],
|
|
"allow_user_byok": policies["allow_user_byok"],
|
|
"allow_user_personas": policies["allow_user_personas"],
|
|
},
|
|
})
|
|
}
|
|
|
|
// ── Provider Configs (Global) ───────────────
|
|
|
|
func (h *AdminHandler) ListGlobalConfigs(c *gin.Context) {
|
|
cfgs, err := h.stores.Providers.ListGlobal(c.Request.Context())
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"})
|
|
return
|
|
}
|
|
|
|
// Redact API keys but expose has_key flag for UI
|
|
type configWithKey struct {
|
|
models.ProviderConfig
|
|
HasKey bool `json:"has_key"`
|
|
}
|
|
out := make([]configWithKey, len(cfgs))
|
|
for i, cfg := range cfgs {
|
|
out[i] = configWithKey{
|
|
ProviderConfig: cfg,
|
|
HasKey: cfg.APIKeyEnc != "",
|
|
}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"configs": out})
|
|
}
|
|
|
|
func (h *AdminHandler) CreateGlobalConfig(c *gin.Context) {
|
|
// Wrapper struct: models.ProviderConfig has APIKeyEnc tagged json:"-"
|
|
// so ShouldBindJSON would silently drop the api_key field.
|
|
var req struct {
|
|
Name string `json:"name" binding:"required"`
|
|
Provider string `json:"provider" binding:"required"`
|
|
Endpoint string `json:"endpoint" binding:"required"`
|
|
APIKey string `json:"api_key"`
|
|
ModelDefault string `json:"model_default,omitempty"`
|
|
Config map[string]interface{} `json:"config,omitempty"`
|
|
Headers map[string]interface{} `json:"headers,omitempty"`
|
|
Settings map[string]interface{} `json:"settings,omitempty"`
|
|
IsPrivate bool `json:"is_private,omitempty"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if _, err := providers.Get(req.Provider); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "unsupported provider: " + req.Provider,
|
|
"supported_providers": providers.List(),
|
|
})
|
|
return
|
|
}
|
|
|
|
cfg := &models.ProviderConfig{
|
|
Name: req.Name,
|
|
Provider: req.Provider,
|
|
Endpoint: req.Endpoint,
|
|
APIKeyEnc: req.APIKey, // TODO: encrypt
|
|
ModelDefault: req.ModelDefault,
|
|
Config: models.JSONMap(req.Config),
|
|
Headers: models.JSONMap(req.Headers),
|
|
Settings: models.JSONMap(req.Settings),
|
|
Scope: models.ScopeGlobal,
|
|
IsActive: true,
|
|
IsPrivate: req.IsPrivate,
|
|
}
|
|
|
|
if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"})
|
|
return
|
|
}
|
|
|
|
h.auditLog(c, "provider.create", "provider_config", cfg.ID, gin.H{"name": cfg.Name, "provider": cfg.Provider})
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"id": cfg.ID,
|
|
"scope": cfg.Scope,
|
|
"name": cfg.Name,
|
|
"provider": cfg.Provider,
|
|
"endpoint": cfg.Endpoint,
|
|
"model_default": cfg.ModelDefault,
|
|
"config": cfg.Config,
|
|
"headers": cfg.Headers,
|
|
"settings": cfg.Settings,
|
|
"is_active": cfg.IsActive,
|
|
"is_private": cfg.IsPrivate,
|
|
"has_key": cfg.APIKeyEnc != "",
|
|
"created_at": cfg.CreatedAt,
|
|
"updated_at": cfg.UpdatedAt,
|
|
})
|
|
}
|
|
|
|
func (h *AdminHandler) UpdateGlobalConfig(c *gin.Context) {
|
|
id := c.Param("id")
|
|
|
|
// Wrapper struct: ProviderConfigPatch has APIKeyEnc tagged json:"-"
|
|
var req struct {
|
|
models.ProviderConfigPatch
|
|
APIKey *string `json:"api_key,omitempty"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
patch := req.ProviderConfigPatch
|
|
// Transfer api_key → APIKeyEnc (json:"-" prevents auto-binding)
|
|
if req.APIKey != nil && *req.APIKey != "" {
|
|
patch.APIKeyEnc = req.APIKey // TODO: encrypt
|
|
}
|
|
|
|
if err := h.stores.Providers.Update(c.Request.Context(), id, patch); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update config"})
|
|
return
|
|
}
|
|
|
|
h.auditLog(c, "provider.update", "provider_config", id, nil)
|
|
c.JSON(http.StatusOK, gin.H{"message": "config updated"})
|
|
}
|
|
|
|
func (h *AdminHandler) DeleteGlobalConfig(c *gin.Context) {
|
|
id := c.Param("id")
|
|
// Delete associated catalog entries first
|
|
h.stores.Catalog.DeleteForProvider(c.Request.Context(), id)
|
|
if err := h.stores.Providers.Delete(c.Request.Context(), id); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete config"})
|
|
return
|
|
}
|
|
h.auditLog(c, "provider.delete", "provider_config", id, nil)
|
|
c.JSON(http.StatusOK, gin.H{"message": "config deleted"})
|
|
}
|
|
|
|
// ── Model Catalog ───────────────────────────
|
|
|
|
func (h *AdminHandler) ListModelConfigs(c *gin.Context) {
|
|
entries, err := h.stores.Catalog.ListAllGlobal(c.Request.Context())
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"models": entries})
|
|
}
|
|
|
|
func (h *AdminHandler) FetchModels(c *gin.Context) {
|
|
var req struct {
|
|
ProviderConfigID string `json:"provider_config_id"`
|
|
}
|
|
c.ShouldBindJSON(&req)
|
|
|
|
// If no specific provider, fetch from ALL global providers
|
|
if req.ProviderConfigID == "" {
|
|
configs, err := h.stores.Providers.ListGlobal(c.Request.Context())
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list providers"})
|
|
return
|
|
}
|
|
if len(configs) == 0 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no providers configured — add one first"})
|
|
return
|
|
}
|
|
|
|
totalAdded, totalUpdated, totalFetched := 0, 0, 0
|
|
var errs []string
|
|
for _, cfg := range configs {
|
|
if !cfg.IsActive {
|
|
continue
|
|
}
|
|
added, updated, fetched, err := h.fetchModelsForProvider(c, &cfg)
|
|
if err != nil {
|
|
errs = append(errs, cfg.Provider+": "+err.Error())
|
|
continue
|
|
}
|
|
totalAdded += added
|
|
totalUpdated += updated
|
|
totalFetched += fetched
|
|
}
|
|
|
|
result := gin.H{
|
|
"message": "models synced",
|
|
"added": totalAdded,
|
|
"updated": totalUpdated,
|
|
"total": totalFetched,
|
|
}
|
|
if len(errs) > 0 {
|
|
result["errors"] = errs
|
|
}
|
|
c.JSON(http.StatusOK, result)
|
|
return
|
|
}
|
|
|
|
// Single provider fetch
|
|
cfg, err := h.stores.Providers.GetByID(c.Request.Context(), req.ProviderConfigID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "provider config not found"})
|
|
return
|
|
}
|
|
|
|
added, updated, fetched, err := h.fetchModelsForProvider(c, cfg)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "models synced",
|
|
"added": added,
|
|
"updated": updated,
|
|
"total": fetched,
|
|
})
|
|
}
|
|
|
|
// fetchModelsForProvider fetches and syncs models for a single provider config.
|
|
func (h *AdminHandler) fetchModelsForProvider(c *gin.Context, cfg *models.ProviderConfig) (added, updated, total int, err error) {
|
|
result, err := syncProviderModels(c.Request.Context(), h.stores, cfg)
|
|
if err != nil {
|
|
return 0, 0, 0, err
|
|
}
|
|
|
|
h.auditLog(c, "models.fetch", "provider_config", cfg.ID, gin.H{
|
|
"added": result.Added, "updated": result.Updated, "total": result.Total,
|
|
})
|
|
|
|
return result.Added, result.Updated, result.Total, nil
|
|
}
|
|
|
|
func (h *AdminHandler) UpdateModelConfig(c *gin.Context) {
|
|
id := c.Param("id")
|
|
var req struct {
|
|
Visibility *string `json:"visibility"`
|
|
DisplayName *string `json:"display_name"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if req.Visibility != nil {
|
|
if err := h.stores.Catalog.SetVisibility(c.Request.Context(), id, *req.Visibility); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update visibility"})
|
|
return
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "model updated"})
|
|
}
|
|
|
|
func (h *AdminHandler) BulkUpdateModels(c *gin.Context) {
|
|
var req struct {
|
|
ProviderConfigID string `json:"provider_config_id"`
|
|
Visibility string `json:"visibility" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
var err error
|
|
if req.ProviderConfigID != "" {
|
|
err = h.stores.Catalog.BulkSetVisibility(c.Request.Context(), req.ProviderConfigID, req.Visibility)
|
|
} else {
|
|
err = h.stores.Catalog.BulkSetVisibilityAll(c.Request.Context(), req.Visibility)
|
|
}
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "bulk update complete"})
|
|
}
|
|
|
|
func (h *AdminHandler) DeleteModelConfig(c *gin.Context) {
|
|
id := c.Param("id")
|
|
if err := h.stores.Catalog.Delete(c.Request.Context(), id); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete model"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"message": "model deleted"})
|
|
}
|
|
|
|
// ── Stats ───────────────────────────────────
|
|
|
|
func (h *AdminHandler) GetStats(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
stats := gin.H{}
|
|
|
|
var userCount, channelCount, messageCount int
|
|
database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&userCount)
|
|
database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM channels").Scan(&channelCount)
|
|
database.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM messages").Scan(&messageCount)
|
|
|
|
stats["users"] = userCount
|
|
stats["channels"] = channelCount
|
|
stats["messages"] = messageCount
|
|
|
|
c.JSON(http.StatusOK, stats)
|
|
}
|
|
|
|
// ── Helpers ─────────────────────────────────
|
|
// NOTE: ListAuditLog and ListAuditActions are in audit.go
|
|
|
|
func (h *AdminHandler) auditLog(c *gin.Context, action, resourceType, resourceID string, metadata interface{}) {
|
|
userID, _ := c.Get("user_id")
|
|
uid, _ := userID.(string)
|
|
|
|
var meta models.JSONMap
|
|
if metadata != nil {
|
|
b, _ := json.Marshal(metadata)
|
|
json.Unmarshal(b, &meta)
|
|
}
|
|
|
|
entry := &models.AuditEntry{
|
|
ActorID: &uid,
|
|
Action: action,
|
|
ResourceType: resourceType,
|
|
ResourceID: resourceID,
|
|
Metadata: meta,
|
|
IPAddress: c.ClientIP(),
|
|
UserAgent: c.GetHeader("User-Agent"),
|
|
}
|
|
|
|
if err := h.stores.Audit.Log(context.Background(), entry); err != nil {
|
|
log.Printf("audit log error: %v", err)
|
|
}
|
|
}
|