Compare commits

..

21 Commits

Author SHA1 Message Date
fac9ac8f12 test 2025-06-25 19:11:33 +08:00
20b4540ac1 docs: 修改文档 2025-05-31 00:34:08 +08:00
970f2a18fa fix: update ai-proxy debug 2025-05-30 23:52:22 +08:00
b094fb618a fix 2025-05-29 14:35:59 +08:00
569446b99a temp: add content-type 2025-05-29 13:47:44 +08:00
2b9f238e41 fix: fix get files 2025-05-27 23:38:20 +08:00
a9f0170775 fix: remove x-file-name for ZHONGWEN 错误 2025-05-25 00:03:39 +08:00
4d39b22bdd update 2025-05-23 19:31:23 +08:00
1c7815ca26 fix 2025-05-23 11:38:14 +08:00
4d9df0fee3 feat: add ai proxy 2025-05-23 11:09:54 +08:00
28a5d82e52 feat: add ws-proxy 2025-05-22 14:33:24 +08:00
1e2f891f31 add r functions 2025-05-21 17:17:58 +08:00
ae459eabfe fix: update for init 2025-05-20 00:29:52 +08:00
46cb3d4e75 add delete ai proxy 2025-05-16 03:38:15 +08:00
d43bbba8ad feat: add dir list 2025-05-15 23:52:47 +08:00
30589e3347 feat: add hash 2025-05-12 23:02:11 +08:00
3938a0f87e update 2025-05-12 17:41:11 +08:00
cf82967060 perf 2025-05-12 04:30:40 +08:00
98d6dfb6db feat: add upload 2025-05-11 19:20:51 +08:00
5de857aca8 add resource proxy 2025-05-11 15:56:06 +08:00
f2cc76a8ea add ai proxy 2025-05-08 00:12:14 +08:00
23 changed files with 1109 additions and 381 deletions

View File

@@ -1,13 +1,13 @@
# 代理配置
PROXY_PORT=3005
PROXY_DOMAIN=localhost
PROXY_RESOURCES=http://localhost:9000/resources
PROXY_ALLOWED_ORIGINS=localhost,xiongxiao.me,zxj.im
PROXY_HOME=/ai/chat
PROXY_ALLOWED_ORIGINS=localhost,xiongxiao.me
# 后台代理
API_HOST=http://localhost:4005
API_PATH=/api/router
# API assistant 客户端地址
API_CLIENT_HOST=https://localhost:51015
# Minio 配置
MINIO_ENDPOINT=localhost
@@ -16,3 +16,19 @@ MINIO_BUCKET_NAME=resources
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=username
MINIO_SECRET_KEY=****
# 本地postgres
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=root
POSTGRES_PASSWORD=needchange
POSTGRES_DB=postgres
## Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=needchange
DATA_WEBSITE_ID=

2
.npmrc
View File

@@ -1,4 +1,2 @@
//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN}
@abearxiong:registry=https://npm.pkg.github.com
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
@kevisual:registry=https://npm.xiongxiao.me

View File

@@ -1,11 +1,16 @@
# page proxy
# 部署方案
page proxy的功能是第一点对后端的接口进行代理第二点是页面的browser路由的内容进行代理第三点对minio的资源的内容进行代理。
属于网关功能page的proxy功能api的代理功能和code-center融合成一个整体的页面的功能。code-center负责apipage-proxy进行对他的api代理。后端可以根据很多个类似code-center的api的服务糅合在一起。
## 部署方案
```sh
envision pack -p -u
envision pack-deploy 330bc5f8-1ae7-4be5-a44c-0ea0b3da184b page-proxy # key和id是人设置的
# 会复制到对应的文件夹里面了,现在启动时后台启动
# 会复制到对应的文件夹里面了,现在,启动时后台启动
# 需要调用类似,但需要token
# token ev token会显示当前登陆的用户的token
# https://kevisual.xiongxiao.me/api/router?path=local-apps&key=updateStatus&appKey=page-proxy&status=start&token=******

37
bun.config.mjs Normal file
View File

@@ -0,0 +1,37 @@
// @ts-check
import { resolvePath } from '@kevisual/use-config/env';
import { execSync } from 'node:child_process';
const entry = 'src/index.ts';
const naming = 'app';
const external = ['sequelize', 'pg', 'sqlite3', 'ioredis', 'pm2'];
/**
* @type {import('bun').BuildConfig}
*/
await Bun.build({
target: 'node',
format: 'esm',
entrypoints: [resolvePath(entry, { meta: import.meta })],
outdir: resolvePath('./dist', { meta: import.meta }),
naming: {
entry: `${naming}.js`,
},
external,
env: 'KEVISUAL_*',
});
// const cmd = `dts -i src/index.ts -o app.d.ts`;
// const cmd = `dts -i ${entry} -o ${naming}.d.ts`;
// execSync(cmd, { stdio: 'inherit' });
// await Bun.build({
// target: 'node',
// format: 'esm',
// entrypoints: [resolvePath('./src/run.ts', { meta: import.meta })],
// outdir: resolvePath('./dist', { meta: import.meta }),
// naming: {
// entry: `${'run'}.js`,
// },
// external,
// env: 'KEVISUAL_*',
// });

View File

@@ -1,26 +1,29 @@
{
"name": "page-proxy",
"version": "0.0.4",
"version": "0.0.6",
"description": "",
"main": "index.js",
"type": "module",
"basename": "/root/page-proxy",
"app": {
"key": "page-proxy",
"entry": "dist/app.mjs",
"entry": "dist/app.js",
"type": "pm2-system-app",
"files": [
"dist"
"runtime": [
"client",
"server"
]
},
"files": [
"dist"
"dist",
".env.example"
],
"home": "",
"scripts": {
"watch": "rollup -c --watch",
"dev": "cross-env NODE_ENV=development nodemon --ignore upload --exec tsx src/index.ts",
"dev:watch": "cross-env NODE_ENV=development concurrently -n \"Watch,Dev\" -c \"green,blue\" \"npm run watch\" \"sleep 1 && npm run dev\" ",
"build": "rimraf dist && rollup -c",
"dev": "bun run --watch --hot --inspect src/index.ts",
"cmd": "bun run src/run.ts ",
"prebuild": "rimraf dist",
"build": "NODE_ENV=production bun bun.config.mjs",
"start": "pm2 start dist/app.mjs --name page-proxy",
"release": "node ./scripts/release/index.mjs",
"deploy": "envision pack -p -u",
@@ -31,30 +34,38 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@kevisual/logger": "^0.0.4",
"@kevisual/oss": "^0.0.12",
"@rollup/plugin-commonjs": "^28.0.3",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-typescript": "^12.1.2",
"@types/busboy": "^1.5.4",
"@types/http-proxy": "^1.17.16",
"@types/node": "^22.13.14",
"@types/node": "^22.15.27",
"@types/send": "^0.17.4",
"@types/ws": "^8.18.1",
"concurrently": "^9.1.2",
"cross-env": "^7.0.3",
"nodemon": "^3.1.9",
"rollup": "^4.38.0",
"nodemon": "^3.1.10",
"rollup": "^4.41.1",
"tslib": "^2.8.1",
"typescript": "^5.8.2"
"typescript": "^5.8.3",
"ws": "npm:@kevisual/ws"
},
"dependencies": {
"@kevisual/code-center-module": "0.0.16",
"@kevisual/permission": "^0.0.1",
"@kevisual/query": "^0.0.15",
"@kevisual/code-center-module": "0.0.20",
"@kevisual/permission": "^0.0.3",
"@kevisual/query": "^0.0.20",
"@kevisual/query-config": "^0.0.2",
"@kevisual/router": "0.0.9",
"@kevisual/use-config": "^1.0.10",
"@kevisual/router": "0.0.21",
"@kevisual/use-config": "^1.0.17",
"@types/lodash-es": "^4.17.12",
"archiver": "^7.0.1",
"busboy": "^1.6.0",
"cookie": "^1.0.2",
"ioredis": "^5.6.0",
"ioredis": "^5.6.1",
"lodash-es": "^4.17.21",
"minio": "^8.0.5",
"nanoid": "^5.1.5",
"send": "^1.2.0",

531
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,8 @@ import { config } from './module/config.ts';
import { app } from './app.ts';
import './route/route.ts';
import net from 'net';
import { WssApp } from './module/ws-proxy/index.ts';
const port = config?.proxy?.port || 3005;
app
@@ -21,10 +23,16 @@ app.listen(port, () => {
app.server.on(handleRequest);
const wssApp = new WssApp();
const main = () => {
console.log('Upgrade initialization started');
app.server.server.on('upgrade', (req, socket, head) => {
app.server.server.on('upgrade', async (req, socket, head) => {
const isUpgrade = wssApp.upgrade(req, socket, head);
if (isUpgrade) {
console.log('WebSocket upgrade successful for path:', req.url);
return;
}
const proxyApiList = config?.apiList || [];
const proxyApi = proxyApiList.find((item) => req.url.startsWith(item.path));
@@ -40,12 +48,12 @@ const main = () => {
const proxySocket = net.connect(options.port, options.hostname, () => {
proxySocket.write(
`GET ${options.path} HTTP/1.1\r\n` +
`Host: ${options.hostname}\r\n` +
`Connection: Upgrade\r\n` +
`Upgrade: websocket\r\n` +
`Sec-WebSocket-Key: ${req.headers['sec-websocket-key']}\r\n` +
`Sec-WebSocket-Version: ${req.headers['sec-websocket-version']}\r\n` +
`\r\n`
`Host: ${options.hostname}\r\n` +
`Connection: Upgrade\r\n` +
`Upgrade: websocket\r\n` +
`Sec-WebSocket-Key: ${req.headers['sec-websocket-key']}\r\n` +
`Sec-WebSocket-Version: ${req.headers['sec-websocket-version']}\r\n` +
`\r\n`,
);
proxySocket.pipe(socket);
socket.pipe(proxySocket);

View File

@@ -1,6 +1,7 @@
import { User } from '@/module/models.ts';
import http from 'http';
import cookie from 'cookie';
import { logger } from '@/module/logger.ts';
export const error = (msg: string, code = 500) => {
return JSON.stringify({ code, message: msg });
};
@@ -55,6 +56,7 @@ export const getLoginUser = async (req: http.IncomingMessage) => {
return null;
}
let tokenUser;
logger.debug('getLoginUser', token);
try {
tokenUser = await User.verifyToken(token);
return { tokenUser, token };

View File

@@ -1,6 +1,7 @@
import { useConfig } from '@kevisual/use-config/env';
import { useFileStore } from '@kevisual/use-config/file-store';
export const fileStore = useFileStore('proxy-upload');
import { minioResources } from './minio.ts';
export const fileStore = useFileStore('pages');
type ConfigType = {
api: {
@@ -45,6 +46,11 @@ type ConfigType = {
*/
websiteId: string;
};
redis?: {
host: string;
port: number;
password?: string;
};
};
// export const config = useConfig();
@@ -60,13 +66,22 @@ export const config: ConfigType = {
path: '/api',
target: envConfig.API_HOST,
},
{
path: '/client',
target: envConfig.API_CLIENT_HOST || 'https://localhost:51015',
},
],
proxy: {
port: envConfig.PROXY_PORT,
domain: envConfig.PROXY_DOMAIN,
resources: envConfig.PROXY_RESOURCES,
resources: minioResources,
allowedOrigin: (envConfig.PROXY_ALLOWED_ORIGINS as string)?.split(',') || [],
},
redis: {
host: envConfig.REDIS_HOST,
port: envConfig.REDIS_PORT,
password: envConfig.REDIS_PASSWORD,
},
stat: {
websiteId: envConfig.DATA_WEBSITE_ID,
},

View File

@@ -1,12 +1,48 @@
import path from 'path';
export const getTextContentType = (ext: string) => {
const textContentTypes = [
'.tsx',
'.jsx', //
'.conf',
'.env',
'.example',
'.log',
'.mjs',
'.map',
'.json5',
'.pem',
'.crt',
];
const include = textContentTypes.includes(ext);
if (!include) {
return {};
}
const contentType = getContentTypeCore(ext);
if (!contentType) {
return {};
}
return {
'Content-Type': contentType,
};
};
// 获取文件的 content-type
export const getContentType = (filePath: string) => {
const extname = path.extname(filePath);
export const getContentTypeCore = (extname: string) => {
const contentType = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.tsx': 'text/typescript; charset=utf-8',
'.ts': 'text/typescript; charset=utf-8',
'.jsx': 'text/javascript; charset=utf-8',
'.conf': 'text/plain; charset=utf-8',
'.env': 'text/plain; charset=utf-8',
'.example': 'text/plain; charset=utf-8',
'.log': 'text/plain; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.json5': 'application/json5; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpg',
@@ -22,7 +58,6 @@ export const getContentType = (filePath: string) => {
'.mp3': 'audio/mpeg', // MP3 音频格式
'.m4a': 'audio/mp4', // M4A 音频格式
'.m3u8': 'application/vnd.apple.mpegurl', // HLS 播放列表
'.ts': 'video/mp2t', // MPEG Transport Stream
'.pdf': 'application/pdf', // PDF 文档
'.doc': 'application/msword', // Word 文档
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // Word 文档 (新版)
@@ -45,5 +80,11 @@ export const getContentType = (filePath: string) => {
'.yml': 'application/x-yaml; charset=utf-8', // YAML 文件(别名)
'.zip': 'application/octet-stream',
};
return contentType[extname] || 'application/octet-stream';
return contentType[extname];
};
export const getContentType = (filePath: string) => {
const extname = path.extname(filePath).toLowerCase();
const contentType = getContentTypeCore(extname);
return contentType || 'application/octet-stream';
};

View File

@@ -7,21 +7,27 @@ import { nanoid } from 'nanoid';
import { pipeline } from 'stream';
import { promisify } from 'util';
import { fetchApp, fetchDomain, fetchTest } from './query/get-router.ts';
import { getAppLoadStatus, setAppLoadStatus, AppLoadStatus } from './redis/get-app-status.ts';
import { bucketName, minioClient, minioResources } from './minio.ts';
import { getAppLoadStatus, setAppLoadStatus } from './redis/get-app-status.ts';
import { minioResources } from './minio.ts';
import { downloadFileFromMinio } from './proxy/http-proxy.ts';
import { logger } from './logger.ts';
const pipelineAsync = promisify(pipeline);
const { resources } = config?.proxy || { resources: 'https://minio.xiongxiao.me/resources' };
const { resources } = config?.proxy || { resources: minioResources };
const wrapperResources = (resources: string, urlpath: string) => {
if (urlpath.startsWith('http')) {
return urlpath;
}
return `${resources}/${urlpath}`;
};
const demoData = {
user: 'root',
key: 'codeflow',
appType: 'web-single', //
version: '1.0.0',
domain: null,
type: 'local', // local, oss 默认是oss
type: 'oss', // oss 默认是oss
data: {
files: [
{
@@ -189,6 +195,7 @@ export class UserApp {
}
const loadStatus = await getAppLoadStatus(user, app);
logger.debug('loadStatus', loadStatus);
if (loadStatus.status === 'loading') {
// 其他情况error或者running都可以重新加载
return {
@@ -207,7 +214,7 @@ export class UserApp {
return { code: 500, message: 'app status is not running' };
}
// console.log('fetchData', JSON.stringify(fetchData.data.files, null, 2));
// const getFileSize
this.setLoaded('loading', 'loading');
const loadProxy = async () => {
const value = fetchData;
@@ -221,9 +228,9 @@ export class UserApp {
// 将文件名和路径添加到 `data` 对象中
files.forEach((file) => {
if (file.name === 'index.html') {
indexHtml = resources + '/' + file.path;
indexHtml = wrapperResources(resources, file.path);
}
data[file.name] = resources + '/' + file.path;
data[file.name] = wrapperResources(resources, file.path);
});
await redis.set('user:app:exist:' + app + ':' + user, indexHtml + '||etag||true', 'EX', 60 * 60 * 24 * 7); // 7天
await redis.set('user:app:permission:' + app + ':' + user, JSON.stringify(permission), 'EX', 60 * 60 * 24 * 7); // 7天
@@ -267,6 +274,7 @@ export class UserApp {
await redis.hset('user:app:set:' + app + ':' + user, data);
this.setLoaded('running', 'loaded');
};
logger.debug('loadFilesFn', fetchData.proxy);
try {
if (fetchData.proxy === true) {
await loadProxy();
@@ -326,38 +334,9 @@ export const downloadUserAppFiles = async (user: string, app: string, data: type
fs.mkdirSync(uploadFiles, { recursive: true });
}
const newFiles = [];
try {
if (data.type === 'local') {
// local copy file
for (let i = 0; i < files.length; i++) {
const file = files[i];
const copyFile = path.join(fileStore, file.path);
const destFile = path.join(uploadFiles, file.name);
const destDir = path.dirname(destFile); // 获取目标文件所在的目录路径
// 检查目录是否存在,如果不存在则创建
if (!checkFileExistsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true }); // 递归创建目录
}
fs.copyFileSync(copyFile, destFile);
// const etag = await setEtag(fs.readFileSync(destFile, 'utf-8'));
const etag = nanoid();
newFiles.push({
name: file.name,
path: destFile.replace(fileStore, '') + '||' + etag,
});
}
}
} catch (err) {
const userApp = new UserApp({ user, app });
userApp.clearCacheData();
return {
data: {
files: [],
},
};
}
if (data.type === 'oss') {
let serverPath = new URL(resources).href + '/';
let serverPath = new URL(resources).href;
let hasIndexHtml = false;
// server download file
for (let i = 0; i < files.length; i++) {
@@ -371,8 +350,9 @@ export const downloadUserAppFiles = async (user: string, app: string, data: type
if (!checkFileExistsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true }); // 递归创建目录
}
const downloadURL = wrapperResources(serverPath, file.path);
// 下载文件到 destFile
await downloadFile(serverPath + file.path, destFile);
await downloadFile(downloadURL, destFile);
const etag = nanoid();
newFiles.push({
name: file.name,

View File

@@ -1,5 +1,6 @@
import { getDNS, isIpv4OrIpv6, isLocalhost } from '@/utils/dns.ts';
import http from 'http';
import https from 'https';
import { UserApp } from './get-user-app.ts';
import { config, fileStore } from '../module/config.ts';
import path from 'path';
@@ -11,15 +12,52 @@ import { getTextFromStreamAndAddStat, httpProxy } from './proxy/http-proxy.ts';
import { UserPermission } from '@kevisual/permission';
import { getLoginUser } from '@/middleware/auth.ts';
import { rediretHome } from './user-home/index.ts';
const api = config?.api || { host: 'kevisual.xiongxiao.me', path: '/api/router' };
const domain = config?.proxy?.domain || 'kevisual.xiongxiao.me';
import { aiProxy } from './proxy/ai-proxy.ts';
import { logger } from './logger.ts';
import { UserV1Proxy } from './ws-proxy/proxy.ts';
const domain = config?.proxy?.domain;
const allowedOrigins = config?.proxy?.allowedOrigin || [];
const noProxyUrl = ['/', '/favicon.ico'];
const notAuthPathList = [
{
user: 'root',
paths: ['center'],
},
{
user: 'admin',
paths: ['center'],
},
{
user: 'user',
paths: ['login'],
},
{
user: 'public',
paths: ['center'],
all: true,
},
{
user: 'test',
paths: ['center'],
all: true,
},
];
const checkNotAuthPath = (user, app) => {
const notAuthPath = notAuthPathList.find((item) => {
if (item.user === user) {
if (item.all) {
return true;
}
return item.paths?.includes?.(app);
}
return false;
});
return notAuthPath;
};
export const handleRequest = async (req: http.IncomingMessage, res: http.ServerResponse) => {
const querySearch = new URL(req.url, `http://${req.headers.host}`).searchParams;
const password = querySearch.get('p');
const loginUser = await getLoginUser(req);
if (req.url === '/favicon.ico') {
res.writeHead(200, { 'Content-Type': 'image/x-icon' });
res.end('proxy no favicon.ico\n');
@@ -46,8 +84,8 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
// 提取req的headers中的非HOST的header
const headers = Object.keys(req.headers).filter((item) => item && item.toLowerCase() !== 'host');
const host = req.headers['host'];
console.log('host', host);
console.log('headers', headers);
logger.info('proxy host', host);
logger.info('headers', headers);
headers.forEach((item) => {
header[item] = req.headers[item];
@@ -64,8 +102,16 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
// @ts-ignore
options.port = _u.port;
}
logger.info('proxy options', options);
const isHttps = _u.protocol === 'https:';
const protocol = isHttps ? https : http;
if (isHttps) {
// 不验证https
// @ts-ignore
options.rejectUnauthorized = false;
}
// 创建代理请求
const proxyReq = http.request(options, (proxyRes) => {
const proxyReq = protocol.request(options, (proxyRes) => {
// 将代理服务器的响应头和状态码返回给客户端
res.writeHead(proxyRes.statusCode, proxyRes.headers);
// 将代理响应流写入客户端响应
@@ -73,7 +119,7 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
});
// 处理代理请求的错误事件
proxyReq.on('error', (err) => {
console.error(`Proxy request error: ${err.message}`);
logger.error(`Proxy request error: ${err.message}`);
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.write(`Proxy request error: ${err.message}`);
});
@@ -98,7 +144,7 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
) {
res.setHeader('Access-Control-Allow-Origin', '*');
}
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE, PUT');
let user, app;
let domainApp = false;
@@ -116,7 +162,7 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
return res.end();
}
// 验证域名
if (dns.hostName !== domain) {
if (domain && dns.hostName !== domain) {
// redis获取域名对应的用户和应用
domainApp = true;
const data = await UserApp.getDomainApp(dns.hostName);
@@ -176,10 +222,7 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
user = _user;
app = _app;
}
const userApp = new UserApp({ user, app });
let isExist = await userApp.getExist();
const createRefreshPage = () => {
const createRefreshPage = (user, app) => {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(createRefreshHtml(user, app));
};
@@ -188,16 +231,30 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
res.write('Server Error\n');
res.end();
};
const createNotFoundPage = async (msg?: string) => {
res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
const createNotFoundPage = async (msg?: string, code = 404) => {
res.writeHead(code, { 'Content-Type': 'text/html; charset=utf-8' });
res.write(msg || 'Not Found App\n');
res.end();
};
if (app === 'ai' || app === 'resources' || app === 'r') {
return aiProxy(req, res, {
createNotFoundPage,
});
}
if (user !== 'api' && app === 'v1') {
return UserV1Proxy(req, res, {
createNotFoundPage,
});
}
const userApp = new UserApp({ user, app });
let isExist = await userApp.getExist();
logger.debug('userApp', userApp, isExist);
if (!isExist) {
try {
const { code, loading, message } = await userApp.setCacheData();
if (loading || code === 20000) {
return createRefreshPage();
return createRefreshPage(user, app);
} else if (code === 500) {
return createNotFoundPage(message || 'Not Found App\n');
} else if (code !== 200) {
@@ -214,42 +271,7 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
if (!isExist) {
return createNotFoundPage();
}
const notAuthPathList = [
{
user: 'root',
paths: ['center'],
},
{
user: 'admin',
paths: ['center'],
},
{
user: 'user',
paths: ['login'],
},
{
user: 'public',
paths: ['center'],
all: true,
},
{
user: 'test',
paths: ['center'],
all: true,
},
];
const checkNotAuthPath = (user, app) => {
const notAuthPath = notAuthPathList.find((item) => {
if (item.user === user) {
if (item.all) {
return true;
}
return item.paths?.includes?.(app);
}
return false;
});
return notAuthPath;
};
if (!checkNotAuthPath(user, app)) {
const { permission } = isExist;
const permissionInstance = new UserPermission({ permission, owner: user });
@@ -271,7 +293,10 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
appFileUrl = (url + '').replace(`/${user}/${app}/`, '');
}
appFileUrl = decodeURIComponent(appFileUrl); // Decode URL components
const appFile = await userApp.getFile(appFileUrl);
let appFile = await userApp.getFile(appFileUrl);
if (!appFile && url.endsWith('/')) {
appFile = await userApp.getFile(appFileUrl + 'index.html');
}
if (isExist.proxy) {
let proxyUrl = appFile || isExist.indexFilePath;
if (!proxyUrl.startsWith('http')) {
@@ -283,7 +308,6 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
userApp,
createNotFoundPage,
});
// userApp.clearCacheData()
return;
}
console.log('appFile', appFile, appFileUrl, isExist);
@@ -295,9 +319,6 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
const isHTML = contentType.includes('html');
const filePath = path.join(fileStore, indexFilePath);
if (!userApp.fileCheck(filePath)) {
// 动态删除文件
// res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
// res.write('App Cache expired, Please refresh\n');
res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8', tips: 'App Cache expired, Please refresh' });
res.write(createRefreshHtml(user, app));
res.end();
@@ -351,7 +372,7 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
headers.set('Content-Type', contentType);
headers.set('Cache-Control', isHTML ? 'no-cache' : 'public, max-age=3600'); // 设置缓存时间为 1 小时
headers.set('ETag', eTag);
res.setHeaders(headers);
res?.setHeaders?.(headers);
if (isHTML) {
const newHtml = await getTextFromStreamAndAddStat(fs.createReadStream(filePath));
resContent = newHtml.html;

2
src/module/logger.ts Normal file
View File

@@ -0,0 +1,2 @@
import { Logger } from '@kevisual/logger/node';
export const logger = new Logger();

View File

@@ -0,0 +1,292 @@
import { bucketName, minioClient } from '../minio.ts';
import { IncomingMessage, ServerResponse } from 'http';
import { filterKeys } from './http-proxy.ts';
import { getUserFromRequest } from '@/utils/get-user.ts';
import { UserPermission, Permission } from '@kevisual/permission';
import { getLoginUser } from '@/middleware/auth.ts';
import busboy from 'busboy';
import { getContentType } from '../get-content-type.ts';
import { OssBase } from '@kevisual/oss';
import { parseSearchValue } from '@kevisual/router/browser';
import { logger } from '../logger.ts';
type FileList = {
name: string;
prefix?: string;
size?: number;
etag?: string;
lastModified?: Date;
path?: string;
url?: string;
pathname?: string;
};
export const getFileList = async (list: any, opts?: { objectName: string; app: string; host?: string }) => {
const { app, host } = opts || {};
const objectName = opts?.objectName || '';
let newObjectName = objectName;
const [user] = objectName.split('/');
let replaceUser = user + '/';
if (app === 'resources') {
replaceUser = `${user}/resources/`;
newObjectName = objectName.replace(`${user}/`, replaceUser);
}
return list.map((item: FileList) => {
if (item.name) {
item.path = item.name?.replace?.(objectName, '');
item.pathname = '/' + item.name.replace(`${user}/`, replaceUser);
} else {
item.path = item.prefix?.replace?.(objectName, '');
item.pathname = '/' + item.prefix.replace(`${user}/`, replaceUser);
}
if (item.name && app === 'ai') {
const [_user, _app, _version, ...rest] = item.name.split('/');
item.pathname = item.pathname.replace(`/${_user}/${_app}/${_version}/`, `/${_user}/${_app}/`);
} else if (app === 'ai') {
const [_user, _app, _version, ...rest] = item.prefix?.split('/');
item.pathname = item.pathname.replace(`/${_user}/${_app}/${_version}/`, `/${_user}/${_app}/`);
}
item.url = new URL(item.pathname, `https://${host}`).toString();
return item;
});
};
// import { logger } from '@/module/logger.ts';
const getAiProxy = async (req: IncomingMessage, res: ServerResponse, opts: ProxyOptions) => {
const { createNotFoundPage } = opts;
const _u = new URL(req.url, 'http://localhost');
const oss = opts.oss;
const params = _u.searchParams;
const password = params.get('p');
const hash = params.get('hash');
let dir = !!params.get('dir');
const recursive = !!params.get('recursive');
const { objectName, app, owner, loginUser, isOwner } = await getObjectName(req);
if (!dir && _u.pathname.endsWith('/')) {
dir = true; // 如果是目录请求强制设置为true
}
logger.debug(`proxy request: ${objectName}`, dir);
try {
if (dir) {
if (!isOwner) {
return createNotFoundPage('no dir permission');
}
const list = await oss.listObjects<true>(objectName, { recursive: recursive });
res.writeHead(200, { 'content-type': 'application/json' });
const host = req.headers['host'] || 'localhost';
res.end(
JSON.stringify({
code: 200,
data: await getFileList(list, {
objectName: objectName,
app: app,
host,
}),
}),
);
return true;
}
const stat = await oss.statObject(objectName);
if (!stat) {
createNotFoundPage('Invalid proxy url');
logger.debug('no stat', objectName, owner, req.url);
return true;
}
const permissionInstance = new UserPermission({ permission: stat.metaData as Permission, owner: owner });
const checkPermission = permissionInstance.checkPermissionSuccess({
username: loginUser?.tokenUser?.username || '',
password: password,
});
if (!checkPermission.success) {
logger.info('no permission', checkPermission, loginUser, owner);
return createNotFoundPage('no permission');
}
if (hash && stat.etag === hash) {
res.writeHead(304); // not modified
res.end('not modified');
return true;
}
const filterMetaData = filterKeys(stat.metaData, ['size', 'etag', 'last-modified']);
const contentLength = stat.size;
const etag = stat.etag;
const lastModified = stat.lastModified.toISOString();
const objectStream = await minioClient.getObject(bucketName, objectName);
const headers = {
'Content-Length': contentLength,
etag,
'last-modified': lastModified,
...filterMetaData,
};
res.writeHead(200, {
...headers,
});
objectStream.pipe(res, { end: true });
return true;
} catch (error) {
console.error(`Proxy request error: ${error.message}`);
createNotFoundPage('Invalid ai proxy url');
return false;
}
};
export const getMetadata = (pathname: string) => {
let meta: any = { 'app-source': 'user-app' };
const isHtml = pathname.endsWith('.html');
if (isHtml) {
meta = {
...meta,
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-cache',
};
} else {
meta = {
...meta,
'content-type': getContentType(pathname),
'cache-control': 'max-age=31536000, immutable',
};
}
return meta;
};
export const postProxy = async (req: IncomingMessage, res: ServerResponse, opts: ProxyOptions) => {
const _u = new URL(req.url, 'http://localhost');
const pathname = _u.pathname;
const oss = opts.oss;
const params = _u.searchParams;
const force = !!params.get('force');
const hash = params.get('hash');
let meta = parseSearchValue(params.get('meta'), { decode: true });
if (!hash && !force) {
return opts?.createNotFoundPage?.('no hash');
}
const { objectName, isOwner } = await getObjectName(req);
if (!isOwner) {
return opts?.createNotFoundPage?.('no permission');
}
const end = (data: any, message?: string, code = 200) => {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ code: code, data: data, message: message || 'success' }));
};
let statMeta: any = {};
if (!force) {
const check = await oss.checkObjectHash(objectName, hash, meta);
statMeta = check?.metaData || {};
let isNewMeta = false;
if (check.success && JSON.stringify(meta) !== '{}' && !check.equalMeta) {
meta = { ...statMeta, ...getMetadata(pathname), ...meta };
isNewMeta = true;
await oss.replaceObject(objectName, { ...meta });
}
if (check.success) {
return end({ success: true, hash, meta, isNewMeta, equalMeta: check.equalMeta }, '文件已存在');
}
}
const bb = busboy({
headers: req.headers,
limits: {
fileSize: 100 * 1024 * 1024, // 100MB
files: 1,
},
});
let fileProcessed = false;
bb.on('file', async (name, file, info) => {
fileProcessed = true;
try {
// console.log('file', stat?.metaData);
// await sleep(2000);
await oss.putObject(
objectName,
file,
{
...statMeta,
...getMetadata(pathname),
...meta,
},
{ check: false, isStream: true },
);
end({ success: true, name, info, isNew: true, hash, meta: meta?.metaData, statMeta }, '上传成功', 200);
} catch (error) {
end({ error: error }, '上传失败', 500);
}
});
bb.on('finish', () => {
// 只有当没有文件被处理时才执行end
if (!fileProcessed) {
end({ success: false }, '没有接收到文件', 400);
}
});
bb.on('error', (err) => {
console.error('Busboy 错误:', err);
end({ error: err }, '文件解析失败', 500);
});
req.pipe(bb);
};
export const getObjectName = async (req: IncomingMessage, opts?: { checkOwner?: boolean }) => {
const _u = new URL(req.url, 'http://localhost');
const pathname = decodeURIComponent(_u.pathname);
const params = _u.searchParams;
const { user, app } = getUserFromRequest(req);
const checkOwner = opts?.checkOwner ?? true;
let objectName = '';
let owner = '';
if (app === 'ai') {
const version = params.get('version') || '1.0.0'; // root/ai
objectName = pathname.replace(`/${user}/${app}/`, `${user}/${app}/${version}/`);
} else {
objectName = pathname.replace(`/${user}/${app}/`, `${user}/`); // root/resources
}
owner = user;
let isOwner = undefined;
let loginUser: Awaited<ReturnType<typeof getLoginUser>> = null;
if (checkOwner) {
loginUser = await getLoginUser(req);
logger.debug('getObjectName', loginUser, user, app);
isOwner = loginUser?.tokenUser?.username === owner;
}
return {
objectName,
loginUser,
owner,
isOwner,
app,
user,
};
};
export const deleteProxy = async (req: IncomingMessage, res: ServerResponse, opts: ProxyOptions) => {
const { objectName, isOwner } = await getObjectName(req);
let oss = opts.oss;
if (!isOwner) {
return opts?.createNotFoundPage?.('no permission');
}
try {
await oss.deleteObject(objectName);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, message: 'delete success', objectName }));
} catch (error) {
logger.error('deleteProxy error', error);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: error }));
}
};
type ProxyOptions = {
createNotFoundPage: (msg?: string) => any;
oss?: OssBase;
};
export const aiProxy = async (req: IncomingMessage, res: ServerResponse, opts: ProxyOptions) => {
const oss = new OssBase({ bucketName, client: minioClient });
if (!opts.oss) {
opts.oss = oss;
}
if (req.method === 'POST') {
return postProxy(req, res, opts);
}
if (req.method === 'DELETE') {
return deleteProxy(req, res, opts);
}
return getAiProxy(req, res, opts);
};

View File

@@ -7,6 +7,9 @@ import http from 'http';
import https from 'https';
import { UserApp } from '../get-user-app.ts';
import { addStat } from '../html/stat/index.ts';
import path from 'path';
import { getTextContentType } from '../get-content-type.ts';
import { logger } from '../logger.ts';
const pipelineAsync = promisify(pipeline);
@@ -32,25 +35,25 @@ export async function minioProxy(
res: ServerResponse,
opts: {
proxyUrl: string;
userApp: UserApp;
createNotFoundPage: (msg?: string) => any;
isDownload?: boolean;
},
) {
const fileUrl = opts.proxyUrl;
const { userApp, createNotFoundPage, isDownload = false } = opts;
const { createNotFoundPage, isDownload = false } = opts;
const objectName = fileUrl.replace(minioResources + '/', '');
try {
const stat = await minioClient.statObject(bucketName, objectName);
if (stat.size === 0) {
return createNotFoundPage('Invalid proxy url');
createNotFoundPage('Invalid proxy url');
return true;
}
const filterMetaData = filterKeys(stat.metaData, ['size', 'etag', 'last-modified']);
const contentLength = stat.size;
const etag = stat.etag;
const lastModified = stat.lastModified.toISOString();
const fileName = objectName.split('/').pop();
const ext = path.extname(fileName || '');
const objectStream = await minioClient.getObject(bucketName, objectName);
const headers = {
'Content-Length': contentLength,
@@ -58,6 +61,7 @@ export async function minioProxy(
'last-modified': lastModified,
'file-name': fileName,
...filterMetaData,
...getTextContentType(ext),
};
if (objectName.endsWith('.html') && !isDownload) {
const { html, contentLength } = await getTextFromStreamAndAddStat(objectStream);
@@ -72,10 +76,11 @@ export async function minioProxy(
});
objectStream.pipe(res, { end: true });
}
return true;
} catch (error) {
console.error(`Proxy request error: ${error.message}`);
userApp.clearCacheData();
return createNotFoundPage('Invalid proxy url');
createNotFoundPage('Invalid proxy url');
return false;
}
}
@@ -114,7 +119,11 @@ export const httpProxy = async (
const params = _u.searchParams;
const isDownload = params.get('download') === 'true';
if (proxyUrl.startsWith(minioResources)) {
return minioProxy(req, res, { ...opts, isDownload });
const isOk = await minioProxy(req, res, { ...opts, isDownload });
if (!isOk) {
userApp.clearCacheData();
}
return;
}
let protocol = proxyUrl.startsWith('https') ? https : http;
// 代理

View File

@@ -1,4 +1,5 @@
import { config } from '../config.ts';
import { logger } from '../logger.ts';
const api = config?.api || { host: 'https://kevisual.cn', path: '/api/router' };
const apiPath = api.path || '/api/router';

View File

@@ -23,8 +23,8 @@ export const getAppLoadStatus = async (user: string, app: string): Promise<AppLo
};
}
};
export const setAppLoadStatus = async (user: string, app: string, status: AppLoadStatus) => {
export const setAppLoadStatus = async (user: string, app: string, status: AppLoadStatus, exp = 3 * 60) => {
const key = 'user:app:status:' + app + ':' + user;
const value = JSON.stringify(status);
await redis.set(key, value);
await redis.set(key, value, 'EX', exp); // 5分钟过期
};

View File

@@ -1,11 +1,11 @@
import { Redis } from 'ioredis';
import { useContextKey } from '@kevisual/use-config/context';
console.log(process.env.REDIS_HOST);
import { config } from '../config.ts';
const redisConfig = {
host: 'localhost', // Redis 服务器的主机名或 IP 地址
port: 6379, // Redis 服务器的端口号
// password: 'your_password', // Redis 的密码 (如果有)
host: config?.redis?.host || 'localhost', // Redis 服务器的主机名或 IP 地址
port: config?.redis?.port || 6379, // Redis 服务器的端口号
password: config?.redis?.password, // Redis 的密码 (如果有)
};
const init = () => {
return new Redis({

View File

@@ -0,0 +1,69 @@
import { WebSocketServer } from 'ws';
import { nanoid } from 'nanoid';
import { WsProxyManager } from './manager.ts';
import { getLoginUser } from '@/middleware/auth.ts';
import { logger } from '../logger.ts';
export const wsProxyManager = new WsProxyManager();
export const upgrade = async (request: any, socket: any, head: any) => {
const req = request as any;
const url = new URL(req.url, 'http://localhost');
const id = url.searchParams.get('id');
if (url.pathname === '/ws/proxy') {
console.log('upgrade', request.url, id);
wss.handleUpgrade(req, socket, head, (ws) => {
// 这里手动触发 connection 事件
// @ts-ignore
wss.emit('connection', ws, req);
});
return true;
}
return false;
};
export const wss = new WebSocketServer({
noServer: true,
path: '/ws/proxy',
});
wss.on('connection', async (ws, req) => {
console.log('connected', req.url);
const url = new URL(req.url, 'http://localhost');
const id = url?.searchParams?.get('id') || nanoid();
const loginUser = await getLoginUser(req);
if (!loginUser) {
ws.send(JSON.stringify({ code: 401, message: 'No Login' }));
ws.close();
return;
}
const user = loginUser.tokenUser?.username;
wsProxyManager.register(id, { user, ws });
ws.send(
JSON.stringify({
type: 'connected',
user: user,
id,
}),
);
ws.on('message', async (event: Buffer) => {
const eventData = event.toString();
if (!eventData) {
return;
}
const data = JSON.parse(eventData);
logger.debug('message', data);
});
ws.on('close', () => {
logger.debug('ws closed');
wsProxyManager.unregister(id, user);
});
});
export class WssApp {
wss: WebSocketServer;
constructor() {
this.wss = wss;
}
upgrade(request: any, socket: any, head: any) {
return upgrade(request, socket, head);
}
}

View File

@@ -0,0 +1,84 @@
import { nanoid } from 'nanoid';
import { WebSocket } from 'ws';
import { logger } from '../logger.ts';
class WsMessage {
ws: WebSocket;
user?: string;
constructor({ ws, user }: WssMessageOptions) {
this.ws = ws;
this.user = user;
}
async sendData(data: any, opts?: { timeout?: number }) {
if (this.ws.readyState !== WebSocket.OPEN) {
return { code: 500, message: 'WebSocket is not open' };
}
const timeout = opts?.timeout || 10 * 6 * 1000; // 10 minutes
const id = nanoid();
const message = JSON.stringify({
id,
type: 'proxy',
data,
});
logger.info('ws-proxy sendData', message);
this.ws.send(message);
return new Promise((resolve) => {
const timer = setTimeout(() => {
resolve({
code: 500,
message: 'timeout',
});
}, timeout);
this.ws.once('message', (event: Buffer) => {
const eventData = event.toString();
if (!eventData) {
return;
}
const data = JSON.parse(eventData);
if (data.id === id) {
resolve(data.data);
clearTimeout(timer);
}
});
});
}
}
type WssMessageOptions = {
ws: WebSocket;
user?: string;
};
export class WsProxyManager {
wssMap: Map<string, WsMessage> = new Map();
constructor() {}
getId(id: string, user?: string) {
return id + '/' + user;
}
register(id: string, opts?: { ws: WebSocket; user: string }) {
const _id = this.getId(id, opts?.user || '');
if (this.wssMap.has(_id)) {
const value = this.wssMap.get(_id);
if (value) {
value.ws.close();
}
}
const value = new WsMessage({ ws: opts?.ws, user: opts?.user });
this.wssMap.set(_id, value);
}
unregister(id: string, user?: string) {
const _id = this.getId(id, user || '');
const value = this.wssMap.get(_id);
if (value) {
value.ws.close();
}
this.wssMap.delete(_id);
}
getIds() {
return Array.from(this.wssMap.keys());
}
get(id: string, user?: string) {
if (user) {
const _id = this.getId(id, user);
return this.wssMap.get(_id);
}
return this.wssMap.get(id);
}
}

View File

@@ -0,0 +1,44 @@
import { IncomingMessage, ServerResponse } from 'http';
import { wsProxyManager } from './index.ts';
import { App } from '@kevisual/router';
import { logger } from '../logger.ts';
import { getLoginUser } from '@/middleware/auth.ts';
type ProxyOptions = {
createNotFoundPage: (msg?: string) => any;
};
export const UserV1Proxy = async (req: IncomingMessage, res: ServerResponse, opts?: ProxyOptions) => {
const { url } = req;
const { pathname } = new URL(url || '', `http://localhost`);
const [user, app, userAppKey] = pathname.split('/').slice(1);
if (!user || !app || !userAppKey) {
opts?.createNotFoundPage?.('应用未找到');
return false;
}
const data = await App.handleRequest(req, res);
const loginUser = await getLoginUser(req);
if (!loginUser) {
opts?.createNotFoundPage?.('没有登录');
return false;
}
if (loginUser.tokenUser?.username !== user) {
opts?.createNotFoundPage?.('没有访问应用权限');
return false;
}
logger.debug('data', data);
const client = wsProxyManager.get(userAppKey, user);
const ids = wsProxyManager.getIds();
if (!client) {
opts?.createNotFoundPage?.(`未找到应用, ${userAppKey}, ${ids.join(',')}`);
return false;
}
const value = await client.sendData(data);
if (value) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(value));
return true;
}
opts?.createNotFoundPage?.('应用未启动');
return true;
};

View File

@@ -4,6 +4,7 @@ import { redis } from '@/module/redis/redis.ts';
import fs from 'fs';
import { fileStore } from '../../module/config.ts';
import { getAppLoadStatus } from '@/module/redis/get-app-status.ts';
import { getLoginUser } from '@/middleware/auth.ts';
export class CenterUserApp {
user: string;
@@ -63,9 +64,15 @@ app
})
.define(async (ctx) => {
const { user } = ctx.query;
if (user !== 'admin') {
ctx.throw('Not Found');
const loginUser = await getLoginUser(ctx.req);
if (loginUser) {
const root = ['admin', 'root'];
if (root.includes(loginUser.tokenUser?.username)) {
return;
}
ctx.throw(401, 'No Proxy App Permission');
}
ctx.throw(401, 'No Login And No Proxy App Permission');
})
.addTo(app);

11
src/utils/get-user.ts Normal file
View File

@@ -0,0 +1,11 @@
import { IncomingMessage, ServerResponse } from 'http';
export const getUserFromRequest = (req: IncomingMessage) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const pathname = url.pathname;
const keys = pathname.split('/');
const [_, user, app] = keys;
return {
user,
app,
};
};