You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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负责校验并生成授权的上传地址,客户端用该地址直接上传并追踪进度。

具体步骤

  1. Server Action完成安全校验并生成签名URL

    • 接收客户端传来的文件,先做魔数验证和大小检查;
    • 验证通过后,用Supabase Admin SDK生成签名上传URL(基于服务端权限,可指定路径、过期时间);
    • 将签名URL返回给客户端。
  2. 客户端用签名URL上传并追踪进度

    • 对每个文件单独发起上传请求,通过Fetch API的progress事件追踪进度;
    • 并行处理多文件上传,避免阻塞UI。

代码实现

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.02 01:54:52