Files
code-center/src/routes-simple/page-proxy.ts

423 lines
14 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, pipeFileStream, pipeProxyReq, pipeProxyRes } 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 { myConfig as 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/v1-ws-proxy/proxy.ts';
import { UserV3Proxy } from '@/modules/v3/index.ts';
import { hasBadUser, userIsBanned, appIsBanned, userPathIsBanned } from '@/modules/off/index.ts';
import { robotsTxt } from '@/modules/html/index.ts';
import { isBun } from '@/utils/get-engine.ts';
import { N5Proxy } from '@/modules/n5/index.ts';
const domain = config?.proxy?.domain;
const allowedOrigins = config?.proxy?.allowedOrigin || [];
const noProxyUrl = ['/', '/favicon.ico'];
const notAuthPathList = [
{
user: 'root',
paths: ['center'],
},
{
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;
};
const forBadUser = (req: http.IncomingMessage, res: http.ServerResponse) => {
// TODO: 记录日志封禁IP等操作
const dns = getDNS(req);
logger.warn(`forBadUser: Bad user access from IP: ${dns.ip}, Host: ${dns.hostName}, URL: ${req.url}`);
// 这里可以添加更多的处理逻辑比如封禁IP等
}
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.debug('proxy host', host);
logger.debug('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.debug('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 });
pipeProxyRes(proxyRes, res);
});
// 处理代理请求的错误事件
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 });
pipeProxyReq(req, proxyReq, res);
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 (
host && _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;
const isDev = isLocalhost(dns?.hostName);
if (isDev) {
console.debug('开发环境访问:', req.url, 'Host:', dns.hostName);
if (req.url === '/') {
res.writeHead(302, { Location: '/root/router-studio/' });
res.end();
return;
}
} 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');
forBadUser(req, res);
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 === '/') {
rediretHome(req, res);
return;
}
res.write('No proxy for this URL\n');
return res.end();
}
if (!domainApp) {
// 原始url地址
const urls = url.split('/');
const [_, _user, _app] = urls;
if (urls.length < 3) {
if (_user === 'robots.txt') {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(robotsTxt);
return;
}
if (userPathIsBanned(_user)) {
logger.warn(`Bad user access from IP: ${dns.ip}, Host: ${dns.hostName}, URL: ${req.url}`);
} else {
console.log('urls error', urls, 'originUrl:', url);
}
res.writeHead(404, { 'Content-Type': 'text/html' });
res.write('Invalid Proxy URL\n');
if (hasBadUser(_user)) {
forBadUser(req, res);
}
return res.end();
} else {
if (userPathIsBanned(_user) || userPathIsBanned(_app)) {
logger.warn(`Bad user access from IP: ${dns.ip}, Host: ${dns.hostName}, URL: ${req.url}`);
return forBadUser(req, res);
}
}
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,
});
}
if (user === 'n5') {
return N5Proxy(req, res, {
createNotFoundPage,
});
}
if (user !== 'api' && app === 'v3') {
return UserV3Proxy(req, res, {
createNotFoundPage,
});
}
const userApp = new UserApp({ user, app });
let isExist = await userApp.getExist();
logger.debug('userApp', userApp, isExist);
if (userIsBanned(user) || appIsBanned(app)) {
if (!isDev) forBadUser(req, res);
return createErrorPage();
}
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,
});
console.log('checkPermission', checkPermission, 'loginUser:', loginUser, password)
if (!checkPermission.success) {
return createNotFoundPage('no permission');
}
}
const indexFile = isExist.indexFilePath; // 已经必定存在了
try {
let appFileUrl: string;
appFileUrl = url.replace(`/${user}/${app}/`, '');
appFileUrl = decodeURIComponent(appFileUrl); // Decode URL components
let appFile = await userApp.getFile(appFileUrl);
if (!appFile && domainApp) {
const domainAppFileUrl = url.replace(`/`, '');
appFile = await userApp.getFile(domainAppFileUrl);
}
if (!appFile && url.endsWith('/')) {
appFile = await userApp.getFile(appFileUrl + 'index.html');
} else if (!appFile && !url.endsWith('/')) {
appFile = await userApp.getFile(appFileUrl + '.html');
}
if (isExist.proxy) {
let proxyUrl = appFile || isExist.indexFilePath;
if (!proxyUrl.startsWith('http')) {
return createNotFoundPage('Invalid proxy url');
}
console.log('proxyUrl indexFile', 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.end(createRefreshHtml(user, app));
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.writeHead(200, Object.fromEntries(headers));
if (isHTML) {
const newHtml = await getTextFromStreamAndAddStat(fs.createReadStream(filePath));
resContent = newHtml.html;
headers.set('Content-Length', newHtml.contentLength.toString());
res.end(resContent);
} else {
pipeFileStream(filePath, res);
}
return;
}
} catch (error) {
console.error('getFile error', error);
}
};