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/admin.go
2026-03-12 12:12:17 +00:00

837 lines
26 KiB
Go

package handlers
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"git.gobha.me/xcaliber/chat-switchboard/auth"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"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/storage"
"git.gobha.me/xcaliber/chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/tools/search"
)
type AdminHandler struct {
stores store.Stores
vault *crypto.KeyResolver
uekCache *crypto.UEKCache
objStore storage.ObjectStore
}
func NewAdminHandler(s store.Stores, vault *crypto.KeyResolver, uekCache *crypto.UEKCache, objStore storage.ObjectStore) *AdminHandler {
return &AdminHandler{stores: s, vault: vault, uekCache: uekCache, objStore: objStore}
}
// ── 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,
Handle: auth.UniqueHandle(c.Request.Context(), h.stores.Users, models.HandleFromName(req.Username)),
}
if err := h.stores.Users.Create(c.Request.Context(), user); err != nil {
if database.IsUniqueViolation(err) {
c.JSON(http.StatusConflict, gin.H{"error": "username or email already exists"})
return
}
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
}
// Guard: prevent demoting the last admin
if req.Role != models.UserRoleAdmin {
user, err := h.stores.Users.GetByID(c.Request.Context(), id)
if err != nil || user == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
if user.Role == models.UserRoleAdmin {
var adminCount int
database.DB.QueryRowContext(c.Request.Context(),
database.Q(`SELECT COUNT(*) FROM users WHERE role = $1`),
models.UserRoleAdmin).Scan(&adminCount)
if adminCount <= 1 {
c.JSON(http.StatusConflict, gin.H{"error": "cannot demote the last admin"})
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
}
// Destroy vault — old UEK is unrecoverable with new password.
// A fresh vault will be initialized on the user's next login.
h.destroyVault(c, id)
h.auditLog(c, "user.password_reset", "user", id, nil)
c.JSON(http.StatusOK, gin.H{"message": "password reset"})
}
// destroyVault nullifies a user's encrypted UEK and deletes their personal
// provider keys. Called when an admin resets a user's password — the old
// UEK cannot be recovered without the old password.
func (h *AdminHandler) destroyVault(c *gin.Context, userID string) {
if h.uekCache == nil {
return
}
deleted := DestroyVaultDB(c.Request.Context(), userID)
// Evict from session cache (if user is currently logged in)
h.uekCache.Evict(userID)
if deleted > 0 {
log.Printf(" 🔐 Destroyed %d personal provider(s) for user %s", deleted, userID)
}
h.auditLog(c, "user.vault_destroyed", "user", userID, models.JSONMap{
"reason": "admin_password_reset",
})
}
// ResetVault explicitly resets a user's vault without changing their password.
// Clears vault columns, deletes personal provider configs, evicts UEK cache.
// The user gets a fresh vault on their next login.
// POST /api/v1/admin/users/:id/vault/reset
func (h *AdminHandler) ResetVault(c *gin.Context) {
userID := c.Param("id")
// Verify user exists
user, err := h.stores.Users.GetByID(c.Request.Context(), userID)
if err != nil || user == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
if h.uekCache == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "vault not configured"})
return
}
deleted := DestroyVaultDB(c.Request.Context(), userID)
h.uekCache.Evict(userID)
h.auditLog(c, "user.vault_reset", "user", userID, models.JSONMap{
"reason": "admin_manual_reset",
"providers_deleted": deleted,
})
log.Printf(" 🔐 Vault reset for user %s (%d personal provider(s) cleared)", userID, deleted)
c.JSON(http.StatusOK, gin.H{
"message": "vault reset — user will get a fresh vault on next login",
"providers_deleted": deleted,
})
}
func (h *AdminHandler) DeleteUser(c *gin.Context) {
id := c.Param("id")
// Guard: prevent deleting the last admin
user, err := h.stores.Users.GetByID(c.Request.Context(), id)
if err != nil || user == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
if user.Role == models.UserRoleAdmin {
var adminCount int
database.DB.QueryRowContext(c.Request.Context(),
database.Q(`SELECT COUNT(*) FROM users WHERE role = $1`),
models.UserRoleAdmin).Scan(&adminCount)
if adminCount <= 1 {
c.JSON(http.StatusConflict, gin.H{"error": "cannot delete the last admin"})
return
}
}
// Destroy vault and personal BYOK keys before deleting the user row
h.destroyVault(c, 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")
// If the key is a known policy, read from the policies table first
if _, ok := models.PolicyDefaults[key]; ok {
val, err := h.stores.Policies.Get(c.Request.Context(), key)
if err == nil {
c.JSON(http.StatusOK, gin.H{"key": key, "value": val})
return
}
// Fall through to global_config if not found in policies
}
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)
// Live-apply hooks for settings that have runtime effects
if key == "search_config" {
applySearchConfig(jsonVal)
}
c.JSON(http.StatusOK, gin.H{"message": "setting updated"})
}
// applySearchConfig updates the active search provider at runtime.
func applySearchConfig(raw models.JSONMap) {
b, err := json.Marshal(raw)
if err != nil {
log.Printf("⚠️ Failed to marshal search config: %v", err)
return
}
var cfg search.Config
if err := json.Unmarshal(b, &cfg); err != nil {
log.Printf("⚠️ Failed to parse search config: %v", err)
return
}
if err := search.ApplyConfig(cfg); err != nil {
log.Printf("⚠️ Failed to apply search config: %v", err)
}
}
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")
systemPrompt, _ := h.stores.GlobalConfig.Get(c.Request.Context(), "system_prompt")
policies, _ := h.stores.Policies.GetAll(c.Request.Context())
// Paste-to-file threshold (admin-configurable via storage.paste_to_file_chars, default 2000)
pasteChars := 2000
if storageSettings, err := h.stores.GlobalConfig.Get(c.Request.Context(), "storage"); err == nil {
if v, ok := storageSettings["paste_to_file_chars"]; ok {
switch n := v.(type) {
case float64:
pasteChars = int(n)
case int:
pasteChars = n
}
}
}
// Only tell the user whether admin prompt exists — don't expose content
hasAdminPrompt := false
if content, ok := systemPrompt["content"].(string); ok && content != "" {
hasAdminPrompt = true
}
// Channel retention mode (v0.23.2 — flexible or retain)
retentionMode := "flexible"
if retCfg, err := h.stores.GlobalConfig.Get(c.Request.Context(), "channel_retention"); err == nil {
if mode, ok := retCfg["mode"].(string); ok && mode != "" {
retentionMode = mode
}
}
c.JSON(http.StatusOK, gin.H{
"banner": banner,
"branding": branding,
"has_admin_prompt": hasAdminPrompt,
"storage_configured": storageConfigured,
"paste_to_file_chars": pasteChars,
"policies": gin.H{
"allow_registration": policies["allow_registration"],
"allow_user_byok": policies["allow_user_byok"],
"allow_user_personas": policies["allow_user_personas"],
"channel_retention_mode": retentionMode,
},
})
}
// ── Vault Status ────────────────────────────
func (h *AdminHandler) VaultStatus(c *gin.Context) {
hasKey := h.vault != nil && h.vault.HasEnvKey()
keyStr := ""
if hasKey {
keyStr = "set" // non-empty signals to VaultStatus that key is configured
}
status, err := crypto.VaultStatus(database.DB, keyStr)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get vault status"})
return
}
c.JSON(http.StatusOK, status)
}
// ── 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.HasKey(),
}
}
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,
ModelDefault: req.ModelDefault,
Config: models.JSONMap(req.Config),
Headers: models.JSONMap(req.Headers),
Settings: models.JSONMap(req.Settings),
Scope: models.ScopeGlobal,
KeyScope: models.ScopeGlobal,
IsActive: true,
IsPrivate: req.IsPrivate,
}
// Encrypt the API key
if req.APIKey != "" {
if h.vault != nil {
enc, nonce, err := h.vault.EncryptForScope(req.APIKey, "global", "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
return
}
cfg.APIKeyEnc = enc
cfg.KeyNonce = nonce
} else {
// No vault configured — store raw bytes (unencrypted fallback)
cfg.APIKeyEnc = []byte(req.APIKey)
}
}
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.HasKey(),
"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 → encrypted fields
if req.APIKey != nil && *req.APIKey != "" {
if h.vault != nil {
enc, nonce, err := h.vault.EncryptForScope(*req.APIKey, "global", "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
return
}
patch.APIKeyEnc = enc
patch.KeyNonce = nonce
} else {
patch.APIKeyEnc = []byte(*req.APIKey)
}
}
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) {
// Decrypt the API key for the provider API call
apiKey := ""
if len(cfg.APIKeyEnc) > 0 {
if h.vault != nil {
apiKey, err = h.vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, "")
if err != nil {
return 0, 0, 0, fmt.Errorf("failed to decrypt API key: %w", err)
}
} else {
// No vault — key stored as raw bytes (unencrypted fallback)
apiKey = string(cfg.APIKeyEnc)
}
}
result, err := syncProviderModels(c.Request.Context(), h.stores, cfg, apiKey)
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"`
}
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{}) {
// Convert interface{} metadata to map[string]interface{} for the shared helper
var meta map[string]interface{}
if metadata != nil {
b, _ := json.Marshal(metadata)
json.Unmarshal(b, &meta)
}
AuditLog(h.stores.Audit, c, action, resourceType, resourceID, meta)
}
// ── Archived Channels (v0.23.2) ──────────────
func (h *AdminHandler) ListArchivedChannels(c *gin.Context) {
page, perPage, offset := parsePagination(c)
channels, total, err := h.stores.Channels.ListArchived(c.Request.Context(), store.ListOptions{
Limit: perPage,
Offset: offset,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
return
}
// Retention config
retentionMode := "flexible"
if retCfg, err := h.stores.GlobalConfig.Get(c.Request.Context(), "channel_retention"); err == nil {
if mode, ok := retCfg["mode"].(string); ok {
retentionMode = mode
}
}
c.JSON(http.StatusOK, gin.H{
"channels": channels,
"total": total,
"page": page,
"per_page": perPage,
"retention_mode": retentionMode,
})
}
func (h *AdminHandler) PurgeChannel(c *gin.Context) {
channelID := c.Param("id")
// Clean up file storage blobs before CASCADE deletes the DB references
if h.objStore != nil {
prefix := fmt.Sprintf("files/%s", channelID)
if err := h.objStore.DeletePrefix(c.Request.Context(), prefix); err != nil {
log.Printf("⚠️ storage cleanup for purged channel %s failed: %v", channelID, err)
}
}
if err := h.stores.Channels.Purge(c.Request.Context(), channelID); err != nil {
msg := err.Error()
switch {
case msg == "channel not found":
c.JSON(http.StatusNotFound, gin.H{"error": msg})
case msg == "channel must be archived before purging":
c.JSON(http.StatusConflict, gin.H{"error": msg})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "purge failed"})
}
return
}
h.auditLog(c, "channel.purged", "channel", channelID, nil)
c.JSON(http.StatusOK, gin.H{"ok": true})
}