package handlers import ( "context" "fmt" "io" "log" "net/http" "path/filepath" "strings" "time" "github.com/gin-gonic/gin" "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) defaultMaxFilesPerMsg = 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 FileHandler struct { stores store.Stores objStore storage.ObjectStore extQueue *extraction.Queue // nil if extraction disabled } func NewFileHandler(stores store.Stores, objStore storage.ObjectStore, extQueue *extraction.Queue) *FileHandler { return &FileHandler{stores: stores, objStore: objStore, extQueue: extQueue} } // ── Upload ───────────────────────────────── // POST /api/v1/channels/:id/files // Multipart form: file field "file", returns file metadata. func (h *FileHandler) 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: files are stored under files/{channel_id}/{file_id}_{filename} // We generate the ID first via a temp UUID, then use it in the key. att := &models.File{ ChannelID: channelID, UserID: userID, Origin: models.FileOriginUserUpload, Filename: sanitizeFilename(header.Filename), ContentType: contentType, SizeBytes: header.Size, DisplayHint: displayHintFor(contentType), 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.Files.Create(c.Request.Context(), att); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create file record"}) return } // Now build the real storage key and update att.StorageKey = fmt.Sprintf("files/%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.Files.Delete(c.Request.Context(), att.ID) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"}) return } // Update storage_key h.stores.Files.UpdateStorageKey(c.Request.Context(), att.ID, att.StorageKey) // For images, mark extraction as not needed (complete immediately) if strings.HasPrefix(contentType, "image/") { h.stores.Files.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.Files.SetExtractedText(c.Request.Context(), att.ID, text) h.stores.Files.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/files/:id/download // Streams file content with auth check via channel membership. func (h *FileHandler) 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.Files.GetByID(c.Request.Context(), attID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "file 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/files/:id func (h *FileHandler) GetMetadata(c *gin.Context) { userID := getUserID(c) attID := c.Param("id") att, err := h.stores.Files.GetByID(c.Request.Context(), attID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) return } if !h.verifyChannelAccess(c, att.ChannelID, userID) { return } c.JSON(http.StatusOK, att) } // ── List Channel Files ─────────────── // GET /api/v1/channels/:id/files func (h *FileHandler) ListByChannel(c *gin.Context) { userID := getUserID(c) channelID := c.Param("id") if !h.verifyChannelAccess(c, channelID, userID) { return } files, err := h.stores.Files.GetByChannel(c.Request.Context(), channelID, "") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"}) return } if files == nil { files = []models.File{} } c.JSON(http.StatusOK, gin.H{"files": files}) } // ── List by Message ─────────────────────── // GET /api/v1/messages/:id/files // Returns files attached to a specific message (inline artifacts, tool output). func (h *FileHandler) ListByMessage(c *gin.Context) { messageID := c.Param("id") files, err := h.stores.Files.GetByMessage(c.Request.Context(), messageID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"}) return } if files == nil { files = []models.File{} } c.JSON(http.StatusOK, gin.H{"files": files}) } // ── List by User (File Manager) ─────────── // GET /api/v1/files?page=1&per_page=50 // Paginated list of all files owned by the authenticated user. func (h *FileHandler) ListByUser(c *gin.Context) { userID := getUserID(c) page, perPage, _ := parsePagination(c) files, total, err := h.stores.Files.GetByUser(c.Request.Context(), userID, page, perPage) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"}) return } if files == nil { files = []models.File{} } c.JSON(http.StatusOK, gin.H{ "files": files, "total": total, "page": page, "per_page": perPage, }) } // ── Delete ───────────────────────────────── // DELETE /api/v1/files/:id func (h *FileHandler) Delete(c *gin.Context) { userID := getUserID(c) attID := c.Param("id") att, err := h.stores.Files.GetByID(c.Request.Context(), attID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) return } if !h.verifyChannelAccess(c, att.ChannelID, userID) { return } // Delete from PG (returns the row for storage cleanup) deleted, err := h.stores.Files.Delete(c.Request.Context(), attID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete file"}) 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": "file deleted"}) } // ── Admin: Orphan Cleanup ────────────────── // POST /admin/storage/cleanup func (h *FileHandler) CleanupOrphans(c *gin.Context) { orphans, err := h.stores.Files.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.Files.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 *FileHandler) OrphanCount(c *gin.Context) { orphans, err := h.stores.Files.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 *FileHandler) 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 *FileHandler) CleanupChannelStorage(channelID string) { if h.objStore == nil { return } // CASCADE already deleted PG rows. Clean up filesystem. prefix := fmt.Sprintf("files/%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 *FileHandler) verifyChannelAccess(c *gin.Context, channelID, userID string) bool { owns, err := h.stores.Channels.UserOwns(c.Request.Context(), channelID, userID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) return false } if !owns { // 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 } // ── Project Files (v0.22.4) ──────────────── // UploadToProject handles project-scoped file uploads. // POST /api/v1/projects/:id/files func (h *FileHandler) UploadToProject(c *gin.Context) { if h.objStore == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"}) return } userID := getUserID(c) projectID := c.Param("id") // Verify project access (user owns or is team member; admins bypass) if c.GetString("role") != "admin" { teamIDs, _ := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID) ok, err := h.stores.Projects.UserCanAccess(c.Request.Context(), userID, projectID, teamIDs) if err != nil || !ok { c.JSON(http.StatusForbidden, gin.H{"error": "project not found or access denied"}) return } } file, header, err := c.Request.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"}) return } defer file.Close() if header.Size > int64(defaultMaxFileSize) { c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file exceeds size limit"}) return } // Detect content type buf := make([]byte, 512) n, _ := file.Read(buf) contentType := http.DetectContentType(buf[:n]) file.Seek(0, io.SeekStart) ext := strings.ToLower(filepath.Ext(header.Filename)) if better, ok := extToMIME[ext]; ok && contentType == "application/octet-stream" { contentType = better } // Store file storageKey := fmt.Sprintf("projects/%s/%s/%s", projectID, store.NewID()[:8], sanitizeFilename(header.Filename)) if err := h.objStore.Put(c.Request.Context(), storageKey, file, header.Size, contentType); err != nil { log.Printf("error: project file upload: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"}) return } att := models.File{ ChannelID: "", // no channel association UserID: userID, ProjectID: &projectID, Origin: models.FileOriginUserUpload, Filename: header.Filename, ContentType: contentType, SizeBytes: header.Size, StorageKey: storageKey, DisplayHint: displayHintFor(contentType), } if err := h.stores.Files.Create(c.Request.Context(), &att); err != nil { log.Printf("error: project file create: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file metadata"}) return } if h.extQueue != nil { if err := h.extQueue.Enqueue(att.ID, att.StorageKey, contentType, att.Filename); err != nil { log.Printf("warning: project file extraction enqueue failed: %v", err) } } c.JSON(http.StatusCreated, att) } // ListByProject returns all files uploaded to a project. // GET /api/v1/projects/:id/files func (h *FileHandler) ListByProject(c *gin.Context) { userID := getUserID(c) projectID := c.Param("id") // Admins bypass project ownership/membership checks if c.GetString("role") != "admin" { teamIDs, _ := h.stores.Teams.GetUserTeamIDs(c.Request.Context(), userID) ok, err := h.stores.Projects.UserCanAccess(c.Request.Context(), userID, projectID, teamIDs) if err != nil || !ok { c.JSON(http.StatusForbidden, gin.H{"error": "project not found or access denied"}) return } } files, err := h.stores.Files.GetByProject(c.Request.Context(), projectID) if err != nil { log.Printf("error: list project files: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list files"}) return } if files == nil { files = []models.File{} } c.JSON(http.StatusOK, gin.H{"files": files, "count": len(files)}) } // 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", } // displayHintFor returns the appropriate display hint for a content type. func displayHintFor(contentType string) string { switch { case strings.HasPrefix(contentType, "image/"): return models.FileHintInline case strings.HasPrefix(contentType, "video/"): return models.FileHintInline case strings.HasPrefix(contentType, "audio/"): return models.FileHintInline case contentType == "application/pdf": return models.FileHintThumbnail case strings.HasPrefix(contentType, "text/"): return models.FileHintInline case contentType == "application/json": return models.FileHintInline default: return models.FileHintDownload } }