diff --git a/danmu_api/configs/envs.js b/danmu_api/configs/envs.js index 0ed2d48c8..d401ab8f7 100644 --- a/danmu_api/configs/envs.js +++ b/danmu_api/configs/envs.js @@ -13,6 +13,13 @@ export class Envs { static originalEnvVars = new Map(); static accessedEnvVars = new Map(); + // Node 本地部署时由 server.js 注入:启动前的真实系统环境变量快照(最高优先级判定依据)与 .env 原始解析结果 + static systemEnvBackup = null; + static rawEnvValues = null; + + // 允许在值中写入 # 等 dotenv 视为注释字符的文本类变量;读取时绕过 dotenv 截断以保留完整内容。仅纳入 encrypt=false 变量(带令牌/密码 URL 若入此集合会绕过加密返回明文,故禁止纳入)。 + static RAW_ENV_KEYS = new Set(['AI_MATCH_PROMPT', 'ANIME_TITLE_FILTER', 'AUTO_MATCH_MAPPING_TABLE', 'BLOCKED_WORDS', 'COLOR_POOL', 'CUSTOM_MERGE_RULES', 'DANMU_OFFSET', 'DANMU_PUSH_URL', 'EPISODE_TITLE_FILTER', 'IP_BLACKLIST', 'OTHER_SERVER', 'TITLE_MAPPING_TABLE', 'TITLE_NOISE_FILTER', 'VOD_SERVERS']); + static VOD_ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'migu', 'sohu', 'leshi', 'xigua', 'maiduidui', 'aiyifan']; // vod允许的播放平台 static ALLOWED_PLATFORMS = ['qiyi', 'bilibili1', 'imgo', 'youku', 'qq', 'migu', 'renren', 'hanjutv', 'sohu', 'leshi', 'xigua', 'maiduidui', 'aiyifan', 'hongguo', 'dandan', 'bahamut', 'animeko', 'custom']; // 全部源允许的播放平台 static ALLOWED_SOURCES = ['360', 'vod', 'tmdb', 'douban', 'tencent', 'youku', 'iqiyi', 'imgo', 'bilibili', 'migu', 'renren', 'hanjutv', 'sohu', 'leshi', 'xigua', 'maiduidui', 'aiyifan', 'hongguo', 'dandan', 'bahamut', 'animeko', 'custom']; // 允许的源 @@ -63,6 +70,10 @@ export class Envs { * @returns {any} 转换后的值 */ static get(key, defaultValue, type = 'string', encrypt = false) { + // 文本类且未加密的自定义变量绕过 dotenv 注释截断,保留 # 等字符;加密变量不在此路径,避免绕过加密返回明文 + if (type === 'string' && !encrypt && Envs.RAW_ENV_KEYS.has(key)) { + return this.getRawEnv(key, defaultValue); + } let value; if (typeof this.env !== 'undefined' && this.env[key]) { value = this.env[key]; @@ -119,6 +130,64 @@ export class Envs { return '*'.repeat(str.length); } + /** + * 解析 .env 原始内容:跳过整行 # 注释、保留行内 #,并剥除整体双引号包裹(与 node-handler 引号写入一致)。 + * @param {string} text .env 文件原始内容 + * @returns {Object} 键值映射 + */ + static parseRawEnvText(text) { + const result = {}; + if (typeof text !== 'string') return result; + const lines = text.split(/\r?\n/); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + if (!key) continue; + let value = trimmed.slice(eq + 1).trim(); + if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) { + value = value.slice(1, -1); + } + result[key] = value; + } + return result; + } + + /** + * 读取自定义文本类变量,绕过 dotenv 截断保留 #:系统环境变量 > .env 原始值 > 默认值;非 Node 部署退化为普通取值。 + * @param {string} key 环境变量键 + * @param {string} defaultValue 默认值 + * @returns {string} 原始值(含 #) + */ + static getRawEnv(key, defaultValue = '') { + const finalize = (v) => { + this.originalEnvVars.set(key, v); + this.accessedEnvVars.set(key, v); + return v; + }; + + // 非 Node 运行时(测试 / Workers):不做文件读取,等价于普通取值,保证测试隔离与平台兼容 + if (!Envs.systemEnvBackup) { + if (this.env && this.env[key]) return finalize(this.env[key]); + if (typeof process !== 'undefined' && process.env?.[key]) return finalize(process.env[key]); + return finalize(defaultValue); + } + + // Node 运行时:系统环境变量始终最高优先级 + if (Object.prototype.hasOwnProperty.call(Envs.systemEnvBackup, key)) { + return finalize(Envs.systemEnvBackup[key]); + } + + // 否则读取 .env 原始行(保留 #),未配置该键时回退 process.env 或默认值 + if (Envs.rawEnvValues && Object.prototype.hasOwnProperty.call(Envs.rawEnvValues, key)) { + return finalize(Envs.rawEnvValues[key]); + } + if (typeof process !== 'undefined' && process.env?.[key]) return finalize(process.env[key]); + return finalize(defaultValue); + } + /** * 解析 VOD 服务器配置 * @returns {Array} 服务器列表 @@ -321,8 +390,7 @@ export class Envs { console.warn(`[Envs] 解析合并映射表规则失败: ${rStr}`, e); } } - - this.accessedEnvVars.set('CUSTOM_MERGE_RULES', raw); + return rules; } diff --git a/danmu_api/server.js b/danmu_api/server.js index 6297b23e4..a5c1e6321 100644 --- a/danmu_api/server.js +++ b/danmu_api/server.js @@ -10,6 +10,7 @@ import dotenv from 'dotenv'; import { Request as NodeFetchRequest } from 'node-fetch'; import { handleRequest } from './worker.js'; import { Globals } from './configs/globals.js'; +import { Envs } from './configs/envs.js'; import { clearBangumiDataCache, initBangumiData } from './utils/bangumi-data-util.js'; import { getLocalCaches, judgeLocalCacheValid } from './utils/cache-util.js'; import { getRedisCaches, judgeRedisValid } from './utils/redis-util.js'; @@ -42,6 +43,9 @@ const envPath = path.join(configDir, '.env'); // 保存系统环境变量的副本,确保它们具有最高优先级 const systemEnvBackup = { ...process.env }; +// 注入到 Envs,供自定义规则变量读取时判定系统环境变量优先级(绕过 dotenv 注释截断) +Envs.systemEnvBackup = systemEnvBackup; + // 引入 zlib 模块,用于响应数据的 GZIP 压缩 // (注:zlib 已在顶部 import,此处保留原版注释意图说明) @@ -138,6 +142,15 @@ function loadEnv() { process.env[key] = value; } + // 解析 .env 原始内容,供支持自定义规则的变量绕过 dotenv 注释截断(保留 # 等字符) + try { + if (fs.existsSync(envPath)) { + Envs.rawEnvValues = Envs.parseRawEnvText(fs.readFileSync(envPath, 'utf8')); + } + } catch (e) { + // 原始解析失败不影响启动,相关变量回退到普通取值语义 + } + console.log('[server] .env file loaded successfully'); } catch (e) { console.log('[server] dotenv not available or .env file not found, using system environment variables'); diff --git a/danmu_api/worker.test.js b/danmu_api/worker.test.js index 17b3eea73..3b4f142c7 100644 --- a/danmu_api/worker.test.js +++ b/danmu_api/worker.test.js @@ -2850,6 +2850,38 @@ test('worker.js API endpoints', async (t) => { // }); // }); +// // 测试自定义文本类变量绕过 dotenv 注释截断(保留 # 等字符),对应 envs.js RAW_ENV_KEYS 修复 +// import { Envs } from './configs/envs.js'; +// +// test('envs RAW_ENV_KEYS 保留 # 不被 dotenv 截断', async (t) => { +// const reset = () => { Envs.systemEnvBackup = null; Envs.rawEnvValues = null; Envs.env = undefined; }; +// +// await t.test('parseRawEnvText 保留行内 # 与剥除外层双引号', () => { +// const parsed = Envs.parseRawEnvText('K1=v1\nK2=v with # hash\nK3="q # v"'); +// assert.strictEqual(parsed.K2, 'v with # hash'); +// assert.strictEqual(parsed.K3, 'q # v'); +// }); +// +// await t.test('CUSTOM_MERGE_RULES / COLOR_POOL / URL 类变量含 # 完整保留', () => { +// reset(); +// Envs.systemEnvBackup = {}; +// Envs.rawEnvValues = { +// CUSTOM_MERGE_RULES: 'A #1 revival@bili', +// COLOR_POOL: '#FF0000,#00FF00', +// DANMU_PUSH_URL: 'http://h.com/cb#frag', +// }; +// assert.strictEqual(Envs.get('CUSTOM_MERGE_RULES', '', 'string'), 'A #1 revival@bili'); +// assert.strictEqual(Envs.get('COLOR_POOL', '', 'string'), '#FF0000,#00FF00'); +// assert.strictEqual(Envs.get('DANMU_PUSH_URL', '', 'string'), 'http://h.com/cb#frag'); +// }); +// +// await t.test('!encrypt 守卫:加密变量不走原始解析,防止绕过加密', () => { +// reset(); +// Envs.systemEnvBackup = {}; +// Envs.rawEnvValues = { DANMU_PUSH_URL: 'http://x.com/cb#frag' }; +// assert.strictEqual(Envs.get('DANMU_PUSH_URL', 'DEF', 'string', true), 'DEF'); +// }); + // test('nipaplay 弹弹302关联工具函数', async (t) => { // // // generateNipaplaySignature:相同入参确定性产出,输出为 sha256 的 base64(44 字符)