文件上传:本地存储与云存储方案
第23章:文件上传:本地存储与云存储方案
文件上传看似简单,但架构选择对性能和成本影响巨大。预签名 URL 直传是生产推荐方案——服务器只负责授权,客户端直接传到云存储。
本章核心问题:直接上传与代理上传各有什么优劣?S3 预签名 URL 如何工作?文件类型验证有哪些层次?
读完本章你将理解:
- Route Handler 处理 multipart/form-data 的基础方案与安全要点
- AWS S3 预签名 URL 直传的完整实现(含 XHR 进度追踪)
- Magic bytes 文件头验证比 MIME 类型检查更可靠
Level 1 · 你需要知道的(1-3年经验)
文件上传的架构选择
文件上传看似简单,但架构选择对性能和成本影响巨大。最直观的方案是"文件发给服务器,服务器存到磁盘"。这在单机小项目里可行,但在无服务器(Vercel)、多实例(Docker Swarm)或高流量场景下会立刻暴露问题:无服务器函数没有持久化磁盘;多实例时上传到实例 A 的文件实例 B 看不到;大文件会占用宝贵的服务器带宽和内存。
成熟的文件上传方案分两个方向:直接上传(客户端直接传到云存储,服务器只负责授权)和代理上传(服务器接收文件再转发)。前者更高效,是生产推荐方案;后者更简单,适合小规模或对存储位置有特殊要求的场景。
Route Handler 中处理 multipart/form-data
最基础的服务端接收文件方式:
// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { writeFile, mkdir } from 'fs/promises'
import path from 'path'
import { auth } from '@/auth'
export async function POST(request: NextRequest) {
const session = await auth()
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const formData = await request.formData()
const file = formData.get('file') as File | null
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
// 服务端验证(客户端验证可被绕过,服务端验证是必须的)
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']
if (!allowedTypes.includes(file.type)) {
return NextResponse.json({ error: 'File type not allowed' }, { status: 400 })
}
const maxSize = 5 * 1024 * 1024 // 5MB
if (file.size > maxSize) {
return NextResponse.json({ error: 'File too large' }, { status: 400 })
}
// 生成安全的文件名(永远不要使用用户提供的文件名)
const ext = path.extname(file.name).toLowerCase()
const fileName = `${crypto.randomUUID()}${ext}`
const uploadDir = path.join(process.cwd(), 'public', 'uploads')
await mkdir(uploadDir, { recursive: true })
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
await writeFile(path.join(uploadDir, fileName), buffer)
return NextResponse.json({ url: `/uploads/${fileName}` })
}
这里有几个安全要点:永远不要使用用户提供的文件名,原因是路径遍历攻击(../../etc/passwd)和文件名冲突;使用 crypto.randomUUID() 生成不可预测的唯一文件名。MIME 类型验证要在服务端做,因为 Content-Type 请求头可以被客户端伪造——但即便如此,更可靠的方式是检查文件的 magic bytes(文件头部的特征字节序列)。
Level 2 · 它是怎么运行的(3-5年经验)
Vercel Blob:最简方案
如果部署在 Vercel,Vercel Blob 是最零配置的云存储选项:
npm install @vercel/blob
// app/api/upload/route.ts(Vercel Blob 版本)
import { put } from '@vercel/blob'
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
export async function POST(request: NextRequest) {
const session = await auth()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const formData = await request.formData()
const file = formData.get('file') as File
if (!file) return NextResponse.json({ error: 'No file' }, { status: 400 })
// Vercel Blob 直接接受 File 对象
const blob = await put(`uploads/${crypto.randomUUID()}-${file.name}`, file, {
access: 'public',
contentType: file.type,
})
return NextResponse.json({ url: blob.url })
}
Vercel Blob 的文件通过全球 CDN 分发,URL 是永久有效的。但这个方案文件先经过你的服务器——不适合大文件或高并发上传。
AWS S3 预签名 URL:生产级直传方案
预签名 URL(Presigned URL)是大文件上传的正确姿势:服务器生成一个临时授权的 S3 URL,客户端直接把文件传给 S3,完全绕过你的服务器。你的服务器带宽和计算资源不参与文件传输。
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
// lib/s3.ts
import { S3Client } from '@aws-sdk/client-s3'
export const s3Client = new S3Client({
region: process.env.AWS_REGION!,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
})
// app/api/upload/presign/route.ts
import { PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { s3Client } from '@/lib/s3'
export async function POST(request: NextRequest) {
const session = await auth()
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { fileName, fileType, fileSize } = await request.json()
// 服务端验证
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']
if (!allowedTypes.includes(fileType)) {
return NextResponse.json({ error: 'File type not allowed' }, { status: 400 })
}
const maxSize = 50 * 1024 * 1024 // 50MB
if (fileSize > maxSize) {
return NextResponse.json({ error: 'File too large' }, { status: 400 })
}
const key = `uploads/${session.user.id}/${crypto.randomUUID()}-${fileName}`
const command = new PutObjectCommand({
Bucket: process.env.AWS_S3_BUCKET!,
Key: key,
ContentType: fileType,
ContentLength: fileSize,
// 强制 Content-Disposition 防止 XSS(用户上传 HTML 文件)
ContentDisposition: 'attachment',
})
// 预签名 URL 10 分钟内有效
const presignedUrl = await getSignedUrl(s3Client, command, { expiresIn: 600 })
return NextResponse.json({
presignedUrl,
key,
publicUrl: `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`,
})
}
客户端使用预签名 URL 直传,并用 XMLHttpRequest 追踪进度:
// hooks/useFileUpload.ts
'use client'
import { useState } from 'react'
interface UploadState {
progress: number
status: 'idle' | 'uploading' | 'success' | 'error'
url: string | null
error: string | null
}
export function useFileUpload() {
const [state, setState] = useState<UploadState>({
progress: 0,
status: 'idle',
url: null,
error: null,
})
async function upload(file: File): Promise<string | null> {
setState({ progress: 0, status: 'uploading', url: null, error: null })
try {
// 1. 从服务器获取预签名 URL
const res = await fetch('/api/upload/presign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: file.name,
fileType: file.type,
fileSize: file.size,
}),
})
if (!res.ok) {
const { error } = await res.json()
throw new Error(error)
}
const { presignedUrl, publicUrl } = await res.json()
// 2. 用 XHR 直传 S3(fetch API 不支持进度事件)
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const progress = Math.round((e.loaded / e.total) * 100)
setState(prev => ({ ...prev, progress }))
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) resolve()
else reject(new Error(`Upload failed: ${xhr.status}`))
})
xhr.addEventListener('error', () => reject(new Error('Network error')))
xhr.open('PUT', presignedUrl)
xhr.setRequestHeader('Content-Type', file.type)
xhr.send(file)
})
setState({ progress: 100, status: 'success', url: publicUrl, error: null })
return publicUrl
} catch (error) {
const message = error instanceof Error ? error.message : 'Upload failed'
setState(prev => ({ ...prev, status: 'error', error: message }))
return null
}
}
return { ...state, upload }
}
fetch API 目前不支持上传进度事件,所以直传 S3 时需要使用 XMLHttpRequest。这是为数不多 XHR 仍然必要的场景。
Cloudflare R2:更经济的 S3 兼容方案
R2 是 Cloudflare 的对象存储,完全兼容 S3 API,最大优势是零出口流量费用(egress free)——从 R2 读取数据到互联网不收费,而 AWS S3 的出口费用在高流量场景下可以非常昂贵。
// lib/r2.ts — R2 使用 S3 Client,只是 endpoint 不同
import { S3Client } from '@aws-sdk/client-s3'
export const r2Client = new S3Client({
region: 'auto',
endpoint: `https://${process.env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
})
R2 的预签名 URL 逻辑与 S3 完全一致,只需把 s3Client 换成 r2Client。如果你的应用有大量文件读取(图片、视频、附件),R2 的零出口费用可以节省大量成本。
Level 3 · 规范怎么定义的(资深)
上传组件:带预览和进度条
// components/FileUpload.tsx
'use client'
import { useState, useCallback } from 'react'
import { useFileUpload } from '@/hooks/useFileUpload'
interface FileUploadProps {
onUploadComplete: (url: string) => void
accept?: string
}
export function FileUpload({ onUploadComplete, accept = 'image/*' }: FileUploadProps) {
const [preview, setPreview] = useState<string | null>(null)
const { progress, status, error, upload } = useFileUpload()
const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// 客户端验证(用户体验优化,但不能替代服务端验证)
if (file.size > 50 * 1024 * 1024) {
alert('文件不能超过 50MB')
return
}
// 图片预览
if (file.type.startsWith('image/')) {
const reader = new FileReader()
reader.onload = (e) => setPreview(e.target?.result as string)
reader.readAsDataURL(file)
}
const url = await upload(file)
if (url) onUploadComplete(url)
}, [upload, onUploadComplete])
return (
<div className="space-y-4">
<input
type="file"
accept={accept}
onChange={handleFileChange}
disabled={status === 'uploading'}
className="block w-full text-sm"
/>
{preview && (
<img src={preview} alt="Preview" className="h-32 w-32 object-cover rounded" />
)}
{status === 'uploading' && (
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span>上传中...</span>
<span>{progress}%</span>
</div>
<div className="h-2 bg-gray-200 rounded">
<div
className="h-full bg-blue-500 rounded transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
{status === 'success' && (
<p className="text-green-600 text-sm">上传成功</p>
)}
{error && (
<p className="text-red-500 text-sm">{error}</p>
)}
</div>
)
}
文件类型验证的深度
客户端的 accept 属性和 MIME 类型检查都可以被绕过。更可靠的方案是在服务端检查文件的 magic bytes——文件实际内容开头的特征字节序列,与文件扩展名无关:
// lib/validateFile.ts
export async function validateImageMagicBytes(file: File): Promise<boolean> {
const buffer = await file.slice(0, 12).arrayBuffer()
const bytes = new Uint8Array(buffer)
// JPEG: FF D8 FF
if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) return true
// PNG: 89 50 4E 47 0D 0A 1A 0A
if (
bytes[0] === 0x89 && bytes[1] === 0x50 &&
bytes[2] === 0x4E && bytes[3] === 0x47
) return true
// WebP: RIFF....WEBP
if (
bytes[0] === 0x52 && bytes[1] === 0x49 &&
bytes[2] === 0x46 && bytes[3] === 0x46 &&
bytes[8] === 0x57 && bytes[9] === 0x45 &&
bytes[10] === 0x42 && bytes[11] === 0x50
) return true
return false
}
孤立文件的清理
上传文件后,用户可能放弃表单提交,导致已上传的文件永远不被引用。定期清理这些孤立文件可以节省存储成本:
// app/api/cron/cleanup-uploads/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { DeleteObjectsCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'
import { s3Client } from '@/lib/s3'
// 在 Vercel 上配置为每日 Cron
export async function GET(request: Request) {
// 验证 Cron 请求(防止未授权触发)
const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// 获取数据库中所有引用的文件 URL
const referencedUrls = await prisma.user.findMany({
where: { image: { not: null } },
select: { image: true },
})
const referencedKeys = new Set(
referencedUrls.map(u => u.image!.split('/').pop())
)
// 列出 S3 中的所有文件
const listCommand = new ListObjectsV2Command({
Bucket: process.env.AWS_S3_BUCKET!,
Prefix: 'uploads/',
})
const listed = await s3Client.send(listCommand)
// 找出超过 24 小时且未被引用的文件
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
const orphans = listed.Contents?.filter(obj =>
obj.LastModified! < oneDayAgo &&
!referencedKeys.has(obj.Key!.split('/').pop())
) ?? []
if (orphans.length > 0) {
await s3Client.send(new DeleteObjectsCommand({
Bucket: process.env.AWS_S3_BUCKET!,
Delete: {
Objects: orphans.map(obj => ({ Key: obj.Key! })),
},
}))
}
return NextResponse.json({ deleted: orphans.length })
}
文件上传是全栈应用的标配功能。本地存储适合开发环境和小规模部署;Vercel Blob 适合快速上线;S3 预签名直传是高流量生产环境的标准方案;R2 是成本敏感场景的优选。选择哪种方案取决于你的规模、部署环境和成本约束——但安全验证(服务端类型检查、随机文件名、权限验证)是所有方案共同的必须要求。
Level 4 · 边界与陷阱(所有人)
陷阱1:永远不要使用用户提供的文件名——路径遍历攻击(../../etc/passwd)和文件名冲突是主要风险。
陷阱2:fetch API 目前不支持上传进度事件——直传 S3 时需要使用 XMLHttpRequest,这是 XHR 仍然必要的为数不多场景。
陷阱3:Cloudflare R2 完全兼容 S3 API 且零出口流量费用——高流量场景可以显著节省成本。