11111
This commit is contained in:
parent
be808e0174
commit
a211083da5
@ -1,6 +1,12 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"sharp": "^0.33.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"javascript-obfuscator": "^4.1.1"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.1+sha512.1acb565e6193efbebda772702950469150cf12bcc764262e7587e71d19dc98a423dff9536e57ea44c49bdf790ff694e83c27be5faa23d67e0c033b583be4bfcf"
|
||||
"packageManager": "pnpm@9.15.1+sha512.1acb565e6193efbebda772702950469150cf12bcc764262e7587e71d19dc98a423dff9536e57ea44c49bdf790ff694e83c27be5faa23d67e0c033b583be4bfcf",
|
||||
"scripts": {
|
||||
"start": "node source.js"
|
||||
}
|
||||
}
|
||||
|
677
source.js
677
source.js
@ -5,134 +5,217 @@ const querystring = require('querystring');
|
||||
const fs = require('fs');
|
||||
const pathModule = require('path');
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
const sharp = require('sharp');
|
||||
|
||||
const cacheDir = pathModule.join(__dirname, '.cache');
|
||||
const args = process.argv.slice(2);
|
||||
const CACHE_DIR_NAME = '.cache';
|
||||
const DEFAULT_PORT = 9001;
|
||||
const DEFAULT_API_ENDPOINT = 'http://183.6.121.121:9005/get/';
|
||||
|
||||
const cacheDir = pathModule.join(__dirname, CACHE_DIR_NAME);
|
||||
const pathIndex = {};
|
||||
|
||||
// 增加访问计数器
|
||||
// 访问计数器
|
||||
const viewsInfo = {
|
||||
// 请求次数
|
||||
request: 0,
|
||||
// 缓存命中次数
|
||||
cacheHit: 0,
|
||||
// API调用次数
|
||||
apiCall: 0,
|
||||
// 缓存调用次数
|
||||
cacheCall: 0,
|
||||
// 缓存读取错误次数
|
||||
cacheReadError: 0,
|
||||
// API 调用错误次数
|
||||
fetchApiError: 0,
|
||||
// API 调用错误次数
|
||||
fetchApiWarning: 0,
|
||||
increment: function(key) {
|
||||
if (this.hasOwnProperty(key)) {
|
||||
this[key]++;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 默认端口号和 API 地址
|
||||
let port = 9001;
|
||||
let apiEndpoint = 'http://183.6.121.121:9005/get/';
|
||||
let port = DEFAULT_PORT;
|
||||
let apiEndpoint = DEFAULT_API_ENDPOINT;
|
||||
|
||||
// 解析命令行参数
|
||||
args.forEach(arg => {
|
||||
// 去掉--
|
||||
if (arg.startsWith('--')) {
|
||||
arg = arg.substring(2);
|
||||
}
|
||||
const [key, value] = arg.split('=');
|
||||
if (key === 'port') {
|
||||
port = parseInt(value, 10);
|
||||
} else if (key === 'api') {
|
||||
apiEndpoint = value;
|
||||
}
|
||||
});
|
||||
|
||||
// 确保缓存目录存在
|
||||
if (!fs.existsSync(cacheDir)) {
|
||||
fs.mkdirSync(cacheDir);
|
||||
// 解析命令行参数函数
|
||||
function parseArguments() {
|
||||
const args = process.argv.slice(2);
|
||||
args.forEach(arg => {
|
||||
const cleanArg = arg.startsWith('--') ? arg.substring(2) : arg;
|
||||
const [key, value] = cleanArg.split('=');
|
||||
if (key === 'port' && value) {
|
||||
const parsedPort = parseInt(value, 10);
|
||||
if (!isNaN(parsedPort)) {
|
||||
port = parsedPort;
|
||||
}
|
||||
} else if (key === 'api' && value) {
|
||||
apiEndpoint = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化函数,包含参数解析和目录创建
|
||||
function initializeApp() {
|
||||
parseArguments();
|
||||
if (!fs.existsSync(cacheDir)) {
|
||||
try {
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
console.log(`Cache directory created: ${cacheDir}`);
|
||||
} catch (err) {
|
||||
console.error(`Error creating cache directory ${cacheDir}:`, err);
|
||||
process.exit(1); // Exit if cache directory cannot be created
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initializeApp();
|
||||
|
||||
const CACHE_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const CACHE_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
const HTTP_STATUS = {
|
||||
OK: 200,
|
||||
NO_CONTENT: 204,
|
||||
REDIRECT: 302,
|
||||
NOT_MODIFIED: 304,
|
||||
BAD_REQUEST: 400,
|
||||
NOT_FOUND: 404,
|
||||
INTERNAL_SERVER_ERROR: 500,
|
||||
BAD_GATEWAY: 502,
|
||||
};
|
||||
|
||||
// 定时清理过期缓存数据
|
||||
setInterval(() => {
|
||||
const currentTime = Date.now();
|
||||
for (const key in pathIndex) {
|
||||
if (currentTime - pathIndex[key].timestamp > 24 * 60 * 60 * 1000) {
|
||||
if (currentTime - pathIndex[key].timestamp > CACHE_EXPIRY_MS) {
|
||||
delete pathIndex[key];
|
||||
// Consider deleting actual cache files as well if not managed elsewhere
|
||||
}
|
||||
}
|
||||
}, 60 * 60 * 1000); // 每隔 1 小时执行一次
|
||||
}, CACHE_CLEANUP_INTERVAL_MS);
|
||||
|
||||
// 处理请求并返回数据
|
||||
const server = http.createServer(async (req, res) => {
|
||||
// 统一发送错误响应
|
||||
function sendErrorResponse(res, statusCode, message) {
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(statusCode, { 'Content-Type': 'text/plain;charset=UTF-8' });
|
||||
res.end(message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Request Handling Logic ---
|
||||
|
||||
async function handleFavicon(req, res) {
|
||||
res.writeHead(HTTP_STATUS.NO_CONTENT);
|
||||
res.end();
|
||||
}
|
||||
|
||||
async function handleEndpoint(req, res, parsedUrl) {
|
||||
if (parsedUrl.query.api) {
|
||||
const urlRegex = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([\/\w.-]*)*\/?$/;
|
||||
if (urlRegex.test(parsedUrl.query.api)) {
|
||||
apiEndpoint = parsedUrl.query.api;
|
||||
console.log(`API endpoint updated to: ${apiEndpoint}`);
|
||||
}
|
||||
}
|
||||
res.writeHead(HTTP_STATUS.OK, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify({
|
||||
code: HTTP_STATUS.OK,
|
||||
data: {
|
||||
api: apiEndpoint,
|
||||
port: port,
|
||||
cacheDir: cacheDir,
|
||||
pathIndexCount: Object.keys(pathIndex).length,
|
||||
viewsInfo: {
|
||||
request: viewsInfo.request,
|
||||
cacheHit: viewsInfo.cacheHit,
|
||||
apiCall: viewsInfo.apiCall,
|
||||
cacheCall: viewsInfo.cacheCall,
|
||||
cacheReadError: viewsInfo.cacheReadError,
|
||||
fetchApiError: viewsInfo.fetchApiError,
|
||||
fetchApiWarning: viewsInfo.fetchApiWarning,
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleApiRedirect(res, apiData) {
|
||||
res.writeHead(HTTP_STATUS.REDIRECT, { Location: apiData.data.url });
|
||||
res.end();
|
||||
}
|
||||
|
||||
async function processSuccessfulApiData(apiData, uniqidhex, reqPath, token, sign, res) {
|
||||
const { url: realUrl, cloudtype, expiration, path: apiPath, headers, uniqid } = apiData.data;
|
||||
const data = { realUrl, cloudtype, expiration: expiration * 1000, path: apiPath, headers, uniqid };
|
||||
|
||||
pathIndex[uniqidhex] = { uniqid: data.uniqid, timestamp: Date.now() };
|
||||
const cacheMetaFile = pathModule.join(cacheDir, `${data.uniqid}.meta`);
|
||||
const cacheContentFile = pathModule.join(cacheDir, `${data.uniqid}.content`);
|
||||
const tempCacheContentFile = pathModule.join(cacheDir, `${data.uniqid}_${crypto.randomBytes(16).toString('hex')}.temp`);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(cacheMetaFile, JSON.stringify(data));
|
||||
} catch (writeError) {
|
||||
console.error(`Error writing meta file ${cacheMetaFile}:`, writeError);
|
||||
sendErrorResponse(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, 'Failed to write cache metadata.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (fs.existsSync(cacheContentFile)) {
|
||||
const stats = fs.statSync(cacheContentFile);
|
||||
const contentLength = stats.size;
|
||||
// If file is very small and content length from API differs, consider re-fetching.
|
||||
// The 2048 threshold seems arbitrary; could be configurable or based on content type.
|
||||
if (contentLength < 2048 && data.headers['content-length'] && parseInt(data.headers['content-length'], 10) !== contentLength) {
|
||||
console.warn(`Content length mismatch for ${cacheContentFile}. API: ${data.headers['content-length']}, Cache: ${contentLength}. Re-fetching.`);
|
||||
fetchAndServe(data, tempCacheContentFile, cacheContentFile, cacheMetaFile, res);
|
||||
} else {
|
||||
serveFromCache(data, cacheContentFile, cacheMetaFile, res);
|
||||
}
|
||||
} else {
|
||||
fetchAndServe(data, tempCacheContentFile, cacheContentFile, cacheMetaFile, res);
|
||||
}
|
||||
}
|
||||
|
||||
async function tryServeFromStaleCacheOrError(uniqidhex, res, errorMessage) {
|
||||
if (pathIndex[uniqidhex]) {
|
||||
const cacheMetaFile = pathModule.join(cacheDir, `${pathIndex[uniqidhex].uniqid}.meta`);
|
||||
const cacheContentFile = pathModule.join(cacheDir, `${pathIndex[uniqidhex].uniqid}.content`);
|
||||
if (fs.existsSync(cacheMetaFile) && fs.existsSync(cacheContentFile)) {
|
||||
console.warn(`API call failed or returned non-200. Serving stale cache for ${uniqidhex}`);
|
||||
try {
|
||||
const cacheData = JSON.parse(fs.readFileSync(cacheMetaFile, 'utf8'));
|
||||
serveFromCache(cacheData, cacheContentFile, cacheMetaFile, res);
|
||||
return;
|
||||
} catch (parseError) {
|
||||
console.error(`Error parsing stale meta file ${cacheMetaFile}:`, parseError);
|
||||
// Fall through to generic error if stale cache is also broken
|
||||
}
|
||||
}
|
||||
}
|
||||
sendErrorResponse(res, HTTP_STATUS.BAD_GATEWAY, errorMessage || 'Bad Gateway');
|
||||
}
|
||||
|
||||
async function handleMainRequest(req, res) {
|
||||
req.url = req.url.replace(/\/{2,}/g, '/');
|
||||
const parsedUrl = url.parse(req.url, true);
|
||||
|
||||
// 解析得到 sign 字段
|
||||
const sign = parsedUrl.query.sign || '';
|
||||
|
||||
// 获取第一个路径
|
||||
let reqPath = parsedUrl.pathname.split('/')[1];
|
||||
|
||||
// 取第一个路径以外的路径
|
||||
let reqPath = parsedUrl.pathname.split('/')[1] || ''; // Ensure reqPath is not undefined
|
||||
let token = parsedUrl.pathname.split('/').slice(2).join('/');
|
||||
|
||||
// 处理根路径请求
|
||||
if (reqPath === 'favicon.ico') {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (reqPath === 'favicon.ico') return handleFavicon(req, res);
|
||||
if (reqPath === 'endpoint') return handleEndpoint(req, res, parsedUrl);
|
||||
|
||||
// 返回 endpoint, 缓存目录, 缓存数量, 用于监听服务是否正常运行
|
||||
if (reqPath === 'endpoint') {
|
||||
if (parsedUrl.query.api) {
|
||||
// 判断 parsedUrl.query.api 是否为网址
|
||||
const urlRegex = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([\/\w.-]*)*\/?$/;
|
||||
if (urlRegex.test(parsedUrl.query.api)) {
|
||||
apiEndpoint = parsedUrl.query.api;
|
||||
}
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
code: 200,
|
||||
data: {
|
||||
api: apiEndpoint,
|
||||
port: port,
|
||||
cacheDir: cacheDir,
|
||||
pathIndexCount: Object.keys(pathIndex).length,
|
||||
viewsInfo: viewsInfo
|
||||
}
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// 当没有 token 或 undefined
|
||||
if (token === '' || typeof token === 'undefined') {
|
||||
if (!token && reqPath) { // If token is empty but reqPath is not, assume reqPath is the token
|
||||
token = reqPath;
|
||||
reqPath = 'app';
|
||||
reqPath = 'app'; // Default to 'app' if only one path segment is provided
|
||||
}
|
||||
|
||||
// 检查第一个路径只能是 avatar,endpoint,go,bbs,www
|
||||
if (!['avatar', 'go', 'bbs', 'www', 'url', 'thumb', 'app'].includes(reqPath)) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain;charset=UTF-8' });
|
||||
res.end('Not Found');
|
||||
return;
|
||||
const ALLOWED_PATHS = ['avatar', 'go', 'bbs', 'www', 'url', 'thumb', 'app'];
|
||||
if (!ALLOWED_PATHS.includes(reqPath) || !token) {
|
||||
return sendErrorResponse(res, HTTP_STATUS.BAD_REQUEST, `Bad Request: Invalid path or missing token.`);
|
||||
}
|
||||
|
||||
if (!token || reqPath === '') {
|
||||
res.writeHead(400, { 'Content-Type': 'text/plain;charset=UTF-8' });
|
||||
res.end('Bad Request: Missing Token or path (' + reqPath + ')');
|
||||
return;
|
||||
}
|
||||
|
||||
// 增加请求次数
|
||||
viewsInfo.request++;
|
||||
|
||||
viewsInfo.increment('request');
|
||||
const uniqidhex = crypto.createHash('md5').update(reqPath + token + sign).digest('hex');
|
||||
|
||||
let cacheMetaFile = '';
|
||||
let cacheContentFile = '';
|
||||
let tempCacheContentFile = '';
|
||||
|
||||
if (pathIndex[uniqidhex]) {
|
||||
cacheMetaFile = pathModule.join(cacheDir, `${pathIndex[uniqidhex].uniqid}.meta`);
|
||||
@ -140,156 +223,170 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
|
||||
if (pathIndex[uniqidhex] && isCacheValid(cacheMetaFile, cacheContentFile)) {
|
||||
|
||||
const { cacheData, isNotModified } = await checkCacheHeaders(req, cacheMetaFile);
|
||||
if (isNotModified) {
|
||||
res.writeHead(304);
|
||||
res.writeHead(HTTP_STATUS.NOT_MODIFIED);
|
||||
res.end();
|
||||
} else {
|
||||
// 增加缓存命中次数
|
||||
viewsInfo.cacheHit++;
|
||||
viewsInfo.increment('cacheHit');
|
||||
serveFromCache(cacheData, cacheContentFile, cacheMetaFile, res);
|
||||
}
|
||||
|
||||
} else {
|
||||
try {
|
||||
// 增加 API 调用次数
|
||||
viewsInfo.apiCall++;
|
||||
viewsInfo.increment('apiCall');
|
||||
const apiData = await fetchApiData(reqPath, token, sign);
|
||||
|
||||
// 302 或 301 重定向
|
||||
if (apiData.code === 302 || apiData.code === 301) {
|
||||
res.writeHead(302, { Location: apiData.data.url });
|
||||
res.end();
|
||||
return;
|
||||
if (apiData.code === HTTP_STATUS.REDIRECT || apiData.code === 301) {
|
||||
return handleApiRedirect(res, apiData);
|
||||
}
|
||||
|
||||
if (apiData.code === 200 && apiData.data && apiData.data.url) {
|
||||
const { url: realUrl, cloudtype, expiration, path, headers, uniqid } = apiData.data;
|
||||
const data = { realUrl, cloudtype, expiration: expiration * 1000, path, headers, uniqid };
|
||||
|
||||
// 修改 pathIndex 记录时,添加时间戳
|
||||
pathIndex[uniqidhex] = { uniqid: data.uniqid, timestamp: Date.now() };
|
||||
cacheMetaFile = pathModule.join(cacheDir, `${data.uniqid}.meta`);
|
||||
cacheContentFile = pathModule.join(cacheDir, `${data.uniqid}.content`);
|
||||
tempCacheContentFile = pathModule.join(cacheDir, `${data.uniqid}_${crypto.randomBytes(16).toString('hex')}.temp`);
|
||||
// 重新写入 meta 缓存
|
||||
fs.writeFileSync(cacheMetaFile, JSON.stringify(data));
|
||||
// 如果内容缓存存在, 则直接调用
|
||||
if (fs.existsSync(cacheContentFile)) {
|
||||
// 读取大小是否一致
|
||||
const contentLength = fs.statSync(cacheContentFile).size;
|
||||
// 如果文件小于 2KB, 且与缓存文件大小不一致时,重新获取
|
||||
if (contentLength < 2048 && data.headers['content-length'] !== contentLength) {
|
||||
fetchAndServe(data, tempCacheContentFile, cacheContentFile, cacheMetaFile, res);
|
||||
return;
|
||||
}
|
||||
serveFromCache(data, cacheContentFile, cacheMetaFile, res);
|
||||
} else {
|
||||
fetchAndServe(data, tempCacheContentFile, cacheContentFile, cacheMetaFile, res);
|
||||
}
|
||||
if (apiData.code === HTTP_STATUS.OK && apiData.data && apiData.data.url) {
|
||||
await processSuccessfulApiData(apiData, uniqidhex, reqPath, token, sign, res);
|
||||
} else {
|
||||
|
||||
// 记录响应错误
|
||||
viewsInfo.fetchApiWarning++;
|
||||
|
||||
|
||||
// 如果有缓存有忽略过期,直接调用
|
||||
if (pathIndex[uniqidhex] && fs.existsSync(cacheMetaFile) && fs.existsSync(cacheContentFile)) {
|
||||
serveFromCache(JSON.parse(fs.readFileSync(cacheMetaFile, 'utf8')), cacheContentFile, cacheMetaFile, res);
|
||||
} else {
|
||||
// utf8 解码
|
||||
res.writeHead(502, { 'Content-Type': 'text/plain;charset=UTF-8;' });
|
||||
res.end(apiData.message || 'Bad Gateway');
|
||||
}
|
||||
|
||||
// utf8 解码
|
||||
|
||||
viewsInfo.increment('fetchApiWarning');
|
||||
await tryServeFromStaleCacheOrError(uniqidhex, res, apiData.message);
|
||||
}
|
||||
} catch (error) {
|
||||
// 记录响应错误
|
||||
viewsInfo.fetchApiError++;
|
||||
|
||||
// 起用备用 API
|
||||
// apiEndpoint = 'https://x-mo.cn:9001/get/';
|
||||
|
||||
// utf8 解码
|
||||
res.writeHead(502, { 'Content-Type': 'text/plain;charset=UTF-8;' });
|
||||
res.end('Bad Gateway: Failed to decode JSON' + error);
|
||||
viewsInfo.increment('fetchApiError');
|
||||
console.error('Error in API call or processing:', error);
|
||||
await tryServeFromStaleCacheOrError(uniqidhex, res, `Bad Gateway: API request failed. ${error.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const server = http.createServer(handleMainRequest);
|
||||
|
||||
// 检查缓存头并返回是否为304
|
||||
const checkCacheHeaders = async (req, cacheMetaFile) => {
|
||||
const cacheData = JSON.parse(fs.readFileSync(cacheMetaFile, 'utf8'));
|
||||
const ifNoneMatch = req.headers['if-none-match'];
|
||||
const ifModifiedSince = req.headers['if-modified-since'];
|
||||
let isNotModified = false;
|
||||
if (ifNoneMatch && ifNoneMatch === cacheData.uniqid) {
|
||||
isNotModified = true;
|
||||
} else if (ifModifiedSince) {
|
||||
const lastModified = new Date(cacheData.headers['Last-Modified']);
|
||||
const modifiedSince = new Date(ifModifiedSince);
|
||||
async function checkCacheHeaders(req, cacheMetaFile) {
|
||||
try {
|
||||
const metaContent = fs.readFileSync(cacheMetaFile, 'utf8');
|
||||
const cacheData = JSON.parse(metaContent);
|
||||
const ifNoneMatch = req.headers['if-none-match'];
|
||||
const ifModifiedSince = req.headers['if-modified-since'];
|
||||
|
||||
if (lastModified <= modifiedSince) {
|
||||
isNotModified = true;
|
||||
// Check ETag first
|
||||
if (ifNoneMatch && cacheData.uniqid && ifNoneMatch === cacheData.uniqid) {
|
||||
return { cacheData, isNotModified: true };
|
||||
}
|
||||
}
|
||||
|
||||
return { cacheData, isNotModified };
|
||||
};
|
||||
// Check If-Modified-Since
|
||||
if (ifModifiedSince && cacheData.headers && cacheData.headers['last-modified']) {
|
||||
try {
|
||||
const lastModifiedDate = new Date(cacheData.headers['last-modified']);
|
||||
const ifModifiedSinceDate = new Date(ifModifiedSince);
|
||||
// The time resolution of an HTTP date is one second.
|
||||
// If If-Modified-Since is at least as new as Last-Modified, send 304.
|
||||
if (lastModifiedDate.getTime() <= ifModifiedSinceDate.getTime()) {
|
||||
return { cacheData, isNotModified: true };
|
||||
}
|
||||
} catch (dateParseError) {
|
||||
console.warn(`Error parsing date for cache header check (${cacheMetaFile}):`, dateParseError);
|
||||
// Proceed as if not modified check failed if dates are invalid
|
||||
}
|
||||
}
|
||||
return { cacheData, isNotModified: false };
|
||||
} catch (error) {
|
||||
console.error(`Error reading or parsing cache meta file ${cacheMetaFile} in checkCacheHeaders:`, error);
|
||||
// If we can't read meta, assume cache is invalid or treat as not modified: false
|
||||
// Returning a dummy cacheData or null might be better depending on how caller handles it.
|
||||
// For now, let it propagate and potentially fail later if cacheData is expected.
|
||||
// Or, more safely, indicate cache is not valid / not modified is false.
|
||||
return { cacheData: null, isNotModified: false }; // Indicate failure to load cacheData
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 检查缓存是否有效
|
||||
const isCacheValid = (cacheMetaFile, cacheContentFile) => {
|
||||
if (!fs.existsSync(cacheMetaFile) || !fs.existsSync(cacheContentFile)) return false;
|
||||
const cacheData = JSON.parse(fs.readFileSync(cacheMetaFile, 'utf8'));
|
||||
return cacheData.expiration > Date.now();
|
||||
};
|
||||
function isCacheValid(cacheMetaFile, cacheContentFile) {
|
||||
if (!fs.existsSync(cacheMetaFile) || !fs.existsSync(cacheContentFile)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const metaContent = fs.readFileSync(cacheMetaFile, 'utf8');
|
||||
const cacheData = JSON.parse(metaContent);
|
||||
// Ensure expiration is a number and in the future
|
||||
return typeof cacheData.expiration === 'number' && cacheData.expiration > Date.now();
|
||||
} catch (error) {
|
||||
console.warn(`Error reading or parsing cache meta file ${cacheMetaFile} for validation:`, error);
|
||||
return false; // If meta file is corrupt or unreadable, cache is not valid
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 从 API 获取数据
|
||||
const fetchApiData = (reqPath, token, sign) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const queryParams = querystring.stringify({
|
||||
type: reqPath,
|
||||
sign: sign
|
||||
});
|
||||
const apiUrl = `${apiEndpoint}?${queryParams}`;
|
||||
const parsedUrl = url.parse(apiUrl);
|
||||
const protocol = parsedUrl.protocol === 'https:' ? https : http;
|
||||
const API_TIMEOUT_MS = 5000;
|
||||
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36';
|
||||
|
||||
const apiReq = protocol.request(apiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json; charset=utf-8',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36',
|
||||
'token': token
|
||||
},
|
||||
// 超时设置, 5 秒
|
||||
timeout: 5000,
|
||||
rejectUnauthorized: false,
|
||||
// 超时设置
|
||||
}, (apiRes) => {
|
||||
let data = '';
|
||||
apiRes.on('data', chunk => data += chunk);
|
||||
async function fetchApiData(reqPath, token, sign) {
|
||||
const queryParams = querystring.stringify({
|
||||
type: reqPath,
|
||||
sign: sign
|
||||
});
|
||||
const apiUrl = `${apiEndpoint}?${queryParams}`;
|
||||
const parsedApiUrl = new URL(apiUrl);
|
||||
const protocol = parsedApiUrl.protocol === 'https:' ? https : http;
|
||||
|
||||
const options = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json; charset=utf-8',
|
||||
'User-Agent': USER_AGENT,
|
||||
'token': token
|
||||
},
|
||||
timeout: API_TIMEOUT_MS,
|
||||
rejectUnauthorized: false, // Allow self-signed certificates, use with caution
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const apiReq = protocol.request(apiUrl, options, (apiRes) => {
|
||||
let responseData = '';
|
||||
apiRes.setEncoding('utf8');
|
||||
apiRes.on('data', chunk => responseData += chunk);
|
||||
apiRes.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
if (apiRes.statusCode >= 400) {
|
||||
// Treat HTTP errors from API as rejections for easier handling
|
||||
console.error(`API request to ${apiUrl} failed with status ${apiRes.statusCode}: ${responseData}`);
|
||||
// Attempt to parse for a message, but prioritize status code for error type
|
||||
let errorPayload = { code: apiRes.statusCode, message: `API Error: ${apiRes.statusCode}` };
|
||||
try {
|
||||
const parsedError = JSON.parse(responseData);
|
||||
if(parsedError && parsedError.message) errorPayload.message = parsedError.message;
|
||||
} catch (e) { /* Ignore if response is not JSON */ }
|
||||
resolve(errorPayload); // Resolve with error structure for consistency
|
||||
return;
|
||||
}
|
||||
resolve(JSON.parse(responseData));
|
||||
} catch (parseError) {
|
||||
console.error(`Error parsing JSON response from ${apiUrl}:`, parseError, responseData);
|
||||
reject(new Error(`Failed to parse API response: ${parseError.message}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
apiReq.on('error', reject);
|
||||
|
||||
apiReq.on('timeout', () => {
|
||||
apiReq.destroy(); // Destroy the request to free up resources
|
||||
console.error(`API request to ${apiUrl} timed out after ${API_TIMEOUT_MS}ms`);
|
||||
reject(new Error('API request timed out'));
|
||||
});
|
||||
|
||||
apiReq.on('error', (networkError) => {
|
||||
console.error(`API request to ${apiUrl} failed:`, networkError);
|
||||
reject(networkError);
|
||||
});
|
||||
|
||||
apiReq.end();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// 从真实 URL 获取数据并写入缓存
|
||||
const fetchAndServe = (data, tempCacheContentFile, cacheContentFile, cacheMetaFile, res) => {
|
||||
const REAL_URL_FETCH_TIMEOUT_MS = 0; // 0 means no timeout for the actual file download
|
||||
|
||||
// 不限超时
|
||||
https.get(data.realUrl, { timeout: 0, rejectUnauthorized: false }, (realRes) => {
|
||||
const fetchAndServe = (data, tempCacheContentFile, cacheContentFile, cacheMetaFile, res) => {
|
||||
const protocol = data.realUrl.startsWith('https:') ? https : http;
|
||||
|
||||
protocol.get(data.realUrl, { timeout: REAL_URL_FETCH_TIMEOUT_MS, rejectUnauthorized: false }, (realRes) => {
|
||||
const cacheStream = fs.createWriteStream(tempCacheContentFile, { flags: 'w' });
|
||||
|
||||
let isVideo = data.path && typeof data.path === 'string' && data.path.includes('.mp4');
|
||||
@ -300,9 +397,11 @@ const fetchAndServe = (data, tempCacheContentFile, cacheContentFile, cacheMetaFi
|
||||
// contentLength 小于 2KB 且与缓存文件大小不一致时,重新获取
|
||||
if (contentLength < 2048 && data.headers['content-length'] !== contentLength) {
|
||||
console.warn('Warning: content-length is different for the response from:', data.realUrl);
|
||||
// 返回错误
|
||||
res.writeHead(502, { 'Content-Type': 'text/plain;charset=UTF-8;' });
|
||||
res.end(`Bad Gateway: ${data.realUrl}`);
|
||||
sendErrorResponse(res, HTTP_STATUS.BAD_GATEWAY, `Bad Gateway: Content-Length mismatch for ${data.realUrl}`);
|
||||
// Clean up temp file if stream hasn't started or failed early
|
||||
if (fs.existsSync(tempCacheContentFile)) {
|
||||
fs.unlinkSync(tempCacheContentFile);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@ -313,112 +412,158 @@ const fetchAndServe = (data, tempCacheContentFile, cacheContentFile, cacheMetaFi
|
||||
console.warn('Warning: content-length is undefined for the response from:', data.realUrl);
|
||||
}
|
||||
|
||||
const defaultHeaders = {
|
||||
const baseHeaders = {
|
||||
'Cloud-Type': data.cloudtype,
|
||||
'Cloud-Expiration': data.expiration,
|
||||
'Content-Type': isVideo ? 'video/mp4' : 'application/octet-stream',
|
||||
'ETag': data.uniqid || '',
|
||||
'Cache-Control': 'public, max-age=31536000',
|
||||
'Cache-Control': 'public, max-age=31536000', // 1 year
|
||||
'Expires': new Date(Date.now() + 31536000000).toUTCString(),
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Connection': 'keep-alive',
|
||||
'Date': new Date().toUTCString(),
|
||||
'Last-Modified': new Date().toUTCString(),
|
||||
'Date': new Date().toUTCString(), // Should be set by the server, but good for consistency
|
||||
'Last-Modified': data.headers['last-modified'] || new Date(fs.statSync(cacheMetaFile).mtime).toUTCString(), // Prefer API's Last-Modified if available
|
||||
};
|
||||
const responseHeaders = {
|
||||
...baseHeaders,
|
||||
'Content-Type': realRes.headers['content-type'] || (isVideo ? 'video/mp4' : 'application/octet-stream'), // Prefer actual content-type
|
||||
...data.headers, // Allow API to override some headers if necessary
|
||||
};
|
||||
|
||||
res.writeHead(realRes.statusCode, Object.assign({}, defaultHeaders, data.headers));
|
||||
res.writeHead(realRes.statusCode, responseHeaders);
|
||||
realRes.pipe(cacheStream);
|
||||
realRes.pipe(res);
|
||||
|
||||
realRes.on('end', () => {
|
||||
cacheStream.end();
|
||||
if (fs.existsSync(tempCacheContentFile)) {
|
||||
try {
|
||||
fs.renameSync(tempCacheContentFile, cacheContentFile);
|
||||
} catch (err) {
|
||||
console.error(`Error renaming file: ${err}`);
|
||||
cacheStream.end(() => { // Ensure stream is fully flushed before renaming
|
||||
if (fs.existsSync(tempCacheContentFile)) {
|
||||
try {
|
||||
// Ensure the target directory exists before renaming
|
||||
const targetDir = pathModule.dirname(cacheContentFile);
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
fs.renameSync(tempCacheContentFile, cacheContentFile);
|
||||
console.log(`Successfully cached: ${cacheContentFile}`);
|
||||
} catch (renameError) {
|
||||
console.error(`Error renaming temp cache file ${tempCacheContentFile} to ${cacheContentFile}:`, renameError);
|
||||
// If rename fails, try to remove the temp file to avoid clutter
|
||||
try { fs.unlinkSync(tempCacheContentFile); } catch (e) { /* ignore */ }
|
||||
}
|
||||
} else {
|
||||
// This case might indicate an issue if the stream ended but no temp file was created/found
|
||||
console.warn(`Temp cache file ${tempCacheContentFile} not found after stream end for ${data.realUrl}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
realRes.on('error', (e) => {
|
||||
handleResponseError(res, tempCacheContentFile, data.realUrl);
|
||||
realRes.on('error', (streamError) => {
|
||||
console.error(`Error during response stream from ${data.realUrl}:`, streamError);
|
||||
cacheStream.end(); // Close the writable stream
|
||||
handleResponseError(res, tempCacheContentFile, data.realUrl); // tempCacheContentFile might be partially written
|
||||
});
|
||||
}).on('error', (e) => {
|
||||
handleResponseError(res, tempCacheContentFile, data.realUrl);
|
||||
|
||||
}).on('error', (requestError) => {
|
||||
console.error(`Error making GET request to ${data.realUrl}:`, requestError);
|
||||
// No cacheStream involved here if the request itself fails before response
|
||||
handleResponseError(res, tempCacheContentFile, data.realUrl); // tempCacheContentFile might not exist or be empty
|
||||
});
|
||||
};
|
||||
|
||||
// 从缓存中读取数据并返回
|
||||
const serveFromCache = (cacheData, cacheContentFile, cacheMetaFile, res) => {
|
||||
// 增加缓存调用次数
|
||||
viewsInfo.cacheCall++;
|
||||
function serveFromCache(cacheData, cacheContentFile, cacheMetaFile, res) {
|
||||
if (!cacheData) { // Added check for null cacheData from checkCacheHeaders failure
|
||||
console.error(`serveFromCache called with null cacheData for ${cacheContentFile}`);
|
||||
sendErrorResponse(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, 'Cache metadata unavailable.');
|
||||
return;
|
||||
}
|
||||
|
||||
viewsInfo.increment('cacheCall');
|
||||
const readStream = fs.createReadStream(cacheContentFile);
|
||||
let isVideo = cacheData.path && typeof cacheData.path === 'string' && cacheData.path.includes('.mp4');
|
||||
const isVideo = cacheData.path && typeof cacheData.path === 'string' && cacheData.path.includes('.mp4');
|
||||
|
||||
let currentContentLength = cacheData.headers && cacheData.headers['content-length'] ? parseInt(cacheData.headers['content-length'], 10) : 0;
|
||||
|
||||
// 查询 cacheData.headers['content-length'] 是否存在
|
||||
if (!cacheData.headers['content-length'] || cacheData.headers['content-length'] === 0) {
|
||||
// 读取文件大小并更新 cacheData.headers['content-length']
|
||||
const contentLength = fs.statSync(cacheContentFile).size;
|
||||
if (contentLength) {
|
||||
cacheData.headers['content-length'] = contentLength;
|
||||
// 更新 cacheData 到缓存 cacheMetaFile
|
||||
fs.writeFileSync(cacheMetaFile, JSON.stringify(cacheData));
|
||||
} else {
|
||||
console.warn('Warning: content-length is undefined for cached content file:', cacheContentFile);
|
||||
if (!currentContentLength || currentContentLength === 0) {
|
||||
try {
|
||||
const stats = fs.statSync(cacheContentFile);
|
||||
currentContentLength = stats.size;
|
||||
if (currentContentLength > 0) {
|
||||
if (!cacheData.headers) cacheData.headers = {};
|
||||
cacheData.headers['content-length'] = currentContentLength.toString();
|
||||
// Update meta file if content-length was missing or zero
|
||||
fs.writeFileSync(cacheMetaFile, JSON.stringify(cacheData));
|
||||
console.log(`Updated content-length in ${cacheMetaFile} to ${currentContentLength}`);
|
||||
} else {
|
||||
console.warn(`Cached content file ${cacheContentFile} has size 0 or stat failed.`);
|
||||
// Potentially treat as an error or serve as is if 0 length is valid for some files
|
||||
}
|
||||
} catch (statError) {
|
||||
console.error(`Error stating cache content file ${cacheContentFile}:`, statError);
|
||||
handleCacheReadError(res, cacheContentFile); // Treat stat error as read error
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
readStream.on('open', () => {
|
||||
const defaultHeaders = {
|
||||
'Cloud-Type': cacheData.cloudtype,
|
||||
'Cloud-Expiration': cacheData.expiration,
|
||||
'Content-Type': isVideo ? 'video/mp4' : 'application/octet-stream',
|
||||
'ETag': cacheData.uniqid || '',
|
||||
'Cache-Control': 'public, max-age=31536000',
|
||||
const baseHeaders = {
|
||||
'Cloud-Type': cacheData.cloudtype || 'unknown',
|
||||
'Cloud-Expiration': cacheData.expiration || 'N/A',
|
||||
'ETag': cacheData.uniqid || crypto.createHash('md5').update(fs.readFileSync(cacheContentFile)).digest('hex'), // Fallback ETag if missing
|
||||
'Cache-Control': 'public, max-age=31536000', // 1 year
|
||||
'Expires': new Date(Date.now() + 31536000000).toUTCString(),
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Connection': 'keep-alive',
|
||||
'Date': new Date().toUTCString(),
|
||||
'Last-Modified': new Date().toUTCString(),
|
||||
}
|
||||
'Last-Modified': (cacheData.headers && cacheData.headers['last-modified']) || new Date(fs.statSync(cacheMetaFile).mtime).toUTCString(),
|
||||
};
|
||||
|
||||
res.writeHead(200, Object.assign({}, defaultHeaders, cacheData.headers));
|
||||
const responseHeaders = {
|
||||
...baseHeaders,
|
||||
'Content-Type': (cacheData.headers && cacheData.headers['content-type']) || (isVideo ? 'video/mp4' : 'application/octet-stream'),
|
||||
// Merge other headers from cacheData.headers, letting them override base if necessary
|
||||
// but ensure our critical headers like Content-Length (if updated) are preserved.
|
||||
...(cacheData.headers || {}),
|
||||
'Content-Length': currentContentLength.toString(), // Ensure this is set correctly
|
||||
};
|
||||
|
||||
res.writeHead(HTTP_STATUS.OK, responseHeaders);
|
||||
readStream.pipe(res);
|
||||
});
|
||||
|
||||
readStream.on('error', (err) => {
|
||||
handleCacheReadError(res);
|
||||
console.error(`Read stream error for ${cacheContentFile}:`, err);
|
||||
handleCacheReadError(res, cacheContentFile);
|
||||
});
|
||||
};
|
||||
|
||||
// Handle cases where client closes connection prematurely
|
||||
res.on('close', () => {
|
||||
if (!res.writableEnded) {
|
||||
console.log(`Client closed connection prematurely for ${cacheContentFile}. Destroying read stream.`);
|
||||
readStream.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 处理响应错误
|
||||
const handleResponseError = (res, tempCacheContentFile, realUrl) => {
|
||||
|
||||
|
||||
// 增加缓存读取错误次数
|
||||
viewsInfo.cacheReadError++;
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(502, { 'Content-Type': 'text/plain;charset=UTF-8' });
|
||||
res.end(`Bad Gateway: ${realUrl}`);
|
||||
}
|
||||
viewsInfo.increment('fetchApiError');
|
||||
console.error(`Error fetching from real URL: ${realUrl}`);
|
||||
sendErrorResponse(res, HTTP_STATUS.BAD_GATEWAY, `Bad Gateway: Failed to fetch from ${realUrl}`);
|
||||
if (fs.existsSync(tempCacheContentFile)) {
|
||||
fs.unlinkSync(tempCacheContentFile);
|
||||
try {
|
||||
fs.unlinkSync(tempCacheContentFile);
|
||||
} catch (unlinkErr) {
|
||||
console.error(`Error unlinking temp file ${tempCacheContentFile}:`, unlinkErr);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 处理缓存读取错误
|
||||
const handleCacheReadError = (res) => {
|
||||
|
||||
// 增加缓存读取错误次数
|
||||
viewsInfo.cacheReadError++;
|
||||
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain;charset=UTF-8' });
|
||||
res.end('Internal Server Error: Unable to read cache content file');
|
||||
}
|
||||
const handleCacheReadError = (res, filePath) => {
|
||||
viewsInfo.increment('cacheReadError');
|
||||
console.error(`Error reading cache file: ${filePath}`);
|
||||
sendErrorResponse(res, HTTP_STATUS.INTERNAL_SERVER_ERROR, 'Internal Server Error: Unable to read cache content file');
|
||||
};
|
||||
|
||||
// 启动服务器
|
||||
|
Loading…
x
Reference in New Issue
Block a user