Skip to content

Commit 83776ee

Browse files
committed
feat: R2 表单签名限流
1 parent e00ceaf commit 83776ee

3 files changed

Lines changed: 223 additions & 45 deletions

File tree

app/api/upload/route.ts

Lines changed: 120 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,23 @@
11
import { auth } from "@/auth";
22
import { NextRequest, NextResponse } from "next/server";
3-
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
4-
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
3+
import { sanitizeSlug, validateSlug } from "@/lib/submission";
4+
import {
5+
MAX_IMAGE_UPLOAD_BYTES,
6+
isAllowedImageContentType,
7+
isExtensionAllowedForContentType,
8+
isValidFileSize,
9+
sanitizeFilename,
10+
} from "@/lib/uploads";
11+
import { createImageUploadPost, createR2Client } from "@/lib/r2";
12+
import type { AllowedImageContentType } from "@/lib/uploads";
513

6-
/**
7-
* R2 配置
8-
* Cloudflare R2 兼容 S3 API,使用 AWS SDK 连接
9-
*/
10-
const r2Client = new S3Client({
11-
region: "auto",
12-
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
13-
credentials: {
14-
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
15-
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
16-
},
17-
});
14+
const MAX_OBJECT_KEY_BYTES = 1024;
1815

1916
interface UploadRequest {
2017
filename: string;
2118
contentType: string;
2219
articleSlug: string;
20+
fileSize: number;
2321
}
2422

2523
/**
@@ -28,27 +26,35 @@ interface UploadRequest {
2826
* - filename: 文件名
2927
* - contentType: 文件 MIME 类型
3028
* - articleSlug: 文章 slug(用于组织文件路径)
29+
* - fileSize: 文件大小(字节,用于限制超大上传)
3130
* @returns NextResponse - 返回 JSON 对象:
32-
* - uploadUrl: 预签名上传 URL(用于 PUT 请求)
31+
* - uploadUrl: 预签名上传 URL(用于表单 POST)
32+
* - fields: 需随表单一同提交的字段
3333
* - publicUrl: 图片的公开访问 URL
3434
* - key: R2 对象键
3535
*/
3636
export async function POST(request: NextRequest) {
3737
try {
38-
// 验证用户身份
3938
const session = await auth();
4039

4140
if (!session?.user?.id) {
4241
return NextResponse.json({ error: "未授权访问" }, { status: 401 });
4342
}
4443

45-
// 验证环境变量
44+
const {
45+
R2_ACCOUNT_ID,
46+
R2_ACCESS_KEY_ID,
47+
R2_SECRET_ACCESS_KEY,
48+
R2_BUCKET_NAME,
49+
R2_PUBLIC_URL,
50+
} = process.env;
51+
4652
if (
47-
!process.env.R2_ACCOUNT_ID ||
48-
!process.env.R2_ACCESS_KEY_ID ||
49-
!process.env.R2_SECRET_ACCESS_KEY ||
50-
!process.env.R2_BUCKET_NAME ||
51-
!process.env.R2_PUBLIC_URL
53+
!R2_ACCOUNT_ID ||
54+
!R2_ACCESS_KEY_ID ||
55+
!R2_SECRET_ACCESS_KEY ||
56+
!R2_BUCKET_NAME ||
57+
!R2_PUBLIC_URL
5258
) {
5359
console.error("R2 环境变量未配置");
5460
return NextResponse.json(
@@ -57,49 +63,118 @@ export async function POST(request: NextRequest) {
5763
);
5864
}
5965

60-
// 解析请求体
61-
const body = (await request.json()) as UploadRequest;
62-
const { filename, contentType, articleSlug } = body;
66+
let body: UploadRequest;
67+
try {
68+
body = (await request.json()) as UploadRequest;
69+
} catch {
70+
return NextResponse.json(
71+
{ error: "请求体格式错误:应为 JSON" },
72+
{ status: 400 },
73+
);
74+
}
75+
76+
const { filename, contentType, articleSlug, fileSize } = body;
77+
78+
if (
79+
typeof filename !== "string" ||
80+
typeof contentType !== "string" ||
81+
typeof articleSlug !== "string" ||
82+
typeof fileSize === "undefined"
83+
) {
84+
return NextResponse.json(
85+
{
86+
error: "缺少必要参数:filename, contentType, articleSlug, fileSize",
87+
},
88+
{ status: 400 },
89+
);
90+
}
91+
92+
const normalizedContentType = contentType.toLowerCase();
93+
const sanitizedSlug = sanitizeSlug(articleSlug);
94+
if (!validateSlug(sanitizedSlug) || sanitizedSlug !== articleSlug) {
95+
return NextResponse.json(
96+
{
97+
error:
98+
"articleSlug 不符合规范(需为 1-100 位小写字母、数字、连字符或下划线)",
99+
},
100+
{ status: 400 },
101+
);
102+
}
63103

64-
// 验证请求参数
65-
if (!filename || !contentType || !articleSlug) {
104+
if (!isValidFileSize(fileSize)) {
66105
return NextResponse.json(
67-
{ error: "缺少必要参数:filename, contentType, articleSlug" },
106+
{
107+
error: `文件大小无效或超过限制(最大 ${
108+
MAX_IMAGE_UPLOAD_BYTES / (1024 * 1024)
109+
}MB)`,
110+
},
68111
{ status: 400 },
69112
);
70113
}
71114

72-
// 验证文件类型
73-
if (!contentType.startsWith("image/")) {
115+
if (!isAllowedImageContentType(normalizedContentType)) {
74116
return NextResponse.json(
75-
{ error: "仅支持图片类型文件" },
117+
{
118+
error: "仅支持图片类型:image/jpeg, image/png, image/gif, image/webp",
119+
},
120+
{ status: 400 },
121+
);
122+
}
123+
124+
const sanitizedFilename = sanitizeFilename(filename);
125+
126+
if (!sanitizedFilename) {
127+
return NextResponse.json(
128+
{
129+
error: "文件名不合法,仅支持字母、数字、., _, -,且不能以 . 开头",
130+
},
131+
{ status: 400 },
132+
);
133+
}
134+
135+
if (
136+
!isExtensionAllowedForContentType(
137+
sanitizedFilename,
138+
normalizedContentType,
139+
)
140+
) {
141+
return NextResponse.json(
142+
{
143+
error: "文件扩展名与 contentType 不匹配或不受支持",
144+
},
76145
{ status: 400 },
77146
);
78147
}
79148

80-
// 生成唯一的对象键
81-
// 格式:users/{userId}/{article-slug}/{timestamp}-{filename}
82149
const timestamp = Date.now();
83150
const userId = session.user.id;
84-
const key = `users/${userId}/${articleSlug}/${timestamp}-${filename}`;
151+
const key = `users/${userId}/${sanitizedSlug}/${timestamp}-${sanitizedFilename}`;
85152

86-
// 创建 PutObject 命令
87-
const command = new PutObjectCommand({
88-
Bucket: process.env.R2_BUCKET_NAME,
89-
Key: key,
90-
ContentType: contentType,
153+
if (Buffer.byteLength(key, "utf8") > MAX_OBJECT_KEY_BYTES) {
154+
return NextResponse.json(
155+
{ error: "生成的对象 Key 过长,请缩短文件名或文章 slug" },
156+
{ status: 400 },
157+
);
158+
}
159+
160+
const r2Client = createR2Client({
161+
accountId: R2_ACCOUNT_ID,
162+
accessKeyId: R2_ACCESS_KEY_ID,
163+
secretAccessKey: R2_SECRET_ACCESS_KEY,
91164
});
92165

93-
// 生成预签名 URL(15 分钟有效期)
94-
const uploadUrl = await getSignedUrl(r2Client, command, {
95-
expiresIn: 900,
166+
const presignedPost = await createImageUploadPost({
167+
client: r2Client,
168+
bucket: R2_BUCKET_NAME,
169+
key,
170+
contentType: normalizedContentType as AllowedImageContentType,
96171
});
97172

98-
// 生成公开访问 URL
99-
const publicUrl = `${process.env.R2_PUBLIC_URL}/${key}`;
173+
const publicUrl = `${R2_PUBLIC_URL}/${key}`;
100174

101175
return NextResponse.json({
102-
uploadUrl,
176+
uploadUrl: presignedPost.url,
177+
fields: presignedPost.fields,
103178
publicUrl,
104179
key,
105180
});

lib/r2.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { S3Client } from "@aws-sdk/client-s3";
2+
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
3+
import type { AllowedImageContentType } from "./uploads";
4+
import { MAX_IMAGE_UPLOAD_BYTES } from "./uploads";
5+
6+
export type R2Config = {
7+
accountId: string;
8+
accessKeyId: string;
9+
secretAccessKey: string;
10+
};
11+
12+
export function createR2Client(config: R2Config) {
13+
return new S3Client({
14+
region: "auto",
15+
endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`,
16+
credentials: {
17+
accessKeyId: config.accessKeyId,
18+
secretAccessKey: config.secretAccessKey,
19+
},
20+
});
21+
}
22+
23+
export async function createImageUploadPost(params: {
24+
client: S3Client;
25+
bucket: string;
26+
key: string;
27+
contentType: AllowedImageContentType;
28+
}) {
29+
return createPresignedPost(params.client, {
30+
Bucket: params.bucket,
31+
Key: params.key,
32+
Fields: {
33+
"Content-Type": params.contentType,
34+
},
35+
Conditions: [
36+
["eq", "$Content-Type", params.contentType],
37+
["content-length-range", 1, MAX_IMAGE_UPLOAD_BYTES],
38+
["eq", "$key", params.key],
39+
],
40+
Expires: 900, // 15min
41+
});
42+
}

lib/uploads.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
export const MAX_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024; // 10MB
2+
export const MAX_FILENAME_LENGTH = 255;
3+
4+
export const ALLOWED_IMAGE_TYPES = {
5+
"image/jpeg": ["jpg", "jpeg"],
6+
"image/png": ["png"],
7+
"image/gif": ["gif"],
8+
"image/webp": ["webp"],
9+
} as const;
10+
11+
export type AllowedImageContentType = keyof typeof ALLOWED_IMAGE_TYPES;
12+
13+
export function isAllowedImageContentType(
14+
contentType: string,
15+
): contentType is AllowedImageContentType {
16+
return contentType in ALLOWED_IMAGE_TYPES;
17+
}
18+
19+
export function isValidFileSize(size: unknown): size is number {
20+
return (
21+
typeof size === "number" &&
22+
Number.isSafeInteger(size) &&
23+
size > 0 &&
24+
size <= MAX_IMAGE_UPLOAD_BYTES
25+
);
26+
}
27+
28+
export function sanitizeFilename(input: string) {
29+
const normalized = input.normalize("NFKC").trim();
30+
let cleaned = normalized.replace(/[^A-Za-z0-9._-]+/g, "_");
31+
cleaned = cleaned.replace(/\.{2,}/g, ".");
32+
cleaned = cleaned.replace(/^\.+/, "_");
33+
cleaned = cleaned.replace(/\.+$/g, "");
34+
35+
if (cleaned.length > MAX_FILENAME_LENGTH) {
36+
cleaned = cleaned.slice(0, MAX_FILENAME_LENGTH).replace(/\.+$/g, "");
37+
}
38+
39+
if (!cleaned || !/[A-Za-z0-9]/.test(cleaned)) {
40+
return "";
41+
}
42+
43+
return cleaned;
44+
}
45+
46+
export function extractFileExtension(filename: string) {
47+
const parts = filename.split(".");
48+
if (parts.length < 2) return "";
49+
return parts.pop()?.toLowerCase() ?? "";
50+
}
51+
52+
export function isExtensionAllowedForContentType(
53+
filename: string,
54+
contentType: string,
55+
) {
56+
if (!isAllowedImageContentType(contentType)) return false;
57+
const ext = extractFileExtension(filename);
58+
if (!ext) return false;
59+
const allowedExtensions: readonly string[] = ALLOWED_IMAGE_TYPES[contentType];
60+
return allowedExtensions.includes(ext);
61+
}

0 commit comments

Comments
 (0)