dev1/backend/server1.js
Josh d71b026ce0
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
cookie implementation
2025-08-13 19:58:24 +00:00

997 lines
32 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';
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
// 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();
app.set('trust proxy', 1);
const PORT = process.env.SERVER1_PORT || 5000;
/* ─── Allowed origins for CORS (comma-separated in env) ──────── */
const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
.split(',')
.map(o => o.trim())
.filter(Boolean);
app.disable('x-powered-by');
app.use(express.json());
app.use(cookieParser());
app.use(
helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false,
})
);
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,
})
);
// Handle preflight requests explicitly
app.options('*', (req, res) => {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader(
'Access-Control-Allow-Headers',
'Authorization, Content-Type, Accept, Origin, X-Requested-With'
);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.status(200).end();
});
// Add HTTP headers for security
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
);
res.setHeader('Content-Security-Policy', "default-src 'self';");
res.removeHeader('X-Powered-By');
next();
});
// Force Content-Type to application/json on all responses
app.use((req, res, next) => {
res.setHeader('Content-Type', 'application/json');
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,
});
// ---- Auth cookie / token helper ----
const COOKIE_NAME = process.env.ACCESS_COOKIE_NAME || 'aptiva_access';
const COOKIE_SECURE = String(process.env.COOKIE_SECURE).toLowerCase() === 'true';
const COOKIE_SAMESITE = process.env.COOKIE_SAMESITE || 'Lax';
const COOKIE_DOMAIN = (process.env.COOKIE_DOMAIN || '').trim() || undefined;
// Default max-age: use TOKEN_MAX_AGE_MS if set, else 2h
const MAX_AGE_MS = Number(process.env.TOKEN_MAX_AGE_MS || 0) || (2 * 60 * 60 * 1000);
const EXPIRES_SEC = Math.floor(MAX_AGE_MS / 1000);
// standardize on `sub` (requireAuth also accepts id/userId)
function issueSession(res, userId) {
const token = jwt.sign({ sub: userId }, JWT_SECRET, { expiresIn: EXPIRES_SEC });
res.cookie(COOKIE_NAME, token, {
httpOnly: true,
secure: COOKIE_SECURE,
sameSite: COOKIE_SAMESITE,
domain: COOKIE_DOMAIN, // undefined => host-only cookie
path: '/',
maxAge: MAX_AGE_MS,
});
return token;
}
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 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' });
}
});
/* ------------------------------------------------------------------
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 maxAgeMs = Number(process.env.TOKEN_MAX_AGE_MS || 0) || 2 * 60 * 60 * 1000;
const expiresSec = Math.floor(maxAgeMs / 1000);
const token = issueSession(res, newProfileId);
return res.status(201).json({
message: 'User registered successfully',
profileId: newProfileId,
token, // optional; frontend doesnt need it anymore
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
*/
app.post('/api/signin', 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 maxAgeMs = Number(process.env.TOKEN_MAX_AGE_MS || 0) || 2 * 60 * 60 * 1000;
const expiresSec = Math.floor(maxAgeMs / 1000);
const token = issueSession(res, row.userProfileId);
return res.status(200).json({
message: 'Login successful',
token, // optional
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' });
}
});
/* ------------------------------------------------------------------
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' });
}
});
/* Logout endpoint */
app.post('/api/logout', (req, res) => {
const cookieName = process.env.ACCESS_COOKIE_NAME || 'aptiva_access';
const isSecure = String(process.env.COOKIE_SECURE).toLowerCase() === 'true';
const sameSite = process.env.COOKIE_SAMESITE || 'Lax';
const cookieDomain = (process.env.COOKIE_DOMAIN || '').trim() || undefined;
res.clearCookie(cookieName, {
httpOnly: true,
secure: isSecure,
sameSite,
domain: cookieDomain, // must match what you set on sign-in
path: '/',
});
res.status(200).json({ ok: true });
});
/* ------------------------------------------------------------------
START SERVER
------------------------------------------------------------------ */
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});