This commit is contained in:
2025-11-27 19:20:46 +08:00
parent 7cba8ae8b1
commit 2838d6163e
37 changed files with 2553 additions and 256 deletions

66
src/modules/auth.ts Normal file
View 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;
}
};

View File

@@ -1,17 +1,87 @@
import path from 'path';
import dotenv from 'dotenv';
// import { useConfig } from '@kevisual/use-config/env';
import { useConfig } from '@kevisual/use-config';
import { useFileStore } from '@kevisual/use-config/file-store';
import { minioResources } from './minio.ts';
export const envFiles = [
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 config = useConfig() as any;
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,
},
};

View 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';
};

View 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
}
}

View 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'

View 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);
};

View 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);
};

View 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();
};

View 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');
}
};

View 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));

View 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>
`;
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View 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>`);
};

View File

@@ -1,5 +1,6 @@
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';
const minioConfig = {
endPoint: config.MINIO_ENDPOINT || 'localhost',
@@ -8,10 +9,14 @@ const minioConfig = {
accessKey: config.MINIO_ACCESS_KEY,
secretKey: config.MINIO_SECRET_KEY,
};
const { port, endPoint, useSSL } = minioConfig;
// console.log('minioConfig', minioConfig);
export const minioClient = new Client(minioConfig);
export const bucketName = config.MINIO_BUCKET_NAME || 'resources';
export const minioUrl = `http${useSSL ? 's' : ''}://${endPoint}:${port || 9000}`;
export const minioResources = `${minioUrl}/resources`;
if (!minioClient) {
throw new Error('Minio client not initialized');
}

View File

@@ -34,6 +34,31 @@ redis.on('error', (err) => {
console.error('Redis 连接错误', err);
});
// 初始化 Redis 客户端
export const redisPublisher = createRedisClient(); // 用于发布消息
export const redisSubscriber = createRedisClient(); // 用于订阅消息
export const subscriber = redis.duplicate(); // 创建一个订阅者连接
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);

View 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分钟过期
};

View 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();
}
});

View 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();
}
};

View 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);
}
}

View File

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

View File

@@ -0,0 +1,44 @@
import { IncomingMessage, ServerResponse } from 'http';
import { wsProxyManager } from './index.ts';
import { App } from '@kevisual/router';
import { logger } from '../logger.ts';
import { getLoginUser } from '@/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;
};