Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
f3770eb
fix(upload): 拒绝 SVG + 把 10MB 大小上限绑进 R2 预签名 URL
longsizhuo Apr 24, 2026
839934d
feat(seo): 加 app/robots.ts,屏蔽登录态 / 内部 API 路径
longsizhuo Apr 24, 2026
1d352c4
fix(seo): settings 页 robots follow 也关掉
longsizhuo Apr 24, 2026
ee4b8ca
fix(search): 干掉 lib/search-index.ts 的 as any cast
longsizhuo Apr 24, 2026
ea041e0
fix(upload): fileSize 改成必填,ContentLength 恒绑进预签名 URL
longsizhuo Apr 24, 2026
b0a5a53
fix(upload): 提取 primary MIME,阻断分号夹带绕过 SVG 黑名单
longsizhuo Apr 24, 2026
acb3d7a
refactor(site-url): 抽 normalizeSiteUrl 到 lib/site-url.ts,robots/sitem…
longsizhuo Apr 24, 2026
8044e39
fix(search): 改用 fumadocs-core/mdx-plugins 公开导出的 StructuredData,删本地副本
longsizhuo Apr 24, 2026
a3f16b9
fix(upload): contentType 加严格 MIME 正则闸,拒 CR/LF 及其他控制字符
longsizhuo Apr 24, 2026
352e83c
fix(site-url): 拿掉 prod 硬编码 fallback,env 缺失生产即抛错
longsizhuo Apr 24, 2026
50b3e4e
fix(editor): 上传前 normalize file.type,避免 R2 SignatureDoesNotMatch
longsizhuo Apr 24, 2026
fd6a286
fix(upload): fileSize 用 Number.isSafeInteger 校验,拒小数
longsizhuo Apr 24, 2026
c7e2853
docs(upload): JSDoc 补齐 fileSize 必填字段,附校验链顺序
longsizhuo Apr 24, 2026
0c40a35
refactor(site-url): 剩余 3 处 NEXT_PUBLIC_SITE_URL 硬编码 fallback 改用 lib/s…
longsizhuo Apr 24, 2026
deef079
fix(site-url): preview deploy 用 VERCEL_URL 兜底,避免 Vercel preview 构建炸
longsizhuo Apr 25, 2026
a9ef0a0
fix(site-url): prod 域名当代码常量,env 仅作可选 override
longsizhuo Apr 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 106 additions & 7 deletions app/api/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,65 @@ interface UploadRequest {
filename: string;
contentType: string;
articleSlug: string;
/**
* 必填:客户端上传前本地读取到的文件字节数(File.size)。
* 服务端会:
* 1. 立刻 reject 超过 MAX_UPLOAD_BYTES 的请求(省得签名)
* 2. 把 Content-Length 绑进预签名 URL,让 R2 在上传时 enforce 大小上限
* 客户端上传时必须带匹配的 Content-Length header,否则 R2 拒签。
*
* 为什么必填:如果 optional,直接打 /api/upload 不带 fileSize 会让服务端
* 签出一张没有 ContentLength 约束的 URL,10MB 上限就成了摆设(客户端可上传 GB 级文件)。
*/
fileSize: number;
}

/**
* 服务端硬上限:单次上传 10 MB。
* 注意:因为 R2 走预签名 URL,真正的拦截必须发生在签名阶段(把 ContentLength 绑进 URL),
* 不能只在 /api/upload 这里做本地 byte check——这里根本看不到后续的 PUT 流量。
*/
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;

/**
* 从完整的 Content-Type header 值里抽出主 MIME(小写、去空白、丢掉所有参数)。
*
* 为什么需要:类似 `"image/jpeg; image/svg+xml"` 或 `"image/jpeg; charset=utf-8"`
* 这种带参数的值,用 `startsWith("image/")` 校验会过、用 `startsWith("image/svg")`
* 拒 SVG 的黑名单又绕得掉(前缀是 `image/jpeg;`),然后原始字符串塞进 R2 的
* ContentType 再原样回吐给浏览器,触发 MIME sniffing 把 SVG payload 执行起来。
* 所以 SVG 黑名单匹配 + 塞给 R2 的值都必须先收敛到分号前的主 MIME。
*/
function extractPrimaryMime(contentType: string): string {
return contentType.split(";")[0]!.trim().toLowerCase();
}

/**
* 严格 MIME 形状:`type/subtype`,两侧只允许 [a-z0-9.+-],首字符必须是 [a-z0-9]。
*
* 用途:拒绝 CR/LF 及其他控制字符,防止注入被 SDK/R2/浏览器当成多个 header。
* 虽然下游(AWS SDK / R2 / 浏览器)per RFC 7230 也会拒 header 值里的 CR/LF,
* 但入口先收口更便宜也更正确,别依赖下游任何一层。
*/
const MIME_PATTERN = /^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/;

/**
* @description POST /api/upload - 生成 R2 预签名 URL,用于客户端直接上传图片
* @param request - NextRequest 对象,请求体包含以下字段:
*
* 校验链(顺序敏感):
* 1. x-satoken + 后端 /auth/me 鉴权
* 2. fileSize 必填 & Number.isSafeInteger & <= MAX_UPLOAD_BYTES
* 3. contentType → extractPrimaryMime → MIME_PATTERN 正则闸 → `image/` 前缀 → SVG 黑名单
* 4. ContentType / ContentLength 绑进预签名 URL
*
* @param request - NextRequest 对象,请求体(UploadRequest)包含以下字段:
* - filename: 文件名
* - contentType: 文件 MIME 类型
* - contentType: 文件 MIME 类型(可带参数,服务端会抽主 MIME)
* - articleSlug: 文章 slug(用于组织文件路径)
* - fileSize: 必填,文件字节数;见 UploadRequest.fileSize 注释,用于把
* ContentLength 绑进预签名 URL 做服务端大小限制
* @returns NextResponse - 返回 JSON 对象:
* - uploadUrl: 预签名上传 URL(用于 PUT 请求)
* - uploadUrl: 预签名上传 URL(用于 PUT 请求;客户端必须发送匹配的 Content-Length / Content-Type header
* - publicUrl: 图片的公开访问 URL
* - key: R2 对象键
*/
Expand Down Expand Up @@ -70,7 +119,7 @@ export async function POST(request: NextRequest) {

// 解析请求体
const body = (await request.json()) as UploadRequest;
const { filename, contentType, articleSlug } = body;
const { filename, contentType, articleSlug, fileSize } = body;

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

// 验证文件类型
if (!contentType.startsWith("image/")) {
// 验证 fileSize 必填 + 合法(必须在签名前完成,否则 ContentLength 绑不进 URL,10MB 上限等于没有)
if (typeof fileSize !== "number") {
return NextResponse.json(
{ error: "缺少必要参数:fileSize(必须是 number)" },
{ status: 400 },
);
}
// Content-Length 必须是非负整数。用 isSafeInteger 直接拒 NaN / Infinity / 小数
// (原来用 isFinite 会放 10.5 之类过去,然后 R2 在 PUT 时才 reject,变成用户看来
// 的静默失败);同时 isSafeInteger 隐含了上界(<=2^53-1),不会被过大的 number 溢出。
if (!Number.isSafeInteger(fileSize) || fileSize < 0) {
return NextResponse.json({ error: "fileSize 参数无效" }, { status: 400 });
}
if (fileSize > MAX_UPLOAD_BYTES) {
return NextResponse.json(
{
error: `文件过大:最大允许 ${MAX_UPLOAD_BYTES} 字节(10 MB)`,
},
{ status: 413 },
);
}

// 验证文件类型:
// 1. 必须是 image/*
// 2. 显式 block image/svg+xml —— SVG 可以内嵌 <script>,即使走 R2 公开 URL 也会在浏览器里执行 JS,
// 构成存储型 XSS 向量。我们宁可让用户转成 PNG/JPG 也不放行。
// 注意:所有判断都走 primaryMime(分号前的主 MIME),绕不过 `"image/jpeg; image/svg+xml"` 这种夹带。
const primaryMime = extractPrimaryMime(contentType);
// 拒绝 CR/LF 及其他控制字符,防止注入被 SDK/R2/浏览器当成多个 header
if (!MIME_PATTERN.test(primaryMime)) {
return NextResponse.json(
{ error: "contentType 格式非法" },
{ status: 400 },
);
}
if (!primaryMime.startsWith("image/")) {
return NextResponse.json(
{ error: "仅支持图片类型文件" },
{ status: 400 },
);
}
if (
primaryMime === "image/svg+xml" ||
primaryMime.startsWith("image/svg")
) {
return NextResponse.json(
{ error: "出于安全原因,不接受 SVG 文件(可能包含可执行脚本)" },
{ status: 400 },
);
}

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

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

// 生成预签名 URL(15 分钟有效期)
Expand Down
4 changes: 2 additions & 2 deletions app/docs/[...slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { source } from "@/lib/source";
import { SITE_URL } from "@/lib/site-url";
import { DocsPage, DocsBody } from "fumadocs-ui/page";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
Expand Down Expand Up @@ -94,8 +95,7 @@ export default async function DocPage({ params }: Param) {
const Mdx = page.data.body;

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

Expand Down
22 changes: 19 additions & 3 deletions app/editor/EditorPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,21 @@ export function EditorPageClient({ user }: EditorPageClientProps) {
file: File,
articleSlug: string,
): Promise<{ blobUrl: string; publicUrl: string }> => {
// 规范化 Content-Type:只取主 MIME(分号前)+ trim + 小写。
// 服务端预签名 URL 绑的是这个规范化后的 ContentType,客户端 PUT 时的
// Content-Type header 必须 byte-exact 对得上,否则 R2 返 403 SignatureDoesNotMatch。
// 浏览器 file.type 在极少见情况下可能是 "Image/JPEG" 或 "image/jpeg; foo=bar",
// 不能直接原样透传。
const primaryMime = file.type.split(";")[0]!.trim().toLowerCase();
if (!primaryMime) {
// 浏览器识别不出 MIME(某些冷门类型会给空串)。此时继续走会被服务端 MIME_PATTERN
// 正则直接 400,给个本地报错更清晰,和 editor 里其它 throw -> handlePublish alert 的
// 链路一致。
throw new Error(
`无法识别图片类型:${file.name}(浏览器未给出 MIME),请另存为 PNG/JPG/WebP 后重试`,
);
}

// 1. 获取预签名 URL(带 x-satoken 请求头,供服务端验证身份)
const token = localStorage.getItem("satoken") ?? "";
const response = await fetch("/api/upload", {
Expand All @@ -92,8 +107,9 @@ export function EditorPageClient({ user }: EditorPageClientProps) {
},
body: JSON.stringify({
filename: file.name,
contentType: file.type,
contentType: primaryMime,
articleSlug,
fileSize: file.size,
}),
});

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

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

// 2. 上传文件到 R2
// 2. 上传文件到 R2 —— Content-Type 必须和签名时服务端绑的 primaryMime byte-exact 一致
const uploadResponse = await fetch(uploadUrl, {
method: "PUT",
headers: {
"Content-Type": file.type,
"Content-Type": primaryMime,
},
body: file,
});
Expand Down
3 changes: 1 addition & 2 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@
weight: "100 900",
});

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

Expand Down Expand Up @@ -146,7 +145,7 @@
href="https://fonts.gstatic.com"
crossOrigin="anonymous"
/>
<link

Check warning on line 148 in app/layout.tsx

View workflow job for this annotation

GitHub Actions / build

Custom fonts not added in `pages/_document.js` will only load for a single page. This is discouraged. See: https://nextjs.org/docs/messages/no-page-custom-font
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Playfair+Display:ital,wght@0,400;0,600;0,700;0,900;1,400&family=Lora:ital,wght@0,400;0,600;1,400&display=swap"
/>
Expand Down
34 changes: 34 additions & 0 deletions app/robots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// app/robots.ts

/**
* @file app/robots.ts
* @description
* 站点 robots.txt 生成器(Next.js App Router 约定文件)。
*
* 屏蔽以下路径:
* - /admin/ —— 后台管理页,登录态专属,没必要入索引
* - /editor/ —— 编辑器页,登录态专属
* - /settings/ —— 用户设置,登录态专属
* - /login —— 登录页,入搜索引擎反而会诱导钓鱼
* - /api/ —— 所有服务端接口,不是给爬虫看的
*
* sitemap 指向 app/sitemap.ts 产出的 /sitemap.xml,hostname 复用同一份 NEXT_PUBLIC_SITE_URL。
*
* @see https://nextjs.org/docs/app/api-reference/file-conventions/robots
*/

import type { MetadataRoute } from "next";
import { SITE_URL } from "@/lib/site-url";

export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/admin/", "/editor/", "/settings/", "/login", "/api/"],
},
],
sitemap: `${SITE_URL}/sitemap.xml`,
};
}
2 changes: 1 addition & 1 deletion app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const metadata: Metadata = {
title: "Settings",
description: "Customize theme, language, and AI assistant preferences.",
alternates: { canonical: "/settings" },
robots: { index: false, follow: true },
robots: { index: false, follow: false },
};

export default function SettingsPage() {
Expand Down
26 changes: 3 additions & 23 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,9 @@
import type { MetadataRoute } from "next";
import { source } from "@/lib/source";
import leaderboard from "@/generated/site-leaderboard.json";

/**
* 从环境变量中读取的站点根 URL。
* 默认为一个回退地址。
*/
const RAW_SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL ?? "https://involutionhell.com";

/**
* 经过规范化处理的站点 URL(确保有协议头,且不带尾部斜杠)。
* 例如: "https://example.com"
*/
const SITE_URL = normalizeSiteUrl(RAW_SITE_URL);
// SITE_URL 由 lib/site-url.ts 统一提供(从 NEXT_PUBLIC_SITE_URL 读 + 归一化),
// 这里和 app/robots.ts 共用一份,避免两边 drift。
import { SITE_URL } from "@/lib/site-url";

/** * 定义 `source.getPages()` 返回的单个页面对象的类型别名
*/
Expand Down Expand Up @@ -228,13 +218,3 @@ function isDraftOrHidden(page: SourcePage): boolean {
d.frontmatter?.hidden
);
}

/**
* 规范化站点的 URL。
* * @param {string} url - 原始 URL 字符串。
* @returns {string} 规范化后的 URL。
*/
function normalizeSiteUrl(url: string): string {
const withProto = /^https?:\/\//i.test(url) ? url : `https://${url}`;
return withProto.replace(/\/+$/, "");
}
4 changes: 2 additions & 2 deletions app/u/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { GithubRepos, GithubReposSkeleton } from "./GithubRepos";
import { Suspense } from "react";
import { getTranslations } from "next-intl/server";
import { sanitizeExternalUrl } from "@/lib/url-safety";
import { SITE_URL } from "@/lib/site-url";

interface UserView {
id: number;
Expand Down Expand Up @@ -342,8 +343,7 @@ export default async function UserProfilePage({ params }: Param) {
});

// Person JSON-LD:让搜索引擎识别这是一个"个人档案"而不是普通页面,有机会走 knowledge panel
const siteUrl =
process.env.NEXT_PUBLIC_SITE_URL || "https://involutionhell.com";
const siteUrl = SITE_URL;
const personJsonLd = {
"@context": "https://schema.org",
"@type": "Person",
Expand Down
28 changes: 18 additions & 10 deletions lib/search-index.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,32 @@
import type { AdvancedIndex } from "fumadocs-core/search/server";
// StructuredData 是 fumadocs-core 的公开导出(从 mdx-plugins 入口),
// 直接用上游类型,不再在本地维护同形副本以免两边 drift。
import type { StructuredData } from "fumadocs-core/mdx-plugins";
import { source } from "@/lib/source";
import { basename, extname } from "path";

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

/**
* fumadocs page.data 在构建产物里的 runtime shape。
* 老路径:structuredData 直接 inline;新路径:通过 load() 异步拉。
*/
interface PageDataShape {
structuredData?: StructuredData;
load?: () => Promise<{ structuredData: StructuredData }>;
title?: string;
description?: string;
}

/**
* 把一个 fumadocs 页面转成 Orama 索引项(复用 fumadocs-core 默认实现逻辑),
* 单独抽出来是因为我们需要分片(zh / en),用 createSearchAPI 手动传 indexes。
*/
export async function pageToIndex(page: Page): Promise<AdvancedIndex> {
const data = page.data as {
structuredData?: unknown;
load?: () => Promise<{ structuredData: unknown }>;
title?: string;
description?: string;
};
const data = page.data as PageDataShape;

let structuredData: unknown;
if ("structuredData" in data && data.structuredData) {
let structuredData: StructuredData | undefined;
if (data.structuredData) {
structuredData = data.structuredData;
} else if (typeof data.load === "function") {
structuredData = (await data.load()).structuredData;
Expand All @@ -35,8 +44,7 @@ export async function pageToIndex(page: Page): Promise<AdvancedIndex> {
description: data.description,
url: page.url,
structuredData,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
};
}

/**
Expand Down
Loading
Loading