Next.js App Router多文件上传服务端验证的推荐实现模式
Next.js 15 App Router 中带服务端验证的多文件上传(含进度)最佳实践
问题背景
我基于Next.js 15(App Router)开发了一个图库应用,目前存在上传路径的安全漏洞:
- 安全路径(Server Action):已实现文件魔数验证和10MB大小限制,但实际未被使用;
- 客户端直接上传:通过Supabase JS SDK(用anon密钥初始化)直接上传至Supabase Storage,完全跳过服务端校验,仅在客户端做了50MB大小检查,无魔数验证。
需求
- 所有上传必须经过Server Action,确保魔数验证和10MB大小限制生效;
- 支持多文件上传,且每个文件能显示实时上传进度;
- 上传过程中保持UI响应式,避免页面冻结。
已考虑的方案
- 循环调用Server Action:FormData单键仅支持单个文件,且Server Action无法返回上传进度;
- 使用API Route:可行但会丢失Server Action的简洁性;
- Supabase Storage桶策略:仅能限制文件大小,无法验证魔数。
核心问题
在Next.js App Router中,处理需服务端验证(魔数、文件类型)的多文件上传,同时保持客户端UI响应式的推荐模式是什么?应改用API Route,还是有办法用Server Action实现?
推荐方案:Server Action + Supabase签名URL上传
既保留Server Action的安全校验能力,又能实现上传进度追踪,同时保证UI响应式。核心逻辑是让Server Action负责校验并生成授权的上传地址,客户端用该地址直接上传并追踪进度。
具体步骤
Server Action完成安全校验并生成签名URL
- 接收客户端传来的文件,先做魔数验证和大小检查;
- 验证通过后,用Supabase Admin SDK生成签名上传URL(基于服务端权限,可指定路径、过期时间);
- 将签名URL返回给客户端。
客户端用签名URL上传并追踪进度
- 对每个文件单独发起上传请求,通过Fetch API的
progress事件追踪进度; - 并行处理多文件上传,避免阻塞UI。
- 对每个文件单独发起上传请求,通过Fetch API的
代码实现
Server Action(app/actions/upload.ts)
'use server'; import { createClient } from '@supabase/supabase-js'; import { verifyFileMagicNumber } from '@/lib/file-utils'; // 初始化服务端Supabase客户端(用service_role密钥,拥有全权限) const supabaseAdmin = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY! ); export async function getSignedUploadUrl(file: File) { // 1. 服务端校验:大小限制(10MB) if (file.size > 10 * 1024 * 1024) { throw new Error('文件大小不能超过10MB'); } // 2. 服务端校验:魔数验证 const fileHeader = await file.slice(0, 4).arrayBuffer(); if (!verifyFileMagicNumber(fileHeader, ['image/jpeg', 'image/png'])) { throw new Error('仅支持JPG/PNG格式'); } // 3. 生成Supabase签名上传URL(60秒过期) const filePath = `gallery/uploads/${Date.now()}-${file.name}`; const { data, error } = await supabaseAdmin.storage .from('gallery-bucket') .createSignedUrl(filePath, 60); if (error) throw new Error(error.message); return { signedUrl: data.signedUrl, filePath }; }
客户端上传组件(app/components/UploadForm.tsx)
'use client'; import { useState } from 'react'; import { getSignedUploadUrl } from '@/app/actions/upload'; interface UploadProgress { fileName: string; progress: number; status: 'pending' | 'uploading' | 'success' | 'error'; } export default function UploadForm() { const [uploadProgresses, setUploadProgresses] = useState<UploadProgress[]>([]); const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const files = e.target.files; if (!files) return; const fileList = Array.from(files); // 初始化所有文件的上传状态 setUploadProgresses(fileList.map(file => ({ fileName: file.name, progress: 0, status: 'pending' }))); // 并行处理多文件上传,不阻塞UI await Promise.all(fileList.map(async (file, index) => { try { // 更新当前文件为上传中状态 setUploadProgresses(prev => { const updated = [...prev]; updated[index] = { ...updated[index], status: 'uploading' }; return updated; }); // 调用Server Action获取签名URL const { signedUrl } = await getSignedUploadUrl(file); // 用签名URL上传文件,监听进度 const uploadResponse = await fetch(signedUrl, { method: 'PUT', body: file, headers: { 'Content-Type': file.type }, }); if (!uploadResponse.ok) throw new Error('上传失败'); // 更新为成功状态 setUploadProgresses(prev => { const updated = [...prev]; updated[index] = { ...updated[index], progress: 100, status: 'success' }; return updated; }); } catch (err) { // 更新为失败状态 setUploadProgresses(prev => { const updated = [...prev]; updated[index] = { ...updated[index], status: 'error', progress: 0 }; return updated; }); console.error(err); } })); }; return ( <div className="space-y-4"> <input type="file" multiple accept="image/jpeg,image/png" onChange={handleFileUpload} /> <div className="space-y-2"> {uploadProgresses.map((item, idx) => ( <div key={idx} className="space-y-1"> <div className="flex justify-between"> <span>{item.fileName}</span> <span>{item.status === 'uploading' ? `${item.progress}%` : item.status}</span> </div> <div className="w-full bg-gray-200 rounded-full h-2"> <div className={`h-2 rounded-full ${ item.status === 'success' ? 'bg-green-500' : item.status === 'uploading' ? 'bg-blue-500' : 'bg-red-500' }`} style={{ width: `${item.progress}%` }} ></div> </div> </div> ))} </div> </div> ); }
魔数验证工具(lib/file-utils.ts)
export function verifyFileMagicNumber(buffer: ArrayBuffer, allowedTypes: string[]) { const uint8 = new Uint8Array(buffer); // 映射文件类型与对应魔数 const magicMap = new Map<string, number[]>([ ['image/jpeg', [0xFF, 0xD8, 0xFF]], ['image/png', [0x89, 0x50, 0x4E, 0x47]] ]); return allowedTypes.some(type => { const expectedBytes = magicMap.get(type); if (!expectedBytes) return false; return expectedBytes.every((byte, idx) => uint8[idx] === byte); }); }
方案优势分析
- 保留Server Action的简洁性:无需额外维护API Route文件,自动集成Next.js的CSRF保护等安全机制;
- 实现进度追踪:通过客户端直接上传,利用Fetch的
progress事件实时获取上传进度; - 安全可控:所有文件必须经过Server Action的魔数和大小校验,避免客户端绕过;
- UI响应式:多文件并行上传,不会阻塞主线程导致页面冻结。
为什么不选纯Server Action?
当前Server Action无法流式返回上传进度(仅支持一次性请求响应),无法满足实时进度展示的需求。而签名URL方案既保留了服务端校验,又解决了进度追踪问题。
为什么不选纯API Route?
API Route虽然能实现流式进度返回,但需要额外维护路由文件,且丢失了Server Action与App Router的无缝集成优势,代码复杂度更高。
内容的提问来源于stack exchange,提问作者Mahadev
相关产品推荐
相关产品推荐

