From 91fdd6abc333ab07f9bbd97cb027a4cbaf6c8655 Mon Sep 17 00:00:00 2001 From: xiongxiao Date: Thu, 18 Dec 2025 20:56:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E5=8A=A9=E6=89=8B?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=EF=BC=8C=E6=B7=BB=E5=8A=A0=E5=BA=94=E7=94=A8?= =?UTF-8?q?ID=E5=92=8CURL=EF=BC=8C=E4=BC=98=E5=8C=96=E8=BA=AB=E4=BB=BD?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E5=92=8C=E4=BB=A3=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/module/assistant/config/index.ts | 81 ++++++++++++++++--- assistant/src/module/assistant/proxy/proxy.ts | 6 +- assistant/src/module/http-token.ts | 42 ++++++++++ assistant/src/module/local-proxy/index.ts | 18 ++++- assistant/src/module/reload-server.ts | 3 + assistant/src/routes/config/index.ts | 11 ++- assistant/src/routes/index.ts | 29 +++++-- assistant/src/routes/user/index.ts | 4 +- assistant/src/services/init/index.ts | 28 ++++--- .../src/services/proxy/proxy-page-index.ts | 81 +++++++++++++++++-- 10 files changed, 259 insertions(+), 44 deletions(-) create mode 100644 assistant/src/module/http-token.ts diff --git a/assistant/src/module/assistant/config/index.ts b/assistant/src/module/assistant/config/index.ts index 2750626..2bcd813 100644 --- a/assistant/src/module/assistant/config/index.ts +++ b/assistant/src/module/assistant/config/index.ts @@ -68,46 +68,92 @@ export const initConfig = (configRootPath: string) => { export type ReturnInitConfigType = ReturnType; type AuthPermission = { - type?: 'auth-proxy' | 'public' | 'private' | 'project'; + share?: 'public' | 'private' | 'protected'; username?: string; // 用户名 admin?: string[]; }; export type AssistantConfigData = { - pageApi?: string; // https://kevisual.cn + app?: { + /** + * 应用ID, 唯一标识,识别是那个设备 + */ + id?: string; + /** + * 应用地址 + */ + url?: string; + } token?: string; registry?: string; // https://kevisual.cn + /** + * 前端代理,比如/root/home 转到https://kevisual.cn/root/home + * path?: string; + * target?: string; + * pathname?: string; + * 例子: { path: '/root/home', target: 'https://kevisual.cn', pathname: '/root/home' } + */ proxy?: ProxyInfo[]; - apiProxyList?: ProxyInfo[]; + /** + * API 代理配置, 比如,api开头的,v1开头的等等 + */ + api?: { + proxy?: ProxyInfo[]; + } description?: string; /** - * 服务启动 + * 服务启动, + * path是配置 127.0.0.1 + * port是配置端口号 */ server?: { path?: string; port?: number; }; + /** + * 被远程调用配置 + * url: 远程应用地址 https://kevisual.cn/ws/proxy + * enabled: 是否启用远程应用 + */ share?: { url: string; - enabled?: boolean; // 是否启用远程应用 - name: string; + enabled?: boolean; }; + /** + * 对pages目录文件监听 + */ watch?: { enabled?: boolean; }; /** - * 首页 + * 首页, 访问 `/` 自动会打开的首页地址 + * 例如: /root/home */ home?: string; + /** + * 启用本地AI代理 + */ ai?: { enabled?: boolean; - provider?: string | 'DeepSeek' | 'SiliconFlow'; + provider?: string | 'DeepSeek' | 'Custom'; apiKey?: string; model?: string; }; + /** + * 自定义脚本, asst 启动时会执行这些脚本 + */ scripts?: { [key: string]: string; }; + /** + * 认证和权限配置 + * share: protected 需要认证代理访问(默认), public 公开访问, private 私有访问 + * share 是对外共享 pages 目录下的页面 + */ auth?: AuthPermission; + /** + * HTTPS 证书配置, 启用后,助手服务会启用 HTTPS 服务, 默认 HTTP + * 理论上也不需要https,因为可以通过反向代理实现https + */ https?: { type?: 'https' | 'http'; keyPath?: string; // 证书私钥路径 @@ -149,12 +195,14 @@ export class AssistantConfig { } } getConfigPath() { } - getConfig() { + getConfig(): AssistantConfigData { try { if (!checkFileExists(this.configPath.configPath)) { fs.writeFileSync(this.configPath.configPath, JSON.stringify({ proxy: [] }, null, 2)); return { - pageApi: '', + app: { + url: 'https://kevisual.cn', + }, proxy: [], }; } @@ -163,7 +211,9 @@ export class AssistantConfig { } catch (error) { console.error('file read', error.message); return { - pageApi: '', + app: { + url: 'https://kevisual.cn', + }, proxy: [], }; } @@ -176,14 +226,19 @@ export class AssistantConfig { } getRegistry() { const config = this.getCacheAssistantConfig(); - return config?.registry || config?.pageApi; + return config?.registry || config?.app?.url || 'https://kevisual.cn'; } /** * 设置 assistant-config.json 配置 * @param config * @returns */ - setConfig(config?: AssistantConfigData) { + setConfig(config?: AssistantConfigData, force?: boolean) { + if (force) { + this.config = config || {}; + fs.writeFileSync(this.configPath.configPath, JSON.stringify(this.config, null, 2)); + return this.config; + } const myConfig = this.getCacheAssistantConfig(); const newConfig = { ...myConfig, ...config }; this.config = newConfig; diff --git a/assistant/src/module/assistant/proxy/proxy.ts b/assistant/src/module/assistant/proxy/proxy.ts index 162a534..80bbd45 100644 --- a/assistant/src/module/assistant/proxy/proxy.ts +++ b/assistant/src/module/assistant/proxy/proxy.ts @@ -4,7 +4,7 @@ export type ProxyInfo = { */ path?: string; /** - * 目标地址 + * 目标url地址,比如http://localhost:3000 */ target?: string; /** @@ -23,10 +23,12 @@ export type ProxyInfo = { */ ws?: boolean; /** - * 首要文件,比如index.html, type为fileProxy代理有用 设置了首要文件,如果文件不存在,则访问首要文件 + * type为file时有效 + * 索引文件,比如index.html, type为fileProxy代理有用 设置了索引文件,如果文件不存在,则访问索引文件 */ indexPath?: string; /** + * type为file时有效 * 根路径, 默认是process.cwd(), type为fileProxy代理有用,必须为绝对路径 */ rootPath?: string; diff --git a/assistant/src/module/http-token.ts b/assistant/src/module/http-token.ts new file mode 100644 index 0000000..27ddd31 --- /dev/null +++ b/assistant/src/module/http-token.ts @@ -0,0 +1,42 @@ +import http from 'http'; +export const error = (msg: string, code = 500) => { + return JSON.stringify({ code, message: msg }); +}; +const cookie = { + parse: (cookieStr: string) => { + const cookies: Record = {}; + const cookiePairs = cookieStr.split(';'); + for (const pair of cookiePairs) { + const [key, value] = pair.split('=').map((v) => v.trim()); + if (key && value) { + cookies[key] = decodeURIComponent(value); + } + } + return cookies; + } +} +export const getToken = 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 = cookie.parse(req.headers.cookie || ''); + token = parsedCookies.token || ''; + } + if (!token) { + return resNoPermission(); + } + if (token) { + token = token.replace('Bearer ', ''); + } + + return { token }; +}; + diff --git a/assistant/src/module/local-proxy/index.ts b/assistant/src/module/local-proxy/index.ts index 0e8b2f9..a233fc2 100644 --- a/assistant/src/module/local-proxy/index.ts +++ b/assistant/src/module/local-proxy/index.ts @@ -44,7 +44,7 @@ export class LocalProxy { initFromAssistantConfig(assistantConfig?: AssistantConfig) { if (!assistantConfig) return; this.pagesDir = assistantConfig.configPath?.pagesDir || ''; - this.watch = !!assistantConfig.getCacheAssistantConfig()?.watch.enabled; + this.watch = assistantConfig.getCacheAssistantConfig?.()?.watch?.enabled ?? true; this.init(); if (this.watch) { this.onWatch(); @@ -112,14 +112,26 @@ export class LocalProxy { }; fs.watch(frontAppDir, { recursive: true }, (eventType, filename) => { if (eventType === 'rename' || eventType === 'change') { + // 过滤 node_modules 目录 + if (filename && filename.includes('node_modules')) { + return; + } + // 只监听 js、html、css 文件 + const validExtensions = ['.js', '.html', '.css', '.json', '.png']; + const hasValidExtension = validExtensions.some(ext => filename && filename.endsWith(ext)); + + if (!hasValidExtension) { + return; + } + const filePath = path.join(frontAppDir, filename); try { const stat = fs.statSync(filePath); - if (stat.isDirectory() || filename.endsWith('.html')) { + if (stat.isFile() || stat.isDirectory()) { // 重新加载 debounce(that.init.bind(that), 5 * 1000); } - } catch (error) {} + } catch (error) { } } }); } diff --git a/assistant/src/module/reload-server.ts b/assistant/src/module/reload-server.ts index b8c25fd..f3d1ea5 100644 --- a/assistant/src/module/reload-server.ts +++ b/assistant/src/module/reload-server.ts @@ -2,6 +2,9 @@ import pm2 from 'pm2'; import { logger } from './logger.ts'; export async function reload() { + if (process.env.PM2_HOME === undefined) { + return; + } return new Promise((resolve, reject) => { pm2.connect((err) => { if (err) { diff --git a/assistant/src/routes/config/index.ts b/assistant/src/routes/config/index.ts index c4d478b..017a5d3 100644 --- a/assistant/src/routes/config/index.ts +++ b/assistant/src/routes/config/index.ts @@ -21,7 +21,16 @@ app }) .define(async (ctx) => { const { data } = ctx.query; - ctx.body = assistantConfig.setConfig(data); + ctx.body = assistantConfig.setConfig(data, true); reload(); }) .addTo(app); + +app.route({ + path: 'config', + key: 'getId' +}).define(async (ctx) => { + const config = assistantConfig.getCacheAssistantConfig(); + ctx.body = config?.app?.id || null; + +}).addTo(app); \ No newline at end of file diff --git a/assistant/src/routes/index.ts b/assistant/src/routes/index.ts index b321698..0df6125 100644 --- a/assistant/src/routes/index.ts +++ b/assistant/src/routes/index.ts @@ -9,18 +9,28 @@ import './hot-api/key-sender/index.ts'; import os from 'node:os'; import { authCache } from '@/module/cache/auth.ts'; -export const getTokenUser = async (ctx: any) => { +const getTokenUser = async (token: string) => { const query = assistantConfig.query const res = await query.post({ path: 'user', key: 'me', - token: ctx.state.token || ctx.query.token, + token: token, }); - if (res.code !== 200) { - return ctx.throw(401, 'not login'); + return res; +} +export const getTokenUserCache = async (token: string) => { + const tokenUser = await authCache.get(token); + if (tokenUser) { + return { + code: 200, + data: tokenUser, + }; } - const tokenUser = res.data || {}; - return tokenUser; + const res = await getTokenUser(token); + if (res.code === 200) { + authCache.set(token, res.data); + } + return res; } const checkAuth = async (ctx: any, isAdmin = false) => { const config = assistantConfig.getConfig(); @@ -33,7 +43,12 @@ const checkAuth = async (ctx: any, isAdmin = false) => { // 鉴权代理 let tokenUser = await authCache.get(token); if (!tokenUser) { - tokenUser = await getTokenUser(ctx); + const tokenUserRes = await getTokenUser(token); + if (tokenUserRes.code !== 200) { + return ctx.throw(tokenUserRes.code, 'not login'); + } else { + tokenUser = tokenUserRes.data; + } authCache.set(token, tokenUser); } ctx.state = { diff --git a/assistant/src/routes/user/index.ts b/assistant/src/routes/user/index.ts index 150ac1f..6b3f344 100644 --- a/assistant/src/routes/user/index.ts +++ b/assistant/src/routes/user/index.ts @@ -56,8 +56,8 @@ app.route({ if (!auth.username) { // 初始管理员账号 auth.username = loginUser; - if (!auth.type) { - auth.type = 'public'; + if (!auth.share) { + auth.share = 'protected'; } assistantConfig.setConfig({ auth }); console.log('set first admin user', { username: loginUser }); diff --git a/assistant/src/services/init/index.ts b/assistant/src/services/init/index.ts index 71e4bf8..23a6f9c 100644 --- a/assistant/src/services/init/index.ts +++ b/assistant/src/services/init/index.ts @@ -51,11 +51,11 @@ export class AssistantInit extends AssistantConfig { return this.#query; } get baseURL() { - return `${this.getConfig()?.pageApi || 'https://kevisual.cn'}/api/router`; + return `${this.getConfig()?.app?.url || 'https://kevisual.cn'}/api/router`; } setQuery(query?: Query) { this.#query = query || new Query({ - url: `${this.getConfig()?.pageApi || 'https://kevisual.cn'}/api/router`, + url: `${this.getConfig()?.app?.url || 'https://kevisual.cn'}/api/router`, }); } checkConfigPath() { @@ -92,6 +92,16 @@ export class AssistantInit extends AssistantConfig { if (!checkFileExists(assistantPath, true)) { this.setConfig(this.getDefaultInitAssistantConfig()); console.log(chalk.green('助手配置文件assistant-config.json创建成功')); + } else { + const config = this.getConfig(); + if (!config?.app?.id) { + if (!config.app) { + config.app = {}; + } + config.app.id = randomId(); + this.setConfig(config); + console.log(chalk.green('助手配置文件assistant-config.json更新成功')); + } } } initPnpm() { @@ -120,7 +130,7 @@ export class AssistantInit extends AssistantConfig { "type": "module", "scripts": { "start": "pm2 start apps/root/code-center/app.mjs --name root/code-center", - "proxy": "pm2 start apps/root/page-proxy/app.mjs --name root/page-proxy" + "cnb": "ASSISTANT_CONFIG_DIR=/workspace asst server -s -p 7878" }, "keywords": [], "author": "", @@ -194,20 +204,18 @@ export class AssistantInit extends AssistantConfig { protected getDefaultInitAssistantConfig() { const id = randomId(); return { - id, + app: { + url: 'https://kevisual.cn', + id, + }, description: '助手配置文件', - docs: "https://kevisual.cn/root/cli-docs/", + docs: "https://kevisual.cn/root/cli/docs/", home: '/root/home', proxy: [], - apiProxyList: [], share: { enabled: false, - name: 'abc', url: 'https://kevisual.cn/ws/proxy', }, - watch: { - enabled: true, - }, } as AssistantConfigData; } getHttps() { diff --git a/assistant/src/services/proxy/proxy-page-index.ts b/assistant/src/services/proxy/proxy-page-index.ts index e5992f2..287d417 100644 --- a/assistant/src/services/proxy/proxy-page-index.ts +++ b/assistant/src/services/proxy/proxy-page-index.ts @@ -3,14 +3,72 @@ import http from 'node:http'; import { LocalProxy } from './local-proxy.ts'; import { assistantConfig, app } from '@/app.ts'; import { log, logger } from '@/module/logger.ts'; +import { getToken } from '@/module/http-token.ts'; +import { getTokenUserCache } from '@/routes/index.ts'; const localProxy = new LocalProxy({}); localProxy.initFromAssistantConfig(assistantConfig); +/** + * 过滤访问的资源,允许谁访问 + * @param req + * @param res + * @returns + */ +const authFilter = async (req: http.IncomingMessage, res: http.ServerResponse) => { + const _assistantConfig = assistantConfig.getCacheAssistantConfig(); + const auth = _assistantConfig?.auth || {}; + const share = auth.share || 'protected'; + const noAdmin = !auth.username; + if (noAdmin) return false; + const admin = auth.username; + const admins = auth.admin || []; + if (admin) { + admins.push(admin); + } + const url = new URL(req.url, 'http://localhost'); + const pathname = decodeURIComponent(url.pathname); + // 放开 / + if (pathname === '/' || pathname === '/favicon.ico') { + return false; + } + // 放开首页 + if (pathname.startsWith('/root/home')) { + return false; + } + // 放开api, 以 /api, /v1, /client, /serve 开头的请求 + const openApiPaths = ['/api', '/v1', '/client', '/serve']; + for (const openPath of openApiPaths) { + if (pathname.startsWith(openPath)) { + return false; + } + } + if (share === 'public') { + return false; + } + const { token } = await getToken(req, res) + if (!token) { + return false; + } + const tokenUser = await getTokenUserCache(token); + if (share === 'protected' && tokenUser?.code === 200) { + return false; + } + if (share === 'private') { + if (tokenUser?.code === 200) { + const username = tokenUser?.data?.username; + if (admins.includes(username)) { + return false; + } + } + } + return true; +} export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResponse) => { const _assistantConfig = assistantConfig.getCacheAssistantConfig(); const home = _assistantConfig?.home || '/root/home'; const auth = _assistantConfig?.auth || {}; let noAdmin = !auth.username; + const toSetting = () => { res.writeHead(302, { Location: `/root/cli/setting/` }); res.end(); @@ -35,9 +93,9 @@ export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResp return; } // client, api, v1, serve 开头的拦截 - const apiProxyList = _assistantConfig?.apiProxyList || []; - const defaultApiProxy = createApiProxy(_assistantConfig?.pageApi || 'https://kevisual.cn'); - const apiBackendProxy = [...apiProxyList, ...defaultApiProxy].find((item) => pathname.startsWith(item.path)); + const apiProxy = _assistantConfig?.api?.proxy || []; + const defaultApiProxy = createApiProxy(_assistantConfig?.app?.url || 'https://kevisual.cn'); + const apiBackendProxy = [...apiProxy, ...defaultApiProxy].find((item) => pathname.startsWith(item.path)); if (apiBackendProxy) { log.debug('apiBackendProxy', { apiBackendProxy, url: req.url }); return httpProxy(req, res, { @@ -76,6 +134,17 @@ export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResp ...proxyApi, indexPath: _indexPath, // 首页路径 }); + } else if (proxyApi && proxyApi.type === 'http') { + log.debug('proxyApi http', { proxyApi, pathname }); + return httpProxy(req, res, { + path: proxyApi.path, + target: proxyApi.target, + type: 'http', + }); + } + const filter = await authFilter(req, res); + if (filter) { + return res.end('Not Authorized Proxy'); } const localProxyProxyList = localProxy.getLocalProxyList(); const localProxyProxy = localProxyProxyList.find((item) => pathname.startsWith(item.path)); @@ -87,7 +156,7 @@ export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResp indexPath: localProxyProxy.indexPath, }); } - const creatCenterProxy = createApiProxy(_assistantConfig?.pageApi || 'https://kevisual.cn', ['/root', '/' + _user]); + const creatCenterProxy = createApiProxy(_assistantConfig?.app?.url || 'https://kevisual.cn', ['/root', '/' + _user]); const centerProxy = creatCenterProxy.find((item) => pathname.startsWith(item.path)); if (centerProxy) { return httpProxy(req, res, { @@ -103,9 +172,9 @@ export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResp }; export const proxyWs = () => { - const apiProxyList = assistantConfig.getCacheAssistantConfig()?.apiProxyList || []; + const apiProxy = assistantConfig.getCacheAssistantConfig()?.api?.proxy || []; const proxy = assistantConfig.getCacheAssistantConfig()?.proxy || []; - const proxyApi = [...apiProxyList, ...proxy].filter((item) => item.ws); + const proxyApi = [...apiProxy, ...proxy].filter((item) => item.ws); log.debug('proxyApi ', proxyApi); wsProxy(app.server.server, { apiList: proxyApi,