Skip to content

Commit 905a30a

Browse files
authored
fix: 上传/SEO/搜索 多点加固 (SVG + 大小 + MIME 控制字符 + robots) (#320)
* fix(upload): 拒绝 SVG + 把 10MB 大小上限绑进 R2 预签名 URL SVG 可内嵌 <script> 直接走 R2 公开 URL 执行 JS,存储型 XSS 向量,显式 block image/svg+xml。 大小限制走签名:读 request body 里的 fileSize(客户端 File.size),超 10MB 直接 413; 合法值绑进 PutObjectCommand.ContentLength,R2 在 PUT 时 enforce 匹配的 Content-Length header, 这是预签名 URL 唯一的服务端大小拦截机制(本地 byte check 看不到后续 PUT 流量)。 EditorPageClient 上传前带上 file.size。 * feat(seo): 加 app/robots.ts,屏蔽登录态 / 内部 API 路径 disallow 列表:/admin/ /editor/ /settings/ /login /api/ —— 这几个路径要么是登录态专属(入索引就是浪费 crawl budget + 泄露内部结构), 要么是服务端接口(爬虫根本读不出有用内容)。/login 入索引还会诱导钓鱼页面蹭 SERP。 sitemap 指向现有 /sitemap.xml,hostname 复用 app/sitemap.ts 的 NEXT_PUBLIC_SITE_URL 同一套规范化。 * fix(seo): settings 页 robots follow 也关掉 原来 index:false / follow:true —— 爬虫虽不收录页面,但会沿着页面里的链接继续爬。 设置页全是用户专属内容(包含 AI 偏好、主题等),里面的链接也没必要喂爬虫, 改成 follow:false 让爬虫到此为止。 * fix(search): 干掉 lib/search-index.ts 的 as any cast AdvancedIndex 的 structuredData 字段契约是 { headings, contents }(fumadocs-core 导出的 StructuredData), 但因为 page.data 是运行时 shape、fumadocs 没把 StructuredData 从顶层 export, 之前偷懒 as any 把整个对象糊过去。 改法:本地写 PageStructuredData(和 StructuredData 结构一致)+ PageDataShape, 让 structuredData 一路 typed 传到返回值,返回对象直接满足 AdvancedIndex,不用任何 cast。 * fix(upload): fileSize 改成必填,ContentLength 恒绑进预签名 URL 原来 fileSize 是可选字段 + 条件 spread ContentLength —— 直接 curl 打 /api/upload 不带 fileSize, 服务端就签出一张没有 ContentLength 约束的预签名 URL,客户端 PUT 任意 GB 级文件都进得去 R2, 10MB 上限完全形同虚设。 改法: - interface UploadRequest 里 fileSize 从 optional 变 required - handler 开头强校验 typeof === number + 有限 + 非负 + <=10MB,少一项直接 400/413 - PutObjectCommand 永远带 ContentLength: fileSize(不再条件 spread) /api/upload 唯一前端调用方 app/editor/EditorPageClient.tsx 已经传 file.size,无需改动。 * fix(upload): 提取 primary MIME,阻断分号夹带绕过 SVG 黑名单 原来 normalizedType = contentType.toLowerCase().trim() 只做大小写/首尾空白归一化,没切分号后的参数。 攻击面:"image/jpeg; image/svg+xml" 这种值,startsWith("image/") 过、 startsWith("image/svg") 拒(因为前缀是 image/jpeg;),然后原始 contentType 被塞进 R2 PutObjectCommand.ContentType 落库,R2 再回吐给浏览器,浏览器 MIME-sniff 成 SVG 把 payload 当脚本跑起来,SVG 黑名单绕掉。 改法:新增 extractPrimaryMime() 只取分号前的主 MIME,所有判断(allow image/*、 deny image/svg*)和塞给 R2 的 ContentType 都走 primaryMime,闭掉这个分号夹带口子。 * refactor(site-url): 抽 normalizeSiteUrl 到 lib/site-url.ts,robots/sitemap 共用 app/robots.ts 和 app/sitemap.ts 各自维护了一份同形的 normalizeSiteUrl + RAW_SITE_URL + SITE_URL, 任何规范化调整都得改两处,容易 drift。 抽到 lib/site-url.ts:export normalizeSiteUrl() + export SITE_URL(模块加载时算一次), 两个消费方只 import { SITE_URL },删掉本地副本。 行为 byte-identical:默认 fallback 还是 'https://involutionhell.com', 归一化规则(无协议补 https://、去尾部所有斜杠)和正则完全不变,sitemap.ts 输出值不变。 * fix(search): 改用 fumadocs-core/mdx-plugins 公开导出的 StructuredData,删本地副本 上一版在 lib/search-index.ts 维护了 PageStructuredData 本地副本,理由是 fumadocs-core 的 StructuredData "没 export"。复查 node_modules 发现 fumadocs-core@15.7.13 的 dist/mdx-plugins/index.d.ts 有: export { ..., S as StructuredData, ... } from '../remark-structure-...'; package.json 里 './mdx-plugins' 也是公开 exports 入口,就是典型的公开 API。 直接 import 上游类型,删掉本地 PageStructuredData,消除跟 fumadocs 升级 drift 的风险。 PageDataShape 内部字段全部换成上游 StructuredData,和 AdvancedIndex.structuredData 结构一致。 * fix(upload): contentType 加严格 MIME 正则闸,拒 CR/LF 及其他控制字符 extractPrimaryMime 只切分号 + trim + 小写,不清洗控制字符。像 'image/jpeg\r\nContent-Type: image/svg+xml' 这种值, 走完 extractPrimaryMime 得到 'image/jpeg\r\ncontent-type: image/svg+xml',startsWith('image/') 过、 startsWith('image/svg') 绕(中间有 \r\n,前缀是 image/jpeg\r\n),然后被塞进 PutObjectCommand.ContentType。 下游(AWS SDK / R2 / 浏览器)per RFC 7230 一般会拒 header 值里的 CRLF,但入口先收口更便宜也更正确。 改法:新增 MIME_PATTERN = /^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/, extractPrimaryMime 返回后立刻 .test(),不匹配直接 400 { error: 'contentType 格式非法' }, 放在 image/* 和 SVG 黑名单之前当最外层 gate。合法 image/jpeg / image/svg+xml / image/webp 等都能过(SVG 交给后续黑名单拦),CR/LF/冒号/空格注入一律挡死。 * fix(site-url): 拿掉 prod 硬编码 fallback,env 缺失生产即抛错 之前 lib/site-url.ts 用 '?? "https://involutionhell.com"' 做兜底,违反 docs/architecture/frontend-backend-separation.md:96-103 "生产环境不做硬编码 fallback" 的约定。 在 preview/staging 漏配 NEXT_PUBLIC_SITE_URL 时会静默产出指向 prod 域的 sitemap/robots, 这正是文档警告的"漏配变静默错地址"。 新策略走 resolveSiteUrl(): - env 非空 → normalizeSiteUrl 返回 - NODE_ENV === 'production' 且 env 缺失 → throw,构建/启动失败(intentional) - 其它(dev/test)→ fallback http://localhost:3000(和 next start -p 3000、 OAuth 回调、next.config.mjs rewrites 的 localhost:3000 约定对齐) robots / sitemap 继续 import { SITE_URL },无变更。 * fix(editor): 上传前 normalize file.type,避免 R2 SignatureDoesNotMatch 服务端现在用 primaryMime(split(';')[0].trim().toLowerCase())绑进 PutObjectCommand.ContentType, 客户端 PUT 时的 Content-Type header 必须 byte-exact 对得上,否则 R2 返 403 SignatureDoesNotMatch。 之前客户端直接 file.type 透传 —— 浏览器在少见情况下会给 'Image/JPEG'(大小写混合)或 'image/jpeg; foo=bar'(带参数),都和服务端签名不一致,真实上传失败。 改法:uploadImage() 开头算一次 primaryMime = file.type.split(';')[0].trim().toLowerCase(), POST /api/upload 的 body.contentType 和后续 PUT 的 Content-Type header 都用同一个值。 空串(浏览器识别不出 MIME)走本地 throw,走 handlePublish 里的 alert,比让服务端 400 更直观。 * fix(upload): fileSize 用 Number.isSafeInteger 校验,拒小数 Content-Length 必须是非负整数,原来用 Number.isFinite 会放 10.5 这种小数通过, 签名 URL 绑 ContentLength: 10.5,R2 在客户端 PUT 时才 reject,用户看来是静默失败。 换成 Number.isSafeInteger: - 拒 NaN / Infinity(isFinite 本来也拒,保持) - 拒所有小数 - 隐含上界 <=2^53-1(Number.MAX_SAFE_INTEGER),不会被天文数字 number 溢出 负数还是靠 < 0 单独挡(isSafeInteger(-5) === true)。大小上限 MAX_UPLOAD_BYTES 不变。 * docs(upload): JSDoc 补齐 fileSize 必填字段,附校验链顺序 POST /api/upload 的 JSDoc 还在描述老接口形状(只列 filename/contentType/ articleSlug),本 PR 把 fileSize 改成了必填 + 加了 MIME primary/正则/SVG 多级闸,JSDoc 对不上签名 & 行为。补上: - @param 列出 fileSize 必填 + 指向 UploadRequest.fileSize 的解释 - 头部加 4 步校验链顺序,顺序敏感 - contentType 标注可带参数(服务端会 extractPrimaryMime) - uploadUrl 说明客户端必须发匹配的 Content-Length / Content-Type * refactor(site-url): 剩余 3 处 NEXT_PUBLIC_SITE_URL 硬编码 fallback 改用 lib/site-url 收尾 acbe3a7 的迁移:layout/docs-slug/u-username 还在各自用 ?? 'https://involutionhell.com',统一走 lib/site-url 的 SITE_URL 常量,符合 docs/architecture/frontend-backend-separation.md:96-103 的 '生产禁止硬编码 fallback' 政策。 * fix(site-url): preview deploy 用 VERCEL_URL 兜底,避免 Vercel preview 构建炸 前一版改成 prod 无 NEXT_PUBLIC_SITE_URL 直接 throw,没考虑 Vercel preview/branch deploy 也跑在 NODE_ENV=production 里、且 Vercel project setting 里通常只给 prod 配 NEXT_PUBLIC_SITE_URL,所以 preview build 在 collect 阶段就被 _not-found 路由的 SITE_URL 求值炸掉。修法:检测 VERCEL_ENV=preview 时用系统注入的 VERCEL_URL(形如 myproject-git-branch-team.vercel.app),prod 仍 throw 不接受 VERCEL_URL 避免漏配静默用 *.vercel.app 域名。 * fix(site-url): prod 域名当代码常量,env 仅作可选 override 实情:NEXT_PUBLIC_SITE_URL 这个 env 一直都没在 Vercel 项目里设过,靠 ?? 'https://involutionhell.com' 兜底活到现在。前两轮把它改成 'prod 必填 + throw' 是按 doc 政策抠字眼,跟现实脱节,结果炸了 Vercel preview。 改成事实陈述:prod 域是常量,4 级解析顺序:显式 env override → Vercel preview 用 VERCEL_URL → 本地 dev 用 localhost:3000 → 其它(含 prod)用硬编码 PROD_SITE_URL。漏配 env 不再 throw,prod build 一次过。
1 parent 1bf1340 commit 905a30a

10 files changed

Lines changed: 254 additions & 50 deletions

File tree

app/api/upload/route.ts

Lines changed: 106 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,65 @@ interface UploadRequest {
2121
filename: string;
2222
contentType: string;
2323
articleSlug: string;
24+
/**
25+
* 必填:客户端上传前本地读取到的文件字节数(File.size)。
26+
* 服务端会:
27+
* 1. 立刻 reject 超过 MAX_UPLOAD_BYTES 的请求(省得签名)
28+
* 2. 把 Content-Length 绑进预签名 URL,让 R2 在上传时 enforce 大小上限
29+
* 客户端上传时必须带匹配的 Content-Length header,否则 R2 拒签。
30+
*
31+
* 为什么必填:如果 optional,直接打 /api/upload 不带 fileSize 会让服务端
32+
* 签出一张没有 ContentLength 约束的 URL,10MB 上限就成了摆设(客户端可上传 GB 级文件)。
33+
*/
34+
fileSize: number;
2435
}
2536

37+
/**
38+
* 服务端硬上限:单次上传 10 MB。
39+
* 注意:因为 R2 走预签名 URL,真正的拦截必须发生在签名阶段(把 ContentLength 绑进 URL),
40+
* 不能只在 /api/upload 这里做本地 byte check——这里根本看不到后续的 PUT 流量。
41+
*/
42+
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
43+
44+
/**
45+
* 从完整的 Content-Type header 值里抽出主 MIME(小写、去空白、丢掉所有参数)。
46+
*
47+
* 为什么需要:类似 `"image/jpeg; image/svg+xml"` 或 `"image/jpeg; charset=utf-8"`
48+
* 这种带参数的值,用 `startsWith("image/")` 校验会过、用 `startsWith("image/svg")`
49+
* 拒 SVG 的黑名单又绕得掉(前缀是 `image/jpeg;`),然后原始字符串塞进 R2 的
50+
* ContentType 再原样回吐给浏览器,触发 MIME sniffing 把 SVG payload 执行起来。
51+
* 所以 SVG 黑名单匹配 + 塞给 R2 的值都必须先收敛到分号前的主 MIME。
52+
*/
53+
function extractPrimaryMime(contentType: string): string {
54+
return contentType.split(";")[0]!.trim().toLowerCase();
55+
}
56+
57+
/**
58+
* 严格 MIME 形状:`type/subtype`,两侧只允许 [a-z0-9.+-],首字符必须是 [a-z0-9]。
59+
*
60+
* 用途:拒绝 CR/LF 及其他控制字符,防止注入被 SDK/R2/浏览器当成多个 header。
61+
* 虽然下游(AWS SDK / R2 / 浏览器)per RFC 7230 也会拒 header 值里的 CR/LF,
62+
* 但入口先收口更便宜也更正确,别依赖下游任何一层。
63+
*/
64+
const MIME_PATTERN = /^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/;
65+
2666
/**
2767
* @description POST /api/upload - 生成 R2 预签名 URL,用于客户端直接上传图片
28-
* @param request - NextRequest 对象,请求体包含以下字段:
68+
*
69+
* 校验链(顺序敏感):
70+
* 1. x-satoken + 后端 /auth/me 鉴权
71+
* 2. fileSize 必填 & Number.isSafeInteger & <= MAX_UPLOAD_BYTES
72+
* 3. contentType → extractPrimaryMime → MIME_PATTERN 正则闸 → `image/` 前缀 → SVG 黑名单
73+
* 4. ContentType / ContentLength 绑进预签名 URL
74+
*
75+
* @param request - NextRequest 对象,请求体(UploadRequest)包含以下字段:
2976
* - filename: 文件名
30-
* - contentType: 文件 MIME 类型
77+
* - contentType: 文件 MIME 类型(可带参数,服务端会抽主 MIME)
3178
* - articleSlug: 文章 slug(用于组织文件路径)
79+
* - fileSize: 必填,文件字节数;见 UploadRequest.fileSize 注释,用于把
80+
* ContentLength 绑进预签名 URL 做服务端大小限制
3281
* @returns NextResponse - 返回 JSON 对象:
33-
* - uploadUrl: 预签名上传 URL(用于 PUT 请求)
82+
* - uploadUrl: 预签名上传 URL(用于 PUT 请求;客户端必须发送匹配的 Content-Length / Content-Type header
3483
* - publicUrl: 图片的公开访问 URL
3584
* - key: R2 对象键
3685
*/
@@ -70,7 +119,7 @@ export async function POST(request: NextRequest) {
70119

71120
// 解析请求体
72121
const body = (await request.json()) as UploadRequest;
73-
const { filename, contentType, articleSlug } = body;
122+
const { filename, contentType, articleSlug, fileSize } = body;
74123

75124
// 验证请求参数
76125
if (!filename || !contentType || !articleSlug) {
@@ -80,13 +129,56 @@ export async function POST(request: NextRequest) {
80129
);
81130
}
82131

83-
// 验证文件类型
84-
if (!contentType.startsWith("image/")) {
132+
// 验证 fileSize 必填 + 合法(必须在签名前完成,否则 ContentLength 绑不进 URL,10MB 上限等于没有)
133+
if (typeof fileSize !== "number") {
134+
return NextResponse.json(
135+
{ error: "缺少必要参数:fileSize(必须是 number)" },
136+
{ status: 400 },
137+
);
138+
}
139+
// Content-Length 必须是非负整数。用 isSafeInteger 直接拒 NaN / Infinity / 小数
140+
// (原来用 isFinite 会放 10.5 之类过去,然后 R2 在 PUT 时才 reject,变成用户看来
141+
// 的静默失败);同时 isSafeInteger 隐含了上界(<=2^53-1),不会被过大的 number 溢出。
142+
if (!Number.isSafeInteger(fileSize) || fileSize < 0) {
143+
return NextResponse.json({ error: "fileSize 参数无效" }, { status: 400 });
144+
}
145+
if (fileSize > MAX_UPLOAD_BYTES) {
146+
return NextResponse.json(
147+
{
148+
error: `文件过大:最大允许 ${MAX_UPLOAD_BYTES} 字节(10 MB)`,
149+
},
150+
{ status: 413 },
151+
);
152+
}
153+
154+
// 验证文件类型:
155+
// 1. 必须是 image/*
156+
// 2. 显式 block image/svg+xml —— SVG 可以内嵌 <script>,即使走 R2 公开 URL 也会在浏览器里执行 JS,
157+
// 构成存储型 XSS 向量。我们宁可让用户转成 PNG/JPG 也不放行。
158+
// 注意:所有判断都走 primaryMime(分号前的主 MIME),绕不过 `"image/jpeg; image/svg+xml"` 这种夹带。
159+
const primaryMime = extractPrimaryMime(contentType);
160+
// 拒绝 CR/LF 及其他控制字符,防止注入被 SDK/R2/浏览器当成多个 header
161+
if (!MIME_PATTERN.test(primaryMime)) {
162+
return NextResponse.json(
163+
{ error: "contentType 格式非法" },
164+
{ status: 400 },
165+
);
166+
}
167+
if (!primaryMime.startsWith("image/")) {
85168
return NextResponse.json(
86169
{ error: "仅支持图片类型文件" },
87170
{ status: 400 },
88171
);
89172
}
173+
if (
174+
primaryMime === "image/svg+xml" ||
175+
primaryMime.startsWith("image/svg")
176+
) {
177+
return NextResponse.json(
178+
{ error: "出于安全原因,不接受 SVG 文件(可能包含可执行脚本)" },
179+
{ status: 400 },
180+
);
181+
}
90182

91183
// 生成唯一的对象键
92184
// 格式:users/{userId}/{article-slug}/{timestamp}-{filename}
@@ -97,10 +189,17 @@ export async function POST(request: NextRequest) {
97189
const key = `users/${userId}/${sanitizedSlug}/${timestamp}-${sanitizedFilename}`;
98190

99191
// 创建 PutObject 命令
192+
// - ContentType 用 primaryMime —— 不能把原始 contentType 原样塞进 R2 对象元数据,
193+
// 否则 `"image/jpeg; image/svg+xml"` 之类的分号夹带会跟着落库,R2 回吐给浏览器时
194+
// 触发 MIME sniffing。
195+
// - ContentLength 强绑 fileSize —— 上传时客户端必须发送匹配的 Content-Length header,
196+
// R2 会 enforce,超过或少于这个数字的 PUT 一律被 R2 拒绝。
197+
// 这是预签名 URL 唯一能做服务端大小限制的机制,所以 fileSize 必须必填。
100198
const command = new PutObjectCommand({
101199
Bucket: process.env.R2_BUCKET_NAME,
102200
Key: key,
103-
ContentType: contentType,
201+
ContentType: primaryMime,
202+
ContentLength: fileSize,
104203
});
105204

106205
// 生成预签名 URL(15 分钟有效期)

app/docs/[...slug]/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { source } from "@/lib/source";
2+
import { SITE_URL } from "@/lib/site-url";
23
import { DocsPage, DocsBody } from "fumadocs-ui/page";
34
import { notFound } from "next/navigation";
45
import type { Metadata } from "next";
@@ -94,8 +95,7 @@ export default async function DocPage({ params }: Param) {
9495
const Mdx = page.data.body;
9596

9697
// SEO 结构化数据
97-
const siteUrl =
98-
process.env.NEXT_PUBLIC_SITE_URL || "https://involutionhell.com";
98+
const siteUrl = SITE_URL;
9999
const slugPath = (slug ?? []).join("/");
100100
const docUrl = slugPath ? `${siteUrl}/docs/${slugPath}` : `${siteUrl}/docs`;
101101

app/editor/EditorPageClient.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,21 @@ export function EditorPageClient({ user }: EditorPageClientProps) {
8282
file: File,
8383
articleSlug: string,
8484
): Promise<{ blobUrl: string; publicUrl: string }> => {
85+
// 规范化 Content-Type:只取主 MIME(分号前)+ trim + 小写。
86+
// 服务端预签名 URL 绑的是这个规范化后的 ContentType,客户端 PUT 时的
87+
// Content-Type header 必须 byte-exact 对得上,否则 R2 返 403 SignatureDoesNotMatch。
88+
// 浏览器 file.type 在极少见情况下可能是 "Image/JPEG" 或 "image/jpeg; foo=bar",
89+
// 不能直接原样透传。
90+
const primaryMime = file.type.split(";")[0]!.trim().toLowerCase();
91+
if (!primaryMime) {
92+
// 浏览器识别不出 MIME(某些冷门类型会给空串)。此时继续走会被服务端 MIME_PATTERN
93+
// 正则直接 400,给个本地报错更清晰,和 editor 里其它 throw -> handlePublish alert 的
94+
// 链路一致。
95+
throw new Error(
96+
`无法识别图片类型:${file.name}(浏览器未给出 MIME),请另存为 PNG/JPG/WebP 后重试`,
97+
);
98+
}
99+
85100
// 1. 获取预签名 URL(带 x-satoken 请求头,供服务端验证身份)
86101
const token = localStorage.getItem("satoken") ?? "";
87102
const response = await fetch("/api/upload", {
@@ -92,8 +107,9 @@ export function EditorPageClient({ user }: EditorPageClientProps) {
92107
},
93108
body: JSON.stringify({
94109
filename: file.name,
95-
contentType: file.type,
110+
contentType: primaryMime,
96111
articleSlug,
112+
fileSize: file.size,
97113
}),
98114
});
99115

@@ -104,11 +120,11 @@ export function EditorPageClient({ user }: EditorPageClientProps) {
104120

105121
const { uploadUrl, publicUrl } = await response.json();
106122

107-
// 2. 上传文件到 R2
123+
// 2. 上传文件到 R2 —— Content-Type 必须和签名时服务端绑的 primaryMime byte-exact 一致
108124
const uploadResponse = await fetch(uploadUrl, {
109125
method: "PUT",
110126
headers: {
111-
"Content-Type": file.type,
127+
"Content-Type": primaryMime,
112128
},
113129
body: file,
114130
});

app/layout.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,7 @@ const geistMono = localFont({
2626
weight: "100 900",
2727
});
2828

29-
const SITE_URL =
30-
process.env.NEXT_PUBLIC_SITE_URL || "https://involutionhell.com";
29+
import { SITE_URL } from "@/lib/site-url";
3130
const en_description =
3231
"内卷地狱(Involution Hell)是一个由开发者发起的开源学习社区,专注算法、系统设计、工程实践与技术分享,帮助华人程序员高效成长,专注真实进步。Involution Hell is an open-source community empowering builders with real-world engineering.";
3332

app/robots.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// app/robots.ts
2+
3+
/**
4+
* @file app/robots.ts
5+
* @description
6+
* 站点 robots.txt 生成器(Next.js App Router 约定文件)。
7+
*
8+
* 屏蔽以下路径:
9+
* - /admin/ —— 后台管理页,登录态专属,没必要入索引
10+
* - /editor/ —— 编辑器页,登录态专属
11+
* - /settings/ —— 用户设置,登录态专属
12+
* - /login —— 登录页,入搜索引擎反而会诱导钓鱼
13+
* - /api/ —— 所有服务端接口,不是给爬虫看的
14+
*
15+
* sitemap 指向 app/sitemap.ts 产出的 /sitemap.xml,hostname 复用同一份 NEXT_PUBLIC_SITE_URL。
16+
*
17+
* @see https://nextjs.org/docs/app/api-reference/file-conventions/robots
18+
*/
19+
20+
import type { MetadataRoute } from "next";
21+
import { SITE_URL } from "@/lib/site-url";
22+
23+
export default function robots(): MetadataRoute.Robots {
24+
return {
25+
rules: [
26+
{
27+
userAgent: "*",
28+
allow: "/",
29+
disallow: ["/admin/", "/editor/", "/settings/", "/login", "/api/"],
30+
},
31+
],
32+
sitemap: `${SITE_URL}/sitemap.xml`,
33+
};
34+
}

app/settings/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export const metadata: Metadata = {
1111
title: "Settings",
1212
description: "Customize theme, language, and AI assistant preferences.",
1313
alternates: { canonical: "/settings" },
14-
robots: { index: false, follow: true },
14+
robots: { index: false, follow: false },
1515
};
1616

1717
export default function SettingsPage() {

app/sitemap.ts

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,9 @@
2121
import type { MetadataRoute } from "next";
2222
import { source } from "@/lib/source";
2323
import leaderboard from "@/generated/site-leaderboard.json";
24-
25-
/**
26-
* 从环境变量中读取的站点根 URL。
27-
* 默认为一个回退地址。
28-
*/
29-
const RAW_SITE_URL =
30-
process.env.NEXT_PUBLIC_SITE_URL ?? "https://involutionhell.com";
31-
32-
/**
33-
* 经过规范化处理的站点 URL(确保有协议头,且不带尾部斜杠)。
34-
* 例如: "https://example.com"
35-
*/
36-
const SITE_URL = normalizeSiteUrl(RAW_SITE_URL);
24+
// SITE_URL 由 lib/site-url.ts 统一提供(从 NEXT_PUBLIC_SITE_URL 读 + 归一化),
25+
// 这里和 app/robots.ts 共用一份,避免两边 drift。
26+
import { SITE_URL } from "@/lib/site-url";
3727

3828
/** * 定义 `source.getPages()` 返回的单个页面对象的类型别名
3929
*/
@@ -228,13 +218,3 @@ function isDraftOrHidden(page: SourcePage): boolean {
228218
d.frontmatter?.hidden
229219
);
230220
}
231-
232-
/**
233-
* 规范化站点的 URL。
234-
* * @param {string} url - 原始 URL 字符串。
235-
* @returns {string} 规范化后的 URL。
236-
*/
237-
function normalizeSiteUrl(url: string): string {
238-
const withProto = /^https?:\/\//i.test(url) ? url : `https://${url}`;
239-
return withProto.replace(/\/+$/, "");
240-
}

app/u/[username]/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { GithubRepos, GithubReposSkeleton } from "./GithubRepos";
1717
import { Suspense } from "react";
1818
import { getTranslations } from "next-intl/server";
1919
import { sanitizeExternalUrl } from "@/lib/url-safety";
20+
import { SITE_URL } from "@/lib/site-url";
2021

2122
interface UserView {
2223
id: number;
@@ -342,8 +343,7 @@ export default async function UserProfilePage({ params }: Param) {
342343
});
343344

344345
// Person JSON-LD:让搜索引擎识别这是一个"个人档案"而不是普通页面,有机会走 knowledge panel
345-
const siteUrl =
346-
process.env.NEXT_PUBLIC_SITE_URL || "https://involutionhell.com";
346+
const siteUrl = SITE_URL;
347347
const personJsonLd = {
348348
"@context": "https://schema.org",
349349
"@type": "Person",

lib/search-index.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,32 @@
11
import type { AdvancedIndex } from "fumadocs-core/search/server";
2+
// StructuredData 是 fumadocs-core 的公开导出(从 mdx-plugins 入口),
3+
// 直接用上游类型,不再在本地维护同形副本以免两边 drift。
4+
import type { StructuredData } from "fumadocs-core/mdx-plugins";
25
import { source } from "@/lib/source";
36
import { basename, extname } from "path";
47

58
type Page = ReturnType<typeof source.getPages>[number];
69

10+
/**
11+
* fumadocs page.data 在构建产物里的 runtime shape。
12+
* 老路径:structuredData 直接 inline;新路径:通过 load() 异步拉。
13+
*/
14+
interface PageDataShape {
15+
structuredData?: StructuredData;
16+
load?: () => Promise<{ structuredData: StructuredData }>;
17+
title?: string;
18+
description?: string;
19+
}
20+
721
/**
822
* 把一个 fumadocs 页面转成 Orama 索引项(复用 fumadocs-core 默认实现逻辑),
923
* 单独抽出来是因为我们需要分片(zh / en),用 createSearchAPI 手动传 indexes。
1024
*/
1125
export async function pageToIndex(page: Page): Promise<AdvancedIndex> {
12-
const data = page.data as {
13-
structuredData?: unknown;
14-
load?: () => Promise<{ structuredData: unknown }>;
15-
title?: string;
16-
description?: string;
17-
};
26+
const data = page.data as PageDataShape;
1827

19-
let structuredData: unknown;
20-
if ("structuredData" in data && data.structuredData) {
28+
let structuredData: StructuredData | undefined;
29+
if (data.structuredData) {
2130
structuredData = data.structuredData;
2231
} else if (typeof data.load === "function") {
2332
structuredData = (await data.load()).structuredData;
@@ -35,8 +44,7 @@ export async function pageToIndex(page: Page): Promise<AdvancedIndex> {
3544
description: data.description,
3645
url: page.url,
3746
structuredData,
38-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
39-
} as any;
47+
};
4048
}
4149

4250
/**

0 commit comments

Comments
 (0)