Skip to content

Commit dafa97f

Browse files
committed
feat: 文件名的处理代码统一使用宽松模式函数,图片等则使用严格清理
1 parent 4508c2d commit dafa97f

File tree

6 files changed

+85
-38
lines changed

6 files changed

+85
-38
lines changed

app/api/upload/route.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { auth } from "@/auth";
22
import { NextRequest, NextResponse } from "next/server";
33
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
44
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
5+
import { sanitizeDocumentSlug, sanitizeResourceKey } from "@/lib/sanitizer";
56

67
/**
78
* R2 配置
@@ -81,7 +82,9 @@ export async function POST(request: NextRequest) {
8182
// 格式:users/{userId}/{article-slug}/{timestamp}-{filename}
8283
const timestamp = Date.now();
8384
const userId = session.user.id;
84-
const key = `users/${userId}/${articleSlug}/${timestamp}-${filename}`;
85+
const sanitizedSlug = sanitizeDocumentSlug(articleSlug);
86+
const sanitizedFilename = sanitizeResourceKey(filename);
87+
const key = `users/${userId}/${sanitizedSlug}/${timestamp}-${sanitizedFilename}`;
8588

8689
// 创建 PutObject 命令
8790
const command = new PutObjectCommand({

app/components/Contribute.tsx

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,16 @@ import { useRouter } from "next/navigation";
2020
import { TreeSelect } from "antd";
2121
import { DataNode } from "antd/es/tree";
2222
import { buildDocsNewUrl } from "@/lib/github";
23-
import { type DirNode, FILENAME_PATTERN } from "@/lib/submission";
23+
import {
24+
FILENAME_PATTERN,
25+
normalizeFilenameBase,
26+
type DirNode,
27+
} from "@/lib/submission";
2428
import {
2529
CREATE_SUBDIR_SUFFIX,
2630
toTreeSelectData,
2731
} from "@/app/components/contribute/tree-utils";
32+
import { sanitizeDocumentSlug } from "@/lib/sanitizer";
2833

2934
// 统一调用工具函数生成 GitHub 新建链接,路径规则与 Edit 按钮一致
3035
function buildGithubNewUrl(dirPath: string, filename: string, title: string) {
@@ -59,22 +64,26 @@ export function Contribute() {
5964
const [articleFile, setArticleFile] = useState("");
6065
const [articleFileTouched, setArticleFileTouched] = useState(false);
6166

62-
const trimmedArticleFile = useMemo(() => articleFile.trim(), [articleFile]);
67+
const normalizedArticleFile = useMemo(
68+
() => normalizeFilenameBase(articleFile),
69+
[articleFile],
70+
);
6371
const { isFileNameValid, fileNameError } = useMemo(() => {
64-
if (!trimmedArticleFile) {
72+
if (!normalizedArticleFile) {
6573
return {
6674
isFileNameValid: false,
6775
fileNameError: "请填写文件名。",
6876
};
6977
}
70-
if (!FILENAME_PATTERN.test(trimmedArticleFile)) {
78+
if (!FILENAME_PATTERN.test(normalizedArticleFile)) {
7179
return {
7280
isFileNameValid: false,
73-
fileNameError: "文件名仅支持英文、数字、连字符或下划线。",
81+
fileNameError:
82+
"文件名仅支持字母、数字、连字符或下划线,并需以字母或数字开头。",
7483
};
7584
}
7685
return { isFileNameValid: true, fileNameError: "" };
77-
}, [trimmedArticleFile]);
86+
}, [normalizedArticleFile]);
7887

7988
useEffect(() => {
8089
let mounted = true;
@@ -99,22 +108,31 @@ export function Contribute() {
99108

100109
const options = useMemo(() => toTreeSelectData(tree), [tree]);
101110

111+
const sanitizedSubdir = useMemo(
112+
() => sanitizeDocumentSlug(newSub, ""),
113+
[newSub],
114+
);
115+
102116
const finalDirPath = useMemo(() => {
103117
if (!selectedKey) return "";
104118
if (selectedKey.endsWith(CREATE_SUBDIR_SUFFIX)) {
105119
const l1 = selectedKey.split("/")[0];
106-
if (!newSub.trim()) return "";
107-
return `${l1}/${newSub.trim().replace(/\s+/g, "-")}`;
120+
if (!l1 || !sanitizedSubdir) return "";
121+
return `${l1}/${sanitizedSubdir}`;
108122
}
109123
return selectedKey;
110-
}, [selectedKey, newSub]);
124+
}, [selectedKey, sanitizedSubdir]);
111125

112126
const canProceed = !!finalDirPath && isFileNameValid;
113127

114128
const handleOpenGithub = () => {
115129
if (!canProceed) return;
116-
const filename = trimmedArticleFile.toLowerCase();
130+
if (!normalizedArticleFile) return;
131+
const filename = normalizedArticleFile;
117132
const title = articleTitle || filename;
133+
if (filename !== articleFile) {
134+
setArticleFile(filename);
135+
}
118136
window.open(
119137
buildGithubNewUrl(finalDirPath, filename, title),
120138
"_blank",
@@ -263,7 +281,8 @@ export function Contribute() {
263281
onChange={(e) => setNewSub(e.target.value)}
264282
/>
265283
<p className="text-xs text-muted-foreground">
266-
将创建路径:{selectedKey.split("/")[0]} / {newSub || "<未填写>"}
284+
将创建路径:{selectedKey.split("/")[0]} /{" "}
285+
{sanitizedSubdir || "<未填写>"}
267286
</p>
268287
</div>
269288
)}

app/components/DocsDestinationForm.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { DataNode } from "antd/es/tree";
66
import { Input } from "@/components/ui/input";
77
import { Label } from "@/app/components/ui/label";
88
import { type DirNode } from "@/lib/submission";
9+
import { sanitizeDocumentSlug } from "@/lib/sanitizer";
910
import {
1011
CREATE_SUBDIR_SUFFIX,
1112
toTreeSelectData,
@@ -50,15 +51,18 @@ export function DocsDestinationForm({ onChange }: DocsDestinationFormProps) {
5051

5152
const options = useMemo(() => toTreeSelectData(tree), [tree]);
5253

54+
const sanitizedSubdir = useMemo(
55+
() => sanitizeDocumentSlug(newSub, ""),
56+
[newSub],
57+
);
58+
5359
const finalDirPath = useMemo(() => {
5460
if (!selectedKey) return "";
5561
if (!selectedKey.endsWith(CREATE_SUBDIR_SUFFIX)) return selectedKey;
5662
const [l1] = selectedKey.split("/");
57-
if (!l1) return "";
58-
const sanitized = newSub.trim().replace(/\s+/g, "-");
59-
if (!sanitized) return "";
60-
return `${l1}/${sanitized}`;
61-
}, [selectedKey, newSub]);
63+
if (!l1 || !sanitizedSubdir) return "";
64+
return `${l1}/${sanitizedSubdir}`;
65+
}, [selectedKey, sanitizedSubdir]);
6266

6367
useEffect(() => {
6468
onChange?.(finalDirPath);
@@ -113,7 +117,8 @@ export function DocsDestinationForm({ onChange }: DocsDestinationFormProps) {
113117
onChange={(e) => setNewSub(e.target.value)}
114118
/>
115119
<p className="text-xs text-muted-foreground">
116-
将创建路径:{selectedKey.split("/")[0]} / {newSub || "<未填写>"}
120+
将创建路径:{selectedKey.split("/")[0]} /{" "}
121+
{sanitizedSubdir || "<未填写>"}
117122
</p>
118123
</div>
119124
)}

app/components/EditorMetadataForm.tsx

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useEffect, useRef, useState } from "react";
3+
import { useState } from "react";
44

55
import { useEditorStore } from "@/lib/editor-store";
66
import { Input } from "@/components/ui/input";
@@ -24,15 +24,17 @@ export function EditorMetadataForm() {
2424
} = useEditorStore();
2525

2626
const [tagsInputValue, setTagsInputValue] = useState(() => tags.join(", "));
27-
const skipNextSync = useRef(false);
27+
const [skipNextSync, setSkipNextSync] = useState(false);
28+
const [prevTags, setPrevTags] = useState(tags);
2829

29-
useEffect(() => {
30-
if (skipNextSync.current) {
31-
skipNextSync.current = false;
32-
return;
30+
if (tags !== prevTags) {
31+
setPrevTags(tags);
32+
if (skipNextSync) {
33+
setSkipNextSync(false);
34+
} else {
35+
setTagsInputValue(tags.join(", "));
3336
}
34-
setTagsInputValue(tags.join(", "));
35-
}, [tags]);
37+
}
3638

3739
// 处理标签输入(逗号分隔)
3840
const handleTagsChange = (value: string) => {
@@ -42,14 +44,14 @@ export function EditorMetadataForm() {
4244
.map((tag) => tag.trim())
4345
.filter((tag) => tag.length > 0);
4446

45-
skipNextSync.current = true;
47+
setSkipNextSync(true);
4648
setTags(processedTags);
4749
};
4850

4951
// 处理标签输入框失去焦点 - 过滤所有空标签并同步展示值
5052
const handleTagsBlur = () => {
5153
const filteredTags = tags.filter((tag) => tag.length > 0);
52-
skipNextSync.current = true;
54+
setSkipNextSync(true);
5355
setTags(filteredTags);
5456
setTagsInputValue(filteredTags.join(", "));
5557
};

app/editor/EditorPageClient.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type { Session } from "next-auth";
1414
import { buildDocsNewUrl } from "@/lib/github";
1515
import {
1616
FILENAME_PATTERN,
17-
ensureMarkdownExtension,
17+
normalizeMarkdownFilename,
1818
stripMarkdownExtension,
1919
} from "@/lib/submission";
2020

@@ -72,7 +72,7 @@ export function EditorPageClient({ session }: EditorPageClientProps) {
7272
const handleImageCountChange = useCallback((count: number) => {
7373
setImageCount(count);
7474
}, []);
75-
const previewFilename = filename ? ensureMarkdownExtension(filename) : "";
75+
const previewFilename = filename ? normalizeMarkdownFilename(filename) : "";
7676

7777
/**
7878
* 上传单个图片到 R2
@@ -137,10 +137,12 @@ export function EditorPageClient({ session }: EditorPageClientProps) {
137137
return;
138138
}
139139

140-
const normalizedFilename = ensureMarkdownExtension(filename);
140+
const normalizedFilename = normalizeMarkdownFilename(filename);
141141
const filenameBase = stripMarkdownExtension(normalizedFilename);
142142
if (!filenameBase || !FILENAME_PATTERN.test(filenameBase)) {
143-
alert("文件名仅支持英文、数字、连字符或下划线,并需以字母或数字开头。");
143+
alert(
144+
"文件名仅支持字母、数字、连字符或下划线,并需以字母或数字开头(已自动清洗空格和特殊符号)。",
145+
);
144146
return;
145147
}
146148

lib/submission.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,35 @@
1+
import { sanitizeDocumentSlug } from "@/lib/sanitizer";
2+
13
export type DirNode = {
24
name: string;
35
path: string;
46
children?: DirNode[];
57
};
68

7-
export const FILENAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]+$/;
9+
export const FILENAME_PATTERN = /^[\p{L}\p{N}][\p{L}\p{N}_-]+$/u;
810

911
export function ensureMarkdownExtension(filename: string) {
1012
const trimmed = filename.trim();
11-
if (!trimmed) return "";
12-
return trimmed.toLowerCase().endsWith(".md")
13-
? trimmed.toLowerCase()
14-
: `${trimmed.toLowerCase()}.md`;
13+
if (!trimmed) {
14+
return "";
15+
}
16+
const normalized = trimmed.toLowerCase();
17+
return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
1518
}
1619

1720
export function stripMarkdownExtension(filename: string) {
18-
return filename.toLowerCase().replace(/\.md$/i, "");
21+
return filename.trim().toLowerCase().replace(/\.md$/i, "");
22+
}
23+
24+
export function normalizeFilenameBase(filename: string) {
25+
const trimmed = filename.trim();
26+
if (!trimmed) return "";
27+
const withoutExt = stripMarkdownExtension(trimmed);
28+
return sanitizeDocumentSlug(withoutExt);
29+
}
30+
31+
export function normalizeMarkdownFilename(filename: string) {
32+
const base = normalizeFilenameBase(filename);
33+
if (!base) return "";
34+
return ensureMarkdownExtension(base);
1935
}

0 commit comments

Comments
 (0)