151 lines
5.0 KiB
Go
151 lines
5.0 KiB
Go
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())
|
||
}
|