Skip to content

Commit c5f37fa

Browse files
longsizhuogithub-actions[bot]Copilot
authored
fix(posts-cr): apply Copilot CR feedback on security and code quality (#352)
- PostContent: narrow rehype-sanitize style attribute from * to span/svg only (KaTeX only needs style on these two elements; global style is an XSS vector) - Extract buildFrontmatter to lib/frontmatter.ts to avoid pulling editor bundle into detail page / card bundles - PostDetailOwnerActions: add .catch(()=>({})) on DELETE res.json() for resilient error body parsing - EditorPageClient: align titleToSlug comment with actual Unicode behavior, add tags trim+filter before POST, guard satoken header to avoid empty token - PromoteToDocsButton: update import path, guard satoken header Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: copilot-pull-request-reviewer[bot] <198982749+copilot-pull-request-reviewer[bot]@users.noreply.github.com>
1 parent 487dd6d commit c5f37fa

5 files changed

Lines changed: 63 additions & 56 deletions

File tree

app/[locale]/editor/EditorPageClient.tsx

Lines changed: 10 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,9 @@ interface EditorPageClientProps {
2222
user: UserView;
2323
}
2424

25-
/**
26-
* 从文章标题生成 slug 候选值,和后端生成逻辑保持一致(kebab-case,纯 ASCII)。
27-
* 后端会做唯一性去重,前端只是提前填充 filename input 用,不是最终 slug。
28-
*/
25+
// titleToSlug:从文章标题生成 slug 候选值,供前端预填 filename input。
26+
// 保留 Unicode 字母/数字(\p{L}\p{N}),允许中文 slug 候选,和后端 sanitizeSlug 对齐。
27+
// 后端会做唯一性去重,前端候选值不是最终 slug。
2928
function titleToSlug(title: string): string {
3029
return title
3130
.toLowerCase()
@@ -37,44 +36,6 @@ function titleToSlug(title: string): string {
3736
.slice(0, 128);
3837
}
3938

40-
// buildFrontmatter 仅在「收录进知识库」(PromoteToDocsButton)路径使用,
41-
// 这里为 PromoteToDocsButton 单独导出,editor 直发不再拼 frontmatter。
42-
export function buildFrontmatter({
43-
title,
44-
description,
45-
tags,
46-
}: {
47-
title: string;
48-
description?: string;
49-
tags?: string[];
50-
}) {
51-
const safeTitle = JSON.stringify(title);
52-
const safeDescription = JSON.stringify(description ?? "");
53-
const date = new Date().toISOString().slice(0, 10);
54-
const normalizedTags = (tags ?? [])
55-
.map((tag) => tag.trim())
56-
.filter((tag) => tag.length > 0);
57-
58-
const lines = [
59-
"---",
60-
`title: ${safeTitle}`,
61-
`description: ${safeDescription}`,
62-
`date: "${date}"`,
63-
];
64-
65-
if (normalizedTags.length > 0) {
66-
lines.push(
67-
"tags:",
68-
...normalizedTags.map((tag) => ` - ${JSON.stringify(tag)}`),
69-
);
70-
} else {
71-
lines.push("tags: []");
72-
}
73-
74-
lines.push("---");
75-
return lines.join("\n");
76-
}
77-
7839
export function EditorPageClient({ user }: EditorPageClientProps) {
7940
const router = useRouter();
8041
const [isPublishing, setIsPublishing] = useState(false);
@@ -195,15 +156,16 @@ export function EditorPageClient({ user }: EditorPageClientProps) {
195156
});
196157
}
197158

198-
// POST /api/posts 直发落库
199-
// TODO(backend-contract): 等后端 #2 完成后确认 BACKEND_URL 路由 rewrite 情况;
200-
// 目前后端路由走 next.config.mjs rewrites 同源代理(参考 /api/community/links),
201-
// 若 posts 同样走代理则直接 fetch "/api/posts",否则需要带 BACKEND_URL。
202159
const token = localStorage.getItem("satoken") ?? "";
160+
if (!token) {
161+
throw new Error("请先登录后再发布");
162+
}
163+
203164
const postRequest: PostRequest = {
204165
title: title.trim(),
205166
description: description.trim() || undefined,
206-
tags: tags.filter((t) => t.trim().length > 0),
167+
// trim + filter 保证后端收到的 tags 无空白项
168+
tags: tags.map((t) => t.trim()).filter(Boolean),
207169
contentMd: finalMarkdown,
208170
// 有用户填的 slug 就带上,后端会去重;没有则不传,后端从 title 自动生成
209171
...(rawSlug ? { slug: rawSlug } : {}),
@@ -214,7 +176,7 @@ export function EditorPageClient({ user }: EditorPageClientProps) {
214176
headers: {
215177
"Content-Type": "application/json",
216178
// rewrite 透传:后端 sa-token.token-name=satoken,需用 satoken 而非 x-satoken
217-
satoken: token,
179+
...(token ? { satoken: token } : {}),
218180
},
219181
body: JSON.stringify(postRequest),
220182
signal: AbortSignal.timeout(30_000),

app/[locale]/u/[username]/posts/[slug]/PostDetailOwnerActions.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,10 @@ export function PostDetailOwnerActions({
5555
const token = localStorage.getItem("satoken") ?? "";
5656
const res = await fetch(`/api/posts/${postId}`, {
5757
method: "DELETE",
58-
// rewrite 透传:后端读 satoken,不是 x-satoken
59-
headers: { satoken: token },
58+
// rewrite 透传:后端读 satoken,不是 x-satoken;空 token 不发 header
59+
headers: { ...(token ? { satoken: token } : {}) },
6060
});
61-
const body = (await res.json()) as ApiResponse<void>;
61+
const body = (await res.json().catch(() => ({}))) as ApiResponse<void>;
6262
if (res.ok && body.success) {
6363
router.replace(`/u/${authorUsername}/posts`);
6464
} else {

app/components/PostContent.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,12 @@ const sanitizeSchema = {
4949
],
5050
attributes: {
5151
...defaultSchema.attributes,
52-
// 允许所有元素携带 className(rehype-katex / rehype-autolink-headings 需要)
53-
"*": [...(defaultSchema.attributes?.["*"] ?? []), "className", "style"],
52+
// className 全局允许(rehype-katex / rehype-autolink-headings 均需要)
53+
// style 不全局放行(XSS via CSS),只给 KaTeX 渲染必须的元素开放
54+
"*": [...(defaultSchema.attributes?.["*"] ?? []), "className"],
55+
// KaTeX span/svg 需要 style 控制数学符号排版,普通 UGC 元素不需要
56+
span: [...(defaultSchema.attributes?.["span"] ?? []), "style"],
57+
svg: [...(defaultSchema.attributes?.["svg"] ?? []), "style"],
5458
// KaTeX math 元素的专有属性
5559
math: ["xmlns", "display"],
5660
annotation: ["encoding"],

app/components/PromoteToDocsButton.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { useState } from "react";
44
import { DocsDestinationForm } from "@/app/components/DocsDestinationForm";
55
import { buildDocsNewUrl } from "@/lib/github";
6-
import { buildFrontmatter } from "@/app/[locale]/editor/EditorPageClient";
6+
import { buildFrontmatter } from "@/lib/frontmatter";
77

88
interface Props {
99
postId: number;
@@ -114,8 +114,8 @@ export function PromoteToDocsButton({
114114
method: "POST",
115115
headers: {
116116
"Content-Type": "application/json",
117-
// rewrite 透传:后端读 satoken,不是 x-satoken
118-
satoken: token,
117+
// rewrite 透传:后端读 satoken,不是 x-satoken;空 token 不发 header
118+
...(token ? { satoken: token } : {}),
119119
},
120120
body: JSON.stringify({ prUrl: githubUrl }),
121121
}).catch((err) => {

lib/frontmatter.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* buildFrontmatter:为 /docs 知识库生成 YAML frontmatter 字符串。
3+
*
4+
* 抽到独立模块的原因:EditorPageClient 和 PromoteToDocsButton 都需要它,
5+
* 把它留在 EditorPageClient.tsx 会让详情页/卡片 bundle 拖进整个编辑器栈。
6+
*/
7+
export function buildFrontmatter({
8+
title,
9+
description,
10+
tags,
11+
}: {
12+
title: string;
13+
description?: string;
14+
tags?: string[];
15+
}): string {
16+
const safeTitle = JSON.stringify(title);
17+
const safeDescription = JSON.stringify(description ?? "");
18+
const date = new Date().toISOString().slice(0, 10);
19+
const normalizedTags = (tags ?? [])
20+
.map((tag) => tag.trim())
21+
.filter((tag) => tag.length > 0);
22+
23+
const lines = [
24+
"---",
25+
`title: ${safeTitle}`,
26+
`description: ${safeDescription}`,
27+
`date: "${date}"`,
28+
];
29+
30+
if (normalizedTags.length > 0) {
31+
lines.push(
32+
"tags:",
33+
...normalizedTags.map((tag) => ` - ${JSON.stringify(tag)}`),
34+
);
35+
} else {
36+
lines.push("tags: []");
37+
}
38+
39+
lines.push("---");
40+
return lines.join("\n");
41+
}

0 commit comments

Comments
 (0)