package handler import ( "net/http" "net/url" "strconv" "strings" "time" "github.com/freefire/jiang13-bbs/realtime" "github.com/freefire/jiang13-bbs/service" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" ) // parseWSChatRoom 解析 WS 房间名 chat:{roomID} func parseWSChatRoom(room string) (uint, bool) { idStr := strings.TrimPrefix(room, "chat:") id, err := strconv.ParseUint(idStr, 10, 64) if err != nil || id == 0 { return 0, false } return uint(id), true } // 实时总线 WebSocket 入口:GET /api/ws // // 鉴权:与 REST 一致,优先读 HttpOnly 的 j13_token cookie(浏览器握手自动携带), // 回退 Authorization 头;ParseToken + ValidateClaims 实时校验 token_version 与封禁。 // WebSocket 握手是 GET 请求,天然不受 CSRF 约束,但必须严格校验 Origin, // 防止跨站页面在用户浏览器中发起握手。 func (h *Handlers) RealtimeWS(c *gin.Context) { tokenStr, err := c.Cookie(service.CookieName) if err != nil || tokenStr == "" { if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") { tokenStr = strings.TrimPrefix(auth, "Bearer ") } } if tokenStr == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"}) return } claims, err := h.Auth.ParseToken(tokenStr) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "登录态无效"}) return } if _, err := h.Auth.ValidateClaims(claims); err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "登录态已失效"}) return } upgrader := websocket.Upgrader{ HandshakeTimeout: 5 * time.Second, CheckOrigin: h.checkWSOrigin, // 子协议不传:鉴权完全走 cookie,禁止 Sec-WebSocket-Protocol 携带 token } conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { return // Upgrade 内部已写错误响应 } // 建连时取实时角色快照决定能否进入 staff 房间(角色变更后旧 JWT 会失效, // 用户重连即按新角色重新入房) isStaff := false canOverseeChat := false if actor, loadErr := h.Auth.LoadActor(claims.ID); loadErr == nil { isStaff = actor.IsStaff() canOverseeChat = service.CanOverseeChat(actor.Role) } client := realtime.NewClient( h.Hub, conn, claims.ID, isStaff, func() bool { _, verr := h.Auth.ValidateClaims(claims) return verr == nil }, func() { h.Auth.TouchLastSeen(claims.ID) }, // 动态房间订阅鉴权:成员可订阅;站长/超管旁路可订阅任意群 func(room string) bool { roomID, ok := parseWSChatRoom(room) if !ok { return false } if canOverseeChat { return true } _, err := h.Chat.Membership(roomID, claims.ID) return err == nil }, ) client.Serve(func(cl *realtime.Client) { // hello 帧:全员带 user_id/ts;staff 额外带在线用户快照用于校正绿点 data := gin.H{ "user_id": claims.ID, "ts": time.Now().Unix(), } if isStaff { data["online_user_ids"] = h.Hub.OnlineUserIDs() } cl.Send(realtime.Envelope{Type: realtime.EventHello, Data: data}) }) } // checkWSOrigin 校验握手 Origin: // - 无 Origin(非浏览器/native 客户端)放行 // - dev:允许 localhost / 127.0.0.1 任意端口(前端 :3000 直连后端 :3001) // - 生产:Origin 主机必须与请求 Host 同源 func (h *Handlers) checkWSOrigin(r *http.Request) bool { origin := r.Header.Get("Origin") if origin == "" { return true } u, err := url.Parse(origin) if err != nil || u.Scheme != "http" && u.Scheme != "https" { return false } if h.Cfg.DevMode { host := u.Hostname() return host == "localhost" || host == "127.0.0.1" } return u.Host == r.Host }