page-proxy/src/module/index.ts
2025-03-30 00:38:10 +08:00

335 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { getDNS, isIpv4OrIpv6, isLocalhost } from '@/utils/dns.ts';
import http from 'http';
import { UserApp } from './get-user-app.ts';
import { config, fileStore } from '../module/config.ts';
import path from 'path';
import fs from 'fs';
import { getContentType } from './get-content-type.ts';
import { createRefreshHtml } from './html/create-refresh-html.ts';
import { fileProxy } from './proxy/file-proxy.ts';
import { getTextFromStreamAndAddStat, httpProxy } from './proxy/http-proxy.ts';
import { UserPermission } from '@kevisual/permission';
import { getLoginUser } from '@/middleware/auth.ts';
const api = config?.api || { host: 'kevisual.xiongxiao.me', path: '/api/router' };
const domain = config?.proxy?.domain || 'kevisual.xiongxiao.me';
const allowedOrigins = config?.proxy?.allowedOrigin || [];
const home = config?.proxy?.home || '/ai/chat';
const noProxyUrl = ['/', '/favicon.ico'];
export const handleRequest = async (req: http.IncomingMessage, res: http.ServerResponse) => {
const querySearch = new URL(req.url, `http://${req.headers.host}`).searchParams;
const password = querySearch.get('p');
const loginUser = await getLoginUser(req);
console.log('loginUser', loginUser);
if (req.url === '/favicon.ico') {
res.writeHead(200, { 'Content-Type': 'image/x-icon' });
res.end('proxy no favicon.ico\n');
return;
}
if (req.url.startsWith('/api/proxy')) {
// 已经代理过了
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'];
console.log('host', host);
console.log('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;
}
// 创建代理请求
const proxyReq = http.request(options, (proxyRes) => {
// 将代理服务器的响应头和状态码返回给客户端
res.writeHead(proxyRes.statusCode, proxyRes.headers);
// 将代理响应流写入客户端响应
proxyRes.pipe(res, { end: true });
});
// 处理代理请求的错误事件
proxyReq.on('error', (err) => {
console.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');
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 (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();
}
// 不是域名代理且是在不代理的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 userApp = new UserApp({ user, app });
let isExist = await userApp.getExist();
const createRefreshPage = () => {
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) => {
res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
res.write(msg || 'Not Found App\n');
res.end();
};
if (!isExist) {
try {
const { code, loading, message } = await userApp.setCacheData();
if (loading || code === 20000) {
return createRefreshPage();
} 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();
}
const notAuthUser = ['root', 'admin', 'user', 'public'];
if (!notAuthUser.includes(user)) {
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,
});
console.log('checkPermission', loginUser, permission);
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
const appFile = await userApp.getFile(appFileUrl);
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,
});
// userApp.clearCacheData()
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' });
res.write('App Cache expired, Please refresh\n');
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);
}
};