From 0b5a0557eedebc4ef806293baf70e6ae7851a52e Mon Sep 17 00:00:00 2001 From: abearxiong Date: Sat, 17 Jan 2026 14:48:49 +0800 Subject: [PATCH] update --- assistant/.gitignore | 4 +- assistant/readme.md | 13 +- assistant/src/module/get-header-token.ts | 22 ++ assistant/src/module/upload/mv.ts | 32 +++ assistant/src/routes-simple/events.ts | 32 +++ assistant/src/routes-simple/index.ts | 217 +----------------- assistant/src/routes-simple/router.ts | 87 +++++++ assistant/src/routes-simple/upload.ts | 179 +++++++++++++++ assistant/src/routes-simple/utils.ts | 30 +++ assistant/src/routes/index.ts | 41 +++- assistant/src/server.ts | 1 + .../src/services/proxy/proxy-page-index.ts | 6 +- assistant/src/test/upload-file.ts | 178 ++++++++++++++ package.json | 4 +- 14 files changed, 613 insertions(+), 233 deletions(-) create mode 100644 assistant/src/module/get-header-token.ts create mode 100644 assistant/src/module/upload/mv.ts create mode 100644 assistant/src/routes-simple/events.ts create mode 100644 assistant/src/routes-simple/router.ts create mode 100644 assistant/src/routes-simple/upload.ts create mode 100644 assistant/src/routes-simple/utils.ts create mode 100644 assistant/src/test/upload-file.ts diff --git a/assistant/.gitignore b/assistant/.gitignore index 99d1b66..79e1af0 100644 --- a/assistant/.gitignore +++ b/assistant/.gitignore @@ -10,4 +10,6 @@ assistant-app .env* !.env*example -libs \ No newline at end of file +libs + +cache-file \ No newline at end of file diff --git a/assistant/readme.md b/assistant/readme.md index fb15f68..853c3dc 100644 --- a/assistant/readme.md +++ b/assistant/readme.md @@ -1,7 +1,14 @@ # assistant cli -## 初始化路径 +## 环境变量配置项 -## 启动服务 +- ASSISTANT_CONFIG_DIR + - 说明:指定 assistant 的配置文件目录 + - 示例:`export ASSISTANT_CONFIG_DIR=/path/to/your/config/dir` -## 配置 + +## 启动命令 + +```bash +asst server -s -p 8686 +``` \ No newline at end of file diff --git a/assistant/src/module/get-header-token.ts b/assistant/src/module/get-header-token.ts new file mode 100644 index 0000000..ec0b681 --- /dev/null +++ b/assistant/src/module/get-header-token.ts @@ -0,0 +1,22 @@ +import http from 'http'; +import * as cookie from '@kevisual/router/src/server/cookie.ts'; + +export const getTokenFromRequest = (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 = cookie.parse(req.headers.cookie || ''); + token = parsedCookies.token || ''; + } + if (token) { + token = token.replace('Bearer ', ''); + } + return token; +} + +export const getTokenFromContext = (ctx: any) => { + return ctx.query.token; +} \ No newline at end of file diff --git a/assistant/src/module/upload/mv.ts b/assistant/src/module/upload/mv.ts new file mode 100644 index 0000000..f4a3c5e --- /dev/null +++ b/assistant/src/module/upload/mv.ts @@ -0,0 +1,32 @@ +import type { AssistantConfig } from '@/module/assistant/index.ts'; +import fs from 'node:fs'; +import path from 'node:path'; +export class UploadManager { + config: AssistantConfig; + constructor(config: AssistantConfig) { + this.config = config; + } + mvFile(opts: { + temppath: string; + type: 'file' | 's3'; + targetPath: string; + }) { + const { type, temppath, targetPath } = opts; + if (type === 'file') { + const pageDir = this.config.configPath?.pagesDir!; + const fullTargetPath = targetPath.startsWith('/') + ? targetPath + : path.join(pageDir, targetPath); + const targetDir = fullTargetPath.substring(0, fullTargetPath.lastIndexOf('/')); + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }); + } + fs.renameSync(temppath, fullTargetPath); + return fullTargetPath; + } + } +} + +export const uploadManager = (config: AssistantConfig) => { + return new UploadManager(config); +} diff --git a/assistant/src/routes-simple/events.ts b/assistant/src/routes-simple/events.ts new file mode 100644 index 0000000..bed1a50 --- /dev/null +++ b/assistant/src/routes-simple/events.ts @@ -0,0 +1,32 @@ +import { simpleRouter, clients, getTaskId, writeEvents, deleteOldClients, error } from './router.ts'; + +simpleRouter.get('/client/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' }) + // 移除客户端连接 + req.on('close', () => { + clients.delete(taskId); + }); +}); + +simpleRouter.get('/client/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'); +}); diff --git a/assistant/src/routes-simple/index.ts b/assistant/src/routes-simple/index.ts index 05ade16..40c23e7 100644 --- a/assistant/src/routes-simple/index.ts +++ b/assistant/src/routes-simple/index.ts @@ -1,216 +1 @@ - -// import Busboy from 'busboy'; -// import { simpleRouter } from '../app.ts' -// import http from 'http'; -// import path from 'path'; -// import { createWriteStream } from 'fs'; - -// import { checkAuth } from '@/routes/index.ts'; -// import { pipeBusboy } from '@/module/assistant/proxy/pipe.ts'; -// simpleRouter.get('/client/upload', async (req, res) => { -// if (res.headersSent) return; // 如果响应已发送,不再处理 -// res.writeHead(200, { 'Content-Type': 'application/json' }); -// res.end(JSON.stringify({ message: 'Upload endpoint reached' })); -// }) -// export const error = (msg: string, code = 500) => { -// return JSON.stringify({ code, message: msg }); -// }; -// export const parseIfJson = (data = '{}') => { -// try { -// const _data = JSON.parse(data); -// if (typeof _data === 'object') return _data; -// return {}; -// } catch (error) { -// return {}; -// } -// }; -// const uploadResources = async (req: http.IncomingMessage, res: http.ServerResponse) => { -// 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 }); -// 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; -// }); - -// busboy.on('file', (fieldname, fileStream, info) => { -// const { filename, encoding, mimeType } = info; -// const tempPath = path.join(cacheFilePath, `${Date.now()}-${Math.random().toString(36).substring(7)}`); -// 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); -// }); - -// 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 minioClient.fPutObject(bucketName, 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); -// } \ No newline at end of file +export * from './upload.ts' \ No newline at end of file diff --git a/assistant/src/routes-simple/router.ts b/assistant/src/routes-simple/router.ts new file mode 100644 index 0000000..e9aaf7b --- /dev/null +++ b/assistant/src/routes-simple/router.ts @@ -0,0 +1,87 @@ +import { simpleRouter } from '@/app.ts'; +import http from 'http'; +import { useContextKey } from '@kevisual/context'; +import { useFileStore } from '@kevisual/use-config'; +export { simpleRouter }; + +export const cacheFilePath = useFileStore('cache-file', { needExists: true }); + +/** + * 事件客户端 + */ +const eventClientsInit = () => { + const clients = new Map(); + return clients; +}; +export const clients = useContextKey('event-clients', () => eventClientsInit()); +/** + * 获取 task-id + * @param req + * @returns + */ +export const getTaskId = (req: http.IncomingMessage) => { + const url = new URL(req.url || '', 'http://localhost'); + const taskId = url.searchParams.get('taskId'); + if (taskId) { + return taskId; + } + return req.headers['task-id'] as string; +}; +type EventData = { + progress: number | string; + message: string; +}; + +/** + * 写入事件 + * @param req + * @param data + */ +export const writeEvents = (req: http.IncomingMessage, data: EventData) => { + const taskId = getTaskId(req); + if (taskId) { + const client = clients.get(taskId)?.client; + if (client) { + client.write(`data: ${JSON.stringify(data)}\n\n`); + } + if (Number(data.progress) === 100) { + clients.delete(taskId); + } + } else { + console.log('taskId is remove.', taskId); + } +}; +/** + * 查找超出2个小时的clients,都删除了 + */ +export const deleteOldClients = () => { + const now = Date.now(); + for (const [taskId, client] of clients) { + // 如果创建时间超过2个小时,则删除 + if (now - client.createTime > 1000 * 60 * 60 * 2) { + clients.delete(taskId); + } + } +}; +/** + * 解析表单数据, 如果表单数据是数组, 则取第一个,appKey, version, username 等 + * @param fields 表单数据 + * @param parseKeys 需要解析的键 + * @returns 解析后的数据 + */ +export const getKey = (fields: Record, parseKeys: string[]) => { + let value: Record = {}; + for (const key of parseKeys) { + const v = fields[key]; + if (Array.isArray(v)) { + value[key] = v[0]; + } else { + value[key] = v; + } + } + return value; +}; + +export const error = (msg: string, code = 500) => { + return JSON.stringify({ code, message: msg }); +}; \ No newline at end of file diff --git a/assistant/src/routes-simple/upload.ts b/assistant/src/routes-simple/upload.ts new file mode 100644 index 0000000..618f38d --- /dev/null +++ b/assistant/src/routes-simple/upload.ts @@ -0,0 +1,179 @@ + +import Busboy from 'busboy'; +import { assistantConfig, simpleRouter } from '../app.ts' +import http from 'http'; +import path from 'path'; +import fs from 'fs'; + +import { checkAuth } from '@/routes/index.ts'; +import { getTokenFromRequest } from '@/module/get-header-token.ts'; +import { pipeBusboy } from '@/module/assistant/proxy/pipe.ts'; +import { logger } from '@/module/logger.ts'; +import { cacheFilePath, getKey, writeEvents } from './router.ts'; +import { getContentType } from '@kevisual/oss/services'; +import { validateDirectory } from './utils.ts'; +import { UploadManager } from '@/module/upload/mv.ts'; +simpleRouter.get('/client/upload', async (req, res) => { + if (res.headersSent) return; // 如果响应已发送,不再处理 + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ message: 'Upload endpoint reached' })); +}) +export const error = (msg: string, code = 500) => { + return JSON.stringify({ code, message: msg }); +}; +export const parseIfJson = (data = '{}') => { + try { + const _data = JSON.parse(data); + if (typeof _data === 'object') return _data; + return {}; + } catch (error) { + return {}; + } +}; +const uploadManager = new UploadManager(assistantConfig) +export const uploadResources = async (req: http.IncomingMessage, res: http.ServerResponse) => { + // const { tokenUser, token } = await checkAuth(req, res); + const token = getTokenFromRequest(req); + let tokenUser: any = null; + const authResult = await checkAuth({ query: { token } }); + if (authResult.code === 200) { + tokenUser = authResult.data?.tokenUser; + } else { + res.end(error(authResult.message, authResult.code)); + return; + } + 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')); + // 使用 busboy 解析 multipart/form-data + const busboy = Busboy({ headers: req.headers, preservePath: true }); + 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; + }); + + busboy.on('file', (fieldname, fileStream, info) => { + const { filename, encoding, mimeType } = info; + const tempPath = path.join(cacheFilePath, `${Date.now()}-${Math.random().toString(36).substring(7)}`); + const writeStream = fs.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', JSON.stringify(data, null, 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) { + 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; + } + console.log('fields', fields); + let { appKey, version, username, directory } = getKey(fields, ['appKey', 'version', 'username', 'directory']); + 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; + + 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 showVersion = false; + const _version = showVersion ? `${version ? '/' + version : ''}` : ''; + const _directory = directory ? `/${directory}` : ''; + const minioPath = `${username || tokenUser.username || 'unknown'}/${appKey}${_version}${_directory}/${relativePath}`; + uploadResults.push({ + name: relativePath, + path: minioPath, + }); + const type = 'file'; + uploadManager.mvFile({ + type: 'file', + temppath: tempPath, + targetPath: minioPath, + }) + if (type !== 'file') { + fs.unlinkSync(tempPath); // 删除临时文件 + } + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + code: 200, + data: { + detect: [], + upload: uploadResults, + }, + }), + ); + }); + + pipeBusboy(req, res, busboy); +} + +simpleRouter.post('/client/upload', uploadResources); diff --git a/assistant/src/routes-simple/utils.ts b/assistant/src/routes-simple/utils.ts new file mode 100644 index 0000000..9de03d2 --- /dev/null +++ b/assistant/src/routes-simple/utils.ts @@ -0,0 +1,30 @@ +/** + * 校验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', + }; +}; diff --git a/assistant/src/routes/index.ts b/assistant/src/routes/index.ts index 5dc70ce..0199182 100644 --- a/assistant/src/routes/index.ts +++ b/assistant/src/routes/index.ts @@ -40,24 +40,32 @@ export const checkAuth = async (ctx: any, isAdmin = false) => { const token = ctx.query.token; console.log('checkAuth', ctx.query, { token }); if (!token) { - return ctx.throw(401, 'not login'); + return { + code: 401, + message: '未登录', + } } // 鉴权代理 let tokenUser = await authCache.get(token); if (!tokenUser) { const tokenUserRes = await getTokenUser(token); if (tokenUserRes.code !== 200) { - return ctx.throw(tokenUserRes.code, 'not login'); + return { + code: tokenUserRes.code, + message: '验证失败' + tokenUserRes.message, + } } else { tokenUser = tokenUserRes.data; } authCache.set(token, tokenUser); } - ctx.state = { - ...ctx.state, - token, - tokenUser, - }; + if (ctx.state) { + ctx.state = { + ...ctx.state, + token, + tokenUser, + }; + } const { username } = tokenUser; if (!auth.username) { // 初始管理员账号 @@ -75,9 +83,16 @@ export const checkAuth = async (ctx: any, isAdmin = false) => { isCheckAdmin = true; } if (!isCheckAdmin) { - return ctx.throw(403, 'not admin user'); + return { + code: 403, + message: '非管理员用户', + } } } + return { + code: 200, + data: { tokenUser, token } + } }; app .route({ @@ -86,7 +101,10 @@ app description: '获取当前登录用户信息, 第一个登录的用户为管理员用户', }) .define(async (ctx) => { - await checkAuth(ctx); + const authResult = await checkAuth(ctx); + if (authResult.code !== 200) { + ctx.throw(authResult.code, authResult.message); + } }) .addTo(app); app @@ -97,7 +115,10 @@ app }) .define(async (ctx) => { console.log('query', ctx.query); - await checkAuth(ctx, true); + const authResult = await checkAuth(ctx, true); + if (authResult.code !== 200) { + ctx.throw(authResult.code, authResult.message); + } }) .addTo(app); diff --git a/assistant/src/server.ts b/assistant/src/server.ts index e4153b2..1aed65f 100644 --- a/assistant/src/server.ts +++ b/assistant/src/server.ts @@ -1,6 +1,7 @@ import { app, assistantConfig } from './app.ts'; import { proxyRoute, proxyWs } from './services/proxy/proxy-page-index.ts'; import './routes/index.ts'; +import './routes-simple/index.ts'; import getPort, { portNumbers } from 'get-port'; import { program } from 'commander'; diff --git a/assistant/src/services/proxy/proxy-page-index.ts b/assistant/src/services/proxy/proxy-page-index.ts index 672c2d1..d760da8 100644 --- a/assistant/src/services/proxy/proxy-page-index.ts +++ b/assistant/src/services/proxy/proxy-page-index.ts @@ -1,7 +1,7 @@ import { fileProxy, httpProxy, createApiProxy, ProxyInfo, proxy } from '@/module/assistant/index.ts'; import http from 'node:http'; import { LocalProxy } from './local-proxy.ts'; -import { assistantConfig, app } from '@/app.ts'; +import { assistantConfig, app, simpleRouter } from '@/app.ts'; import { log, logger } from '@/module/logger.ts'; import { getToken } from '@/module/http-token.ts'; import { getTokenUserCache } from '@/routes/index.ts'; @@ -103,6 +103,10 @@ export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResp res.end('Not Found Favicon'); return; } + if (pathname.startsWith('/client/upload')) { + simpleRouter.parse(req, res); + return; + } if (pathname.startsWith('/client')) { logger.debug('handle by router', { url: req.url }); return; diff --git a/assistant/src/test/upload-file.ts b/assistant/src/test/upload-file.ts new file mode 100644 index 0000000..987e6f4 --- /dev/null +++ b/assistant/src/test/upload-file.ts @@ -0,0 +1,178 @@ +// 使用 fetch 和 FormData 上传文件的示例代码 + +// 示例 1: 使用 File 对象上传 +async function uploadFileUsingFileObject(file: File) { + const formData = new FormData(); + formData.append('file', file); + // 可以添加其他字段 + formData.append('filename', file.name); + formData.append('description', '文件描述'); + formData.append('appKey', 'test'); + formData.append('version', '1.0.0'); + + try { + const response = await fetch('http://localhost:51516/client/router', { + method: 'POST', + body: formData, + // 注意:使用 FormData 时不需要手动设置 Content-Type, + // 浏览器会自动设置正确的 multipart/form-data 边界 + }); + + if (!response.ok) { + throw new Error(`上传失败: ${response.status} ${response.statusText}`); + } + + const result = await response.json(); + console.log('上传成功:', result); + return result; + } catch (error) { + console.error('上传出错:', error); + throw error; + } +} + +// 示例 2: 使用 FileList(如 input[type="file"])上传 +async function uploadFilesUsingFileList(files: FileList) { + const formData = new FormData(); + + // 多个文件使用相同的字段名 + for (let i = 0; i < files.length; i++) { + formData.append('files', files[i]); + } + + try { + const response = await fetch('http://localhost:51516/client/router', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error(`上传失败: ${response.status} ${response.statusText}`); + } + + const result = await response.json(); + console.log('上传成功:', result); + return result; + } catch (error) { + console.error('上传出错:', error); + throw error; + } +} + +// 示例 3: 从路径读取文件上传(Node.js 环境,使用 fs 和 Blob) +import fs from 'fs'; + +async function uploadFileFromPath(fileList: string[]) { + + const formData = new FormData(); + for (let m of fileList) { + const buffer = fs.readFileSync(m); + const file = new File([buffer], m.split('/').pop()!, { + type: 'application/octet-stream', + }); + formData.append('file', file); + } + formData.append('appKey', 'test'); + formData.append('version', '1.0.0'); + + let token = 'st_n9ycynd4m7wdyw3lejb8plnkyi62uejd'; // 如果需要身份验证,添加令牌 + try { + const response = await fetch('http://localhost:51516/client/upload' + `?token=${token}`, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error(`上传失败: ${response.status} ${response.statusText}`); + } + + const result = await response.json(); + console.log('上传成功:', JSON.stringify(result, null, 2)); + return result; + } catch (error) { + console.error('上传出错:', error); + throw error; + } +} + +uploadFileFromPath(['./src/test/remote-app.ts', './src/test/upload-file.ts']); +// 示例 4: 完整的 HTML 使用示例(浏览器环境) +/* +// HTML +// +// + +async function handleUpload() { + const fileInput = document.getElementById('fileInput') as HTMLInputElement; + const files = fileInput.files; + + if (!files || files.length === 0) { + alert('请选择文件'); + return; + } + + const formData = new FormData(); + + for (let i = 0; i < files.length; i++) { + formData.append('files', files[i]); + } + + // 添加额外数据 + formData.append('userId', '12345'); + formData.append('timestamp', Date.now().toString()); + + try { + const response = await fetch('http://localhost:51516/client/router', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error(`上传失败: ${response.status}`); + } + + const result = await response.json(); + alert('上传成功: ' + JSON.stringify(result)); + } catch (error) { + alert('上传出错: ' + error); + } +} +*/ + +// 示例 5: 带进度监控的上传(浏览器环境) +/* +async function uploadWithProgress(file: File, onProgress: (percent: number) => void) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + const formData = new FormData(); + formData.append('file', file); + + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + const percent = (e.loaded / e.total) * 100; + onProgress(percent); + } + }); + + xhr.addEventListener('load', () => { + if (xhr.status === 200) { + resolve(JSON.parse(xhr.responseText)); + } else { + reject(new Error(`上传失败: ${xhr.status}`)); + } + }); + + xhr.addEventListener('error', () => reject(new Error('网络错误'))); + xhr.addEventListener('abort', () => reject(new Error('上传被取消'))); + + xhr.open('POST', 'http://localhost:51516/client/router'); + xhr.send(formData); + }); +} + +// 使用 +// const file = fileInput.files[0]; +// uploadWithProgress(file, (percent) => { +// console.log(`上传进度: ${percent.toFixed(1)}%`); +// }).then(result => console.log('完成', result)); +*/ diff --git a/package.json b/package.json index e2820d6..fb446f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kevisual/cli", - "version": "0.0.82", + "version": "0.0.83", "description": "envision 命令行工具", "type": "module", "basename": "/root/cli", @@ -86,4 +86,4 @@ "publishConfig": { "access": "public" } -} \ No newline at end of file +}