fix: 管理会话自动恢复与访问来源统计优化
- 新增 AdminSessionRecover:后台离线/后端重启时自动重试恢复会话,明确失效才回跳登录 - 认证 cookie 契约调整(SameSite=Lax 与刷新轮转适配),路由与守卫适配 - 访问来源统计查询优化与测试、管理端 analytics sources 页面适配 - 移动端布局修正:覆盖 body min-h-screen 避免内容区高度异常
This commit is contained in:
@@ -242,13 +242,21 @@ func (h *Handlers) Refresh(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
accessToken, newRefresh, user, err := h.Auth.RotateRefreshToken(refreshToken, c.ClientIP(), c.Request.UserAgent())
|
accessToken, newRefresh, user, err := h.Auth.RotateRefreshToken(refreshToken, c.ClientIP(), c.Request.UserAgent())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
clearAuthCookies(c, !h.Cfg.DevMode)
|
|
||||||
if errors.Is(err, service.ErrAccountBanned) {
|
if errors.Is(err, service.ErrAccountBanned) {
|
||||||
// 账号已封禁:清 cookie 的同时给出可识别 code,前端弹封禁告知并强制下线
|
// 账号已封禁:清 cookie 的同时给出可识别 code,前端弹封禁告知并强制下线
|
||||||
|
clearAuthCookies(c, !h.Cfg.DevMode)
|
||||||
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被封禁", "code": "account_banned"})
|
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被封禁", "code": "account_banned"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
|
if errors.Is(err, service.ErrRefreshInvalid) || errors.Is(err, service.ErrRefreshExpired) {
|
||||||
|
// 登录态确实失效:清 cookie 要求重新登录(前端弹"登录已失效")
|
||||||
|
clearAuthCookies(c, !h.Cfg.DevMode)
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 其他错误(数据库抖动等):服务暂不可用。保留 cookie 等下次刷新,
|
||||||
|
// 前端按瞬态失败处理(不弹"登录已失效"、不强制重登)
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "登录刷新暂不可用,请稍后重试"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setAuthCookies(c, accessToken, newRefresh, !h.Cfg.DevMode)
|
setAuthCookies(c, accessToken, newRefresh, !h.Cfg.DevMode)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/middleware"
|
"github.com/freefire/jiang13-bbs/middleware"
|
||||||
@@ -16,13 +17,20 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxSearchKeywordRunes 帖子搜索关键词长度上限(字符数,与前端搜索框 maxLength 一致)
|
||||||
|
const maxSearchKeywordRunes = 100
|
||||||
|
|
||||||
// Posts 获取帖子列表
|
// Posts 获取帖子列表
|
||||||
func (h *Handlers) Posts(c *gin.Context) {
|
func (h *Handlers) Posts(c *gin.Context) {
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||||
boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64)
|
boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64)
|
||||||
sort := c.DefaultQuery("sort", "latest")
|
sort := c.DefaultQuery("sort", "latest")
|
||||||
keyword := c.Query("keyword")
|
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||||
|
// 搜索词上限:前端输入框同限;超长词 ILIKE 必然无意义结果,静默截断
|
||||||
|
if runes := []rune(keyword); len(runes) > maxSearchKeywordRunes {
|
||||||
|
keyword = string(runes[:maxSearchKeywordRunes])
|
||||||
|
}
|
||||||
recommended := c.Query("recommended") == "true"
|
recommended := c.Query("recommended") == "true"
|
||||||
|
|
||||||
var viewerID uint
|
var viewerID uint
|
||||||
|
|||||||
@@ -120,9 +120,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
}
|
}
|
||||||
limiter := service.DefaultRateLimiter()
|
limiter := service.DefaultRateLimiter()
|
||||||
|
|
||||||
// 访问统计管道:攒批落库 + 保留期滚动清理(随进程生命周期)
|
// 访问统计管道:攒批落库 + 保留期滚动清理(随进程生命周期)。
|
||||||
pipeCtx, pipeCancel := context.WithCancel(context.Background())
|
// 不用 WithCancel+defer:Setup 返回即 cancel 会让落库协程在启动阶段就退出,事件永远不落库
|
||||||
defer pipeCancel()
|
pipeCtx := context.Background()
|
||||||
go visitSvc.StartFlusher(pipeCtx)
|
go visitSvc.StartFlusher(pipeCtx)
|
||||||
go visitSvc.RunRetention(pipeCtx)
|
go visitSvc.RunRetention(pipeCtx)
|
||||||
|
|
||||||
|
|||||||
@@ -282,7 +282,9 @@ var (
|
|||||||
var (
|
var (
|
||||||
ErrRefreshInvalid = errors.New("refresh token 无效")
|
ErrRefreshInvalid = errors.New("refresh token 无效")
|
||||||
ErrRefreshExpired = errors.New("refresh token 已过期")
|
ErrRefreshExpired = errors.New("refresh token 已过期")
|
||||||
ErrRefreshReused = errors.New("refresh token 存在重用嫌疑")
|
// errReplayChainDead 宽限期外重放时,token 所在轮转链已无有效头节点
|
||||||
|
// (家族被整体吊销:设备剔除/超限淘汰/改密/封禁),属真实失效
|
||||||
|
errReplayChainDead = errors.New("refresh token 轮转链已无有效会话")
|
||||||
)
|
)
|
||||||
|
|
||||||
// hashRefreshToken refresh token 只以 SHA-256 形式落库
|
// hashRefreshToken refresh token 只以 SHA-256 形式落库
|
||||||
@@ -401,7 +403,8 @@ func (s *AuthService) ValidateRefreshToken(token string) (*model.User, error) {
|
|||||||
// - 正常:旧行吊销并链接到新行,返回新 access + 新 refresh
|
// - 正常:旧行吊销并链接到新行,返回新 access + 新 refresh
|
||||||
// - 宽限期并发重放(RSC 预取/多实例几乎同时到达):返回【同一个】新 token 对,
|
// - 宽限期并发重放(RSC 预取/多实例几乎同时到达):返回【同一个】新 token 对,
|
||||||
// 不再轮转,避免浏览器收到互相吊销的两套 cookie 而随机掉线
|
// 不再轮转,避免浏览器收到互相吊销的两套 cookie 而随机掉线
|
||||||
// - 吊销后重放(被盗信号):撤销该用户全部 refresh token,强制重新登录
|
// - 宽限期外重放(轮换响应丢失后浏览器补投旧 token):轮转链头节点仍健康时
|
||||||
|
// 返回头节点 token 对自愈放行;整链已死才是真失效(见 healReplayFromHead)
|
||||||
func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, string, *model.User, error) {
|
func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, string, *model.User, error) {
|
||||||
var (
|
var (
|
||||||
accessToken, newRefresh string
|
accessToken, newRefresh string
|
||||||
@@ -412,6 +415,11 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin
|
|||||||
var rt model.RefreshToken
|
var rt model.RefreshToken
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
Where("token_hash = ?", hashRefreshToken(oldToken)).First(&rt).Error; err != nil {
|
Where("token_hash = ?", hashRefreshToken(oldToken)).First(&rt).Error; err != nil {
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
// 数据库故障 ≠ 凭据无效:透传给上层按"服务暂不可用"处理,
|
||||||
|
// 不得据此清除用户 cookie(否则 DB 抖动会变成全员强制重登)
|
||||||
|
return err
|
||||||
|
}
|
||||||
outErr = ErrRefreshInvalid
|
outErr = ErrRefreshInvalid
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -441,17 +449,24 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 其他吊销后重放:判定为盗用,仅撤销该轮转链所在家族(OAuth BCP:
|
// 宽限期外重放:真实浏览器里最常见成因不是盗用,而是轮换响应丢失
|
||||||
// 被盗的是这台设备的链,不牵连用户其余登录设备——否则开发期
|
// (RSC 预取被浏览器中止、移动端切后台挂起截断请求、middleware 超时
|
||||||
// middleware/浏览器丢轮换的竞态会把所有设备一齐踢下线)
|
// 放弃、网络抖动)——后端已完成轮转落库,但 Set-Cookie 没进浏览器
|
||||||
fam := sessionFamilyID(rt)
|
// jar,旧 token 因此变成"已吊销"。若按盗用吊销整个家族,等价于把
|
||||||
if err := tx.Model(&model.RefreshToken{}).
|
// 无辜设备强制下线("登录已失效"弹窗的主要来源)。故沿 rotated_to
|
||||||
Where("(family_id = ? OR id = ?) AND revoked = ?", fam, fam, false).
|
// 链找头节点:仍健康则补发头节点 token 对自愈;整链已死才是真失效。
|
||||||
Updates(map[string]any{"revoked": true, "token_cipher": "", "updated_at": now}).Error; err != nil {
|
at, plain, healedUser, healErr := s.healReplayFromHead(tx, &rt, now)
|
||||||
return err
|
if healErr == nil {
|
||||||
|
accessToken, newRefresh, outUser = at, plain, healedUser
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
log.Printf("[auth] 检测到 refresh token 吊销后重放,已撤销 family=%d 的 refresh token", fam)
|
if !errors.Is(healErr, errReplayChainDead) {
|
||||||
outErr = ErrRefreshReused
|
// 封禁等账号级错误透传,让前端给出对应告知
|
||||||
|
outErr = healErr
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Printf("[auth] refresh 宽限期外重放且轮转链已失效,拒绝 family=%d", sessionFamilyID(rt))
|
||||||
|
outErr = ErrRefreshInvalid
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,6 +525,46 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin
|
|||||||
return accessToken, newRefresh, outUser, nil
|
return accessToken, newRefresh, outUser, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// healReplayFromHead 宽限期外的同链重放自愈:沿 rotated_to 轮转链走到头节点
|
||||||
|
// (该会话家族当前生效的 refresh token),头节点仍健康则返回其 token 对——
|
||||||
|
// 等价于把丢失的轮换响应补投给浏览器。链已断/头已死(家族被整体吊销)时
|
||||||
|
// 返回 errReplayChainDead;用户被封禁时透传 ErrAccountBanned。
|
||||||
|
// 安全取舍:放行重放意味着偷到旧 token 者也能追上当前会话,故仅在此处用
|
||||||
|
// 可用性换安全(HttpOnly cookie 下旧 token 外泄门槛高,且改密/封禁/剔除
|
||||||
|
// 设备仍可随时终止会话);头部吊销型处置(改密、封禁、剔除、超限淘汰)
|
||||||
|
// 之后链上无活头节点,自愈自然失效,不会被绕过。
|
||||||
|
func (s *AuthService) healReplayFromHead(tx *gorm.DB, rt *model.RefreshToken, now time.Time) (string, string, *model.User, error) {
|
||||||
|
cur := *rt
|
||||||
|
// 迭代上限防御脏数据成环;正常链长即 7 天寿命内的轮转次数
|
||||||
|
for i := 0; cur.Revoked && i < 128; i++ {
|
||||||
|
if cur.RotatedTo == 0 {
|
||||||
|
return "", "", nil, errReplayChainDead
|
||||||
|
}
|
||||||
|
var next model.RefreshToken
|
||||||
|
if err := tx.First(&next, cur.RotatedTo).Error; err != nil {
|
||||||
|
return "", "", nil, errReplayChainDead
|
||||||
|
}
|
||||||
|
cur = next
|
||||||
|
}
|
||||||
|
if cur.Revoked || cur.UserID != rt.UserID || now.After(cur.ExpiresAt) || cur.TokenCipher == "" {
|
||||||
|
return "", "", nil, errReplayChainDead
|
||||||
|
}
|
||||||
|
user, err := s.loadActiveUser(tx, cur.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", nil, err
|
||||||
|
}
|
||||||
|
plain, err := s.decryptToken(cur.TokenCipher)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", nil, errReplayChainDead
|
||||||
|
}
|
||||||
|
at, err := s.generateToken(user, sessionFamilyID(cur))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", nil, err
|
||||||
|
}
|
||||||
|
log.Printf("[auth] refresh 宽限期外重放,轮转链头节点仍健康,自愈放行 family=%d", sessionFamilyID(cur))
|
||||||
|
return at, plain, user, nil
|
||||||
|
}
|
||||||
|
|
||||||
// RevokeRefreshToken 撤销单个 refresh token(单设备登出时用)
|
// RevokeRefreshToken 撤销单个 refresh token(单设备登出时用)
|
||||||
func (s *AuthService) RevokeRefreshToken(token string) error {
|
func (s *AuthService) RevokeRefreshToken(token string) error {
|
||||||
if token == "" {
|
if token == "" {
|
||||||
|
|||||||
@@ -188,10 +188,10 @@ func TestRevokeLoginDeviceWithMultiSessions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRefreshReuseRevokesFamilyOnly 宽限期外的重放判定盗用时,仅吊销该轮转链
|
// TestRefreshReplayBeyondGraceSelfHeals 宽限期外的同链重放:最常见成因是
|
||||||
// 所在家族,不牵连同用户其他登录设备(此前误撤全用户会话:开发期 middleware
|
// 轮换响应丢失(RSC 预取被中止、移动端切后台挂起等),而非盗用。重放应补发
|
||||||
// /浏览器丢轮换的竞态会把 Chrome 等所有设备一齐踢下线)。
|
// 轮转链头节点 token 对自愈,家族保持活跃,也不牵连同用户其他登录设备。
|
||||||
func TestRefreshReuseRevokesFamilyOnly(t *testing.T) {
|
func TestRefreshReplayBeyondGraceSelfHeals(t *testing.T) {
|
||||||
db := newLoginSessionTestDB(t)
|
db := newLoginSessionTestDB(t)
|
||||||
s := NewAuthService(db, "test-secret")
|
s := NewAuthService(db, "test-secret")
|
||||||
user := newLoginSessionUser(t, db, "dave")
|
user := newLoginSessionUser(t, db, "dave")
|
||||||
@@ -204,7 +204,7 @@ func TestRefreshReuseRevokesFamilyOnly(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("parse access 1: %v", err)
|
t.Fatalf("parse access 1: %v", err)
|
||||||
}
|
}
|
||||||
access2, refresh2, _, _, err := s.Login("dave", "password123", "2.2.2.2", "iPhone-Mobile")
|
access2, _, _, _, err := s.Login("dave", "password123", "2.2.2.2", "iPhone-Mobile")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("login 2: %v", err)
|
t.Fatalf("login 2: %v", err)
|
||||||
}
|
}
|
||||||
@@ -213,8 +213,9 @@ func TestRefreshReuseRevokesFamilyOnly(t *testing.T) {
|
|||||||
t.Fatalf("parse access 2: %v", err)
|
t.Fatalf("parse access 2: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 家族1 正常轮转一次:旧行吊销并链接到新行
|
// 家族1 正常轮转一次:旧行吊销并链接到新行,得到头节点 refresh2
|
||||||
if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); err != nil {
|
_, refresh2, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home")
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("rotate 1: %v", err)
|
t.Fatalf("rotate 1: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,21 +226,102 @@ func TestRefreshReuseRevokesFamilyOnly(t *testing.T) {
|
|||||||
t.Fatalf("backdate rotated_at: %v", err)
|
t.Fatalf("backdate rotated_at: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 宽限期外重放:判定盗用,仅家族1 被整体吊销
|
// 宽限期外重放:自愈放行,返回头节点(当前代)token 对
|
||||||
if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); !errors.Is(err, ErrRefreshReused) {
|
at, replayRefresh, replayUser, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home")
|
||||||
t.Fatalf("期望 ErrRefreshReused,实际 %v", err)
|
if err != nil {
|
||||||
|
t.Fatalf("宽限期外重放应自愈放行,实际 %v", err)
|
||||||
}
|
}
|
||||||
if s.familySessionActive(user.ID, claims1.FamilyID) {
|
if replayRefresh != refresh2 {
|
||||||
t.Fatal("重放所在家族应被整体吊销")
|
t.Fatal("自愈应返回轮转链头节点的 refresh token")
|
||||||
|
}
|
||||||
|
if replayUser == nil || replayUser.ID != user.ID {
|
||||||
|
t.Fatal("自愈应返回所属用户")
|
||||||
|
}
|
||||||
|
if _, err := s.ValidateRefreshToken(refresh2); err != nil {
|
||||||
|
t.Fatalf("头节点 refresh 应保持有效: %v", err)
|
||||||
|
}
|
||||||
|
if !s.familySessionActive(user.ID, claims1.FamilyID) {
|
||||||
|
t.Fatal("重放所在家族应保持活跃,不被吊销")
|
||||||
}
|
}
|
||||||
if !s.familySessionActive(user.ID, claims2.FamilyID) {
|
if !s.familySessionActive(user.ID, claims2.FamilyID) {
|
||||||
t.Fatal("其他登录设备(家族)不应被牵连")
|
t.Fatal("其他登录设备(家族)不应被牵连")
|
||||||
}
|
}
|
||||||
if n := countActiveSessions(t, db, user.ID); n != 1 {
|
if n := countActiveSessions(t, db, user.ID); n != 2 {
|
||||||
t.Fatalf("应仅剩家族2 的 1 个活跃会话,实际 %d", n)
|
t.Fatalf("两个家族应共 2 个活跃会话,实际 %d", n)
|
||||||
}
|
}
|
||||||
if _, err := s.ValidateRefreshToken(refresh2); err != nil {
|
if claims, err := s.ParseToken(at); err != nil {
|
||||||
t.Fatalf("其他设备的 refresh 不应受影响: %v", err)
|
t.Fatalf("自愈签发的 access token 应可解析: %v", err)
|
||||||
|
} else if _, err := s.ValidateClaims(claims); err != nil {
|
||||||
|
t.Fatalf("自愈签发的 access token 应有效: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRefreshReplayMultiHopHealsToHead 轮转链已前进多代后重放最早一代:
|
||||||
|
// 应沿 rotated_to 链走到头节点,补发最新一代 token 对。
|
||||||
|
func TestRefreshReplayMultiHopHealsToHead(t *testing.T) {
|
||||||
|
db := newLoginSessionTestDB(t)
|
||||||
|
s := NewAuthService(db, "test-secret")
|
||||||
|
newLoginSessionUser(t, db, "frank")
|
||||||
|
|
||||||
|
_, refresh1, _, _, err := s.Login("frank", "password123", "1.1.1.1", "Chrome-Home")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("login: %v", err)
|
||||||
|
}
|
||||||
|
_, refresh2, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rotate 1: %v", err)
|
||||||
|
}
|
||||||
|
_, refresh3, _, err := s.RotateRefreshToken(refresh2, "1.1.1.1", "Chrome-Home")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rotate 2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 各代 rotated_at 均拨回宽限期之外,模拟浏览器长时间后才补投旧 token
|
||||||
|
if err := db.Model(&model.RefreshToken{}).
|
||||||
|
Where("token_hash IN ?", []string{hashRefreshToken(refresh1), hashRefreshToken(refresh2)}).
|
||||||
|
Update("rotated_at", time.Now().Add(-2*time.Minute)).Error; err != nil {
|
||||||
|
t.Fatalf("backdate rotated_at: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, replayRefresh, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("多级链重放应自愈到头节点,实际 %v", err)
|
||||||
|
}
|
||||||
|
if replayRefresh != refresh3 {
|
||||||
|
t.Fatal("自愈应返回最新一代(头节点)的 refresh token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRefreshReplayDeadChainRejected 轮转链已无有效头节点(家族被整体吊销:
|
||||||
|
// 设备剔除/超限淘汰/改密/封禁)时,宽限期外重放属真实失效,应拒绝并返回
|
||||||
|
// ErrRefreshInvalid,且不得复活任何会话。
|
||||||
|
func TestRefreshReplayDeadChainRejected(t *testing.T) {
|
||||||
|
db := newLoginSessionTestDB(t)
|
||||||
|
s := NewAuthService(db, "test-secret")
|
||||||
|
user := newLoginSessionUser(t, db, "grace")
|
||||||
|
|
||||||
|
_, refresh1, _, _, err := s.Login("grace", "password123", "1.1.1.1", "Chrome-Home")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("login: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); err != nil {
|
||||||
|
t.Fatalf("rotate: %v", err)
|
||||||
|
}
|
||||||
|
// 模拟改密/封禁/剔除设备类处置:家族整体吊销
|
||||||
|
if err := s.RevokeAllUserRefreshTokens(user.ID); err != nil {
|
||||||
|
t.Fatalf("revoke all: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.Model(&model.RefreshToken{}).
|
||||||
|
Where("token_hash = ?", hashRefreshToken(refresh1)).
|
||||||
|
Update("rotated_at", time.Now().Add(-2*time.Minute)).Error; err != nil {
|
||||||
|
t.Fatalf("backdate rotated_at: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); !errors.Is(err, ErrRefreshInvalid) {
|
||||||
|
t.Fatalf("死链重放应返回 ErrRefreshInvalid,实际 %v", err)
|
||||||
|
}
|
||||||
|
if n := countActiveSessions(t, db, user.ID); n != 0 {
|
||||||
|
t.Fatalf("死链自愈不得复活会话,活跃会话应仍为 0,实际 %d", n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,3 +29,26 @@ func TestClassifyReferrer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDecodeSearchTerm(t *testing.T) {
|
||||||
|
// 「新人报道」的 UTF-8 百分号编码前缀,模拟 512 字节截断
|
||||||
|
news := "%E6%96%B0%E4%BA%BA%E6%8A%A5%E9%81%93"
|
||||||
|
cases := []struct {
|
||||||
|
raw string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"%E4%BD%A0%E5%A5%BD", "你好"}, // 完整编码
|
||||||
|
{"a+b", "a b"}, // + 按空格解码
|
||||||
|
{"plain", "plain"}, // 无编码
|
||||||
|
{"2222%E5%A6%82%E6%9E%9C", "2222如果"}, // 完整转义结尾
|
||||||
|
{news + "%E6%96", "新人报道"}, // 截断后结尾是完整转义但只是半截字符 → 去非法 UTF-8 尾巴
|
||||||
|
{news + "%E6%96%9", "新人报道"}, // 一位十六进制的残缺转义 → 剥尾重试 + 去半截字符
|
||||||
|
{news + "%", "新人报道"}, // 截断在孤立的 %
|
||||||
|
{news + "%E", "新人报道"}, // 截断在 1 位十六进制
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := decodeSearchTerm(c.raw); got != c.want {
|
||||||
|
t.Errorf("decodeSearchTerm(%q) = %q, want %q", c.raw, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/freefire/jiang13-bbs/model"
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -88,11 +91,11 @@ type VisitSourcesKindDay struct {
|
|||||||
|
|
||||||
// VisitSources 来源分析 Tab
|
// VisitSources 来源分析 Tab
|
||||||
type VisitSources struct {
|
type VisitSources struct {
|
||||||
RangeDays int `json:"range_days"`
|
RangeDays int `json:"range_days"`
|
||||||
Kinds []VisitNamedCount `json:"kinds"`
|
Kinds []VisitNamedCount `json:"kinds"`
|
||||||
SearchHost []VisitNamedCount `json:"search_hosts"`
|
SearchTerms []VisitNamedCount `json:"search_terms"` // 站内搜索词 Top N(/?q= 结果页浏览)
|
||||||
External []VisitNamedCount `json:"external"`
|
External []VisitNamedCount `json:"external"`
|
||||||
Trend []VisitSourcesKindDay `json:"trend"`
|
Trend []VisitSourcesKindDay `json:"trend"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// VisitPages 受访页面 Tab
|
// VisitPages 受访页面 Tab
|
||||||
@@ -336,8 +339,7 @@ func (s *VisitStatsService) Sources(rangeQ string) (*VisitSources, error) {
|
|||||||
if out.Kinds, err = s.groupCounts(start, "ref_kind", model.VisitKindPageview, "", 8); err != nil {
|
if out.Kinds, err = s.groupCounts(start, "ref_kind", model.VisitKindPageview, "", 8); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if out.SearchHost, err = s.groupCounts(start, "ref_host", model.VisitKindPageview,
|
if out.SearchTerms, err = s.siteSearchTerms(start, 10); err != nil {
|
||||||
"ref_kind = '"+RefKindSearch+"'", 20); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if out.External, err = s.groupCounts(start, "ref_host", model.VisitKindPageview,
|
if out.External, err = s.groupCounts(start, "ref_host", model.VisitKindPageview,
|
||||||
@@ -350,6 +352,64 @@ func (s *VisitStatsService) Sources(rangeQ string) (*VisitSources, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// siteSearchTerms 站内搜索词:聚合搜索结果页(/?q=…,path 记录完整 query)的浏览量。
|
||||||
|
// 词以 URL 编码存于 path,按编码串分组后解码展示(站内 encodeURIComponent 编码确定性强)。
|
||||||
|
func (s *VisitStatsService) siteSearchTerms(start time.Time, limit int) ([]VisitNamedCount, error) {
|
||||||
|
const expr = "substring(path from '[?&]q=([^&]*)')"
|
||||||
|
var rows []struct {
|
||||||
|
Key string
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
if err := s.db.Model(&model.VisitEvent{}).
|
||||||
|
Select(expr+" AS key, COUNT(*) AS count").
|
||||||
|
Where("created_at >= ? AND kind = ?", start, model.VisitKindPageview).
|
||||||
|
Where(expr + " IS NOT NULL AND " + expr + " <> ''").
|
||||||
|
Group("key").Order("count DESC").Limit(limit).
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]VisitNamedCount, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
term := decodeSearchTerm(r.Key)
|
||||||
|
if runes := []rune(term); len(runes) > 30 {
|
||||||
|
term = string(runes[:30]) + "…"
|
||||||
|
}
|
||||||
|
out = append(out, VisitNamedCount{Key: term, Label: term, Count: r.Count})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeSearchTerm 解码 path 里 q 参数值(URL 编码)。path 512 字节截断可能:
|
||||||
|
// 1) 把 %XX 转义拦腰切断(孤 % / 一位十六进制)→ 整串解码失败,剥掉残缺尾部重试;
|
||||||
|
// 2) 恰好切在多字节字符的转义序列中间 → 解码「成功」但尾部是半截字符,去掉非法 UTF-8 尾巴。
|
||||||
|
func decodeSearchTerm(raw string) string {
|
||||||
|
term := strings.ReplaceAll(raw, "+", " ")
|
||||||
|
dec, err := url.QueryUnescape(term)
|
||||||
|
if err != nil {
|
||||||
|
// 残缺转义(如结尾孤 % 或一位十六进制):剥掉最后一个 % 起的尾部重试
|
||||||
|
if i := strings.LastIndex(term, "%"); i > 0 {
|
||||||
|
dec, err = url.QueryUnescape(term[:i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return term
|
||||||
|
}
|
||||||
|
return validUTF8Prefix(dec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// validUTF8Prefix 返回 s 的最长合法 UTF-8 前缀(截断产生的半截多字节字符会被去掉;
|
||||||
|
// 真实的 U+FFFD 字符合法保留,与非法字节可区分)。
|
||||||
|
func validUTF8Prefix(s string) string {
|
||||||
|
for i := 0; i < len(s); {
|
||||||
|
r, size := utf8.DecodeRuneInString(s[i:])
|
||||||
|
if r == utf8.RuneError && size == 1 {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
i += size
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// SourcesTrend 来源按日明细(kind 分列行)
|
// SourcesTrend 来源按日明细(kind 分列行)
|
||||||
func (s *VisitStatsService) SourcesTrend(rangeQ string) ([]VisitSourcesKindDay, error) {
|
func (s *VisitStatsService) SourcesTrend(rangeQ string) ([]VisitSourcesKindDay, error) {
|
||||||
days := parseRangeDays(rangeQ)
|
days := parseRangeDays(rangeQ)
|
||||||
|
|||||||
141
frontend/app/admin/AdminSessionRecover.tsx
Normal file
141
frontend/app/admin/AdminSessionRecover.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Loader2, RefreshCw, ShieldAlert } from "lucide-react";
|
||||||
|
import { apiMe, apiRefresh } from "@/lib/api";
|
||||||
|
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台布局降级自愈:SSR 侧 /api/me 未识别出用户(access 过期轮转竞态、后端
|
||||||
|
* 瞬断等)时布局只能渲染无壳的降级卡片。本组件挂载后走客户端链路复判——
|
||||||
|
* apiMe 内部会在 401 时经 fetchWithRefresh 自动续期后重试:
|
||||||
|
* - 复判出用户 → router.refresh() 让服务端重出完整后台壳(顶栏/抽屉回归);
|
||||||
|
* - 仍是游客且凭据已被清理 → 会话确实失效,带回归地址去登录页;
|
||||||
|
* - 仍是游客但凭据还在(refresh 瞬态失败/后端暂不可用)→ 转入 offline 态,
|
||||||
|
* 有限次自动重试(间隔 5s),期间随时可手动重试/重新登录。
|
||||||
|
* refresh 成功但服务端仍识别失败时最多重试 3 次,超出转入手动重试,避免循环刷新。
|
||||||
|
*/
|
||||||
|
const MAX_AUTO_REFRESH = 3;
|
||||||
|
// offline 态自动重试次数(间隔 5s,共 50s):后端短暂抖动/重启场景无需用户手动干预
|
||||||
|
const MAX_AUTO_RETRY = 10;
|
||||||
|
const AUTO_RETRY_DELAY_MS = 5000;
|
||||||
|
|
||||||
|
export default function AdminSessionRecover() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [state, setState] = useState<"recovering" | "offline">("recovering");
|
||||||
|
const autoRefreshLeft = useRef(MAX_AUTO_REFRESH);
|
||||||
|
const autoRetryLeft = useRef(MAX_AUTO_RETRY);
|
||||||
|
|
||||||
|
const recover = useCallback(() => {
|
||||||
|
setState("recovering");
|
||||||
|
apiMe()
|
||||||
|
.then(async (res) => {
|
||||||
|
if (res.user) {
|
||||||
|
if (autoRefreshLeft.current > 0) {
|
||||||
|
autoRefreshLeft.current -= 1;
|
||||||
|
router.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState("offline");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// apiMe 内部的续期复判以 sessionSeen 为门(本页从未识别出用户时为 false,
|
||||||
|
// 不会触发),这里显式补一轮 refresh:access 竞态/瞬断多数在此恢复
|
||||||
|
const back = encodeURIComponent(location.pathname + location.search);
|
||||||
|
if (hasAuthCookieHint()) {
|
||||||
|
const refreshed = await apiRefresh();
|
||||||
|
if (refreshed.ok) {
|
||||||
|
const again = await apiMe();
|
||||||
|
if (again.user) {
|
||||||
|
if (autoRefreshLeft.current > 0) {
|
||||||
|
autoRefreshLeft.current -= 1;
|
||||||
|
router.refresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState("offline");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState("offline");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (refreshed.transient) {
|
||||||
|
// 后端暂不可用:无法证明登录态失效,留在原地可手动重试
|
||||||
|
setState("offline");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// refresh 明确失效(后端同时下发清 cookie):会话确实终结,回跳登录
|
||||||
|
router.replace(`/login?redirect=${back}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 无任何凭据痕迹:直接回跳登录
|
||||||
|
router.replace(`/login?redirect=${back}`);
|
||||||
|
})
|
||||||
|
.catch(() => setState("offline"));
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
recover();
|
||||||
|
}, [recover]);
|
||||||
|
|
||||||
|
// offline 态有限次自动重试:后端短暂抖动/重启期间留在本页轮询恢复,
|
||||||
|
// 次数用尽后仅手动重试可再触发,避免无限轮询;手动重试会重置次数。
|
||||||
|
useEffect(() => {
|
||||||
|
if (state !== "offline") return;
|
||||||
|
if (autoRetryLeft.current <= 0) return;
|
||||||
|
autoRetryLeft.current -= 1;
|
||||||
|
const t = window.setTimeout(recover, AUTO_RETRY_DELAY_MS);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [state, recover]);
|
||||||
|
|
||||||
|
const loginHref =
|
||||||
|
state === "offline" && typeof window !== "undefined"
|
||||||
|
? `/login?redirect=${encodeURIComponent(location.pathname + location.search)}`
|
||||||
|
: "/login";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-md w-full panel p-8 text-center">
|
||||||
|
<span
|
||||||
|
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
|
||||||
|
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
|
||||||
|
>
|
||||||
|
<ShieldAlert size={26} />
|
||||||
|
</span>
|
||||||
|
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
|
||||||
|
正在恢复登录状态
|
||||||
|
</h1>
|
||||||
|
{state === "recovering" ? (
|
||||||
|
<p className="meta mt-2 text-[13px] inline-flex items-center justify-center gap-2">
|
||||||
|
<Loader2 size={14} className="animate-spin" aria-hidden />
|
||||||
|
正在确认会话,稍候将自动进入后台…
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="meta mt-2 text-[13px]">暂时无法确认登录状态,请稍后重试。</p>
|
||||||
|
<div className="flex items-center justify-center gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
autoRefreshLeft.current = MAX_AUTO_REFRESH;
|
||||||
|
autoRetryLeft.current = MAX_AUTO_RETRY;
|
||||||
|
recover();
|
||||||
|
}}
|
||||||
|
className="btn btn-primary inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} aria-hidden /> 重试
|
||||||
|
</button>
|
||||||
|
<Link href={loginHref} className="btn btn-line">
|
||||||
|
重新登录
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<p className="mt-4">
|
||||||
|
<Link href="/" className="meta text-[13px]">
|
||||||
|
返回首页
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { AdminChartCard, NamedBarChart, NamedPieChart } from "@/components/admin/charts";
|
import { AdminChartCard, NamedBarChart, NamedPieChart } from "@/components/admin/charts";
|
||||||
import { apiStatsSources, type VisitSources } from "@/lib/api";
|
import { apiStatsSources, type VisitSources } from "@/lib/api";
|
||||||
import { RangeSegmented, StatsState, TableEmpty, refKindLabel } from "../ui";
|
import { RangeSegmented, StatsState, TableEmpty, formatInt, refKindLabel } from "../ui";
|
||||||
|
|
||||||
/** 来源按日表:固定展示四类主要来源列 */
|
/** 来源按日表:固定展示四类主要来源列 */
|
||||||
const TREND_KINDS = ["direct", "search", "social", "external"] as const;
|
const TREND_KINDS = ["direct", "search", "social", "external"] as const;
|
||||||
@@ -49,11 +49,25 @@ export default function SourcesPage() {
|
|||||||
</AdminChartCard>
|
</AdminChartCard>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<AdminChartCard title="搜索引擎明细" hint="来自搜索结果页的浏览 Top 10">
|
<AdminChartCard title="站内搜索词 Top 10" hint="搜索结果页(/?q=)浏览量 Top 10">
|
||||||
{data.search_hosts.length ? (
|
{data.search_terms.length ? (
|
||||||
<NamedBarChart data={data.search_hosts.map((h) => ({ label: h.key, count: h.count }))} />
|
<ul className="flex flex-col divide-y max-h-[360px] overflow-y-auto" style={{ borderColor: "var(--line)" }}>
|
||||||
|
{data.search_terms.map((t, i) => (
|
||||||
|
<li key={`${i}-${t.key}`} className="py-2 flex items-center gap-3 text-[13px]">
|
||||||
|
<span className="w-5 text-right tabular-nums shrink-0" style={{ color: "var(--ink-3)" }}>
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 min-w-0 truncate" style={{ color: "var(--ink)" }} title={t.key}>
|
||||||
|
{t.key}
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums shrink-0" style={{ color: "var(--ink-2)" }}>
|
||||||
|
{formatInt(t.count)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<TableEmpty text="暂无搜索引擎来源" />
|
<TableEmpty text="暂无站内搜索" />
|
||||||
)}
|
)}
|
||||||
</AdminChartCard>
|
</AdminChartCard>
|
||||||
<AdminChartCard title="外部链接明细" hint="其他站点跳转来源 Top 10">
|
<AdminChartCard title="外部链接明细" hint="其他站点跳转来源 Top 10">
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { cookies } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { ShieldAlert } from "lucide-react";
|
import { ShieldAlert } from "lucide-react";
|
||||||
import { authCookieHeader, TOKEN_COOKIE } from "@/lib/cookies";
|
import { authCookieHeader, REFRESH_COOKIE, CSRF_COOKIE, TOKEN_COOKIE } from "@/lib/cookies";
|
||||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
import { canAccessAdminMessages, isStaff } from "@/lib/roles";
|
import { canAccessAdminMessages, isStaff } from "@/lib/roles";
|
||||||
import AdminShell from "@/components/admin/AdminShell";
|
import AdminShell from "@/components/admin/AdminShell";
|
||||||
|
import AdminSessionRecover from "./AdminSessionRecover";
|
||||||
|
|
||||||
// 整个 /admin 树不进入索引(子页面无需重复声明)
|
// 整个 /admin 树不进入索引(子页面无需重复声明)
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -25,6 +27,48 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|||||||
!!me.user && (isStaff(me.user.role) || canAccessAdminMessages(me.user));
|
!!me.user && (isStaff(me.user.role) || canAccessAdminMessages(me.user));
|
||||||
|
|
||||||
if (!allowed || !me.user) {
|
if (!allowed || !me.user) {
|
||||||
|
// 已登录但无后台权限:保留提示卡片
|
||||||
|
if (me.user) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-admin-viewport
|
||||||
|
id="main"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="min-h-screen flex items-center justify-center px-4 outline-none"
|
||||||
|
>
|
||||||
|
<div className="max-w-md w-full panel p-8 text-center">
|
||||||
|
<span
|
||||||
|
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
|
||||||
|
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
|
||||||
|
>
|
||||||
|
<ShieldAlert size={26} />
|
||||||
|
</span>
|
||||||
|
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
|
||||||
|
需要管理员权限
|
||||||
|
</h1>
|
||||||
|
<p className="meta mt-2 text-[13px]">此区域仅供站点管理员访问,如有疑问请联系站长。</p>
|
||||||
|
<Link href="/" className="btn btn-primary mt-6">
|
||||||
|
返回首页
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSR 侧未识别出用户(/api/me 瞬断、access 过期轮转竞态等)时,原实现直接
|
||||||
|
// 落到无壳的权限卡片——手机端表现为「首次进入后台整块导航 header 消失」,
|
||||||
|
// 无法切换管理板块,再进一次才恢复。这里按凭据状态分流入修复:
|
||||||
|
// ① 完全无凭据:直达登录页并回跳目标地址,不再给无导航的死胡同卡片;
|
||||||
|
// ② 仍带凭据:客户端经 fetchWithRefresh 自动续期后 router.refresh 自愈。
|
||||||
|
const hasAuthHint = !!(
|
||||||
|
cookieStore.get(TOKEN_COOKIE)?.value ||
|
||||||
|
cookieStore.get(REFRESH_COOKIE)?.value ||
|
||||||
|
cookieStore.get(CSRF_COOKIE)?.value
|
||||||
|
);
|
||||||
|
if (!hasAuthHint) {
|
||||||
|
const pathname = (await headers()).get("x-pathname") || "/admin";
|
||||||
|
redirect(`/login?redirect=${encodeURIComponent(pathname)}`);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-admin-viewport
|
data-admin-viewport
|
||||||
@@ -32,21 +76,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="min-h-screen flex items-center justify-center px-4 outline-none"
|
className="min-h-screen flex items-center justify-center px-4 outline-none"
|
||||||
>
|
>
|
||||||
<div className="max-w-md w-full panel p-8 text-center">
|
<AdminSessionRecover />
|
||||||
<span
|
|
||||||
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
|
|
||||||
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
|
|
||||||
>
|
|
||||||
<ShieldAlert size={26} />
|
|
||||||
</span>
|
|
||||||
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
|
|
||||||
需要管理员权限
|
|
||||||
</h1>
|
|
||||||
<p className="meta mt-2 text-[13px]">此区域仅供站点管理员访问,如有疑问请联系站长。</p>
|
|
||||||
<Link href="/" className="btn btn-primary mt-6">
|
|
||||||
返回首页
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3177,6 +3177,9 @@ body:has([data-compose-viewport]) #main > [data-compose-viewport] {
|
|||||||
/* 聊天页:整页锁在视口内,避免「页面滚动 + 对话区滚动」双滚动条 */
|
/* 聊天页:整页锁在视口内,避免「页面滚动 + 对话区滚动」双滚动条 */
|
||||||
body:has([data-chat-viewport]) {
|
body:has([data-chat-viewport]) {
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
|
/* 覆盖 body 上的 min-h-screen(100vh):min-height 优先级高于 height/max-height,
|
||||||
|
否则移动端 body 实际高 100vh,比可见视口高出地址栏高度,输入框会被推出屏幕 */
|
||||||
|
min-height: 0;
|
||||||
max-height: 100dvh;
|
max-height: 100dvh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export default function AccountGuard() {
|
|||||||
id="session-ended-dialog-desc"
|
id="session-ended-dialog-desc"
|
||||||
className="meta text-[13px] leading-relaxed text-center"
|
className="meta text-[13px] leading-relaxed text-center"
|
||||||
>
|
>
|
||||||
你的登录会话已被移除(在安全设置中剔除,或会话数超出上限被淘汰),当前设备的登录状态已失效。
|
你的登录会话已失效(在安全设置中剔除、会话数超出上限被淘汰,或登录有效期已过)。
|
||||||
请重新登录后继续使用。
|
请重新登录后继续使用。
|
||||||
</p>
|
</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ import { useUrlStyle } from "./SiteBrandProvider";
|
|||||||
import { leaderboardPath } from "@/lib/urlStyle";
|
import { leaderboardPath } from "@/lib/urlStyle";
|
||||||
import type { BrandLogoFit, BrandLogoSize, BrandMark } from "@/lib/brand";
|
import type { BrandLogoFit, BrandLogoSize, BrandMark } from "@/lib/brand";
|
||||||
|
|
||||||
|
// 搜索关键词长度上限(与后端 handler maxSearchKeywordRunes 一致);
|
||||||
|
// 接近上限时输入框内显示 n/100 计数帮助用户感知
|
||||||
|
const SEARCH_MAX_LEN = 100;
|
||||||
|
const SEARCH_COUNT_FROM = 80;
|
||||||
|
|
||||||
export default function Header({
|
export default function Header({
|
||||||
initialUser,
|
initialUser,
|
||||||
initialUnread = 0,
|
initialUnread = 0,
|
||||||
@@ -280,18 +285,30 @@ export default function Header({
|
|||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
|
maxLength={SEARCH_MAX_LEN}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
onKeyDown={onSearchKeyDown}
|
onKeyDown={onSearchKeyDown}
|
||||||
placeholder="搜索帖子..."
|
placeholder="搜索帖子..."
|
||||||
aria-label="搜索帖子"
|
aria-label="搜索帖子"
|
||||||
className="j13-search-field w-full pl-10 pr-10 py-2.5 text-sm rounded-full"
|
className={`j13-search-field w-full pl-10 py-2.5 text-sm rounded-full ${
|
||||||
|
searchQuery.length >= SEARCH_COUNT_FROM ? "pr-16" : "pr-10"
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
<kbd
|
{searchQuery.length >= SEARCH_COUNT_FROM ? (
|
||||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-[11px] w-5 h-5 flex items-center justify-center rounded-md pointer-events-none"
|
<span
|
||||||
style={{ color: "var(--ink-3)", border: "1px solid var(--line-2)", background: "var(--bg)" }}
|
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-[11px] tabular-nums pointer-events-none"
|
||||||
>
|
style={{ color: searchQuery.length >= SEARCH_MAX_LEN ? "var(--danger)" : "var(--ink-3)" }}
|
||||||
/
|
>
|
||||||
</kbd>
|
{searchQuery.length}/{SEARCH_MAX_LEN}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<kbd
|
||||||
|
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-[11px] w-5 h-5 flex items-center justify-center rounded-md pointer-events-none"
|
||||||
|
style={{ color: "var(--ink-3)", border: "1px solid var(--line-2)", background: "var(--bg)" }}
|
||||||
|
>
|
||||||
|
/
|
||||||
|
</kbd>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<ThemeToggle initialTheme={initialTheme} className={iconBtn} style={iconBtnStyle} />
|
<ThemeToggle initialTheme={initialTheme} className={iconBtn} style={iconBtnStyle} />
|
||||||
@@ -365,6 +382,7 @@ export default function Header({
|
|||||||
autoFocus
|
autoFocus
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
|
maxLength={SEARCH_MAX_LEN}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
@@ -374,8 +392,18 @@ export default function Header({
|
|||||||
}}
|
}}
|
||||||
aria-label="搜索帖子"
|
aria-label="搜索帖子"
|
||||||
placeholder="搜索帖子..."
|
placeholder="搜索帖子..."
|
||||||
className="j13-search-field w-full pl-10 pr-4 py-3 rounded-full text-sm"
|
className={`j13-search-field w-full pl-10 py-3 rounded-full text-sm ${
|
||||||
|
searchQuery.length >= SEARCH_COUNT_FROM ? "pr-16" : "pr-4"
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
|
{searchQuery.length >= SEARCH_COUNT_FROM && (
|
||||||
|
<span
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 text-[11px] tabular-nums pointer-events-none"
|
||||||
|
style={{ color: searchQuery.length >= SEARCH_MAX_LEN ? "var(--danger)" : "var(--ink-3)" }}
|
||||||
|
>
|
||||||
|
{searchQuery.length}/{SEARCH_MAX_LEN}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,26 +1,37 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { Suspense, useEffect, useRef } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
import { apiTelemetryPageView } from "@/lib/api";
|
import { apiTelemetryPageView } from "@/lib/api";
|
||||||
|
|
||||||
/**
|
function BeaconInner() {
|
||||||
* 公开站页面访问埋点:同 path 30s 内合并;跳过 /admin。
|
|
||||||
*/
|
|
||||||
export default function VisitBeacon() {
|
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const search = useSearchParams().toString();
|
||||||
const lastRef = useRef<{ path: string; at: number }>({ path: "", at: 0 });
|
const lastRef = useRef<{ path: string; at: number }>({ path: "", at: 0 });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pathname || pathname === "/admin" || pathname.startsWith("/admin/")) return;
|
if (!pathname || pathname === "/admin" || pathname.startsWith("/admin/")) return;
|
||||||
|
// 完整 URL(含 query):query 变化也上报,站内搜索 /?q=… 才能被记录
|
||||||
|
const full = pathname + (search ? `?${search}` : "");
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (lastRef.current.path === pathname && now - lastRef.current.at < 30_000) return;
|
if (lastRef.current.path === full && now - lastRef.current.at < 30_000) return;
|
||||||
lastRef.current = { path: pathname, at: now };
|
lastRef.current = { path: full, at: now };
|
||||||
// 带 query 完整路径 + 来源页,供后台来源分析
|
// 来源页,供后台来源分析
|
||||||
const full = pathname + (typeof window !== "undefined" ? window.location.search : "");
|
|
||||||
const ref = typeof document !== "undefined" ? document.referrer : "";
|
const ref = typeof document !== "undefined" ? document.referrer : "";
|
||||||
void apiTelemetryPageView(full, ref);
|
void apiTelemetryPageView(full, ref);
|
||||||
}, [pathname]);
|
}, [pathname, search]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公开站页面访问埋点:同 URL 30s 内合并;跳过 /admin。
|
||||||
|
* useSearchParams 需 Suspense 边界(与 FooterTiming 同模式)。
|
||||||
|
*/
|
||||||
|
export default function VisitBeacon() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<BeaconInner />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -726,7 +726,7 @@ function detectBannedBody(res: Response): void {
|
|||||||
// 调用 /api/auth/refresh 刷新 access token(refresh cookie 由浏览器自动携带)。
|
// 调用 /api/auth/refresh 刷新 access token(refresh cookie 由浏览器自动携带)。
|
||||||
// transient=true 表示网络异常或后端暂不可用(含代理 5xx):此时无法证明登录态
|
// transient=true 表示网络异常或后端暂不可用(含代理 5xx):此时无法证明登录态
|
||||||
// 失效,调用方不应据此弹"登录已失效"——dev 后端 go run 重启窗口内尤其常见
|
// 失效,调用方不应据此弹"登录已失效"——dev 后端 go run 重启窗口内尤其常见
|
||||||
async function apiRefresh(): Promise<{ ok: boolean; banned: boolean; transient: boolean }> {
|
export async function apiRefresh(): Promise<{ ok: boolean; banned: boolean; transient: boolean }> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/auth/refresh", {
|
const res = await fetch("/api/auth/refresh", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -1748,7 +1748,7 @@ export interface VisitSourcesKindDay {
|
|||||||
export interface VisitSources {
|
export interface VisitSources {
|
||||||
range_days: number;
|
range_days: number;
|
||||||
kinds: VisitNamedCount[];
|
kinds: VisitNamedCount[];
|
||||||
search_hosts: VisitNamedCount[];
|
search_terms: VisitNamedCount[];
|
||||||
external: VisitNamedCount[];
|
external: VisitNamedCount[];
|
||||||
trend: VisitSourcesKindDay[];
|
trend: VisitSourcesKindDay[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user