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/files.go
gobha 96a4f16bc5 Changeset 0.37.17 (#229)
Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
2026-03-24 16:50:00 +00:00

993 lines
29 KiB
Go

package handlers
import (
"context"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"chat-switchboard/extraction"
"chat-switchboard/models"
"chat-switchboard/storage"
"chat-switchboard/store"
"chat-switchboard/workspace"
)
// ── 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
wfs *workspace.FS // nil if workspace FS not configured
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}
}
// SetWorkspaceFS attaches the workspace filesystem for project file operations.
func (h *FileHandler) SetWorkspaceFS(wfs *workspace.FS) {
h.wfs = wfs
}
// ── 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, reworked v0.37.17) ─────────────
// Project files route through the Workspace FS subsystem.
// A workspace is auto-created on first file upload (lazy binding).
// verifyProjectAccess checks that the user can access the project.
// Returns true if access is granted (admins bypass).
func (h *FileHandler) verifyProjectAccess(c *gin.Context, projectID, userID string) bool {
if c.GetString("role") == "admin" {
return true
}
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 false
}
return true
}
// ensureProjectWorkspace returns the project's workspace, creating one if needed.
func (h *FileHandler) ensureProjectWorkspace(ctx context.Context, projectID string) (*models.Workspace, error) {
proj, err := h.stores.Projects.GetByID(ctx, projectID)
if err != nil {
return nil, fmt.Errorf("project not found: %w", err)
}
// Already has a workspace — load and return it.
if proj.WorkspaceID != nil && *proj.WorkspaceID != "" {
ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID)
if err != nil {
return nil, fmt.Errorf("workspace %s not found: %w", *proj.WorkspaceID, err)
}
return ws, nil
}
// Create workspace for this project.
ws := &models.Workspace{
OwnerType: models.WorkspaceOwnerProject,
OwnerID: projectID,
Name: proj.Name + " Files",
Status: models.WorkspaceStatusActive,
}
ws.ID = store.NewID()
ws.RootPath = "workspaces/" + ws.ID
if err := h.stores.Workspaces.Create(ctx, ws); err != nil {
// Race guard: another request may have created it. Re-read the project.
proj2, err2 := h.stores.Projects.GetByID(ctx, projectID)
if err2 == nil && proj2.WorkspaceID != nil && *proj2.WorkspaceID != "" {
return h.stores.Workspaces.GetByID(ctx, *proj2.WorkspaceID)
}
return nil, fmt.Errorf("failed to create workspace: %w", err)
}
// Create directory on disk.
if err := h.wfs.CreateDir(ws); err != nil {
log.Printf("warning: workspace mkdir for project %s: %v", projectID, err)
}
// Bind workspace to project.
h.stores.Projects.Update(ctx, projectID, models.ProjectPatch{WorkspaceID: &ws.ID})
return ws, nil
}
// UploadToProject handles project-scoped file uploads via workspace FS.
// POST /api/v1/projects/:id/files
func (h *FileHandler) UploadToProject(c *gin.Context) {
if h.wfs == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
}
userID := getUserID(c)
projectID := c.Param("id")
ctx := c.Request.Context()
if !h.verifyProjectAccess(c, projectID, userID) {
return
}
ws, err := h.ensureProjectWorkspace(ctx, projectID)
if err != nil {
log.Printf("error: ensure workspace for project %s: %v", projectID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initialize file storage"})
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
}
// Quota check
stats, _ := h.stores.Workspaces.GetStats(ctx, ws.ID)
quota := workspace.DefaultMaxBytes
if ws.MaxBytes != nil {
quota = *ws.MaxBytes
}
if stats != nil && stats.TotalBytes+header.Size > quota {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{
"error": fmt.Sprintf("workspace quota exceeded (%d bytes max)", quota),
})
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
}
filename := sanitizeFilename(header.Filename)
// Determine upload path (support optional path prefix via query param)
uploadPath := filename
if prefix := c.Query("path"); prefix != "" {
uploadPath = strings.TrimSuffix(prefix, "/") + "/" + filename
}
if err := h.wfs.WriteFile(ctx, ws, uploadPath, file, header.Size); err != nil {
log.Printf("error: project file write: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store file"})
return
}
c.JSON(http.StatusCreated, gin.H{
"path": uploadPath,
"filename": filename,
"content_type": contentType,
"size_bytes": header.Size,
})
}
// ListByProject returns workspace files for a project.
// GET /api/v1/projects/:id/files
func (h *FileHandler) ListByProject(c *gin.Context) {
userID := getUserID(c)
projectID := c.Param("id")
ctx := c.Request.Context()
if !h.verifyProjectAccess(c, projectID, userID) {
return
}
// Load project to get workspace_id
proj, err := h.stores.Projects.GetByID(ctx, projectID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
return
}
// No workspace yet → empty list
if proj.WorkspaceID == nil || *proj.WorkspaceID == "" {
c.JSON(http.StatusOK, gin.H{"files": []interface{}{}, "count": 0})
return
}
ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"files": []interface{}{}, "count": 0})
return
}
dirPath := c.DefaultQuery("path", "")
recursive := c.DefaultQuery("recursive", "true") == "true"
files, err := h.wfs.ListDir(ctx, ws, dirPath, recursive)
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.WorkspaceFile{}
}
c.JSON(http.StatusOK, gin.H{"files": files, "count": len(files)})
}
// DownloadProjectFile streams a single file from the project workspace.
// GET /api/v1/projects/:id/files/download?path=...
func (h *FileHandler) DownloadProjectFile(c *gin.Context) {
if h.wfs == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
}
userID := getUserID(c)
projectID := c.Param("id")
ctx := c.Request.Context()
if !h.verifyProjectAccess(c, projectID, userID) {
return
}
filePath := c.Query("path")
if filePath == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
return
}
proj, err := h.stores.Projects.GetByID(ctx, projectID)
if err != nil || proj.WorkspaceID == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no files"})
return
}
ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
rc, size, err := h.wfs.ReadFile(ctx, ws, filePath)
if err != nil {
if strings.Contains(err.Error(), "not found") {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
defer rc.Close()
// Detect content type
f, _ := h.wfs.Stat(ctx, ws, filePath)
contentType := "application/octet-stream"
if f != nil && f.ContentType != "" {
contentType = f.ContentType
}
filename := filepath.Base(filePath)
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
c.Header("Content-Length", fmt.Sprintf("%d", size))
c.DataFromReader(http.StatusOK, size, contentType, rc, nil)
}
// DeleteProjectFile deletes a file or directory from the project workspace.
// DELETE /api/v1/projects/:id/files?path=...
func (h *FileHandler) DeleteProjectFile(c *gin.Context) {
if h.wfs == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
}
userID := getUserID(c)
projectID := c.Param("id")
ctx := c.Request.Context()
if !h.verifyProjectAccess(c, projectID, userID) {
return
}
filePath := c.Query("path")
if filePath == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
return
}
proj, err := h.stores.Projects.GetByID(ctx, projectID)
if err != nil || proj.WorkspaceID == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no files"})
return
}
ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
recursive := c.Query("recursive") == "true"
if err := h.wfs.DeleteFile(ctx, ws, filePath, recursive); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// MkdirProject creates a directory in the project workspace.
// POST /api/v1/projects/:id/files/mkdir
func (h *FileHandler) MkdirProject(c *gin.Context) {
if h.wfs == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
}
userID := getUserID(c)
projectID := c.Param("id")
ctx := c.Request.Context()
if !h.verifyProjectAccess(c, projectID, userID) {
return
}
ws, err := h.ensureProjectWorkspace(ctx, projectID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initialize file storage"})
return
}
// Accept path from query param or JSON body
dirPath := c.Query("path")
if dirPath == "" {
var body struct {
Path string `json:"path"`
}
if c.ShouldBindJSON(&body) == nil && body.Path != "" {
dirPath = body.Path
}
}
if dirPath == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "path parameter required"})
return
}
if err := h.wfs.Mkdir(ctx, ws, dirPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"ok": true, "path": dirPath})
}
// UploadProjectArchive extracts a zip/tar.gz archive into the project workspace.
// POST /api/v1/projects/:id/archive/upload
func (h *FileHandler) UploadProjectArchive(c *gin.Context) {
if h.wfs == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
}
userID := getUserID(c)
projectID := c.Param("id")
ctx := c.Request.Context()
if !h.verifyProjectAccess(c, projectID, userID) {
return
}
ws, err := h.ensureProjectWorkspace(ctx, projectID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initialize file storage"})
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file upload required"})
return
}
defer file.Close()
format := detectArchiveFormat(header.Filename)
if format == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported archive format (use .zip or .tar.gz)"})
return
}
// Save to temp file (zip extraction needs seekable file)
tmp, err := os.CreateTemp("", "project-archive-*")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
return
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := io.Copy(tmp, file); err != nil {
tmp.Close()
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"})
return
}
tmp.Close()
count, err := h.wfs.ExtractArchive(ctx, ws, tmpName, format)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "files_extracted": count})
}
// DownloadProjectArchive creates and streams a zip/tar.gz of all project files.
// GET /api/v1/projects/:id/archive/download?format=zip
func (h *FileHandler) DownloadProjectArchive(c *gin.Context) {
if h.wfs == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "file storage not configured"})
return
}
userID := getUserID(c)
projectID := c.Param("id")
ctx := c.Request.Context()
if !h.verifyProjectAccess(c, projectID, userID) {
return
}
proj, err := h.stores.Projects.GetByID(ctx, projectID)
if err != nil || proj.WorkspaceID == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no files"})
return
}
ws, err := h.stores.Workspaces.GetByID(ctx, *proj.WorkspaceID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
return
}
format := c.DefaultQuery("format", "zip")
if format != "zip" && format != "tar.gz" && format != "tgz" {
c.JSON(http.StatusBadRequest, gin.H{"error": "format must be zip or tar.gz"})
return
}
archivePath, err := h.wfs.CreateArchive(ctx, ws, format)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
defer os.Remove(archivePath)
ext := "zip"
if format == "tar.gz" || format == "tgz" {
ext = "tar.gz"
}
filename := fmt.Sprintf("%s.%s", proj.Name, ext)
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
c.File(archivePath)
}
// 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
}
}