feat: SSR 安全防卫与缓存作用域化(branding/limits/pages/auth/session)

theme/useTheme 服务端防卫 window/document;branding/limits/pages 缓存读请求作用域、seed 只写作用域;homeBoot 拆出 seedHomeBoot 与帖子详情快照构建器供 prefetchRoute 复用;getHomeStoreState 改返回 state 修复命令式访问
This commit is contained in:
2026-09-09 01:05:12 +08:00
parent 86a317c862
commit 27132e89c2
10 changed files with 144 additions and 61 deletions

View File

@@ -1,6 +1,8 @@
import type { User } from '../api/types';
import { getScope } from '../ssr/scope';
/** 首页 SSR boot 注入的鉴权种子(consumeHomeBoot 写入;AuthProvider 仅 peek,保留至下次 boot) */
/** 首页 SSR boot 注入的鉴权种子(consumeHomeBoot 写入;AuthProvider 仅 peek,保留至下次 boot)
* SSR 时写入请求作用域,客户端为模块级单例 */
let seededUser: User | null | undefined;
let seededUnread = 0;
let hasAuthSeed = false;
@@ -8,6 +10,11 @@ let hasAuthSeed = false;
let bootUnread = 0;
export function seedAuthFromHomeBoot(user: User | null | undefined, unread: number) {
const scope = getScope();
if (scope) {
scope.auth = { hasSeed: true, user: user ?? null, unread: Math.max(0, unread | 0) };
return;
}
hasAuthSeed = true;
seededUser = user ?? null;
seededUnread = Math.max(0, unread | 0);
@@ -18,6 +25,12 @@ type AuthSeed = { hasSeed: boolean; user: User | null; unread: number };
/** 只读种子,不清空(StrictMode 双挂 / useState initializer 可重复调用) */
export function peekAuthSeed(): AuthSeed {
const scope = getScope();
if (scope) {
return scope.auth.hasSeed
? { hasSeed: true, user: scope.auth.user, unread: scope.auth.unread }
: { hasSeed: false, user: null, unread: 0 };
}
if (!hasAuthSeed) {
return { hasSeed: false, user: null, unread: bootUnread };
}

View File

@@ -1,10 +1,14 @@
import type {
Board,
CheckInStatus,
Comment,
CommunityShowcaseItem,
ForumLimitsPublic,
ForumStats,
PostDetailResponse,
PostItem,
PostLotteryView,
PollView,
RecentComment,
RecentUser,
SiteBranding,
@@ -26,6 +30,7 @@ import {
import { setSessionSnapshot } from './sessionPageCache';
import { seedAuthFromHomeBoot } from './authBoot';
import { checkInCacheKey } from '../hooks/useCheckIn';
import { isTimeDiffSignificant } from './content';
import type { FeedSort } from '../components/FeedSortBar';
/** 与 Go homeBootPayload / window.__J13_HOME_BOOT__ 对齐 */
@@ -61,6 +66,7 @@ declare global {
/** 读取并清除文档 SSR 注入的首页 boot,灌入各层缓存 */
export function consumeHomeBoot(): HomeBootPayload | null {
if (typeof window === 'undefined') return null;
const boot = window.__J13_HOME_BOOT__;
try {
delete window.__J13_HOME_BOOT__;
@@ -68,7 +74,11 @@ export function consumeHomeBoot(): HomeBootPayload | null {
window.__J13_HOME_BOOT__ = undefined;
}
if (!boot || typeof boot !== 'object') return null;
return seedHomeBoot(boot);
}
/** 将 boot 数据灌入当前缓存层(SSR 时经请求作用域隔离;客户端为模块单例) */
export function seedHomeBoot(boot: HomeBootPayload): HomeBootPayload {
if (boot.limits) seedForumLimitsCache(boot.limits);
if (boot.branding) seedSiteBrandingCache(boot.branding);
if (Array.isArray(boot.pages)) seedSitePagesCache(boot.pages);
@@ -111,3 +121,46 @@ export function consumeHomeBoot(): HomeBootPayload | null {
return boot;
}
/** 与 PostDetailPage 会话快照同形(prefetchRoute / SSR +data 共用) */
export type PostDetailSnapshot = {
post: PostItem;
comments: Comment[];
poll: PollView | null;
lottery: PostLotteryView | null;
liked: boolean;
favorited: boolean;
canEdit: boolean;
isEdited: boolean;
editBlockReason: string;
editWindowHours: number;
bountyCanRefund: boolean;
bountyRefundBlockReason: string;
bountyEligibleReplyCount: number;
scrollTop: number;
};
export function postDetailCacheKey(id: number): string {
return `post:${id}`;
}
/** 由帖子详情 API 响应构建会话快照(客户端 prefetch 与服务端 +data 共用) */
export function buildPostDetailSnapshot(detail: PostDetailResponse, comments: Comment[]): PostDetailSnapshot {
return {
post: detail.post,
comments: Array.isArray(comments) ? comments : [],
poll: detail.poll ?? null,
lottery: detail.lottery ?? null,
liked: detail.liked,
favorited: detail.favorited,
canEdit: detail.can_edit ?? false,
isEdited: detail.is_edited
?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at),
editBlockReason: detail.edit_block_reason ?? '',
editWindowHours: detail.post_edit_window_hours ?? 0,
bountyCanRefund: detail.bounty_can_refund ?? true,
bountyRefundBlockReason: detail.bounty_refund_block_reason ?? '',
bountyEligibleReplyCount: detail.bounty_eligible_reply_count ?? 0,
scrollTop: 0,
};
}

View File

@@ -16,9 +16,11 @@ import { ensureForumLimitsLoaded, getCachedForumLimits } from '../hooks/useForum
import { ensureSiteBrandingLoaded } from '../hooks/useSiteBranding';
import { ensureSitePagesLoaded } from '../hooks/useSitePages';
import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
import { buildPostDetailSnapshot, postDetailCacheKey } from './homeBoot';
import { resolveAsideWidgets } from './asideWidgets';
import { isTimeDiffSignificant } from './content';
import { loadMyCommentIds } from './guest';
import { isChunkLoadError, reloadForStaleChunk } from './chunkLoad';
import { parsePermalinkID, parsePermalinkSlug } from './permalink';
import { getSessionSnapshot, setSessionSnapshot } from './sessionPageCache';
import {
setCachedBoards,
setCachedStats,
@@ -26,27 +28,7 @@ import {
setCachedRecentUsers,
setCachedTags,
} from './layoutCache';
import { isChunkLoadError, reloadForStaleChunk } from './chunkLoad';
import { parsePermalinkID, parsePermalinkSlug } from './permalink';
import { getSessionSnapshot, setSessionSnapshot } from './sessionPageCache';
/** 与 PostDetailPage 会话快照同形,供预取写入 */
type PostDetailSnapshot = {
post: PostItem;
comments: Comment[];
poll: PollView | null;
lottery: PostLotteryView | null;
liked: boolean;
favorited: boolean;
canEdit: boolean;
isEdited: boolean;
editBlockReason: string;
editWindowHours: number;
bountyCanRefund: boolean;
bountyRefundBlockReason: string;
bountyEligibleReplyCount: number;
scrollTop: number;
};
import { loadMyCommentIds } from './guest';
function resolveUrl(to: To): URL {
if (typeof to === 'string') return new URL(to, window.location.origin);
@@ -137,7 +119,7 @@ async function prefetchFeed(url: URL, force: boolean): Promise<void> {
}
async function prefetchPost(id: number, force: boolean): Promise<void> {
const key = `post:${id}`;
const key = postDetailCacheKey(id);
if (!force && getSessionSnapshot(key) !== undefined) return;
// force:保留旧快照直到新数据写回,避免软刷新中间态空白
@@ -146,23 +128,7 @@ async function prefetchPost(id: number, force: boolean): Promise<void> {
api.post(id),
api.comments(id, myIds),
]);
const snap: PostDetailSnapshot = {
post: detail.post,
comments: Array.isArray(comm.comments) ? comm.comments : [],
poll: detail.poll ?? null,
lottery: detail.lottery ?? null,
liked: detail.liked,
favorited: detail.favorited,
canEdit: detail.can_edit ?? false,
isEdited: detail.is_edited
?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at),
editBlockReason: detail.edit_block_reason ?? '',
editWindowHours: detail.post_edit_window_hours ?? 0,
bountyCanRefund: detail.bounty_can_refund ?? true,
bountyRefundBlockReason: detail.bounty_refund_block_reason ?? '',
bountyEligibleReplyCount: detail.bounty_eligible_reply_count ?? 0,
scrollTop: 0,
};
const snap = buildPostDetailSnapshot(detail, comm.comments);
setSessionSnapshot(key, snap);
}

View File

@@ -1,4 +1,6 @@
/** 会话内页面快照:前进后退命中则不再请求,手动刷新或登录态变化时清空 */
/** 会话内页面快照:前进后退命中则不再请求,手动刷新或登录态变化时清空
* SSR 时经由请求作用域(scope.kv)隔离,客户端为模块级单例 */
import { getScope } from '../ssr/scope';
const MAX_ENTRIES = 48;
@@ -16,12 +18,19 @@ function touch(key: string) {
}
export function getSessionSnapshot<T>(key: string): T | undefined {
const scope = getScope();
if (scope) return scope.kv.get(key) as T | undefined;
if (!store.has(key)) return undefined;
touch(key);
return store.get(key) as T;
}
export function setSessionSnapshot<T>(key: string, data: T): void {
const scope = getScope();
if (scope) {
scope.kv.set(key, data);
return;
}
store.set(key, data);
touch(key);
}

View File

@@ -4,11 +4,13 @@ const STORAGE_KEY = 'j13-theme';
/** 在 React 渲染前同步应用主题,避免首屏主题闪烁 */
export function getStoredTheme(): Theme {
if (typeof window === 'undefined') return 'light';
const stored = localStorage.getItem(STORAGE_KEY);
return stored === 'dark' ? 'dark' : 'light';
}
export function applyTheme(theme: Theme) {
if (typeof document === 'undefined') return;
const root = document.documentElement;
root.classList.toggle('dark', theme === 'dark');
root.style.colorScheme = theme;