// lib/config.mjs — 身份与凭据解析 // 优先级:FEME_* 环境变量 > /opt/data/fmode-identity.json > 兼容旧名 FMODE_* // // 铁律:任何情况下都不打印、不落盘、不提交明文 token。 // 本模块只提供「取值」与「脱敏显示」,脱敏一律走 mask()。 import { readFileSync, existsSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; /** 身份文件候选路径(按序探测,取第一个存在的) */ export const IDENTITY_PATHS = [ process.env.FEME_IDENTITY_FILE, process.env.FMODE_IDENTITY_FILE, '/opt/data/fmode-identity.json', join(homedir(), '.fmode', 'identity.json'), ].filter(Boolean); /** 网关基础地址候选环境变量(FEME_* 优先) */ const BASE_ENV_KEYS = [ 'FEME_API_BASE_URL', 'FEME_API_BASE', 'FMODE_API_BASE_URL', 'FMODE_API_BASE', ]; /** token 候选环境变量(FEME_* 优先) */ const TOKEN_ENV_KEYS = [ 'FEME_API_TOKEN', 'FEME_API_KEY', 'FEME_TOKEN', 'FMODE_API_TOKEN', 'FMODE_API_KEY', 'FMODE_NEWAPI_TOKEN', 'OPENAI_API_KEY', ]; const DEFAULT_BASE = 'https://api.fmode.cn/v1'; /** 读取身份文件(不存在则返回 null,绝不抛错) */ function readIdentityFile() { for (const p of IDENTITY_PATHS) { try { if (p && existsSync(p)) { return { path: p, data: JSON.parse(readFileSync(p, 'utf8')) }; } } catch { /* 忽略损坏的身份文件,继续找下一个 */ } } return { path: null, data: {} }; } /** 从环境变量取第一个非空值 */ function firstEnv(keys) { for (const k of keys) { const v = process.env[k]; if (v && String(v).trim() && !String(v).includes('***')) return String(v).trim(); } return null; } /** 把 base url 归一化成以 /v1 结尾、无尾斜杠 */ export function normalizeBaseUrl(url) { let u = String(url || DEFAULT_BASE).trim().replace(/\/+$/, ''); if (!/\/v\d+$/.test(u)) u += '/v1'; return u; } let _cache = null; /** * 解析运行配置。结果会被缓存(同一进程内身份不会变)。 * @returns {{baseUrl:string, rootUrl:string, token:string|null, tokenSource:string, * identityPath:string|null, identity:object}} */ export function loadConfig({ refresh = false } = {}) { if (_cache && !refresh) return _cache; const ident = readIdentityFile(); const id = ident.data || {}; // token:FEME_* > 身份文件 > FMODE_* let token = firstEnv(['FEME_API_TOKEN', 'FEME_API_KEY', 'FEME_TOKEN']); let tokenSource = token ? 'env:FEME_*' : null; if (!token) { for (const k of ['newapi_token', 'api_key', 'apiKey', 'token']) { if (id[k] && String(id[k]).trim()) { token = String(id[k]).trim(); tokenSource = `identity:${k}`; break; } } } if (!token) { token = firstEnv(['FMODE_API_TOKEN', 'FMODE_API_KEY', 'FMODE_NEWAPI_TOKEN', 'OPENAI_API_KEY']); tokenSource = token ? 'env:FMODE_*' : null; } // base url:FEME_* > 身份文件 > FMODE_* > 默认 let baseUrl = firstEnv(['FEME_API_BASE_URL', 'FEME_API_BASE']); if (!baseUrl && id.api_base_url) baseUrl = String(id.api_base_url); if (!baseUrl) baseUrl = firstEnv(['FMODE_API_BASE_URL', 'FMODE_API_BASE']); if (!baseUrl) baseUrl = DEFAULT_BASE; const normalized = normalizeBaseUrl(baseUrl); const rootUrl = normalized.replace(/\/v\d+$/, ''); _cache = { baseUrl: normalized, rootUrl, token, tokenSource, identityPath: ident.path, identity: id, }; return _cache; } /** 脱敏显示,用于日志/错误信息。永远不要把未脱敏 token 拼进字符串。 */ export function mask(secret) { const s = String(secret || ''); if (!s) return ''; if (s.length <= 8) return s[0] + '*'.repeat(Math.max(1, s.length - 1)); return `${s.slice(0, 3)}${'*'.repeat(6)}${s.slice(-4)}`; } /** 取 token,缺失则抛出可操作的错误 */ export function requireToken() { const { token } = loadConfig(); if (!token) { throw new Error( '未找到 API 凭据。请任选其一:\n' + ' 1) 设置环境变量 FEME_API_TOKEN=<你的token>\n' + ' 2) 写入 /opt/data/fmode-identity.json 的 newapi_token 字段\n' + ' 3) 兼容旧变量 FMODE_API_KEY\n' + '(本技能不会把 token 写入任何文件或日志)' ); } return token; } /** * 生成到子进程的安全环境变量(供 ffmpeg 等外部命令使用), * 不包含任何 token。 */ export function safeEnv() { const env = { ...process.env }; for (const k of [...TOKEN_ENV_KEYS, ...BASE_ENV_KEYS]) delete env[k]; return env; } /** 打印当前配置摘要(token 已脱敏) */ export function describeConfig() { const c = loadConfig(); return [ `baseUrl : ${c.baseUrl}`, `rootUrl : ${c.rootUrl}`, `token : ${mask(c.token)} (来源: ${c.tokenSource || '未配置'})`, `identityFile : ${c.identityPath || '未找到'}`, ].join('\n'); } export { BASE_ENV_KEYS, TOKEN_ENV_KEYS, DEFAULT_BASE };