QUAL-004 handlers/messages.js — DM-on-customer-reply now reads guild.members.cache.get(claimerId) first and only falls back to guild.members.fetch on cache miss. Avoids a REST round-trip per non-staff reply on busy tickets. GuildMembers intent already keeps the cache warm. QUAL-005 handlers/buttons.js (runFinalClose) + handlers/commands/close.js (finalizeForceClose) — close paths now $unset welcomeMessageId alongside the status: 'closed' write. Stops a stale message-ID from carrying into a future reopen on the same Gmail thread, where escalation's "edit welcome buttons" path would silently fail trying to fetch a message in a deleted channel. QUAL-007 services/configPersistence.js — writeEnvFile mismatch error now includes the missing/extra key sets, not just count vs count. Saves the operator from guessing which key vanished after a partial write. QUAL-008 utils.js stripEmailQuotes — replaced order-dependent first-match loop with an earliest-match-across-all-markers scan. The previous code could truncate at a late "_____" signature underline even when an earlier "On X wrote:" reply header was the real cutoff. New test in tests/utils.test.js exercises the dual-marker case. QUAL-010 broccolini-discord.js — moved `let httpServer / internalServer / appReady` declarations from after the ready handler to before it. Same runtime behavior (module-load completes before ready fires asynchronously), but the read order now matches the assignment order. SEC-002 routes/internalApi.js — POST /restart now goes through a tighter 2/min limiter on top of the shared 10/min internalLimiter. Defense in depth in case INTERNAL_API_SECRET ever leaks; an attacker with the secret can no longer crash-loop the container. Skipped: QUAL-009 (re-checked the regex; ^\s*\n* → \n is already idempotent — the audit finding was incorrect). vitest run: 88/88 (one new test for QUAL-008).
214 lines
7.1 KiB
JavaScript
214 lines
7.1 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const { CONFIG } = require('../config');
|
|
const { getValidator } = require('./configSchema');
|
|
|
|
const ENV_PATH = path.resolve(process.cwd(), '.env');
|
|
|
|
/**
|
|
* Serialize a runtime value for .env storage.
|
|
*
|
|
* Default container: backticks. Under dotenv v17, backtick-wrapped values are
|
|
* preserved verbatim (literal newlines and inner quotes/backslashes all round-trip
|
|
* without escape processing), which is the only container that survives quotes
|
|
* AND newlines cleanly. Double-quoted values only decode `\n` / `\r`; `\"` and
|
|
* `\\` stay literal, so quoted-with-escapes doesn't round-trip.
|
|
*
|
|
* Fallback for values that themselves contain a backtick (vanishingly rare in
|
|
* env-style config): double-quote with escape-encoded `\\`, `\"`, `\n`, `\r`,
|
|
* `\t`. Caveat — CONFIG will receive the still-escaped `\"` / `\\` after boot
|
|
* because dotenv v17 won't reverse those. Flagged in the key-count verification
|
|
* at the bottom of writeEnvFile; the combination is unreachable via the UI.
|
|
*/
|
|
function encodeEnvValue(v) {
|
|
const s = String(v == null ? '' : v);
|
|
if (!s.includes('`')) return '`' + s + '`';
|
|
const escaped = s
|
|
.replace(/\\/g, '\\\\')
|
|
.replace(/"/g, '\\"')
|
|
.replace(/\n/g, '\\n')
|
|
.replace(/\r/g, '\\r')
|
|
.replace(/\t/g, '\\t');
|
|
return `"${escaped}"`;
|
|
}
|
|
|
|
/**
|
|
* Decode a raw .env value. Backtick and double-quote containers supported;
|
|
* unquoted values pass through (hand-edited .env back-compat).
|
|
*/
|
|
function decodeEnvValue(raw) {
|
|
if (raw.length >= 2 && raw.startsWith('`') && raw.endsWith('`')) {
|
|
return raw.slice(1, -1);
|
|
}
|
|
if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) {
|
|
const inner = raw.slice(1, -1);
|
|
let out = '';
|
|
for (let i = 0; i < inner.length; i++) {
|
|
if (inner[i] === '\\' && i + 1 < inner.length) {
|
|
const next = inner[i + 1];
|
|
if (next === 'n') out += '\n';
|
|
else if (next === 'r') out += '\r';
|
|
else if (next === 't') out += '\t';
|
|
else if (next === '"') out += '"';
|
|
else if (next === '\\') out += '\\';
|
|
else out += inner[i] + next;
|
|
i++;
|
|
} else {
|
|
out += inner[i];
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
/**
|
|
* Read the current .env file and parse into a key->value Map.
|
|
* Backtick-wrapped values may span multiple physical lines (dotenv behavior);
|
|
* this reader joins continuation lines until the closing backtick is found.
|
|
* Double-quoted values are decoded (`\n`/`\r` escapes processed);
|
|
* unquoted values pass through.
|
|
*/
|
|
function readEnvFile() {
|
|
if (!fs.existsSync(ENV_PATH)) return new Map();
|
|
const lines = fs.readFileSync(ENV_PATH, 'utf8').split('\n');
|
|
const map = new Map();
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
const idx = line.indexOf('=');
|
|
if (idx === -1) continue;
|
|
const key = line.slice(0, idx).trim();
|
|
let value = line.slice(idx + 1);
|
|
if (value.trimStart().startsWith('`')) {
|
|
let btCount = (value.match(/`/g) || []).length;
|
|
while (btCount < 2 && i + 1 < lines.length) {
|
|
i++;
|
|
value += '\n' + lines[i];
|
|
btCount = (value.match(/`/g) || []).length;
|
|
}
|
|
}
|
|
map.set(key, decodeEnvValue(value.trim()));
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Write a Map of key->value back to the .env file,
|
|
* preserving comments and blank lines. Values are encoded via encodeEnvValue.
|
|
*
|
|
* After write, re-reads the file and throws if the key count doesn't match the
|
|
* expected count — catches truncation or corrupted-quote escaping.
|
|
*/
|
|
function writeEnvFile(updates) {
|
|
const expected = updates.size;
|
|
|
|
if (!fs.existsSync(ENV_PATH)) {
|
|
const lines = [];
|
|
for (const [k, v] of updates) lines.push(`${k}=${encodeEnvValue(v)}`);
|
|
fs.writeFileSync(ENV_PATH, lines.join('\n') + '\n', 'utf8');
|
|
} else {
|
|
const raw = fs.readFileSync(ENV_PATH, 'utf8');
|
|
const lines = raw.split('\n');
|
|
const written = new Set();
|
|
const result = [];
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) { result.push(line); continue; }
|
|
const idx = line.indexOf('=');
|
|
if (idx === -1) { result.push(line); continue; }
|
|
const key = line.slice(0, idx).trim();
|
|
|
|
const spanStart = i;
|
|
let valueSoFar = line.slice(idx + 1);
|
|
if (valueSoFar.trimStart().startsWith('`')) {
|
|
let btCount = (valueSoFar.match(/`/g) || []).length;
|
|
while (btCount < 2 && i + 1 < lines.length) {
|
|
i++;
|
|
valueSoFar += '\n' + lines[i];
|
|
btCount = (valueSoFar.match(/`/g) || []).length;
|
|
}
|
|
}
|
|
|
|
if (updates.has(key)) {
|
|
written.add(key);
|
|
result.push(`${key}=${encodeEnvValue(updates.get(key))}`);
|
|
} else {
|
|
for (let j = spanStart; j <= i; j++) result.push(lines[j]);
|
|
}
|
|
}
|
|
|
|
for (const [k, v] of updates) {
|
|
if (!written.has(k)) result.push(`${k}=${encodeEnvValue(v)}`);
|
|
}
|
|
|
|
fs.writeFileSync(ENV_PATH, result.join('\n'), 'utf8');
|
|
}
|
|
|
|
const roundtrip = readEnvFile();
|
|
if (roundtrip.size !== expected) {
|
|
const expectedKeys = new Set(updates.keys());
|
|
const actualKeys = new Set(roundtrip.keys());
|
|
const missing = [...expectedKeys].filter(k => !actualKeys.has(k));
|
|
const extra = [...actualKeys].filter(k => !expectedKeys.has(k));
|
|
throw new Error(
|
|
`writeEnvFile: key count mismatch after write ` +
|
|
`(expected ${expected}, got ${roundtrip.size})` +
|
|
(missing.length ? `. Missing: [${missing.join(', ')}]` : '') +
|
|
(extra.length ? `. Extra: [${extra.join(', ')}]` : '')
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply a flat object of { KEY: value } to both CONFIG and .env.
|
|
* Returns { applied: string[], errors: Array<{ key, error }> }.
|
|
*
|
|
* Every value is routed through services/configSchema so invalid inputs are
|
|
* rejected before being persisted. Valid values are typed via the validator's
|
|
* coerced return (boolean/number/string) into CONFIG; .env always stores
|
|
* String(coerced). Invalid values are reported and NEITHER written to .env NOR
|
|
* applied to CONFIG — callers see them in the `errors` array.
|
|
*/
|
|
function applyConfigUpdates(updates) {
|
|
const applied = [];
|
|
const errors = [];
|
|
const coercedForEnv = new Map();
|
|
|
|
for (const [key, rawValue] of Object.entries(updates)) {
|
|
const validator = getValidator(key);
|
|
const result = validator.validate(rawValue);
|
|
if (!result.ok) {
|
|
errors.push({ key, error: result.error });
|
|
continue;
|
|
}
|
|
CONFIG[key] = result.coerced;
|
|
coercedForEnv.set(key, String(result.coerced));
|
|
applied.push(key);
|
|
}
|
|
|
|
// Write only the valid entries to .env. Skip the disk write entirely when
|
|
// nothing validated, so a fully-rejected save doesn't rewrite the file.
|
|
if (applied.length > 0) {
|
|
const envMap = readEnvFile();
|
|
for (const [key, value] of coercedForEnv) {
|
|
envMap.set(key, value);
|
|
}
|
|
writeEnvFile(envMap);
|
|
}
|
|
|
|
return { applied, errors };
|
|
}
|
|
|
|
/**
|
|
* Read all current env values for the settings UI.
|
|
*/
|
|
function readAllConfig() {
|
|
return readEnvFile();
|
|
}
|
|
|
|
module.exports = { applyConfigUpdates, readAllConfig, readEnvFile, writeEnvFile };
|