11import { auth } from "@/auth" ;
22import { 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
1916interface 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 */
3636export 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 } ) ;
0 commit comments