Changeset 0.37.17 (#229)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user