Compare commits
43 Commits
02c505c83a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fac9ac8f12 | |||
| 20b4540ac1 | |||
| 970f2a18fa | |||
| b094fb618a | |||
| 569446b99a | |||
| 2b9f238e41 | |||
| a9f0170775 | |||
| 4d39b22bdd | |||
| 1c7815ca26 | |||
| 4d9df0fee3 | |||
| 28a5d82e52 | |||
| 1e2f891f31 | |||
| ae459eabfe | |||
| 46cb3d4e75 | |||
| d43bbba8ad | |||
| 30589e3347 | |||
| 3938a0f87e | |||
| cf82967060 | |||
| 98d6dfb6db | |||
| 5de857aca8 | |||
| f2cc76a8ea | |||
| b4fc1a545c | |||
| e15d0be1e1 | |||
| c9a3f7fda9 | |||
| 3b32ba7244 | |||
| 912d3196cf | |||
| 87014f4d74 | |||
| 4e44685f43 | |||
| 69b5a02449 | |||
| 50fab1e6df | |||
| f4af7d5c7f | |||
| 29d0709083 | |||
| e99a584887 | |||
| feaad567b4 | |||
| 0ac2d6518c | |||
| 3d6ce4cbf4 | |||
| 07cfa1dded | |||
| bb86a7c507 | |||
| d548d3ab04 | |||
| cad914eead | |||
| 2fff202ed8 | |||
| 1662fd4dfa | |||
| 0b47b38060 |
34
.env.example
Normal file
34
.env.example
Normal file
@@ -0,0 +1,34 @@
|
||||
# 代理配置
|
||||
PROXY_PORT=3005
|
||||
PROXY_DOMAIN=localhost
|
||||
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
|
||||
MINIO_PORT=9000
|
||||
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=
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -11,3 +11,12 @@ release/*
|
||||
!release/.gitkeep
|
||||
|
||||
/*.tgz
|
||||
|
||||
proxy-upload/*
|
||||
proxy-upload/.gitkeep
|
||||
|
||||
.env
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
pack-dist
|
||||
2
.npmrc
2
.npmrc
@@ -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
|
||||
@@ -1,11 +1,16 @@
|
||||
# page proxy
|
||||
|
||||
# 部署方案
|
||||
page proxy的功能是,第一点对后端的接口进行代理,第二点是页面的browser路由的内容进行代理,第三点,对minio的资源的内容进行代理。
|
||||
|
||||
属于网关功能,page的proxy功能,api的代理功能,和code-center融合成一个整体的页面的功能。code-center负责api,page-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=******
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
api: {
|
||||
host: 'localhost:4002', // 后台代理
|
||||
path: '/api/router',
|
||||
},
|
||||
proxy: {
|
||||
port: 3005,
|
||||
domain: 'kevisual.xiongxiao.me',
|
||||
resources: 'https://minio.xiongxiao.me/resources',
|
||||
allowedOrigins: ['localhost', 'xiongxiao.me', 'zxj.im', 'silkyai.cn'],
|
||||
},
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
port: 3005,
|
||||
api: {
|
||||
host: 'localhost:3000', // 后台代理
|
||||
path: '/api/router',
|
||||
},
|
||||
allowedOrigins: ['localhost', 'xiongxiao.me', 'zxj.im'],
|
||||
domain: 'demo.kevisual.xiongxiao.me',
|
||||
resources: 'localhost:9000/resources',
|
||||
}
|
||||
37
bun.config.mjs
Normal file
37
bun.config.mjs
Normal 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_*',
|
||||
// });
|
||||
67
package.json
67
package.json
@@ -1,51 +1,78 @@
|
||||
{
|
||||
"name": "page-proxy",
|
||||
"version": "0.0.1",
|
||||
"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": {
|
||||
"dev": "cross-env NODE_ENV=development nodemon --ignore upload --exec tsx src/index.ts",
|
||||
"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 switch root && envision pack -p -u",
|
||||
"pub": "npm run build && npm run deploy"
|
||||
"deploy": "envision pack -p -u",
|
||||
"pub": "npm run build && npm run deploy",
|
||||
"ssl": "ssh -L 6379:localhost:6379 light"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^28.0.2",
|
||||
"@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.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.4",
|
||||
"@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.34.8",
|
||||
"nodemon": "^3.1.10",
|
||||
"rollup": "^4.41.1",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.7.3"
|
||||
"typescript": "^5.8.3",
|
||||
"ws": "npm:@kevisual/ws"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kevisual/router": "0.0.6-alpha-5",
|
||||
"@kevisual/use-config": "^1.0.7",
|
||||
"ioredis": "^5.5.0",
|
||||
"nanoid": "^5.1.0"
|
||||
"@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.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.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"minio": "^8.0.5",
|
||||
"nanoid": "^5.1.5",
|
||||
"send": "^1.2.0",
|
||||
"sequelize": "^6.37.7"
|
||||
},
|
||||
"resolutions": {
|
||||
"picomatch": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"pnpm": {}
|
||||
}
|
||||
2569
pnpm-lock.yaml
generated
2569
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -25,5 +25,5 @@ export default {
|
||||
declaration: false,
|
||||
}), // 使用 @rollup/plugin-typescript 处理 TypeScript 文件
|
||||
],
|
||||
external: ['ioredis', '@kevisual/router', '@kevisual/use-config'],
|
||||
external: ['ioredis', '@kevisual/router', '@kevisual/use-config', 'sequelize'],
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { App } from '@kevisual/router';
|
||||
|
||||
// import { redis } from './module/redis/redis.ts';
|
||||
export const app = new App({
|
||||
serverOptions: {
|
||||
path: '/api/proxy',
|
||||
|
||||
53
src/index.ts
53
src/index.ts
@@ -2,6 +2,9 @@ import { handleRequest } from './module/index.ts';
|
||||
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
|
||||
@@ -19,3 +22,53 @@ app.listen(port, () => {
|
||||
});
|
||||
|
||||
app.server.on(handleRequest);
|
||||
|
||||
const wssApp = new WssApp();
|
||||
const main = () => {
|
||||
console.log('Upgrade initialization started');
|
||||
|
||||
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));
|
||||
|
||||
if (proxyApi) {
|
||||
const _u = new URL(req.url, `${proxyApi.target}`);
|
||||
const options = {
|
||||
hostname: _u.hostname,
|
||||
port: Number(_u.port) || 80,
|
||||
path: _u.pathname,
|
||||
headers: req.headers,
|
||||
};
|
||||
|
||||
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`,
|
||||
);
|
||||
proxySocket.pipe(socket);
|
||||
socket.pipe(proxySocket);
|
||||
});
|
||||
|
||||
proxySocket.on('error', (err) => {
|
||||
console.error(`WebSocket proxy error: ${err.message}`);
|
||||
socket.end();
|
||||
});
|
||||
} else {
|
||||
socket.end();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
main();
|
||||
}, 1200);
|
||||
|
||||
66
src/middleware/auth.ts
Normal file
66
src/middleware/auth.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
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 });
|
||||
};
|
||||
export const checkAuth = async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||
let token = (req.headers?.['authorization'] as string) || (req.headers?.['Authorization'] as string) || '';
|
||||
const url = new URL(req.url || '', 'http://localhost');
|
||||
const resNoPermission = () => {
|
||||
res.statusCode = 401;
|
||||
res.end(error('Invalid authorization'));
|
||||
return { tokenUser: null, token: null };
|
||||
};
|
||||
if (!token) {
|
||||
token = url.searchParams.get('token') || '';
|
||||
}
|
||||
if (!token) {
|
||||
const parsedCookies = cookie.parse(req.headers.cookie || '');
|
||||
token = parsedCookies.token || '';
|
||||
}
|
||||
if (!token) {
|
||||
return resNoPermission();
|
||||
}
|
||||
if (token) {
|
||||
token = token.replace('Bearer ', '');
|
||||
}
|
||||
let tokenUser;
|
||||
try {
|
||||
tokenUser = await User.verifyToken(token);
|
||||
} catch (e) {
|
||||
console.log('checkAuth error', e);
|
||||
res.statusCode = 401;
|
||||
res.end(error('Invalid token'));
|
||||
return { tokenUser: null, token: null };
|
||||
}
|
||||
return { tokenUser, token };
|
||||
};
|
||||
|
||||
export const getLoginUser = async (req: http.IncomingMessage) => {
|
||||
let token = (req.headers?.['authorization'] as string) || (req.headers?.['Authorization'] as string) || '';
|
||||
const url = new URL(req.url || '', 'http://localhost');
|
||||
if (!token) {
|
||||
token = url.searchParams.get('token') || '';
|
||||
}
|
||||
if (!token) {
|
||||
const parsedCookies = cookie.parse(req.headers.cookie || '');
|
||||
token = parsedCookies.token || '';
|
||||
}
|
||||
|
||||
if (token) {
|
||||
token = token.replace('Bearer ', '');
|
||||
}
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
let tokenUser;
|
||||
logger.debug('getLoginUser', token);
|
||||
try {
|
||||
tokenUser = await User.verifyToken(token);
|
||||
return { tokenUser, token };
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useConfig } from '@kevisual/use-config';
|
||||
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: {
|
||||
@@ -11,6 +12,17 @@ type ConfigType = {
|
||||
path?: string;
|
||||
port?: number;
|
||||
};
|
||||
apiList: {
|
||||
path: string;
|
||||
/**
|
||||
* url或者相对路径
|
||||
*/
|
||||
target: string;
|
||||
/**
|
||||
* 类型
|
||||
*/
|
||||
type?: 'static' | 'dynamic' | 'minio';
|
||||
}[];
|
||||
proxy: {
|
||||
port?: number;
|
||||
/**
|
||||
@@ -26,8 +38,51 @@ type ConfigType = {
|
||||
* allow origin xiongxiao.me zxj.im silkyai.cn
|
||||
* 允许跨域访问的地址
|
||||
*/
|
||||
allowOrigin: string[];
|
||||
allowedOrigin: string[];
|
||||
};
|
||||
stat: {
|
||||
/**
|
||||
* 统计网站ID
|
||||
*/
|
||||
websiteId: string;
|
||||
};
|
||||
redis?: {
|
||||
host: string;
|
||||
port: number;
|
||||
password?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const config = useConfig<ConfigType>();
|
||||
// export const config = useConfig();
|
||||
export const envConfig = useConfig() as any;
|
||||
export const config: ConfigType = {
|
||||
api: {
|
||||
host: envConfig.API_HOST,
|
||||
path: envConfig.API_PATH,
|
||||
port: envConfig.PROXY_PORT,
|
||||
},
|
||||
apiList: [
|
||||
{
|
||||
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: 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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
@@ -7,17 +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 } 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 status: { [key: string]: boolean } = {};
|
||||
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',
|
||||
type: 'oss', // oss, 默认是oss
|
||||
data: {
|
||||
files: [
|
||||
{
|
||||
@@ -51,13 +61,38 @@ export class UserApp {
|
||||
this.isTest = true;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 是否已经加载到本地了
|
||||
* @returns
|
||||
*/
|
||||
async getExist() {
|
||||
const app = this.app;
|
||||
const user = this.user;
|
||||
const key = 'user:app:exist:' + app + ':' + user;
|
||||
const permissionKey = 'user:app:permission:' + app + ':' + user;
|
||||
const value = await redis.get(key);
|
||||
return value;
|
||||
const permission = await redis.get(permissionKey);
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
const [indexFilePath, etag, proxy] = value.split('||');
|
||||
try {
|
||||
return {
|
||||
indexFilePath,
|
||||
etag,
|
||||
proxy: proxy === 'true',
|
||||
permission: permission ? JSON.parse(permission) : { share: 'private' },
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('getExist error parse', e);
|
||||
await this.clearCacheData();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 获取缓存数据,不存在不会加载
|
||||
* @returns
|
||||
*/
|
||||
async getCache() {
|
||||
const app = this.app;
|
||||
const user = this.user;
|
||||
@@ -73,6 +108,8 @@ export class UserApp {
|
||||
const user = this.user;
|
||||
const key = 'user:app:set:' + app + ':' + user;
|
||||
const value = await redis.hget(key, appFileUrl);
|
||||
// const values = await redis.hgetall(key);
|
||||
// console.log('getFile', values);
|
||||
return value;
|
||||
}
|
||||
static async getDomainApp(domain: string) {
|
||||
@@ -106,41 +143,66 @@ export class UserApp {
|
||||
user: fetchData.user,
|
||||
app: fetchData.key,
|
||||
};
|
||||
redis.set(key, data.user + ':' + data.app, 'EX', 60 * 60 * 24 * 7); // 24小时
|
||||
redis.set(key, data.user + ':' + data.app, 'EX', 60 * 60 * 24 * 7); // 7天
|
||||
|
||||
const userDomainApp = 'user:domain:app:' + data.user + ':' + data.app;
|
||||
|
||||
const domainKeys = await redis.get(userDomainApp);
|
||||
let domainKeysList = domainKeys ? JSON.parse(domainKeys) : [];
|
||||
domainKeysList.push(domain);
|
||||
const uniq = (arr: string[]) => {
|
||||
return [...new Set(arr)];
|
||||
};
|
||||
domainKeysList = uniq(domainKeysList);
|
||||
await redis.set(userDomainApp, JSON.stringify(domainKeysList), 'EX', 60 * 60 * 24 * 7); // 7天
|
||||
return data;
|
||||
}
|
||||
async setLoaded() {
|
||||
/**
|
||||
* 加载结束
|
||||
* @param msg
|
||||
*/
|
||||
async setLoaded(status: 'running' | 'error' | 'loading', msg?: string) {
|
||||
const app = this.app;
|
||||
const user = this.user;
|
||||
const key = 'user:app:' + app + ':' + user;
|
||||
if (status[key]) {
|
||||
status[key] = false;
|
||||
}
|
||||
await setAppLoadStatus(user, app, {
|
||||
status,
|
||||
message: msg,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 获取加载状态
|
||||
* @returns
|
||||
*/
|
||||
async getLoaded() {
|
||||
const app = this.app;
|
||||
const user = this.user;
|
||||
const key = 'user:app:' + app + ':' + user;
|
||||
return status[key];
|
||||
const value = await getAppLoadStatus(user, app);
|
||||
return value;
|
||||
}
|
||||
/**
|
||||
* 设置缓存数据,当出问题了,就重新加载。
|
||||
* @returns
|
||||
*/
|
||||
async setCacheData() {
|
||||
const app = this.app;
|
||||
const user = this.user;
|
||||
const isTest = this.isTest;
|
||||
const key = 'user:app:' + app + ':' + user;
|
||||
if (status[key]) {
|
||||
const fetchRes = isTest ? await fetchTest(app) : await fetchApp({ user, app });
|
||||
if (fetchRes?.code !== 200) {
|
||||
console.log('fetchRes is error', fetchRes, 'user', user, 'app', app);
|
||||
return { code: 500, message: 'fetchRes is error' };
|
||||
}
|
||||
|
||||
const loadStatus = await getAppLoadStatus(user, app);
|
||||
logger.debug('loadStatus', loadStatus);
|
||||
if (loadStatus.status === 'loading') {
|
||||
// 其他情况,error或者running都可以重新加载
|
||||
return {
|
||||
loading: true,
|
||||
};
|
||||
}
|
||||
status[key] = true;
|
||||
|
||||
const fetchRes = isTest ? await fetchTest(app) : await fetchApp({ user, app });
|
||||
if (fetchRes?.code !== 200) {
|
||||
console.log('fetchRes is error', fetchRes);
|
||||
this.setLoaded();
|
||||
return { code: 500, message: 'fetchRes is error' };
|
||||
}
|
||||
const fetchData = fetchRes.data;
|
||||
if (!fetchData.type) {
|
||||
// console.error('fetchData type is error', fetchData);
|
||||
@@ -149,50 +211,88 @@ export class UserApp {
|
||||
}
|
||||
if (fetchData.status !== 'running') {
|
||||
console.error('fetchData status is not running', fetchData.user, fetchData.key);
|
||||
this.setLoaded();
|
||||
return {
|
||||
code: 500,
|
||||
message: 'fetchData status is not running',
|
||||
};
|
||||
}
|
||||
const value = await downloadUserAppFiles(user, app, fetchData);
|
||||
if (value.data.files.length === 0) {
|
||||
console.error('root files length is zero', user, app);
|
||||
this.setLoaded();
|
||||
return { code: 404 };
|
||||
}
|
||||
let valueIndexHtml = value.data.files.find((file) => file.name === 'index.html');
|
||||
if (!valueIndexHtml) {
|
||||
valueIndexHtml = value.data.files.find((file) => file.name === 'index.js');
|
||||
if (!valueIndexHtml) {
|
||||
valueIndexHtml = value.data.files[0];
|
||||
}
|
||||
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;
|
||||
await redis.set(key, JSON.stringify(value));
|
||||
await redis.set('user:app:exist:' + app + ':' + user, valueIndexHtml.path, 'EX', 60 * 60 * 24 * 7); // 24小时
|
||||
const files = value.data.files;
|
||||
// await redis.hset(key, 'files', JSON.stringify(files));
|
||||
const version = value.version;
|
||||
let indexHtml = resources + '/' + user + '/' + app + '/' + version + '/index.html';
|
||||
const files = value?.data?.files || [];
|
||||
const permission = value?.data?.permission || { share: 'private' };
|
||||
const data = {};
|
||||
|
||||
// 将文件名和路径添加到 `data` 对象中
|
||||
files.forEach((file) => {
|
||||
data[file.name] = file.path;
|
||||
});
|
||||
await redis.hset('user:app:set:' + app + ':' + user, data);
|
||||
this.setLoaded();
|
||||
|
||||
return { code: 200, data: valueIndexHtml.path };
|
||||
if (file.name === 'index.html') {
|
||||
indexHtml = wrapperResources(resources, file.path);
|
||||
}
|
||||
async getAllCacheData() {
|
||||
const app = this.app;
|
||||
const user = this.user;
|
||||
const key = 'user:app:' + app + ':' + user;
|
||||
const value = await redis.get(key);
|
||||
console.log('getAllCacheData', JSON.parse(value));
|
||||
const exist = await redis.get('user:app:exist:' + app + ':' + user);
|
||||
console.log('getAllCacheData:exist', exist);
|
||||
const files = await redis.hgetall('user:app:set:' + app + ':' + user);
|
||||
console.log('getAllCacheData:files', files);
|
||||
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天
|
||||
await redis.hset('user:app:set:' + app + ':' + user, data);
|
||||
this.setLoaded('running', 'loaded');
|
||||
};
|
||||
const loadFilesFn = async () => {
|
||||
const value = await downloadUserAppFiles(user, app, fetchData);
|
||||
if (value.data.files.length === 0) {
|
||||
console.error('root files length is zero', user, app);
|
||||
this.setLoaded('running', 'root files length is zero');
|
||||
const mockPath = path.join(fileStore, user, app, 'index.html');
|
||||
value.data.files = [
|
||||
{
|
||||
name: 'index.html', // 映射
|
||||
path: mockPath.replace(fileStore, ''), // 实际
|
||||
},
|
||||
];
|
||||
if (!checkFileExistsSync(path.join(fileStore, user, app))) {
|
||||
fs.mkdirSync(path.join(fileStore, user, app), { recursive: true });
|
||||
}
|
||||
// 自己创建一个index.html
|
||||
fs.writeFileSync(path.join(fileStore, user, app, 'index.html'), 'not has any app info', {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
}
|
||||
await redis.set(key, JSON.stringify(value));
|
||||
const files = value.data.files;
|
||||
const permission = fetchData?.data?.permission || { share: 'private' };
|
||||
const data = {};
|
||||
let indexHtml = path.join(fileStore, user, app, 'index.html') + '||etag||false';
|
||||
// 将文件名和路径添加到 `data` 对象中
|
||||
files.forEach((file) => {
|
||||
data[file.name] = file.path;
|
||||
if (file.name === 'index.html') {
|
||||
indexHtml = file.path;
|
||||
}
|
||||
});
|
||||
await redis.set('user:app:exist:' + app + ':' + user, indexHtml, 'EX', 60 * 60 * 24 * 7); // 7天
|
||||
await redis.set('user:app:permission:' + app + ':' + user, JSON.stringify(permission), 'EX', 60 * 60 * 24 * 7); // 7天
|
||||
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();
|
||||
return {
|
||||
code: 200,
|
||||
data: 'loaded',
|
||||
};
|
||||
} else {
|
||||
loadFilesFn();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('loadFilesFn error', e);
|
||||
this.setLoaded('error', 'loadFilesFn error');
|
||||
}
|
||||
return {
|
||||
code: 20000,
|
||||
data: 'loading',
|
||||
};
|
||||
}
|
||||
async clearCacheData() {
|
||||
const app = this.app;
|
||||
@@ -201,6 +301,18 @@ export class UserApp {
|
||||
await redis.del(key);
|
||||
await redis.del('user:app:exist:' + app + ':' + user);
|
||||
await redis.del('user:app:set:' + app + ':' + user);
|
||||
await redis.del('user:app:status:' + app + ':' + user);
|
||||
await redis.del('user:app:permission:' + app + ':' + user);
|
||||
const userDomainApp = 'user:domain:app:' + user + ':' + app;
|
||||
const domainKeys = await redis.get(userDomainApp);
|
||||
if (domainKeys) {
|
||||
const domainKeysList = JSON.parse(domainKeys);
|
||||
domainKeysList.forEach(async (domain: string) => {
|
||||
await redis.del('domain:' + domain);
|
||||
});
|
||||
}
|
||||
await redis.del(userDomainApp);
|
||||
|
||||
// 删除所有文件
|
||||
deleteUserAppFiles(user, app);
|
||||
}
|
||||
@@ -222,53 +334,38 @@ 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
|
||||
|
||||
if (data.type === 'oss') {
|
||||
let serverPath = new URL(resources).href;
|
||||
let hasIndexHtml = false;
|
||||
// server download 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 (file.name === 'index.html') {
|
||||
hasIndexHtml = true;
|
||||
}
|
||||
// 检查目录是否存在,如果不存在则创建
|
||||
if (!checkFileExistsSync(destDir)) {
|
||||
fs.mkdirSync(destDir, { recursive: true }); // 递归创建目录
|
||||
}
|
||||
fs.copyFileSync(copyFile, destFile);
|
||||
// const etag = await setEtag(fs.readFileSync(destFile, 'utf-8'));
|
||||
const downloadURL = wrapperResources(serverPath, file.path);
|
||||
// 下载文件到 destFile
|
||||
await downloadFile(downloadURL, destFile);
|
||||
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 + '/';
|
||||
// server download file
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const destFile = path.join(uploadFiles, file.name);
|
||||
const destDir = path.dirname(destFile); // 获取目标文件所在的目录路径
|
||||
// 检查目录是否存在,如果不存在则创建
|
||||
if (!checkFileExistsSync(destDir)) {
|
||||
fs.mkdirSync(destDir, { recursive: true }); // 递归创建目录
|
||||
}
|
||||
// 下载文件到 destFile
|
||||
await downloadFile(serverPath + file.path, destFile);
|
||||
const etag = nanoid();
|
||||
if (!hasIndexHtml) {
|
||||
newFiles.push({
|
||||
name: file.name,
|
||||
path: destFile.replace(fileStore, '') + '||' + etag,
|
||||
name: 'index.html',
|
||||
path: path.join(uploadFiles, 'index.html'),
|
||||
});
|
||||
fs.writeFileSync(path.join(uploadFiles, 'index.html'), JSON.stringify(files), {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -303,13 +400,18 @@ export const deleteUserAppFiles = async (user: string, app: string) => {
|
||||
}
|
||||
// console.log('deleteUserAppFiles', res);
|
||||
};
|
||||
|
||||
async function downloadFile(fileUrl: string, destFile: string) {
|
||||
if (fileUrl.startsWith(minioResources)) {
|
||||
await downloadFileFromMinio(fileUrl, destFile);
|
||||
return;
|
||||
}
|
||||
console.log('destFile', destFile, 'fileUrl', fileUrl);
|
||||
const res = await fetch(fileUrl);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch ${fileUrl}: ${res.statusText}`);
|
||||
}
|
||||
console.log('destFile', destFile);
|
||||
const destStream = fs.createWriteStream(destFile);
|
||||
|
||||
// 使用 `pipeline` 将 `res.body` 中的数据传递给 `destStream`
|
||||
|
||||
199
src/module/html/create-refresh-html.ts
Normal file
199
src/module/html/create-refresh-html.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 创建一个刷新页面,定时
|
||||
* fetch('/api/proxy/refresh?user=user&app=app'), 如果返回200,则刷新页面
|
||||
* @param {string} user - 用户名
|
||||
* @param {string} app - 应用名
|
||||
* @returns {string} - HTML字符串
|
||||
*/
|
||||
export const createRefreshHtml = (user, app) => {
|
||||
return `
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>App: ${user}/${app}</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary-color: #4f46e5;
|
||||
--primary-hover: #4338ca;
|
||||
--text-color: #1f2937;
|
||||
--bg-color: #f9fafb;
|
||||
--card-bg: #ffffff;
|
||||
--border-color: #e5e7eb;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.app-icon {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
background-color: #f3f4f6;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 3px solid rgba(79, 70, 229, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--primary-color);
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.refresh-link {
|
||||
display: inline-block;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
margin-top: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.refresh-link:hover {
|
||||
background-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-size: 0.9rem;
|
||||
color: #6b7280;
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.count-number {
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 2rem;
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--primary-color: #6366f1;
|
||||
--primary-hover: #818cf8;
|
||||
--text-color: #f9fafb;
|
||||
--bg-color: #111827;
|
||||
--card-bg: #1f2937;
|
||||
--border-color: #374151;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
background-color: #374151;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1 class="app-title">
|
||||
<span class="app-icon">📱</span>
|
||||
${user}/${app}
|
||||
</h1>
|
||||
|
||||
<div class="status-card">
|
||||
<p class="loading-text">正在加载应用...</p>
|
||||
<div class="loading-spinner"></div>
|
||||
<p>应用正在启动中,请稍候</p>
|
||||
</div>
|
||||
|
||||
<p>如果长时间没有加载出来,请手动 <a class="refresh-link" href="javascript:void(0)" onclick="window.location.reload()">刷新页面</a></p>
|
||||
|
||||
<div class="counter">
|
||||
<span>检查次数:</span>
|
||||
<span id="loadCount" class="count-number">0</span>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
© ${new Date().getFullYear()} ${user}/${app} - 自动刷新页面
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
let count = 0;
|
||||
const refresh = () => {
|
||||
const origin = window.location.origin;
|
||||
const loadCount = document.getElementById('loadCount');
|
||||
count++;
|
||||
loadCount.innerHTML = count.toString();
|
||||
|
||||
fetch(origin + '/api/router?user=${user}&app=${app}&path=page-proxy-app&key=status')
|
||||
.then((res) => {
|
||||
if (res.status === 200) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
setTimeout(refresh, 3000);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error checking app status:', error);
|
||||
setTimeout(refresh, 5000); // Longer timeout on error
|
||||
});
|
||||
};
|
||||
|
||||
// Start checking after a short delay
|
||||
setTimeout(refresh, 2000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
BIN
src/module/html/favicon.ico
Normal file
BIN
src/module/html/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
17
src/module/html/stat/index.ts
Normal file
17
src/module/html/stat/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { config } from '../../config.ts';
|
||||
|
||||
/**
|
||||
* 添加统计脚本
|
||||
* @param html
|
||||
* @returns
|
||||
*/
|
||||
export const addStat = (html: string, addStat = true) => {
|
||||
if (!addStat) {
|
||||
return html;
|
||||
}
|
||||
const { websiteId } = config.stat || {};
|
||||
if (!websiteId) {
|
||||
return html;
|
||||
}
|
||||
return html.replace('</head>', `<script defer src="https://umami.xiongxiao.me/script.js" data-website-id="${websiteId}"></script></head>`);
|
||||
};
|
||||
@@ -1,30 +1,79 @@
|
||||
import { getDNS, isLocalhost } from '@/utils/dns.ts';
|
||||
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';
|
||||
import fs from 'fs';
|
||||
import { getContentType } from './get-content-type.ts';
|
||||
import { sleep } from '@/utils/sleep.ts';
|
||||
|
||||
const api = config?.api || { host: 'kevisual.xiongxiao.me', path: '/api/router' };
|
||||
const domain = config?.proxy?.domain || 'kevisual.xiongxiao.me';
|
||||
const allowedOrigins = config?.proxy?.allowOrigin || [];
|
||||
|
||||
import { createRefreshHtml } from './html/create-refresh-html.ts';
|
||||
import { fileProxy } from './proxy/file-proxy.ts';
|
||||
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';
|
||||
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');
|
||||
if (req.url === '/favicon.ico') {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.write('proxy no favicon.ico\n');
|
||||
res.writeHead(200, { 'Content-Type': 'image/x-icon' });
|
||||
res.end('proxy no favicon.ico\n');
|
||||
return;
|
||||
}
|
||||
if (req.url.startsWith('/api/proxy')) {
|
||||
// 已经代理过了
|
||||
return;
|
||||
}
|
||||
if (req.url.startsWith('/api')) {
|
||||
// 代理到 http://codeflow.xiongxiao.me/api
|
||||
const _u = new URL(req.url, `http://${api.host}`);
|
||||
const proxyApiList = config?.apiList || [];
|
||||
const proxyApi = proxyApiList.find((item) => req.url.startsWith(item.path));
|
||||
if (proxyApi && proxyApi?.type === 'static') {
|
||||
return fileProxy(req, res, proxyApi);
|
||||
}
|
||||
if (proxyApi) {
|
||||
const _u = new URL(req.url, `${proxyApi.target}`);
|
||||
// 设置代理请求的目标 URL 和请求头
|
||||
let header: any = {};
|
||||
if (req.headers?.['Authorization']) {
|
||||
@@ -32,9 +81,15 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
|
||||
} else if (req.headers?.['authorization']) {
|
||||
header.authorization = req.headers['authorization'];
|
||||
}
|
||||
if (req.headers?.['Content-Type']) {
|
||||
header['Content-Type'] = req.headers?.['Content-Type'];
|
||||
}
|
||||
// 提取req的headers中的非HOST的header
|
||||
const headers = Object.keys(req.headers).filter((item) => item && item.toLowerCase() !== 'host');
|
||||
const host = req.headers['host'];
|
||||
logger.info('proxy host', host);
|
||||
logger.info('headers', headers);
|
||||
|
||||
headers.forEach((item) => {
|
||||
header[item] = req.headers[item];
|
||||
});
|
||||
const options = {
|
||||
host: _u.hostname,
|
||||
path: req.url,
|
||||
@@ -47,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);
|
||||
// 将代理响应流写入客户端响应
|
||||
@@ -56,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}`);
|
||||
});
|
||||
@@ -64,7 +127,7 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
|
||||
req.pipe(proxyReq, { end: true });
|
||||
return;
|
||||
}
|
||||
if (req.url.startsWith('/api')) {
|
||||
if (req.url.startsWith('/api') || req.url.startsWith('/v1')) {
|
||||
res.end('not catch api');
|
||||
return;
|
||||
}
|
||||
@@ -81,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;
|
||||
@@ -91,20 +154,27 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
|
||||
// app = 'codeflow';
|
||||
// domainApp = true;
|
||||
} else {
|
||||
if (isIpv4OrIpv6(dns.hostName)) {
|
||||
// 打印出 req.url 和错误信息
|
||||
console.error('Invalid domain: ', req.url, dns.hostName);
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.end('Invalid domain\n');
|
||||
return res.end();
|
||||
}
|
||||
// 验证域名
|
||||
if (dns.hostName !== domain) {
|
||||
if (domain && dns.hostName !== domain) {
|
||||
// redis获取域名对应的用户和应用
|
||||
domainApp = true;
|
||||
const data = await UserApp.getDomainApp(dns.hostName);
|
||||
if (!data) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.write('Invalid domain\n');
|
||||
return res.end();
|
||||
res.end('Invalid domain\n');
|
||||
return;
|
||||
}
|
||||
if (!data.user || !data.app) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
res.write('Invalid domain config\n');
|
||||
return res.end();
|
||||
res.end('Invalid domain config\n');
|
||||
return;
|
||||
}
|
||||
user = data.user;
|
||||
app = data.app;
|
||||
@@ -112,8 +182,21 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
|
||||
}
|
||||
// const url = req.url;
|
||||
const pathname = new URL(req.url, `http://${dns.hostName}`).pathname;
|
||||
|
||||
/**
|
||||
* url是pathname的路径
|
||||
*/
|
||||
const url = pathname;
|
||||
if (!domainApp && noProxyUrl.includes(url)) {
|
||||
if (url === '/') {
|
||||
// TODO: 获取一下登陆用户,如果没有登陆用户,重定向到ai-chat页面
|
||||
// 重定向到
|
||||
// res.writeHead(302, { Location: home });
|
||||
// return res.end();
|
||||
rediretHome(req, res);
|
||||
return;
|
||||
}
|
||||
// 不是域名代理,且是在不代理的url当中
|
||||
res.write('No proxy for this URL\n');
|
||||
return res.end();
|
||||
}
|
||||
@@ -139,58 +222,105 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
|
||||
user = _user;
|
||||
app = _app;
|
||||
}
|
||||
const createRefreshPage = (user, app) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(createRefreshHtml(user, app));
|
||||
};
|
||||
const createErrorPage = () => {
|
||||
res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.write('Server Error\n');
|
||||
res.end();
|
||||
};
|
||||
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, data } = await userApp.setCacheData();
|
||||
if (loading) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.write('Loading App\n');
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (code !== 200) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/html' });
|
||||
res.write('Not Found App\n');
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
await sleep(1000);
|
||||
isExist = data; // 设置缓存后再次获取
|
||||
if (!isExist) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/html' });
|
||||
res.write('Not Found App Index Page\n');
|
||||
res.end();
|
||||
return;
|
||||
const { code, loading, message } = await userApp.setCacheData();
|
||||
if (loading || code === 20000) {
|
||||
return createRefreshPage(user, app);
|
||||
} else if (code === 500) {
|
||||
return createNotFoundPage(message || 'Not Found App\n');
|
||||
} else if (code !== 200) {
|
||||
return createErrorPage();
|
||||
}
|
||||
isExist = await userApp.getExist();
|
||||
} catch (error) {
|
||||
console.error('setCacheData error', error);
|
||||
res.writeHead(500, { 'Content-Type': 'text/html' });
|
||||
res.write('Server Error\n');
|
||||
res.end();
|
||||
userApp.setLoaded();
|
||||
createErrorPage();
|
||||
userApp.setLoaded('error', 'setCacheData error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const indexFile = isExist; // 已经必定存在了
|
||||
if (!isExist) {
|
||||
return createNotFoundPage();
|
||||
}
|
||||
|
||||
if (!checkNotAuthPath(user, app)) {
|
||||
const { permission } = isExist;
|
||||
const permissionInstance = new UserPermission({ permission, owner: user });
|
||||
const loginUser = await getLoginUser(req);
|
||||
const checkPermission = permissionInstance.checkPermissionSuccess({
|
||||
username: loginUser?.tokenUser?.username || '',
|
||||
password: password,
|
||||
});
|
||||
if (!checkPermission.success) {
|
||||
return createNotFoundPage('no permission');
|
||||
}
|
||||
}
|
||||
const indexFile = isExist.indexFilePath; // 已经必定存在了
|
||||
try {
|
||||
let appFileUrl: string;
|
||||
if (domainApp) {
|
||||
appFileUrl = (url + '').replace(`/`, '');
|
||||
} else {
|
||||
appFileUrl = (url + '').replace(`/${user}/${app}/`, '');
|
||||
}
|
||||
const appFile = await userApp.getFile(appFileUrl);
|
||||
appFileUrl = decodeURIComponent(appFileUrl); // Decode URL components
|
||||
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')) {
|
||||
return createNotFoundPage('Invalid proxy url');
|
||||
}
|
||||
console.log('proxyUrl', appFileUrl, proxyUrl);
|
||||
httpProxy(req, res, {
|
||||
proxyUrl,
|
||||
userApp,
|
||||
createNotFoundPage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
console.log('appFile', appFile, appFileUrl, isExist);
|
||||
// console.log('isExist', isExist);
|
||||
if (!appFile) {
|
||||
const [indexFilePath, etag] = indexFile.split('||');
|
||||
// console.log('indexFilePath', indexFile, path.join(fileStore, indexFilePath));
|
||||
const contentType = getContentType(indexFilePath);
|
||||
const isHTML = contentType.includes('html');
|
||||
const filePath = path.join(fileStore, indexFilePath);
|
||||
if (!userApp.fileCheck(filePath)) {
|
||||
res.writeHead(500, { 'Content-Type': 'text/html' });
|
||||
res.write('File expired, Not Found\n');
|
||||
res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8', tips: 'App Cache expired, Please refresh' });
|
||||
res.write(createRefreshHtml(user, app));
|
||||
res.end();
|
||||
await userApp.clearCacheData();
|
||||
return;
|
||||
@@ -203,9 +333,13 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
|
||||
}
|
||||
// 不存在的文件,返回indexFile的文件
|
||||
res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': isHTML ? 'no-cache' : 'public, max-age=3600' });
|
||||
|
||||
if (isHTML) {
|
||||
const newHtml = await getTextFromStreamAndAddStat(fs.createReadStream(filePath));
|
||||
res.end(newHtml.html);
|
||||
} else {
|
||||
const readStream = fs.createReadStream(filePath);
|
||||
readStream.pipe(res);
|
||||
}
|
||||
|
||||
return;
|
||||
} else {
|
||||
@@ -225,22 +359,34 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
|
||||
const fileName = path.basename(appFilePath);
|
||||
res.setHeader('Content-Disposition', `attachment; filename=${fileName}`);
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': isHTML ? 'no-cache' : 'public, max-age=3600', // 设置缓存时间为 1 小时
|
||||
ETag: eTag,
|
||||
});
|
||||
|
||||
if (!userApp.fileCheck(filePath)) {
|
||||
console.error('File expired', filePath);
|
||||
res.writeHead(500, { 'Content-Type': 'text/html' });
|
||||
res.write('File expired\n');
|
||||
res.end();
|
||||
res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end('File expired\n');
|
||||
await userApp.clearCacheData();
|
||||
return;
|
||||
}
|
||||
let resContent = '';
|
||||
const headers = new Map<string, string>();
|
||||
headers.set('Content-Type', contentType);
|
||||
headers.set('Cache-Control', isHTML ? 'no-cache' : 'public, max-age=3600'); // 设置缓存时间为 1 小时
|
||||
headers.set('ETag', eTag);
|
||||
res?.setHeaders?.(headers);
|
||||
if (isHTML) {
|
||||
const newHtml = await getTextFromStreamAndAddStat(fs.createReadStream(filePath));
|
||||
resContent = newHtml.html;
|
||||
headers.set('Content-Length', newHtml.contentLength.toString());
|
||||
res.writeHead(200);
|
||||
res.end(resContent);
|
||||
} else {
|
||||
res.writeHead(200);
|
||||
const readStream = fs.createReadStream(filePath);
|
||||
readStream.pipe(res);
|
||||
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('getFile error', error);
|
||||
}
|
||||
};
|
||||
|
||||
2
src/module/logger.ts
Normal file
2
src/module/logger.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { Logger } from '@kevisual/logger/node';
|
||||
export const logger = new Logger();
|
||||
21
src/module/minio.ts
Normal file
21
src/module/minio.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Client } from 'minio';
|
||||
import { useConfig } from '@kevisual/use-config/env';
|
||||
const config = useConfig();
|
||||
const minioConfig = {
|
||||
bucketName: config.MINIO_BUCKET_NAME,
|
||||
endPoint: config.MINIO_ENDPOINT,
|
||||
port: config.MINIO_PORT,
|
||||
useSSL: config.MINIO_USE_SSL === 'true',
|
||||
accessKey: config.MINIO_ACCESS_KEY,
|
||||
secretKey: config.MINIO_SECRET_KEY,
|
||||
};
|
||||
// const config = useConfig<MinioConfig>();
|
||||
const { port, endPoint, useSSL } = minioConfig;
|
||||
export const minioUrl = `http${useSSL ? 's' : ''}://${endPoint}:${port || 9000}`;
|
||||
export const minioResources = `${minioUrl}/resources`;
|
||||
export const minioClient = new Client(minioConfig);
|
||||
export const bucketName = minioConfig.bucketName;
|
||||
|
||||
if (!minioClient) {
|
||||
throw new Error('Minio client not initialized');
|
||||
}
|
||||
8
src/module/models.ts
Normal file
8
src/module/models.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { User, UserInit, Org, OrgInit } from '@kevisual/code-center-module/models';
|
||||
|
||||
export { User, Org };
|
||||
|
||||
export const initModels = () => {
|
||||
OrgInit();
|
||||
UserInit();
|
||||
};
|
||||
292
src/module/proxy/ai-proxy.ts
Normal file
292
src/module/proxy/ai-proxy.ts
Normal 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);
|
||||
};
|
||||
26
src/module/proxy/file-proxy.ts
Normal file
26
src/module/proxy/file-proxy.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import http from 'http';
|
||||
import send from 'send';
|
||||
import fs from 'fs';
|
||||
import { fileIsExist } from '@kevisual/use-config';
|
||||
import path from 'path';
|
||||
export type ProxyInfo = {
|
||||
path?: string;
|
||||
target: string;
|
||||
type?: 'static' | 'dynamic' | 'minio';
|
||||
};
|
||||
export const fileProxy = (req: http.IncomingMessage, res: http.ServerResponse, proxyApi: ProxyInfo) => {
|
||||
// url开头的文件
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
const pathname = url.pathname;
|
||||
// 检测文件是否存在,如果文件不存在,则返回404
|
||||
const filePath = path.join(process.cwd(), proxyApi.target, pathname);
|
||||
if (!fileIsExist(filePath)) {
|
||||
res.statusCode = 404;
|
||||
res.end('Not Found File');
|
||||
return;
|
||||
}
|
||||
const file = send(req, pathname, {
|
||||
root: proxyApi.target,
|
||||
});
|
||||
file.pipe(res);
|
||||
};
|
||||
165
src/module/proxy/http-proxy.ts
Normal file
165
src/module/proxy/http-proxy.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { pipeline, Readable } from 'stream';
|
||||
import { promisify } from 'util';
|
||||
import { bucketName, minioClient, minioResources } from '../minio.ts';
|
||||
import fs from 'fs';
|
||||
import { IncomingMessage, ServerResponse } from 'http';
|
||||
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);
|
||||
|
||||
export async function downloadFileFromMinio(fileUrl: string, destFile: string) {
|
||||
const objectName = fileUrl.replace(minioResources + '/', '');
|
||||
const objectStream = await minioClient.getObject(bucketName, objectName);
|
||||
const destStream = fs.createWriteStream(destFile);
|
||||
await pipelineAsync(objectStream, destStream);
|
||||
console.log(`minio File downloaded to ${minioResources}/${objectName} \n ${destFile}`);
|
||||
}
|
||||
export const filterKeys = (metaData: Record<string, string>, clearKeys: string[] = []) => {
|
||||
const keys = Object.keys(metaData);
|
||||
// remove X-Amz- meta data
|
||||
const removeKeys = ['password', 'accesskey', 'secretkey', ...clearKeys];
|
||||
const filteredKeys = keys.filter((key) => !removeKeys.includes(key));
|
||||
return filteredKeys.reduce((acc, key) => {
|
||||
acc[key] = metaData[key];
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
};
|
||||
export async function minioProxy(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
opts: {
|
||||
proxyUrl: string;
|
||||
createNotFoundPage: (msg?: string) => any;
|
||||
isDownload?: boolean;
|
||||
},
|
||||
) {
|
||||
const fileUrl = opts.proxyUrl;
|
||||
const { createNotFoundPage, isDownload = false } = opts;
|
||||
const objectName = fileUrl.replace(minioResources + '/', '');
|
||||
try {
|
||||
const stat = await minioClient.statObject(bucketName, objectName);
|
||||
if (stat.size === 0) {
|
||||
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,
|
||||
etag,
|
||||
'last-modified': lastModified,
|
||||
'file-name': fileName,
|
||||
...filterMetaData,
|
||||
...getTextContentType(ext),
|
||||
};
|
||||
if (objectName.endsWith('.html') && !isDownload) {
|
||||
const { html, contentLength } = await getTextFromStreamAndAddStat(objectStream);
|
||||
res.writeHead(200, {
|
||||
...headers,
|
||||
'Content-Length': contentLength,
|
||||
});
|
||||
res.end(html);
|
||||
} else {
|
||||
res.writeHead(200, {
|
||||
...headers,
|
||||
});
|
||||
objectStream.pipe(res, { end: true });
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Proxy request error: ${error.message}`);
|
||||
createNotFoundPage('Invalid proxy url');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加一个辅助函数来从流中获取文本
|
||||
async function getTextFromStream(stream: Readable | IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
stream.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
stream.on('end', () => {
|
||||
resolve(data);
|
||||
});
|
||||
stream.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
export async function getTextFromStreamAndAddStat(stream: Readable | IncomingMessage): Promise<{ html: string; contentLength: number }> {
|
||||
const html = await getTextFromStream(stream);
|
||||
const newHtml = addStat(html);
|
||||
const newContentLength = Buffer.byteLength(newHtml);
|
||||
return { html: newHtml, contentLength: newContentLength };
|
||||
}
|
||||
export const httpProxy = async (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
opts: {
|
||||
proxyUrl: string;
|
||||
userApp: UserApp;
|
||||
createNotFoundPage: (msg?: string) => any;
|
||||
},
|
||||
) => {
|
||||
const { proxyUrl, userApp, createNotFoundPage } = opts;
|
||||
const _u = new URL(req.url, 'http://localhost');
|
||||
const params = _u.searchParams;
|
||||
const isDownload = params.get('download') === 'true';
|
||||
if (proxyUrl.startsWith(minioResources)) {
|
||||
const isOk = await minioProxy(req, res, { ...opts, isDownload });
|
||||
if (!isOk) {
|
||||
userApp.clearCacheData();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let protocol = proxyUrl.startsWith('https') ? https : http;
|
||||
// 代理
|
||||
const proxyReq = protocol.request(proxyUrl, async (proxyRes) => {
|
||||
const headers = proxyRes.headers;
|
||||
|
||||
if (proxyRes.statusCode === 404) {
|
||||
userApp.clearCacheData();
|
||||
return createNotFoundPage('Invalid proxy url');
|
||||
}
|
||||
if (proxyRes.statusCode === 302) {
|
||||
res.writeHead(302, { Location: proxyRes.headers.location });
|
||||
return res.end();
|
||||
}
|
||||
if (proxyUrl.endsWith('.html') && !isDownload) {
|
||||
try {
|
||||
const { html, contentLength } = await getTextFromStreamAndAddStat(proxyRes);
|
||||
res.writeHead(200, {
|
||||
...headers,
|
||||
'Content-Length': contentLength,
|
||||
});
|
||||
res.end(html);
|
||||
} catch (error) {
|
||||
console.error(`Proxy request error: ${error.message}`);
|
||||
return createNotFoundPage('Invalid proxy url:' + error.message);
|
||||
}
|
||||
} else {
|
||||
res.writeHead(proxyRes.statusCode, {
|
||||
...headers,
|
||||
});
|
||||
proxyRes.pipe(res, { end: true });
|
||||
}
|
||||
});
|
||||
proxyReq.on('error', (err) => {
|
||||
console.error(`Proxy request error: ${err.message}`);
|
||||
userApp.clearCacheData();
|
||||
});
|
||||
proxyReq.end();
|
||||
};
|
||||
24
src/module/proxy/minio-proxy.ts
Normal file
24
src/module/proxy/minio-proxy.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import http from 'http';
|
||||
import { minioClient } from '../minio.ts';
|
||||
export type ProxyInfo = {
|
||||
path?: string;
|
||||
target: string;
|
||||
type?: 'static' | 'dynamic' | 'minio';
|
||||
};
|
||||
export const minioProxy = async (req: http.IncomingMessage, res: http.ServerResponse, proxyApi: ProxyInfo) => {
|
||||
try {
|
||||
const requestUrl = new URL(req.url, 'http://localhost');
|
||||
const objectPath = requestUrl.pathname;
|
||||
const bucketName = proxyApi.target;
|
||||
let objectName = objectPath.slice(1);
|
||||
if (objectName.startsWith(bucketName)) {
|
||||
objectName = objectName.slice(bucketName.length);
|
||||
}
|
||||
const objectStream = await minioClient.getObject(bucketName, objectName);
|
||||
objectStream.pipe(res);
|
||||
} catch (error) {
|
||||
console.error('Error fetching object from MinIO:', error);
|
||||
res.statusCode = 500;
|
||||
res.end('Internal Server Error');
|
||||
}
|
||||
};
|
||||
10
src/module/query.ts
Normal file
10
src/module/query.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Query } from '@kevisual/query';
|
||||
import { QueryConfig } from '@kevisual/query-config';
|
||||
|
||||
export const query = new Query({
|
||||
url: 'https://kevisual.cn/api/router',
|
||||
});
|
||||
|
||||
export const queryConfig = new QueryConfig({
|
||||
query: query,
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { config } from '../config.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
|
||||
const api = config?.api || { host: 'kevisual.xiongxiao.me', path: '/api/router' };
|
||||
const api = config?.api || { host: 'https://kevisual.cn', path: '/api/router' };
|
||||
const apiPath = api.path || '/api/router';
|
||||
export const fetchTest = async (id: string) => {
|
||||
const fetchUrl = 'http://' + api.host + apiPath;
|
||||
const fetchUrl = api.host + apiPath;
|
||||
const fetchRes = await fetch(fetchUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -19,7 +20,7 @@ export const fetchTest = async (id: string) => {
|
||||
};
|
||||
|
||||
export const fetchDomain = async (domain: string) => {
|
||||
const fetchUrl = 'http://' + api.host + apiPath;
|
||||
const fetchUrl = api.host + apiPath;
|
||||
const fetchRes = await fetch(fetchUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -37,7 +38,7 @@ export const fetchDomain = async (domain: string) => {
|
||||
};
|
||||
|
||||
export const fetchApp = async ({ user, app }) => {
|
||||
const fetchUrl = 'http://' + api.host + apiPath;
|
||||
const fetchUrl = api.host + apiPath;
|
||||
const fetchRes = await fetch(fetchUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
30
src/module/redis/get-app-status.ts
Normal file
30
src/module/redis/get-app-status.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { redis } from './redis.ts';
|
||||
|
||||
export type AppLoadStatus = {
|
||||
status: 'running' | 'loading' | 'error' | 'not-exist';
|
||||
message: string;
|
||||
};
|
||||
|
||||
export const getAppLoadStatus = async (user: string, app: string): Promise<AppLoadStatus> => {
|
||||
const key = 'user:app:status:' + app + ':' + user;
|
||||
const value = await redis.get(key);
|
||||
if (!value) {
|
||||
return {
|
||||
status: 'not-exist',
|
||||
message: 'not-exist',
|
||||
}; // 没有加载过
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (err) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: 'error',
|
||||
};
|
||||
}
|
||||
};
|
||||
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, 'EX', exp); // 5分钟过期
|
||||
};
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Redis } from 'ioredis';
|
||||
import { useConfig } from '@kevisual/use-config';
|
||||
|
||||
const config = useConfig<{
|
||||
redis: ConstructorParameters<typeof Redis>;
|
||||
}>();
|
||||
// 配置 Redis 连接
|
||||
export const redis = new Redis({
|
||||
import { useContextKey } from '@kevisual/use-config/context';
|
||||
console.log(process.env.REDIS_HOST);
|
||||
import { config } from '../config.ts';
|
||||
const redisConfig = {
|
||||
host: config?.redis?.host || 'localhost', // Redis 服务器的主机名或 IP 地址
|
||||
port: config?.redis?.port || 6379, // Redis 服务器的端口号
|
||||
password: config?.redis?.password, // Redis 的密码 (如果有)
|
||||
};
|
||||
const init = () => {
|
||||
return new Redis({
|
||||
host: 'localhost', // Redis 服务器的主机名或 IP 地址
|
||||
port: 6379, // Redis 服务器的端口号
|
||||
// password: 'your_password', // Redis 的密码 (如果有)
|
||||
@@ -16,8 +19,11 @@ export const redis = new Redis({
|
||||
return Math.min(times * 50, 2000); // 每次重试时延迟增加
|
||||
},
|
||||
maxRetriesPerRequest: null, // 允许请求重试的次数 (如果需要无限次重试)
|
||||
...config.redis,
|
||||
...redisConfig,
|
||||
});
|
||||
};
|
||||
// 配置 Redis 连接
|
||||
export const redis = useContextKey('redis', init);
|
||||
export const subscriber = redis.duplicate(); // 创建一个订阅者连接
|
||||
|
||||
async function ensureKeyspaceNotifications() {
|
||||
|
||||
26
src/module/sequelize.ts
Normal file
26
src/module/sequelize.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Sequelize } from 'sequelize';
|
||||
import { useContextKey, useContext } from '@kevisual/use-config/context';
|
||||
import { useConfig } from '@kevisual/use-config/env';
|
||||
const config = useConfig();
|
||||
|
||||
const postgresConfig = {
|
||||
username: config.POSTGRES_USERNAME,
|
||||
password: config.POSTGRES_PASSWORD,
|
||||
host: config.POSTGRES_HOST,
|
||||
port: config.POSTGRES_PORT,
|
||||
database: config.POSTGRES_DATABASE,
|
||||
};
|
||||
|
||||
if (!postgresConfig) {
|
||||
console.error('postgres config is required');
|
||||
process.exit(1);
|
||||
}
|
||||
// connect to db
|
||||
export const init = () => {
|
||||
return new Sequelize({
|
||||
dialect: 'postgres',
|
||||
...postgresConfig,
|
||||
// logging: false,
|
||||
});
|
||||
};
|
||||
export const sequelize = useContextKey('sequelize', init);
|
||||
31
src/module/user-home/index.ts
Normal file
31
src/module/user-home/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import http from 'http';
|
||||
import { getLoginUser } from '@/middleware/auth.ts';
|
||||
import { queryConfig } from '../query.ts';
|
||||
|
||||
/**
|
||||
* 重定向到用户首页
|
||||
* @param req
|
||||
* @param res
|
||||
*/
|
||||
export const rediretHome = async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||
const user = await getLoginUser(req);
|
||||
if (!user?.token) {
|
||||
res.writeHead(302, { Location: '/user/login/' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
let redirectURL = '/root/center/';
|
||||
try {
|
||||
const token = user.token;
|
||||
const resConfig = await queryConfig.getConfigByKey('user.json', { token });
|
||||
if (resConfig.code === 200) {
|
||||
const configData = resConfig.data?.data as any;
|
||||
redirectURL = configData?.redirectURL || redirectURL;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('get resConfig user.json', error);
|
||||
} finally {
|
||||
res.writeHead(302, { Location: redirectURL });
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
69
src/module/ws-proxy/index.ts
Normal file
69
src/module/ws-proxy/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
84
src/module/ws-proxy/manager.ts
Normal file
84
src/module/ws-proxy/manager.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
44
src/module/ws-proxy/proxy.ts
Normal file
44
src/module/ws-proxy/proxy.ts
Normal 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;
|
||||
};
|
||||
@@ -1,34 +1,112 @@
|
||||
import { UserApp } from '@/module/get-user-app.ts';
|
||||
import { deleteUserAppFiles } from '@/module/get-user-app.ts';
|
||||
import { app } from '../../app.ts';
|
||||
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;
|
||||
app: string;
|
||||
constructor({ user, app }: { user: string; app: string }) {
|
||||
this.user = user;
|
||||
this.app = app;
|
||||
}
|
||||
async clearCache() {
|
||||
const keys = await redis.keys('user:app:*');
|
||||
return keys;
|
||||
}
|
||||
async getCache() {
|
||||
const app = this.app;
|
||||
const user = this.user;
|
||||
const key = 'user:app:' + app + ':' + user;
|
||||
const value = await redis.get(key);
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(value);
|
||||
}
|
||||
async getLoaded() {
|
||||
const app = this.app;
|
||||
const user = this.user;
|
||||
const value = await getAppLoadStatus(user, app);
|
||||
return value;
|
||||
}
|
||||
async clearCacheData() {
|
||||
const app = this.app;
|
||||
const user = this.user;
|
||||
const key = 'user:app:' + app + ':' + user;
|
||||
await redis.del(key);
|
||||
await redis.del('user:app:exist:' + app + ':' + user);
|
||||
await redis.del('user:app:set:' + app + ':' + user);
|
||||
await redis.del('user:app:status:' + app + ':' + user);
|
||||
await redis.del('user:app:permission:' + app + ':' + user);
|
||||
const userDomainApp = 'user:domain:app:' + user + ':' + app;
|
||||
const domainKeys = await redis.get(userDomainApp);
|
||||
if (domainKeys) {
|
||||
const domainKeysList = JSON.parse(domainKeys);
|
||||
domainKeysList.forEach(async (domain: string) => {
|
||||
await redis.del('domain:' + domain);
|
||||
});
|
||||
}
|
||||
await redis.del(userDomainApp);
|
||||
|
||||
// 删除所有文件
|
||||
deleteUserAppFiles(user, app);
|
||||
}
|
||||
}
|
||||
app
|
||||
.route({
|
||||
path: 'page-proxy-app',
|
||||
key: 'auth-admin',
|
||||
id: 'auth-admin',
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const { user } = ctx.query;
|
||||
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);
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'app',
|
||||
path: 'page-proxy-app',
|
||||
key: 'list',
|
||||
middleware: ['auth-admin'],
|
||||
description: '获取应用列表',
|
||||
isDebug: true,
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const keys = await redis.keys('user:app:*');
|
||||
// const keys = await redis.keys('user:app:exist:*');
|
||||
// const data = await redis.mget(...keys);
|
||||
const domainList = await redis.keys('domain:*');
|
||||
ctx.body = {
|
||||
// data: data,
|
||||
keys,
|
||||
domainList,
|
||||
};
|
||||
})
|
||||
.addTo(app);
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'app',
|
||||
path: 'page-proxy-app',
|
||||
key: 'delete',
|
||||
middleware: ['auth-admin'],
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const { user, app } = ctx.query;
|
||||
try {
|
||||
const userApp = new UserApp({ user, app });
|
||||
const userApp = new CenterUserApp({ user, app });
|
||||
await userApp.clearCacheData();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -40,7 +118,7 @@ app
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'app',
|
||||
path: 'page-proxy-app',
|
||||
key: 'deleteAll',
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
@@ -56,7 +134,7 @@ app
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'app',
|
||||
path: 'page-proxy-app',
|
||||
key: 'clear',
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
@@ -74,11 +152,12 @@ app
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'app',
|
||||
path: 'page-proxy-app',
|
||||
key: 'get',
|
||||
middleware: ['auth-admin'],
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const { user, app } = ctx.query.data || {};
|
||||
const { user, app } = ctx.query;
|
||||
if (!user || !app) {
|
||||
if (!user) {
|
||||
ctx.throw('user is required');
|
||||
@@ -87,7 +166,7 @@ app
|
||||
ctx.throw('app is required');
|
||||
}
|
||||
}
|
||||
const userApp = new UserApp({ user, app });
|
||||
const userApp = new CenterUserApp({ user, app });
|
||||
const cache = await userApp.getCache();
|
||||
if (!cache) {
|
||||
ctx.throw('Not Found App');
|
||||
@@ -95,3 +174,17 @@ app
|
||||
ctx.body = cache;
|
||||
})
|
||||
.addTo(app);
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'page-proxy-app',
|
||||
key: 'status',
|
||||
middleware: [],
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const { user, app } = ctx.query;
|
||||
const userApp = new CenterUserApp({ user, app });
|
||||
const status = await userApp.getLoaded();
|
||||
ctx.body = status;
|
||||
})
|
||||
.addTo(app);
|
||||
|
||||
7
src/scripts/clear.ts
Normal file
7
src/scripts/clear.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { UserApp, clearAllUserApp } from '../module/get-user-app.ts';
|
||||
|
||||
const main = async () => {
|
||||
await clearAllUserApp();
|
||||
};
|
||||
|
||||
main();
|
||||
@@ -1,47 +0,0 @@
|
||||
import { UserApp, clearAllUserApp } from '../module/get-user-app.ts';
|
||||
import { redis } from '../module/redis/redis.ts';
|
||||
import path from 'path';
|
||||
import { config, fileStore } from '../module/config.ts';
|
||||
|
||||
const main = async () => {
|
||||
const userApp = new UserApp({ user: 'root', app: 'codeflow' });
|
||||
const res = await userApp.setCacheData();
|
||||
console.log(res);
|
||||
// userApp.close();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
// main();
|
||||
|
||||
const getAll = async () => {
|
||||
const userApp = new UserApp({ user: 'root', app: 'codeflow' });
|
||||
const res = await userApp.getAllCacheData();
|
||||
userApp.close();
|
||||
};
|
||||
|
||||
// getAll();
|
||||
|
||||
// console.log('path', path.join(filePath, '/module/get-user-app.ts'));
|
||||
|
||||
const clearData = async () => {
|
||||
const userApp = new UserApp({ user: 'root', app: 'codeflow' });
|
||||
const res = await userApp.clearCacheData();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
// clearData();
|
||||
// clearAllUserApp();
|
||||
|
||||
const expireData = async () => {
|
||||
await redis.set('user:app:exist:' + 'codeflow:root', 'value', 'EX', 2);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
// expireData();
|
||||
|
||||
const keysData = async () => {
|
||||
const keys = await redis.keys('user:app:exist:*');
|
||||
console.log('keys', keys);
|
||||
process.exit(0);
|
||||
};
|
||||
keysData();
|
||||
5
src/scripts/get-env.ts
Normal file
5
src/scripts/get-env.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { useConfig } from '@kevisual/use-config/env';
|
||||
|
||||
console.log(useConfig());
|
||||
|
||||
console.log(process.env);
|
||||
22
src/scripts/query-config.ts
Normal file
22
src/scripts/query-config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
const baseURL = 'https://kevisual.xiongxiao.me/api/router';
|
||||
export const fetchCnfig = async () => {
|
||||
const res = await fetch(baseURL, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
path: 'app',
|
||||
key: 'getApp',
|
||||
data: {
|
||||
user: 'root',
|
||||
key: 'code-center',
|
||||
},
|
||||
}),
|
||||
});
|
||||
return res.json();
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const res = await fetchCnfig();
|
||||
// console.log(res);
|
||||
console.log(JSON.stringify(res, null, 2));
|
||||
};
|
||||
main();
|
||||
18
src/scripts/test-net-socket.ts
Normal file
18
src/scripts/test-net-socket.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import net from 'net';
|
||||
|
||||
const main = () => {
|
||||
const options = {
|
||||
port: 3003,
|
||||
hostname: '192.168.31.220',
|
||||
path: '/api/v1',
|
||||
};
|
||||
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` + `\r\n`);
|
||||
});
|
||||
|
||||
proxySocket.on('data', (data) => {
|
||||
console.log('data', data.toString());
|
||||
});
|
||||
};
|
||||
|
||||
main();
|
||||
@@ -9,3 +9,9 @@ export const getDNS = (req: http.IncomingMessage) => {
|
||||
export const isLocalhost = (hostName: string) => {
|
||||
return hostName.includes('localhost') || hostName.includes('192.168');
|
||||
};
|
||||
|
||||
export const isIpv4OrIpv6 = (hostName: string) => {
|
||||
const ipv4 = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||
const ipv6 = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/;
|
||||
return ipv4.test(hostName) || ipv6.test(hostName);
|
||||
};
|
||||
|
||||
11
src/utils/get-user.ts
Normal file
11
src/utils/get-user.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -10,7 +10,7 @@
|
||||
"baseUrl": "./",
|
||||
"typeRoots": [
|
||||
"node_modules/@types",
|
||||
"src/@types"
|
||||
// "node_modules/@kevisual/types"
|
||||
],
|
||||
"declaration": true,
|
||||
"noEmit": false,
|
||||
@@ -23,15 +23,11 @@
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
],
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"typings.d.ts",
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"webpack.config.cjs",
|
||||
],
|
||||
"exclude": [],
|
||||
}
|
||||
Reference in New Issue
Block a user