Refactor AI proxy error handling and remove deprecated upload and event routes

- Updated `getAiProxy` function to return a JSON response for missing objects when the user is the owner.
- Removed the `upload.ts`, `event.ts`, and related middleware files to streamline the codebase.
- Cleaned up `handle-request.ts` and `index.ts` by removing unused imports and routes.
- Deleted chunk upload handling and related utility functions to simplify resource management.
- Enhanced app manager list functionality to support app creation if not found.
This commit is contained in:
2026-02-02 18:06:31 +08:00
parent 82e3392b36
commit 158dd9e85c
14 changed files with 92 additions and 908 deletions

View File

@@ -49,7 +49,7 @@
"dependencies": {
"@kevisual/ai": "^0.0.24",
"@kevisual/auth": "^2.0.3",
"@kevisual/query": "^0.0.38",
"@kevisual/query": "^0.0.39",
"@types/busboy": "^1.5.4",
"@types/send": "^1.2.1",
"@types/ws": "^8.18.1",
@@ -77,16 +77,16 @@
"@kevisual/file-listener": "^0.0.2",
"@kevisual/local-app-manager": "0.1.32",
"@kevisual/logger": "^0.0.4",
"@kevisual/oss": "0.0.18",
"@kevisual/permission": "^0.0.3",
"@kevisual/router": "0.0.65",
"@kevisual/oss": "0.0.19",
"@kevisual/permission": "^0.0.4",
"@kevisual/router": "0.0.66",
"@kevisual/types": "^0.0.12",
"@kevisual/use-config": "^1.0.28",
"@kevisual/use-config": "^1.0.30",
"@types/archiver": "^7.0.0",
"@types/bun": "^1.3.8",
"@types/crypto-js": "^4.2.2",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.1.0",
"@types/node": "^25.2.0",
"@types/pg": "^8.16.0",
"@types/semver": "^7.7.1",
"@types/xml2js": "^0.4.14",

88
pnpm-lock.yaml generated
View File

@@ -19,8 +19,8 @@ importers:
specifier: ^2.0.3
version: 2.0.3
'@kevisual/query':
specifier: ^0.0.38
version: 0.0.38
specifier: ^0.0.39
version: 0.0.39
'@types/busboy':
specifier: ^1.5.4
version: 1.5.4
@@ -98,20 +98,20 @@ importers:
specifier: ^0.0.4
version: 0.0.4
'@kevisual/oss':
specifier: 0.0.18
version: 0.0.18
specifier: 0.0.19
version: 0.0.19
'@kevisual/permission':
specifier: ^0.0.3
version: 0.0.3
specifier: ^0.0.4
version: 0.0.4
'@kevisual/router':
specifier: 0.0.65
version: 0.0.65(typescript@5.9.3)
specifier: 0.0.66
version: 0.0.66(typescript@5.9.3)
'@kevisual/types':
specifier: ^0.0.12
version: 0.0.12
'@kevisual/use-config':
specifier: ^1.0.28
version: 1.0.28(dotenv@17.2.3)
specifier: ^1.0.30
version: 1.0.30(dotenv@17.2.3)
'@types/archiver':
specifier: ^7.0.0
version: 7.0.0
@@ -125,8 +125,8 @@ importers:
specifier: ^9.0.10
version: 9.0.10
'@types/node':
specifier: ^25.1.0
version: 25.1.0
specifier: ^25.2.0
version: 25.2.0
'@types/pg':
specifier: ^8.16.0
version: 8.16.0
@@ -876,15 +876,21 @@ packages:
'@kevisual/logger@0.0.4':
resolution: {integrity: sha512-+fpr92eokSxoGOW1SIRl/27lPuO+zyY+feR5o2Q4YCNlAdt2x64NwC/w8r/3NEC5QenLgd4K0azyKTI2mHbARw==}
'@kevisual/oss@0.0.18':
resolution: {integrity: sha512-vTdXe41inq4oc+bfYIR3xMDm8GZyOAaWq3DBh+Eur9uNOJcIUdgZBVPOm2uSigmjl3PvqekUw8bE/vbWWJAY7w==}
'@kevisual/oss@0.0.19':
resolution: {integrity: sha512-4Y5krJTqLQOsEwJf7K7a/88t9YHm8PQNuZ5SJDTMopYDOflJlwVjvqiu0lapQ0UrpI+wG6FdfmdmnWpXdQsa1Q==}
'@kevisual/permission@0.0.3':
resolution: {integrity: sha512-8JsA/5O5Ax/z+M+MYpFYdlioHE6jNmWMuFSokBWYs9CCAHNiSKMR01YLkoVDoPvncfH/Y8F5K/IEXRCbptuMNA==}
'@kevisual/permission@0.0.4':
resolution: {integrity: sha512-zwBYPnT/z21W4q2wkklJrxvoYBYWG/+a3iXFDKqXQAnDOcxm/SU1f1N6FQb9KxGKl36/fclVlhxlxqszvKCenQ==}
'@kevisual/query@0.0.38':
resolution: {integrity: sha512-bfvbSodsZyMfwY+1T2SvDeOCKsT/AaIxlVe0+B1R/fNhlg2MDq2CP0L9HKiFkEm+OXrvXcYDMKPUituVUM5J6Q==}
'@kevisual/query@0.0.39':
resolution: {integrity: sha512-3UEPBIvtdykNkrby3hvrgrHdgd17Uq+Pnr4zs+JBzATkU2eKaOqtTUJqdyIEwuySCwzGTxrnlUzWP4tziDQDLQ==}
'@kevisual/router@0.0.21':
resolution: {integrity: sha512-XKTxbNO924cT18UOAGplWErZ+hMze8Y53F2jYCk18v4jsdsvjRho5uXXjJb6HSVsuITMtQR4R3rG0IcM3jkDKQ==}
@@ -897,14 +903,14 @@ packages:
'@kevisual/router@0.0.60':
resolution: {integrity: sha512-2v/ZzUstsaq+Uqo+tZX9ys5E+/2erPggCtljv9jTb3NA88ZdHsYUAsd5wUFvLtf9QucpJCzyWEt+InDV/98FKw==}
'@kevisual/router@0.0.65':
resolution: {integrity: sha512-UiGqjLWheDbWOhEBBOSggCnafYFz3tCjLZYDp44ahiyeC2APwFRozz7UYbEq7+amH4Ex1wdqk1AlKmuP7w04og==}
'@kevisual/router@0.0.66':
resolution: {integrity: sha512-yoiCfKJ8yxrXToh8ud1+/JFqlRexrZmJ0PhofQX3jyfmmyEBQQJFL+2UYewm4FxbG3l7ndBC/NIhu1v5CdwxiQ==}
'@kevisual/types@0.0.12':
resolution: {integrity: sha512-zJXH2dosir3jVrQ6QG4i0+iLQeT9gJ3H+cKXs8ReWboxBSYzUZO78XssVeVrFPsJ33iaAqo4q3DWbSS1dWGn7Q==}
'@kevisual/use-config@1.0.28':
resolution: {integrity: sha512-ngF+LDbjxpXWrZNmnShIKF/jPpAa+ezV+DcgoZIIzHlRnIjE+rr9sLkN/B7WJbiH9C/j1tQXOILY8ujBqILrow==}
'@kevisual/use-config@1.0.30':
resolution: {integrity: sha512-kPdna0FW/X7D600aMdiZ5UTjbCo6d8d4jjauSc8RMmBwUU6WliFDSPUNKVpzm2BsDX5Nth1IXFPYMqH+wxqAmw==}
peerDependencies:
dotenv: ^17
@@ -1408,8 +1414,8 @@ packages:
'@types/node@25.0.10':
resolution: {integrity: sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==}
'@types/node@25.1.0':
resolution: {integrity: sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==}
'@types/node@25.2.0':
resolution: {integrity: sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==}
'@types/pg@8.16.0':
resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==}
@@ -3872,7 +3878,7 @@ snapshots:
dependencies:
'@kevisual/auth': 1.0.5
'@kevisual/router': 0.0.21
'@kevisual/use-config': 1.0.28(dotenv@17.2.3)
'@kevisual/use-config': 1.0.30(dotenv@17.2.3)
ioredis: 5.9.2
nanoid: 5.1.6
pg: 8.18.0
@@ -3898,7 +3904,7 @@ snapshots:
dependencies:
'@kevisual/auth': 1.0.5
'@kevisual/router': 0.0.23
'@kevisual/use-config': 1.0.28(dotenv@17.2.3)
'@kevisual/use-config': 1.0.30(dotenv@17.2.3)
ioredis: 5.9.2
nanoid: 5.1.6
pg: 8.18.0
@@ -3937,7 +3943,7 @@ snapshots:
dependencies:
'@kevisual/code-center-module': 0.0.20(dotenv@17.2.3)
'@kevisual/router': 0.0.22
'@kevisual/use-config': 1.0.28(dotenv@17.2.3)
'@kevisual/use-config': 1.0.30(dotenv@17.2.3)
cookie: 1.1.1
dayjs: 1.11.19
formidable: 3.5.4
@@ -3971,14 +3977,20 @@ snapshots:
'@kevisual/logger@0.0.4': {}
'@kevisual/oss@0.0.18': {}
'@kevisual/oss@0.0.19': {}
'@kevisual/permission@0.0.3': {}
'@kevisual/permission@0.0.4': {}
'@kevisual/query@0.0.38':
dependencies:
tslib: 2.8.1
'@kevisual/query@0.0.39':
dependencies:
tslib: 2.8.1
'@kevisual/router@0.0.21':
dependencies:
path-to-regexp: 8.3.0
@@ -4001,7 +4013,7 @@ snapshots:
dependencies:
hono: 4.11.5
'@kevisual/router@0.0.65(typescript@5.9.3)':
'@kevisual/router@0.0.66(typescript@5.9.3)':
dependencies:
'@kevisual/dts': 0.0.3(typescript@5.9.3)
hono: 4.11.7
@@ -4010,7 +4022,7 @@ snapshots:
'@kevisual/types@0.0.12': {}
'@kevisual/use-config@1.0.28(dotenv@17.2.3)':
'@kevisual/use-config@1.0.30(dotenv@17.2.3)':
dependencies:
'@kevisual/load': 0.0.6
dotenv: 17.2.3
@@ -4562,13 +4574,13 @@ snapshots:
'@types/busboy@1.5.4':
dependencies:
'@types/node': 25.1.0
'@types/node': 25.2.0
'@types/cookie@0.4.1': {}
'@types/cors@2.8.17':
dependencies:
'@types/node': 25.1.0
'@types/node': 25.2.0
'@types/crypto-js@4.2.2': {}
@@ -4581,31 +4593,31 @@ snapshots:
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 0.7.34
'@types/node': 25.1.0
'@types/node': 25.2.0
'@types/ms@0.7.34': {}
'@types/node-forge@1.3.11':
dependencies:
'@types/node': 25.1.0
'@types/node': 25.2.0
'@types/node@25.0.10':
dependencies:
undici-types: 7.16.0
'@types/node@25.1.0':
'@types/node@25.2.0':
dependencies:
undici-types: 7.16.0
'@types/pg@8.16.0':
dependencies:
'@types/node': 25.1.0
'@types/node': 25.2.0
pg-protocol: 1.11.0
pg-types: 2.2.0
'@types/readdir-glob@1.1.5':
dependencies:
'@types/node': 25.1.0
'@types/node': 25.2.0
'@types/resolve@1.20.2': {}
@@ -4613,13 +4625,13 @@ snapshots:
'@types/send@1.2.1':
dependencies:
'@types/node': 25.1.0
'@types/node': 25.2.0
'@types/validator@13.12.2': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 25.1.0
'@types/node': 25.2.0
'@types/xml2js@0.4.14':
dependencies:
@@ -4789,7 +4801,7 @@ snapshots:
bun-types@1.3.8:
dependencies:
'@types/node': 25.1.0
'@types/node': 25.2.0
busboy@1.6.0:
dependencies:
@@ -4999,7 +5011,7 @@ snapshots:
dependencies:
'@types/cookie': 0.4.1
'@types/cors': 2.8.17
'@types/node': 25.1.0
'@types/node': 25.2.0
accepts: 1.3.8
base64id: 2.0.0
cookie: 0.4.2
@@ -6291,7 +6303,7 @@ snapshots:
wkx@0.5.0:
dependencies:
'@types/node': 25.1.0
'@types/node': 25.2.0
wrap-ansi@7.0.0:
dependencies:

View File

@@ -98,10 +98,19 @@ const getAiProxy = async (req: IncomingMessage, res: ServerResponse, opts: Proxy
return true;
}
const stat = await oss.statObject(objectName);
if (!stat) {
createNotFoundPage('Invalid proxy url');
if (!stat && isOwner) {
// createNotFoundPage('文件不存在');
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({
code: 404,
message: 'object not found',
}),
);
logger.debug('no stat', objectName, owner, req.url);
return true;
} else if (!stat && !isOwner) {
return createNotFoundPage('Invalid ai proxy url');
}
const permissionInstance = new UserPermission({ permission: stat.metaData as Permission, owner: owner });
const checkPermission = permissionInstance.checkPermissionSuccess({
@@ -112,6 +121,7 @@ const getAiProxy = async (req: IncomingMessage, res: ServerResponse, opts: Proxy
logger.info('no permission', checkPermission, loginUser, owner);
return createNotFoundPage('no permission');
}
if (showStat) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(

View File

@@ -1,171 +0,0 @@
import Busboy from 'busboy';
import { checkAuth } from '../middleware/auth.ts';
import { router, clients, writeEvents } from '../router.ts';
import { error } from '../middleware/auth.ts';
import fs from 'fs';
import { useFileStore } from '@kevisual/use-config';
import { app, oss } from '@/app.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; // 如果响应已发送,不再处理
res.writeHead(200, { 'Content-Type': 'application/json' });
const { tokenUser, token } = await checkAuth(req, res);
if (!tokenUser) return;
// 使用 busboy 解析 multipart/form-data
const busboy = Busboy({ headers: req.headers, preservePath: true, defCharset: 'utf-8' });
const fields: any = {};
let file: any = null;
let filePromise: Promise<void> | null = null;
let bytesReceived = 0;
let bytesExpected = parseInt(req.headers['content-length'] || '0');
busboy.on('field', (fieldname, value) => {
fields[fieldname] = value;
});
busboy.on('file', (fieldname, fileStream, info) => {
const { filename, encoding, mimeType } = info;
// 处理 UTF-8 文件名编码
const decodedFilename = typeof filename === 'string' ? Buffer.from(filename, 'latin1').toString('utf8') : filename;
const tempPath = path.join(cacheFilePath, `${Date.now()}-${Math.random().toString(36).substring(7)}`);
const writeStream = createWriteStream(tempPath);
const hash = crypto.createHash('md5');
let size = 0;
filePromise = new Promise<void>((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);
}
});
fileStream.pipe(writeStream);
writeStream.on('finish', () => {
file = {
filepath: tempPath,
originalFilename: decodedFilename,
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 = () => {
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)) {
appKey = _appKey?.[0];
} else {
appKey = _appKey;
}
if (Array.isArray(_collecion)) {
collection = _collecion?.[0];
} else {
collection = _collecion;
}
collection = parseIfJson(collection);
appKey = appKey || 'micro-app';
console.log('Appkey', appKey);
console.log('collection', collection);
// 处理上传的文件
const uploadResults = [];
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 oss.fPutObject(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',
key: 'upload',
payload: {
token: token,
data: {
appKey,
collection,
files: uploadResults,
},
},
});
const data: any = {
code: r.code,
data: r.body,
};
if (r.message) {
data.message = r.message;
}
res.end(JSON.stringify(data));
});
pipeBusboy(req, res, busboy);
});
function parseIfJson(collection: any): any {
try {
return JSON.parse(collection);
} catch (e) {
return collection;
}
}

View File

@@ -1,49 +0,0 @@
import { router, error, checkAuth, clients, getTaskId, writeEvents, deleteOldClients } from './router.ts';
router.get('/api/events', async (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const taskId = getTaskId(req);
if (!taskId) {
res.end(error('task-id is required'));
return;
}
// 将客户端连接推送到 clients 数组
clients.set(taskId, { client: res, createTime: Date.now() });
// 移除客户端连接
req.on('close', () => {
clients.delete(taskId);
});
});
router.get('/api/s1/events', async (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const taskId = getTaskId(req);
if (!taskId) {
res.end(error('task-id is required'));
return;
}
// 将客户端连接推送到 clients 数组
clients.set(taskId, { client: res, createTime: Date.now() });
writeEvents(req, { progress: 0, message: 'start' });
// 不自动关闭连接
// res.end('ok');
});
router.get('/api/s1/events/close', async (req, res) => {
const taskId = getTaskId(req);
if (!taskId) {
res.end(error('task-id is required'));
return;
}
deleteOldClients();
clients.delete(taskId);
res.end('ok');
});

View File

@@ -1,20 +1,9 @@
import { useFileStore } from '@kevisual/use-config';
import http from 'node:http';
import fs from 'fs';
import Busboy from 'busboy';
import { app, oss } from '@/app.ts';
import { getContentType } from '@/utils/get-content-type.ts';
import { User } from '@/models/user.ts';
import { router, error, checkAuth, writeEvents } from './router.ts';
import { router } from './router.ts';
import './index.ts';
import { handleRequest as PageProxy } from './page-proxy.ts';
const simpleAppsPrefixs = [
"/api/micro-app/",
"/api/events",
"/api/s1/",
"/api/resource/",
"/api/wxmsg"
];

View File

@@ -1,6 +0,0 @@
// import './code/upload.ts';
import './event.ts';
import './resources/upload.ts';
import './resources/chunk.ts';
// import './resources/get-resources.ts';

View File

@@ -1,61 +0,0 @@
import { User } from '@/models/user.ts';
import http from 'http';
import { parse } from '@kevisual/router/src/server/cookie.ts';
export const error = (msg: string, code = 500) => {
return JSON.stringify({ code, message: msg });
};
export const checkAuth = async (req: http.IncomingMessage, res: http.ServerResponse) => {
let token = (req.headers?.['authorization'] as string) || (req.headers?.['Authorization'] as string) || '';
const url = new URL(req.url || '', 'http://localhost');
const resNoPermission = () => {
res.statusCode = 401;
res.end(error('Invalid authorization'));
return { tokenUser: null, token: null };
};
if (!token) {
token = url.searchParams.get('token') || '';
}
if (!token) {
const parsedCookies = parse(req.headers.cookie || '');
token = parsedCookies.token || '';
}
if (!token) {
return resNoPermission();
}
if (token) {
token = token.replace('Bearer ', '');
}
let tokenUser;
try {
tokenUser = await User.verifyToken(token);
} catch (e) {
console.log('checkAuth error', e);
res.statusCode = 401;
res.end(error('Invalid token'));
return { tokenUser: null, token: null };
}
return { tokenUser, token };
};
export const getLoginUser = async (req: http.IncomingMessage) => {
let token = (req.headers?.['authorization'] as string) || (req.headers?.['Authorization'] as string) || '';
const url = new URL(req.url || '', 'http://localhost');
if (!token) {
token = url.searchParams.get('token') || '';
}
if (!token) {
const parsedCookies = parse(req.headers.cookie || '');
token = parsedCookies.token || '';
}
if (token) {
token = token.replace('Bearer ', '');
}
let tokenUser;
try {
tokenUser = await User.verifyToken(token);
return { tokenUser, token };
} catch (e) {
return null;
}
};

View File

@@ -1 +0,0 @@
export * from './auth.ts'

View File

@@ -1,237 +0,0 @@
import { useFileStore } from '@kevisual/use-config';
import { checkAuth, error, router, writeEvents, getKey, getTaskId } from '../router.ts';
import Busboy from 'busboy';
import { app, oss } from '@/app.ts';
import { getContentType } from '@/utils/get-content-type.ts';
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 });
router.get('/api/s1/resources/upload/chunk', async (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Upload API is ready');
});
// /api/s1/resources/upload
router.post('/api/s1/resources/upload/chunk', async (req, res) => {
const { tokenUser, token } = await checkAuth(req, res);
if (!tokenUser) return;
const url = new URL(req.url || '', 'http://localhost');
const share = !!url.searchParams.get('public');
const noCheckAppFiles = !!url.searchParams.get('noCheckAppFiles');
const taskId = getTaskId(req);
const finalFilePath = `${cacheFilePath}/${taskId}`;
if (!taskId) {
res.end(error('taskId is required'));
return;
}
// 使用 busboy 解析 multipart/form-data
const busboy = Busboy({ headers: req.headers, preservePath: true, defCharset: 'utf-8' });
const fields: any = {};
let file: any = null;
let tempPath = '';
let filePromise: Promise<void> | null = null;
busboy.on('field', (fieldname, value) => {
fields[fieldname] = value;
});
busboy.on('file', (fieldname, fileStream, info) => {
const { filename, encoding, mimeType } = info;
// 处理 UTF-8 文件名编码
const decodedFilename = typeof filename === 'string' ? Buffer.from(filename, 'latin1').toString('utf8') : filename;
tempPath = path.join(cacheFilePath, `${Date.now()}-${Math.random().toString(36).substring(7)}`);
const writeStream = createWriteStream(tempPath);
filePromise = new Promise<void>((resolve, reject) => {
fileStream.pipe(writeStream);
writeStream.on('finish', () => {
file = {
filepath: tempPath,
originalFilename: decodedFilename,
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 (tempPath && fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
if (fs.existsSync(finalFilePath)) {
fs.unlinkSync(finalFilePath);
}
};
if (!file) {
res.end(error('No file uploaded'));
return;
}
// Handle chunked upload logic here
let { chunkIndex, totalChunks, appKey, version, username, directory } = getKey(fields, [
'chunkIndex',
'totalChunks',
'appKey',
'version',
'username',
'directory',
]);
if (!chunkIndex || !totalChunks) {
res.end(error('chunkIndex, totalChunks is required'));
clearFiles();
return;
}
const relativePath = file.originalFilename;
const writeStream = fs.createWriteStream(finalFilePath, { flags: 'a' });
const readStream = fs.createReadStream(tempPath);
readStream.pipe(writeStream);
writeStream.on('finish', async () => {
fs.unlinkSync(tempPath); // 删除临时文件
// Write event for progress tracking
const progress = ((parseInt(chunkIndex) + 1) / parseInt(totalChunks)) * 100;
writeEvents(req, {
progress,
message: `Upload progress: ${progress}%`,
});
if (parseInt(chunkIndex) + 1 === parseInt(totalChunks)) {
let uid = tokenUser.id;
if (username) {
const user = await User.getUserByToken(token);
const has = await user.hasUser(username, true);
if (!has) {
res.end(error('username is not found'));
clearFiles();
return;
}
const _user = await User.findOne({ where: { username } });
uid = _user?.id || '';
}
if (!appKey || !version) {
const config = await ConfigModel.getUploadConfig({ uid });
if (config) {
appKey = config.config?.data?.key || '';
version = config.config?.data?.version || '';
}
}
if (!appKey || !version) {
res.end(error('appKey or version is not found, please check the upload config.'));
clearFiles();
return;
}
const { code, message } = validateDirectory(directory);
if (code !== 200) {
res.end(error(message));
clearFiles();
return;
}
const minioPath = `${username || tokenUser.username}/${appKey}/${version}${directory ? `/${directory}` : ''}/${relativePath}`;
const metadata: any = {};
if (share) {
metadata.share = 'public';
}
// All chunks uploaded, now upload to MinIO
await oss.fPutObject(minioPath, finalFilePath, {
'Content-Type': getContentType(relativePath),
'app-source': 'user-app',
'Cache-Control': relativePath.endsWith('.html') ? 'no-cache' : 'max-age=31536000, immutable',
...metadata,
});
// Clean up the final file
fs.unlinkSync(finalFilePath);
const downloadBase = '/api/s1/share';
const uploadResult = {
name: relativePath,
path: `${downloadBase}/${minioPath}`,
appKey,
version,
username,
};
if (!noCheckAppFiles) {
// Notify the app
const r = await app.call({
path: 'app',
key: 'detectVersionList',
payload: {
token: token,
data: {
appKey,
version,
username,
},
},
});
const data: any = {
code: r.code,
data: {
app: r.body,
upload: [uploadResult],
},
};
if (r.message) {
data.message = r.message;
}
console.log('upload data', data);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
} else {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
code: 200,
message: 'Chunk uploaded successfully',
data: { chunkIndex, totalChunks, upload: [uploadResult] },
}),
);
}
} else {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
code: 200,
message: 'Chunk uploaded successfully',
data: {
chunkIndex,
totalChunks,
},
}),
);
}
});
});
pipeBusboy(req, res, busboy);
});

View File

@@ -1,290 +0,0 @@
import { useFileStore } from '@kevisual/use-config';
import { checkAuth, error, router, writeEvents, getKey } from '../router.ts';
import Busboy from 'busboy';
import { app } from '@/app.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 'es-toolkit';
import { getFileStat } from '@/routes/file/index.ts';
import { logger } from '@/modules/logger.ts';
import { oss } from '@/modules/s3.ts';
const cacheFilePath = useFileStore('cache-file', { needExists: true });
router.get('/api/s1/resources/upload', async (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Upload API is ready');
});
export const parseIfJson = (data = '{}') => {
try {
const _data = JSON.parse(data);
if (typeof _data === 'object') return _data;
return {};
} catch (error) {
return {};
}
};
router.post('/api/s1/resources/upload/check', async (req, res) => {
const { tokenUser, token } = await checkAuth(req, res);
if (!tokenUser) {
res.end(error('Token is invalid.'));
return;
}
console.log('data', req.url);
res.writeHead(200, { 'Content-Type': 'application/json' });
const data = await router.getBody(req);
type Data = {
appKey: string;
version: string;
username: string;
directory: string;
files: { path: string; hash: string }[];
};
let { appKey, version, username, directory, files } = pick(data, ['appKey', 'version', 'username', 'directory', 'files']) as Data;
let uid = tokenUser.id;
if (username) {
const user = await User.getUserByToken(token);
const has = await user.hasUser(username, true);
if (!has) {
res.end(error('username is not found'));
return;
}
const _user = await User.findOne({ where: { username } });
uid = _user?.id || '';
}
if (!appKey || !version) {
res.end(error('appKey and version is required'));
}
const { code, message } = validateDirectory(directory);
if (code !== 200) {
res.end(error(message));
return;
}
type CheckResult = {
path: string;
stat: any;
resourcePath: string;
hash: string;
uploadHash: string;
isUpload?: boolean;
};
const checkResult: CheckResult[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const relativePath = file.path;
const minioPath = `${username || tokenUser.username}/${appKey}/${version}${directory ? `/${directory}` : ''}/${relativePath}`;
let stat = await getFileStat(minioPath, true);
const statHash = stat?.etag || '';
checkResult.push({
path: relativePath,
uploadHash: file.hash,
resourcePath: minioPath,
isUpload: statHash === file.hash,
stat,
hash: statHash,
});
}
res.end(JSON.stringify({ code: 200, data: checkResult }));
});
// /api/s1/resources/upload
router.post('/api/s1/resources/upload', async (req, res) => {
const { tokenUser, token } = await checkAuth(req, res);
if (!tokenUser) {
res.end(error('Token is invalid.'));
return;
}
const url = new URL(req.url || '', 'http://localhost');
const share = !!url.searchParams.get('public');
const meta = parseIfJson(url.searchParams.get('meta'));
const noCheckAppFiles = !!url.searchParams.get('noCheckAppFiles');
// 使用 busboy 解析 multipart/form-data
const busboy = Busboy({ headers: req.headers, preservePath: true, defCharset: 'utf-8' });
const fields: any = {};
const files: any[] = [];
const filePromises: Promise<void>[] = [];
let bytesReceived = 0;
let bytesExpected = parseInt(req.headers['content-length'] || '0');
busboy.on('field', (fieldname, value) => {
fields[fieldname] = value;
});
busboy.on('file', (fieldname, fileStream, info) => {
const { filename, encoding, mimeType } = info;
// 处理 UTF-8 文件名编码busboy 可能返回 Latin-1 编码的缓冲区)
const decodedFilename = typeof filename === 'string' ? Buffer.from(filename, 'latin1').toString('utf8') : filename;
const tempPath = path.join(cacheFilePath, `${Date.now()}-${Math.random().toString(36).substring(7)}`);
const writeStream = createWriteStream(tempPath);
const filePromise = new Promise<void>((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: decodedFilename,
mimetype: mimeType,
});
resolve();
});
writeStream.on('error', (err) => {
reject(err);
});
});
filePromises.push(filePromise);
});
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 = () => {
files.forEach((file) => {
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, directory, description } = getKey(fields, ['appKey', 'version', 'username', 'directory', 'description']);
let uid = tokenUser.id;
if (username) {
const user = await User.getUserByToken(token);
const has = await user.hasUser(username, true);
if (!has) {
res.end(error('username is not found'));
clearFiles();
return;
}
const _user = await User.findOne({ where: { username } });
uid = _user?.id || '';
}
if (!appKey || !version) {
const config = await ConfigModel.getUploadConfig({ uid });
if (config) {
appKey = config.config?.data?.key || '';
version = config.config?.data?.version || '';
}
}
if (!appKey || !version) {
res.end(error('appKey or version is not found, please check the upload config.'));
clearFiles();
return;
}
const { code, message } = validateDirectory(directory);
if (code !== 200) {
res.end(error(message));
clearFiles();
return;
}
// 逐个处理每个上传的文件
const uploadedFiles = files;
logger.info(
'upload files',
uploadedFiles.map((item) => {
return pick(item, ['filepath', 'originalFilename']);
}),
);
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 = `${username || tokenUser.username}/${appKey}/${version}${directory ? `/${directory}` : ''}/${relativePath}`;
// 上传到 MinIO 并保留文件夹结构
const isHTML = relativePath.endsWith('.html');
const metadata: any = {};
if (share) {
metadata.share = 'public';
}
Object.assign(metadata, meta);
await oss.fPutObject(minioPath, tempPath, {
'Content-Type': getContentType(relativePath),
'app-source': 'user-app',
'Cache-Control': isHTML ? 'no-cache' : 'max-age=31536000, immutable', // 缓存一年
...metadata,
});
uploadResults.push({
name: relativePath,
path: minioPath,
});
fs.unlinkSync(tempPath); // 删除临时文件
}
if (!noCheckAppFiles) {
const _data = { appKey, version, username, files: uploadResults, description, }
if (_data.description) {
delete _data.description;
}
// 受控
const r = await app.call({
path: 'app',
key: 'uploadFiles',
payload: {
token: token,
data: _data,
},
});
const data: any = {
code: r.code,
data: {
app: r.body,
upload: uploadResults,
},
};
if (r.message) {
data.message = r.message;
}
console.log('upload data', data);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
} else {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
code: 200,
data: {
detect: [],
upload: uploadResults,
},
}),
);
}
});
pipeBusboy(req, res, busboy);
});

View File

@@ -1,30 +0,0 @@
/**
* 校验directory是否合法, 合法返回200, 不合法返回500
*
* directory 不能以/开头,不能以/结尾。不能以.开头,不能以.结尾。
* 把directory的/替换掉后,只能包含数字、字母、下划线、中划线
* @param directory 目录
* @returns
*/
export const validateDirectory = (directory?: string) => {
// 对directory进行校验不能以/开头,不能以/结尾。不能以.开头,不能以.结尾。
if (directory && (directory.startsWith('/') || directory.endsWith('/') || directory.startsWith('..') || directory.endsWith('..'))) {
return {
code: 500,
message: 'directory is invalid',
};
}
// 把directory的/替换掉后,只能包含数字、字母、下划线、中划线
// 可以包含.
let _directory = directory?.replace(/\//g, '');
if (_directory && !/^[a-zA-Z0-9_.-]+$/.test(_directory)) {
return {
code: 500,
message: 'directory is invalid, only number, letter, underline and hyphen are allowed',
};
}
return {
code: 200,
message: 'directory is valid',
};
};

View File

@@ -1,14 +1,13 @@
import { router } from '@/app.ts';
import http from 'http';
import { useContextKey } from '@kevisual/context';
import { checkAuth, error } from './middleware/auth.ts';
export { router, checkAuth, error };
export { router, };
/**
* 事件客户端
*/
const eventClientsInit = () => {
const clients = new Map<string, { client?: http.ServerResponse; createTime?: number; [key: string]: any }>();
const clients = new Map<string, { client?: http.ServerResponse; createTime?: number;[key: string]: any }>();
return clients;
};
export const clients = useContextKey('event-clients', () => eventClientsInit());

View File

@@ -43,7 +43,7 @@ app
console.log('get app manager called');
const tokenUser = ctx.state.tokenUser;
const id = ctx.query.id;
const { key, version } = ctx.query?.data || {};
const { key, version, create = false } = ctx.query?.data || {};
if (!id && (!key || !version)) {
throw new CustomError('id is required');
}
@@ -59,8 +59,27 @@ app
},
});
}
if (!am && create) {
am = await AppListModel.create({
key,
version,
uid: tokenUser.id,
data: {},
});
const res = await app.run({ path: 'app', key: "detectVersionList", payload: { data: { appKey: key, version, username: tokenUser.username }, token: ctx.query.token } });
if (res.code !== 200) {
ctx.throw(res.message || 'detect version list error');
}
am = await AppListModel.findOne({
where: {
key,
version,
uid: tokenUser.id,
},
});
}
if (!am) {
throw new CustomError('app not found');
ctx.throw('app not found');
}
console.log('get app', am.id, am.key, am.version);
ctx.body = prefixFix(am, tokenUser.username);