Changeset 0.37.17 (#229)

Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
2026-03-24 16:50:00 +00:00
committed by xcaliber
parent e0687d2ea6
commit 96a4f16bc5
27 changed files with 4107 additions and 107 deletions

View File

@@ -6,6 +6,7 @@ import (
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
@@ -16,6 +17,7 @@ import (
"chat-switchboard/models"
"chat-switchboard/storage"
"chat-switchboard/store"
"chat-switchboard/workspace"
)
// ── Default Limits ─────────────────────────
@@ -53,6 +55,7 @@ var allowedMIMETypes = map[string]bool{
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
}
@@ -60,6 +63,11 @@ func NewFileHandler(stores store.Stores, objStore storage.ObjectStore, extQueue
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.
@@ -496,27 +504,92 @@ func sanitizeFilename(name string) string {
return name
}
// ── Project Files (v0.22.4) ────────────────
// ── 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).
// UploadToProject handles project-scoped file uploads.
// 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.objStore == nil {
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()
// 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
}
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")
@@ -531,6 +604,19 @@ func (h *FileHandler) UploadToProject(c *gin.Context) {
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)
@@ -542,70 +628,331 @@ func (h *FileHandler) UploadToProject(c *gin.Context) {
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)
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
}
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)
c.JSON(http.StatusCreated, gin.H{
"path": uploadPath,
"filename": filename,
"content_type": contentType,
"size_bytes": header.Size,
})
}
// ListByProject returns all files uploaded to a project.
// 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()
// 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
}
if !h.verifyProjectAccess(c, projectID, userID) {
return
}
files, err := h.stores.Files.GetByProject(c.Request.Context(), projectID)
// 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.File{}
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{

View File

@@ -888,14 +888,18 @@ func main() {
protected.DELETE("/projects/:id/notes/:noteId", projectH.RemoveNote)
protected.GET("/projects/:id/notes", projectH.ListNotes)
// Project files (v0.22.4) — wired via fileH which is declared later,
// so we use a closure that captures the variable.
protected.POST("/projects/:id/files", func(c *gin.Context) {
handlers.NewFileHandler(stores, objStore, extQueue).UploadToProject(c)
})
protected.GET("/projects/:id/files", func(c *gin.Context) {
handlers.NewFileHandler(stores, objStore, extQueue).ListByProject(c)
})
// Project files (v0.22.4, reworked v0.37.17 — workspace-backed)
projFileH := handlers.NewFileHandler(stores, objStore, extQueue)
if wfs != nil {
projFileH.SetWorkspaceFS(wfs)
}
protected.POST("/projects/:id/files", projFileH.UploadToProject)
protected.GET("/projects/:id/files", projFileH.ListByProject)
protected.GET("/projects/:id/files/download", projFileH.DownloadProjectFile)
protected.DELETE("/projects/:id/files", projFileH.DeleteProjectFile)
protected.POST("/projects/:id/files/mkdir", projFileH.MkdirProject)
protected.POST("/projects/:id/archive/upload", projFileH.UploadProjectArchive)
protected.GET("/projects/:id/archive/download", projFileH.DownloadProjectArchive)
// Notifications (v0.20.0)
notifH := handlers.NewNotificationHandler(stores, hub)

View File

@@ -4142,11 +4142,23 @@ paths:
tags:
- Projects
- Files
summary: List project files
summary: List project files (workspace-backed)
description: Returns workspace files for the project. Auto-creates workspace on first upload.
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/ResourceID'
- name: path
in: query
schema:
type: string
description: Directory path to list (empty = root)
- name: recursive
in: query
schema:
type: string
default: 'true'
description: Include subdirectories
responses:
'200':
description: Project files
@@ -4155,10 +4167,23 @@ paths:
schema:
type: object
properties:
data:
files:
type: array
items:
$ref: '#/components/schemas/Project'
type: object
properties:
id:
type: string
path:
type: string
is_directory:
type: boolean
content_type:
type: string
size_bytes:
type: integer
count:
type: integer
'401':
$ref: '#/components/responses/Unauthorized'
'404':
@@ -4167,11 +4192,17 @@ paths:
tags:
- Projects
- Files
summary: Upload file to project
summary: Upload file to project workspace
description: Uploads a file to the project's workspace. Auto-creates workspace on first upload.
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/ResourceID'
- name: path
in: query
schema:
type: string
description: Subdirectory path to upload into
requestBody:
content:
multipart/form-data:
@@ -4187,17 +4218,164 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/Project'
'400':
$ref: '#/components/responses/BadRequest'
type: object
properties:
path:
type: string
filename:
type: string
content_type:
type: string
size_bytes:
type: integer
'413':
description: File too large or workspace quota exceeded
'401':
$ref: '#/components/responses/Unauthorized'
delete:
tags:
- Projects
- Files
summary: Delete project file
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/ResourceID'
- name: path
in: query
required: true
schema:
type: string
- name: recursive
in: query
schema:
type: string
responses:
'200':
description: Success
description: File deleted
content:
application/json:
schema:
$ref: '#/components/schemas/MessageResponse'
'401':
$ref: '#/components/responses/Unauthorized'
/api/v1/projects/{id}/files/download:
get:
tags:
- Projects
- Files
summary: Download project file
description: Streams raw file content with Content-Disposition attachment header.
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/ResourceID'
- name: path
in: query
required: true
schema:
type: string
responses:
'200':
description: File content
content:
application/octet-stream:
schema:
type: string
format: binary
'404':
$ref: '#/components/responses/NotFound'
/api/v1/projects/{id}/files/mkdir:
post:
tags:
- Projects
- Files
summary: Create directory in project workspace
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/ResourceID'
requestBody:
content:
application/json:
schema:
type: object
properties:
path:
type: string
required:
- path
responses:
'201':
description: Directory created
content:
application/json:
schema:
$ref: '#/components/schemas/MessageResponse'
'401':
$ref: '#/components/responses/Unauthorized'
/api/v1/projects/{id}/archive/upload:
post:
tags:
- Projects
- Files
summary: Upload and extract archive into project workspace
description: Accepts .zip or .tar.gz, extracts contents into workspace root.
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/ResourceID'
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
file:
type: string
format: binary
responses:
'200':
description: Archive extracted
content:
application/json:
schema:
type: object
properties:
ok:
type: boolean
files_extracted:
type: integer
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
/api/v1/projects/{id}/archive/download:
get:
tags:
- Projects
- Files
summary: Download all project files as archive
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/ResourceID'
- name: format
in: query
schema:
type: string
enum: [zip, tar.gz, tgz]
default: zip
responses:
'200':
description: Archive download
content:
application/zip:
schema:
type: string
format: binary
'404':
$ref: '#/components/responses/NotFound'
/api/v1/workspaces:
get:
tags: