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

使用Next.js签名URL上传文件至GCS时遭遇403错误排查

问题描述

我要实现以下文件上传流程:

  1. 在api/fileupload.js生成GCS的签名URL,用于文件上传
  2. 通过Next.js API(http://localhost:3000/api/fileupload)获取该签名URL
  3. 在index.jsx中使用签名URL将文件上传至GCS

目前签名URL可成功生成,但用FormData上传图片到GCS时返回403错误,响应信息如下:

body : (...)
bodyUsed : false
headers : 
Headers {}
ok : false
redirected : false
status : 0
statusText : ""
type : "opaque"
url : ""

请问index.jsx中用FormData上传的方式是否正确?或是我遗漏了什么配置?

附相关代码:

index.jsx原代码

import { useState } from "react";

export default function Home() {
  const [url, setUrl] = useState("");
  const [file, setFile] = useState<any>(null);
  const [dataloaded, setDataloaded] = useState(true);

  const handleSubmit = async (e: any) => {
    setDataloaded(false);

    e.preventDefault();

    let formData = new FormData();
    formData.append("file", file.data);
    formData.append("Content-Type", `${file.data.type}`);

    console.log(file);
    const response = await fetch("http://localhost:3000/api/fileupload", {
      method: "POST",
      body: formData
    });
    const responseWithBody = await response.json();
    console.log(responseWithBody);
    setDataloaded(true);
    if (response.status === 200) {
      setUrl(responseWithBody.url);
    } else {
      console.log("error in generating url");
    }
    const response1 = await fetch(
      responseWithBody.url,
      {
        mode: "no-cors",
        method: "POST",
        body: formData,
        headers: {
          "Access-Control-Allow-Origin": "*",
          "content-type": "image/png"
        }
      }
    );
    console.log(response1);
  };

  const handleFileChange = (e: any) => {
    const img = {
        preview: URL.createObjectURL(e.target.files[0]),
        data: e.target.files[0]
     };
   setFile(img);
  };

  return (
     <>
      <div className="form-container">
        <form onSubmit={handleSubmit}>
          <div className="image-preview-container">
            {file ? (
              <img src={file.preview} alt="File to upload" />
            ) : (
              <img
            src="https://raw.githubusercontent.com/koehlersimon/fallback/master/Resources/Public/Images/placeholder.jpg"
             alt="Fallback"
           />
         )}
       </div>
       <div className="file-name">
         {file && file.data.name}
         {url && (
           <a href={url} target="_blank" rel="noreferrer">
             FiExternalLink
           </a>
         )}
       </div>
       <input
         type="file"
         name="file"
         onChange={handleFileChange}
         className="custom-file-input"
       ></input>
       <button
         className="submit-button"
         type="submit"
         disabled={!file}
         onClick={handleSubmit}
       >
         Submit
       </button>
     </form>
   </div>
 </>
 );
 }

api/fileupload.js原代码

import { Storage } from "@google-cloud/storage";
import multer from "multer";
import type { NextApiRequest, NextApiResponse } from "next";

const storage = new Storage({
  keyFilename: `service_account_key.json`,
  projectId: "my-project-id"
});
const bucketName = "my-bucket-name";

async function parseFormData(
  req: NextApiRequest & { files?: any },
  res: NextApiResponse
) {
  const storage = multer.memoryStorage();
  const multerUpload = multer({ storage });
  const multerFiles = multerUpload.any();
  await new Promise((resolve, reject) => {
    multerFiles(req as any, res as any, (result: any) => {
      if (result instanceof Error) {
        return reject(result);
      }
      return resolve(result);
    });
  });
  return {
    fields: req.body,
    files: req.files
  };
}

export default async function handler(
  req: NextApiRequest & { files?: any },
  res: NextApiResponse<any>
) {
  const options = {
    version: "v4",
    action: "write",
    expires: Date.now() + 15 * 60 * 1000, // 15 minutes
    contentType: "application/octet-stream"
  } as any;

  const result = await parseFormData(req, res);
  // console.log(result);
  const file = storage
    .bucket(bucketName)
    .file(result?.files[0]?.originalname || "new-file.png");
  const [url]: any = await file.getSignedUrl(options);

  console.log("Generated PUT signed URL:");
  console.log(url);

  res.status(200).json({ url: url });
}

问题分析与修复方案

你的代码存在几个关键问题导致上传失败,逐一修正如下:

1. 请求方法不匹配

GCS签名URL生成时指定了action: "write",默认对应PUT方法,但你前端上传用了POST方法,直接触发权限验证失败。

2. Content-Type不匹配

  • 生成签名URL时固定设置了application/octet-stream,但前端硬编码了image/png,若上传非PNG文件会直接不匹配;
  • FormData会自动添加multipart/form-data请求头,和签名URL要求的类型冲突,导致403。

3. no-cors模式冗余且误导

开启no-cors会让浏览器屏蔽所有响应详情,无法看到真实的403原因,且该模式下很多请求头无法正常传递,完全没必要使用。

4. 前端上传冗余操作

上传到GCS不需要用FormData包裹文件,直接传递文件二进制数据即可;另外Access-Control-Allow-Origin是服务器返回头,前端请求时设置无任何作用。


修正后的代码

1. 修正api/fileupload.js

使用文件真实的Content-Type生成签名URL:

import { Storage } from "@google-cloud/storage";
import multer from "multer";
import type { NextApiRequest, NextApiResponse } from "next";

const storage = new Storage({
  keyFilename: `service_account_key.json`,
  projectId: "my-project-id"
});
const bucketName = "my-bucket-name";

async function parseFormData(
  req: NextApiRequest & { files?: any },
  res: NextApiResponse
) {
  const storage = multer.memoryStorage();
  const multerUpload = multer({ storage });
  const multerFiles = multerUpload.any();
  await new Promise((resolve, reject) => {
    multerFiles(req as any, res as any, (result: any) => {
      if (result instanceof Error) {
        return reject(result);
      }
      return resolve(result);
    });
  });
  return {
    fields: req.body,
    files: req.files
  };
}

export default async function handler(
  req: NextApiRequest & { files?: any },
  res: NextApiResponse<any>
) {
  const result = await parseFormData(req, res);
  const targetFile = result?.files[0];
  if (!targetFile) {
    return res.status(400).json({ error: "未提供文件" });
  }

  // 使用文件真实的MIME类型生成签名URL
  const options = {
    version: "v4",
    action: "write",
    expires: Date.now() + 15 * 60 * 1000, // 15分钟有效期
    contentType: targetFile.mimetype
  };

  const file = storage
    .bucket(bucketName)
    .file(targetFile.originalname || "new-file");
  const [url]: any = await file.getSignedUrl(options);

  console.log("生成PUT签名URL:", url);
  res.status(200).json({ url });
}

2. 修正index.jsx

直接上传文件二进制,使用PUT方法:

import { useState } from "react";

export default function Home() {
  const [url, setUrl] = useState("");
  const [file, setFile] = useState<any>(null);
  const [dataloaded, setDataloaded] = useState(true);

  const handleSubmit = async (e: any) => {
    e.preventDefault();
    setDataloaded(false);

    // 1. 获取签名URL
    const formData = new FormData();
    formData.append("file", file.data);
    
    const signResponse = await fetch("http://localhost:3000/api/fileupload", {
      method: "POST",
      body: formData
    });

    if (!signResponse.ok) {
      console.log("生成签名URL失败");
      setDataloaded(true);
      return;
    }

    const { url: signedUrl } = await signResponse.json();
    setUrl(signedUrl);

    // 2. 用签名URL上传文件到GCS
    try {
      const uploadResponse = await fetch(signedUrl, {
        method: "PUT", // 改为PUT方法
        body: file.data, // 直接传文件二进制
        headers: {
          "Content-Type": file.data.type // 和签名URL的Content-Type保持一致
        }
      });

      if (uploadResponse.ok) {
        console.log("文件上传成功");
      } else {
        const errorText = await uploadResponse.text();
        console.error("上传失败:", errorText);
      }
    } catch (err) {
      console.error("上传出错:", err);
    } finally {
      setDataloaded(true);
    }
  };

  const handleFileChange = (e: any) => {
    const img = {
      preview: URL.createObjectURL(e.target.files[0]),
      data: e.target.files[0]
    };
    setFile(img);
  };

  return (
    <>
      <div className="form-container">
        <form onSubmit={handleSubmit}>
          <div className="image-preview-container">
            {file ? (
              <img src={file.preview} alt="待上传文件" />
            ) : (
              <img
                src="https://raw.githubusercontent.com/koehlersimon/fallback/master/Resources/Public/Images/placeholder.jpg"
                alt="占位图"
              />
            )}
          </div>
          <div className="file-name">
            {file && file.data.name}
            {url && (
              <a href={url} target="_blank" rel="noreferrer">
                查看文件
              </a>
            )}
          </div>
          <input
            type="file"
            name="file"
            onChange={handleFileChange}
            className="custom-file-input"
          ></input>
          <button
            className="submit-button"
            type="submit"
            disabled={!file}
          >
            提交
          </button>
        </form>
      </div>
    </>
  );
}

额外注意事项
  • 确保GCS服务账号拥有storage.objects.create权限
  • 签名URL有效期不要设置过长,避免安全风险
  • 上传时的Content-Type必须和生成签名URL时指定的完全一致,否则会返回403

内容的提问来源于stack exchange,提问作者LadyBug

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.20 10:27:01