package middleware import ( "net/http" "strings" "time" "github.com/gin-gonic/gin" "fileshare/internal/audit" "fileshare/internal/model" "fileshare/internal/response" ) // auditHooks 审计钩子:由 API 层在响应前后填充与落库。 // 中间件负责计时与公共字段(IP/UA/设备/耗时),业务上下文通过 auditEntry 传递。 type auditEntry struct { Entry audit.Entry // start 请求进入审计中间件的时刻,用于计算耗时。 start time.Time // writer 下载动作时包装的响应计数器。 writer *bytesCountWriter // skip 为 true 表示业务 handler 显式跳过审计(AuditSkip)。 skip bool // recorded 防止重复落库。 recorded bool } // bytesCountWriter 统计响应体写出字节数(用于下载审计)。 type bytesCountWriter struct { gin.ResponseWriter count int64 } func (w *bytesCountWriter) Write(b []byte) (int, error) { n, err := w.ResponseWriter.Write(b) w.count += int64(n) return n, err } func (w *bytesCountWriter) WriteString(s string) (int, error) { n, err := w.ResponseWriter.WriteString(s) w.count += int64(n) return n, err } // Classifier 判定请求是否属于需审计的动作;返回动作名与是否命中。 type Classifier func(c *gin.Context) (action string, ok bool) // DefaultClassifier 按任务合同的默认路由语义分类: // - 上传:POST /share/file、/share/text、/chunk/upload*、/presign* // - 下载:GET /share/download、/share/select、/share/metadata // - 管理(L5):POST/PATCH/DELETE 的敏感管理操作——登录/登出、配置与密码 // 修改、存储引擎切换、文件更新/删除/策略动作 // // API 层可传入自定义分类器覆盖。 func DefaultClassifier(c *gin.Context) (string, bool) { path := c.FullPath() if path == "" { path = c.Request.URL.Path } p := strings.TrimRight(path, "/") switch c.Request.Method { case http.MethodPost, http.MethodPut: switch { case p == "/share/file" || p == "/share/text": return audit.ActionUpload, true case strings.HasPrefix(p, "/chunk/upload"): return audit.ActionUpload, true case strings.HasPrefix(p, "/presign"): return audit.ActionUpload, true } if adminAuditActions[p] { return audit.ActionAdmin, true } case http.MethodPatch, http.MethodDelete: if adminAuditActions[p] { return audit.ActionAdmin, true } case http.MethodGet: switch p { case "/share/download", "/share/select", "/share/metadata": return audit.ActionDownload, true } } return "", false } // adminAuditActions 需要审计的管理端敏感操作路由(L5)。 var adminAuditActions = map[string]bool{ "/admin/login": true, "/admin/logout": true, "/admin/config/update": true, "/admin/settings/password": true, "/admin/storage/switch": true, "/admin/file/update": true, "/admin/file/delete": true, "/admin/file/batch-delete": true, "/admin/file/batch-update": true, "/admin/file/policy-action": true, "/admin/file/batch-policy-action": true, } // Audit 审计中间件:对分类器命中的 upload/download/admin 动作写审计日志。 // handler 通过 AuditSet 填充取件码/文件名/字节数等业务字段; // handler 未显式 AuditRecordRequest 时按 HTTP 状态兜底落库。 func Audit(service *audit.Service, classify Classifier) gin.HandlerFunc { if classify == nil { classify = DefaultClassifier } return func(c *gin.Context) { start := time.Now() action, ok := classify(c) // 未命中审计动作的请求直接放行,不产生审计记录。 // L5:admin 类动作同样需要建 auditEntry 并落库(登录失败/配置变更等)。 if !ok { c.Next() return } entry := audit.Entry{ Action: action, IP: GetClientIP(c), UserAgent: c.Request.UserAgent(), } info := audit.ParseUserAgent(entry.UserAgent) entry.DeviceOS = info.OS entry.DeviceBrowser = info.Browser entry.DeviceType = info.Type // 交给后续 handler 填充 state := &auditEntry{Entry: entry, start: start} c.Set("auditEntry", state) // 下载动作:包装 Writer 以捕获实际写出字节数(必须在 c.Next() 前替换) if action == audit.ActionDownload { state.writer = &bytesCountWriter{ResponseWriter: c.Writer} c.Writer = state.writer } c.Next() // 下载兜底统计:handler 未填 TransferredBytes 时取响应写出字节 if action == audit.ActionDownload && state.Entry.TransferredBytes == 0 && !state.recorded && !state.skip && state.writer != nil { state.Entry.TransferredBytes = state.writer.count } // handler 未显式落库时兜底记录 ae, exists := c.Get("auditEntry") if !exists { return } state, isState := ae.(*auditEntry) if !isState || state.recorded || state.skip { return } state.Entry.Duration = time.Since(start) state.Entry.Actor = resolveActor(c) status := c.Writer.Status() switch { case state.Entry.Result != "": // handler 已给出结论 case status >= 500: state.Entry.Result = model.AuditResultFailed case status == 401 || status == 403 || status == 423 || status == 429 || status == 428: state.Entry.Result = model.AuditResultDenied case status >= 400: state.Entry.Result = model.AuditResultFailed default: state.Entry.Result = model.AuditResultSuccess } switch { case state.Entry.ErrorMsg != "": // handler 已给出错误信息 case c.Errors.String() != "": state.Entry.ErrorMsg = c.Errors.String() case status >= 400: // 兜底:记录 HTTP 状态 state.Entry.ErrorMsg = "HTTP " + itoa64(int64(status)) } service.Record(state.Entry) state.recorded = true } } // AuditEntry 获取当前请求的审计状态(由 Audit 中间件创建)。 func AuditEntry(c *gin.Context) *auditEntry { if v, ok := c.Get("auditEntry"); ok { if ae, ok := v.(*auditEntry); ok { return ae } } return nil } // AuditSet 填充当前请求的审计字段;仅对已启用审计的请求生效。 func AuditSet(c *gin.Context, fn func(e *audit.Entry)) { if ae := AuditEntry(c); ae != nil && fn != nil { fn(&ae.Entry) } } // AuditRecordRequest 显式触发落库(含耗时);由 handler 在响应前调用。 func AuditRecordRequest(c *gin.Context, service *audit.Service, result, errMsg string) { ae := AuditEntry(c) if ae == nil || ae.recorded || ae.skip { return } ae.Entry.Duration = time.Since(ae.start) ae.Entry.Result = result ae.Entry.ErrorMsg = errMsg ae.Entry.Actor = resolveActor(c) service.Record(ae.Entry) ae.recorded = true } // AuditSkip 标记当前请求不写审计。 func AuditSkip(c *gin.Context) { if ae := AuditEntry(c); ae != nil { ae.skip = true } } // resolveActor 判断请求者角色:管理员 JWT 有效 → admin,否则 guest。 func resolveActor(c *gin.Context) string { header := c.GetHeader("Authorization") if len(header) > 7 && header[:7] == "Bearer " { // 仅检查声明是否有效,不重复校验签名逻辑(AdminAuth 已处理受保护路由) if _, ok := c.Get("claims"); ok { return audit.ActorAdmin } } return audit.ActorGuest } // AuditRecord 显式按结果落库;duration 由中间件按起始时间计算。 func AuditRecord(c *gin.Context, service *audit.Service, result, errMsg string) { ae := AuditEntry(c) if ae == nil || ae.recorded || ae.skip { return } AuditRecordRequest(c, service, result, errMsg) } // GuardNotInitialized 系统未初始化守卫:除 setup/health 外返回 428。 func GuardNotInitialized(isInit func() bool) gin.HandlerFunc { return func(c *gin.Context) { if isInit() { c.Next() return } path := c.Request.URL.Path if path == "/setup" || path == "/api/v1/health" { c.Next() return } response.Fail(c, 428, "系统未初始化,请先完成初始化") } }