Files
attacker 77c234b060 feat(audit): F1+F2 告警静默规则标记完成 + 审计日志CSV导出实现
F1 告警静默规则 [done]:
- 后端 alert_silence.go: CRUD + Toggle (onetime/daily/weekly 三模式)
- 告警引擎 isSilenced 集成 (alert_engine.go)
- 前端 SilenceRuleTab + SilenceDialog
- 路由 /alerts/silences 已注册

F2 审计日志导出 [done]:
- 重写 ExportAuditLogs: 真正的 CSV 导出 (encoding/csv + UTF-8 BOM)
- 支持 module/action/username/startTime/endTime 筛选
- 导出限制 10000 条防溢出,自动记录审计日志
- JSON 格式向后兼容 (format=json)
- 前端 exportLogs 传递查询参数

同时包含累积的前期工作:
- 目录重构 internal/ -> controller/model/router (v0.55-v0.57)
- 后端分层架构 dto/repository/service/utils
- 审计模块全功能 (终端审计/操作审计/登录日志)
- asciicast v2 录制格式
- SFTP 文件浏览器
- StatStrip 紧凑统计条
- 在线会话自动刷新
- LiveMonitor WebSocket 诊断修复

验证: go build ✅ | npm run build ✅ | Docker 部署 ✅ | 10 回归端点 ✅ | CSV 导出 ✅ | 静默 CRUD ✅
2026-06-25 20:16:30 +08:00

185 lines
4.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}