From 035190420f068612ead9af99b0232f0b82a80bea Mon Sep 17 00:00:00 2001 From: stringadmin Date: Tue, 2 Jun 2026 15:45:29 +0800 Subject: [PATCH] fix(oss): add binary upload route + base64 fallback fix + MIME types - Add /oss/upload-binary route using busboy for FormData multipart uploads - Fix parseUploadPayload base64 fallback: strip data:xxx;base64 prefix instead of using entire rawData string as base64 (caused 44-byte corrupt files when DATA_URL_PATTERN regex did not match) - Add image/avif, image/heic, image/heif to MIME_EXTENSIONS Root cause of ecommerce 502: base64 dataUrl not matching regex pattern caused server to store corrupt 44-byte files on OSS, DashScope could not read them and returned "image format is illegal" error. --- package-lock.json | 20 +++++++++++++ package.json | 1 + src/routes/oss.js | 72 ++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index f23bc0a..640ee9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "alipay-sdk": "^4.14.0", "bcryptjs": "^2.4.3", + "busboy": "^1.6.0", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.0", @@ -159,6 +160,17 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1527,6 +1539,14 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/superagent": { "version": "8.0.6", "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.0.6.tgz", diff --git a/package.json b/package.json index ad2140f..53a6d93 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dependencies": { "alipay-sdk": "^4.14.0", "bcryptjs": "^2.4.3", + "busboy": "^1.6.0", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.0", diff --git a/src/routes/oss.js b/src/routes/oss.js index fcd23c4..777aab2 100644 --- a/src/routes/oss.js +++ b/src/routes/oss.js @@ -1,6 +1,7 @@ const crypto = require("node:crypto"); const dns = require("node:dns"); const { requireAuth } = require("./context"); +const Busboy = require("busboy"); const { putObject, isOssConfigured, createSignedReadUrl } = require("../ossClient"); const DATA_URL_PATTERN = /^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/; @@ -61,6 +62,9 @@ const MIME_EXTENSIONS = { "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", + "image/avif": "avif", + "image/heic": "heic", + "image/heif": "heif", "image/gif": "gif", "video/mp4": "mp4", "video/webm": "webm", @@ -166,7 +170,7 @@ function parseUploadPayload(body) { const rawData = String(body?.dataUrl || body?.data || ""); const dataUrlMatch = rawData.match(DATA_URL_PATTERN); const mimeType = normalizeMimeType(body?.mimeType || dataUrlMatch?.[1]); - const base64 = (dataUrlMatch?.[2] || rawData).replace(/\s+/g, ""); + const base64 = (dataUrlMatch?.[2] || rawData.replace(/^data:[^;,]+;base64,/, "")).replace(/\s+/g, ""); if (!base64) { const error = new Error("Missing upload data"); error.status = 400; @@ -317,6 +321,72 @@ function registerOssRoutes(router) { if (!res.headersSent) res.status(502).json({ error: "Proxy failed" }); } }); + router.post("/oss/upload-binary", requireAuth, (req, res) => { + if (!isOssConfigured()) { + return res.status(501).json({ error: "OSS 未配置" }); + } + + const busboy = Busboy({ headers: req.headers, limits: { fileSize: 10 * 1024 * 1024 } }); + let fileBuffer = null; + let fileMimeType = "application/octet-stream"; + let fileName = "upload"; + let scope = ""; + + busboy.on("file", (fieldname, stream, info) => { + if (fieldname !== "file") { + stream.resume(); + return; + } + fileName = info.filename || "upload"; + fileMimeType = info.mimeType || "application/octet-stream"; + const chunks = []; + stream.on("data", (chunk) => chunks.push(chunk)); + stream.on("end", () => { + fileBuffer = Buffer.concat(chunks); + }); + stream.on("error", (err) => { + console.error("[oss/upload-binary] stream error:", err.message); + res.status(500).json({ error: "文件读取失败" }); + }); + }); + + busboy.on("field", (fieldname, val) => { + if (fieldname === "scope") scope = String(val).trim(); + if (fieldname === "mimeType") fileMimeType = String(val).trim(); + }); + + busboy.on("finish", async () => { + try { + if (!fileBuffer) { + return res.status(400).json({ error: "未收到文件" }); + } + const normalizedMimeType = normalizeMimeType(fileMimeType); + const ext = MIME_EXTENSIONS[normalizedMimeType] || "bin"; + const assetDir = getAssetDirectory(normalizedMimeType); + const safeUserId = String(req.user.id).replace(/[^a-zA-Z0-9_-]/g, ""); + const objectKey = + getProfileObjectKey(scope, req.user.id, ext, normalizedMimeType) || + getCommunityObjectKey(scope, req.user.id, ext, normalizedMimeType) || + `tmp/${safeUserId}/generation-inputs/${assetDir}/${Date.now()}_${crypto.randomUUID()}.${ext}`; + await putObject(objectKey, fileBuffer, normalizedMimeType, { "x-oss-object-acl": "public-read" }); + const url = buildOssPublicUrl(objectKey); + const signedUrl = typeof createSignedReadUrl === "function" ? createSignedReadUrl(objectKey) : url; + console.info("[oss/upload-binary] mimeType:", normalizedMimeType, "size:", fileBuffer.length, "userId:", req.user.id); + res.status(201).json({ ossKey: objectKey, url, signedUrl }); + } catch (err) { + const status = err.status || 500; + console.error("[oss/upload-binary] failed:", err.message); + res.status(status).json({ error: err.message || "上传素材失败" }); + } + }); + + busboy.on("error", (err) => { + console.error("[oss/upload-binary] busboy error:", err.message); + res.status(500).json({ error: "文件解析失败" }); + }); + + req.pipe(busboy); + }); } module.exports = {