159 lines
6.1 KiB
JavaScript
159 lines
6.1 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';
|
||
|
||
/* ── 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'
|
||
],
|
||
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','status'],
|
||
reminders : ['phone_e164','message_body'],
|
||
milestone_impacts : ['amount','impact_type'],
|
||
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(); }
|
||
|
||
/* ── 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 : 5,
|
||
ssl : {
|
||
ca : process.env.DB_SSL_CA,
|
||
key : process.env.DB_SSL_KEY,
|
||
cert : process.env.DB_SSL_CERT
|
||
}
|
||
});
|
||
|
||
/* ── 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 normalized = sql.replace(/\s+/g, ' ').toLowerCase();
|
||
|
||
// INSERT INTO table (col1, col2, ...) VALUES (?, ?, ...)
|
||
if (normalized.includes('insert into')) {
|
||
const m = normalized.match(/\(\s*([^)]+?)\s*\)\s*values/i);
|
||
if (!m || !m[1]) {
|
||
console.warn(`[DAO] INSERT column extraction failed for param ${paramIndex}`);
|
||
return null;
|
||
}
|
||
const colList = m[1].split(',').map(s => s.replace(/`/g, '').trim());
|
||
const col = colList[paramIndex] ?? null;
|
||
console.log(`[DAO] Param ${paramIndex} maps to column: ${col}`);
|
||
return col;
|
||
}
|
||
|
||
// UPDATE table SET col1 = ?, col2 = ? WHERE ...
|
||
if (normalized.includes('update')) {
|
||
const m = normalized.match(/set\s+(.*?)\s*(where|$)/);
|
||
if (!m || !m[1]) {
|
||
console.warn(`[DAO] UPDATE column extraction failed for param ${paramIndex}`);
|
||
return null;
|
||
}
|
||
const colList = m[1].split(',').map(s =>
|
||
s.split('=')[0].replace(/`/g, '').trim()
|
||
);
|
||
const col = colList[paramIndex] ?? null;
|
||
console.log(`[DAO] Param ${paramIndex} maps to column: ${col}`);
|
||
return col;
|
||
}
|
||
|
||
// SELECT ... WHERE col = ?
|
||
if (normalized.includes('where')) {
|
||
const m = normalized.match(/where\s+([a-z0-9_]+)\s*=/i);
|
||
const col = m?.[1]?.trim() ?? null;
|
||
console.log(`[DAO] Param ${paramIndex} maps to column (WHERE clause): ${col}`);
|
||
return col;
|
||
}
|
||
|
||
console.log(`[DAO] No column mapping for param ${paramIndex} — unsupported SQL`);
|
||
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 tables = extractTables(sql);
|
||
const encryptNeeded = (col) => tables.some(t => TABLE_MAP[t]?.includes(col));
|
||
|
||
const encParams = params.map((v, i) => {
|
||
const col = extractColumn(sql, i);
|
||
return (col && encryptNeeded(col) && v != null && !isEncrypted(v))
|
||
? encrypt(v)
|
||
: v;
|
||
});
|
||
|
||
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
|