Runtime hardening, logs, rate limits
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
parent
893757646b
commit
888bdd2939
@ -1 +1 @@
|
||||
afd62e0deab27814cfa0067f1fae1dc4ad79e7dd-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b
|
||||
fb83dd6424562765662889aea6436fdb4b1b975f-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b
|
||||
|
@ -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"]
|
||||
|
||||
|
@ -50,6 +50,7 @@ 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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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: ''
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -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]<cut) u.win.shift(); }
|
||||
|
||||
/* ---------- helpers ---------- */
|
||||
const classifyIntent = txt =>
|
||||
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";
|
||||
}
|
||||
|
||||
res.writeHead(200, headers);
|
||||
res.flushHeaders?.();
|
||||
|
||||
const sendChunk = (txt = "") => { res.write(txt); res.flush?.(); };
|
||||
// ---- 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' });
|
||||
}
|
||||
|
||||
const { prompt = "", chatHistory = [], pageContext = "", snapshot = {} } = req.body || {};
|
||||
if (!prompt.trim()) return res.status(400).json({ error: "Empty prompt" });
|
||||
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" });
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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<g+2DO-eTb2mb5',
|
||||
DB_NAME : 'user_profile_db',
|
||||
|
||||
TWILIO_ACCOUNT_SID : 'ACd700c6fb9f691ccd9ccab73f2dd4173d',
|
||||
TWILIO_AUTH_TOKEN : 'fb8979ccb172032a249014c9c30eba80',
|
||||
TWILIO_MESSAGING_SERVICE_SID : 'MGMGaa07992a9231c841b1bfb879649026d6'
|
||||
}
|
||||
}
|
||||
|
||||
],
|
||||
|
||||
deploy : {
|
||||
production : {
|
||||
user : 'SSH_USERNAME',
|
||||
host : 'SSH_HOSTMACHINE',
|
||||
ref : 'origin/master',
|
||||
repo : 'GIT_REPOSITORY',
|
||||
path : 'DESTINATION_PATH',
|
||||
'pre-deploy-local': '',
|
||||
'post-deploy' : 'npm install && pm2 reload ecosystem.config.js --env production',
|
||||
'pre-setup': ''
|
||||
}
|
||||
}
|
||||
};
|
545
full_schema.sql
Normal file
545
full_schema.sql
Normal file
@ -0,0 +1,545 @@
|
||||
-- MySQL dump 10.13 Distrib 8.0.42, for Linux (x86_64)
|
||||
--
|
||||
-- Host: 34.67.180.54 Database: user_profile_db
|
||||
-- ------------------------------------------------------
|
||||
-- Server version 8.0.40-google
|
||||
|
||||
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
|
||||
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
|
||||
/*!50503 SET NAMES utf8mb4 */;
|
||||
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
|
||||
/*!40103 SET TIME_ZONE='+00:00' */;
|
||||
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
|
||||
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
|
||||
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
|
||||
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
|
||||
SET @MYSQLDUMP_TEMP_LOG_BIN = @@SESSION.SQL_LOG_BIN;
|
||||
SET @@SESSION.SQL_LOG_BIN= 0;
|
||||
|
||||
--
|
||||
-- GTID state at the beginning of the backup
|
||||
--
|
||||
|
||||
SET @@GLOBAL.GTID_PURGED=/*!80000 '+'*/ '';
|
||||
|
||||
--
|
||||
-- Table structure for table `ai_chat_messages`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `ai_chat_messages`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `ai_chat_messages` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`thread_id` char(36) NOT NULL,
|
||||
`user_id` bigint NOT NULL,
|
||||
`role` enum('user','assistant','system') NOT NULL,
|
||||
`content` mediumtext NOT NULL,
|
||||
`meta_json` json DEFAULT NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `thread_id` (`thread_id`,`created_at`),
|
||||
CONSTRAINT `fk_chat_thread` FOREIGN KEY (`thread_id`) REFERENCES `ai_chat_threads` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_messages_thread` FOREIGN KEY (`thread_id`) REFERENCES `ai_chat_threads` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=53 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `ai_chat_threads`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `ai_chat_threads`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `ai_chat_threads` (
|
||||
`id` char(36) NOT NULL,
|
||||
`user_id` bigint NOT NULL,
|
||||
`bot_type` enum('support','retire','coach') NOT NULL,
|
||||
`title` varchar(200) DEFAULT NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `user_id` (`user_id`,`bot_type`,`updated_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `ai_generated_ksa`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `ai_generated_ksa`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `ai_generated_ksa` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`soc_code` varchar(50) NOT NULL,
|
||||
`career_title` varchar(255) NOT NULL,
|
||||
`knowledge_json` mediumtext,
|
||||
`skills_json` mediumtext,
|
||||
`abilities_json` mediumtext,
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `soc_code` (`soc_code`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=20 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `ai_risk_analysis`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `ai_risk_analysis`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `ai_risk_analysis` (
|
||||
`soc_code` varchar(10) NOT NULL,
|
||||
`career_name` text,
|
||||
`job_description` text,
|
||||
`tasks` text,
|
||||
`risk_level` varchar(50) DEFAULT NULL,
|
||||
`reasoning` text,
|
||||
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`soc_code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `career_profiles`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `career_profiles`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `career_profiles` (
|
||||
`id` varchar(36) NOT NULL,
|
||||
`user_id` int NOT NULL,
|
||||
`career_name` varchar(255) DEFAULT NULL,
|
||||
`status` varchar(100) DEFAULT NULL,
|
||||
`start_date` varchar(128) DEFAULT NULL,
|
||||
`retirement_start_date` varchar(128) DEFAULT NULL,
|
||||
`college_enrollment_status` varchar(100) DEFAULT NULL,
|
||||
`currently_working` varchar(10) DEFAULT NULL,
|
||||
`planned_monthly_expenses` varchar(128) DEFAULT NULL,
|
||||
`planned_monthly_debt_payments` varchar(128) DEFAULT NULL,
|
||||
`planned_monthly_retirement_contribution` varchar(128) DEFAULT NULL,
|
||||
`planned_monthly_emergency_contribution` varchar(128) DEFAULT NULL,
|
||||
`planned_surplus_emergency_pct` varchar(128) DEFAULT NULL,
|
||||
`planned_surplus_retirement_pct` varchar(128) DEFAULT NULL,
|
||||
`planned_additional_income` varchar(128) DEFAULT NULL,
|
||||
`scenario_title` varchar(255) DEFAULT NULL,
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`career_goals` mediumtext,
|
||||
`desired_retirement_income_monthly` varchar(128) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `user_id` (`user_id`,`career_name`),
|
||||
CONSTRAINT `fk_career_profiles_user` FOREIGN KEY (`user_id`) REFERENCES `user_profile` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `college_profiles`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `college_profiles`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `college_profiles` (
|
||||
`id` varchar(36) NOT NULL,
|
||||
`user_id` int NOT NULL,
|
||||
`career_profile_id` varchar(36) DEFAULT NULL,
|
||||
`selected_school` varchar(512) DEFAULT NULL,
|
||||
`selected_program` varchar(512) DEFAULT NULL,
|
||||
`program_type` varchar(50) DEFAULT NULL,
|
||||
`academic_calendar` varchar(50) DEFAULT NULL,
|
||||
`is_in_state` tinyint DEFAULT NULL,
|
||||
`is_in_district` tinyint DEFAULT NULL,
|
||||
`is_online` tinyint DEFAULT NULL,
|
||||
`college_enrollment_status` varchar(50) DEFAULT NULL,
|
||||
`annual_financial_aid` varchar(128) DEFAULT NULL,
|
||||
`existing_college_debt` varchar(128) DEFAULT NULL,
|
||||
`tuition` varchar(128) DEFAULT NULL,
|
||||
`tuition_paid` varchar(128) DEFAULT NULL,
|
||||
`loan_deferral_until_graduation` varchar(128) DEFAULT NULL,
|
||||
`loan_term` varchar(128) DEFAULT NULL,
|
||||
`interest_rate` varchar(128) DEFAULT NULL,
|
||||
`extra_payment` varchar(128) DEFAULT NULL,
|
||||
`credit_hours_per_year` decimal(5,2) DEFAULT NULL,
|
||||
`hours_completed` decimal(5,1) DEFAULT NULL,
|
||||
`program_length` decimal(5,2) DEFAULT NULL,
|
||||
`credit_hours_required` decimal(5,2) DEFAULT NULL,
|
||||
`expected_graduation` date DEFAULT NULL,
|
||||
`expected_salary` varchar(128) DEFAULT NULL,
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`enrollment_date` date DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `ux_user_school_prog` (`user_id`,`career_profile_id`,`selected_school`(192),`selected_program`(192),`program_type`),
|
||||
KEY `idx_school` (`selected_school`(191)),
|
||||
KEY `idx_program` (`selected_program`(191)),
|
||||
KEY `idx_school_prog` (`selected_school`(191),`selected_program`(191)),
|
||||
KEY `idx_college_user` (`user_id`),
|
||||
KEY `idx_college_career` (`career_profile_id`),
|
||||
CONSTRAINT `fk_college_profiles_career` FOREIGN KEY (`career_profile_id`) REFERENCES `career_profiles` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT `fk_college_profiles_user` FOREIGN KEY (`user_id`) REFERENCES `user_profile` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `context_cache`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `context_cache`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `context_cache` (
|
||||
`user_id` bigint NOT NULL,
|
||||
`career_profile_id` varchar(36) NOT NULL,
|
||||
`ctx_hash` char(40) NOT NULL,
|
||||
`ctx_text` mediumtext,
|
||||
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`user_id`,`career_profile_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `encryption_canary`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `encryption_canary`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `encryption_canary` (
|
||||
`id` tinyint NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `financial_profiles`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `financial_profiles`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `financial_profiles` (
|
||||
`user_id` int NOT NULL,
|
||||
`current_salary` varchar(128) DEFAULT NULL,
|
||||
`additional_income` varchar(128) DEFAULT NULL,
|
||||
`monthly_expenses` varchar(128) DEFAULT NULL,
|
||||
`monthly_debt_payments` varchar(128) DEFAULT NULL,
|
||||
`retirement_savings` varchar(128) DEFAULT NULL,
|
||||
`emergency_fund` varchar(128) DEFAULT NULL,
|
||||
`retirement_contribution` varchar(128) DEFAULT NULL,
|
||||
`emergency_contribution` varchar(128) DEFAULT NULL,
|
||||
`extra_cash_emergency_pct` varchar(64) DEFAULT NULL,
|
||||
`extra_cash_retirement_pct` varchar(64) DEFAULT NULL,
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`user_id`),
|
||||
CONSTRAINT `fk_financial_profiles_user` FOREIGN KEY (`user_id`) REFERENCES `user_profile` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `financial_projections`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `financial_projections`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `financial_projections` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int NOT NULL,
|
||||
`career_profile_id` varchar(36) DEFAULT NULL,
|
||||
`projection_data` text NOT NULL,
|
||||
`loan_paid_off_month` varchar(20) DEFAULT NULL,
|
||||
`final_emergency_savings` decimal(10,2) DEFAULT '0.00',
|
||||
`final_retirement_savings` decimal(10,2) DEFAULT '0.00',
|
||||
`final_loan_balance` decimal(10,2) DEFAULT '0.00',
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `fk_financial_projections_user` (`user_id`),
|
||||
KEY `fk_financial_projections_career` (`career_profile_id`),
|
||||
CONSTRAINT `fk_financial_projections_career` FOREIGN KEY (`career_profile_id`) REFERENCES `career_profiles` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT `fk_financial_projections_user` FOREIGN KEY (`user_id`) REFERENCES `user_profile` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `milestone_impacts`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `milestone_impacts`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `milestone_impacts` (
|
||||
`id` varchar(36) NOT NULL,
|
||||
`milestone_id` varchar(36) NOT NULL,
|
||||
`impact_type` varchar(255) DEFAULT NULL,
|
||||
`direction` varchar(255) DEFAULT NULL,
|
||||
`amount` varchar(128) DEFAULT NULL,
|
||||
`start_date` varchar(255) DEFAULT NULL,
|
||||
`end_date` varchar(255) DEFAULT NULL,
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `milestones`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `milestones`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `milestones` (
|
||||
`id` varchar(36) NOT NULL,
|
||||
`user_id` int NOT NULL,
|
||||
`title` varchar(255) DEFAULT NULL,
|
||||
`description` mediumtext,
|
||||
`date` varchar(128) DEFAULT NULL,
|
||||
`progress` varchar(128) DEFAULT NULL,
|
||||
`status` varchar(50) DEFAULT 'planned',
|
||||
`career_profile_id` varchar(36) DEFAULT NULL,
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`is_universal` tinyint DEFAULT '0',
|
||||
`origin_milestone_id` varchar(36) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `fk_milestones_user` (`user_id`),
|
||||
KEY `fk_milestones_career` (`career_profile_id`),
|
||||
CONSTRAINT `fk_milestones_career` FOREIGN KEY (`career_profile_id`) REFERENCES `career_profiles` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT `fk_milestones_user` FOREIGN KEY (`user_id`) REFERENCES `user_profile` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `onboarding_drafts`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `onboarding_drafts`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `onboarding_drafts` (
|
||||
`user_id` bigint NOT NULL,
|
||||
`id` char(36) NOT NULL,
|
||||
`step` tinyint NOT NULL DEFAULT '0',
|
||||
`data` json NOT NULL,
|
||||
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`user_id`),
|
||||
UNIQUE KEY `uniq_id` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `password_resets`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `password_resets`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `password_resets` (
|
||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||
`email` varchar(255) NOT NULL,
|
||||
`token_hash` char(64) NOT NULL,
|
||||
`expires_at` bigint NOT NULL,
|
||||
`used_at` bigint DEFAULT NULL,
|
||||
`created_at` bigint NOT NULL,
|
||||
`ip` varchar(64) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `email` (`email`),
|
||||
KEY `token_hash` (`token_hash`),
|
||||
KEY `expires_at` (`expires_at`),
|
||||
KEY `idx_password_resets_token_hash` (`token_hash`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `reminders`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `reminders`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `reminders` (
|
||||
`id` char(36) NOT NULL,
|
||||
`user_id` int NOT NULL,
|
||||
`phone_e164` varchar(128) DEFAULT NULL,
|
||||
`message_body` mediumtext,
|
||||
`send_at_utc` datetime NOT NULL,
|
||||
`sent_at` datetime DEFAULT NULL,
|
||||
`status` enum('pending','sent','failed') DEFAULT 'pending',
|
||||
`twilio_sid` varchar(64) DEFAULT NULL,
|
||||
`error_code` varchar(32) DEFAULT NULL,
|
||||
`error_message` text,
|
||||
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `fk_reminders_user` (`user_id`),
|
||||
CONSTRAINT `fk_reminders_user` FOREIGN KEY (`user_id`) REFERENCES `user_profile` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `salary_data`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `salary_data`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `salary_data` (
|
||||
`AREA` varchar(10) DEFAULT NULL,
|
||||
`AREA_TITLE` varchar(255) DEFAULT NULL,
|
||||
`AREA_TYPE` int DEFAULT NULL,
|
||||
`PRIM_STATE` varchar(10) DEFAULT NULL,
|
||||
`NAICS` varchar(10) DEFAULT NULL,
|
||||
`NAICS_TITLE` varchar(255) DEFAULT NULL,
|
||||
`I_GROUP` varchar(50) DEFAULT NULL,
|
||||
`OWN_CODE` varchar(10) DEFAULT NULL,
|
||||
`OCC_CODE` varchar(10) DEFAULT NULL,
|
||||
`OCC_TITLE` varchar(255) DEFAULT NULL,
|
||||
`O_GROUP` varchar(50) DEFAULT NULL,
|
||||
`TOT_EMP` int DEFAULT NULL,
|
||||
`EMP_PRSE` decimal(10,2) DEFAULT NULL,
|
||||
`JOBS_1000` decimal(10,2) DEFAULT NULL,
|
||||
`LOC_QUOTIENT` decimal(10,2) DEFAULT NULL,
|
||||
`PCT_TOTAL` decimal(10,2) DEFAULT NULL,
|
||||
`PCT_RPT` decimal(10,2) DEFAULT NULL,
|
||||
`H_MEAN` decimal(10,2) DEFAULT NULL,
|
||||
`A_MEAN` int DEFAULT NULL,
|
||||
`MEAN_PRSE` decimal(10,2) DEFAULT NULL,
|
||||
`H_PCT10` decimal(10,2) DEFAULT NULL,
|
||||
`H_PCT25` decimal(10,2) DEFAULT NULL,
|
||||
`H_MEDIAN` decimal(10,2) DEFAULT NULL,
|
||||
`H_PCT75` decimal(10,2) DEFAULT NULL,
|
||||
`H_PCT90` decimal(10,2) DEFAULT NULL,
|
||||
`A_PCT10` int DEFAULT NULL,
|
||||
`A_PCT25` int DEFAULT NULL,
|
||||
`A_MEDIAN` int DEFAULT NULL,
|
||||
`A_PCT75` int DEFAULT NULL,
|
||||
`A_PCT90` int DEFAULT NULL,
|
||||
`ANNUAL` tinyint(1) DEFAULT NULL,
|
||||
`HOURLY` tinyint(1) DEFAULT NULL,
|
||||
`JOB_ZONE` int DEFAULT NULL,
|
||||
`limited_data` tinyint(1) DEFAULT '0'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `stripe_events`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `stripe_events`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `stripe_events` (
|
||||
`id` varchar(255) NOT NULL,
|
||||
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `tasks`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `tasks`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `tasks` (
|
||||
`id` varchar(36) NOT NULL,
|
||||
`milestone_id` varchar(36) NOT NULL,
|
||||
`user_id` int NOT NULL,
|
||||
`title` varchar(255) DEFAULT NULL,
|
||||
`description` mediumtext,
|
||||
`due_date` varchar(255) DEFAULT NULL,
|
||||
`status` varchar(255) DEFAULT 'not_started',
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `fk_tasks_user` (`user_id`),
|
||||
CONSTRAINT `fk_tasks_user` FOREIGN KEY (`user_id`) REFERENCES `user_profile` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `user_auth`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `user_auth`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `user_auth` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int NOT NULL,
|
||||
`username` varchar(255) NOT NULL,
|
||||
`hashed_password` text NOT NULL,
|
||||
`password_changed_at` bigint unsigned DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `username` (`username`),
|
||||
KEY `ix_user_auth_userid_changedat` (`user_id`,`password_changed_at`),
|
||||
CONSTRAINT `fk_user_id` FOREIGN KEY (`user_id`) REFERENCES `user_profile` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=47 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `user_profile`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `user_profile`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!50503 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `user_profile` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`username` varchar(255) DEFAULT NULL,
|
||||
`firstname` varchar(400) DEFAULT NULL,
|
||||
`lastname` varchar(400) DEFAULT NULL,
|
||||
`email` varchar(512) DEFAULT NULL,
|
||||
`email_lookup` char(64) NOT NULL DEFAULT '',
|
||||
`phone_e164` varchar(128) DEFAULT NULL,
|
||||
`sms_opt_in` tinyint(1) NOT NULL DEFAULT '0',
|
||||
`zipcode` varchar(64) DEFAULT NULL,
|
||||
`state` varchar(50) NOT NULL,
|
||||
`area` varchar(255) NOT NULL,
|
||||
`is_premium` tinyint DEFAULT '0',
|
||||
`is_pro_premium` tinyint DEFAULT '0',
|
||||
`stripe_customer_id` varchar(128) DEFAULT NULL,
|
||||
`career_situation` text,
|
||||
`interest_inventory_answers` mediumtext,
|
||||
`riasec_scores` varchar(768) DEFAULT NULL,
|
||||
`resume_optimizations_used` int DEFAULT '0',
|
||||
`resume_limit_reset` date DEFAULT NULL,
|
||||
`resume_booster_count` int DEFAULT '0',
|
||||
`career_priorities` mediumtext,
|
||||
`career_list` mediumtext,
|
||||
`stripe_customer_id_hash` char(64) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `email` (`email`),
|
||||
UNIQUE KEY `stripe_customer_id` (`stripe_customer_id`),
|
||||
KEY `idx_customer_hash` (`stripe_customer_id_hash`),
|
||||
KEY `idx_user_profile_email_lookup` (`email_lookup`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=64 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Dumping events for database 'user_profile_db'
|
||||
--
|
||||
|
||||
--
|
||||
-- Dumping routines for database 'user_profile_db'
|
||||
--
|
||||
SET @@SESSION.SQL_LOG_BIN = @MYSQLDUMP_TEMP_LOG_BIN;
|
||||
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
|
||||
|
||||
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
|
||||
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
|
||||
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
|
||||
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
|
||||
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
|
||||
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
|
||||
|
||||
-- Dump completed on 2025-08-27 16:38:46
|
@ -234,3 +234,17 @@ ALTER TABLE ai_chat_messages
|
||||
ADD CONSTRAINT fk_messages_thread
|
||||
FOREIGN KEY (thread_id) REFERENCES ai_chat_threads(id)
|
||||
ON DELETE CASCADE;
|
||||
|
||||
|
||||
mysqldump \
|
||||
--host=34.67.180.54 \
|
||||
--port=3306 \
|
||||
--user=sqluser \
|
||||
-p \
|
||||
--no-data \
|
||||
--routines \
|
||||
--triggers \
|
||||
--events \
|
||||
user_profile_db > full_schema.sql
|
||||
|
||||
|
||||
|
43
nginx.conf
43
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;
|
||||
|
@ -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) {
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user