鉴权只认 cookie、SEO 只走 Next、sort 与推荐对齐;删除 Bearer、Go sitemap、post_type 回填等冗余路径。 Co-authored-by: Cursor <cursoragent@cursor.com>
122 lines
3.7 KiB
Go
122 lines
3.7 KiB
Go
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
|
||
}
|