package server import ( "bytes" "database/sql" "encoding/csv" "fmt" "strings" "time" "unicode" "unicode/utf8" "github.com/gin-gonic/gin" "vdi-cloud/platform/internal/model" "vdi-cloud/platform/internal/store" ) const auditExportLimit = 10000 // Filters are shared by the paginated view and export. No implicit history cap. func (s *Server) auditFilter(c *gin.Context) (string, []any, bool) { where, args := " FROM audit_logs WHERE true", []any{} add := func(expr string, value any) { args = append(args, value) where += fmt.Sprintf(" AND "+expr, len(args)) } var from, to time.Time for _, key := range []string{"from", "to"} { if raw := c.Query(key); raw != "" { parsed, err := time.Parse(time.RFC3339Nano, raw) if err != nil { s.fail(c, 400, "invalid_range", "时间格式无效,请使用含时区的时间") return "", nil, false } if key == "from" { from = parsed add("created_at >= $%d", parsed) } else { to = parsed add("created_at <= $%d", parsed) } } } if !from.IsZero() && !to.IsZero() && !to.After(from) { s.fail(c, 400, "invalid_range", "结束时间必须晚于开始时间") return "", nil, false } for _, key := range []string{"keyword", "actor", "action", "result"} { value := c.Query(key) if utf8.RuneCountInString(value) > 200 || strings.IndexByte(value, 0) >= 0 { s.fail(c, 400, "invalid_request", "筛选条件最长 200 字,且不能包含空字符") return "", nil, false } if value == "" { continue } if key == "action" || key == "result" { add(key+" = $%d", value) continue } // Search terms are literal, including SQL LIKE wildcards supplied by users. pattern := "%" + strings.NewReplacer(`\`, `\\`, "%", `\%`, "_", `\_`).Replace(value) + "%" if key == "actor" { add("actor ILIKE $%d", pattern) } else { args = append(args, pattern) p := fmt.Sprintf("$%d", len(args)) where += " AND (id ILIKE " + p + " OR actor ILIKE " + p + " OR action ILIKE " + p + " OR target ILIKE " + p + " OR result ILIKE " + p + " OR request_id ILIKE " + p + ")" } } return where, args, true } func (s *Server) audits(c *gin.Context) { where, args, ok := s.auditFilter(c) if !ok { return } page, size := pageParams(c) tx, err := s.DB.BeginTxx(c.Request.Context(), &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true}) if err != nil { s.dbError(c, err) return } defer tx.Rollback() var total int if err = tx.GetContext(c.Request.Context(), &total, "SELECT count(*)"+where, args...); err != nil { s.dbError(c, err) return } items := []model.Audit{} query := "SELECT *" + where + fmt.Sprintf(" ORDER BY created_at DESC,id DESC LIMIT $%d OFFSET $%d", len(args)+1, len(args)+2) args = append(args, size, (page-1)*size) if err = tx.SelectContext(c.Request.Context(), &items, query, args...); err != nil { s.dbError(c, err) return } c.JSON(200, gin.H{"items": items, "total": total, "page": page, "pageSize": size, "requestId": c.GetString("requestId")}) } func safeCSVCell(value string) string { trimmed := strings.TrimLeftFunc(value, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) || r == '\ufeff' }) if len(trimmed) > 0 && strings.ContainsRune("=+-@", rune(trimmed[0])) { return "'" + value } return value } func (s *Server) exportAudits(c *gin.Context) { where, args, ok := s.auditFilter(c) if !ok { return } // An extra row detects oversized exports without silently truncating evidence. items := []model.Audit{} if err := s.DB.SelectContext(c.Request.Context(), &items, "SELECT *"+where+" ORDER BY created_at DESC,id DESC LIMIT 10001", args...); err != nil { s.dbError(c, err) return } if len(items) > auditExportLimit { s.fail(c, 413, "export_too_large", "匹配记录超过 10000 条,请缩小时间范围或增加筛选条件后导出") return } var content bytes.Buffer content.WriteString("\ufeff") w := csv.NewWriter(&content) w.UseCRLF = true _ = w.Write([]string{"时间(UTC)", "操作者", "动作", "对象", "结果", "请求标识", "审计标识"}) for _, row := range items { cells := []string{row.CreatedAt.UTC().Format(time.RFC3339Nano), row.Actor, row.Action, row.Target, row.Result, row.RequestID, row.ID} for i := range cells { cells[i] = safeCSVCell(cells[i]) } _ = w.Write(cells) } w.Flush() if err := w.Error(); err != nil { s.fail(c, 500, "export_failed", "导出生成失败,请重试") return } // Record preparation, without storing credentials or the user's search terms. if _, err := s.DB.ExecContext(c.Request.Context(), "INSERT INTO audit_logs(id,actor,action,target,result,request_id) VALUES($1,$2,'export_audit_logs',$3,'success',$4)", store.ID("audit_"), s.admin(c).Username, fmt.Sprintf("rows=%d", len(items)), c.GetString("requestId")); err != nil { s.dbError(c, err) return } c.Header("Content-Disposition", `attachment; filename="audit-logs-`+time.Now().UTC().Format("20060102-150405")+`.csv"`) c.Header("X-Export-Count", fmt.Sprint(len(items))) c.Header("X-Content-Type-Options", "nosniff") c.Data(200, "text/csv; charset=utf-8", content.Bytes()) }