import express from 'express'; import cors from 'cors'; import helmet from 'helmet'; import dotenv from 'dotenv'; import { fileURLToPath } from 'url'; import path from 'path'; import bodyParser from 'body-parser'; import bcrypt from 'bcrypt'; import jwt from 'jsonwebtoken'; import { initEncryption, encrypt, decrypt, verifyCanary, SENTINEL } from './shared/crypto/encryption.js'; import pool from './config/mysqlPool.js'; import sqlite3 from 'sqlite3'; import crypto from 'crypto'; import sgMail from '@sendgrid/mail'; import rateLimit from 'express-rate-limit'; import { readFile } from 'fs/promises'; // ← needed for /healthz import { requireAuth } from './shared/requireAuth.js'; import cookieParser from 'cookie-parser'; const CANARY_SQL = ` CREATE TABLE IF NOT EXISTS encryption_canary ( id TINYINT NOT NULL PRIMARY KEY, value TEXT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootPath = path.resolve(__dirname, '..'); const env = process.env.NODE_ENV?.trim() || 'development'; const envPath = path.resolve(rootPath, `.env.${env}`); dotenv.config({ path: envPath, override: false }); const { JWT_SECRET, CORS_ALLOWED_ORIGINS, SERVER1_PORT = 5000 } = process.env; if (!JWT_SECRET) { console.error('FATAL: JWT_SECRET missing – aborting startup'); process.exit(1); } if (!CORS_ALLOWED_ORIGINS) { console.error('FATAL: CORS_ALLOWED_ORIGINS missing – aborting startup'); process.exit(1); } // Unwrap / verify DEK and seed canary before serving traffic try { await initEncryption(); // <-- wrap in try/catch const db = pool.raw || pool; // <-- bypass DAO wrapper for canary ops const DB_POOL_SIZE = 12; // quick connectivity check await db.query('SELECT 1'); // ① ensure table await db.query(CANARY_SQL); // ② insert sentinel on first run (ignore if exists) await db.query( 'INSERT IGNORE INTO encryption_canary (id, value) VALUES (1, ?)', [encrypt(SENTINEL)] ); // ③ read back & verify const [rows] = await db.query( 'SELECT value FROM encryption_canary WHERE id = 1 LIMIT 1' ); const plaintext = decrypt(rows[0]?.value || ''); if (plaintext !== SENTINEL) { throw new Error('DEK mismatch with database sentinel'); } console.log('[ENCRYPT] DEK verified against canary – proceeding'); } catch (err) { console.error('FATAL:', err?.message || err); process.exit(1); } // …the rest of your server: app = express(), middlewares, routes, app.listen() /* ──────────────────────────────────────────────────────────────── Express app & middleware ---------------------------------------------------------------- */ const app = express(); const PORT = process.env.SERVER1_PORT || 5000; app.disable('x-powered-by'); app.use(express.json({ limit: '1mb' })); if (process.env.NODE_ENV === 'prod') app.set('trust proxy', 1); // important if you're behind a proxy/HTTPS terminator app.use(cookieParser()); app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false })); app.use((req, res, next) => { if (req.path.startsWith('/api/')) res.type('application/json'); next(); }); // --- Request ID + minimal audit log for /api/* --- function getRequestId(req, res) { const hdr = req.headers['x-request-id']; if (typeof hdr === 'string' && hdr) return hdr; // from Nginx const rid = crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2,8)}`; res.setHeader('X-Request-ID', rid); return rid; } app.use((req, res, next) => { if (!req.path.startsWith('/api/')) return next(); const rid = getRequestId(req, res); const t0 = process.hrtime.bigint(); res.on('finish', () => { const durMs = Number((process.hrtime.bigint() - t0) / 1_000_000n); const out = { ts: new Date().toISOString(), rid, ip: req.ip || req.headers['x-forwarded-for'] || '', method: req.method, path: req.path, status: res.statusCode, dur_ms: durMs, bytes_sent: Number(res.getHeader('Content-Length') || 0), userId: req.userId || req.id || null }; try { console.log(JSON.stringify(out)); } catch {} }); next(); }); // ---- RUNTIME: minimal audit logging (API only, redacted) ---- function pickIp(req) { // trust proxy already set in your apps return req.ip || req.headers['x-forwarded-for'] || req.socket?.remoteAddress || ''; } function redactHeaders(h) { const out = { ...h }; delete out.authorization; delete out.cookie; delete out['x-forwarded-for']; return out; } function sampleBody(b) { if (!b || typeof b !== 'object') return undefined; // avoid logging PII: show keys + small snippet const keys = Object.keys(b); const preview = {}; for (const k of keys.slice(0, 12)) { const v = b[k]; preview[k] = typeof v === 'string' ? (v.length > 80 ? v.slice(0, 80) + '…' : v) : (Array.isArray(v) ? `[array:${v.length}]` : typeof v); } return preview; } app.use((req, res, next) => { if (!req.path.startsWith('/api/')) return next(); // correlation id const rid = req.headers['x-request-id'] || crypto.randomUUID?.() || String(Date.now()); res.setHeader('X-Request-ID', rid); const t0 = process.hrtime.bigint(); // capture minimal request data const reqLog = { ts: new Date().toISOString(), rid, ip: pickIp(req), method: req.method, path: req.path, userId: req.userId || req.id || null, // populated by your auth middleware on many routes ua: req.headers['user-agent'] || '', hdr: redactHeaders(req.headers), body: sampleBody(req.body) }; res.on('finish', () => { const durMs = Number((process.hrtime.bigint() - t0) / 1_000_000n); const out = { ...reqLog, status: res.statusCode, dur_ms: durMs, bytes_sent: Number(res.getHeader('Content-Length') || 0) }; // one line JSON per request try { console.log(JSON.stringify(out)); } catch {} }); next(); }); // ---- RUNTIME: never cache API responses ---- app.use((req, res, next) => { if (req.path.startsWith('/api/')) { res.set('Cache-Control', 'no-store'); res.set('Pragma', 'no-cache'); res.set('Expires', '0'); } next(); }); process.on('unhandledRejection', (e) => console.error('[unhandledRejection]', e)); process.on('uncaughtException', (e) => console.error('[uncaughtException]', e)); // ---- RUNTIME: enforce JSON on API writes (with narrow exceptions) ---- const MUST_JSON = new Set(['POST','PUT','PATCH']); const EXEMPT_PATHS = [ // server3 /^\/api\/premium\/resume\/optimize$/, // multer (multipart/form-data) /^\/api\/premium\/stripe\/webhook$/, // Stripe (express.raw) // add others if truly needed ]; app.use((req, res, next) => { if (!req.path.startsWith('/api/')) return next(); if (!MUST_JSON.has(req.method)) return next(); if (EXEMPT_PATHS.some(rx => rx.test(req.path))) return next(); const ct = req.headers['content-type'] || ''; if (!ct.toLowerCase().includes('application/json')) { return res.status(415).json({ error: 'unsupported_media_type' }); } next(); }); // ---- RUNTIME PROTECTION: HPP guard (dedupe + cap arrays) ---- app.use((req, _res, next) => { const MAX_ARRAY = 20; // sane cap; adjust if you truly need more const sanitize = (obj) => { if (!obj || typeof obj !== 'object') return; for (const k of Object.keys(obj)) { const v = obj[k]; if (Array.isArray(v)) { // keep first value semantics + bound array size obj[k] = v.slice(0, MAX_ARRAY).filter(x => x !== '' && x != null); if (obj[k].length === 1) obj[k] = obj[k][0]; // collapse singletons } } }; sanitize(req.query); sanitize(req.body); next(); }); // ---- RUNTIME: reject request bodies on GET/HEAD ---- app.use((req, res, next) => { if ((req.method === 'GET' || req.method === 'HEAD') && Number(req.headers['content-length'] || 0) > 0) { return res.status(400).json({ error: 'no_body_allowed' }); } next(); }); // ---- RUNTIME: last-resort error sanitizer ---- app.use((err, req, res, _next) => { // don’t double-send if (res.headersSent) return; // map a few known errors cleanly if (err?.code === 'LIMIT_FILE_SIZE') { return res.status(413).json({ error: 'file_too_large', limit_mb: 10 }); } if (err?.message && String(err.message).startsWith('blocked_outbound_host:')) { return res.status(400).json({ error: 'blocked_outbound_host' }); } if (err?.message === 'unsupported_type') { return res.status(415).json({ error: 'unsupported_type' }); } // default: generic 500 without internals console.error('[unhandled]', err?.message || err); // logs to stderr only return res.status(500).json({ error: 'Server error' }); }); /* ─── Allowed origins for CORS (comma-separated in env) ──────── */ const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS .split(',') .map(o => o.trim()) .filter(Boolean); function sessionCookieOptions() { const IS_PROD = process.env.NODE_ENV === 'prod'; const CROSS_SITE = process.env.CROSS_SITE_COOKIES === '1'; // set to "1" if FE and API are different sites const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || undefined; return { httpOnly: true, secure: IS_PROD, // <-- not secure in local dev sameSite: CROSS_SITE ? 'none' : 'lax', path: '/', maxAge: 2 * 60 * 60 * 1000, ...(COOKIE_DOMAIN ? { domain: COOKIE_DOMAIN } : {}), }; } const COOKIE_NAME = process.env.SESSION_COOKIE_NAME || 'aptiva_session'; function fprPathFromEnv() { const p = (process.env.DEK_PATH || '').trim(); return p ? path.join(path.dirname(p), 'dek.fpr') : null; } // 1) Liveness: process is up and event loop responsive app.get('/livez', (_req, res) => res.type('text').send('OK')); // 2) Readiness: crypto + canary are good app.get('/readyz', async (_req, res) => { try { await initEncryption(); // load/unlock DEK await verifyCanary(pool); // DB + decrypt sentinel return res.type('text').send('OK'); } catch (e) { console.error('[READYZ]', e.message); return res.status(500).type('text').send('FAIL'); } }); // 3) Health: detailed JSON (you can curl this to “see everything”) app.get('/healthz', async (_req, res) => { const out = { service: process.env.npm_package_name || 'server', version: process.env.IMG_TAG || null, uptime_s: Math.floor(process.uptime()), now: new Date().toISOString(), checks: { live: { ok: true }, // if we reached here, process is up crypto: { ok: false, fp: null }, db: { ok: false, ping_ms: null }, canary: { ok: false } } }; // crypto / DEK try { await initEncryption(); out.checks.crypto.ok = true; const p = fprPathFromEnv(); if (p) { try { out.checks.crypto.fp = (await readFile(p, 'utf8')).trim(); } catch { /* fp optional */ } } } catch (e) { out.checks.crypto.error = e.message; } // DB ping const t0 = Date.now(); try { await pool.query('SELECT 1'); out.checks.db.ok = true; out.checks.db.ping_ms = Date.now() - t0; } catch (e) { out.checks.db.error = e.message; } // canary try { await verifyCanary(pool); out.checks.canary.ok = true; } catch (e) { out.checks.canary.error = e.message; } const ready = out.checks.crypto.ok && out.checks.db.ok && out.checks.canary.ok; return res.status(ready ? 200 : 503).json(out); }); // Password reset token table (MySQL) try { const db = pool.raw || pool; await db.query(` CREATE TABLE IF NOT EXISTS password_resets ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, email VARCHAR(255) NOT NULL, token_hash CHAR(64) NOT NULL, expires_at BIGINT NOT NULL, used_at BIGINT NULL, created_at BIGINT NOT NULL, ip VARCHAR(64) NULL, KEY (email), KEY (token_hash), KEY (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; `); console.log('[AUTH] password_resets table ready'); } catch (e) { console.error('FATAL creating password_resets table:', e?.message || e); process.exit(1); } // Enable CORS with dynamic origin checking app.use( cors({ origin: (origin, callback) => { if (!origin || allowedOrigins.includes(origin)) { callback(null, true); } else { console.error('Blocked by CORS:', origin); callback(new Error('Not allowed by CORS')); } }, methods: ['GET', 'POST', 'OPTIONS'], allowedHeaders: [ 'Authorization', 'Content-Type', 'Accept', 'Origin', 'X-Requested-With', ], credentials: true, }) ); // keep tight on request const pwRequestLimiter = rateLimit({ windowMs: 30 * 1000, max: 1, standardHeaders: true, legacyHeaders: false, keyGenerator: (req) => req.ip, }); // allow a couple retries on confirm const pwConfirmLimiter = rateLimit({ windowMs: 30 * 1000, max: 3, standardHeaders: true, legacyHeaders: false, keyGenerator: (req) => req.ip, }); // change-password inside the app const pwChangeLimiter = rateLimit({ windowMs: 30 * 1000, max: 3, standardHeaders: true, legacyHeaders: false, keyGenerator: (req) => req.ip, }); const pwDailyLimiter = rateLimit({ windowMs: 24 * 60 * 60 * 1000, // per day max: 5, standardHeaders: true, legacyHeaders: false, keyGenerator: (req) => req.ip, }); async function setPasswordByEmail(email, bcryptHash) { const sql = ` UPDATE user_auth ua JOIN user_profile up ON up.id = ua.user_id SET ua.hashed_password = ?, ua.password_changed_at = ? WHERE up.email_lookup = ? `; const now = Date.now(); const [r] = await (pool.raw || pool).query(sql, [bcryptHash, now, emailLookup(email)]); return !!r?.affectedRows; } // ---- Email index helper (HMAC-SHA256 of normalized email) ---- const EMAIL_INDEX_KEY = process.env.EMAIL_INDEX_SECRET || JWT_SECRET; function emailLookup(email) { return crypto .createHmac('sha256', EMAIL_INDEX_KEY) .update(String(email).trim().toLowerCase()) .digest('hex'); } // ----- Password reset config (zero-config dev mode) ----- const RESET_CONFIG = { // accept both spellings just in case BASE_URL: process.env.APTIVA_API_BASE || 'http://localhost:5173', FROM: 'no-reply@aptivaai.com', // edit here if you want TTL_MIN: 60, // edit here if you want }; // --- SendGrid config (safe + simple) --- const SENDGRID_KEY = String( process.env.SUPPORT_SENDGRID_API_KEY || process.env.SENDGRID_API_KEY || // optional fallback process.env.SUPPORT_SENDGRID_API_KEY_dev || // if you exported _dev '' ).trim(); const SENDGRID_ENABLED = SENDGRID_KEY.length > 0; if (SENDGRID_ENABLED) { sgMail.setApiKey(SENDGRID_KEY); console.log('[MAIL] SendGrid enabled'); } else { console.log('[MAIL] SendGrid disabled — will log reset links only'); } const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*]).{8,}$/; const PASSWORD_HELP = 'Password must include at least 8 characters, one uppercase, one lowercase, one number, and one special character (!@#$%^&*).'; // Change password (must be logged in) app.post('/api/auth/password-change', requireAuth, pwChangeLimiter, async (req, res) => { try { const now = Date.now(); const userId = req.userId; if (!userId) return res.status(401).json({ error: 'Not authenticated' }); const { currentPassword, newPassword } = req.body || {}; if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') { return res.status(400).json({ error: 'Invalid payload' }); } if (!PASSWORD_REGEX.test(newPassword)) { return res.status(400).json({ error: PASSWORD_HELP }); } if (newPassword === currentPassword) { return res.status(400).json({ error: 'New password must be different' }); } const db = pool.raw || pool; const [rows] = await db.query( 'SELECT hashed_password FROM user_auth WHERE user_id = ? ORDER BY id DESC LIMIT 1', [userId] ); const existing = rows?.[0]; if (!existing) return res.status(404).json({ error: 'Account not found' }); const ok = await bcrypt.compare(currentPassword, existing.hashed_password); if (!ok) return res.status(403).json({ error: 'Current password is incorrect' }); const newHash = await bcrypt.hash(newPassword, 10); // 🔧 store epoch ms to match requireAuth’s comparison const [upd] = await db.query( 'UPDATE user_auth SET hashed_password = ?, password_changed_at = ? WHERE user_id = ?', [newHash, now, userId] ); if (!upd?.affectedRows) return res.status(500).json({ error: 'Password update failed' }); // Optional: revoke all refresh tokens for this user on password change // await db.query('DELETE FROM refresh_tokens WHERE user_id = ?', [userId]); return res.status(200).json({ ok: true }); } catch (e) { console.error('[password-change]', e?.message || e); return res.status(500).json({ error: 'Server error' }); } }); /*Password reset request (MySQL)*/ app.post('/api/auth/password-reset/request', pwRequestLimiter, pwDailyLimiter, async (req, res) => { try { const email = String(req.body?.email || '').trim().toLowerCase(); // Always respond generically to avoid enumeration const generic = () => res.status(200).json({ ok: true }); if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return generic(); // Check if email exists (generic response regardless) let exists = false; try { const [rows] = await (pool.raw || pool).query( `SELECT ua.user_id FROM user_auth ua JOIN user_profile up ON up.id = ua.user_id WHERE up.email_lookup = ? LIMIT 1`, [emailLookup(email)] ); exists = !!rows?.length; } catch { /* ignore */ } // Only send if (a) we have SendGrid configured AND (b) email exists if (exists) { const token = crypto.randomBytes(32).toString('hex'); const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); const now = Date.now(); const expiresAt = now + RESET_CONFIG.TTL_MIN * 60 * 1000; await (pool.raw || pool).query( `INSERT INTO password_resets (email, token_hash, expires_at, created_at, ip) VALUES (?, ?, ?, ?, ?)`, [email, tokenHash, expiresAt, now, req.ip] ); const base = RESET_CONFIG.BASE_URL.replace(/\/+$/, ''); const link = `${base}/reset-password/${token}`; const text = `We received a request to reset your Aptiva password. If you requested this, use the link below (valid for ${RESET_CONFIG.TTL_MIN} minutes): ${link} If you didn’t request it, you can ignore this email.`; if (SENDGRID_ENABLED) { await sgMail.send({ to: email, from: RESET_CONFIG.FROM, subject: 'Reset your Aptiva password', text, html: `
${text}
` }); } else { // Zero-config dev mode: just log the link so you can click it console.log(`[DEV] Password reset link for ${email}: ${link}`); } } return generic(); } catch (e) { console.error('[password-reset/request]', e?.message || e); // Still generic return res.status(200).json({ ok: true }); } }); app.post('/api/auth/password-reset/confirm', pwConfirmLimiter, async (req, res) => { try { const { token, password } = req.body || {}; // 1) Validate input + password policy if (typeof password !== 'string' || !PASSWORD_REGEX.test(password)) { return res.status(400).json({ error: PASSWORD_HELP }); } if (typeof token !== 'string' || !token) { return res.status(400).json({ error: 'Invalid or expired token' }); } const now = Date.now(); const db = pool.raw || pool; // 2) Lookup token row (not used, not expired) const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); const [tokRows] = await db.query( `SELECT * FROM password_resets WHERE token_hash = ? AND used_at IS NULL AND expires_at > ? ORDER BY id DESC LIMIT 1`, [tokenHash, now] ); const tok = tokRows?.[0]; if (!tok) { return res.status(400).json({ error: 'Invalid or expired token' }); } // 3) Find user by email_lookup (HMAC of normalized email) const emailNorm = String(tok.email).trim().toLowerCase(); const emailLookupVal = emailLookup(emailNorm); const [userRows] = await db.query( `SELECT ua.user_id, ua.hashed_password FROM user_auth ua JOIN user_profile up ON up.id = ua.user_id WHERE up.email_lookup = ? ORDER BY ua.id DESC LIMIT 1`, [emailLookupVal] ); const user = userRows?.[0]; if (!user) { // burn token anyway await db.query(`UPDATE password_resets SET used_at = ? WHERE id = ? LIMIT 1`, [now, tok.id]); return res.status(400).json({ error: 'Account not found for this link' }); } // 4) Prevent same-password reset const same = await bcrypt.compare(password, user.hashed_password); if (same) { await db.query(`UPDATE password_resets SET used_at = ? WHERE id = ? LIMIT 1`, [now, tok.id]); return res.status(400).json({ error: 'New password must be different from the current password.' }); } // 5) Update password (stamp epoch ms to match requireAuth) const newHash = await bcrypt.hash(password, 10); const [upd] = await db.query( `UPDATE user_auth SET hashed_password = ?, password_changed_at = ? WHERE user_id = ?`, [newHash, now, user.user_id] ); if (!upd?.affectedRows) { return res.status(500).json({ error: 'Password update failed' }); } // 6) Burn the token await db.query(`UPDATE password_resets SET used_at = ? WHERE id = ? LIMIT 1`, [now, tok.id]); return res.status(200).json({ ok: true }); } catch (e) { console.error('[password-reset/confirm]', e?.message || e); if (e?.stack) console.error(e.stack); return res.status(500).json({ error: 'Server error' }); } }); /* ------------------------------------------------------------------ USER REGISTRATION (MySQL) ------------------------------------------------------------------ */ app.post('/api/register', async (req, res) => { const { username, password, firstname, lastname, email, zipcode, state, area, career_situation, phone_e164, sms_opt_in } = req.body; if (!username || !password || !firstname || !lastname || !email || !zipcode || !state || !area) { return res.status(400).json({ error: 'Missing required fields.' }); } if (sms_opt_in && !/^\+\d{8,15}$/.test(phone_e164 || '')) { return res.status(400).json({ error: 'Phone must be +E.164 format.' }); } try { const hashedPassword = await bcrypt.hash(password, 10); const emailNorm = String(email).trim().toLowerCase(); const encEmail = encrypt(emailNorm); // if encrypt() is async in your lib, use: await encrypt(...) const emailLookupVal = emailLookup(emailNorm); const [resultProfile] = await pool.query(` INSERT INTO user_profile (username, firstname, lastname, email, email_lookup, zipcode, state, area, career_situation, phone_e164, sms_opt_in) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ username, firstname, lastname, encEmail, emailLookupVal, zipcode, state, area, career_situation || null, phone_e164 || null, sms_opt_in ? 1 : 0 ]); const newProfileId = resultProfile.insertId; const authQuery = `INSERT INTO user_auth (user_id, username, hashed_password) VALUES (?, ?, ?)`; await pool.query(authQuery, [newProfileId, username, hashedPassword]); const token = jwt.sign({ id: newProfileId }, JWT_SECRET, { expiresIn: '2h' }); res.cookie(COOKIE_NAME, token, sessionCookieOptions()); return res.status(201).json({ message: 'User registered successfully', profileId: newProfileId, token, user: { username, firstname, lastname, email: emailNorm, zipcode, state, area, career_situation, phone_e164: phone_e164 || null, sms_opt_in: !!sms_opt_in } }); } catch (err) { // If you added UNIQUE idx on email_lookup, surface a nicer error for duplicates: if (err.code === 'ER_DUP_ENTRY') { return res.status(409).json({ error: 'An account with this email already exists.' }); } console.error('Error during registration:', err.message); return res.status(500).json({ error: 'Internal server error' }); } }); /* ------------------------------------------------------------------ SIGN-IN (MySQL) ------------------------------------------------------------------ */ /** * POST /api/signin * Body: { username, password } * Returns JWT signed with user_profile.id */ const signinLimiter = rateLimit({ windowMs: 15*60*1000, max: 50, standardHeaders: true, legacyHeaders: false }); app.post('/api/signin', signinLimiter, async (req, res) => { const { username, password } = req.body; if (!username || !password) { return res .status(400) .json({ error: 'Both username and password are required' }); } // SELECT only the columns you actually have: // 'ua.id' is user_auth's primary key, // 'ua.user_id' references user_profile.id, // and we alias user_profile.id as profileId for clarity. const query = ` SELECT ua.id AS authId, ua.user_id AS userProfileId, ua.hashed_password, up.firstname, up.lastname, up.email, up.zipcode, up.state, up.area, up.career_situation FROM user_auth ua LEFT JOIN user_profile up ON ua.user_id = up.id WHERE ua.username = ? `; try { const [results] = await pool.query(query, [username]); if (!results || results.length === 0) { return res.status(401).json({ error: 'Invalid username or password' }); } const row = results[0]; // Compare password with bcrypt const isMatch = await bcrypt.compare(password, row.hashed_password); if (!isMatch) { return res.status(401).json({ error: 'Invalid username or password' }); } // Return user info + token // 'authId' is user_auth's PK, but typically you won't need it on the client // 'row.userProfileId' is the actual user_profile.id const [profileRows] = await pool.query( 'SELECT firstname, lastname, email, zipcode, state, area, career_situation \ FROM user_profile WHERE id = ?', [row.userProfileId] ); const profile = profileRows[0]; if (profile?.email) { try { profile.email = decrypt(profile.email); } catch {} } const token = jwt.sign({ id: row.userProfileId }, JWT_SECRET, { expiresIn: '2h' }); res.cookie(COOKIE_NAME, token, sessionCookieOptions()); res.status(200).json({ message: 'Login successful', token, id: row.userProfileId, user: profile }); } catch (err) { console.error('Error querying user_auth:', err.message); return res .status(500) .json({ error: 'Failed to query user authentication data' }); } }); app.post('/api/logout', (_req, res) => { res.clearCookie(COOKIE_NAME, sessionCookieOptions()); return res.status(200).json({ ok: true }); }); /* ------------------------------------------------------------------ CHECK USERNAME (MySQL) ------------------------------------------------------------------ */ app.get('/api/check-username/:username', async (req, res) => { const { username } = req.params; try { const [results] = await pool.query(`SELECT username FROM user_auth WHERE username = ?`, [username]); res.status(200).json({ exists: results.length > 0 }); } catch (err) { console.error('Error checking username:', err.message); res.status(500).json({ error: 'Database error' }); } }); /* ------------------------------------------------------------------ UPSERT USER PROFILE (MySQL) ------------------------------------------------------------------ */ app.post('/api/user-profile', requireAuth, async (req, res) => { const profileId = req.userId; // from requireAuth middleware const { userName, firstName, lastName, email, zipCode, state, area, careerSituation, interest_inventory_answers, riasec: riasec_scores, career_priorities, career_list, phone_e164, sms_opt_in } = req.body; try { const [rows] = await pool.query(`SELECT * FROM user_profile WHERE id = ?`, [profileId]); const existing = rows[0]; if (!existing && (!firstName || !lastName || !email || !zipCode || !state || !area)) { return res.status(400).json({ error: 'All fields are required for initial profile creation.' }); } const finalAnswers = (interest_inventory_answers !== undefined) ? interest_inventory_answers : existing?.interest_inventory_answers ?? null; const finalCareerPriorities = (career_priorities !== undefined) ? career_priorities : existing?.career_priorities ?? null; const finalCareerList = (career_list !== undefined) ? career_list : existing?.career_list ?? null; const finalUserName = (userName !== undefined) ? userName : existing?.username ?? null; const finalRiasec = riasec_scores ? JSON.stringify(riasec_scores) : existing?.riasec_scores ?? null; // Normalize email and compute lookup iff email is provided (or keep existing) const safeDecrypt = (v) => { try { return decrypt(v); } catch { return v; } }; const emailNorm = email ? String(email).trim().toLowerCase() : existing?.email ? safeDecrypt(existing.email) : null; const encEmail = email ? encrypt(emailNorm) : existing?.email; const emailLookupVal = email ? emailLookup(emailNorm) : existing?.email_lookup ?? null; const phoneFinal = (phone_e164 !== undefined) ? (phone_e164 || null) : (existing?.phone_e164 ?? null); const smsOptFinal = (typeof sms_opt_in === 'boolean') ? (sms_opt_in ? 1 : 0) : (existing?.sms_opt_in ?? 0); if (existing) { const updateQuery = ` UPDATE user_profile SET username = ?, firstname = ?, lastname = ?, email = ?, email_lookup = ?, zipcode = ?, state = ?, area = ?, career_situation = ?, interest_inventory_answers = ?, riasec_scores = ?, career_priorities = ?, career_list = ?, phone_e164 = ?, sms_opt_in = ? WHERE id = ? `; const params = [ finalUserName, firstName ?? existing.firstname, lastName ?? existing.lastname, encEmail, emailLookupVal, zipCode ?? existing.zipcode, state ?? existing.state, area ?? existing.area, careerSituation ?? existing.career_situation, finalAnswers, finalRiasec, finalCareerPriorities, finalCareerList, phoneFinal, smsOptFinal, profileId ]; await pool.query(updateQuery, params); return res.status(200).json({ message: 'User profile updated successfully' }); } else { // INSERT branch const insertQuery = ` INSERT INTO user_profile (id, username, firstname, lastname, email, email_lookup, zipcode, state, area, career_situation, interest_inventory_answers, riasec_scores, career_priorities, career_list, phone_e164, sms_opt_in) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `; const params = [ profileId, finalUserName, firstName, lastName, encEmail, // <-- was emailNorm emailLookupVal, zipCode, state, area, careerSituation ?? null, finalAnswers, finalRiasec, finalCareerPriorities, finalCareerList, phoneFinal, smsOptFinal ]; await pool.query(insertQuery, params); return res.status(201).json({ message: 'User profile created successfully', id: profileId }); } } catch (err) { if (err.code === 'ER_DUP_ENTRY') { return res.status(409).json({ error: 'An account with this email already exists.' }); } console.error('Error upserting user profile:', err.message); return res.status(500).json({ error: 'Internal server error' }); } }); /* ------------------------------------------------------------------ FETCH USER PROFILE (MySQL) ------------------------------------------------------------------ */ app.get('/api/user-profile', requireAuth, async (req, res) => { const profileId = req.userId; // from requireAuth middleware try { const [results] = await pool.query('SELECT * FROM user_profile WHERE id = ?', [profileId]); if (!results || results.length === 0) { return res.status(404).json({ error: 'User profile not found' }); } const row = results[0]; if (row?.email) { try { row.email = decrypt(row.email); } catch {} } res.status(200).json(row); } catch (err) { console.error('Error fetching user profile:', err.message); res.status(500).json({ error: 'Internal server error' }); } }); /* ------------------------------------------------------------------ SALARY_INFO REMAINS IN SQLITE ------------------------------------------------------------------ */ app.get('/api/areas', (req, res) => { const { state } = req.query; if (!state) { return res.status(400).json({ error: 'State parameter is required' }); } // Use env when present (Docker), fall back for local dev const salaryDbPath = process.env.SALARY_DB_PATH // ← preferred || process.env.SALARY_DB // ← legacy || '/app/salary_info.db'; // final fallback const salaryDb = new sqlite3.Database( salaryDbPath, sqlite3.OPEN_READONLY, (err) => { if (err) { console.error('DB connect error:', err.message); return res .status(500) .json({ error: 'Failed to connect to database' }); } } ); const query = `SELECT DISTINCT AREA_TITLE FROM salary_data WHERE PRIM_STATE = ?`; salaryDb.all(query, [state], (err, rows) => { if (err) { console.error('Query error:', err.message); return res .status(500) .json({ error: 'Failed to fetch areas' }); } res.json({ areas: rows.map(r => r.AREA_TITLE) }); }); salaryDb.close(); }); /* ------------------------------------------------------------------ PREMIUM UPGRADE ENDPOINT ------------------------------------------------------------------ */ app.post('/api/activate-premium', requireAuth, async (req, res) => { const profileId = req.userId; try { await pool.query(` UPDATE user_profile SET is_premium = 1, is_pro_premium = 1 WHERE id = ? `, [profileId]); res.status(200).json({ message: 'Premium activated successfully' }); } catch (err) { console.error('Error updating premium status:', err.message); res.status(500).json({ error: 'Failed to activate premium' }); } }); app.use((err, req, res, _next) => { if (res.headersSent) return; const rid = req.headers['x-request-id'] || res.get('X-Request-ID') || getRequestId(req, res); console.error(`[ref ${rid}]`, err?.message || err); // map known cases if you have them; otherwise generic: return res.status(500).json({ error: 'Server error', ref: rid }); }); /* ------------------------------------------------------------------ START SERVER ------------------------------------------------------------------ */ app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); });