Changeset 0.28.0.4 (#176)
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -17,6 +16,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
@@ -25,10 +25,11 @@ 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) *AdminHandler {
|
||||
return &AdminHandler{stores: s, vault: vault, uekCache: uekCache}
|
||||
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 ─────────────────────────
|
||||
@@ -87,6 +88,10 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -105,6 +110,25 @@ func (h *AdminHandler) UpdateUserRole(c *gin.Context) {
|
||||
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
|
||||
@@ -215,6 +239,27 @@ func (h *AdminHandler) ResetVault(c *gin.Context) {
|
||||
|
||||
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
|
||||
@@ -238,6 +283,17 @@ func (h *AdminHandler) ListGlobalSettings(c *gin.Context) {
|
||||
|
||||
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"})
|
||||
@@ -639,8 +695,7 @@ func (h *AdminHandler) fetchModelsForProvider(c *gin.Context, cfg *models.Provid
|
||||
func (h *AdminHandler) UpdateModelConfig(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req struct {
|
||||
Visibility *string `json:"visibility"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
Visibility *string `json:"visibility"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -712,67 +767,28 @@ func (h *AdminHandler) GetStats(c *gin.Context) {
|
||||
// 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
|
||||
// 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
AuditLog(h.stores.Audit, c, action, resourceType, resourceID, meta)
|
||||
}
|
||||
|
||||
// ── Archived Channels (v0.23.2) ──────────────
|
||||
|
||||
func (h *AdminHandler) ListArchivedChannels(c *gin.Context) {
|
||||
rows, err := database.DB.QueryContext(c.Request.Context(), database.Q(`
|
||||
SELECT c.id, c.title, c.type, c.user_id,
|
||||
COALESCE(u.username, '') AS owner_name,
|
||||
c.updated_at,
|
||||
(SELECT COUNT(*) FROM messages WHERE channel_id = c.id) AS message_count
|
||||
FROM channels c
|
||||
LEFT JOIN users u ON u.id = c.user_id
|
||||
WHERE c.is_archived = true
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT 100
|
||||
`))
|
||||
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
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type archivedChannel struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
OwnerName string `json:"owner_name"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
MessageCount int `json:"message_count"`
|
||||
}
|
||||
|
||||
channels := []archivedChannel{}
|
||||
for rows.Next() {
|
||||
var ch archivedChannel
|
||||
var userID string
|
||||
if rows.Scan(&ch.ID, &ch.Title, &ch.Type, &userID, &ch.OwnerName, &ch.UpdatedAt, &ch.MessageCount) == nil {
|
||||
channels = append(channels, ch)
|
||||
}
|
||||
}
|
||||
|
||||
// Retention config
|
||||
retentionMode := "flexible"
|
||||
@@ -784,6 +800,9 @@ func (h *AdminHandler) ListArchivedChannels(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"channels": channels,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
"retention_mode": retentionMode,
|
||||
})
|
||||
}
|
||||
@@ -791,32 +810,27 @@ func (h *AdminHandler) ListArchivedChannels(c *gin.Context) {
|
||||
func (h *AdminHandler) PurgeChannel(c *gin.Context) {
|
||||
channelID := c.Param("id")
|
||||
|
||||
// Verify the channel is actually archived
|
||||
var isArchived bool
|
||||
err := database.DB.QueryRowContext(c.Request.Context(), database.Q(`
|
||||
SELECT is_archived FROM channels WHERE id = $1
|
||||
`), channelID).Scan(&isArchived)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||
return
|
||||
// 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 !isArchived {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "channel must be archived before purging"})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Hard delete (CASCADE removes messages, participants, models)
|
||||
_, err = database.DB.ExecContext(c.Request.Context(), database.Q(`
|
||||
DELETE FROM channels WHERE id = $1
|
||||
`), channelID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "purge failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Audit log
|
||||
uid := getUserID(c)
|
||||
h.auditLog(c, uid, "channel.purged", "channel", channelID)
|
||||
|
||||
h.auditLog(c, "channel.purged", "channel", channelID, nil)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user