使用Next.js签名URL上传文件至GCS时遭遇403错误排查
问题描述
我要实现以下文件上传流程:
- 在
api/fileupload.js生成GCS的签名URL,用于文件上传 - 通过Next.js API(
http://localhost:3000/api/fileupload)获取该签名URL - 在
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
相关产品推荐
相关产品推荐

