From 8a633feb4f55d452b3a95a87d1b1be1ddcb76839 Mon Sep 17 00:00:00 2001 From: abearxiong Date: Sun, 21 Dec 2025 06:41:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=BE=9D=E8=B5=96=E9=A1=B9?= =?UTF-8?q?=EF=BC=8C=E4=BD=BF=E7=94=A8=20Busboy=20=E6=9B=BF=E4=BB=A3=20for?= =?UTF-8?q?midable=20=E5=A4=84=E7=90=86=E6=96=87=E4=BB=B6=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=EF=BC=8C=E4=BC=98=E5=8C=96=E4=B8=8A=E4=BC=A0=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E6=94=B9=E8=BF=9B=E6=9D=83=E9=99=90=E6=A3=80?= =?UTF-8?q?=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 4 +- pnpm-lock.yaml | 10 +- src/app.ts | 2 +- src/index.ts | 5 +- src/modules/fm-manager/index.ts | 4 +- src/modules/fm-manager/pipe-busboy.ts | 13 +++ src/modules/ws-proxy/proxy.ts | 16 ++- src/routes-simple/code/upload.ts | 157 ++++++++++++++++---------- src/routes-simple/handle-request.ts | 108 ++++++++++++------ src/routes-simple/resources/chunk.ts | 80 +++++++++---- src/routes-simple/resources/upload.ts | 97 +++++++++++----- src/routes-simple/router.ts | 3 +- 12 files changed, 344 insertions(+), 155 deletions(-) create mode 100644 src/modules/fm-manager/pipe-busboy.ts diff --git a/package.json b/package.json index ef26112..c615e35 100644 --- a/package.json +++ b/package.json @@ -69,13 +69,12 @@ "@kevisual/logger": "^0.0.4", "@kevisual/oss": "0.0.13", "@kevisual/permission": "^0.0.3", - "@kevisual/router": "0.0.46", + "@kevisual/router": "0.0.48", "@kevisual/types": "^0.0.10", "@kevisual/use-config": "^1.0.21", "@types/archiver": "^7.0.0", "@types/bun": "^1.3.5", "@types/crypto-js": "^4.2.2", - "@types/formidable": "^3.4.6", "@types/jsonwebtoken": "^9.0.10", "@types/lodash-es": "^4.17.12", "@types/node": "^25.0.3", @@ -84,7 +83,6 @@ "crypto-js": "^4.2.0", "dayjs": "^1.11.19", "dotenv": "^17.2.3", - "formidable": "3.5.4", "ioredis": "^5.8.2", "jsonwebtoken": "^9.0.3", "lodash-es": "^4.17.22", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f982b7a..2fc2f25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,8 +86,8 @@ importers: specifier: ^0.0.3 version: 0.0.3 '@kevisual/router': - specifier: 0.0.46 - version: 0.0.46 + specifier: 0.0.48 + version: 0.0.48 '@kevisual/types': specifier: ^0.0.10 version: 0.0.10 @@ -255,8 +255,8 @@ packages: '@kevisual/router@0.0.33': resolution: {integrity: sha512-9z7TkSzCIGbXn9SuHPBdZpGwHlAuwA8iN5jNAZBUvbEvBRkBxlrbdCSe9fBYiAHueLm2AceFNrW74uulOiAkqA==} - '@kevisual/router@0.0.46': - resolution: {integrity: sha512-KMsyVTQxCZdt35yRdeDJDIwXco6w7xSG3C90NlKMkrsj5OCZlIEJaRSs2ASIb3kYgrWFDl8NTUNbObDO03Q7bA==} + '@kevisual/router@0.0.48': + resolution: {integrity: sha512-WsSvT+NpfC/bZbaAzE3WSKD2DRZP0JuPQJGr4YucSdO/lOLB4cEpOZRbPlV3l7G064ow8QJRAN2DUW+bRjrp1A==} '@kevisual/types@0.0.10': resolution: {integrity: sha512-Q73uzzjk9UidumnmCvOpgzqDDvQxsblz22bIFuoiioUFJWwaparx8bpd8ArRyFojicYL1YJoFDzDZ9j9NN8grA==} @@ -2154,7 +2154,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@kevisual/router@0.0.46': + '@kevisual/router@0.0.48': dependencies: path-to-regexp: 8.3.0 selfsigned: 5.2.0 diff --git a/src/app.ts b/src/app.ts index 36bf0db..79cab56 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,4 +1,4 @@ -import { App } from '@kevisual/router/src/app.ts'; +import { App } from '@kevisual/router'; import * as redisLib from './modules/redis.ts'; import * as minioLib from './modules/minio.ts'; import * as sequelizeLib from './modules/sequelize.ts'; diff --git a/src/index.ts b/src/index.ts index 13c1a96..b362045 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,16 +3,17 @@ import './route.ts'; import { handleRequest } from './routes-simple/handle-request.ts'; import { port } from './modules/config.ts'; import { wssFun } from './modules/ws-proxy/index.ts'; +import { WebSocketListenerFun, HttpListenerFun } from '@kevisual/router/src/server/server-type.js'; console.log('Starting server...', port); app.listen(port, '0.0.0.0', () => { console.log(`server is running at http://localhost:${port}`); }); app.server.on([{ id: 'handle-all', - fun: handleRequest, + func: handleRequest as any, }, { id: 'wss', io: true, path: '/ws/proxy', - fun: wssFun, + func: wssFun as WebSocketListenerFun, }]); diff --git a/src/modules/fm-manager/index.ts b/src/modules/fm-manager/index.ts index 539e103..8900245 100644 --- a/src/modules/fm-manager/index.ts +++ b/src/modules/fm-manager/index.ts @@ -9,4 +9,6 @@ export * from './get-content-type.ts' export * from './utils.ts' -export { pipeFileStream, pipeStream } from './pipe.ts' \ No newline at end of file +export { pipeFileStream, pipeStream } from './pipe.ts' + +export { pipeBusboy } from './pipe-busboy.ts' \ No newline at end of file diff --git a/src/modules/fm-manager/pipe-busboy.ts b/src/modules/fm-manager/pipe-busboy.ts new file mode 100644 index 0000000..ebf2541 --- /dev/null +++ b/src/modules/fm-manager/pipe-busboy.ts @@ -0,0 +1,13 @@ +import { isBun } from '@/utils/get-engine.ts'; +import http from 'node:http'; +export const pipeBusboy = async (req: http.IncomingMessage, res: http.ServerResponse, busboy: any) => { + if (isBun) { + // @ts-ignore + const bunRequest = req.bun.request; + const arrayBuffer = await bunRequest.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + busboy.end(buffer); + } else { + req.pipe(busboy); + } +} \ No newline at end of file diff --git a/src/modules/ws-proxy/proxy.ts b/src/modules/ws-proxy/proxy.ts index 4ac7e5d..f1f2bd6 100644 --- a/src/modules/ws-proxy/proxy.ts +++ b/src/modules/ws-proxy/proxy.ts @@ -11,26 +11,36 @@ type ProxyOptions = { export const UserV1Proxy = async (req: IncomingMessage, res: ServerResponse, opts?: ProxyOptions) => { const { url } = req; const { pathname } = new URL(url || '', `http://localhost`); - const [user, app, userAppKey] = pathname.split('/').slice(1); + let [user, app, userAppKey] = pathname.split('/').slice(1); if (!user || !app || !userAppKey) { opts?.createNotFoundPage?.('应用未找到'); return false; } + const data = await App.handleRequest(req, res); const loginUser = await getLoginUser(req); if (!loginUser) { opts?.createNotFoundPage?.('没有登录'); return false; } - if (loginUser.tokenUser?.username !== user) { + const isAdmin = loginUser.tokenUser?.username === user + // TODO: 如果不是管理员,是否需要添加其他人可以访问的逻辑? + if (!isAdmin) { opts?.createNotFoundPage?.('没有访问应用权限'); return false; } + if (!userAppKey.startsWith(user + '-')) { + userAppKey = user + '-' + userAppKey; + } logger.debug('data', data); const client = wsProxyManager.get(userAppKey); const ids = wsProxyManager.getIds(); if (!client) { - opts?.createNotFoundPage?.(`未找到应用 [${userAppKey}], 当前应用列表: ${ids.join(',')}`); + if (isAdmin) { + opts?.createNotFoundPage?.(`未找到应用 [${userAppKey}], 当前应用列表: ${ids.join(',')}`); + } else { + opts?.createNotFoundPage?.('应用访问失败'); + } return false; } const value = await client.sendData(data); diff --git a/src/routes-simple/code/upload.ts b/src/routes-simple/code/upload.ts index 5ff3872..b646d9c 100644 --- a/src/routes-simple/code/upload.ts +++ b/src/routes-simple/code/upload.ts @@ -1,4 +1,4 @@ -import { IncomingForm } from 'formidable'; +import Busboy from 'busboy'; import { checkAuth } from '../middleware/auth.ts'; import { router, clients, writeEvents } from '../router.ts'; import { error } from '../middleware/auth.ts'; @@ -7,50 +7,94 @@ import { useFileStore } from '@kevisual/use-config/file-store'; import { app, minioClient } from '@/app.ts'; import { bucketName } from '@/modules/minio.ts'; import { getContentType } from '@/utils/get-content-type.ts'; +import path from 'path'; +import { createWriteStream } from 'fs'; +import crypto from 'crypto'; +import { pipeBusboy } from '@/modules/fm-manager/index.ts'; const cacheFilePath = useFileStore('cache-file', { needExists: true }); router.post('/api/micro-app/upload', async (req, res) => { - if (res.headersSent) return; // 如果响应已发送,不再处理 + if (res.headersSent) return; // 如果响应已发送,不再处理 res.writeHead(200, { 'Content-Type': 'application/json' }); const { tokenUser, token } = await checkAuth(req, res); if (!tokenUser) return; - // - // 使用 formidable 解析 multipart/form-data - const form = new IncomingForm({ - multiples: false, // 支持多文件上传 - uploadDir: cacheFilePath, // 上传文件存储目录 - allowEmptyFiles: true, // 允许空 - minFileSize: 0, // 最小文件大小 - maxFiles: 1, // 最大文件数量 - createDirsFromUploads: false, // 根据上传的文件夹结构创建目录 - keepExtensions: true, // 保留文件 - hashAlgorithm: 'md5', // 文件哈希算法 + + // 使用 busboy 解析 multipart/form-data + const busboy = Busboy({ headers: req.headers }); + const fields: any = {}; + let file: any = null; + let filePromise: Promise | null = null; + let bytesReceived = 0; + let bytesExpected = parseInt(req.headers['content-length'] || '0'); + + busboy.on('field', (fieldname, value) => { + fields[fieldname] = value; }); - form.on('progress', (bytesReceived, bytesExpected) => { - const progress = (bytesReceived / bytesExpected) * 100; - console.log(`Upload progress: ${progress.toFixed(2)}%`); - const data = { - progress: progress.toFixed(2), - message: `Upload progress: ${progress.toFixed(2)}%`, - }; - writeEvents(req, data); - }); - // 解析上传的文件 - form.parse(req, async (err, fields, files) => { - if (err) { - res.end(error(`Upload error: ${err.message}`)); - const uploadedFiles = Array.isArray(files.file) ? files.file : [files.file]; - uploadedFiles.forEach((file) => { - fs.unlinkSync(file.filepath); + + busboy.on('file', (fieldname, fileStream, info) => { + const { filename, encoding, mimeType } = info; + const tempPath = path.join(cacheFilePath, `${Date.now()}-${Math.random().toString(36).substring(7)}-${filename}`); + const writeStream = createWriteStream(tempPath); + const hash = crypto.createHash('md5'); + let size = 0; + + filePromise = new Promise((resolve, reject) => { + fileStream.on('data', (chunk) => { + bytesReceived += chunk.length; + size += chunk.length; + hash.update(chunk); + if (bytesExpected > 0) { + const progress = (bytesReceived / bytesExpected) * 100; + console.log(`Upload progress: ${progress.toFixed(2)}%`); + const data = { + progress: progress.toFixed(2), + message: `Upload progress: ${progress.toFixed(2)}%`, + }; + writeEvents(req, data); + } }); - return; + + fileStream.pipe(writeStream); + + writeStream.on('finish', () => { + file = { + filepath: tempPath, + originalFilename: filename, + mimetype: mimeType, + hash: hash.digest('hex'), + size: size, + }; + resolve(); + }); + + writeStream.on('error', (err) => { + reject(err); + }); + }); + }); + + busboy.on('finish', async () => { + // 等待文件写入完成 + if (filePromise) { + try { + await filePromise; + } catch (err) { + console.error(`File write error: ${err.message}`); + res.end(error(`File write error: ${err.message}`)); + return; + } } const clearFiles = () => { - const uploadedFiles = Array.isArray(files.file) ? files.file : [files.file]; - uploadedFiles.forEach((file) => { + if (file?.filepath && fs.existsSync(file.filepath)) { fs.unlinkSync(file.filepath); - }); + } }; + + if (!file) { + res.end(error('No file uploaded')); + return; + } + let appKey, collection; const { appKey: _appKey, collection: _collecion } = fields; if (Array.isArray(_appKey)) { @@ -68,31 +112,28 @@ router.post('/api/micro-app/upload', async (req, res) => { appKey = appKey || 'micro-app'; console.log('Appkey', appKey); console.log('collection', collection); - // 逐个处理每个上传的文件 - const uploadedFiles = Array.isArray(files.file) ? files.file : [files.file]; + + // 处理上传的文件 const uploadResults = []; - for (let i = 0; i < uploadedFiles.length; i++) { - const file = uploadedFiles[i]; - // @ts-ignore - const tempPath = file.filepath; // 文件上传时的临时路径 - const relativePath = file.originalFilename; // 保留表单中上传的文件名 (包含文件夹结构) - // 比如 child2/b.txt - const minioPath = `private/${tokenUser.username}/${appKey}/${relativePath}`; - // 上传到 MinIO 并保留文件夹结构 - const isHTML = relativePath.endsWith('.html'); - await minioClient.fPutObject(bucketName, minioPath, tempPath, { - 'Content-Type': getContentType(relativePath), - 'app-source': 'user-micro-app', - 'Cache-Control': isHTML ? 'no-cache' : 'max-age=31536000, immutable', // 缓存一年 - }); - uploadResults.push({ - name: relativePath, - path: minioPath, - hash: file.hash, - size: file.size, - }); - fs.unlinkSync(tempPath); // 删除临时文件 - } + const tempPath = file.filepath; // 文件上传时的临时路径 + const relativePath = file.originalFilename; // 保留表单中上传的文件名 (包含文件夹结构) + // 比如 child2/b.txt + const minioPath = `private/${tokenUser.username}/${appKey}/${relativePath}`; + // 上传到 MinIO 并保留文件夹结构 + const isHTML = relativePath.endsWith('.html'); + await minioClient.fPutObject(bucketName, minioPath, tempPath, { + 'Content-Type': getContentType(relativePath), + 'app-source': 'user-micro-app', + 'Cache-Control': isHTML ? 'no-cache' : 'max-age=31536000, immutable', // 缓存一年 + }); + uploadResults.push({ + name: relativePath, + path: minioPath, + hash: file.hash, + size: file.size, + }); + fs.unlinkSync(tempPath); // 删除临时文件 + // 受控 const r = await app.call({ path: 'micro-app', @@ -115,6 +156,8 @@ router.post('/api/micro-app/upload', async (req, res) => { } res.end(JSON.stringify(data)); }); + + pipeBusboy(req, res, busboy); }); diff --git a/src/routes-simple/handle-request.ts b/src/routes-simple/handle-request.ts index aa17a26..67564ca 100644 --- a/src/routes-simple/handle-request.ts +++ b/src/routes-simple/handle-request.ts @@ -1,7 +1,7 @@ import { useFileStore } from '@kevisual/use-config/file-store'; import http from 'node:http'; import fs from 'fs'; -import { IncomingForm } from 'formidable'; +import Busboy from 'busboy'; import { app, minioClient } from '@/app.ts'; import { bucketName } from '@/modules/minio.ts'; @@ -11,6 +11,9 @@ import { getContainerById } from '@/routes/container/module/get-container-file.t import { router, error, checkAuth, writeEvents } from './router.ts'; import './index.ts'; import { handleRequest as PageProxy } from './page-proxy.ts'; +import path from 'path'; +import { createWriteStream } from 'fs'; +import { pipeBusboy } from '@/modules/fm-manager/pipe-busboy.ts'; const cacheFilePath = useFileStore('cache-file', { needExists: true }); router.get('/api/app/upload', async (req, res) => { @@ -23,41 +26,80 @@ router.post('/api/app/upload', async (req, res) => { res.writeHead(200, { 'Content-Type': 'application/json' }); const { tokenUser, token } = await checkAuth(req, res); if (!tokenUser) return; - // 使用 formidable 解析 multipart/form-data - const form = new IncomingForm({ - multiples: true, // 支持多文件上传 - uploadDir: cacheFilePath, // 上传文件存储目录 - allowEmptyFiles: true, // 允许空 - minFileSize: 0, // 最小文件大小 - createDirsFromUploads: false, // 根据上传的文件夹结构创建目录 - keepExtensions: true, // 保留文件 - hashAlgorithm: 'md5', // 文件哈希算法 + + // 使用 busboy 解析 multipart/form-data + const busboy = Busboy({ headers: req.headers }); + const fields: any = {}; + const files: any = []; + const filePromises: Promise[] = []; + let bytesReceived = 0; + let bytesExpected = parseInt(req.headers['content-length'] || '0'); + + busboy.on('field', (fieldname, value) => { + fields[fieldname] = value; }); - form.on('progress', (bytesReceived, bytesExpected) => { - const progress = (bytesReceived / bytesExpected) * 100; - console.log(`Upload progress: ${progress.toFixed(2)}%`); - const data = { - progress: progress.toFixed(2), - message: `Upload progress: ${progress.toFixed(2)}%`, - }; - writeEvents(req, data); - }); - // 解析上传的文件 - form.parse(req, async (err, fields, files) => { - if (err) { - res.end(error(`Upload error: ${err.message}`)); - const uploadedFiles = Array.isArray(files.file) ? files.file : [files.file]; - uploadedFiles.forEach((file) => { - fs.unlinkSync(file.filepath); + + busboy.on('file', (fieldname, fileStream, info) => { + const { filename, encoding, mimeType } = info; + const tempPath = path.join(cacheFilePath, `${Date.now()}-${Math.random().toString(36).substring(7)}-${filename}`); + const writeStream = createWriteStream(tempPath); + + const filePromise = new Promise((resolve, reject) => { + fileStream.on('data', (chunk) => { + bytesReceived += chunk.length; + if (bytesExpected > 0) { + const progress = (bytesReceived / bytesExpected) * 100; + console.log(`Upload progress: ${progress.toFixed(2)}%`); + const data = { + progress: progress.toFixed(2), + message: `Upload progress: ${progress.toFixed(2)}%`, + }; + writeEvents(req, data); + } }); + + fileStream.pipe(writeStream); + + writeStream.on('finish', () => { + files.push({ + filepath: tempPath, + originalFilename: filename, + mimetype: mimeType, + }); + resolve(); + }); + + writeStream.on('error', (err) => { + reject(err); + }); + }); + + filePromises.push(filePromise); + }); + + busboy.on('finish', async () => { + // 等待所有文件写入完成 + try { + await Promise.all(filePromises); + } catch (err) { + console.error(`File write error: ${err.message}`); + res.end(error(`File write error: ${err.message}`)); return; } const clearFiles = () => { - const uploadedFiles = Array.isArray(files.file) ? files.file : [files.file]; - uploadedFiles.forEach((file) => { - fs.unlinkSync(file.filepath); + files.forEach((file: any) => { + if (file?.filepath && fs.existsSync(file.filepath)) { + fs.unlinkSync(file.filepath); + } }); }; + + // 检查是否有文件上传 + if (files.length === 0) { + res.end(error('files is required')); + return; + } + let appKey, version, username = ''; @@ -99,11 +141,9 @@ router.post('/api/app/upload', async (req, res) => { console.log('Appkey', appKey, version); // 逐个处理每个上传的文件 - const uploadedFiles = Array.isArray(files.file) ? files.file : [files.file]; const uploadResults = []; - for (let i = 0; i < uploadedFiles.length; i++) { - const file = uploadedFiles[i]; - // @ts-ignore + for (let i = 0; i < files.length; i++) { + const file = files[i]; const tempPath = file.filepath; // 文件上传时的临时路径 const relativePath = file.originalFilename; // 保留表单中上传的文件名 (包含文件夹结构) // 比如 child2/b.txt @@ -144,6 +184,8 @@ router.post('/api/app/upload', async (req, res) => { } res.end(JSON.stringify(data)); }); + + pipeBusboy(req, res, busboy); }); router.get('/api/container/file/:id', async (req, res) => { diff --git a/src/routes-simple/resources/chunk.ts b/src/routes-simple/resources/chunk.ts index 82b1c49..604838c 100644 --- a/src/routes-simple/resources/chunk.ts +++ b/src/routes-simple/resources/chunk.ts @@ -1,6 +1,6 @@ import { useFileStore } from '@kevisual/use-config/file-store'; import { checkAuth, error, router, writeEvents, getKey, getTaskId } from '../router.ts'; -import { IncomingForm } from 'formidable'; +import Busboy from 'busboy'; import { app, oss } from '@/app.ts'; import { getContentType } from '@/utils/get-content-type.ts'; @@ -8,6 +8,9 @@ import { User } from '@/models/user.ts'; import fs from 'fs'; import { ConfigModel } from '@/routes/config/models/model.ts'; import { validateDirectory } from './util.ts'; +import path from 'path'; +import { createWriteStream } from 'fs'; +import { pipeBusboy } from '@/modules/fm-manager/index.ts'; const cacheFilePath = useFileStore('cache-file', { needExists: true }); @@ -23,35 +26,70 @@ router.post('/api/s1/resources/upload/chunk', async (req, res) => { const url = new URL(req.url || '', 'http://localhost'); const share = !!url.searchParams.get('public'); const noCheckAppFiles = !!url.searchParams.get('noCheckAppFiles'); - // 使用 formidable 解析 multipart/form-data - const form = new IncomingForm({ - multiples: false, // 改为单文件上传 - uploadDir: cacheFilePath, // 上传文件存储目录 - allowEmptyFiles: true, // 允许空 - minFileSize: 0, // 最小文件大小 - createDirsFromUploads: false, // 根据上传的文件夹结构创建目录 - keepExtensions: true, // 保留文件拓展名 - hashAlgorithm: 'md5', // 文件哈希算法 - }); + const taskId = getTaskId(req); const finalFilePath = `${cacheFilePath}/${taskId}`; if (!taskId) { res.end(error('taskId is required')); return; } - // 解析上传的文件 - form.parse(req, async (err, fields, files) => { - const file = Array.isArray(files.file) ? files.file[0] : files.file; + + // 使用 busboy 解析 multipart/form-data + const busboy = Busboy({ headers: req.headers }); + const fields: any = {}; + let file: any = null; + let tempPath = ''; + let filePromise: Promise | null = null; + + busboy.on('field', (fieldname, value) => { + fields[fieldname] = value; + }); + + busboy.on('file', (fieldname, fileStream, info) => { + const { filename, encoding, mimeType } = info; + tempPath = path.join(cacheFilePath, `${Date.now()}-${Math.random().toString(36).substring(7)}-${filename}`); + const writeStream = createWriteStream(tempPath); + + filePromise = new Promise((resolve, reject) => { + fileStream.pipe(writeStream); + + writeStream.on('finish', () => { + file = { + filepath: tempPath, + originalFilename: filename, + mimetype: mimeType, + }; + resolve(); + }); + + writeStream.on('error', (err) => { + reject(err); + }); + }); + }); + + busboy.on('finish', async () => { + // 等待文件写入完成 + if (filePromise) { + try { + await filePromise; + } catch (err) { + console.error(`File write error: ${err.message}`); + res.end(error(`File write error: ${err.message}`)); + return; + } + } const clearFiles = () => { - if (file) { - fs.unlinkSync(file.filepath); + if (tempPath && fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } + if (fs.existsSync(finalFilePath)) { fs.unlinkSync(finalFilePath); } }; - if (err) { - res.end(error(`Upload error: ${err.message}`)); - clearFiles(); + if (!file) { + res.end(error('No file uploaded')); return; } @@ -69,9 +107,7 @@ router.post('/api/s1/resources/upload/chunk', async (req, res) => { clearFiles(); return; } - const tempPath = file.filepath; const relativePath = file.originalFilename; - // Append chunk to the final file const writeStream = fs.createWriteStream(finalFilePath, { flags: 'a' }); const readStream = fs.createReadStream(tempPath); @@ -195,4 +231,6 @@ router.post('/api/s1/resources/upload/chunk', async (req, res) => { } }); }); + + pipeBusboy(req, res, busboy); }); diff --git a/src/routes-simple/resources/upload.ts b/src/routes-simple/resources/upload.ts index 69ca3fa..a1d91f5 100644 --- a/src/routes-simple/resources/upload.ts +++ b/src/routes-simple/resources/upload.ts @@ -1,12 +1,15 @@ import { useFileStore } from '@kevisual/use-config/file-store'; import { checkAuth, error, router, writeEvents, getKey } from '../router.ts'; -import { IncomingForm } from 'formidable'; +import Busboy from 'busboy'; import { app, minioClient } from '@/app.ts'; import { bucketName } from '@/modules/minio.ts'; import { getContentType } from '@/utils/get-content-type.ts'; import { User } from '@/models/user.ts'; import fs from 'fs'; +import path from 'path'; +import { createWriteStream } from 'fs'; +import { pipeBusboy } from '@/modules/fm-manager/pipe-busboy.ts'; import { ConfigModel } from '@/routes/config/models/model.ts'; import { validateDirectory } from './util.ts'; import { pick } from 'lodash-es'; @@ -103,41 +106,79 @@ router.post('/api/s1/resources/upload', async (req, res) => { const share = !!url.searchParams.get('public'); const meta = parseIfJson(url.searchParams.get('meta')); const noCheckAppFiles = !!url.searchParams.get('noCheckAppFiles'); - // 使用 formi dable 解析 multipart/form-data - const form = new IncomingForm({ - multiples: true, // 支持多文件上传 - uploadDir: cacheFilePath, // 上传文件存储目录 - allowEmptyFiles: true, // 允许空 - minFileSize: 0, // 最小文件大小 - createDirsFromUploads: false, // 根据上传的文件夹结构创建目录 - keepExtensions: true, // 保留文件拓展名 - hashAlgorithm: 'md5', // 文件哈希算法 + // 使用 busboy 解析 multipart/form-data + const busboy = Busboy({ headers: req.headers }); + const fields: any = {}; + const files: any[] = []; + const filePromises: Promise[] = []; + let bytesReceived = 0; + let bytesExpected = parseInt(req.headers['content-length'] || '0'); + + busboy.on('field', (fieldname, value) => { + fields[fieldname] = value; }); - form.on('progress', (bytesReceived, bytesExpected) => { - const progress = (bytesReceived / bytesExpected) * 100; - const data = { - progress: progress.toFixed(2), - message: `Upload progress: ${progress.toFixed(2)}%`, - }; - console.log('progress-upload', data); - writeEvents(req, data); + + busboy.on('file', (fieldname, fileStream, info) => { + const { filename, encoding, mimeType } = info; + const tempPath = path.join(cacheFilePath, `${Date.now()}-${Math.random().toString(36).substring(7)}-${filename}`); + const writeStream = createWriteStream(tempPath); + + const filePromise = new Promise((resolve, reject) => { + fileStream.on('data', (chunk) => { + bytesReceived += chunk.length; + if (bytesExpected > 0) { + const progress = (bytesReceived / bytesExpected) * 100; + const data = { + progress: progress.toFixed(2), + message: `Upload progress: ${progress.toFixed(2)}%`, + }; + console.log('progress-upload', data); + writeEvents(req, data); + } + }); + + fileStream.pipe(writeStream); + + writeStream.on('finish', () => { + files.push({ + filepath: tempPath, + originalFilename: filename, + mimetype: mimeType, + }); + resolve(); + }); + + writeStream.on('error', (err) => { + reject(err); + }); + }); + + filePromises.push(filePromise); }); - // 解析上传的文件 - form.parse(req, async (err, fields, files) => { + + busboy.on('finish', async () => { + // 等待所有文件写入完成 + try { + await Promise.all(filePromises); + } catch (err) { + logger.error(`File write error: ${err.message}`); + res.end(error(`File write error: ${err.message}`)); + return; + } const clearFiles = () => { - const uploadedFiles = Array.isArray(files.file) ? files.file : [files.file]; - uploadedFiles.forEach((file) => { + files.forEach((file) => { if (file?.filepath && fs.existsSync(file.filepath)) { fs.unlinkSync(file.filepath); } }); }; - if (err) { - logger.error(`Upload error: ${err.message}`); - res.end(error(`Upload error: ${err.message}`)); - clearFiles(); + + // 检查是否有文件上传 + if (files.length === 0) { + res.end(error('files is required')); return; } + let { appKey, version, username, directory, description } = getKey(fields, ['appKey', 'version', 'username', 'directory', 'description']); let uid = tokenUser.id; if (username) { @@ -170,7 +211,7 @@ router.post('/api/s1/resources/upload', async (req, res) => { return; } // 逐个处理每个上传的文件 - const uploadedFiles = Array.isArray(files.file) ? files.file : [files.file]; + const uploadedFiles = files; logger.info( 'upload files', uploadedFiles.map((item) => { @@ -244,4 +285,6 @@ router.post('/api/s1/resources/upload', async (req, res) => { ); } }); + + pipeBusboy(req, res, busboy); }); diff --git a/src/routes-simple/router.ts b/src/routes-simple/router.ts index b834c05..3de694d 100644 --- a/src/routes-simple/router.ts +++ b/src/routes-simple/router.ts @@ -2,7 +2,6 @@ import { router } from '@/app.ts'; import http from 'http'; import { useContextKey } from '@kevisual/context'; import { checkAuth, error } from './middleware/auth.ts'; -import formidable from 'formidable'; export { router, checkAuth, error }; /** @@ -68,7 +67,7 @@ export const deleteOldClients = () => { * @param parseKeys 需要解析的键 * @returns 解析后的数据 */ -export const getKey = (fields: formidable.Fields, parseKeys: string[]) => { +export const getKey = (fields: Record, parseKeys: string[]) => { let value: Record = {}; for (const key of parseKeys) { const v = fields[key];