From 888bdd2939c42265d4aa02257a4f7926aa0b0182 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 28 Aug 2025 18:03:45 +0000 Subject: [PATCH] Runtime hardening, logs, rate limits --- .build.hash | 2 +- Dockerfile.server3 | 2 + backend/server1.js | 245 ++++++++++--- backend/server2.js | 245 ++++++++++++- backend/server3.js | 390 ++++++++++++++++++-- backend/shared/db/withEncryption.js | 9 +- backend/utils/chatFreeEndpoint.js | 82 +++-- docker-compose.yml | 17 +- ecosystem.config.cjs | 57 --- full_schema.sql | 545 ++++++++++++++++++++++++++++ migrate_encrypted_columns.sql | 14 + nginx.conf | 43 +++ schema.sql | 0 src/App.js | 2 +- src/auth/apiClient.js | 6 + src/auth/apiFetch.js | 8 + src/components/ResumeRewrite.js | 21 +- 17 files changed, 1508 insertions(+), 180 deletions(-) delete mode 100644 ecosystem.config.cjs create mode 100644 full_schema.sql delete mode 100644 schema.sql diff --git a/.build.hash b/.build.hash index c96630b..ca31f14 100644 --- a/.build.hash +++ b/.build.hash @@ -1 +1 @@ -afd62e0deab27814cfa0067f1fae1dc4ad79e7dd-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b +fb83dd6424562765662889aea6436fdb4b1b975f-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b diff --git a/Dockerfile.server3 b/Dockerfile.server3 index 9c2ac02..e782ac6 100644 --- a/Dockerfile.server3 +++ b/Dockerfile.server3 @@ -19,6 +19,8 @@ COPY --chown=app:app src/assets/ ./src/assets/ COPY --chown=app:app backend/data/ ./backend/data/ RUN mkdir -p /run/secrets && chown -R app:app /run/secrets +RUN mkdir -p /data/uploads && chown -R app:app /data + USER app CMD ["node", "backend/server3.js"] diff --git a/backend/server1.js b/backend/server1.js index 239d93c..0f2d8a9 100755 --- a/backend/server1.js +++ b/backend/server1.js @@ -50,7 +50,8 @@ 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'); @@ -86,16 +87,197 @@ try { const app = express(); const PORT = process.env.SERVER1_PORT || 5000; + app.disable('x-powered-by'); -app.use(express.json()); -app.set('trust proxy', 1); // important if you're behind a proxy/HTTPS terminator +app.use(express.json({ limit: '1mb' })); +if (process.env.NODE_ENV === 'prod') app.set('trust proxy', 1); // important if you're behind a proxy/HTTPS terminator app.use(cookieParser()); -app.use( - helmet({ - contentSecurityPolicy: false, - crossOriginEmbedderPolicy: false, - }) -); +app.use(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 @@ -104,7 +286,7 @@ const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS .filter(Boolean); function sessionCookieOptions() { - const IS_PROD = process.env.NODE_ENV === 'production'; + const IS_PROD = process.env.NODE_ENV === 'prod'; const CROSS_SITE = process.env.CROSS_SITE_COOKIES === '1'; // set to "1" if FE and API are different sites const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || undefined; @@ -190,7 +372,6 @@ app.get('/healthz', async (_req, res) => { return res.status(ready ? 200 : 503).json(out); }); - // Password reset token table (MySQL) try { const db = pool.raw || pool; @@ -237,35 +418,6 @@ app.use( }) ); -// 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'); // <-- add this - 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, @@ -627,7 +779,11 @@ app.post('/api/register', async (req, res) => { * Body: { username, password } * Returns JWT signed with user_profile.id */ -app.post('/api/signin', async (req, res) => { + +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 @@ -963,6 +1119,13 @@ app.post('/api/activate-premium', requireAuth, async (req, res) => { } }); +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 diff --git a/backend/server2.js b/backend/server2.js index c2c9954..867e829 100755 --- a/backend/server2.js +++ b/backend/server2.js @@ -42,6 +42,7 @@ const CIP_TO_SOC_PATH = path.join(DATA_DIR, 'CIP_to_ONET_SOC.xlsx'); const INSTITUTION_DATA_PATH = path.join(DATA_DIR, 'Institution_data.json'); const SALARY_DB_PATH = path.join(ROOT_DIR, 'salary_info.db'); const USER_PROFILE_DB_PATH = path.join(ROOT_DIR, 'user_profile.db'); +const DB_POOL_SIZE = 6; for (const p of [CIP_TO_SOC_PATH, INSTITUTION_DATA_PATH, SALARY_DB_PATH, USER_PROFILE_DB_PATH]) { if (!fs.existsSync(p)) { @@ -59,6 +60,36 @@ const chatLimiter = rateLimit({ }); + + +// ── RUNTIME PROTECTION: outbound host allowlist (server2) ── +const OUTBOUND_ALLOW = new Set([ + 'services.onetcenter.org', // O*NET + 'maps.googleapis.com', // Google Distance + 'api.openai.com' // Free chat (chatFreeEndpoint) +]); + +// Guard global fetch (Node 20+) +const _fetch = globalThis.fetch; +globalThis.fetch = async (input, init) => { + const u = new URL(typeof input === 'string' ? input : input.url, 'http://local/'); + // allow relative/internal URLs (no hostname) + if (!u.hostname || u.hostname === 'local') return _fetch(input, init); + if (!OUTBOUND_ALLOW.has(u.hostname)) throw new Error(`blocked_outbound_host:${u.hostname}`); + return _fetch(input, init); +}; + +// Guard axios +axios.interceptors.request.use((cfg) => { + try { + const u = cfg.baseURL ? new URL(cfg.url, cfg.baseURL) : new URL(cfg.url, 'http://local/'); + if (!u.hostname || u.hostname === 'local') return cfg; // internal/relative + if (!OUTBOUND_ALLOW.has(u.hostname)) return Promise.reject(new Error(`blocked_outbound_host:${u.hostname}`)); + } catch { /* leave internal relatives alone */ } + return cfg; +}); + + // ── helpers ───────────────────────────────────────────────────────── const normTitle = (s='') => String(s) @@ -141,6 +172,189 @@ try { const app = express(); const PORT = process.env.SERVER2_PORT || 5001; app.use(cookieParser()); +app.disable('x-powered-by'); +app.set('trust proxy', 1); +app.use(express.json({ limit: '1mb' })); + +// --- 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; +} + +// ---- 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: 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' }); +}); + +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 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(); +}); function fprPathFromEnv() { const p = (process.env.DEK_PATH || '').trim(); @@ -316,29 +530,22 @@ app.use( /* 4 — Dynamic CORS / pre-flight handling */ app.use((req, res, next) => { - const origin = req.headers.origin; +const origin = req.headers.origin || ''; - /* 4a — Whitelisted origins (credentials allowed) */ - if (origin && allowedOrigins.includes(origin)) { + // A) No Origin header (e.g. same-origin, curl, server->server) → allow + if (!origin) return next(); + + // B) Whitelisted browser origins (credentials allowed) + if (allowedOrigins.includes(origin)) { 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' - ); - - /* 4c — Default permissive fallback (same as your original) */ - } else { - res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - res.setHeader( - 'Access-Control-Allow-Headers', - 'Authorization, Content-Type, Accept, Origin, X-Requested-With' - ); + } else { + return res.status(403).end(); } /* 4d — Short-circuit pre-flight requests */ @@ -1666,7 +1873,13 @@ app.get('/api/tuition/estimate', (req, res) => { }); - +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 the Express server diff --git a/backend/server3.js b/backend/server3.js index 48ea93a..6f2eb0a 100644 --- a/backend/server3.js +++ b/backend/server3.js @@ -8,7 +8,7 @@ const __dirname = path.dirname(__filename); import express from 'express'; import helmet from 'helmet'; -import { readFile } from 'fs/promises'; // <-- add this +import { readFile, unlink } from 'fs/promises'; // <-- add this import fs from 'fs'; import multer from 'multer'; import fetch from 'node-fetch'; @@ -24,6 +24,7 @@ import OpenAI from 'openai'; import Fuse from 'fuse.js'; import Stripe from 'stripe'; import { createReminder } from './utils/smsService.js'; +import rateLimit from 'express-rate-limit'; // already used elsewhere; ok to keep import { initEncryption, verifyCanary } from './shared/crypto/encryption.js'; import { hashForLookup } from './shared/crypto/encryption.js'; @@ -33,7 +34,7 @@ import './jobs/reminderCron.js'; import { cacheSummary } from "./utils/ctxCache.js"; const rootPath = path.resolve(__dirname, '..'); -const env = (process.env.NODE_ENV || 'production'); +const env = (process.env.NODE_ENV || 'prod'); const envPath = path.resolve(rootPath, `.env.${env}`); if (!process.env.FROM_SECRETS_MANAGER) { dotenv.config({ path: envPath, override: false }); @@ -54,6 +55,31 @@ const ALLOWED_REDIRECT_HOSTS = new Set([ new URL(PUBLIC_BASE || 'http://localhost').host ]); +// ── RUNTIME PROTECTION: outbound host allowlist (server3) ── +const OUTBOUND_ALLOW = new Set([ + 'server2', // compose DNS (server2:5001) + 'localhost', // self-calls (localhost:5002) + 'api.openai.com', // OpenAI SDK traffic + 'api.stripe.com', // Stripe SDK traffic + 'api.twilio.com' // smsService may hit Twilio from this proc +]); + +function assertAllowed(url) { + const u = new URL(url, 'http://localhost'); + const host = u.hostname; + if (!OUTBOUND_ALLOW.has(host)) { + throw new Error(`blocked_outbound_host:${host}`); + } +} + +// Wrap fetch for this file (don’t reassign the imported binding) +const rawFetch = fetch; +async function guardedFetch(input, init) { + const url = typeof input === 'string' ? input : input?.url; + assertAllowed(url); + return rawFetch(input, init); +} + function isSafeRedirect(url) { try { const u = new URL(url); @@ -63,11 +89,198 @@ function isSafeRedirect(url) { const app = express(); app.use(cookieParser()); +app.disable('x-powered-by'); +app.set('trust proxy', 1); +app.use(express.json({ limit: '1mb' })); +app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false })); + +// --- 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 +]; + +// ---- 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' }); +}); + +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(); +}); + const { getDocument } = pkg; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: '2024-04-10' }); // ── Use raw pool for canary/db checks (avoid DAO wrapper noise) ── const db = pool.raw || pool; +const DB_POOL_SIZE = 12; // Bootstrap: unwrap DEK, check DB, verify canary try { @@ -148,11 +361,110 @@ app.get('/healthz', async (_req, res) => { return res.status(ready ? 200 : 503).json(out); }); -// …rest of your routes and app.listen(PORT) +// ── Tier config (env-overridable) ───────────────────────────── +const CHAT_BURST_WINDOW_SEC = Number(process.env.CHAT_BURST_WINDOW_SEC || 300); // 5 min +const CHAT_CONCURRENCY_PER_USER = Number(process.env.CHAT_CONCURRENCY_PER_USER || 1); + +// “coach” chat (general) +const CHAT_BURST = { + basic: Number(process.env.CHAT_BURST_BASIC || 3), // per 5 min + premium: Number(process.env.CHAT_BURST_PREMIUM || 6), + pro: Number(process.env.CHAT_BURST_PRO || 12), +}; +const CHAT_DAILY = { + basic: Number(process.env.CHAT_DAILY_BASIC || 20), // per 24h + premium: Number(process.env.CHAT_DAILY_PREMIUM || 60), + pro: Number(process.env.CHAT_DAILY_PRO || 120), +}; + +// “retire” beta (stricter) +const RET_BURST = { + premium: Number(process.env.RET_BURST_PREMIUM || 2), // per 5 min + pro: Number(process.env.RET_BURST_PRO || 4), +}; +const RET_DAILY = { + premium: Number(process.env.RET_DAILY_PREMIUM || 5), // per 24h + pro: Number(process.env.RET_DAILY_PRO || 10), +}; + +const tierCache = new Map(); // userId -> { tier, exp } +async function resolveTier(userId) { + const c = tierCache.get(userId); + if (c && c.exp > Date.now()) return c.tier; + const [[row]] = await pool.query('SELECT is_premium, is_pro_premium FROM user_profile WHERE id=? LIMIT 1', [userId]); + const t = row?.is_pro_premium ? 'pro' : row?.is_premium ? 'premium' : 'basic'; + tierCache.set(userId, { tier: t, exp: Date.now() + 60_000 }); // cache 60s + return t; +} + +// in-memory sliding window + daily + concurrency +const usage = new Map(); // userId -> { win: number[], dayStart: number, dayCount: number, inflight: number } + +function getU(id) { + let u = usage.get(id); + if (!u) { u = { win: [], dayStart: Date.now(), dayCount: 0, inflight: 0 }; usage.set(id, u); } + return u; +} + +function resetDayIfNeeded(u) { + const DAY = 24*60*60*1000; + if (Date.now() - u.dayStart >= DAY) { u.dayStart = Date.now(); u.dayCount = 0; u.win.length = 0; } +} + +function cleanWindow(u, windowMs) { + const cutoff = Date.now() - windowMs; + while (u.win.length && u.win[0] < cutoff) u.win.shift(); +} + +function chatGate(kind /* 'coach' | 'retire' */) { + return async (req, res, next) => { + const userId = req.id; + if (!userId) return res.status(401).json({ error: 'auth_required' }); + + const tier = await resolveTier(userId); + const u = getU(userId); + resetDayIfNeeded(u); + cleanWindow(u, CHAT_BURST_WINDOW_SEC*1000); + + // choose caps + const burstCap = (kind === 'retire') + ? (tier === 'pro' ? RET_BURST.pro : RET_BURST.premium) + : (tier === 'pro' ? CHAT_BURST.pro : tier === 'premium' ? CHAT_BURST.premium : CHAT_BURST.basic); + + const dayCap = (kind === 'retire') + ? (tier === 'pro' ? RET_DAILY.pro : RET_DAILY.premium) // no 'basic' for retire + : (tier === 'pro' ? CHAT_DAILY.pro : tier === 'premium' ? CHAT_DAILY.premium : CHAT_DAILY.basic); + + // concurrency guard + if (u.inflight >= CHAT_CONCURRENCY_PER_USER) { + res.set('Retry-After', '3'); + return res.status(429).json({ error: 'chat_in_progress' }); + } + + // daily cap + if (u.dayCount >= dayCap) { + return res.status(429).json({ error: 'daily_limit_reached' }); + } + + // burst cap (sliding window) + if (u.win.length >= burstCap) { + const retryMs = Math.max(0, (u.win[0] + CHAT_BURST_WINDOW_SEC*1000) - Date.now()); + res.set('Retry-After', String(Math.ceil(retryMs/1000))); + return res.status(429).json({ error: 'slow_down' }); + } + + // admit; book slots + u.inflight += 1; + u.dayCount += 1; + u.win.push(Date.now()); + res.on('finish', () => { u.inflight = Math.max(0, u.inflight - 1); }); + next(); + }; +} function internalFetch(req, urlPath, opts = {}) { - return fetch(`${API_BASE}${urlPath}`, { + return guardedFetch(`${API_BASE}${urlPath}`, { ...opts, headers: { "Content-Type": "application/json", @@ -339,7 +651,7 @@ const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS /* ─── Dynamic CORS middleware (matches server1 / server2) ────────────── */ app.use((req, res, next) => { const origin = req.headers.origin; - +res.setHeader('Vary', 'Origin'); // A) whitelisted origins (credentials allowed) if (origin && allowedOrigins.includes(origin)) { res.setHeader('Access-Control-Allow-Origin', origin); @@ -350,14 +662,8 @@ app.use((req, res, next) => { ); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); - // B) default permissive fallback (same as server2’s behaviour) } else { - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); - res.setHeader( - 'Access-Control-Allow-Headers', - 'Authorization, Content-Type, Accept, Origin, X-Requested-With' - ); + return res.status(403).end(); } if (req.method === 'OPTIONS') { @@ -530,7 +836,7 @@ async function ensureDescriptionAndTasks({ socCode, jobDescription, tasks }) { try { // hit server2 directly on the compose network - const r = await fetch(`http://server2:5001/api/onet/career-description/${encodeURIComponent(socCode)}`, { + const r = await guardedfetch(`http://server2:5001/api/onet/career-description/${encodeURIComponent(socCode)}`, { headers: { Accept: 'application/json' } }); if (r.ok) { @@ -819,7 +1125,7 @@ app.delete('/api/premium/career-profile/:careerProfileId', authenticatePremiumUs } }); -app.post('/api/premium/ai/chat', authenticatePremiumUser, async (req, res) => { +app.post('/api/premium/ai/chat', authenticatePremiumUser, chatGate('coach'), async (req, res) => { try { const { userProfile = {}, @@ -1595,10 +1901,7 @@ Check your Milestones tab. Let me know if you want any changes! /* ────────────────────────────────────────────── RETIREMENT AI-CHAT ENDPOINT (clone + patch) ─────────────────────────────────────────── */ -app.post( - '/api/premium/retirement/aichat', - authenticatePremiumUser, - async (req, res) => { +app.post('/api/premium/retirement/aichat', authenticatePremiumUser, chatGate('retire'), async (req, res) => { try { /* 0️⃣ pull + sanity-check inputs */ const { @@ -3959,7 +4262,15 @@ return res.json({ const upload = multer({ storage, - limits: { fileSize: 10 * 1024 * 1024 } // 10MB (tune as needed) + limits: { fileSize: 10 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + const ok = [ + 'application/pdf', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/msword' + ].includes(file.mimetype); + cb(ok ? null : new Error('unsupported_type'), ok); + } }); function buildResumePrompt(resumeText, jobTitle, jobDescription) { @@ -3990,7 +4301,7 @@ Precisely Tailored, ATS-Optimized Resume: } async function extractTextFromPDF(filePath) { - const fileBuffer = await fs.readFile(filePath); + const fileBuffer = await readFile(filePath); const uint8Array = new Uint8Array(fileBuffer); const pdfDoc = await getDocument({ data: uint8Array }).promise; @@ -4003,8 +4314,16 @@ async function extractTextFromPDF(filePath) { return text; } +const resumeLimiter = rateLimit({ + windowMs: 5 * 60 * 1000, + max: 20, + standardHeaders: true, + legacyHeaders: false, +}); + app.post( '/api/premium/resume/optimize', + resumeLimiter, upload.single('resumeFile'), authenticatePremiumUser, async (req, res) => { @@ -4029,14 +4348,14 @@ app.post( } // figure out usage limit - let userPlan = 'basic'; + let userPlan = 'premium'; if (userProfile.is_pro_premium) { userPlan = 'pro'; } else if (userProfile.is_premium) { userPlan = 'premium'; } - const weeklyLimits = { basic: 1, premium: 2, pro: 5 }; + const weeklyLimits = { premium: 3, pro: 5 }; const userWeeklyLimit = weeklyLimits[userPlan] || 0; let resetDate = new Date(userProfile.resume_limit_reset); @@ -4073,7 +4392,7 @@ app.post( const result = await mammoth.extractRawText({ path: filePath }); resumeText = result.value; } else { - await fs.unlink(filePath); + await unlink(filePath); return res.status(400).json({ error: 'Unsupported or corrupted file upload.' }); } @@ -4100,7 +4419,7 @@ app.post( const remainingOptimizations = totalLimit - (userProfile.resume_optimizations_used + 1); // remove uploaded file - await fs.unlink(filePath); + await unlink(filePath); res.json({ optimizedResume, @@ -4309,14 +4628,25 @@ app.post('/api/ai-risk', async (req, res) => { } }); +// ---- upload error mapper (multer) ---- +app.use((err, _req, res, next) => { + if (!err) return next(); + if (err.code === 'LIMIT_FILE_SIZE') { + return res.status(413).json({ error: 'file_too_large', limit_mb: 10 }); + } + if (err.code === 'LIMIT_UNEXPECTED_FILE' || err.code === 'LIMIT_PART_COUNT') { + return res.status(400).json({ error: 'bad_upload' }); + } + return next(err); +}); -/* ------------------------------------------------------------------ - FALLBACK 404 - ------------------------------------------------------------------ */ -app.use((req, res) => { - console.warn(`No route matched for ${req.method} ${req.originalUrl}`); - res.status(404).json({ error: 'Not found' }); +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 diff --git a/backend/shared/db/withEncryption.js b/backend/shared/db/withEncryption.js index 1d2ae13..972ed0f 100644 --- a/backend/shared/db/withEncryption.js +++ b/backend/shared/db/withEncryption.js @@ -51,6 +51,10 @@ const TABLE_MAP = { /* ── initialise KMS unwrap once ─────────────────────────────── */ async function ensureCryptoReady () { await initEncryption(); } +// choose cap per-process from env (server1/2/3 each have their own DB_POOL_SIZE) +const cap = Number(process.env.DB_POOL_SIZE || 5); +console.log(`[db] mysql pool connectionLimit=${cap}`); + /* ── mysql connection pool (uses env injected by docker) ────── */ export const pool = mysql.createPool({ host : process.env.DB_HOST, @@ -59,11 +63,12 @@ export const pool = mysql.createPool({ password : process.env.DB_PASSWORD, database : process.env.DB_NAME, waitForConnections : true, - connectionLimit : 5, + connectionLimit : cap, ssl : { ca : process.env.DB_SSL_CA, key : process.env.DB_SSL_KEY, - cert : process.env.DB_SSL_CERT + cert : process.env.DB_SSL_CERT, + servername: '' } }); diff --git a/backend/utils/chatFreeEndpoint.js b/backend/utils/chatFreeEndpoint.js index 5b12aa5..867e04d 100644 --- a/backend/utils/chatFreeEndpoint.js +++ b/backend/utils/chatFreeEndpoint.js @@ -33,6 +33,22 @@ const PAGE_TOOLMAP = JSON.parse( await fs.readFile(path.join(assetsDir, "pageToolMap.json"), "utf8") ); +const FREE_PROMPT_MAX_CHARS = Number(process.env.FREE_PROMPT_MAX_CHARS || 1500); +const FREE_BURST_WINDOW_MS = Number(process.env.FREE_BURST_WINDOW_MS || 5 * 60 * 1000); // 5 min +const FREE_BURST_LIMIT = Number(process.env.FREE_BURST_LIMIT || 6); // 6 chats / 5 min +const FREE_DAILY_LIMIT = Number(process.env.FREE_DAILY_LIMIT || 60); // 60 chats / day +const FREE_CONCURRENCY_PER_U = Number(process.env.FREE_CONCURRENCY_PER_U || 1); + +const FREE_ALLOWED_ORIGINS = new Set( + (process.env.CORS_ALLOWED_ORIGINS || '').split(',').map(s=>s.trim()).filter(Boolean) +); + +// in-mem counters (per user if authed, else per IP) +const usage = new Map(); // key -> { win: number[], dayStart: number, dayCount: number, inflight: number } +const DAY = 24*60*60*1000; +function getU(key){ let u=usage.get(key); if(!u){u={win:[],dayStart:Date.now(),dayCount:0,inflight:0}; usage.set(key,u);} return u; } +function clean(u){ if(Date.now()-u.dayStart>=DAY){u.dayStart=Date.now();u.dayCount=0;u.win.length=0;} const cut=Date.now()-FREE_BURST_WINDOW_MS; while(u.win.length&&u.win[0] HELP_TRIGGERS.some(k => txt.toLowerCase().includes(k)) ? "support" : "guide"; @@ -178,39 +194,45 @@ export default function chatFreeEndpoint( ]; /* ----------------------------- ROUTE ----------------------------- */ - app.post( - "/api/chat/free", - chatLimiter, - authenticateUser, - async (req, res) => { +app.post("/api/chat/free", chatLimiter, authenticateUser, async (req, res) => { try { - const headers = { - // streaming MIME type – browsers still treat it as text, but - // it signals “keep pushing” semantics more clearly - "Content-Type": "text/event-stream; charset=utf-8", - "Cache-Control": "no-cache", - "X-Accel-Buffering": "no" // disables Nginx/ALB buffering -}; -// “Connection” is allowed **only** on HTTP/1.x -if (req.httpVersionMajor < 2) { - headers.Connection = "keep-alive"; -} + // ---- Guards (before writing any headers) ---- + const origin = req.headers.origin || ''; + if (origin && !FREE_ALLOWED_ORIGINS.has(origin)) { + return res.status(403).json({ error: 'origin_not_allowed' }); + } -res.writeHead(200, headers); - res.flushHeaders?.(); + const { prompt = "", chatHistory = [], pageContext = "", snapshot = {} } = req.body || {}; + const p = String(prompt || '').trim(); + if (!p) return res.status(400).json({ error: "Empty prompt" }); + if (p.length > FREE_PROMPT_MAX_CHARS) { + return res.status(413).json({ error: "prompt_too_long" }); + } - const sendChunk = (txt = "") => { res.write(txt); res.flush?.(); }; - - const { prompt = "", chatHistory = [], pageContext = "", snapshot = {} } = req.body || {}; - if (!prompt.trim()) return res.status(400).json({ error: "Empty prompt" }); + // per-user/IP light limits: 5-min burst, daily, 1 concurrent + const key = String((req.user && (req.user.id || req.user.sub)) || req.ip); + const u = getU(key); + clean(u); + if (u.inflight >= FREE_CONCURRENCY_PER_U) { + res.set('Retry-After','3'); return res.status(429).json({ error:'chat_in_progress' }); + } + if (u.dayCount >= FREE_DAILY_LIMIT) return res.status(429).json({ error:'daily_limit_reached' }); + if (u.win.length >= FREE_BURST_LIMIT) { + const retryMs = Math.max(0, (u.win[0] + FREE_BURST_WINDOW_MS) - Date.now()); + res.set('Retry-After', String(Math.ceil(retryMs/1000))); + return res.status(429).json({ error:'slow_down' }); + } + u.inflight++; u.dayCount++; u.win.push(Date.now()); + res.on('finish', ()=>{ u.inflight = Math.max(0, u.inflight-1); }); + res.on('close', ()=>{ u.inflight = Math.max(0, u.inflight-1); }); /* ---------- 0️⃣ FAQ fast-path ---------- */ let faqHit = null; try { const { data } = await openai.embeddings.create({ model: "text-embedding-3-small", - input: prompt + input: p }); const hits = await vectorSearch(FAQ_PATH, data[0].embedding, 1); if (hits.length && hits[0].score >= FAQ_THRESHOLD) faqHit = hits[0]; @@ -379,17 +401,25 @@ res.writeHead(200, headers); let messages = [ { role: "system", content: system }, ...chatHistory, - { role: "user", content: prompt } + { role: "user", content: p } ]; const chatStream = await openai.chat.completions.create({ model : "gpt-4o-mini", stream : true, messages, - tools, }); -for await (const part of chatStream) { +const headers = { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no" // disables Nginx/ALB buffering +}; + if (req.httpVersionMajor < 2) headers.Connection = "keep-alive"; + res.writeHead(200, headers); + res.flushHeaders?.(); + const sendChunk = (txt="") => { res.write(txt); res.flush?.(); }; + for await (const part of chatStream) { const txt = part.choices?.[0]?.delta?.content; if (txt) sendChunk(txt); } diff --git a/docker-compose.yml b/docker-compose.yml index a5442ab..8eae7e5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,3 @@ -# --------------------------------------------------------------------------- -# A single env‑file (.env) contains ONLY non‑secret constants. -# Every secret is exported from fetch‑secrets.sh and injected at deploy time. -# --------------------------------------------------------------------------- x-env: &with-env restart: unless-stopped @@ -13,6 +9,14 @@ services: volumes: - dek-vol:/run/secrets/dev restart: "no" + + uploads-init: + image: busybox:1.36 + user: "0:0" + command: sh -lc 'mkdir -p /data/uploads && chown -R 1000:1000 /data/uploads && chmod 770 /data/uploads' + volumes: + - aptiva_uploads:/data/uploads + restart: "no" # ───────────────────────────── server1 ───────────────────────────── server1: <<: *with-env @@ -42,6 +46,7 @@ services: DB_SSL_CERT: ${DB_SSL_CERT} DB_SSL_KEY: ${DB_SSL_KEY} DB_SSL_CA: ${DB_SSL_CA} + DB_POOL_SIZE: "12" CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} SUPPORT_SENDGRID_API_KEY: ${SUPPORT_SENDGRID_API_KEY} EMAIL_INDEX_SECRET: ${EMAIL_INDEX_SECRET} @@ -90,6 +95,7 @@ services: DB_SSL_CERT: ${DB_SSL_CERT} DB_SSL_KEY: ${DB_SSL_KEY} DB_SSL_CA: ${DB_SSL_CA} + DB_POOL_SIZE: "6" CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} SUPPORT_SENDGRID_API_KEY: ${SUPPORT_SENDGRID_API_KEY} EMAIL_INDEX_SECRET: ${EMAIL_INDEX_SECRET} @@ -109,7 +115,7 @@ services: # ───────────────────────────── server3 ───────────────────────────── server3: - depends_on: [dek-init] + depends_on: [dek-init, uploads-init] <<: *with-env image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server3:${IMG_TAG} user: "1000:1000" @@ -146,6 +152,7 @@ services: DB_SSL_CERT: ${DB_SSL_CERT} DB_SSL_KEY: ${DB_SSL_KEY} DB_SSL_CA: ${DB_SSL_CA} + DB_POOL_SIZE: "12" CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} SUPPORT_SENDGRID_API_KEY: ${SUPPORT_SENDGRID_API_KEY} SALARY_DB_PATH: /app/salary_info.db diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs deleted file mode 100644 index 6eb6836..0000000 --- a/ecosystem.config.cjs +++ /dev/null @@ -1,57 +0,0 @@ -module.exports = { - apps: [ - /* ─────────────── SERVER-2 ─────────────── */ - { - name: 'server2', - script: './backend/server2.js', - watch: false, // or true in dev - - env_development: { - NODE_ENV: 'development', - ONET_USERNAME: 'aptivaai', - ONET_PASSWORD: '2296ahq' - }, - env_production: { - NODE_ENV: 'production', - ONET_USERNAME: 'aptivaai', - ONET_PASSWORD: '2296ahq' - } - }, - - { - name : 'server3', - script : './backend/server3.js', - watch : false, - - /* 👇 everything lives here, nothing else to pass at start-time */ - env: { - NODE_ENV : 'production', - PREMIUM_PORT : 5002, - - DB_HOST : '34.67.180.54', - DB_PORT : 3306, - DB_USER : 'sqluser', - DB_PASSWORD : 'ps full_schema.sql + + diff --git a/nginx.conf b/nginx.conf index 7328cca..de3403d 100644 --- a/nginx.conf +++ b/nginx.conf @@ -4,6 +4,12 @@ http { include /etc/nginx/mime.types; default_type application/octet-stream; resolver 127.0.0.11 ipv6=off; + limit_conn_zone $binary_remote_addr zone=perip:10m; + limit_req_zone $binary_remote_addr zone=reqperip:10m rate=10r/s; + set_real_ip_from 130.211.0.0/22; + set_real_ip_from 35.191.0.0/16; + real_ip_header X-Forwarded-For; + real_ip_recursive on; # ───────────── upstreams to Docker services ───────────── upstream backend5000 { server server1:5000; } # auth & free @@ -34,6 +40,43 @@ http { ssl_certificate_key /etc/letsencrypt/live/dev1.aptivaai.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; + # ==== RUNTIME PROTECTIONS (dev test) ==== + server_tokens off; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header X-Frame-Options SAMEORIGIN always; + + client_max_body_size 10m; + large_client_header_buffers 4 8k; + + client_header_timeout 30s; + client_body_timeout 30s; + send_timeout 35s; + keepalive_timeout 65s; + + proxy_set_header X-Request-ID $request_id; + add_header X-Request-ID $request_id always; + proxy_request_buffering off; + proxy_max_temp_file_size 0; + proxy_buffer_size 16k; + proxy_buffers 8 16k; + proxy_busy_buffers_size 32k; + + limit_conn perip 20; # typical users stay << 10 + limit_conn_status 429; # surface as 429 Too Many Requests + + limit_req zone=reqperip burst=20 nodelay; + limit_req_status 429; + + if ($request_method !~ ^(GET|POST|PUT|PATCH|DELETE|OPTIONS)$) { return 405; } + + if ($host !~* ^(dev1\.aptivaai\.com)$) { return 444; } + + location ~ /\.(?!well-known/) { deny all; } + + location ~* \.(?:env|ini|log|sql|sqlite|db|db3|bak|old|orig|swp)$ { deny all; } + # ───── React static assets ───── root /usr/share/nginx/html; index index.html; diff --git a/schema.sql b/schema.sql deleted file mode 100644 index e69de29..0000000 diff --git a/src/App.js b/src/App.js index 13779f1..47aba32 100644 --- a/src/App.js +++ b/src/App.js @@ -246,7 +246,7 @@ const confirmLogout = async () => { // 1) Ask the server to clear the session cookie try { // If you created /logout (no /api prefix): - await api.post('/logout'); // axios client is withCredentials: true + await api.post('api/logout'); // axios client is withCredentials: true // If your route is /api/signout instead, use: // await api.post('/api/signout'); } catch (e) { diff --git a/src/auth/apiClient.js b/src/auth/apiClient.js index 2fcdc28..e438731 100644 --- a/src/auth/apiClient.js +++ b/src/auth/apiClient.js @@ -22,6 +22,12 @@ axios.interceptors.request.use((config) => { axios.interceptors.response.use( r => r, (err) => { + // capture request id from server/nginx for troubleshooting + const rid = err?.response?.headers?.['x-request-id']; + if (rid) { + err.requestId = rid; + console.error('Request failed — X-Request-ID:', rid); + } const status = err?.response?.status; if (status === 401) { clearToken(); diff --git a/src/auth/apiFetch.js b/src/auth/apiFetch.js index 18b1ec4..fb6b513 100644 --- a/src/auth/apiFetch.js +++ b/src/auth/apiFetch.js @@ -11,5 +11,13 @@ export default function apiFetch(input, init = {}) { ...init, headers, credentials: 'include' // ← send cookie + }).then(async (res) => { + if (res.ok) return res; + const rid = res.headers.get('x-request-id'); + const text = await res.text().catch(() => ''); + const err = new Error(text || res.statusText || 'Request failed'); + err.status = res.status; + if (rid) err.requestId = rid; + throw err; }); } diff --git a/src/components/ResumeRewrite.js b/src/components/ResumeRewrite.js index 8a48705..8df9456 100644 --- a/src/components/ResumeRewrite.js +++ b/src/components/ResumeRewrite.js @@ -66,6 +66,12 @@ function ResumeRewrite() { setError('Please fill in all fields.'); return; } + // If a previous error existed, reset the file input to prompt a fresh pick + if (error) { + // reset the native input if accessible + const el = e.target?.querySelector('input[type="file"]'); + if (el) el.value = ''; + } setLoading(true); try { @@ -84,7 +90,20 @@ function ResumeRewrite() { fetchRemainingOptimizations(); } catch (err) { console.error('Resume optimization error:', err); - setError(err.response?.data?.error || 'Failed to optimize resume.'); + const status = err?.response?.status; + const code = err?.response?.data?.error; + + if (status === 413 || code === 'file_too_large') { + setError(`File is too large. Maximum ${MAX_MB}MB.`); + } else if (status === 415 || code === 'unsupported_type') { + setError('Unsupported file type. Please upload a PDF or DOCX.'); + } else if (status === 429) { + setError('Too many requests. Please wait a moment and try again.'); + } else if (status === 400) { + setError('Bad upload. Please re-select your file and try again.'); + } else { + setError('Failed to optimize resume. Please try again.'); + } } finally { setLoading(false); }