dev1/backend/server1.js
Josh 666427a7c9
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/manual/woodpecker Pipeline was successful
Added email/phone verification
2025-09-12 09:36:00 +00:00

1293 lines
45 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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' }));
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) => {
// dont 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 requireAuths 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 didnt 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>`
});
return res.status(200).json({ ok: true });
} 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 } = 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' });
}
// persist/overwrite phone on file
await (pool.raw || pool).query('UPDATE user_profile SET phone_e164=? WHERE id=?', [phone_e164, req.userId]);
const code = String(Math.floor(100000 + Math.random() * 900000));
await sendSMS({ to: phone_e164, body: `AptivaAI security code: ${code}. Expires in 10 minutes.` });
// 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) {
console.error('[verify/phone/confirm]', 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
} = 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) — 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}`);
});