- M3 认证/权限/审计/限流 + 全部 REST 接口 - M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制) - M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配) - M6 WebSocket 实时同步中心 - M7 xlsx 导出 + 赛事包 zip + 备份 - 规则包 YAML 导入导出(差异预览) - 字体 vendor 子集化(4 套 x2 字重) - 集成:realtime/export/rules 接入 main+httpapi;backup 子命令 - 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
233 lines
6.9 KiB
Go
233 lines
6.9 KiB
Go
package middleware
|
||
|
||
import (
|
||
"bufio"
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"log/slog"
|
||
"net"
|
||
"net/http"
|
||
"runtime/debug"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||
)
|
||
|
||
// HeaderRequestID 是客户端可复用的幂等/追踪请求头。
|
||
const HeaderRequestID = "X-Request-Id"
|
||
|
||
// RequestID 复用客户端传入的 request_id,没有则生成一个并写回响应头。
|
||
func RequestID(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
id := r.Header.Get(HeaderRequestID)
|
||
if id == "" {
|
||
buf := make([]byte, 8)
|
||
if _, err := rand.Read(buf); err == nil {
|
||
id = hex.EncodeToString(buf)
|
||
} else {
|
||
id = time.Now().UTC().Format("20060102150405.000000")
|
||
}
|
||
}
|
||
w.Header().Set(HeaderRequestID, id)
|
||
next.ServeHTTP(w, r.WithContext(contextWithRequestID(r.Context(), id)))
|
||
})
|
||
}
|
||
|
||
func contextWithRequestID(ctx context.Context, id string) context.Context {
|
||
return context.WithValue(ctx, ctxKeyRequestID, id)
|
||
}
|
||
|
||
// Recover 捕获 handler panic,返回统一 500,避免进程被单个请求带崩。
|
||
func Recover(logger *slog.Logger) func(http.Handler) http.Handler {
|
||
if logger == nil {
|
||
logger = slog.Default()
|
||
}
|
||
return func(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
defer func() {
|
||
if rec := recover(); rec != nil {
|
||
logger.Error("请求处理 panic",
|
||
"err", rec,
|
||
"path", r.URL.Path,
|
||
"requestId", GetRequestID(r.Context()),
|
||
"stack", string(debug.Stack()),
|
||
)
|
||
writeError(w, http.StatusInternalServerError, "INTERNAL", "服务端异常", nil)
|
||
}
|
||
}()
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
}
|
||
|
||
// Log 记录访问日志(方法、路径、状态、耗时、request_id)。
|
||
func Log(logger *slog.Logger) func(http.Handler) http.Handler {
|
||
if logger == nil {
|
||
logger = slog.Default()
|
||
}
|
||
return func(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
start := time.Now()
|
||
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
|
||
next.ServeHTTP(sw, r)
|
||
logger.Debug("http",
|
||
"method", r.Method,
|
||
"path", r.URL.Path,
|
||
"status", sw.status,
|
||
"dur", time.Since(start),
|
||
"requestId", GetRequestID(r.Context()),
|
||
"ip", ClientIP(r),
|
||
)
|
||
})
|
||
}
|
||
}
|
||
|
||
// Authenticate 解析会话 Cookie;没有或无效时按 guest 放行(只读默认身份)。
|
||
func Authenticate(svc *auth.Service) func(http.Handler) http.Handler {
|
||
return func(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
u := &auth.User{Role: auth.RoleGuest}
|
||
if c, err := r.Cookie(auth.CookieSession); err == nil && c.Value != "" {
|
||
if found, _, err := svc.Lookup(r.Context(), c.Value); err == nil {
|
||
u = found
|
||
}
|
||
}
|
||
next.ServeHTTP(w, r.WithContext(WithUser(r.Context(), u)))
|
||
})
|
||
}
|
||
}
|
||
|
||
// RequireRole 要求至少 need 权限;未登录或权限不足一律 403。
|
||
// 用于「guest 也不该看」的只读接口(系统配置),以便与未登录写操作区分。
|
||
func RequireRole(need auth.Role) func(http.Handler) http.Handler {
|
||
return func(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if !User(r.Context()).Role.Allows(need) {
|
||
writeError(w, http.StatusForbidden, "FORBIDDEN", "无权访问该资源", map[string]any{"required": need.String()})
|
||
return
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
}
|
||
|
||
// RequireWrite 要求至少 need 权限的写操作;guest 返回 401(需登录),角色不足返回 403。
|
||
func RequireWrite(need auth.Role) func(http.Handler) http.Handler {
|
||
return func(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
u := User(r.Context())
|
||
if u.Role == auth.RoleGuest {
|
||
writeError(w, http.StatusUnauthorized, "AUTH_REQUIRED", "请先登录", nil)
|
||
return
|
||
}
|
||
if !u.Role.Allows(need) {
|
||
writeError(w, http.StatusForbidden, "FORBIDDEN", "无权执行该操作", map[string]any{"required": need.String()})
|
||
return
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
}
|
||
|
||
// CSRF 校验双提交 Cookie:已登录的写请求必须带 X-CSRF-Token 且与 csrf_token Cookie 一致。
|
||
func CSRF(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
switch r.Method {
|
||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||
next.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
if User(r.Context()).Role == auth.RoleGuest {
|
||
// 未登录请求由 RequireWrite 处理,无需 CSRF。
|
||
next.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
var cookie string
|
||
if c, err := r.Cookie(auth.CookieCSRF); err == nil {
|
||
cookie = c.Value
|
||
}
|
||
if !auth.ValidCSRF(r.Header.Get("X-CSRF-Token"), cookie) {
|
||
writeError(w, http.StatusForbidden, "FORBIDDEN", "CSRF 校验失败", nil)
|
||
return
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
// RateLimitWrite 对已登录用户的写操作限流(默认 30 次/分钟/用户)。
|
||
func RateLimitWrite(svc *auth.Service) func(http.Handler) http.Handler {
|
||
return func(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
switch r.Method {
|
||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||
next.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
u := User(r.Context())
|
||
if u.Role != auth.RoleGuest && !svc.AllowWrite(u.ID) {
|
||
writeError(w, http.StatusTooManyRequests, "RATE_LIMITED", "操作过于频繁,请稍后再试", nil)
|
||
return
|
||
}
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
}
|
||
|
||
// statusWriter 记录响应状态码,供日志与审计判断是否成功。
|
||
type statusWriter struct {
|
||
http.ResponseWriter
|
||
status int
|
||
written bool
|
||
}
|
||
|
||
func (w *statusWriter) WriteHeader(code int) {
|
||
if !w.written {
|
||
w.status = code
|
||
w.written = true
|
||
}
|
||
w.ResponseWriter.WriteHeader(code)
|
||
}
|
||
|
||
func (w *statusWriter) Write(b []byte) (int, error) {
|
||
if !w.written {
|
||
w.written = true
|
||
}
|
||
return w.ResponseWriter.Write(b)
|
||
}
|
||
|
||
// Hijack 透传底层连接:WebSocket 升级依赖 http.Hijacker,
|
||
// 包装 ResponseWriter 时必须显式转发,否则握手会退化成 501。
|
||
func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||
hj, ok := w.ResponseWriter.(http.Hijacker)
|
||
if !ok {
|
||
return nil, nil, http.ErrNotSupported
|
||
}
|
||
return hj.Hijack()
|
||
}
|
||
|
||
// Flush 透传,保证流式响应不被缓冲。
|
||
func (w *statusWriter) Flush() {
|
||
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
||
f.Flush()
|
||
}
|
||
}
|
||
|
||
// writeError 输出统一错误结构;中间件不依赖 httpapi,避免包循环。
|
||
func writeError(w http.ResponseWriter, status int, code, message string, detail any) {
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.WriteHeader(status)
|
||
body := map[string]any{
|
||
"ok": false,
|
||
"error": map[string]any{
|
||
"code": code,
|
||
"message": message,
|
||
},
|
||
}
|
||
if detail != nil {
|
||
body["error"].(map[string]any)["detail"] = detail
|
||
}
|
||
_ = json.NewEncoder(w).Encode(body)
|
||
}
|