update
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -23,3 +23,5 @@ release/*
|
|||||||
|
|
||||||
pack-dist
|
pack-dist
|
||||||
app.config.json5.envision
|
app.config.json5.envision
|
||||||
|
|
||||||
|
/pages
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "codecenter",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"author": "abearxiong",
|
|
||||||
"basename": "/root/code-center",
|
|
||||||
"app": {
|
|
||||||
"type": "pm2-system-app",
|
|
||||||
"key": "code-center",
|
|
||||||
"entry": "./dist/app.mjs"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"start": "pm2 start apps/code-center/dist/app.mjs --name code-center"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@kevisual/router": "^0.0.20",
|
|
||||||
"@kevisual/use-config": "^1.0.17",
|
|
||||||
"ioredis": "^5.6.1",
|
|
||||||
"minio": "^8.0.5",
|
|
||||||
"pg": "^8.16.0",
|
|
||||||
"sequelize": "^6.37.7",
|
|
||||||
"sqlite3": "^5.1.7",
|
|
||||||
"socket.io": "^4.8.1",
|
|
||||||
"pm2": "^6.0.6",
|
|
||||||
"dotenv": "^16.5.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import * as nanoid from 'nanoid';
|
|
||||||
|
|
||||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
|
||||||
|
|
||||||
//HMAC-256:推荐 32 字节。
|
|
||||||
// HMAC-512:推荐 64 字节。
|
|
||||||
|
|
||||||
// jsonwentoken 默认使用 HMAC-256 算法,生成 32 字节的 token。
|
|
||||||
|
|
||||||
const v = nanoid.customAlphabet(alphabet, 32);
|
|
||||||
console.log('v', v());
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import archiver from 'archiver';
|
|
||||||
import { exec } from 'child_process';
|
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
|
|
||||||
const cwd = process.cwd();
|
|
||||||
const pkgPath = path.join(cwd, 'package.json');
|
|
||||||
|
|
||||||
export const checkFileExistsSync = (filePath) => {
|
|
||||||
try {
|
|
||||||
// 使用 F_OK 检查文件或目录是否存在
|
|
||||||
fs.accessSync(filePath, fs.constants.F_OK);
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
||||||
|
|
||||||
const releasePath = path.join(cwd, 'release');
|
|
||||||
|
|
||||||
const distPath = path.join(cwd, 'dist');
|
|
||||||
const scriptPath = path.join(cwd, 'script');
|
|
||||||
const zip = archiver('zip', {
|
|
||||||
zlib: { level: 9 },
|
|
||||||
});
|
|
||||||
const zipName = `code-center-${pkg.version}.zip`;
|
|
||||||
const zipCache = path.join(releasePath, `code-center-${pkg.version}.zip`);
|
|
||||||
|
|
||||||
const getZip = async () => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const output = fs.createWriteStream(zipCache);
|
|
||||||
const startTime = (new Date().getTime() / 1000).toFixed(0);
|
|
||||||
// 监听事件
|
|
||||||
output.on('close', async () => {
|
|
||||||
const bytes = zip.pointer();
|
|
||||||
const size = bytes < 1024 ? `${bytes} bytes` : `${(bytes / 1024).toFixed(2)} KB`;
|
|
||||||
console.log(`Zip file has been created successfully. Total size: ${size} bytes.`);
|
|
||||||
let time = (new Date().getTime() / 1000).toFixed(0);
|
|
||||||
console.log('time', time - startTime);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
output.on('end', () => {
|
|
||||||
console.log('Data has been drained.'); // 数据已被耗尽
|
|
||||||
throw new CustomError('Data has been drained.');
|
|
||||||
});
|
|
||||||
|
|
||||||
zip.on('warning', (err) => {
|
|
||||||
if (err.code === 'ENOENT') {
|
|
||||||
console.warn('File not found:', err);
|
|
||||||
} else {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
zip.on('error', (err) => {
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 通过管道将 zip 数据流输出到指定文件
|
|
||||||
zip.pipe(output);
|
|
||||||
|
|
||||||
// 添加 sh 字符串作为文件到 zip 中
|
|
||||||
const sh = `#!/bin/bash
|
|
||||||
npm i -g pnpm
|
|
||||||
pnpm install --prod
|
|
||||||
`;
|
|
||||||
zip.append(sh, {
|
|
||||||
name: 'start.sh',
|
|
||||||
//类型是可执行文件
|
|
||||||
mode: 0o755,
|
|
||||||
});
|
|
||||||
// 把dist目录下的文件添加到zip中
|
|
||||||
zip.directory(distPath, 'dist');
|
|
||||||
zip.directory(scriptPath, 'script');
|
|
||||||
|
|
||||||
// 把README.md添加到zip中
|
|
||||||
zip.file(path.join(cwd, 'README.md'), { name: 'README.md' });
|
|
||||||
// 把package.json添加到zip中
|
|
||||||
zip.file(pkgPath, { name: 'package.json' });
|
|
||||||
const ecosystemContent = `module.exports = {
|
|
||||||
apps: [
|
|
||||||
{
|
|
||||||
name: 'codecenter', // 应用名称
|
|
||||||
script: './dist/app.mjs', // 入口文件
|
|
||||||
// cwd: '.', // 设置当前工作目录
|
|
||||||
output: './logs/codecenter.log',
|
|
||||||
error: './logs/codecenter.log',
|
|
||||||
log_date_format: 'YYYY-MM-DD HH:mm:ss',
|
|
||||||
// watch: true, // 自动监控文件变化
|
|
||||||
watch: ['dist'], // 监控的文件夹
|
|
||||||
ignore_watch: ['node_modules', 'logs'], // 忽略的文件夹
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
zip.append(ecosystemContent, { name: 'ecosystem.config.cjs' });
|
|
||||||
let json5Content = fs.readFileSync(path.join(cwd, 'app.config.json5.example'), 'utf8');
|
|
||||||
// tokenSecret 是一个随机字符串,用于生成 token
|
|
||||||
let tokenSecret = 'XX' + nanoid(39);
|
|
||||||
json5Content = json5Content.replace('<TOKEN_SECRET>', tokenSecret);
|
|
||||||
// tokenSecret
|
|
||||||
// 把app.config.json5.example添加到zip中
|
|
||||||
// zip.file(path.join(cwd, 'app.config.json5.example'), { name: 'app.config.json5.example' });
|
|
||||||
zip.append(json5Content, { name: 'app.config.json5.example' });
|
|
||||||
zip.append('version-check', { name: `version-check-${pkg.version}` });
|
|
||||||
// 结束归档(必须调用,否则 zip 文件无法完成)
|
|
||||||
zip.finalize();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
getZip().then(() => {
|
|
||||||
console.log('zip success');
|
|
||||||
// 固定上传位置在1.0.0版本,不需要更新,因为是zip文件
|
|
||||||
console.log(`envision switch system && envision deploy ./release/${zipName} -v 1.0.0 -k code-center -y y -u`);
|
|
||||||
|
|
||||||
console.log(`download zip:\n\ncurl -O https://kevisual.xiongxiao.me/system/code-center/${zipName} && unzip ${zipName}`);
|
|
||||||
});
|
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
"start": "pm2 start dist/app.js --name code-center",
|
"start": "pm2 start dist/app.js --name code-center",
|
||||||
"client:start": "pm2 start apps/code-center/dist/app.js --name code-center",
|
"client:start": "pm2 start apps/code-center/dist/app.js --name code-center",
|
||||||
"ssl": "ssh -L 5432:localhost:5432 light",
|
"ssl": "ssh -L 5432:localhost:5432 light",
|
||||||
|
"ssl:redis": "ssh -L 6379:localhost:6379 light",
|
||||||
"pub": "envision pack -p -u -c"
|
"pub": "envision pack -p -u -c"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
@@ -44,12 +45,17 @@
|
|||||||
],
|
],
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@types/busboy": "^1.5.4",
|
||||||
|
"@types/send": "^1.2.1",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"busboy": "^1.6.0",
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
"cookie": "^1.1.1",
|
"cookie": "^1.1.1",
|
||||||
"ioredis": "^5.8.2",
|
"ioredis": "^5.8.2",
|
||||||
"minio": "^8.0.6",
|
"minio": "^8.0.6",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"pm2": "^6.0.14",
|
"pm2": "^6.0.14",
|
||||||
|
"send": "^1.2.0",
|
||||||
"sequelize": "^6.37.7"
|
"sequelize": "^6.37.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -90,6 +96,7 @@
|
|||||||
"strip-ansi": "^7.1.2",
|
"strip-ansi": "^7.1.2",
|
||||||
"tar": "^7.5.2",
|
"tar": "^7.5.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
"ws": "npm:@kevisual/ws",
|
||||||
"zod": "^4.1.13"
|
"zod": "^4.1.13"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
|
|||||||
59
pnpm-lock.yaml
generated
59
pnpm-lock.yaml
generated
@@ -12,6 +12,18 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@types/busboy':
|
||||||
|
specifier: ^1.5.4
|
||||||
|
version: 1.5.4
|
||||||
|
'@types/send':
|
||||||
|
specifier: ^1.2.1
|
||||||
|
version: 1.2.1
|
||||||
|
'@types/ws':
|
||||||
|
specifier: ^8.18.1
|
||||||
|
version: 8.18.1
|
||||||
|
busboy:
|
||||||
|
specifier: ^1.6.0
|
||||||
|
version: 1.6.0
|
||||||
commander:
|
commander:
|
||||||
specifier: ^14.0.2
|
specifier: ^14.0.2
|
||||||
version: 14.0.2
|
version: 14.0.2
|
||||||
@@ -30,6 +42,9 @@ importers:
|
|||||||
pm2:
|
pm2:
|
||||||
specifier: ^6.0.14
|
specifier: ^6.0.14
|
||||||
version: 6.0.14
|
version: 6.0.14
|
||||||
|
send:
|
||||||
|
specifier: ^1.2.0
|
||||||
|
version: 1.2.0
|
||||||
sequelize:
|
sequelize:
|
||||||
specifier: ^6.37.7
|
specifier: ^6.37.7
|
||||||
version: 6.37.7(pg@8.16.3)
|
version: 6.37.7(pg@8.16.3)
|
||||||
@@ -130,6 +145,9 @@ importers:
|
|||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.9.3
|
specifier: ^5.9.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
ws:
|
||||||
|
specifier: npm:@kevisual/ws
|
||||||
|
version: '@kevisual/ws@8.0.0'
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.1.13
|
specifier: ^4.1.13
|
||||||
version: 4.1.13
|
version: 4.1.13
|
||||||
@@ -197,6 +215,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
dotenv: ^17
|
dotenv: ^17
|
||||||
|
|
||||||
|
'@kevisual/ws@8.0.0':
|
||||||
|
resolution: {integrity: sha512-jlFxSlXUEz93cFW+UYT5BXv/rFVgiMQnIfqRYZ0gj1hSP8PMGRqMqUoHSLfKvfRRS4jseLSvTTeEKSQpZJtURg==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
'@noble/hashes@1.8.0':
|
'@noble/hashes@1.8.0':
|
||||||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
||||||
engines: {node: ^14.21.3 || >=16}
|
engines: {node: ^14.21.3 || >=16}
|
||||||
@@ -236,6 +258,9 @@ packages:
|
|||||||
'@types/archiver@7.0.0':
|
'@types/archiver@7.0.0':
|
||||||
resolution: {integrity: sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==}
|
resolution: {integrity: sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==}
|
||||||
|
|
||||||
|
'@types/busboy@1.5.4':
|
||||||
|
resolution: {integrity: sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==}
|
||||||
|
|
||||||
'@types/cookie@0.4.1':
|
'@types/cookie@0.4.1':
|
||||||
resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==}
|
resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==}
|
||||||
|
|
||||||
@@ -275,9 +300,15 @@ packages:
|
|||||||
'@types/semver@7.7.1':
|
'@types/semver@7.7.1':
|
||||||
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
|
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
|
||||||
|
|
||||||
|
'@types/send@1.2.1':
|
||||||
|
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
|
||||||
|
|
||||||
'@types/validator@13.12.2':
|
'@types/validator@13.12.2':
|
||||||
resolution: {integrity: sha512-6SlHBzUW8Jhf3liqrGGXyTJSIFe4nqlJ5A5KaMZ2l/vbM3Wh3KSybots/wfWVzNLK4D1NZluDlSQIbIEPx6oyA==}
|
resolution: {integrity: sha512-6SlHBzUW8Jhf3liqrGGXyTJSIFe4nqlJ5A5KaMZ2l/vbM3Wh3KSybots/wfWVzNLK4D1NZluDlSQIbIEPx6oyA==}
|
||||||
|
|
||||||
|
'@types/ws@8.18.1':
|
||||||
|
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||||
|
|
||||||
'@zxing/text-encoding@0.9.0':
|
'@zxing/text-encoding@0.9.0':
|
||||||
resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==}
|
resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==}
|
||||||
|
|
||||||
@@ -411,6 +442,10 @@ packages:
|
|||||||
buffer@6.0.3:
|
buffer@6.0.3:
|
||||||
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||||
|
|
||||||
|
busboy@1.6.0:
|
||||||
|
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
|
||||||
|
engines: {node: '>=10.16.0'}
|
||||||
|
|
||||||
call-bind@1.0.7:
|
call-bind@1.0.7:
|
||||||
resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==}
|
resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1424,6 +1459,10 @@ packages:
|
|||||||
stream-json@1.8.0:
|
stream-json@1.8.0:
|
||||||
resolution: {integrity: sha512-HZfXngYHUAr1exT4fxlbc1IOce1RYxp2ldeaf97LYCOPSoOqY/1Psp7iGvpb+6JIOgkra9zDYnPX01hGAHzEPw==}
|
resolution: {integrity: sha512-HZfXngYHUAr1exT4fxlbc1IOce1RYxp2ldeaf97LYCOPSoOqY/1Psp7iGvpb+6JIOgkra9zDYnPX01hGAHzEPw==}
|
||||||
|
|
||||||
|
streamsearch@1.1.0:
|
||||||
|
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
streamx@2.20.1:
|
streamx@2.20.1:
|
||||||
resolution: {integrity: sha512-uTa0mU6WUC65iUvzKH4X9hEdvSW7rbPxPtwfWiLMSj3qTdQbAiUboZTxauKfpFuGIGa1C2BYijZ7wgdUXICJhA==}
|
resolution: {integrity: sha512-uTa0mU6WUC65iUvzKH4X9hEdvSW7rbPxPtwfWiLMSj3qTdQbAiUboZTxauKfpFuGIGa1C2BYijZ7wgdUXICJhA==}
|
||||||
|
|
||||||
@@ -1779,6 +1818,8 @@ snapshots:
|
|||||||
'@kevisual/load': 0.0.6
|
'@kevisual/load': 0.0.6
|
||||||
dotenv: 17.2.3
|
dotenv: 17.2.3
|
||||||
|
|
||||||
|
'@kevisual/ws@8.0.0': {}
|
||||||
|
|
||||||
'@noble/hashes@1.8.0': {}
|
'@noble/hashes@1.8.0': {}
|
||||||
|
|
||||||
'@paralleldrive/cuid2@2.2.2':
|
'@paralleldrive/cuid2@2.2.2':
|
||||||
@@ -1848,6 +1889,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/readdir-glob': 1.1.5
|
'@types/readdir-glob': 1.1.5
|
||||||
|
|
||||||
|
'@types/busboy@1.5.4':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 24.10.1
|
||||||
|
|
||||||
'@types/cookie@0.4.1': {}
|
'@types/cookie@0.4.1': {}
|
||||||
|
|
||||||
'@types/cors@2.8.17':
|
'@types/cors@2.8.17':
|
||||||
@@ -1891,8 +1936,16 @@ snapshots:
|
|||||||
|
|
||||||
'@types/semver@7.7.1': {}
|
'@types/semver@7.7.1': {}
|
||||||
|
|
||||||
|
'@types/send@1.2.1':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 24.10.1
|
||||||
|
|
||||||
'@types/validator@13.12.2': {}
|
'@types/validator@13.12.2': {}
|
||||||
|
|
||||||
|
'@types/ws@8.18.1':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 24.10.1
|
||||||
|
|
||||||
'@zxing/text-encoding@0.9.0':
|
'@zxing/text-encoding@0.9.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -2021,6 +2074,10 @@ snapshots:
|
|||||||
base64-js: 1.5.1
|
base64-js: 1.5.1
|
||||||
ieee754: 1.2.1
|
ieee754: 1.2.1
|
||||||
|
|
||||||
|
busboy@1.6.0:
|
||||||
|
dependencies:
|
||||||
|
streamsearch: 1.1.0
|
||||||
|
|
||||||
call-bind@1.0.7:
|
call-bind@1.0.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-define-property: 1.0.0
|
es-define-property: 1.0.0
|
||||||
@@ -3068,6 +3125,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
stream-chain: 2.2.5
|
stream-chain: 2.2.5
|
||||||
|
|
||||||
|
streamsearch@1.1.0: {}
|
||||||
|
|
||||||
streamx@2.20.1:
|
streamx@2.20.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-fifo: 1.3.2
|
fast-fifo: 1.3.2
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ export const oss = useContextKey(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
export const redis = useContextKey('redis', () => redisLib.redis);
|
export const redis = useContextKey('redis', () => redisLib.redis);
|
||||||
export const redisPublisher = useContextKey('redisPublisher', () => redisLib.redisPublisher);
|
export const subscriber = useContextKey('subscriber', () => redisLib.subscriber);
|
||||||
export const redisSubscriber = useContextKey('redisSubscriber', () => redisLib.redisSubscriber);
|
|
||||||
export const minioClient = useContextKey('minioClient', () => minioLib.minioClient);
|
export const minioClient = useContextKey('minioClient', () => minioLib.minioClient);
|
||||||
export const sequelize = useContextKey('sequelize', () => sequelizeLib.sequelize);
|
export const sequelize = useContextKey('sequelize', () => sequelizeLib.sequelize);
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { router } from '../modules/router.ts';
|
|
||||||
import { Route } from '@kevisual/router';
|
|
||||||
|
|
||||||
const getList = new Route('test', 'getList');
|
|
||||||
getList.run = async (ctx) => {
|
|
||||||
ctx.body = 'test';
|
|
||||||
return ctx;
|
|
||||||
};
|
|
||||||
router.add(getList);
|
|
||||||
|
|
||||||
const codeRun = `async function run(ctx) {
|
|
||||||
ctx.body = 'test js';
|
|
||||||
return ctx;
|
|
||||||
}`;
|
|
||||||
const fn: any = new Function(
|
|
||||||
'ctx',
|
|
||||||
`
|
|
||||||
${codeRun}
|
|
||||||
return run(ctx);
|
|
||||||
`,
|
|
||||||
);
|
|
||||||
const codeRunRoute = new Route('test', 'run');
|
|
||||||
codeRunRoute.run = fn;
|
|
||||||
|
|
||||||
router.add(codeRunRoute);
|
|
||||||
59
src/index.ts
59
src/index.ts
@@ -1,11 +1,62 @@
|
|||||||
import { config } from './modules/config.ts';
|
import { myConfig as config } from './modules/config.ts';
|
||||||
import { app } from './app.ts';
|
import { app } from './app.ts';
|
||||||
import './route.ts';
|
import './route.ts';
|
||||||
import { uploadMiddleware } from './routes-simple/upload.ts';
|
import { handleRequest } from './routes-simple/handle-request.ts';
|
||||||
import { port } from './modules/config.ts';
|
import { port } from './modules/config.ts';
|
||||||
|
import { WssApp } from './modules/ws-proxy/index.ts';
|
||||||
|
import net from 'node:net';
|
||||||
// if (import.meta.url === `file://${process.argv[1]}`) {
|
// if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`server is running at http://localhost:${port}`);
|
console.log(`server is running at http://localhost:${port}`);
|
||||||
});
|
});
|
||||||
app.server.on(uploadMiddleware);
|
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);
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { useConfig } from '@kevisual/use-config/env';
|
|
||||||
import { Logger } from '@kevisual/logger';
|
|
||||||
const config = useConfig();
|
|
||||||
|
|
||||||
export const logger = new Logger({
|
|
||||||
level: config.LOG_LEVEL || 'info',
|
|
||||||
showTime: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const logError = (message: string, data?: any) => logger.error({ data }, message);
|
|
||||||
export const logWarning = (message: string, data?: any) => logger.warn({ data }, message);
|
|
||||||
export const logInfo = (message: string, data?: any) => logger.info({ data }, message);
|
|
||||||
export const logDebug = (message: string, data?: any) => logger.debug({ data }, message);
|
|
||||||
|
|
||||||
export const log = {
|
|
||||||
error: logError,
|
|
||||||
warn: logWarning,
|
|
||||||
info: logInfo,
|
|
||||||
debug: logDebug,
|
|
||||||
};
|
|
||||||
66
src/modules/auth.ts
Normal file
66
src/modules/auth.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { User } from '@/models/user.ts';
|
||||||
|
import http from 'http';
|
||||||
|
import cookie from 'cookie';
|
||||||
|
import { logger } from './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,17 +1,87 @@
|
|||||||
import path from 'path';
|
import { useConfig } from '@kevisual/use-config';
|
||||||
import dotenv from 'dotenv';
|
import { useFileStore } from '@kevisual/use-config/file-store';
|
||||||
// import { useConfig } from '@kevisual/use-config/env';
|
import { minioResources } from './minio.ts';
|
||||||
|
|
||||||
export const envFiles = [
|
export const config = useConfig() as any;
|
||||||
path.resolve(process.cwd(), process.env.NODE_ENV === 'development' ? '.env.dev' : '.env'),
|
|
||||||
// path.resolve(process.cwd(), '.env'), //
|
|
||||||
];
|
|
||||||
console.log('envFiles', envFiles);
|
|
||||||
export const config = dotenv.config({
|
|
||||||
path: envFiles,
|
|
||||||
override: true,
|
|
||||||
}).parsed;
|
|
||||||
// const config = useConfig();
|
|
||||||
// export const config = process.env;
|
|
||||||
// console.log('config', config);
|
|
||||||
export const port = config.PORT || 4005;
|
export const port = config.PORT || 4005;
|
||||||
|
export const fileStore = useFileStore('pages');
|
||||||
|
type ConfigType = {
|
||||||
|
api: {
|
||||||
|
/**
|
||||||
|
* API host address
|
||||||
|
*/
|
||||||
|
host: string;
|
||||||
|
path?: string;
|
||||||
|
port?: number;
|
||||||
|
};
|
||||||
|
apiList: {
|
||||||
|
path: string;
|
||||||
|
/**
|
||||||
|
* url或者相对路径
|
||||||
|
*/
|
||||||
|
target: string;
|
||||||
|
/**
|
||||||
|
* 类型
|
||||||
|
*/
|
||||||
|
type?: 'static' | 'dynamic' | 'minio';
|
||||||
|
}[];
|
||||||
|
proxy: {
|
||||||
|
port?: number;
|
||||||
|
/**
|
||||||
|
* self domain kevisual.xiongxiao.me
|
||||||
|
*/
|
||||||
|
domain: string;
|
||||||
|
/**
|
||||||
|
* resources path
|
||||||
|
* https://minio.xiongxiao.me/resources
|
||||||
|
*/
|
||||||
|
resources: string;
|
||||||
|
/**
|
||||||
|
* allow origin xiongxiao.me zxj.im silkyai.cn
|
||||||
|
* 允许跨域访问的地址
|
||||||
|
*/
|
||||||
|
allowedOrigin: string[];
|
||||||
|
};
|
||||||
|
stat: {
|
||||||
|
/**
|
||||||
|
* 统计网站ID
|
||||||
|
*/
|
||||||
|
websiteId: string;
|
||||||
|
};
|
||||||
|
redis?: {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export const myConfig: ConfigType = {
|
||||||
|
api: {
|
||||||
|
host: config.API_HOST,
|
||||||
|
path: config.API_PATH,
|
||||||
|
port: config.PROXY_PORT,
|
||||||
|
},
|
||||||
|
apiList: [
|
||||||
|
// {
|
||||||
|
// path: '/api',
|
||||||
|
// target: config.API_HOST,
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
path: '/client',
|
||||||
|
target: config.API_CLIENT_HOST || 'http://localhost:51015',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
proxy: {
|
||||||
|
port: config.PROXY_PORT,
|
||||||
|
domain: config.PROXY_DOMAIN,
|
||||||
|
resources: minioResources,
|
||||||
|
allowedOrigin: (config.PROXY_ALLOWED_ORIGINS as string)?.split(',') || [],
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
host: config.REDIS_HOST,
|
||||||
|
port: config.REDIS_PORT,
|
||||||
|
password: config.REDIS_PASSWORD,
|
||||||
|
},
|
||||||
|
stat: {
|
||||||
|
websiteId: config.DATA_WEBSITE_ID,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
90
src/modules/fm-manager/get-content-type.ts
Normal file
90
src/modules/fm-manager/get-content-type.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import path from 'node: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 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',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.wav': 'audio/wav',
|
||||||
|
'.mp4': 'video/mp4',
|
||||||
|
'.md': 'text/markdown; charset=utf-8', // utf-8配置
|
||||||
|
'.ico': 'image/x-icon', // Favicon 图标
|
||||||
|
'.webp': 'image/webp', // WebP 图像格式
|
||||||
|
'.webm': 'video/webm', // WebM 视频格式
|
||||||
|
'.ogg': 'audio/ogg', // Ogg 音频格式
|
||||||
|
'.mp3': 'audio/mpeg', // MP3 音频格式
|
||||||
|
'.m4a': 'audio/mp4', // M4A 音频格式
|
||||||
|
'.m3u8': 'application/vnd.apple.mpegurl', // HLS 播放列表
|
||||||
|
'.pdf': 'application/pdf', // PDF 文档
|
||||||
|
'.doc': 'application/msword', // Word 文档
|
||||||
|
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // Word 文档 (新版)
|
||||||
|
'.ppt': 'application/vnd.ms-powerpoint', // PowerPoint 演示文稿
|
||||||
|
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', // PowerPoint (新版)
|
||||||
|
'.xls': 'application/vnd.ms-excel', // Excel 表格
|
||||||
|
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // Excel 表格 (新版)
|
||||||
|
'.csv': 'text/csv; charset=utf-8', // CSV 文件
|
||||||
|
'.xml': 'application/xml; charset=utf-8', // XML 文件
|
||||||
|
'.rtf': 'application/rtf', // RTF 文本文件
|
||||||
|
'.eot': 'application/vnd.ms-fontobject', // Embedded OpenType 字体
|
||||||
|
'.ttf': 'font/ttf', // TrueType 字体
|
||||||
|
'.woff': 'font/woff', // Web Open Font Format 1.0
|
||||||
|
'.woff2': 'font/woff2', // Web Open Font Format 2.0
|
||||||
|
'.otf': 'font/otf', // OpenType 字体
|
||||||
|
'.wasm': 'application/wasm', // WebAssembly 文件
|
||||||
|
'.pem': 'application/x-pem-file', // PEM 证书文件
|
||||||
|
'.crt': 'application/x-x509-ca-cert', // CRT 证书文件
|
||||||
|
'.yaml': 'application/x-yaml; charset=utf-8', // YAML 文件
|
||||||
|
'.yml': 'application/x-yaml; charset=utf-8', // YAML 文件(别名)
|
||||||
|
'.zip': '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';
|
||||||
|
};
|
||||||
67
src/modules/fm-manager/get-router.ts
Normal file
67
src/modules/fm-manager/get-router.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { config } from '../config.ts';
|
||||||
|
import { app } from '@/app.ts'
|
||||||
|
const api = config?.api || { host: 'https://kevisual.cn', path: '/api/router' };
|
||||||
|
const apiPath = api.path || '/api/router';
|
||||||
|
export const fetchTest = async (id: string) => {
|
||||||
|
const res = await app.call({
|
||||||
|
path: 'user-app',
|
||||||
|
key: 'test',
|
||||||
|
payload: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
code: res.code,
|
||||||
|
data: res.body,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchDomain = async (domain: string): Promise<{ code: number; data: any, message?: string }> => {
|
||||||
|
const res = await app.call({
|
||||||
|
path: 'app',
|
||||||
|
key: 'getDomainApp',
|
||||||
|
payload: {
|
||||||
|
data: {
|
||||||
|
domain,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
code: res.code,
|
||||||
|
data: res.body as any,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchApp = async (payload: { user: string; app: string }) => {
|
||||||
|
const res = await app.call({
|
||||||
|
path: 'app',
|
||||||
|
key: 'getApp',
|
||||||
|
payload: {
|
||||||
|
data: {
|
||||||
|
user: payload.user,
|
||||||
|
key: payload.app,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
code: res.code,
|
||||||
|
data: res.body,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserConfig = async (token: string) => {
|
||||||
|
// await queryConfig.getConfigByKey('user.json', { token })
|
||||||
|
const res = await app.call({
|
||||||
|
path: 'config',
|
||||||
|
key: 'defaultConfig',
|
||||||
|
payload: {
|
||||||
|
configKey: 'user.json',
|
||||||
|
token,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const data = res.body;
|
||||||
|
return {
|
||||||
|
code: res.code,
|
||||||
|
data
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/modules/fm-manager/index.ts
Normal file
10
src/modules/fm-manager/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export * from './proxy/http-proxy.ts'
|
||||||
|
export * from './proxy/file-proxy.ts'
|
||||||
|
export * from './proxy/minio-proxy.ts'
|
||||||
|
export * from './proxy/ai-proxy.ts'
|
||||||
|
|
||||||
|
export * from './get-router.ts'
|
||||||
|
|
||||||
|
export * from './get-content-type.ts'
|
||||||
|
|
||||||
|
export * from './utils.ts'
|
||||||
292
src/modules/fm-manager/proxy/ai-proxy.ts
Normal file
292
src/modules/fm-manager/proxy/ai-proxy.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
import { bucketName, minioClient } from '@/modules/minio.ts';
|
||||||
|
import { IncomingMessage, ServerResponse } from 'http';
|
||||||
|
import { filterKeys } from './http-proxy.ts';
|
||||||
|
import { getUserFromRequest } from '../utils.ts';
|
||||||
|
import { UserPermission, Permission } from '@kevisual/permission';
|
||||||
|
import { getLoginUser } from '@/modules/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 '@/modules/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);
|
||||||
|
};
|
||||||
25
src/modules/fm-manager/proxy/file-proxy.ts
Normal file
25
src/modules/fm-manager/proxy/file-proxy.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import http from 'node:http';
|
||||||
|
import send from 'send';
|
||||||
|
import { fileIsExist } from '@kevisual/use-config';
|
||||||
|
import path from 'node: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/modules/fm-manager/proxy/http-proxy.ts
Normal file
165
src/modules/fm-manager/proxy/http-proxy.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { pipeline, Readable } from 'node:stream';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { bucketName, minioClient, minioResources } from '@/modules/minio.ts';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
import http from 'node:http';
|
||||||
|
import https from 'node:https';
|
||||||
|
import { UserApp } from '@/modules/user-app/index.ts';
|
||||||
|
import { addStat } from '@/modules/html/stat/index.ts';
|
||||||
|
import path from 'path';
|
||||||
|
import { getTextContentType } from '@/modules/fm-manager/index.ts';
|
||||||
|
import { logger } from '@/modules/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/modules/fm-manager/proxy/minio-proxy.ts
Normal file
24
src/modules/fm-manager/proxy/minio-proxy.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import http from 'http';
|
||||||
|
import { minioClient } from '@/modules/minio.ts';
|
||||||
|
type ProxyInfo = {
|
||||||
|
path?: string;
|
||||||
|
target: string;
|
||||||
|
type?: 'static' | 'dynamic' | 'minio';
|
||||||
|
};
|
||||||
|
export const minioProxyOrigin = 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
31
src/modules/fm-manager/utils.ts
Normal file
31
src/modules/fm-manager/utils.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { IncomingMessage } from 'node:http';
|
||||||
|
import http from 'node: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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const getDNS = (req: http.IncomingMessage) => {
|
||||||
|
const hostName = req.headers.host;
|
||||||
|
const ip = req.socket.remoteAddress;
|
||||||
|
return { hostName, ip };
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
199
src/modules/html/create-refresh-html.ts
Normal file
199
src/modules/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/modules/html/favicon.ico
Normal file
BIN
src/modules/html/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
17
src/modules/html/stat/index.ts
Normal file
17
src/modules/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,5 +1,6 @@
|
|||||||
import { Client, ClientOptions } from 'minio';
|
import { Client, ClientOptions } from 'minio';
|
||||||
import { config } from './config.ts';
|
import { useConfig } from '@kevisual/use-config';
|
||||||
|
const config = useConfig();
|
||||||
import { OssBase } from '@kevisual/oss/services';
|
import { OssBase } from '@kevisual/oss/services';
|
||||||
const minioConfig = {
|
const minioConfig = {
|
||||||
endPoint: config.MINIO_ENDPOINT || 'localhost',
|
endPoint: config.MINIO_ENDPOINT || 'localhost',
|
||||||
@@ -8,10 +9,14 @@ const minioConfig = {
|
|||||||
accessKey: config.MINIO_ACCESS_KEY,
|
accessKey: config.MINIO_ACCESS_KEY,
|
||||||
secretKey: config.MINIO_SECRET_KEY,
|
secretKey: config.MINIO_SECRET_KEY,
|
||||||
};
|
};
|
||||||
|
const { port, endPoint, useSSL } = minioConfig;
|
||||||
// console.log('minioConfig', minioConfig);
|
// console.log('minioConfig', minioConfig);
|
||||||
export const minioClient = new Client(minioConfig);
|
export const minioClient = new Client(minioConfig);
|
||||||
|
|
||||||
export const bucketName = config.MINIO_BUCKET_NAME || 'resources';
|
export const bucketName = config.MINIO_BUCKET_NAME || 'resources';
|
||||||
|
|
||||||
|
export const minioUrl = `http${useSSL ? 's' : ''}://${endPoint}:${port || 9000}`;
|
||||||
|
export const minioResources = `${minioUrl}/resources`;
|
||||||
|
|
||||||
if (!minioClient) {
|
if (!minioClient) {
|
||||||
throw new Error('Minio client not initialized');
|
throw new Error('Minio client not initialized');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,31 @@ redis.on('error', (err) => {
|
|||||||
console.error('Redis 连接错误', err);
|
console.error('Redis 连接错误', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 初始化 Redis 客户端
|
export const subscriber = redis.duplicate(); // 创建一个订阅者连接
|
||||||
export const redisPublisher = createRedisClient(); // 用于发布消息
|
|
||||||
export const redisSubscriber = createRedisClient(); // 用于订阅消息
|
async function ensureKeyspaceNotifications() {
|
||||||
|
try {
|
||||||
|
// 获取当前的 `notify-keyspace-events` 配置
|
||||||
|
const currentConfig = (await redis.config('GET', 'notify-keyspace-events')) as string[];
|
||||||
|
|
||||||
|
// 检查返回的数组长度是否大于1,表示获取成功
|
||||||
|
if (currentConfig && currentConfig.length > 1) {
|
||||||
|
const currentSetting = currentConfig[1]; // 值在数组的第二个元素
|
||||||
|
// 检查当前配置是否包含 "Ex"
|
||||||
|
if (!currentSetting.includes('E') || !currentSetting.includes('x')) {
|
||||||
|
console.log('Keyspace notifications are not fully enabled. Setting correct value...');
|
||||||
|
await redis.config('SET', 'notify-keyspace-events', 'Ex');
|
||||||
|
console.log('Keyspace notifications enabled with setting "Ex".');
|
||||||
|
} else {
|
||||||
|
// console.log('Keyspace notifications are already correctly configured.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('Failed to get the current notify-keyspace-events setting.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error while configuring Redis keyspace notifications:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保键空间通知被正确设置
|
||||||
|
ensureKeyspaceNotifications().catch(console.error);
|
||||||
|
|||||||
30
src/modules/user-app/get-app-status.ts
Normal file
30
src/modules/user-app/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分钟过期
|
||||||
|
};
|
||||||
464
src/modules/user-app/index.ts
Normal file
464
src/modules/user-app/index.ts
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import { redis, subscriber } from '../redis.ts';
|
||||||
|
import { myConfig as config, fileStore } from '../config.ts';
|
||||||
|
import fs from 'fs';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { pipeline } from 'stream';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import { getAppLoadStatus, setAppLoadStatus } from './get-app-status.ts';
|
||||||
|
import { minioResources } from '../minio.ts';
|
||||||
|
import { downloadFileFromMinio, fetchApp, fetchDomain, fetchTest } from '@/modules/fm-manager/index.ts';
|
||||||
|
import { logger } from '../logger.ts';
|
||||||
|
export * from './get-app-status.ts';
|
||||||
|
export * from './user-home.ts';
|
||||||
|
|
||||||
|
const pipelineAsync = promisify(pipeline);
|
||||||
|
|
||||||
|
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: 'oss', // oss, 默认是oss
|
||||||
|
data: {
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
name: 'index.html',
|
||||||
|
path: 'codeflow/index.html',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'assets/index-14y4J8dP.js',
|
||||||
|
path: 'codeflow/assets/index-14y4J8dP.js',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'assets/index-C-libw4a.css',
|
||||||
|
path: 'codeflow/assets/index-C-libw4a.css',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
type UserAppOptions = {
|
||||||
|
user: string;
|
||||||
|
app: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class UserApp {
|
||||||
|
user: string;
|
||||||
|
app: string;
|
||||||
|
isTest: boolean;
|
||||||
|
constructor(options: UserAppOptions) {
|
||||||
|
this.user = options.user;
|
||||||
|
this.app = options.app;
|
||||||
|
if (this.user === 'test') {
|
||||||
|
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);
|
||||||
|
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;
|
||||||
|
const key = 'user:app:' + app + ':' + user;
|
||||||
|
const value = await redis.get(key);
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return JSON.parse(value);
|
||||||
|
}
|
||||||
|
async getFile(appFileUrl: string) {
|
||||||
|
const app = this.app;
|
||||||
|
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) {
|
||||||
|
const key = 'domain:' + domain;
|
||||||
|
const value = await redis.get(key);
|
||||||
|
if (value) {
|
||||||
|
const [_user, _app] = value.split(':');
|
||||||
|
return {
|
||||||
|
user: _user,
|
||||||
|
app: _app,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取域名对应的用户和应用
|
||||||
|
const fetchRes = await fetchDomain(domain).catch((err) => {
|
||||||
|
return {
|
||||||
|
code: 500,
|
||||||
|
message: err,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (fetchRes?.code !== 200) {
|
||||||
|
console.log('fetchRes is error', fetchRes);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const fetchData = fetchRes.data;
|
||||||
|
if (fetchData.status !== 'running') {
|
||||||
|
console.error('fetchData status is not running', fetchData.user, fetchData.key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = {
|
||||||
|
user: fetchData.user,
|
||||||
|
app: fetchData.key,
|
||||||
|
};
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 加载结束
|
||||||
|
* @param msg
|
||||||
|
*/
|
||||||
|
async setLoaded(status: 'running' | 'error' | 'loading', msg?: string) {
|
||||||
|
const app = this.app;
|
||||||
|
const user = this.user;
|
||||||
|
await setAppLoadStatus(user, app, {
|
||||||
|
status,
|
||||||
|
message: msg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 获取加载状态
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
async getLoaded() {
|
||||||
|
const app = this.app;
|
||||||
|
const user = this.user;
|
||||||
|
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;
|
||||||
|
const fetchRes = isTest ? await fetchTest(app) : await fetchApp({ user, app });
|
||||||
|
if (fetchRes?.code !== 200) {
|
||||||
|
console.log('获取缓存的cache错误', 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchData = fetchRes.data;
|
||||||
|
if (!fetchData.type) {
|
||||||
|
// console.error('fetchData type is error', fetchData);
|
||||||
|
// return false;
|
||||||
|
fetchData.type = 'oss';
|
||||||
|
}
|
||||||
|
if (fetchData.status !== 'running') {
|
||||||
|
console.error('fetchData status is not running', fetchData.user, fetchData.key);
|
||||||
|
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));
|
||||||
|
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) => {
|
||||||
|
if (file.name === 'index.html') {
|
||||||
|
indexHtml = wrapperResources(resources, file.path);
|
||||||
|
}
|
||||||
|
data[file.name] = wrapperResources(resources, file.path);
|
||||||
|
});
|
||||||
|
await redis.set('user:app:exist:' + app + ':' + user, indexHtml + '||etag||true', 'EX', 60 * 60 * 24 * 7); // 7天
|
||||||
|
await redis.set('user:app:permission:' + app + ':' + user, JSON.stringify(permission), 'EX', 60 * 60 * 24 * 7); // 7天
|
||||||
|
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;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
fileCheck(file: string) {
|
||||||
|
return checkFileExistsSync(file);
|
||||||
|
}
|
||||||
|
async close() {
|
||||||
|
// 关闭连接
|
||||||
|
await redis.quit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const downloadUserAppFiles = async (user: string, app: string, data: typeof demoData) => {
|
||||||
|
const {
|
||||||
|
data: { files, ...dataRest },
|
||||||
|
...rest
|
||||||
|
} = data;
|
||||||
|
const uploadFiles = path.join(fileStore, user, app);
|
||||||
|
if (!checkFileExistsSync(uploadFiles)) {
|
||||||
|
fs.mkdirSync(uploadFiles, { recursive: true });
|
||||||
|
}
|
||||||
|
const newFiles = [];
|
||||||
|
|
||||||
|
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 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 }); // 递归创建目录
|
||||||
|
}
|
||||||
|
const downloadURL = wrapperResources(serverPath, file.path);
|
||||||
|
// 下载文件到 destFile
|
||||||
|
await downloadFile(downloadURL, destFile);
|
||||||
|
const etag = nanoid();
|
||||||
|
newFiles.push({
|
||||||
|
name: file.name,
|
||||||
|
path: destFile.replace(fileStore, '') + '||' + etag,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!hasIndexHtml) {
|
||||||
|
newFiles.push({
|
||||||
|
name: 'index.html',
|
||||||
|
path: path.join(uploadFiles, 'index.html'),
|
||||||
|
});
|
||||||
|
fs.writeFileSync(path.join(uploadFiles, 'index.html'), JSON.stringify(files), {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
data: {
|
||||||
|
...dataRest,
|
||||||
|
files: newFiles,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export const checkFileExistsSync = (filePath: string) => {
|
||||||
|
try {
|
||||||
|
// 使用 F_OK 检查文件或目录是否存在
|
||||||
|
fs.accessSync(filePath, fs.constants.F_OK);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export const deleteUserAppFiles = async (user: string, app: string) => {
|
||||||
|
const uploadFiles = path.join(fileStore, user, app);
|
||||||
|
try {
|
||||||
|
fs.rmSync(uploadFiles, { recursive: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ENOENT') {
|
||||||
|
// 文件不存在
|
||||||
|
} else {
|
||||||
|
console.error('deleteUserAppFiles', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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}`);
|
||||||
|
}
|
||||||
|
const destStream = fs.createWriteStream(destFile);
|
||||||
|
|
||||||
|
// 使用 `pipeline` 将 `res.body` 中的数据传递给 `destStream`
|
||||||
|
await pipelineAsync(res.body, destStream);
|
||||||
|
|
||||||
|
console.log(`File downloaded to ${destFile}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clearAllUserApp = async () => {
|
||||||
|
// redis 删除 所有的 user:app:*
|
||||||
|
const keys = await redis.keys('user:app:*');
|
||||||
|
console.log('clearAllUserApp', keys);
|
||||||
|
if (keys.length > 0) {
|
||||||
|
const pipeline = redis.pipeline();
|
||||||
|
keys.forEach((key) => pipeline.del(key)); // 将每个键的删除操作添加到 pipeline 中
|
||||||
|
await pipeline.exec(); // 执行 pipeline 中的所有命令
|
||||||
|
console.log('All keys deleted successfully using pipeline');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export const setEtag = async (fileContent: string) => {
|
||||||
|
const eTag = crypto.createHash('md5').update(fileContent).digest('hex');
|
||||||
|
return eTag;
|
||||||
|
};
|
||||||
|
|
||||||
|
// redis 监听 user:app:exist:*的过期
|
||||||
|
subscriber.on('ready', () => {
|
||||||
|
console.log('Subscriber is ready and connected.');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 订阅 Redis 频道
|
||||||
|
subscriber.subscribe('__keyevent@0__:expired', (err, count) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Failed to subscribe: ', err);
|
||||||
|
} else {
|
||||||
|
console.log(`Subscribed to ${count} channel(s). Waiting for expired events...`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听消息事件
|
||||||
|
subscriber.on('message', (channel, message) => {
|
||||||
|
// 检查是否匹配 user:app:exist:* 模式
|
||||||
|
if (message.startsWith('user:app:exist:')) {
|
||||||
|
const [_user, _app, _exist, app, user] = message.split(':');
|
||||||
|
// 在这里执行你的逻辑,例如清理缓存或通知用户
|
||||||
|
console.log('User app exist key expired:', app, user);
|
||||||
|
const userApp = new UserApp({ user, app });
|
||||||
|
userApp.clearCacheData();
|
||||||
|
}
|
||||||
|
});
|
||||||
31
src/modules/user-app/user-home.ts
Normal file
31
src/modules/user-app/user-home.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import http from 'http';
|
||||||
|
import { getLoginUser } from '@/modules/auth.ts';
|
||||||
|
import { getUserConfig } from '@/modules/fm-manager/index.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: '/root/login/' });
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let redirectURL = '/root/center/';
|
||||||
|
try {
|
||||||
|
const token = user.token;
|
||||||
|
const resConfig = await getUserConfig(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/modules/ws-proxy/index.ts
Normal file
69
src/modules/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 '@/modules/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/modules/ws-proxy/manager.ts
Normal file
84
src/modules/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/modules/ws-proxy/proxy.ts
Normal file
44
src/modules/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 '@/modules/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;
|
||||||
|
};
|
||||||
@@ -5,8 +5,6 @@ import { program, Command } from 'commander';
|
|||||||
// import * as minioLib from './modules/minio.ts';
|
// import * as minioLib from './modules/minio.ts';
|
||||||
|
|
||||||
// export const redis = useContextKey('redis', () => redisLib.redis);
|
// export const redis = useContextKey('redis', () => redisLib.redis);
|
||||||
// export const redisPublisher = useContextKey('redisPublisher', () => redisLib.redisPublisher);
|
|
||||||
// export const redisSubscriber = useContextKey('redisSubscriber', () => redisLib.redisSubscriber);
|
|
||||||
// export const minioClient = useContextKey('minioClient', () => minioLib.minioClient);
|
// export const minioClient = useContextKey('minioClient', () => minioLib.minioClient);
|
||||||
// export const sequelize = useContextKey('sequelize', () => sequelizeLib.sequelize);
|
// export const sequelize = useContextKey('sequelize', () => sequelizeLib.sequelize);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useFileStore } from '@kevisual/use-config/file-store';
|
import { useFileStore } from '@kevisual/use-config/file-store';
|
||||||
import http from 'http';
|
import http from 'node:http';
|
||||||
import fs, { rm } from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
|
||||||
import { IncomingForm } from 'formidable';
|
import { IncomingForm } from 'formidable';
|
||||||
import { app, minioClient } from '@/app.ts';
|
import { app, minioClient } from '@/app.ts';
|
||||||
|
|
||||||
@@ -9,16 +8,10 @@ import { bucketName } from '@/modules/minio.ts';
|
|||||||
import { getContentType } from '@/utils/get-content-type.ts';
|
import { getContentType } from '@/utils/get-content-type.ts';
|
||||||
import { User } from '@/models/user.ts';
|
import { User } from '@/models/user.ts';
|
||||||
import { getContainerById } from '@/routes/container/module/get-container-file.ts';
|
import { getContainerById } from '@/routes/container/module/get-container-file.ts';
|
||||||
import { router, error, checkAuth, clients, writeEvents } from './router.ts';
|
import { router, error, checkAuth, writeEvents } from './router.ts';
|
||||||
import './index.ts';
|
import './index.ts';
|
||||||
|
import { handleRequest as PageProxy } from './page-proxy.ts';
|
||||||
const cacheFilePath = useFileStore('cache-file', { needExists: true });
|
const cacheFilePath = useFileStore('cache-file', { needExists: true });
|
||||||
// curl -X POST http://localhost:4000/api/upload -F "file=@readme.md"
|
|
||||||
// curl -X POST http://localhost:4000/api/upload \
|
|
||||||
// -F "file=@readme.md" \
|
|
||||||
// -F "file=@types/index.d.ts" \
|
|
||||||
// -F "description=This is a test upload" \
|
|
||||||
// -F "username=testuser"
|
|
||||||
|
|
||||||
router.get('/api/app/upload', async (req, res) => {
|
router.get('/api/app/upload', async (req, res) => {
|
||||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
@@ -177,21 +170,28 @@ router.get('/api/container/file/:id', async (req, res) => {
|
|||||||
res.end(JSON.stringify(container));
|
res.end(JSON.stringify(container));
|
||||||
});
|
});
|
||||||
|
|
||||||
// router.get('/api/code/version', async (req, res) => {
|
const simpleAppsPrefixs = [
|
||||||
// const version = VERSION;
|
"/api/app/",
|
||||||
// res.writeHead(200, {
|
"/api/micro-app/",
|
||||||
// 'Content-Type': 'application/json',
|
"/api/events",
|
||||||
// });
|
"/api/s1/",
|
||||||
// res.end(JSON.stringify({ code: 200, data: { version } }));
|
"/api/container/",
|
||||||
// });
|
"/api/resource/",
|
||||||
|
];
|
||||||
|
|
||||||
export const uploadMiddleware = async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
export const handleRequest = async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||||
if (req.url?.startsWith('/api/router')) {
|
if (req.url?.startsWith('/api/router')) {
|
||||||
|
// router自己管理
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 设置跨域
|
if (req.url && simpleAppsPrefixs.some(prefix => req.url!.startsWith(prefix))) {
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
// 简单应用路由处理
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST');
|
// 设置跨域
|
||||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
return router.parse(req, res);
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST');
|
||||||
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||||
|
return router.parse(req, res);
|
||||||
|
}
|
||||||
|
// 其他请求交给页面代理处理
|
||||||
|
return PageProxy(req, res);
|
||||||
};
|
};
|
||||||
386
src/routes-simple/page-proxy.ts
Normal file
386
src/routes-simple/page-proxy.ts
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
import { getDNS, isIpv4OrIpv6, isLocalhost } from '../modules/fm-manager/index.ts';
|
||||||
|
import http from 'node:http';
|
||||||
|
import https from 'node:https';
|
||||||
|
import { UserApp } from '../modules/user-app/index.ts';
|
||||||
|
import { config, fileStore } from '../modules/config.ts';
|
||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { getContentType } from '../modules/fm-manager/index.ts';
|
||||||
|
import { createRefreshHtml } from '../modules/html/create-refresh-html.ts';
|
||||||
|
import { fileProxy, getTextFromStreamAndAddStat, httpProxy, aiProxy } from '../modules/fm-manager/index.ts';
|
||||||
|
import { UserPermission } from '@kevisual/permission';
|
||||||
|
import { getLoginUser } from '../modules/auth.ts';
|
||||||
|
import { rediretHome } from '../modules/user-app/index.ts';
|
||||||
|
import { logger } from '../modules/logger.ts';
|
||||||
|
import { UserV1Proxy } from '../modules/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': 'image/x-icon' });
|
||||||
|
res.end('proxy no favicon.ico\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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']) {
|
||||||
|
header.authorization = req.headers['Authorization'];
|
||||||
|
} else if (req.headers?.['authorization']) {
|
||||||
|
header.authorization = req.headers['authorization'];
|
||||||
|
}
|
||||||
|
// 提取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,
|
||||||
|
method: req.method,
|
||||||
|
headers: {
|
||||||
|
...header,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (_u.port) {
|
||||||
|
// @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 = protocol.request(options, (proxyRes) => {
|
||||||
|
// 将代理服务器的响应头和状态码返回给客户端
|
||||||
|
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||||
|
// 将代理响应流写入客户端响应
|
||||||
|
proxyRes.pipe(res, { end: true });
|
||||||
|
});
|
||||||
|
// 处理代理请求的错误事件
|
||||||
|
proxyReq.on('error', (err) => {
|
||||||
|
logger.error(`Proxy request error: ${err.message}`);
|
||||||
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
||||||
|
res.write(`Proxy request error: ${err.message}`);
|
||||||
|
});
|
||||||
|
// 处理 POST 请求的请求体(传递数据到目标服务器)
|
||||||
|
req.pipe(proxyReq, { end: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url.startsWith('/api') || req.url.startsWith('/v1')) {
|
||||||
|
res.end('not catch api');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dns = getDNS(req);
|
||||||
|
// 配置可以跨域
|
||||||
|
// 配置可以访问的域名 localhost, xiongxiao.me
|
||||||
|
const _orings = allowedOrigins || [];
|
||||||
|
const host = dns.hostName;
|
||||||
|
if (
|
||||||
|
_orings.some((item) => {
|
||||||
|
return host.includes(item);
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
|
}
|
||||||
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE, PUT');
|
||||||
|
|
||||||
|
let user, app;
|
||||||
|
let domainApp = false;
|
||||||
|
if (isLocalhost(dns.hostName)) {
|
||||||
|
// 本地开发环境 测试
|
||||||
|
// user = 'root';
|
||||||
|
// 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 (domain && dns.hostName !== domain) {
|
||||||
|
// redis获取域名对应的用户和应用
|
||||||
|
domainApp = true;
|
||||||
|
const data = await UserApp.getDomainApp(dns.hostName);
|
||||||
|
if (!data) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('Invalid domain\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data.user || !data.app) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('Invalid domain config\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
user = data.user;
|
||||||
|
app = data.app;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
if (!domainApp) {
|
||||||
|
// 原始url地址
|
||||||
|
const urls = url.split('/');
|
||||||
|
if (urls.length < 3) {
|
||||||
|
console.log('urls errpr', urls);
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/html' });
|
||||||
|
res.write('Invalid Proxy URL\n');
|
||||||
|
return res.end();
|
||||||
|
}
|
||||||
|
const [_, _user, _app] = urls;
|
||||||
|
if (_app && urls.length === 3) {
|
||||||
|
// 重定向到
|
||||||
|
res.writeHead(302, { Location: `${url}/` });
|
||||||
|
return res.end();
|
||||||
|
}
|
||||||
|
if (!_user || !_app) {
|
||||||
|
res.write('Invalid URL\n');
|
||||||
|
return res.end();
|
||||||
|
}
|
||||||
|
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, 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);
|
||||||
|
createErrorPage();
|
||||||
|
userApp.setLoaded('error', 'setCacheData error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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}/`, '');
|
||||||
|
}
|
||||||
|
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; charset=utf-8', tips: 'App Cache expired, Please refresh' });
|
||||||
|
res.write(createRefreshHtml(user, app));
|
||||||
|
res.end();
|
||||||
|
await userApp.clearCacheData();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 如果 content是 'application/octet-stream' 会下载文件, 添加文件后缀
|
||||||
|
if (contentType === 'application/octet-stream') {
|
||||||
|
// 提取文件名,只保留文件名而不是整个路径
|
||||||
|
const fileName = path.basename(indexFilePath);
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename=${fileName}`);
|
||||||
|
}
|
||||||
|
// 不存在的文件,返回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 {
|
||||||
|
const [appFilePath, eTag] = appFile.split('||');
|
||||||
|
// 检查 If-None-Match 头判断缓存是否有效
|
||||||
|
if (req.headers['if-none-match'] === eTag) {
|
||||||
|
res.statusCode = 304; // 内容未修改
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const filePath = path.join(fileStore, appFilePath);
|
||||||
|
let contentType = getContentType(filePath);
|
||||||
|
const isHTML = contentType.includes('html');
|
||||||
|
// 如果 content是 'application/octet-stream' 会下载文件, 添加文件后缀
|
||||||
|
if (contentType === 'application/octet-stream') {
|
||||||
|
// 提取文件名,只保留文件名而不是整个路径
|
||||||
|
const fileName = path.basename(appFilePath);
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename=${fileName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userApp.fileCheck(filePath)) {
|
||||||
|
console.error('File expired', filePath);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -11,7 +11,7 @@ import { ConfigModel } from '@/routes/config/models/model.ts';
|
|||||||
import { validateDirectory } from './util.ts';
|
import { validateDirectory } from './util.ts';
|
||||||
import { pick } from 'lodash-es';
|
import { pick } from 'lodash-es';
|
||||||
import { getFileStat } from '@/routes/file/index.ts';
|
import { getFileStat } from '@/routes/file/index.ts';
|
||||||
import { logger } from '@/logger/index.ts';
|
import { logger } from '@/modules/logger.ts';
|
||||||
|
|
||||||
const cacheFilePath = useFileStore('cache-file', { needExists: true });
|
const cacheFilePath = useFileStore('cache-file', { needExists: true });
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
import './page-proxy.ts';
|
import './page-proxy.ts';
|
||||||
|
import './list.ts'
|
||||||
189
src/routes/app-manager/proxy/list.ts
Normal file
189
src/routes/app-manager/proxy/list.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { deleteUserAppFiles } from '@/modules/user-app/index.ts';
|
||||||
|
import { app, redis } from '@/app.ts';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { fileStore } from '@/modules/config.ts';
|
||||||
|
import { getAppLoadStatus } from '@/modules/user-app/index.ts';
|
||||||
|
import { getLoginUser } from '@/modules/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: '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: 'page-proxy-app',
|
||||||
|
key: 'delete',
|
||||||
|
middleware: ['auth-admin'],
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
const { user, app } = ctx.query;
|
||||||
|
try {
|
||||||
|
const userApp = new CenterUserApp({ user, app });
|
||||||
|
await userApp.clearCacheData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
ctx.throw('删除失败');
|
||||||
|
}
|
||||||
|
ctx.body = 'successfully';
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'page-proxy-app',
|
||||||
|
key: 'deleteAll',
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
const keys = await redis.keys('user:app:*');
|
||||||
|
for (const key of keys) {
|
||||||
|
await redis.set(key, '', 'EX', 1);
|
||||||
|
}
|
||||||
|
ctx.body = {
|
||||||
|
keys,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'page-proxy-app',
|
||||||
|
key: 'clear',
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
const keys = await redis.keys('user:app:*');
|
||||||
|
if (keys.length > 0) {
|
||||||
|
await redis.del(...keys);
|
||||||
|
}
|
||||||
|
fs.rmSync(fileStore, { recursive: true });
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
keys,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'page-proxy-app',
|
||||||
|
key: 'get',
|
||||||
|
middleware: ['auth-admin'],
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
const { user, app } = ctx.query;
|
||||||
|
if (!user || !app) {
|
||||||
|
if (!user) {
|
||||||
|
ctx.throw('user is required');
|
||||||
|
}
|
||||||
|
if (!app) {
|
||||||
|
ctx.throw('app is required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const userApp = new CenterUserApp({ user, app });
|
||||||
|
const cache = await userApp.getCache();
|
||||||
|
if (!cache) {
|
||||||
|
ctx.throw('Not Found 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);
|
||||||
Reference in New Issue
Block a user