From 595cdd63887e90440f463a8e5d11e78e4ede1a57 Mon Sep 17 00:00:00 2001 From: wan0ge Date: Thu, 13 Aug 2026 21:23:29 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=87=AA=E5=AE=9A=E4=B9=89=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E7=B1=BB=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E7=BB=95=E8=BF=87=20dotenv=20=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=E6=88=AA=E6=96=AD=E4=BB=A5=E4=BF=9D=E7=95=99=20#?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:dotenv.config() 将未加引号的行内 # 视为注释起始,导致自定义文本类变量中合法包含的 # 被截断、规则失效;即便用户通过前端写入双引号可规避,直接编辑 .env 不加引号时仍会触发。 方案:在 Envs.get 中对未加密的文本类自定义变量(RAW_ENV_KEYS)统一路由到 getRawEnv,直接解析 .env 原始文本(仅跳过整行 # 注释、剥离外层双引号),从而保留 # 等字符。优先级保持系统环境变量 > .env 原始值 > 默认值,与既有 systemEnvBackup 机制一致;非 Node 运行时(测试 / Workers)无原始 .env 时退化为普通取值语义,不影响测试隔离与平台兼容。前端设置页读取的 originalEnvVars 同样由 get/getRawEnv 写入,显示侧一并修复。同步移除 resolveCustomMergeRules 中已冗余的 accessedEnvVars.set 调用。 覆盖范围:RAW_ENV_KEYS 含合并映射表、标题/自动匹配映射表、弹幕偏移、屏蔽词、各标题/剧集噪音过滤器、IP 黑名单、AI 匹配提示,以及非加密的 COLOR_POOL / DANMU_PUSH_URL / OTHER_SERVER / VOD_SERVERS(URL / 颜色类)。 加密守卫:get() 路由条件加 !encrypt,防止加密变量(PROXY_URL / CUSTOM_SOURCE_API_URL / UPSTASH_REDIS_REST_URL / LOCAL_REDIS_URL 等带令牌 URL)被绕过加密返回明文;此类加密变量刻意不入 RAW_ENV_KEYS(其 # 风险极低且经安全写入路径)。 测试:worker.test.js 增加对应注释测试桩(按项目约定注释待启用);独立的 raw-env-read.test.mjs 仅本地验证、不入库。 --- danmu_api/configs/envs.js | 72 +++++++++++++++++++++++++++++++++++++-- danmu_api/server.js | 13 +++++++ danmu_api/worker.test.js | 33 ++++++++++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/danmu_api/configs/envs.js b/danmu_api/configs/envs.js index fda601011..d080ab319 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 385f38b25..9e8d84242 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'; @@ -41,6 +42,9 @@ const envPath = path.join(configDir, '.env'); // 保存系统环境变量的副本,确保它们具有最高优先级 const systemEnvBackup = { ...process.env }; +// 注入到 Envs,供自定义规则变量读取时判定系统环境变量优先级(绕过 dotenv 注释截断) +Envs.systemEnvBackup = systemEnvBackup; + // 引入 zlib 模块,用于响应数据的 GZIP 压缩 // (注:zlib 已在顶部 import,此处保留原版注释意图说明) @@ -137,6 +141,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 c634b1faa..f31985253 100644 --- a/danmu_api/worker.test.js +++ b/danmu_api/worker.test.js @@ -2727,3 +2727,36 @@ 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'); +// }); +// });