package controller import ( "fmt" "io" "net/http" "os" "path/filepath" "strconv" "strings" "github.com/gin-gonic/gin" "gorm.io/gorm" "ezops/model" ) // WorkspaceFilesHandler 工作区文件浏览器处理器 type WorkspaceFilesHandler struct { db *gorm.DB } func NewWorkspaceFilesHandler(db *gorm.DB) *WorkspaceFilesHandler { return &WorkspaceFilesHandler{db: db} } // FileInfo 文件信息 type FileInfo struct { Name string `json:"name"` Path string `json:"path"` IsDir bool `json:"is_dir"` Size int64 `json:"size"` ModTime string `json:"mod_time"` } // resolveWorkspace 解析 run ID 获取 workspace 路径 func (h *WorkspaceFilesHandler) resolveWorkspace(c *gin.Context) (string, error) { runID, err := strconv.Atoi(c.Param("runId")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid run id"}) return "", err } var run model.PipelineRun if err := h.db.First(&run, runID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "run not found"}) return "", err } if run.WorkspacePath == "" { c.JSON(http.StatusNotFound, gin.H{"error": "workspace path not available, pipeline may still be running"}) return "", fmt.Errorf("no workspace") } return run.WorkspacePath, nil } // safePath 安全拼接路径,防止目录穿越 func safePath(base, sub string) (string, error) { if sub == "" || sub == "." { sub = "/" } // 清理路径 clean := filepath.Clean(sub) if filepath.IsAbs(clean) { // 去掉根前缀,转为相对路径 clean = clean[1:] } fullPath := filepath.Join(base, clean) // 确保不逃逸出 base absBase, _ := filepath.Abs(base) absFull, err := filepath.Abs(fullPath) if err != nil { return "", fmt.Errorf("invalid path") } if !strings.HasPrefix(absFull, absBase) { return "", fmt.Errorf("path traversal detected") } return fullPath, nil } // ListFiles 列出目录内容 // GET /api/v1/pipelines/runs/:runId/files?path=subdir func (h *WorkspaceFilesHandler) ListFiles(c *gin.Context) { workspace, err := h.resolveWorkspace(c) if err != nil { return } subPath := c.Query("path") targetPath, err := safePath(workspace, subPath) if err != nil { c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) return } entries, err := os.ReadDir(targetPath) if err != nil { if os.IsNotExist(err) { c.JSON(http.StatusNotFound, gin.H{"error": "directory not found"}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read directory"}) } return } files := make([]FileInfo, 0) dirs := make([]FileInfo, 0) for _, entry := range entries { info, err := entry.Info() if err != nil { continue } rel, _ := filepath.Rel(workspace, filepath.Join(targetPath, entry.Name())) fi := FileInfo{ Name: entry.Name(), Path: rel, IsDir: entry.IsDir(), Size: info.Size(), ModTime: info.ModTime().Format("2006-01-02 15:04:05"), } if entry.IsDir() { dirs = append(dirs, fi) } else { files = append(files, fi) } } // 当前路径(相对于 workspace) currentRel, _ := filepath.Rel(workspace, targetPath) if currentRel == "." { currentRel = "" } c.JSON(http.StatusOK, gin.H{ "current": currentRel, "dirs": dirs, "files": files, }) } // DownloadFile 下载文件 // GET /api/v1/pipelines/runs/:runId/files/download?path=file func (h *WorkspaceFilesHandler) DownloadFile(c *gin.Context) { workspace, err := h.resolveWorkspace(c) if err != nil { return } subPath := c.Query("path") if subPath == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "path is required"}) return } targetPath, err := safePath(workspace, subPath) if err != nil { c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) return } info, err := os.Stat(targetPath) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) return } if info.IsDir() { c.JSON(http.StatusBadRequest, gin.H{"error": "cannot download a directory"}) return } file, err := os.Open(targetPath) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open file"}) return } defer file.Close() c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filepath.Base(targetPath))) c.Header("Content-Type", "application/octet-stream") c.Header("Content-Length", fmt.Sprintf("%d", info.Size())) io.Copy(c.Writer, file) }