Changeset 0.12.0 (#63)
This commit is contained in:
@@ -277,9 +277,10 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"banner": banner,
|
||||
"branding": branding,
|
||||
"has_admin_prompt": hasAdminPrompt,
|
||||
"banner": banner,
|
||||
"branding": branding,
|
||||
"has_admin_prompt": hasAdminPrompt,
|
||||
"storage_configured": storageConfigured,
|
||||
"policies": gin.H{
|
||||
"allow_registration": policies["allow_registration"],
|
||||
"allow_user_byok": policies["allow_user_byok"],
|
||||
@@ -288,6 +289,22 @@ func (h *AdminHandler) PublicSettings(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
|
||||
476
server/handlers/attachments.go
Normal file
476
server/handlers/attachments.go
Normal file
@@ -0,0 +1,476 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/extraction"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/storage"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── Default Limits ─────────────────────────
|
||||
// Overridable via global_settings keys.
|
||||
const (
|
||||
defaultMaxFileSize = 10 * 1024 * 1024 // 10 MB
|
||||
defaultMaxUploadSize = 50 * 1024 * 1024 // 50 MB total per request (future: multi-file)
|
||||
defaultMaxAttachmentsPerMsg = 5 // future: multi-file per message
|
||||
defaultOrphanMaxAge = 24 * time.Hour
|
||||
)
|
||||
|
||||
// allowedMIMETypes is the default allowlist. Admin can override via global_settings.
|
||||
var allowedMIMETypes = map[string]bool{
|
||||
// Images
|
||||
"image/jpeg": true, "image/png": true, "image/gif": true,
|
||||
"image/webp": true, "image/svg+xml": true,
|
||||
// Documents
|
||||
"application/pdf": true,
|
||||
"text/plain": true, "text/markdown": true, "text/csv": true,
|
||||
// Microsoft Office
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": true,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true,
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": true,
|
||||
"application/msword": true, "application/vnd.ms-excel": true,
|
||||
// OpenDocument
|
||||
"application/vnd.oasis.opendocument.text": true,
|
||||
"application/vnd.oasis.opendocument.spreadsheet": true,
|
||||
"application/vnd.oasis.opendocument.presentation": true,
|
||||
// Other
|
||||
"application/rtf": true,
|
||||
}
|
||||
|
||||
// ── Handler ────────────────────────────────
|
||||
|
||||
type AttachmentHandler struct {
|
||||
stores store.Stores
|
||||
objStore storage.ObjectStore
|
||||
extQueue *extraction.Queue // nil if extraction disabled
|
||||
}
|
||||
|
||||
func NewAttachmentHandler(stores store.Stores, objStore storage.ObjectStore, extQueue *extraction.Queue) *AttachmentHandler {
|
||||
return &AttachmentHandler{stores: stores, objStore: objStore, extQueue: extQueue}
|
||||
}
|
||||
|
||||
// ── Upload ─────────────────────────────────
|
||||
// POST /api/v1/channels/:id/attachments
|
||||
// Multipart form: file field "file", returns attachment metadata.
|
||||
func (h *AttachmentHandler) Upload(c *gin.Context) {
|
||||
if h.objStore == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := getUserID(c)
|
||||
channelID := c.Param("id")
|
||||
|
||||
// Verify channel ownership
|
||||
if !h.verifyChannelAccess(c, channelID, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse multipart
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file field"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Size check
|
||||
if header.Size > defaultMaxFileSize {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("file too large (max %d MB)", defaultMaxFileSize/(1024*1024)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// MIME detection: read first 512 bytes for sniffing, then reset
|
||||
buf := make([]byte, 512)
|
||||
n, _ := file.Read(buf)
|
||||
detectedType := http.DetectContentType(buf[:n])
|
||||
|
||||
// Reset reader to start
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process file"})
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize MIME type (strip params like charset)
|
||||
contentType := detectedType
|
||||
if idx := strings.Index(contentType, ";"); idx > 0 {
|
||||
contentType = strings.TrimSpace(contentType[:idx])
|
||||
}
|
||||
|
||||
// For types that DetectContentType can't distinguish (returns application/octet-stream),
|
||||
// fall back to extension-based detection
|
||||
if contentType == "application/octet-stream" {
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
if mapped, ok := extToMIME[ext]; ok {
|
||||
contentType = mapped
|
||||
}
|
||||
}
|
||||
|
||||
// Allowlist check
|
||||
if !allowedMIMETypes[contentType] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("file type %q not allowed", contentType),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Build storage key: attachments/{channel_id}/{attachment_id}_{filename}
|
||||
// We generate the ID first via a temp UUID, then use it in the key.
|
||||
att := &models.Attachment{
|
||||
ChannelID: channelID,
|
||||
UserID: userID,
|
||||
Filename: sanitizeFilename(header.Filename),
|
||||
ContentType: contentType,
|
||||
SizeBytes: header.Size,
|
||||
Metadata: models.JSONMap{
|
||||
"extraction_status": "pending",
|
||||
},
|
||||
}
|
||||
|
||||
// Create PG row first to get the UUID
|
||||
// storage_key is set after we have the ID
|
||||
att.StorageKey = "placeholder"
|
||||
if err := h.stores.Attachments.Create(c.Request.Context(), att); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create attachment record"})
|
||||
return
|
||||
}
|
||||
|
||||
// Now build the real storage key and update
|
||||
att.StorageKey = fmt.Sprintf("attachments/%s/%s_%s", channelID, att.ID, att.Filename)
|
||||
|
||||
// Write to object store
|
||||
if err := h.objStore.Put(c.Request.Context(), att.StorageKey, file, header.Size, contentType); err != nil {
|
||||
// Rollback PG row on storage failure
|
||||
h.stores.Attachments.Delete(c.Request.Context(), att.ID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update storage_key in PG
|
||||
database.DB.ExecContext(c.Request.Context(),
|
||||
`UPDATE attachments SET storage_key = $1 WHERE id = $2`,
|
||||
att.StorageKey, att.ID)
|
||||
|
||||
// For images, mark extraction as not needed (complete immediately)
|
||||
if strings.HasPrefix(contentType, "image/") {
|
||||
h.stores.Attachments.UpdateMetadata(c.Request.Context(), att.ID, map[string]interface{}{
|
||||
"extraction_status": "complete",
|
||||
})
|
||||
att.Metadata["extraction_status"] = "complete"
|
||||
}
|
||||
|
||||
// For text/plain, extract inline (trivial — just read the file)
|
||||
if contentType == "text/plain" || contentType == "text/markdown" || contentType == "text/csv" {
|
||||
if _, err := file.Seek(0, io.SeekStart); err == nil {
|
||||
if textBytes, err := io.ReadAll(file); err == nil {
|
||||
text := string(textBytes)
|
||||
h.stores.Attachments.SetExtractedText(c.Request.Context(), att.ID, text)
|
||||
h.stores.Attachments.UpdateMetadata(c.Request.Context(), att.ID, map[string]interface{}{
|
||||
"extraction_status": "complete",
|
||||
})
|
||||
att.Metadata["extraction_status"] = "complete"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For documents requiring extraction (PDF, DOCX, etc.), enqueue for sidecar
|
||||
if extraction.IsExtractable(contentType) && h.extQueue != nil {
|
||||
if err := h.extQueue.Enqueue(att.ID, att.StorageKey, contentType, att.Filename); err != nil {
|
||||
log.Printf("extraction enqueue failed for %s: %v", att.ID, err)
|
||||
// Non-fatal: file is uploaded, just won't have extracted text
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, att)
|
||||
}
|
||||
|
||||
// ── Download ───────────────────────────────
|
||||
// GET /api/v1/attachments/:id/download
|
||||
// Streams file content with auth check via channel membership.
|
||||
func (h *AttachmentHandler) Download(c *gin.Context) {
|
||||
if h.objStore == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
userID := getUserID(c)
|
||||
attID := c.Param("id")
|
||||
|
||||
att, err := h.stores.Attachments.GetByID(c.Request.Context(), attID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "attachment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Channel-scoped access check
|
||||
if !h.verifyChannelAccess(c, att.ChannelID, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
reader, size, _, err := h.objStore.Get(c.Request.Context(), att.StorageKey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
c.Header("Content-Type", att.ContentType)
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, att.Filename))
|
||||
c.Header("Content-Length", fmt.Sprintf("%d", size))
|
||||
c.Status(http.StatusOK)
|
||||
io.Copy(c.Writer, reader)
|
||||
}
|
||||
|
||||
// ── Get Metadata ───────────────────────────
|
||||
// GET /api/v1/attachments/:id
|
||||
func (h *AttachmentHandler) GetMetadata(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
attID := c.Param("id")
|
||||
|
||||
att, err := h.stores.Attachments.GetByID(c.Request.Context(), attID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "attachment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if !h.verifyChannelAccess(c, att.ChannelID, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, att)
|
||||
}
|
||||
|
||||
// ── List Channel Attachments ───────────────
|
||||
// GET /api/v1/channels/:id/attachments
|
||||
func (h *AttachmentHandler) ListByChannel(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
channelID := c.Param("id")
|
||||
|
||||
if !h.verifyChannelAccess(c, channelID, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
attachments, err := h.stores.Attachments.GetByChannel(c.Request.Context(), channelID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list attachments"})
|
||||
return
|
||||
}
|
||||
if attachments == nil {
|
||||
attachments = []models.Attachment{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"attachments": attachments})
|
||||
}
|
||||
|
||||
// ── Delete ─────────────────────────────────
|
||||
// DELETE /api/v1/attachments/:id
|
||||
func (h *AttachmentHandler) DeleteAttachment(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
attID := c.Param("id")
|
||||
|
||||
att, err := h.stores.Attachments.GetByID(c.Request.Context(), attID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "attachment not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if !h.verifyChannelAccess(c, att.ChannelID, userID) {
|
||||
return
|
||||
}
|
||||
|
||||
// Delete from PG (returns the row for storage cleanup)
|
||||
deleted, err := h.stores.Attachments.Delete(c.Request.Context(), attID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete attachment"})
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up storage (async-safe: fire and forget with background context)
|
||||
if h.objStore != nil && deleted != nil {
|
||||
storageKey := deleted.StorageKey
|
||||
go func() {
|
||||
ctx := context.Background()
|
||||
if err := h.objStore.Delete(ctx, storageKey); err != nil {
|
||||
log.Printf("storage cleanup failed for %s: %v", storageKey, err)
|
||||
}
|
||||
// Also clean up thumbnail if it exists
|
||||
h.objStore.Delete(ctx, storageKey+"_thumb.jpg")
|
||||
}()
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "attachment deleted"})
|
||||
}
|
||||
|
||||
// ── Admin: Orphan Cleanup ──────────────────
|
||||
// POST /admin/storage/cleanup
|
||||
func (h *AttachmentHandler) CleanupOrphans(c *gin.Context) {
|
||||
orphans, err := h.stores.Attachments.ListOrphans(c.Request.Context(), defaultOrphanMaxAge)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list orphans"})
|
||||
return
|
||||
}
|
||||
|
||||
deleted := 0
|
||||
var freedBytes int64
|
||||
|
||||
for _, att := range orphans {
|
||||
if _, err := h.stores.Attachments.Delete(c.Request.Context(), att.ID); err != nil {
|
||||
log.Printf("orphan cleanup: failed to delete %s from PG: %v", att.ID, err)
|
||||
continue
|
||||
}
|
||||
if h.objStore != nil {
|
||||
h.objStore.Delete(c.Request.Context(), att.StorageKey)
|
||||
h.objStore.Delete(c.Request.Context(), att.StorageKey+"_thumb.jpg")
|
||||
}
|
||||
deleted++
|
||||
freedBytes += att.SizeBytes
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"deleted": deleted,
|
||||
"freed_bytes": freedBytes,
|
||||
"scanned": len(orphans),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Admin: Orphan Count ────────────────────
|
||||
// GET /admin/storage/orphans (for the admin panel card)
|
||||
func (h *AttachmentHandler) OrphanCount(c *gin.Context) {
|
||||
orphans, err := h.stores.Attachments.ListOrphans(c.Request.Context(), defaultOrphanMaxAge)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count orphans"})
|
||||
return
|
||||
}
|
||||
|
||||
var totalBytes int64
|
||||
for _, att := range orphans {
|
||||
totalBytes += att.SizeBytes
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"count": len(orphans),
|
||||
"reclaimable_bytes": totalBytes,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Admin: Extraction Queue Status ─────────
|
||||
// GET /admin/storage/extraction
|
||||
func (h *AttachmentHandler) ExtractionStatus(c *gin.Context) {
|
||||
if h.extQueue == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"enabled": false,
|
||||
"items": []interface{}{},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
items, err := h.extQueue.ListAll()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list extraction queue"})
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []extraction.QueueItem{}
|
||||
}
|
||||
|
||||
// Count by status
|
||||
counts := map[string]int{}
|
||||
for _, item := range items {
|
||||
counts[item.Status]++
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"enabled": true,
|
||||
"total": len(items),
|
||||
"counts": counts,
|
||||
"items": items,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Channel Delete Hook ────────────────────
|
||||
// Called by ChannelHandler.DeleteChannel to clean up storage.
|
||||
func (h *AttachmentHandler) CleanupChannelStorage(channelID string) {
|
||||
if h.objStore == nil {
|
||||
return
|
||||
}
|
||||
// CASCADE already deleted PG rows. Clean up filesystem.
|
||||
prefix := fmt.Sprintf("attachments/%s", channelID)
|
||||
if err := h.objStore.DeletePrefix(context.Background(), prefix); err != nil {
|
||||
log.Printf("storage cleanup for channel %s failed: %v", channelID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────
|
||||
|
||||
// verifyChannelAccess checks that the requesting user owns the channel.
|
||||
// Future RBAC (v0.20.0): replace with rbac.Can(userID, channelID, permission).
|
||||
func (h *AttachmentHandler) verifyChannelAccess(c *gin.Context, channelID, userID string) bool {
|
||||
var ownerID string
|
||||
err := database.DB.QueryRowContext(c.Request.Context(),
|
||||
`SELECT user_id FROM channels WHERE id = $1`, channelID).Scan(&ownerID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||
return false
|
||||
}
|
||||
if ownerID != userID {
|
||||
// Check if user is admin (admins can access any channel)
|
||||
role, _ := c.Get("role")
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// sanitizeFilename cleans a filename for safe storage.
|
||||
func sanitizeFilename(name string) string {
|
||||
// Take only the base name (strip path separators)
|
||||
name = filepath.Base(name)
|
||||
// Replace problematic characters
|
||||
replacer := strings.NewReplacer(
|
||||
"/", "_", "\\", "_", "..", "_", "\x00", "",
|
||||
)
|
||||
name = replacer.Replace(name)
|
||||
if name == "" || name == "." {
|
||||
name = "unnamed"
|
||||
}
|
||||
// Truncate to 200 chars (leave room for UUID prefix in storage key)
|
||||
if len(name) > 200 {
|
||||
ext := filepath.Ext(name)
|
||||
name = name[:200-len(ext)] + ext
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// extToMIME maps file extensions to MIME types for cases where
|
||||
// http.DetectContentType returns application/octet-stream.
|
||||
var extToMIME = map[string]string{
|
||||
".pdf": "application/pdf",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
".doc": "application/msword",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".odt": "application/vnd.oasis.opendocument.text",
|
||||
".ods": "application/vnd.oasis.opendocument.spreadsheet",
|
||||
".odp": "application/vnd.oasis.opendocument.presentation",
|
||||
".rtf": "application/rtf",
|
||||
".md": "text/markdown",
|
||||
".csv": "text/csv",
|
||||
".svg": "image/svg+xml",
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -27,33 +28,35 @@ type createChannelRequest struct {
|
||||
}
|
||||
|
||||
type updateChannelRequest struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Model *string `json:"model,omitempty"`
|
||||
SystemPrompt *string `json:"system_prompt,omitempty"`
|
||||
APIConfigID *string `json:"provider_config_id,omitempty"`
|
||||
IsArchived *bool `json:"is_archived,omitempty"`
|
||||
IsPinned *bool `json:"is_pinned,omitempty"`
|
||||
Folder *string `json:"folder,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Model *string `json:"model,omitempty"`
|
||||
SystemPrompt *string `json:"system_prompt,omitempty"`
|
||||
APIConfigID *string `json:"provider_config_id,omitempty"`
|
||||
IsArchived *bool `json:"is_archived,omitempty"`
|
||||
IsPinned *bool `json:"is_pinned,omitempty"`
|
||||
Folder *string `json:"folder,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Settings *json.RawMessage `json:"settings,omitempty"` // JSONB merge into existing settings
|
||||
}
|
||||
|
||||
type channelResponse struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Description *string `json:"description"`
|
||||
Model *string `json:"model"`
|
||||
APIConfigID *string `json:"provider_config_id"`
|
||||
SystemPrompt *string `json:"system_prompt"`
|
||||
IsArchived bool `json:"is_archived"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
Folder *string `json:"folder"`
|
||||
Tags []string `json:"tags"`
|
||||
MessageCount int `json:"message_count"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Description *string `json:"description"`
|
||||
Model *string `json:"model"`
|
||||
APIConfigID *string `json:"provider_config_id"`
|
||||
SystemPrompt *string `json:"system_prompt"`
|
||||
IsArchived bool `json:"is_archived"`
|
||||
IsPinned bool `json:"is_pinned"`
|
||||
Folder *string `json:"folder"`
|
||||
Tags []string `json:"tags"`
|
||||
Settings json.RawMessage `json:"settings,omitempty"`
|
||||
MessageCount int `json:"message_count"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type paginatedResponse struct {
|
||||
@@ -72,6 +75,16 @@ func NewChannelHandler() *ChannelHandler {
|
||||
return &ChannelHandler{}
|
||||
}
|
||||
|
||||
// channelDeleteHook is called after a channel is successfully deleted.
|
||||
// Set at startup by SetChannelDeleteHook to clean up storage files.
|
||||
var channelDeleteHook func(channelID string)
|
||||
|
||||
// SetChannelDeleteHook registers a callback invoked after channel deletion.
|
||||
// Used to clean up attachment files on the storage backend.
|
||||
func SetChannelDeleteHook(fn func(channelID string)) {
|
||||
channelDeleteHook = fn
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
// getUserID extracts the authenticated user's ID from context.
|
||||
@@ -141,7 +154,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
||||
// Fetch channels with message count
|
||||
query := `
|
||||
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
|
||||
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags,
|
||||
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags, c.settings,
|
||||
COALESCE(mc.cnt, 0) AS message_count,
|
||||
c.created_at, c.updated_at
|
||||
FROM channels c
|
||||
@@ -186,7 +199,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
|
||||
err := rows.Scan(
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
|
||||
pq.Array(&tags),
|
||||
pq.Array(&tags), &ch.Settings,
|
||||
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -236,13 +249,13 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
|
||||
INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt,
|
||||
is_archived, is_pinned, folder, tags, created_at, updated_at
|
||||
is_archived, is_pinned, folder, tags, settings, created_at, updated_at
|
||||
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.APIConfigID,
|
||||
req.Folder, pq.Array(req.Tags),
|
||||
).Scan(
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
|
||||
pq.Array(&tags), &ch.CreatedAt, &ch.UpdatedAt,
|
||||
pq.Array(&tags), &ch.Settings, &ch.CreatedAt, &ch.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create channel"})
|
||||
@@ -284,7 +297,7 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
|
||||
var tags []string
|
||||
err := database.DB.QueryRow(`
|
||||
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
|
||||
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags,
|
||||
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags, c.settings,
|
||||
COALESCE(mc.cnt, 0) AS message_count,
|
||||
c.created_at, c.updated_at
|
||||
FROM channels c
|
||||
@@ -295,7 +308,7 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
|
||||
`, channelID, userID).Scan(
|
||||
&ch.ID, &ch.UserID, &ch.Title, &ch.Type, &ch.Description, &ch.Model, &ch.APIConfigID,
|
||||
&ch.SystemPrompt, &ch.IsArchived, &ch.IsPinned, &ch.Folder,
|
||||
pq.Array(&tags),
|
||||
pq.Array(&tags), &ch.Settings,
|
||||
&ch.MessageCount, &ch.CreatedAt, &ch.UpdatedAt,
|
||||
)
|
||||
|
||||
@@ -383,6 +396,12 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
|
||||
if req.Tags != nil {
|
||||
addClause("tags", pq.Array(req.Tags))
|
||||
}
|
||||
if req.Settings != nil {
|
||||
// JSONB merge: new settings keys overwrite existing, unmentioned keys preserved
|
||||
setClauses = append(setClauses, "settings = COALESCE(settings, '{}'::jsonb) || $"+strconv.Itoa(argN)+"::jsonb")
|
||||
args = append(args, []byte(*req.Settings))
|
||||
argN++
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
||||
@@ -430,5 +449,10 @@ func (h *ChannelHandler) DeleteChannel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up storage files (CASCADE already removed PG attachment rows)
|
||||
if channelDeleteHook != nil {
|
||||
go channelDeleteHook(channelID)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "channel deleted"})
|
||||
}
|
||||
|
||||
@@ -3,49 +3,54 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/crypto"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/storage"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/tools"
|
||||
)
|
||||
|
||||
// ── Request Types ───────────────────────────
|
||||
|
||||
type completionRequest struct {
|
||||
ChannelID string `json:"channel_id"` // preferred; validated manually below
|
||||
ChatID string `json:"chat_id"` // deprecated alias — maps to channel_id
|
||||
Content string `json:"content" binding:"required"`
|
||||
Model string `json:"model,omitempty"`
|
||||
PresetID string `json:"preset_id,omitempty"` // if set, unwraps preset → base model + config
|
||||
APIConfigID string `json:"provider_config_id,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
Stream *bool `json:"stream,omitempty"`
|
||||
ChannelID string `json:"channel_id"` // preferred; validated manually below
|
||||
ChatID string `json:"chat_id"` // deprecated alias — maps to channel_id
|
||||
Content string `json:"content" binding:"required"`
|
||||
Model string `json:"model,omitempty"`
|
||||
PresetID string `json:"preset_id,omitempty"` // if set, unwraps preset → base model + config
|
||||
APIConfigID string `json:"provider_config_id,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
Stream *bool `json:"stream,omitempty"`
|
||||
AttachmentIDs []string `json:"attachment_ids,omitempty"` // staged attachment UUIDs to include in request
|
||||
}
|
||||
|
||||
// CompletionHandler proxies LLM requests through the backend.
|
||||
type CompletionHandler struct {
|
||||
vault *crypto.KeyResolver
|
||||
stores store.Stores
|
||||
hub *events.Hub // WebSocket hub for browser tool bridge
|
||||
vault *crypto.KeyResolver
|
||||
stores store.Stores
|
||||
hub *events.Hub // WebSocket hub for browser tool bridge
|
||||
objStore storage.ObjectStore // file storage for attachment content (nil = disabled)
|
||||
}
|
||||
|
||||
// NewCompletionHandler creates a new handler.
|
||||
func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub) *CompletionHandler {
|
||||
return &CompletionHandler{vault: vault, stores: stores, hub: hub}
|
||||
func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub, objStore storage.ObjectStore) *CompletionHandler {
|
||||
return &CompletionHandler{vault: vault, stores: stores, hub: hub, objStore: objStore}
|
||||
}
|
||||
|
||||
// ── Chat Completion ─────────────────────────
|
||||
@@ -138,26 +143,53 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Add the new user message
|
||||
messages = append(messages, providers.Message{
|
||||
// Resolve capabilities early — needed for vision gating below
|
||||
caps := h.getModelCapabilities(c, model, configID)
|
||||
|
||||
// Build user message — multimodal if attachments are present
|
||||
userMsg := providers.Message{
|
||||
Role: "user",
|
||||
Content: req.Content,
|
||||
})
|
||||
}
|
||||
|
||||
// Persist user message
|
||||
if _, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil); err != nil {
|
||||
if len(req.AttachmentIDs) > 0 && h.objStore != nil {
|
||||
parts, augContent, _, err := h.buildMultimodalParts(c, channelID, req.Content, req.AttachmentIDs, caps)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
// Multimodal (images present): use ContentParts
|
||||
userMsg.ContentParts = parts
|
||||
} else if augContent != req.Content {
|
||||
// Doc-only: use augmented text content
|
||||
userMsg.Content = augContent
|
||||
}
|
||||
}
|
||||
|
||||
messages = append(messages, userMsg)
|
||||
|
||||
// Persist user message (text-only for storage — multimodal parts are ephemeral)
|
||||
msgID, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil)
|
||||
if err != nil {
|
||||
log.Printf("Failed to persist user message: %v", err)
|
||||
}
|
||||
|
||||
// Link attachments to the persisted message
|
||||
if msgID != "" && len(req.AttachmentIDs) > 0 {
|
||||
for _, attID := range req.AttachmentIDs {
|
||||
if err := h.stores.Attachments.SetMessageID(c.Request.Context(), attID, msgID); err != nil {
|
||||
log.Printf("Failed to link attachment %s to message %s: %v", attID, msgID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build provider request
|
||||
provReq := providers.CompletionRequest{
|
||||
Model: model,
|
||||
Messages: messages,
|
||||
}
|
||||
|
||||
// Resolve capabilities for this model — auto-set defaults
|
||||
caps := h.getModelCapabilities(c, model, configID)
|
||||
|
||||
if req.MaxTokens > 0 {
|
||||
provReq.MaxTokens = req.MaxTokens
|
||||
} else {
|
||||
@@ -405,6 +437,128 @@ func (h *CompletionHandler) getModelCapabilities(c *gin.Context, model, apiConfi
|
||||
return ResolveModelCaps(c, model, apiConfigID)
|
||||
}
|
||||
|
||||
// ── Multimodal Assembly ─────────────────────
|
||||
// Builds content parts from attachments for the user message.
|
||||
//
|
||||
// Returns:
|
||||
// - parts: ContentParts array (non-nil only when images are present)
|
||||
// - augContent: enriched text content with document context (for doc-only case)
|
||||
// - validIDs: attachment IDs that were successfully processed
|
||||
// - error: if any attachment is invalid or vision is needed but missing
|
||||
//
|
||||
// Rules:
|
||||
// - Images → base64 data URI (requires vision capability)
|
||||
// - Documents with extracted_text → text injection
|
||||
// - Documents without extraction → filename placeholder
|
||||
// - Text is always the first part
|
||||
|
||||
func (h *CompletionHandler) buildMultimodalParts(
|
||||
c *gin.Context,
|
||||
channelID, textContent string,
|
||||
attachmentIDs []string,
|
||||
caps models.ModelCapabilities,
|
||||
) ([]providers.ContentPart, string, []string, error) {
|
||||
|
||||
parts := []providers.ContentPart{
|
||||
{Type: "text", Text: textContent},
|
||||
}
|
||||
var docTexts []string
|
||||
var validIDs []string
|
||||
hasImage := false
|
||||
|
||||
for _, attID := range attachmentIDs {
|
||||
att, err := h.stores.Attachments.GetByID(c.Request.Context(), attID)
|
||||
if err != nil {
|
||||
return nil, "", nil, fmt.Errorf("attachment %s not found", attID)
|
||||
}
|
||||
|
||||
// Security: verify attachment belongs to this channel
|
||||
if att.ChannelID != channelID {
|
||||
return nil, "", nil, fmt.Errorf("attachment %s does not belong to this channel", attID)
|
||||
}
|
||||
|
||||
if isImageContentType(att.ContentType) {
|
||||
// Vision gating: reject images if model lacks vision
|
||||
if !caps.Vision {
|
||||
return nil, "", nil, fmt.Errorf("model does not support image input; remove image attachments or choose a vision-capable model")
|
||||
}
|
||||
|
||||
// Read image from storage, base64 encode, build data URI
|
||||
reader, _, _, err := h.objStore.Get(c.Request.Context(), att.StorageKey)
|
||||
if err != nil {
|
||||
log.Printf("Failed to read attachment %s from storage: %v", attID, err)
|
||||
parts = append(parts, providers.ContentPart{
|
||||
Type: "text",
|
||||
Text: fmt.Sprintf("[Image: %s — failed to read from storage]", att.Filename),
|
||||
})
|
||||
validIDs = append(validIDs, attID)
|
||||
continue
|
||||
}
|
||||
data, err := io.ReadAll(reader)
|
||||
reader.Close()
|
||||
if err != nil {
|
||||
log.Printf("Failed to read attachment %s bytes: %v", attID, err)
|
||||
validIDs = append(validIDs, attID)
|
||||
continue
|
||||
}
|
||||
|
||||
b64 := base64.StdEncoding.EncodeToString(data)
|
||||
dataURI := fmt.Sprintf("data:%s;base64,%s", att.ContentType, b64)
|
||||
|
||||
parts = append(parts, providers.ContentPart{
|
||||
Type: "image_url",
|
||||
ImageURL: &providers.ImageURL{
|
||||
URL: dataURI,
|
||||
Detail: "auto",
|
||||
},
|
||||
})
|
||||
hasImage = true
|
||||
|
||||
} else if att.ExtractedText != nil && *att.ExtractedText != "" {
|
||||
// Document with extracted text → inject as context
|
||||
docText := fmt.Sprintf("[Document: %s]\n%s", att.Filename, *att.ExtractedText)
|
||||
parts = append(parts, providers.ContentPart{
|
||||
Type: "text",
|
||||
Text: docText,
|
||||
})
|
||||
docTexts = append(docTexts, docText)
|
||||
|
||||
} else {
|
||||
// Document without extraction (pending, failed, or not extractable)
|
||||
status := "pending"
|
||||
if s, ok := att.Metadata["extraction_status"].(string); ok {
|
||||
status = s
|
||||
}
|
||||
placeholder := fmt.Sprintf("[Attached file: %s (extraction %s)]", att.Filename, status)
|
||||
parts = append(parts, providers.ContentPart{
|
||||
Type: "text",
|
||||
Text: placeholder,
|
||||
})
|
||||
docTexts = append(docTexts, placeholder)
|
||||
}
|
||||
|
||||
validIDs = append(validIDs, attID)
|
||||
}
|
||||
|
||||
// If images present → use ContentParts (multimodal array)
|
||||
if hasImage {
|
||||
return parts, textContent, validIDs, nil
|
||||
}
|
||||
|
||||
// Doc-only: merge document context into a single text string (more efficient)
|
||||
augmented := textContent
|
||||
for _, dt := range docTexts {
|
||||
augmented += "\n\n" + dt
|
||||
}
|
||||
return nil, augmented, validIDs, nil
|
||||
}
|
||||
|
||||
// isImageContentType returns true for MIME types that should be sent as
|
||||
// base64 image content parts rather than text extraction.
|
||||
func isImageContentType(ct string) bool {
|
||||
return strings.HasPrefix(ct, "image/")
|
||||
}
|
||||
|
||||
// ── Config Resolution ───────────────────────
|
||||
// Priority: request.provider_config_id → chat.provider_config_id → user's first active config
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/middleware"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/roles"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
||||
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
|
||||
)
|
||||
|
||||
// ── Test Harness ────────────────────────────
|
||||
@@ -132,9 +132,19 @@ func setupHarness(t *testing.T) *testHarness {
|
||||
channels := NewChannelHandler()
|
||||
protected.GET("/channels", channels.ListChannels)
|
||||
protected.POST("/channels", channels.CreateChannel)
|
||||
protected.GET("/channels/:id", channels.GetChannel)
|
||||
protected.DELETE("/channels/:id", channels.DeleteChannel)
|
||||
|
||||
// Attachments (nil storage = upload returns 503, but metadata works)
|
||||
attachH := NewAttachmentHandler(stores, nil, nil)
|
||||
protected.POST("/channels/:id/attachments", attachH.Upload)
|
||||
protected.GET("/channels/:id/attachments", attachH.ListByChannel)
|
||||
protected.GET("/attachments/:id", attachH.GetMetadata)
|
||||
protected.GET("/attachments/:id/download", attachH.Download)
|
||||
protected.DELETE("/attachments/:id", attachH.DeleteAttachment)
|
||||
|
||||
// Completions
|
||||
completions := NewCompletionHandler(nil, stores, nil)
|
||||
completions := NewCompletionHandler(nil, stores, nil, nil)
|
||||
protected.POST("/chat/completions", completions.Complete)
|
||||
|
||||
// Admin routes
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||
"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"
|
||||
)
|
||||
@@ -58,14 +59,15 @@ type cursorRequest struct {
|
||||
|
||||
// MessageHandler holds dependencies for message endpoints.
|
||||
type MessageHandler struct {
|
||||
vault *crypto.KeyResolver
|
||||
stores store.Stores
|
||||
hub *events.Hub
|
||||
vault *crypto.KeyResolver
|
||||
stores store.Stores
|
||||
hub *events.Hub
|
||||
objStore storage.ObjectStore
|
||||
}
|
||||
|
||||
// NewMessageHandler creates a new message handler.
|
||||
func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub) *MessageHandler {
|
||||
return &MessageHandler{vault: vault, stores: stores, hub: hub}
|
||||
func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub, objStore storage.ObjectStore) *MessageHandler {
|
||||
return &MessageHandler{vault: vault, stores: stores, hub: hub, objStore: objStore}
|
||||
}
|
||||
|
||||
// ── List Messages (flat, all branches) ──────
|
||||
@@ -360,7 +362,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
|
||||
|
||||
// ── Resolve model + provider ──
|
||||
|
||||
comp := NewCompletionHandler(h.vault, h.stores, h.hub)
|
||||
comp := NewCompletionHandler(h.vault, h.stores, h.hub, h.objStore)
|
||||
|
||||
var presetSystemPrompt string
|
||||
model := req.Model
|
||||
|
||||
62
server/handlers/storage.go
Normal file
62
server/handlers/storage.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/storage"
|
||||
)
|
||||
|
||||
// storageConfigured is a package-level flag set during init.
|
||||
// Read by PublicSettings to include in the boot payload.
|
||||
var storageConfigured bool
|
||||
|
||||
// SetStorageConfigured sets the package-level flag indicating whether
|
||||
// file storage is available. Called from main.go after storage init.
|
||||
func SetStorageConfigured(configured bool) {
|
||||
storageConfigured = configured
|
||||
}
|
||||
|
||||
// StorageHandler handles file storage admin endpoints.
|
||||
type StorageHandler struct {
|
||||
store storage.ObjectStore
|
||||
}
|
||||
|
||||
// NewStorageHandler creates a StorageHandler.
|
||||
// store may be nil if storage is not configured.
|
||||
func NewStorageHandler(store storage.ObjectStore) *StorageHandler {
|
||||
return &StorageHandler{store: store}
|
||||
}
|
||||
|
||||
// Configured returns true if a storage backend is available.
|
||||
func (h *StorageHandler) Configured() bool {
|
||||
return h.store != nil
|
||||
}
|
||||
|
||||
// Status returns storage backend status and statistics.
|
||||
//
|
||||
// GET /admin/storage/status
|
||||
func (h *StorageHandler) Status(c *gin.Context) {
|
||||
if h.store == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"backend": "none",
|
||||
"configured": false,
|
||||
"healthy": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := h.store.Stats(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "failed to collect storage stats",
|
||||
"backend": h.store.Backend(),
|
||||
"configured": true,
|
||||
"healthy": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
Reference in New Issue
Block a user