feat: 更新助手配置,添加应用ID和URL,优化身份验证和代理逻辑

This commit is contained in:
2025-12-18 20:56:18 +08:00
parent c77578805a
commit 91fdd6abc3
10 changed files with 259 additions and 44 deletions

View File

@@ -68,46 +68,92 @@ export const initConfig = (configRootPath: string) => {
export type ReturnInitConfigType = ReturnType<typeof initConfig>; export type ReturnInitConfigType = ReturnType<typeof initConfig>;
type AuthPermission = { type AuthPermission = {
type?: 'auth-proxy' | 'public' | 'private' | 'project'; share?: 'public' | 'private' | 'protected';
username?: string; // 用户名 username?: string; // 用户名
admin?: string[]; admin?: string[];
}; };
export type AssistantConfigData = { export type AssistantConfigData = {
pageApi?: string; // https://kevisual.cn app?: {
/**
* 应用ID, 唯一标识,识别是那个设备
*/
id?: string;
/**
* 应用地址
*/
url?: string;
}
token?: string; token?: string;
registry?: string; // https://kevisual.cn 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[]; proxy?: ProxyInfo[];
apiProxyList?: ProxyInfo[]; /**
* API 代理配置, 比如api开头的v1开头的等等
*/
api?: {
proxy?: ProxyInfo[];
}
description?: string; description?: string;
/** /**
* 服务启动 * 服务启动
* path是配置 127.0.0.1
* port是配置端口号
*/ */
server?: { server?: {
path?: string; path?: string;
port?: number; port?: number;
}; };
/**
* 被远程调用配置
* url: 远程应用地址 https://kevisual.cn/ws/proxy
* enabled: 是否启用远程应用
*/
share?: { share?: {
url: string; url: string;
enabled?: boolean; // 是否启用远程应用 enabled?: boolean;
name: string;
}; };
/**
* 对pages目录文件监听
*/
watch?: { watch?: {
enabled?: boolean; enabled?: boolean;
}; };
/** /**
* 首页 * 首页, 访问 `/` 自动会打开的首页地址
* 例如: /root/home
*/ */
home?: string; home?: string;
/**
* 启用本地AI代理
*/
ai?: { ai?: {
enabled?: boolean; enabled?: boolean;
provider?: string | 'DeepSeek' | 'SiliconFlow'; provider?: string | 'DeepSeek' | 'Custom';
apiKey?: string; apiKey?: string;
model?: string; model?: string;
}; };
/**
* 自定义脚本, asst 启动时会执行这些脚本
*/
scripts?: { scripts?: {
[key: string]: string; [key: string]: string;
}; };
/**
* 认证和权限配置
* share: protected 需要认证代理访问(默认) public 公开访问, private 私有访问
* share 是对外共享 pages 目录下的页面
*/
auth?: AuthPermission; auth?: AuthPermission;
/**
* HTTPS 证书配置, 启用后,助手服务会启用 HTTPS 服务, 默认 HTTP
* 理论上也不需要https因为可以通过反向代理实现https
*/
https?: { https?: {
type?: 'https' | 'http'; type?: 'https' | 'http';
keyPath?: string; // 证书私钥路径 keyPath?: string; // 证书私钥路径
@@ -149,12 +195,14 @@ export class AssistantConfig {
} }
} }
getConfigPath() { } getConfigPath() { }
getConfig() { getConfig(): AssistantConfigData {
try { try {
if (!checkFileExists(this.configPath.configPath)) { if (!checkFileExists(this.configPath.configPath)) {
fs.writeFileSync(this.configPath.configPath, JSON.stringify({ proxy: [] }, null, 2)); fs.writeFileSync(this.configPath.configPath, JSON.stringify({ proxy: [] }, null, 2));
return { return {
pageApi: '', app: {
url: 'https://kevisual.cn',
},
proxy: [], proxy: [],
}; };
} }
@@ -163,7 +211,9 @@ export class AssistantConfig {
} catch (error) { } catch (error) {
console.error('file read', error.message); console.error('file read', error.message);
return { return {
pageApi: '', app: {
url: 'https://kevisual.cn',
},
proxy: [], proxy: [],
}; };
} }
@@ -176,14 +226,19 @@ export class AssistantConfig {
} }
getRegistry() { getRegistry() {
const config = this.getCacheAssistantConfig(); const config = this.getCacheAssistantConfig();
return config?.registry || config?.pageApi; return config?.registry || config?.app?.url || 'https://kevisual.cn';
} }
/** /**
* 设置 assistant-config.json 配置 * 设置 assistant-config.json 配置
* @param config * @param config
* @returns * @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 myConfig = this.getCacheAssistantConfig();
const newConfig = { ...myConfig, ...config }; const newConfig = { ...myConfig, ...config };
this.config = newConfig; this.config = newConfig;

View File

@@ -4,7 +4,7 @@ export type ProxyInfo = {
*/ */
path?: string; path?: string;
/** /**
* 目标地址 * 目标url地址比如http://localhost:3000
*/ */
target?: string; target?: string;
/** /**
@@ -23,10 +23,12 @@ export type ProxyInfo = {
*/ */
ws?: boolean; ws?: boolean;
/** /**
* 首要文件比如index.html type为fileProxy代理有用 设置了首要文件,如果文件不存在,则访问首要文件 * type为file时有效
* 索引文件比如index.html type为fileProxy代理有用 设置了索引文件,如果文件不存在,则访问索引文件
*/ */
indexPath?: string; indexPath?: string;
/** /**
* type为file时有效
* 根路径, 默认是process.cwd(), type为fileProxy代理有用必须为绝对路径 * 根路径, 默认是process.cwd(), type为fileProxy代理有用必须为绝对路径
*/ */
rootPath?: string; rootPath?: string;

View File

@@ -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<string, string> = {};
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 };
};

View File

@@ -44,7 +44,7 @@ export class LocalProxy {
initFromAssistantConfig(assistantConfig?: AssistantConfig) { initFromAssistantConfig(assistantConfig?: AssistantConfig) {
if (!assistantConfig) return; if (!assistantConfig) return;
this.pagesDir = assistantConfig.configPath?.pagesDir || ''; this.pagesDir = assistantConfig.configPath?.pagesDir || '';
this.watch = !!assistantConfig.getCacheAssistantConfig()?.watch.enabled; this.watch = assistantConfig.getCacheAssistantConfig?.()?.watch?.enabled ?? true;
this.init(); this.init();
if (this.watch) { if (this.watch) {
this.onWatch(); this.onWatch();
@@ -112,14 +112,26 @@ export class LocalProxy {
}; };
fs.watch(frontAppDir, { recursive: true }, (eventType, filename) => { fs.watch(frontAppDir, { recursive: true }, (eventType, filename) => {
if (eventType === 'rename' || eventType === 'change') { 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); const filePath = path.join(frontAppDir, filename);
try { try {
const stat = fs.statSync(filePath); const stat = fs.statSync(filePath);
if (stat.isDirectory() || filename.endsWith('.html')) { if (stat.isFile() || stat.isDirectory()) {
// 重新加载 // 重新加载
debounce(that.init.bind(that), 5 * 1000); debounce(that.init.bind(that), 5 * 1000);
} }
} catch (error) {} } catch (error) { }
} }
}); });
} }

View File

@@ -2,6 +2,9 @@ import pm2 from 'pm2';
import { logger } from './logger.ts'; import { logger } from './logger.ts';
export async function reload() { export async function reload() {
if (process.env.PM2_HOME === undefined) {
return;
}
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
pm2.connect((err) => { pm2.connect((err) => {
if (err) { if (err) {

View File

@@ -21,7 +21,16 @@ app
}) })
.define(async (ctx) => { .define(async (ctx) => {
const { data } = ctx.query; const { data } = ctx.query;
ctx.body = assistantConfig.setConfig(data); ctx.body = assistantConfig.setConfig(data, true);
reload(); reload();
}) })
.addTo(app); .addTo(app);
app.route({
path: 'config',
key: 'getId'
}).define(async (ctx) => {
const config = assistantConfig.getCacheAssistantConfig();
ctx.body = config?.app?.id || null;
}).addTo(app);

View File

@@ -9,18 +9,28 @@ import './hot-api/key-sender/index.ts';
import os from 'node:os'; import os from 'node:os';
import { authCache } from '@/module/cache/auth.ts'; import { authCache } from '@/module/cache/auth.ts';
export const getTokenUser = async (ctx: any) => { const getTokenUser = async (token: string) => {
const query = assistantConfig.query const query = assistantConfig.query
const res = await query.post({ const res = await query.post({
path: 'user', path: 'user',
key: 'me', key: 'me',
token: ctx.state.token || ctx.query.token, token: token,
}); });
if (res.code !== 200) { return res;
return ctx.throw(401, 'not login'); }
export const getTokenUserCache = async (token: string) => {
const tokenUser = await authCache.get(token);
if (tokenUser) {
return {
code: 200,
data: tokenUser,
};
} }
const tokenUser = res.data || {}; const res = await getTokenUser(token);
return tokenUser; if (res.code === 200) {
authCache.set(token, res.data);
}
return res;
} }
const checkAuth = async (ctx: any, isAdmin = false) => { const checkAuth = async (ctx: any, isAdmin = false) => {
const config = assistantConfig.getConfig(); const config = assistantConfig.getConfig();
@@ -33,7 +43,12 @@ const checkAuth = async (ctx: any, isAdmin = false) => {
// 鉴权代理 // 鉴权代理
let tokenUser = await authCache.get(token); let tokenUser = await authCache.get(token);
if (!tokenUser) { 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); authCache.set(token, tokenUser);
} }
ctx.state = { ctx.state = {

View File

@@ -56,8 +56,8 @@ app.route({
if (!auth.username) { if (!auth.username) {
// 初始管理员账号 // 初始管理员账号
auth.username = loginUser; auth.username = loginUser;
if (!auth.type) { if (!auth.share) {
auth.type = 'public'; auth.share = 'protected';
} }
assistantConfig.setConfig({ auth }); assistantConfig.setConfig({ auth });
console.log('set first admin user', { username: loginUser }); console.log('set first admin user', { username: loginUser });

View File

@@ -51,11 +51,11 @@ export class AssistantInit extends AssistantConfig {
return this.#query; return this.#query;
} }
get baseURL() { get baseURL() {
return `${this.getConfig()?.pageApi || 'https://kevisual.cn'}/api/router`; return `${this.getConfig()?.app?.url || 'https://kevisual.cn'}/api/router`;
} }
setQuery(query?: Query) { setQuery(query?: Query) {
this.#query = query || new 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() { checkConfigPath() {
@@ -92,6 +92,16 @@ export class AssistantInit extends AssistantConfig {
if (!checkFileExists(assistantPath, true)) { if (!checkFileExists(assistantPath, true)) {
this.setConfig(this.getDefaultInitAssistantConfig()); this.setConfig(this.getDefaultInitAssistantConfig());
console.log(chalk.green('助手配置文件assistant-config.json创建成功')); 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() { initPnpm() {
@@ -120,7 +130,7 @@ export class AssistantInit extends AssistantConfig {
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "pm2 start apps/root/code-center/app.mjs --name root/code-center", "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": [], "keywords": [],
"author": "", "author": "",
@@ -194,20 +204,18 @@ export class AssistantInit extends AssistantConfig {
protected getDefaultInitAssistantConfig() { protected getDefaultInitAssistantConfig() {
const id = randomId(); const id = randomId();
return { return {
id, app: {
url: 'https://kevisual.cn',
id,
},
description: '助手配置文件', description: '助手配置文件',
docs: "https://kevisual.cn/root/cli-docs/", docs: "https://kevisual.cn/root/cli/docs/",
home: '/root/home', home: '/root/home',
proxy: [], proxy: [],
apiProxyList: [],
share: { share: {
enabled: false, enabled: false,
name: 'abc',
url: 'https://kevisual.cn/ws/proxy', url: 'https://kevisual.cn/ws/proxy',
}, },
watch: {
enabled: true,
},
} as AssistantConfigData; } as AssistantConfigData;
} }
getHttps() { getHttps() {

View File

@@ -3,14 +3,72 @@ import http from 'node:http';
import { LocalProxy } from './local-proxy.ts'; import { LocalProxy } from './local-proxy.ts';
import { assistantConfig, app } from '@/app.ts'; import { assistantConfig, app } from '@/app.ts';
import { log, logger } from '@/module/logger.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({}); const localProxy = new LocalProxy({});
localProxy.initFromAssistantConfig(assistantConfig); 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) => { export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResponse) => {
const _assistantConfig = assistantConfig.getCacheAssistantConfig(); const _assistantConfig = assistantConfig.getCacheAssistantConfig();
const home = _assistantConfig?.home || '/root/home'; const home = _assistantConfig?.home || '/root/home';
const auth = _assistantConfig?.auth || {}; const auth = _assistantConfig?.auth || {};
let noAdmin = !auth.username; let noAdmin = !auth.username;
const toSetting = () => { const toSetting = () => {
res.writeHead(302, { Location: `/root/cli/setting/` }); res.writeHead(302, { Location: `/root/cli/setting/` });
res.end(); res.end();
@@ -35,9 +93,9 @@ export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResp
return; return;
} }
// client, api, v1, serve 开头的拦截 // client, api, v1, serve 开头的拦截
const apiProxyList = _assistantConfig?.apiProxyList || []; const apiProxy = _assistantConfig?.api?.proxy || [];
const defaultApiProxy = createApiProxy(_assistantConfig?.pageApi || 'https://kevisual.cn'); const defaultApiProxy = createApiProxy(_assistantConfig?.app?.url || 'https://kevisual.cn');
const apiBackendProxy = [...apiProxyList, ...defaultApiProxy].find((item) => pathname.startsWith(item.path)); const apiBackendProxy = [...apiProxy, ...defaultApiProxy].find((item) => pathname.startsWith(item.path));
if (apiBackendProxy) { if (apiBackendProxy) {
log.debug('apiBackendProxy', { apiBackendProxy, url: req.url }); log.debug('apiBackendProxy', { apiBackendProxy, url: req.url });
return httpProxy(req, res, { return httpProxy(req, res, {
@@ -76,6 +134,17 @@ export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResp
...proxyApi, ...proxyApi,
indexPath: _indexPath, // 首页路径 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 localProxyProxyList = localProxy.getLocalProxyList();
const localProxyProxy = localProxyProxyList.find((item) => pathname.startsWith(item.path)); 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, 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)); const centerProxy = creatCenterProxy.find((item) => pathname.startsWith(item.path));
if (centerProxy) { if (centerProxy) {
return httpProxy(req, res, { return httpProxy(req, res, {
@@ -103,9 +172,9 @@ export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResp
}; };
export const proxyWs = () => { export const proxyWs = () => {
const apiProxyList = assistantConfig.getCacheAssistantConfig()?.apiProxyList || []; const apiProxy = assistantConfig.getCacheAssistantConfig()?.api?.proxy || [];
const proxy = assistantConfig.getCacheAssistantConfig()?.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); log.debug('proxyApi ', proxyApi);
wsProxy(app.server.server, { wsProxy(app.server.server, {
apiList: proxyApi, apiList: proxyApi,