mirror of
https://github.com/LibreSpark/LibreTV.git
synced 2026-05-06 22:02:33 +08:00
251 lines
6.9 KiB
JavaScript
251 lines
6.9 KiB
JavaScript
import path from 'path';
|
||
import express from 'express';
|
||
import axios from 'axios';
|
||
import cors from 'cors';
|
||
import { fileURLToPath } from 'url';
|
||
import fs from 'fs';
|
||
import crypto from 'crypto';
|
||
import dotenv from 'dotenv';
|
||
|
||
dotenv.config();
|
||
|
||
const __filename = fileURLToPath(import.meta.url);
|
||
const __dirname = path.dirname(__filename);
|
||
|
||
const config = {
|
||
port: process.env.PORT || 8080,
|
||
password: process.env.PASSWORD || '',
|
||
corsOrigin: process.env.CORS_ORIGIN || '*',
|
||
timeout: parseInt(process.env.REQUEST_TIMEOUT || '5000'),
|
||
maxRetries: parseInt(process.env.MAX_RETRIES || '2'),
|
||
cacheMaxAge: process.env.CACHE_MAX_AGE || '1d',
|
||
userAgent: process.env.USER_AGENT || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||
debug: process.env.DEBUG === 'true'
|
||
};
|
||
|
||
const log = (...args) => {
|
||
if (config.debug) {
|
||
console.log('[DEBUG]', ...args);
|
||
}
|
||
};
|
||
|
||
const app = express();
|
||
|
||
app.use(cors({
|
||
origin: config.corsOrigin,
|
||
methods: ['GET', 'POST'],
|
||
allowedHeaders: ['Content-Type', 'Authorization']
|
||
}));
|
||
|
||
app.use((req, res, next) => {
|
||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
||
res.setHeader('X-XSS-Protection', '1; mode=block');
|
||
next();
|
||
});
|
||
|
||
function sha256Hash(input) {
|
||
return new Promise((resolve) => {
|
||
const hash = crypto.createHash('sha256');
|
||
hash.update(input);
|
||
resolve(hash.digest('hex'));
|
||
});
|
||
}
|
||
|
||
async function renderPage(filePath, password) {
|
||
let content = fs.readFileSync(filePath, 'utf8');
|
||
if (password !== '') {
|
||
const sha256 = await sha256Hash(password);
|
||
content = content.replace('{{PASSWORD}}', sha256);
|
||
} else {
|
||
content = content.replace('{{PASSWORD}}', '');
|
||
}
|
||
return content;
|
||
}
|
||
|
||
app.get(['/', '/index.html', '/player.html'], async (req, res) => {
|
||
try {
|
||
let filePath;
|
||
switch (req.path) {
|
||
case '/player.html':
|
||
filePath = path.join(__dirname, 'player.html');
|
||
break;
|
||
default: // '/' 和 '/index.html'
|
||
filePath = path.join(__dirname, 'index.html');
|
||
break;
|
||
}
|
||
|
||
const content = await renderPage(filePath, config.password);
|
||
res.send(content);
|
||
} catch (error) {
|
||
console.error('页面渲染错误:', error);
|
||
res.status(500).send('读取静态页面失败');
|
||
}
|
||
});
|
||
|
||
app.get('/s=:keyword', async (req, res) => {
|
||
try {
|
||
const filePath = path.join(__dirname, 'index.html');
|
||
const content = await renderPage(filePath, config.password);
|
||
res.send(content);
|
||
} catch (error) {
|
||
console.error('搜索页面渲染错误:', error);
|
||
res.status(500).send('读取静态页面失败');
|
||
}
|
||
});
|
||
|
||
function isValidUrl(urlString) {
|
||
try {
|
||
const parsed = new URL(urlString);
|
||
const allowedProtocols = ['http:', 'https:'];
|
||
|
||
// 从环境变量获取阻止的主机名列表
|
||
const blockedHostnames = (process.env.BLOCKED_HOSTS || 'localhost,127.0.0.1,0.0.0.0,::1').split(',');
|
||
|
||
// 从环境变量获取阻止的 IP 前缀
|
||
const blockedPrefixes = (process.env.BLOCKED_IP_PREFIXES || '192.168.,10.,172.').split(',');
|
||
|
||
if (!allowedProtocols.includes(parsed.protocol)) return false;
|
||
if (blockedHostnames.includes(parsed.hostname)) return false;
|
||
|
||
for (const prefix of blockedPrefixes) {
|
||
if (parsed.hostname.startsWith(prefix)) return false;
|
||
}
|
||
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 验证代理请求的鉴权
|
||
function validateProxyAuth(req) {
|
||
const authHash = req.query.auth;
|
||
const timestamp = req.query.t;
|
||
|
||
// 获取服务器端密码哈希
|
||
const serverPassword = config.password;
|
||
if (!serverPassword) {
|
||
console.error('服务器未设置 PASSWORD 环境变量,代理访问被拒绝');
|
||
return false;
|
||
}
|
||
|
||
// 使用 crypto 模块计算 SHA-256 哈希
|
||
const serverPasswordHash = crypto.createHash('sha256').update(serverPassword).digest('hex');
|
||
|
||
if (!authHash || authHash !== serverPasswordHash) {
|
||
console.warn('代理请求鉴权失败:密码哈希不匹配');
|
||
console.warn(`期望: ${serverPasswordHash}, 收到: ${authHash}`);
|
||
return false;
|
||
}
|
||
|
||
// 验证时间戳(10分钟有效期)
|
||
if (timestamp) {
|
||
const now = Date.now();
|
||
const maxAge = 10 * 60 * 1000; // 10分钟
|
||
if (now - parseInt(timestamp) > maxAge) {
|
||
console.warn('代理请求鉴权失败:时间戳过期');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
app.get('/proxy/:encodedUrl', async (req, res) => {
|
||
try {
|
||
// 验证鉴权
|
||
if (!validateProxyAuth(req)) {
|
||
return res.status(401).json({
|
||
success: false,
|
||
error: '代理访问未授权:请检查密码配置或鉴权参数'
|
||
});
|
||
}
|
||
|
||
const encodedUrl = req.params.encodedUrl;
|
||
const targetUrl = decodeURIComponent(encodedUrl);
|
||
|
||
// 安全验证
|
||
if (!isValidUrl(targetUrl)) {
|
||
return res.status(400).send('无效的 URL');
|
||
}
|
||
|
||
log(`代理请求: ${targetUrl}`);
|
||
|
||
// 添加请求超时和重试逻辑
|
||
const maxRetries = config.maxRetries;
|
||
let retries = 0;
|
||
|
||
const makeRequest = async () => {
|
||
try {
|
||
return await axios({
|
||
method: 'get',
|
||
url: targetUrl,
|
||
responseType: 'stream',
|
||
timeout: config.timeout,
|
||
headers: {
|
||
'User-Agent': config.userAgent
|
||
}
|
||
});
|
||
} catch (error) {
|
||
if (retries < maxRetries) {
|
||
retries++;
|
||
log(`重试请求 (${retries}/${maxRetries}): ${targetUrl}`);
|
||
return makeRequest();
|
||
}
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
const response = await makeRequest();
|
||
|
||
// 转发响应头(过滤敏感头)
|
||
const headers = { ...response.headers };
|
||
const sensitiveHeaders = (
|
||
process.env.FILTERED_HEADERS ||
|
||
'content-security-policy,cookie,set-cookie,x-frame-options,access-control-allow-origin'
|
||
).split(',');
|
||
|
||
sensitiveHeaders.forEach(header => delete headers[header]);
|
||
res.set(headers);
|
||
|
||
// 管道传输响应流
|
||
response.data.pipe(res);
|
||
} catch (error) {
|
||
console.error('代理请求错误:', error.message);
|
||
if (error.response) {
|
||
res.status(error.response.status || 500);
|
||
error.response.data.pipe(res);
|
||
} else {
|
||
res.status(500).send(`请求失败: ${error.message}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
app.use(express.static(path.join(__dirname), {
|
||
maxAge: config.cacheMaxAge
|
||
}));
|
||
|
||
app.use((err, req, res, next) => {
|
||
console.error('服务器错误:', err);
|
||
res.status(500).send('服务器内部错误');
|
||
});
|
||
|
||
app.use((req, res) => {
|
||
res.status(404).send('页面未找到');
|
||
});
|
||
|
||
// 启动服务器
|
||
app.listen(config.port, () => {
|
||
console.log(`服务器运行在 http://localhost:${config.port}`);
|
||
if (config.password !== '') {
|
||
console.log('用户登录密码已设置');
|
||
} else {
|
||
console.log('警告: 未设置 PASSWORD 环境变量,用户将被要求设置密码');
|
||
}
|
||
if (config.debug) {
|
||
console.log('调试模式已启用');
|
||
console.log('配置:', { ...config, password: config.password ? '******' : '' });
|
||
}
|
||
});
|