feat(hide): 密码可见隐藏块,发帖双栏预览与门禁体验修复
增加密码解锁与游客签名 cookie;发帖页对齐 1440 并默认双栏预览;修复 locked 解析、按钮对比度与回复聚焦。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -838,6 +838,15 @@ export async function apiUnlockPost(postId: string) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiUnlockPostPassword(postId: string, password: string) {
|
||||
const res = await fetchWithRefresh(`/api/posts/${postId}/unlock-password`, {
|
||||
method: "POST",
|
||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiGetPoints(): Promise<{ points: number }> {
|
||||
const res = await fetchWithRefresh("/api/points", {
|
||||
method: "GET",
|
||||
|
||||
@@ -7,10 +7,17 @@ export const TOKEN_COOKIE = `${HOST_PREFIX}j13_token`;
|
||||
export const REFRESH_COOKIE = `${HOST_PREFIX}j13_refresh`;
|
||||
export const CSRF_COOKIE = `${HOST_PREFIX}j13_csrf`;
|
||||
|
||||
/** 密码隐藏块解锁 cookie 前缀(与 backend HidePasswordCookie 一致) */
|
||||
export const HIDE_PWD_COOKIE_PREFIX = "j13_hp_";
|
||||
|
||||
type CookieReader = {
|
||||
get(name: string): { value: string } | undefined;
|
||||
};
|
||||
|
||||
type CookieStoreLike = CookieReader & {
|
||||
getAll?: () => { name: string; value: string }[];
|
||||
};
|
||||
|
||||
// 构造转发给后端的最小 Cookie 头:SSR 公开/鉴权接口只需要 access token 来
|
||||
// 识别用户(填充 liked 等状态),不应把 refresh token、主题等无关 cookie 全量转发。
|
||||
// 返回空串表示当前无登录凭据,调用方应省略 Cookie 头。
|
||||
@@ -19,6 +26,24 @@ export function authCookieHeader(store: CookieReader): string {
|
||||
return token ? `${TOKEN_COOKIE}=${token.value}` : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 帖子详情 SSR:access token + 密码隐藏块解锁 cookie。
|
||||
* 解锁 cookie 为 HttpOnly,必须随 SSR 转发,否则 refresh 后密码块会再次锁上。
|
||||
*/
|
||||
export function postViewerCookieHeader(store: CookieStoreLike): string {
|
||||
const parts: string[] = [];
|
||||
const auth = authCookieHeader(store);
|
||||
if (auth) parts.push(auth);
|
||||
if (typeof store.getAll === "function") {
|
||||
for (const c of store.getAll()) {
|
||||
if (c.name.startsWith(HIDE_PWD_COOKIE_PREFIX)) {
|
||||
parts.push(`${c.name}=${c.value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
// 客户端判断浏览器是否可能持有登录态:j13_csrf 非 HttpOnly 可读,
|
||||
// 生命周期与 refresh 一致,适合作为挂载后静默校正的触发信号。
|
||||
export function hasAuthCookieHint(): boolean {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** 正文 :::hide 块解析(与 backend/markdown/hide.go 契约一致) */
|
||||
|
||||
export type HideKind = "login" | "reply" | "points";
|
||||
export type HideKind = "login" | "reply" | "points" | "password";
|
||||
|
||||
export type HideSegment =
|
||||
| { type: "md"; text: string }
|
||||
@@ -12,26 +12,50 @@ export type HideSegment =
|
||||
body: string;
|
||||
};
|
||||
|
||||
const OPEN_RE = /^:::hide\s+(login|reply|points)(?:\s+(\d+))?(?:\s+(locked))?\s*$/;
|
||||
const HIDE_KINDS = new Set<HideKind>(["login", "reply", "points", "password"]);
|
||||
|
||||
function isClose(line: string): boolean {
|
||||
return line.trim() === ":::";
|
||||
}
|
||||
|
||||
/** 与 Go parseOpenMarker 对齐::::hide <kind> [points|password] [locked] */
|
||||
function parseOpen(trimmed: string): {
|
||||
kind: HideKind;
|
||||
points: number;
|
||||
locked: boolean;
|
||||
} | null {
|
||||
const m = OPEN_RE.exec(trimmed);
|
||||
if (!m) return null;
|
||||
const kind = m[1] as HideKind;
|
||||
const points = m[2] ? Number(m[2]) : 0;
|
||||
const locked = m[3] === "locked";
|
||||
if (kind === "points" && (!Number.isFinite(points) || points < 1)) {
|
||||
return null;
|
||||
if (!trimmed.startsWith(":::hide")) return null;
|
||||
const parts = trimmed
|
||||
.slice(":::hide".length)
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
if (parts.length === 0) return null;
|
||||
const kind = parts[0] as HideKind;
|
||||
if (!HIDE_KINDS.has(kind)) return null;
|
||||
|
||||
let idx = 1;
|
||||
let points = 0;
|
||||
let locked = false;
|
||||
|
||||
if (kind === "points") {
|
||||
if (idx >= parts.length) return null;
|
||||
const n = Number(parts[idx]);
|
||||
if (!Number.isFinite(n) || n < 1 || n > 100000) return null;
|
||||
points = n;
|
||||
idx++;
|
||||
} else if (kind === "password") {
|
||||
// 脱敏::::hide password locked;原文::::hide password <pwd> [locked]
|
||||
if (idx < parts.length && parts[idx] !== "locked") {
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
return { kind, points: kind === "points" ? points : 0, locked };
|
||||
|
||||
for (; idx < parts.length; idx++) {
|
||||
if (parts[idx] === "locked") locked = true;
|
||||
else return null;
|
||||
}
|
||||
return { kind, points, locked };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,7 +111,6 @@ export function splitHideSegments(content: string): HideSegment[] {
|
||||
continue;
|
||||
}
|
||||
if (parseOpen(inner)) {
|
||||
// 嵌套:放弃隐藏语义,整段当普通文本
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
found = false;
|
||||
@@ -101,7 +124,6 @@ export function splitHideSegments(content: string): HideSegment[] {
|
||||
j++;
|
||||
}
|
||||
if (!found) {
|
||||
// 未闭合:开标记行起当普通文本
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
continue;
|
||||
@@ -150,5 +172,7 @@ export function hideKindLabel(kind: HideKind): string {
|
||||
return "回复可见";
|
||||
case "points":
|
||||
return "积分可见";
|
||||
case "password":
|
||||
return "密码可见";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export const CONTENT_ACCESS = [
|
||||
{ value: "login", label: "登录可见" },
|
||||
{ value: "reply", label: "回复可见" },
|
||||
{ value: "points", label: "积分可见" },
|
||||
{ value: "password", label: "密码可见" },
|
||||
{ value: "mixed", label: "含隐藏内容" },
|
||||
] as const;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user