Files
jiang13-bbs/backend/handler/realtime.go
freefire 833bcd33a1 chore: 去掉未上线前的旧兼容垫片,板块流收口到首页
鉴权只认 cookie、SEO 只走 Next、sort 与推荐对齐;删除 Bearer、Go sitemap、post_type 回填等冗余路径。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 05:04:47 +08:00

122 lines
3.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(浏览器握手自动携带);
// 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 == "" {
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(浏览器 WS 握手总会带;拒绝空 Origin 的脚本/非浏览器探测)
// - dev:允许 localhost / 127.0.0.1 任意端口(页面 :3000 直连后端 :3001,端口不同但 cookie 仍会带上)
// - 生产:Origin 的 host 必须与请求 Host 完全一致(需反向代理把页面与 /api/ws 挂到同一 Host)
func (h *Handlers) checkWSOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return false
}
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
}