164 lines
6.2 KiB
JavaScript
164 lines
6.2 KiB
JavaScript
/* ────────────────────────────────────────────────────────────────
|
||
Drop‑in replacement for mysql2 that transparently encrypts /
|
||
decrypts selected columns, using helpers above.
|
||
---------------------------------------------------------------- */
|
||
|
||
import mysql from 'mysql2/promise';
|
||
import {
|
||
encrypt,
|
||
decrypt,
|
||
isEncrypted,
|
||
initEncryption
|
||
} from '../crypto/encryption.js';
|
||
|
||
const WRITE_RE = /^\s*(insert|update|replace)\s/i;
|
||
|
||
/* ── map of columns that must be protected ─────────────────── */
|
||
const TABLE_MAP = {
|
||
user_profile : [
|
||
'username', 'firstname', 'lastname', 'email', 'phone_e164',
|
||
'zipcode', 'stripe_customer_id',
|
||
'interest_inventory_answers', 'riasec_scores',
|
||
'career_priorities', 'career_list'
|
||
],
|
||
financial_profiles : [
|
||
'current_salary','additional_income','monthly_expenses',
|
||
'monthly_debt_payments','retirement_savings','emergency_fund',
|
||
'retirement_contribution','emergency_contribution',
|
||
'extra_cash_emergency_pct','extra_cash_retirement_pct'
|
||
],
|
||
career_profiles : [
|
||
'planned_monthly_expenses','planned_monthly_debt_payments',
|
||
'planned_monthly_retirement_contribution','planned_monthly_emergency_contribution',
|
||
'planned_surplus_emergency_pct','planned_surplus_retirement_pct',
|
||
'planned_additional_income','career_goals','desired_retirement_income_monthly',
|
||
'career_name','start_date','retirement_start_date','scenario_title'
|
||
],
|
||
college_profiles : [
|
||
'selected_school','selected_program','annual_financial_aid',
|
||
'existing_college_debt','tuition','tuition_paid','loan_deferral_until_graduation',
|
||
'loan_term','interest_rate','extra_payment','expected_salary'
|
||
],
|
||
milestones : ['title','description','date','progress'],
|
||
tasks : ['title','description','due_date'],
|
||
reminders : ['phone_e164','message_body'],
|
||
milestone_impacts : ['amount','impact_type', 'direction'],
|
||
ai_risk_analysis : ['reasoning','risk_level'],
|
||
ai_generated_ksa : ['knowledge_json','abilities_json','skills_json'],
|
||
context_cache : ['ctx_text']
|
||
};
|
||
|
||
/* ── initialise KMS unwrap once ─────────────────────────────── */
|
||
async function ensureCryptoReady () { await initEncryption(); }
|
||
|
||
// choose cap per-process from env (server1/2/3 each have their own DB_POOL_SIZE)
|
||
const cap = Number(process.env.DB_POOL_SIZE || 5);
|
||
console.log(`[db] mysql pool connectionLimit=${cap}`);
|
||
|
||
/* ── mysql connection pool (uses env injected by docker) ────── */
|
||
export const pool = mysql.createPool({
|
||
host : process.env.DB_HOST,
|
||
port : process.env.DB_PORT,
|
||
user : process.env.DB_USER,
|
||
password : process.env.DB_PASSWORD,
|
||
database : process.env.DB_NAME,
|
||
waitForConnections : true,
|
||
connectionLimit : cap,
|
||
ssl : {
|
||
ca : process.env.DB_SSL_CA,
|
||
key : process.env.DB_SSL_KEY,
|
||
cert : process.env.DB_SSL_CERT,
|
||
servername: ''
|
||
}
|
||
});
|
||
|
||
/* ── tiny helpers to parse SQL (works for *your* queries) ───── */
|
||
function extractTables (sql) {
|
||
return [...new Set(
|
||
[...sql.matchAll(/\b(?:from|join|into|update)\s+`?([a-z0-9_]+)`?/ig)]
|
||
.map(m => m[1].toLowerCase())
|
||
)];
|
||
}
|
||
|
||
function extractColumn(sql, paramIndex) {
|
||
const s = sql.replace(/\s+/g, ' ').toLowerCase();
|
||
|
||
// INSERT INTO t (c1, c2, ...) VALUES (?, ?, ...)
|
||
const ins = s.match(/insert\s+into\s+[`\w]+\s*\(([^)]+)\)\s*values\s*\(([^)]+)\)/i);
|
||
if (ins) {
|
||
const cols = ins[1].split(',').map(c => c.replace(/`/g, '').trim());
|
||
return cols[paramIndex] || null; // only VALUES params exist here
|
||
}
|
||
|
||
// UPDATE t SET c1 = ?, c2 = ? WHERE ...
|
||
const upd = s.match(/update\s+[`\w]+\s+set\s+(.+?)(?:\s+where|\s*$)/i);
|
||
if (upd) {
|
||
const setPart = upd[1];
|
||
|
||
// Build a list of columns in the same order as the '?'s in SET only
|
||
const pairs = setPart.split(',').map(p => p.trim());
|
||
const colsForQs = [];
|
||
for (const p of pairs) {
|
||
const col = p.split('=')[0].replace(/`/g, '').trim();
|
||
const qCnt = (p.match(/\?/g) || []).length;
|
||
for (let i = 0; i < qCnt; i++) colsForQs.push(col);
|
||
}
|
||
|
||
const setQCount = colsForQs.length;
|
||
if (paramIndex < setQCount) return colsForQs[paramIndex];
|
||
|
||
// params after SET (WHERE/LIMIT/etc.) → no mapping
|
||
return null;
|
||
}
|
||
|
||
// SELECT/DELETE/etc. → we don’t map WHERE/LIMIT params
|
||
return null;
|
||
}
|
||
|
||
|
||
|
||
function decryptRow (row, tables) {
|
||
for (const t of tables) {
|
||
const encSet = new Set((TABLE_MAP[t] ?? []).map(c => c.toLowerCase()));
|
||
for (const k of Object.keys(row)) {
|
||
if (!encSet.has(k.toLowerCase())) continue;
|
||
const val = row[k];
|
||
if (val != null && isEncrypted(val)) row[k] = decrypt(val);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ─────────────────────────────────────────────────────────────
|
||
Replacement for pool.execute / pool.query
|
||
──────────────────────────────────────────────────────────── */
|
||
export async function exec(sql, params = []) {
|
||
await ensureCryptoReady();
|
||
|
||
const isWrite = WRITE_RE.test(sql);
|
||
const tables = extractTables(sql);
|
||
|
||
const encryptNeeded = (col) =>
|
||
tables.some(t => (TABLE_MAP[t] || []).some(c =>
|
||
c.toLowerCase() === String(col || '').toLowerCase()
|
||
));
|
||
|
||
const encParams = isWrite
|
||
? params.map((v, i) => {
|
||
const col = extractColumn(sql, i);
|
||
return (col && encryptNeeded(col) && v != null && !isEncrypted(v))
|
||
? encrypt(v)
|
||
: v;
|
||
})
|
||
: params; // SELECT path: never touch params
|
||
|
||
const [rows, fields] = await pool.execute(sql, encParams);
|
||
if (Array.isArray(rows)) for (const row of rows) decryptRow(row, tables);
|
||
return [rows, fields];
|
||
}
|
||
|
||
/* ── mysql‑like façade so existing code keeps working ───────── */
|
||
export const query = exec;
|
||
export const getConnection = () => pool.getConnection();
|
||
export const end = () => pool.end();
|
||
export const poolRaw = pool; // escape hatch
|