1325 lines
46 KiB
JavaScript
Executable File
1325 lines
46 KiB
JavaScript
Executable File
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';
|
||
import { sendSMS } from './utils/smsService.js';
|
||
|
||
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 isProd = (process.env.ENV_NAME === 'prod');
|
||
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' }));
|
||
app.set('trust proxy', 1); // behind proxy/HTTPS in all envs
|
||
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() {
|
||
// All envs terminate TLS at Nginx; cookies must be Secure everywhere
|
||
const IS_HTTPS = true;
|
||
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_HTTPS, // <-- 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 strict, env-driven origin allowlist (exact scheme+host)
|
||
app.use((req, res, next) => {
|
||
const origin = req.headers.origin || '';
|
||
if (!origin) return next(); // same-origin or server→server
|
||
if (!allowedOrigins.includes(origin)) {
|
||
// exact match only; no localhost/IP unless present in env for that env
|
||
return res.status(403).end();
|
||
}
|
||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||
res.setHeader(
|
||
'Access-Control-Allow-Headers',
|
||
'Authorization, Content-Type, Accept, Origin, X-Requested-With, Access-Control-Allow-Methods'
|
||
);
|
||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||
if (req.method === 'OPTIONS') return res.status(204).end();
|
||
return next();
|
||
});
|
||
|
||
|
||
// 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) -----
|
||
if (!process.env.APTIVA_API_BASE) {
|
||
console.error('FATAL: APTIVA_API_BASE missing – set this to your web origin (e.g., https://dev1.aptivaai.com)');
|
||
process.exit(1);
|
||
}
|
||
const RESET_CONFIG = {
|
||
BASE_URL: process.env.APTIVA_API_BASE, // must be a public web origin
|
||
FROM: 'no-reply@aptivaai.com',
|
||
TTL_MIN: 60,
|
||
};
|
||
|
||
// --- 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 (!@#$%^&*).';
|
||
|
||
/* ────────────────────────────────────────────────────────────────
|
||
Verification helpers / rate limits
|
||
---------------------------------------------------------------- */
|
||
function absoluteWebBase() {
|
||
// You already require APTIVA_API_BASE above; reuse it to build magic links.
|
||
return String(RESET_CONFIG.BASE_URL || '').replace(/\/+$/, '');
|
||
}
|
||
const verifySendLimiter = rateLimit({
|
||
windowMs: 60 * 1000,
|
||
max: 3,
|
||
standardHeaders: true,
|
||
legacyHeaders: false,
|
||
keyGenerator: (req) => req.ip,
|
||
});
|
||
const verifyConfirmLimiter = rateLimit({
|
||
windowMs: 60 * 1000,
|
||
max: 6,
|
||
standardHeaders: true,
|
||
legacyHeaders: false,
|
||
keyGenerator: (req) => req.ip,
|
||
});
|
||
|
||
// 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: `<pre style="font-family: ui-monospace, Menlo, monospace; white-space: pre-wrap">${text}</pre>`
|
||
});
|
||
} 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' });
|
||
}
|
||
});
|
||
|
||
/* ------------------------------------------------------------------
|
||
AUTH STATUS (used by client gate)
|
||
------------------------------------------------------------------ */
|
||
app.get('/api/auth/status', requireAuth, async (req, res) => {
|
||
try {
|
||
const uid = req.userId;
|
||
const [rows] = await (pool.raw || pool).query(
|
||
'SELECT email_verified_at, phone_verified_at FROM user_profile WHERE id = ? LIMIT 1',
|
||
[uid]
|
||
);
|
||
const row = rows?.[0] || {};
|
||
return res.status(200).json({
|
||
is_authenticated: true,
|
||
email_verified_at: row.email_verified_at || null,
|
||
phone_verified_at: row.phone_verified_at || null
|
||
});
|
||
} catch (e) {
|
||
console.error('[auth/status]', e?.message || e);
|
||
return res.status(500).json({ error: 'Server error' });
|
||
}
|
||
});
|
||
|
||
/* ------------------------------------------------------------------
|
||
EMAIL VERIFICATION (send + confirm)
|
||
------------------------------------------------------------------ */
|
||
app.post('/api/auth/verify/email/send', requireAuth, verifySendLimiter, async (req, res) => {
|
||
try {
|
||
if (!SENDGRID_ENABLED) return res.status(503).json({ error: 'Email not configured' });
|
||
const uid = req.userId;
|
||
const [[row]] = await (pool.raw || pool).query('SELECT email FROM user_profile WHERE id=? LIMIT 1', [uid]);
|
||
const enc = row?.email;
|
||
if (!enc) return res.status(400).json({ error: 'No email on file' });
|
||
let emailPlain = '';
|
||
try { emailPlain = decrypt(enc); } catch { emailPlain = enc; }
|
||
|
||
const token = jwt.sign({ sub: String(uid), prp: 'verify_email' }, JWT_SECRET, { expiresIn: '30m' });
|
||
const link = `${absoluteWebBase()}/verify?t=${encodeURIComponent(token)}`;
|
||
|
||
const text =
|
||
`Verify your AptivaAI email by clicking the link below (expires in 30 minutes):
|
||
+${link}`;
|
||
|
||
await sgMail.send({
|
||
to: emailPlain,
|
||
from: RESET_CONFIG.FROM,
|
||
subject: 'Verify your email — AptivaAI',
|
||
text,
|
||
html: `<pre style="font-family: ui-monospace, Menlo, monospace; white-space: pre-wrap">${text}</pre>`
|
||
});
|
||
// In non-production, include token to enable E2E to complete verification without email I/O.
|
||
const extra = (process.env.ENV_NAME === 'prod') ? {} : { test_token: token };
|
||
return res.status(200).json({ ok: true, ...extra });
|
||
|
||
} catch (e) {
|
||
console.error('[verify/email/send]', e?.message || e);
|
||
return res.status(500).json({ error: 'Failed to send verification email' });
|
||
}
|
||
});
|
||
|
||
app.post('/api/auth/verify/email/confirm', requireAuth, verifyConfirmLimiter, async (req, res) => {
|
||
try {
|
||
const { token } = req.body || {};
|
||
if (!token) return res.status(400).json({ error: 'Token required' });
|
||
let payload;
|
||
try { payload = jwt.verify(token, JWT_SECRET); }
|
||
catch { return res.status(400).json({ error: 'Invalid or expired token' }); }
|
||
if (String(payload?.sub) !== String(req.userId) || payload?.prp !== 'verify_email') {
|
||
return res.status(400).json({ error: 'Token/user mismatch' });
|
||
}
|
||
const [r] = await (pool.raw || pool).query(
|
||
'UPDATE user_profile SET email_verified_at = UTC_TIMESTAMP() WHERE id = ?',
|
||
[req.userId]
|
||
);
|
||
return res.status(200).json({ ok: !!r?.affectedRows });
|
||
} catch (e) {
|
||
console.error('[verify/email/confirm]', e?.message || e);
|
||
return res.status(500).json({ error: 'Failed to confirm email' });
|
||
}
|
||
});
|
||
|
||
/* ------------------------------------------------------------------
|
||
PHONE VERIFICATION (send + confirm) — optional
|
||
------------------------------------------------------------------ */
|
||
app.post('/api/auth/verify/phone/send', requireAuth, verifySendLimiter, async (req, res) => {
|
||
try {
|
||
const { phone_e164, consent } = req.body || {};
|
||
if (!phone_e164 || !/^\+1\d{10}$/.test(phone_e164)) {
|
||
return res.status(400).json({ error: 'Phone must be +1 followed by 10 digits' });
|
||
}
|
||
if (!consent) return res.status(400).json({ error: 'consent_required' });
|
||
/// persist phone + record explicit consent using existing flag
|
||
await (pool.raw || pool).query(
|
||
'UPDATE user_profile SET phone_e164=?, sms_opt_in=1 WHERE id=?',
|
||
[phone_e164, req.userId]
|
||
);
|
||
const code = String(Math.floor(100000 + Math.random() * 900000));
|
||
await sendSMS({
|
||
to: phone_e164,
|
||
body: `AptivaAI code: ${code}. Expires in 10 minutes. Reply STOP to cancel, HELP for help.`
|
||
});
|
||
|
||
// store short-lived challenge in HttpOnly cookie (10 min)
|
||
const tok = jwt.sign({ sub: String(req.userId), prp: 'verify_phone', code }, JWT_SECRET, { expiresIn: '10m' });
|
||
res.cookie('aptiva_phone_vc', tok, { ...sessionCookieOptions(), maxAge: 10 * 60 * 1000 });
|
||
return res.status(200).json({ ok: true });
|
||
} catch (e) {
|
||
console.error('[verify/phone/send]', e?.message || e);
|
||
return res.status(500).json({ error: 'Failed to send code' });
|
||
}
|
||
});
|
||
|
||
app.post('/api/auth/verify/phone/confirm', requireAuth, verifyConfirmLimiter, async (req, res) => {
|
||
try {
|
||
const { code } = req.body || {};
|
||
if (!code) return res.status(400).json({ error: 'Code required' });
|
||
const tok = req.cookies?.aptiva_phone_vc;
|
||
if (!tok) return res.status(400).json({ error: 'No challenge issued' });
|
||
let payload;
|
||
try { payload = jwt.verify(tok, JWT_SECRET); }
|
||
catch { return res.status(400).json({ error: 'Challenge expired' }); }
|
||
if (String(payload?.sub) !== String(req.userId) || payload?.prp !== 'verify_phone') {
|
||
return res.status(400).json({ error: 'Challenge mismatch' });
|
||
}
|
||
if (String(payload?.code) !== String(code)) {
|
||
return res.status(400).json({ error: 'Invalid code' });
|
||
}
|
||
const [r] = await (pool.raw || pool).query(
|
||
'UPDATE user_profile SET phone_verified_at = UTC_TIMESTAMP() WHERE id = ?',
|
||
[req.userId]
|
||
);
|
||
res.clearCookie('aptiva_phone_vc', sessionCookieOptions());
|
||
return res.status(200).json({ ok: !!r?.affectedRows });
|
||
} catch (e) {
|
||
if (String(e?.code) === '21610') { // Twilio: user replied STOP
|
||
try { await (pool.raw || pool).query('UPDATE user_profile SET sms_opt_in=0 WHERE id=?', [req.userId]); } catch {}
|
||
return res.status(409).json({ error: 'user_opted_out' });
|
||
}
|
||
console.error('[verify/phone/send]', e?.message || e);
|
||
return res.status(500).json({ error: 'Failed to confirm phone' });
|
||
}
|
||
});
|
||
|
||
/* ------------------------------------------------------------------
|
||
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' });
|
||
}
|
||
|
||
// Only fetch what you need to verify creds
|
||
const query = `
|
||
SELECT ua.user_id AS userProfileId, ua.hashed_password
|
||
FROM user_auth ua
|
||
WHERE ua.username = ?
|
||
LIMIT 1
|
||
`;
|
||
|
||
try {
|
||
const [results] = await pool.query(query, [username]);
|
||
if (!results || results.length === 0) {
|
||
return res.status(401).json({ error: 'Invalid username or password' });
|
||
}
|
||
|
||
const { userProfileId, hashed_password } = results[0];
|
||
const isMatch = await bcrypt.compare(password, hashed_password);
|
||
if (!isMatch) {
|
||
return res.status(401).json({ error: 'Invalid username or password' });
|
||
}
|
||
|
||
// Cookie-based session only; do NOT return id/token/user in body
|
||
const token = jwt.sign({ id: userProfileId }, JWT_SECRET, { expiresIn: '2h' });
|
||
res.cookie(COOKIE_NAME, token, sessionCookieOptions());
|
||
|
||
return res.status(200).json({ message: 'Login successful' });
|
||
} 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,
|
||
sms_reminders_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);
|
||
|
||
const smsRemindersFinal = (typeof sms_reminders_opt_in === 'boolean')
|
||
? (sms_reminders_opt_in ? 1 : 0)
|
||
: (existing?.sms_reminders_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 = ?,
|
||
sms_reminders_opt_in = ?,
|
||
sms_reminders_opt_in_at =
|
||
CASE
|
||
WHEN ? = 1 AND (sms_reminders_opt_in IS NULL OR sms_reminders_opt_in = 0)
|
||
THEN UTC_TIMESTAMP()
|
||
ELSE sms_reminders_opt_in_at
|
||
END
|
||
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,
|
||
smsRemindersFinal,
|
||
smsRemindersFinal,
|
||
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, sms_reminders_opt_in,
|
||
sms_reminders_opt_in_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?,
|
||
?, ?, ?,
|
||
?, ?, ?, ?, ?,
|
||
CASE WHEN ? = 1 THEN UTC_TIMESTAMP() ELSE NULL END)
|
||
`;
|
||
const params = [
|
||
profileId,
|
||
finalUserName,
|
||
firstName,
|
||
lastName,
|
||
encEmail,
|
||
emailLookupVal,
|
||
zipCode,
|
||
state,
|
||
area,
|
||
careerSituation ?? null,
|
||
finalAnswers,
|
||
finalRiasec,
|
||
finalCareerPriorities,
|
||
finalCareerList,
|
||
phoneFinal,
|
||
smsOptFinal,
|
||
smsRemindersFinal,
|
||
smsRemindersFinal,
|
||
];
|
||
|
||
|
||
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) — safe, minimal, no id
|
||
------------------------------------------------------------------ */
|
||
app.get('/api/user-profile', requireAuth, async (req, res) => {
|
||
const profileId = req.userId;
|
||
try {
|
||
// Optional minimal-field mode: /api/user-profile?fields=a,b,c
|
||
const raw = (req.query.fields || '').toString().trim();
|
||
|
||
if (raw) {
|
||
// No 'id' here on purpose
|
||
const ALLOW = new Set([
|
||
'username','firstname','lastname','career_situation',
|
||
'is_premium','is_pro_premium',
|
||
'state','area','zipcode',
|
||
'career_priorities','interest_inventory_answers','riasec_scores','career_list',
|
||
'email',
|
||
'phone_e164',
|
||
'sms_opt_in',
|
||
'email_verified_at',
|
||
'phone_verified_at'
|
||
]);
|
||
|
||
const requested = raw.split(',').map(s => s.trim()).filter(Boolean);
|
||
const cols = requested.filter(c => ALLOW.has(c));
|
||
if (cols.length === 0) {
|
||
return res.status(400).json({ error: 'no_allowed_fields' });
|
||
}
|
||
|
||
const sql = `SELECT ${cols.join(', ')} FROM user_profile WHERE id = ? LIMIT 1`;
|
||
const [rows] = await pool.query(sql, [profileId]);
|
||
const row = rows && rows[0] ? rows[0] : null; // <-- declare BEFORE using
|
||
|
||
if (!row) return res.status(404).json({ error: 'User profile not found' });
|
||
|
||
// Decrypt only if explicitly requested and present
|
||
if (cols.includes('email') && row.email) {
|
||
try { row.email = decrypt(row.email); } catch {}
|
||
}
|
||
// Phone may be encrypted; normalize sms_opt_in to boolean
|
||
if (cols.includes('phone_e164') && typeof row.phone_e164 === 'string' && row.phone_e164.startsWith('gcm:')) {
|
||
try { row.phone_e164 = decrypt(row.phone_e164); } catch {}
|
||
}
|
||
if (cols.includes('sms_opt_in')) {
|
||
row.sms_opt_in = !!row.sms_opt_in; // BIT/TINYINT → boolean
|
||
}
|
||
|
||
return res.status(200).json(row);
|
||
}
|
||
|
||
// Legacy fallback: return only something harmless (no id/email)
|
||
const [rows] = await pool.query(
|
||
'SELECT firstname FROM user_profile WHERE id = ? LIMIT 1',
|
||
[profileId]
|
||
);
|
||
const row = rows && rows[0] ? rows[0] : null;
|
||
if (!row) return res.status(404).json({ error: 'User profile not found' });
|
||
return res.status(200).json(row);
|
||
|
||
} catch (err) {
|
||
console.error('Error fetching user profile:', err?.message || err);
|
||
return 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(`Server1 listening on port ${PORT}`);
|
||
});
|