security
This commit is contained in:
385
chat/server.js
385
chat/server.js
@@ -550,7 +550,8 @@ const AUTO_MODEL_TOKEN = 'auto';
|
|||||||
const DEFAULT_PROVIDER_FALLBACK = 'opencode';
|
const DEFAULT_PROVIDER_FALLBACK = 'opencode';
|
||||||
const DEFAULT_PROVIDER_SEEDS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'chutes', 'cerebras', 'ollama', DEFAULT_PROVIDER_FALLBACK, 'cohere', 'kilo'];
|
const DEFAULT_PROVIDER_SEEDS = ['openrouter', 'mistral', 'google', 'groq', 'nvidia', 'chutes', 'cerebras', 'ollama', DEFAULT_PROVIDER_FALLBACK, 'cohere', 'kilo'];
|
||||||
const PROVIDER_PERSIST_DEBOUNCE_MS = 200;
|
const PROVIDER_PERSIST_DEBOUNCE_MS = 200;
|
||||||
const TOKEN_ESTIMATION_BUFFER = 400;
|
const TOKEN_ESTIMATION_BUFFER = 800;
|
||||||
|
const TOKEN_HISTORY_OVERHEAD = 200;
|
||||||
const BOOST_PACK_SIZE = 500_000;
|
const BOOST_PACK_SIZE = 500_000;
|
||||||
const BOOST_BASE_PRICE = 15;
|
const BOOST_BASE_PRICE = 15;
|
||||||
const TOKEN_GRACE_RATIO = 0.05;
|
const TOKEN_GRACE_RATIO = 0.05;
|
||||||
@@ -1603,6 +1604,7 @@ async function gracefulShutdown(signal) {
|
|||||||
// Stop periodic tasks first
|
// Stop periodic tasks first
|
||||||
stopAutoSave();
|
stopAutoSave();
|
||||||
stopMemoryCleanup();
|
stopMemoryCleanup();
|
||||||
|
stopPendingCleanup();
|
||||||
|
|
||||||
// Notify all active SSE connections about the restart
|
// Notify all active SSE connections about the restart
|
||||||
for (const [messageId, streams] of activeStreams.entries()) {
|
for (const [messageId, streams] of activeStreams.entries()) {
|
||||||
@@ -2112,10 +2114,19 @@ function validateCsrfToken(token, userId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Security: Honeypot Detection
|
// Security: Honeypot Detection
|
||||||
|
const HONEYPOT_FIELDS = ['website', 'url', 'homepage', 'web_address', 'site', 'link'];
|
||||||
function checkHoneypot(body) {
|
function checkHoneypot(body) {
|
||||||
return !!(body.website && body.website.length > 0);
|
if (!body || typeof body !== 'object') return false;
|
||||||
|
for (const field of HONEYPOT_FIELDS) {
|
||||||
|
if (body[field] && typeof body[field] === 'string' && body[field].length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const passwordResetAttempts = new Map();
|
||||||
|
|
||||||
// Security: Prompt Injection Protection - Comprehensive
|
// Security: Prompt Injection Protection - Comprehensive
|
||||||
function sanitizePromptInput(input, options = {}) {
|
function sanitizePromptInput(input, options = {}) {
|
||||||
if (!input || typeof input !== 'string') return '';
|
if (!input || typeof input !== 'string') return '';
|
||||||
@@ -2302,7 +2313,7 @@ function startAdminSession(res) {
|
|||||||
'SameSite=Lax',
|
'SameSite=Lax',
|
||||||
`Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}`,
|
`Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}`,
|
||||||
];
|
];
|
||||||
if (process.env.COOKIE_SECURE === '0') parts.push('Secure');
|
if (process.env.COOKIE_SECURE !== '0' && process.env.NODE_ENV !== 'development') parts.push('Secure');
|
||||||
res.setHeader('Set-Cookie', parts.join('; '));
|
res.setHeader('Set-Cookie', parts.join('; '));
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
@@ -9678,43 +9689,18 @@ async function sendOpenRouterChat({ messages, model }) {
|
|||||||
|
|
||||||
async function sendMistralChat({ messages, model }) {
|
async function sendMistralChat({ messages, model }) {
|
||||||
if (!MISTRAL_API_KEY) {
|
if (!MISTRAL_API_KEY) {
|
||||||
console.error('[MISTRAL] API key missing');
|
|
||||||
log('Mistral API key missing, cannot fulfill planning request');
|
log('Mistral API key missing, cannot fulfill planning request');
|
||||||
throw new Error('Mistral API key is not configured');
|
throw new Error('Mistral API key is not configured');
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeMessages = Array.isArray(messages) ? messages : [];
|
const safeMessages = Array.isArray(messages) ? messages : [];
|
||||||
if (!safeMessages.length) {
|
if (!safeMessages.length) {
|
||||||
console.error('[MISTRAL] Empty messages array');
|
|
||||||
throw new Error('Mistral messages must be a non-empty array');
|
throw new Error('Mistral messages must be a non-empty array');
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedModel = model || resolveMistralModel();
|
const resolvedModel = model || resolveMistralModel();
|
||||||
const payload = { model: resolvedModel, messages: safeMessages };
|
const payload = { model: resolvedModel, messages: safeMessages };
|
||||||
|
|
||||||
console.log('[MISTRAL] Starting API request', {
|
|
||||||
url: MISTRAL_API_URL,
|
|
||||||
model: resolvedModel,
|
|
||||||
messageCount: safeMessages.length,
|
|
||||||
hasApiKey: !!MISTRAL_API_KEY,
|
|
||||||
apiKeyPrefix: MISTRAL_API_KEY ? MISTRAL_API_KEY.substring(0, 8) + '...' : 'none'
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[MISTRAL] Request payload:', {
|
|
||||||
model: payload.model,
|
|
||||||
messagesCount: payload.messages.length,
|
|
||||||
firstMessage: payload.messages[0] ? {
|
|
||||||
role: payload.messages[0].role,
|
|
||||||
contentLength: payload.messages[0].content?.length || 0,
|
|
||||||
contentPreview: payload.messages[0].content?.substring(0, 100)
|
|
||||||
} : null,
|
|
||||||
lastMessage: payload.messages[payload.messages.length - 1] ? {
|
|
||||||
role: payload.messages[payload.messages.length - 1].role,
|
|
||||||
contentLength: payload.messages[payload.messages.length - 1].content?.length || 0,
|
|
||||||
contentPreview: payload.messages[payload.messages.length - 1].content?.substring(0, 100)
|
|
||||||
} : null
|
|
||||||
});
|
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${MISTRAL_API_KEY}`,
|
'Authorization': `Bearer ${MISTRAL_API_KEY}`,
|
||||||
@@ -9723,85 +9709,29 @@ async function sendMistralChat({ messages, model }) {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(MISTRAL_API_URL, { method: 'POST', headers, body: JSON.stringify(payload) });
|
const res = await fetch(MISTRAL_API_URL, { method: 'POST', headers, body: JSON.stringify(payload) });
|
||||||
|
|
||||||
console.log('[MISTRAL] Response received', {
|
|
||||||
status: res.status,
|
|
||||||
statusText: res.statusText,
|
|
||||||
ok: res.ok,
|
|
||||||
headers: Object.fromEntries(res.headers.entries())
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let detail = '';
|
let detail = '';
|
||||||
try {
|
try {
|
||||||
detail = await res.text();
|
detail = await res.text();
|
||||||
console.error('[MISTRAL] Error response body:', detail);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[MISTRAL] Failed to read error body', String(err));
|
|
||||||
log('Mistral error body read failed', { status: res.status, err: String(err) });
|
log('Mistral error body read failed', { status: res.status, err: String(err) });
|
||||||
}
|
}
|
||||||
const err = buildProviderError('Mistral', res.status, detail || res.statusText);
|
const err = buildProviderError('Mistral', res.status, detail || res.statusText);
|
||||||
console.error('[MISTRAL] Request failed', { status: res.status, detail: err.detail });
|
|
||||||
log('Mistral request failed', { status: res.status, detail: err.detail || res.statusText });
|
log('Mistral request failed', { status: res.status, detail: err.detail || res.statusText });
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
// Log the FULL raw response for debugging
|
|
||||||
console.log('[MISTRAL] Full API response:', JSON.stringify(data, null, 2));
|
|
||||||
|
|
||||||
// Log the response data structure analysis
|
|
||||||
console.log('[MISTRAL] Response data structure:', {
|
|
||||||
hasChoices: !!data?.choices,
|
|
||||||
choicesLength: data?.choices?.length || 0,
|
|
||||||
firstChoiceKeys: data?.choices?.[0] ? Object.keys(data.choices[0]) : [],
|
|
||||||
hasMessage: !!data?.choices?.[0]?.message,
|
|
||||||
messageKeys: data?.choices?.[0]?.message ? Object.keys(data.choices[0].message) : [],
|
|
||||||
hasContent: !!data?.choices?.[0]?.message?.content,
|
|
||||||
contentLength: data?.choices?.[0]?.message?.content?.length || 0,
|
|
||||||
rawDataKeys: Object.keys(data || {})
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log each step of content extraction
|
|
||||||
console.log('[MISTRAL] Choices array:', data?.choices);
|
|
||||||
console.log('[MISTRAL] First choice:', data?.choices?.[0]);
|
|
||||||
console.log('[MISTRAL] Message object:', data?.choices?.[0]?.message);
|
|
||||||
console.log('[MISTRAL] Content value:', data?.choices?.[0]?.message?.content);
|
|
||||||
console.log('[MISTRAL] Content type:', typeof data?.choices?.[0]?.message?.content);
|
|
||||||
|
|
||||||
const reply = data?.choices?.[0]?.message?.content || '';
|
const reply = data?.choices?.[0]?.message?.content || '';
|
||||||
|
|
||||||
console.log('[MISTRAL] Extracted reply:', {
|
|
||||||
reply: reply,
|
|
||||||
replyType: typeof reply,
|
|
||||||
replyLength: reply?.length || 0,
|
|
||||||
isEmpty: reply === '',
|
|
||||||
isNull: reply === null,
|
|
||||||
isUndefined: reply === undefined,
|
|
||||||
isFalsy: !reply
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!reply) {
|
if (!reply) {
|
||||||
console.error('[MISTRAL] No content in response!', {
|
log('Mistral returned empty response', { model: resolvedModel });
|
||||||
fullData: JSON.stringify(data, null, 2),
|
|
||||||
extractedReply: reply,
|
|
||||||
replyType: typeof reply
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log('[MISTRAL] Successfully extracted reply', {
|
|
||||||
replyLength: reply.length,
|
|
||||||
replyPreview: reply.substring(0, 200)
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log('Mistral request succeeded', { model: resolvedModel, replyLength: reply.length });
|
log('Mistral request succeeded', { model: resolvedModel, replyLength: reply.length });
|
||||||
return { reply: reply ? String(reply).trim() : '', model: resolvedModel, raw: data };
|
return { reply: reply ? String(reply).trim() : '', model: resolvedModel, raw: data };
|
||||||
} catch (fetchErr) {
|
} catch (fetchErr) {
|
||||||
console.error('[MISTRAL] Fetch error:', {
|
log('Mistral fetch error', { error: String(fetchErr) });
|
||||||
error: String(fetchErr),
|
|
||||||
message: fetchErr.message,
|
|
||||||
stack: fetchErr.stack
|
|
||||||
});
|
|
||||||
throw fetchErr;
|
throw fetchErr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10066,8 +9996,17 @@ async function handlePlanMessage(req, res, userId) {
|
|||||||
stripMarkdownFromDisplay(sanitizePromptInput(body.displayContent)) :
|
stripMarkdownFromDisplay(sanitizePromptInput(body.displayContent)) :
|
||||||
stripMarkdownFromDisplay(content);
|
stripMarkdownFromDisplay(content);
|
||||||
if (!content) return sendJson(res, 400, { error: 'Message is required' });
|
if (!content) return sendJson(res, 400, { error: 'Message is required' });
|
||||||
|
|
||||||
|
const historyMessages = (session.messages || []).filter((m) => m.phase === 'plan');
|
||||||
|
let historyTokens = 0;
|
||||||
|
historyMessages.forEach((m) => {
|
||||||
|
if (m.content) historyTokens += estimateTokensFromText(m.content);
|
||||||
|
if (m.reply) historyTokens += estimateTokensFromText(m.reply);
|
||||||
|
});
|
||||||
|
const totalEstimatedTokens = estimateTokensFromText(content) + TOKEN_ESTIMATION_BUFFER + (historyMessages.length * TOKEN_HISTORY_OVERHEAD) + historyTokens;
|
||||||
|
|
||||||
const userPlan = resolveUserPlan(session.userId);
|
const userPlan = resolveUserPlan(session.userId);
|
||||||
const allowance = canConsumeTokens(session.userId, userPlan, estimateTokensFromText(content) + TOKEN_ESTIMATION_BUFFER);
|
const allowance = canConsumeTokens(session.userId, userPlan, totalEstimatedTokens);
|
||||||
if (!allowance.allowed) {
|
if (!allowance.allowed) {
|
||||||
return sendJson(res, 402, { error: 'You have reached your token allowance. Upgrade or add a boost.', allowance });
|
return sendJson(res, 402, { error: 'You have reached your token allowance. Upgrade or add a boost.', allowance });
|
||||||
}
|
}
|
||||||
@@ -10083,7 +10022,6 @@ async function handlePlanMessage(req, res, userId) {
|
|||||||
const safePluginName = session.pluginName || `Plugin Compass ${session.title || 'Plugin'}`;
|
const safePluginName = session.pluginName || `Plugin Compass ${session.title || 'Plugin'}`;
|
||||||
let finalSystemPrompt = (systemPrompt || '').replace('{{PLUGIN_SLUG}}', safePluginSlug);
|
let finalSystemPrompt = (systemPrompt || '').replace('{{PLUGIN_SLUG}}', safePluginSlug);
|
||||||
finalSystemPrompt = finalSystemPrompt.replace('{{PLUGIN_NAME}}', safePluginName);
|
finalSystemPrompt = finalSystemPrompt.replace('{{PLUGIN_NAME}}', safePluginName);
|
||||||
const historyMessages = (session.messages || []).filter((m) => m.phase === 'plan');
|
|
||||||
const messages = [{ role: 'system', content: finalSystemPrompt }];
|
const messages = [{ role: 'system', content: finalSystemPrompt }];
|
||||||
historyMessages.forEach((m) => {
|
historyMessages.forEach((m) => {
|
||||||
if (m.content) messages.push({ role: 'user', content: sanitizePromptInput(m.content) });
|
if (m.content) messages.push({ role: 'user', content: sanitizePromptInput(m.content) });
|
||||||
@@ -14173,6 +14111,11 @@ async function handleTopupCheckout(req, res) {
|
|||||||
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
||||||
if (!DODO_ENABLED) return sendJson(res, 503, { error: 'Dodo Payments is not configured' });
|
if (!DODO_ENABLED) return sendJson(res, 503, { error: 'Dodo Payments is not configured' });
|
||||||
|
|
||||||
|
const csrfToken = req.headers['x-csrf-token'];
|
||||||
|
if (!csrfToken || !validateCsrfToken(csrfToken, session.userId)) {
|
||||||
|
return sendJson(res, 403, { error: 'Invalid CSRF token' });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await parseJsonBody(req).catch(() => ({}));
|
const body = await parseJsonBody(req).catch(() => ({}));
|
||||||
const tier = body?.tier || 'topup_1';
|
const tier = body?.tier || 'topup_1';
|
||||||
@@ -14616,6 +14559,11 @@ async function handlePaygCheckout(req, res) {
|
|||||||
if (!isPaidPlan(plan)) return sendJson(res, 400, { error: 'Pay-as-you-go is only available on paid plans' });
|
if (!isPaidPlan(plan)) return sendJson(res, 400, { error: 'Pay-as-you-go is only available on paid plans' });
|
||||||
if (!DODO_ENABLED) return sendJson(res, 503, { error: 'Dodo Payments is not configured' });
|
if (!DODO_ENABLED) return sendJson(res, 503, { error: 'Dodo Payments is not configured' });
|
||||||
|
|
||||||
|
const csrfToken = req.headers['x-csrf-token'];
|
||||||
|
if (!csrfToken || !validateCsrfToken(csrfToken, session.userId)) {
|
||||||
|
return sendJson(res, 403, { error: 'Invalid CSRF token' });
|
||||||
|
}
|
||||||
|
|
||||||
const payg = computePaygSummary(user.id, plan);
|
const payg = computePaygSummary(user.id, plan);
|
||||||
if (payg.billableTokens <= 0 || payg.amount <= 0) {
|
if (payg.billableTokens <= 0 || payg.amount <= 0) {
|
||||||
return sendJson(res, 400, { error: 'No pay-as-you-go usage to bill', payg });
|
return sendJson(res, 400, { error: 'No pay-as-you-go usage to bill', payg });
|
||||||
@@ -14814,6 +14762,11 @@ async function handleSubscriptionCheckout(req, res) {
|
|||||||
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
if (!user) return sendJson(res, 404, { error: 'User not found' });
|
||||||
if (!DODO_ENABLED) return sendJson(res, 503, { error: 'Dodo Payments is not configured' });
|
if (!DODO_ENABLED) return sendJson(res, 503, { error: 'Dodo Payments is not configured' });
|
||||||
|
|
||||||
|
const csrfToken = req.headers['x-csrf-token'];
|
||||||
|
if (!csrfToken || !validateCsrfToken(csrfToken, session.userId)) {
|
||||||
|
return sendJson(res, 403, { error: 'Invalid CSRF token' });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await parseJsonBody(req);
|
const body = await parseJsonBody(req);
|
||||||
const plan = normalizePlanSelection(body.plan);
|
const plan = normalizePlanSelection(body.plan);
|
||||||
@@ -15248,6 +15201,106 @@ async function handleSubscriptionCancel(req, res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const webhookProcessingLocks = new Map();
|
||||||
|
const PENDING_CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
|
||||||
|
const PENDING_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
class AsyncMutex {
|
||||||
|
constructor() {
|
||||||
|
this._locked = false;
|
||||||
|
this._queue = [];
|
||||||
|
}
|
||||||
|
acquire() {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
if (!this._locked) {
|
||||||
|
this._locked = true;
|
||||||
|
resolve(() => this._release());
|
||||||
|
} else {
|
||||||
|
this._queue.push(resolve);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_release() {
|
||||||
|
const next = this._queue.shift();
|
||||||
|
if (next) {
|
||||||
|
next(() => this._release());
|
||||||
|
} else {
|
||||||
|
this._locked = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentMutex = new Map();
|
||||||
|
|
||||||
|
async function withPaymentLock(paymentId, fn) {
|
||||||
|
if (!paymentMutex.has(paymentId)) {
|
||||||
|
paymentMutex.set(paymentId, new AsyncMutex());
|
||||||
|
}
|
||||||
|
const mutex = paymentMutex.get(paymentId);
|
||||||
|
const release = await mutex.acquire();
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupStalePendingPayments() {
|
||||||
|
const now = Date.now();
|
||||||
|
let cleanedTopups = 0;
|
||||||
|
let cleanedPayg = 0;
|
||||||
|
let cleanedSubscriptions = 0;
|
||||||
|
|
||||||
|
for (const [key, pending] of Object.entries(pendingTopups)) {
|
||||||
|
if (pending?.createdAt) {
|
||||||
|
const age = now - new Date(pending.createdAt).getTime();
|
||||||
|
if (age > PENDING_MAX_AGE_MS) {
|
||||||
|
delete pendingTopups[key];
|
||||||
|
cleanedTopups++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, pending] of Object.entries(pendingPayg)) {
|
||||||
|
if (pending?.createdAt) {
|
||||||
|
const age = now - new Date(pending.createdAt).getTime();
|
||||||
|
if (age > PENDING_MAX_AGE_MS) {
|
||||||
|
delete pendingPayg[key];
|
||||||
|
cleanedPayg++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, pending] of Object.entries(pendingSubscriptions)) {
|
||||||
|
if (pending?.createdAt) {
|
||||||
|
const age = now - new Date(pending.createdAt).getTime();
|
||||||
|
if (age > PENDING_MAX_AGE_MS) {
|
||||||
|
delete pendingSubscriptions[key];
|
||||||
|
cleanedSubscriptions++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanedTopups > 0 || cleanedPayg > 0 || cleanedSubscriptions > 0) {
|
||||||
|
log('Cleaned up stale pending payments', { cleanedTopups, cleanedPayg, cleanedSubscriptions });
|
||||||
|
await Promise.all([persistPendingTopups(), persistPendingPayg(), persistPendingSubscriptions()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pendingCleanupTimer = null;
|
||||||
|
|
||||||
|
function startPendingCleanup() {
|
||||||
|
if (pendingCleanupTimer) return;
|
||||||
|
pendingCleanupTimer = setInterval(cleanupStalePendingPayments, PENDING_CLEANUP_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPendingCleanup() {
|
||||||
|
if (pendingCleanupTimer) {
|
||||||
|
clearInterval(pendingCleanupTimer);
|
||||||
|
pendingCleanupTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDodoWebhook(req, res) {
|
async function handleDodoWebhook(req, res) {
|
||||||
try {
|
try {
|
||||||
const DODO_WEBHOOK_KEY = process.env.DODO_PAYMENTS_WEBHOOK_KEY || '';
|
const DODO_WEBHOOK_KEY = process.env.DODO_PAYMENTS_WEBHOOK_KEY || '';
|
||||||
@@ -15260,17 +15313,25 @@ async function handleDodoWebhook(req, res) {
|
|||||||
|
|
||||||
const signature = req.headers['dodo-signature'] || '';
|
const signature = req.headers['dodo-signature'] || '';
|
||||||
|
|
||||||
if (DODO_WEBHOOK_KEY && signature) {
|
const isProduction = process.env.NODE_ENV === 'production' || DODO_ENVIRONMENT.includes('live');
|
||||||
|
|
||||||
|
if (!DODO_WEBHOOK_KEY) {
|
||||||
|
if (isProduction) {
|
||||||
|
log('CRITICAL: Dodo webhook received but DODO_PAYMENTS_WEBHOOK_KEY not set in production - rejecting');
|
||||||
|
return sendJson(res, 500, { error: 'Webhook processing not configured' });
|
||||||
|
}
|
||||||
|
log('WARNING: Dodo webhook received without webhook key configured (development mode)');
|
||||||
|
} else if (!signature) {
|
||||||
|
log('Dodo webhook missing signature', { hasKey: !!DODO_WEBHOOK_KEY });
|
||||||
|
return sendJson(res, 401, { error: 'Missing signature' });
|
||||||
|
} else {
|
||||||
const expectedSignature = `sha256=${require('crypto').createHmac('sha256', DODO_WEBHOOK_KEY).update(rawBody).digest('hex')}`;
|
const expectedSignature = `sha256=${require('crypto').createHmac('sha256', DODO_WEBHOOK_KEY).update(rawBody).digest('hex')}`;
|
||||||
const sigBuffer = Buffer.from(signature);
|
const sigBuffer = Buffer.from(signature);
|
||||||
const expectedBuffer = Buffer.from(expectedSignature);
|
const expectedBuffer = Buffer.from(expectedSignature);
|
||||||
if (sigBuffer.length !== expectedBuffer.length || !require('crypto').timingSafeEqual(sigBuffer, expectedBuffer)) {
|
if (sigBuffer.length !== expectedBuffer.length || !require('crypto').timingSafeEqual(sigBuffer, expectedBuffer)) {
|
||||||
log('Dodo webhook signature verification failed', { signature });
|
log('Dodo webhook signature verification failed', { signature: signature.substring(0, 20) + '...' });
|
||||||
return sendJson(res, 401, { error: 'Invalid signature' });
|
return sendJson(res, 401, { error: 'Invalid signature' });
|
||||||
}
|
}
|
||||||
} else if (DODO_WEBHOOK_KEY) {
|
|
||||||
log('Dodo webhook missing signature', { hasKey: !!DODO_WEBHOOK_KEY });
|
|
||||||
return sendJson(res, 401, { error: 'Missing signature' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = JSON.parse(rawBody);
|
const event = JSON.parse(rawBody);
|
||||||
@@ -15417,68 +15478,71 @@ async function handlePaymentSucceeded(event) {
|
|||||||
if (inferredType === 'topup') {
|
if (inferredType === 'topup') {
|
||||||
const pendingKey = (checkoutId && pendingTopups?.[checkoutId] ? checkoutId : '') || findKeyByOrderId(pendingTopups, orderId);
|
const pendingKey = (checkoutId && pendingTopups?.[checkoutId] ? checkoutId : '') || findKeyByOrderId(pendingTopups, orderId);
|
||||||
const pending = pendingKey ? pendingTopups[pendingKey] : null;
|
const pending = pendingKey ? pendingTopups[pendingKey] : null;
|
||||||
|
const lockKey = pendingKey || paymentId || orderId;
|
||||||
|
|
||||||
const tokens = Number(metadata.tokens || pending?.tokens || 0);
|
await withPaymentLock(lockKey, async () => {
|
||||||
if (!tokens) {
|
const tokens = Number(metadata.tokens || pending?.tokens || 0);
|
||||||
log('payment_succeeded: top-up missing tokens', { userId, eventId: event.id, checkoutId, orderId });
|
if (!tokens) {
|
||||||
return;
|
log('payment_succeeded: top-up missing tokens', { userId, eventId: event.id, checkoutId, orderId });
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const amount = parseAmount(metadata.amount || pending?.amount || data.amount || data.amount_total || data.total_amount);
|
const amount = parseAmount(metadata.amount || pending?.amount || data.amount || data.amount_total || data.total_amount);
|
||||||
const currency = String(metadata.currency || pending?.currency || data.currency || '').toLowerCase() || null;
|
const currency = String(metadata.currency || pending?.currency || data.currency || '').toLowerCase() || null;
|
||||||
const tier = metadata.tier || pending?.tier || null;
|
const tier = metadata.tier || pending?.tier || null;
|
||||||
|
|
||||||
|
if (pendingKey && processedTopups[pendingKey]) {
|
||||||
|
await ensureInvoice('topup', {
|
||||||
|
tokens,
|
||||||
|
amount: processedTopups[pendingKey].amount ?? amount,
|
||||||
|
currency: processedTopups[pendingKey].currency ?? currency,
|
||||||
|
tier: processedTopups[pendingKey].tier ?? tier,
|
||||||
|
source: {
|
||||||
|
provider: 'dodo',
|
||||||
|
checkoutId: pendingKey,
|
||||||
|
orderId: processedTopups[pendingKey].orderId || orderId,
|
||||||
|
paymentId: processedTopups[pendingKey].paymentId || paymentId,
|
||||||
|
eventId: event.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucket = ensureTokenUsageBucket(userId);
|
||||||
|
bucket.addOns = Math.max(0, Number(bucket.addOns || 0) + tokens);
|
||||||
|
await persistTokenUsage();
|
||||||
|
|
||||||
|
if (pendingKey) {
|
||||||
|
processedTopups[pendingKey] = {
|
||||||
|
userId: user.id,
|
||||||
|
orderId: (pending?.orderId || orderId) || null,
|
||||||
|
paymentId: paymentId || null,
|
||||||
|
tokens,
|
||||||
|
tier: tier || null,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
delete pendingTopups[pendingKey];
|
||||||
|
await Promise.all([persistTopupSessions(), persistPendingTopups()]);
|
||||||
|
}
|
||||||
|
|
||||||
if (pendingKey && processedTopups[pendingKey]) {
|
|
||||||
await ensureInvoice('topup', {
|
await ensureInvoice('topup', {
|
||||||
tokens,
|
tokens,
|
||||||
amount: processedTopups[pendingKey].amount ?? amount,
|
amount,
|
||||||
currency: processedTopups[pendingKey].currency ?? currency,
|
currency,
|
||||||
tier: processedTopups[pendingKey].tier ?? tier,
|
tier,
|
||||||
source: {
|
source: {
|
||||||
provider: 'dodo',
|
provider: 'dodo',
|
||||||
checkoutId: pendingKey,
|
checkoutId: pendingKey || checkoutId,
|
||||||
orderId: processedTopups[pendingKey].orderId || orderId,
|
orderId,
|
||||||
paymentId: processedTopups[pendingKey].paymentId || paymentId,
|
paymentId,
|
||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bucket = ensureTokenUsageBucket(userId);
|
log('payment_succeeded: top-up processed via webhook', { userId, tokens, eventId: event.id, checkoutId: pendingKey || checkoutId });
|
||||||
bucket.addOns = Math.max(0, Number(bucket.addOns || 0) + tokens);
|
|
||||||
await persistTokenUsage();
|
|
||||||
|
|
||||||
if (pendingKey) {
|
|
||||||
processedTopups[pendingKey] = {
|
|
||||||
userId: user.id,
|
|
||||||
orderId: (pending?.orderId || orderId) || null,
|
|
||||||
paymentId: paymentId || null,
|
|
||||||
tokens,
|
|
||||||
tier: tier || null,
|
|
||||||
amount,
|
|
||||||
currency,
|
|
||||||
completedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
delete pendingTopups[pendingKey];
|
|
||||||
await Promise.all([persistTopupSessions(), persistPendingTopups()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
await ensureInvoice('topup', {
|
|
||||||
tokens,
|
|
||||||
amount,
|
|
||||||
currency,
|
|
||||||
tier,
|
|
||||||
source: {
|
|
||||||
provider: 'dodo',
|
|
||||||
checkoutId: pendingKey || checkoutId,
|
|
||||||
orderId,
|
|
||||||
paymentId,
|
|
||||||
eventId: event.id,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
log('payment_succeeded: top-up processed via webhook', { userId, tokens, eventId: event.id, checkoutId: pendingKey || checkoutId });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16273,6 +16337,16 @@ async function handleVerifyEmailApi(req, res, url) {
|
|||||||
|
|
||||||
async function handlePasswordResetRequest(req, res) {
|
async function handlePasswordResetRequest(req, res) {
|
||||||
try {
|
try {
|
||||||
|
const clientIp = req.socket?.remoteAddress || 'unknown';
|
||||||
|
const rateLimit = checkLoginRateLimit(clientIp, 5, passwordResetAttempts);
|
||||||
|
if (rateLimit.blocked) {
|
||||||
|
log('password reset rate limited', { ip: clientIp, retryAfter: rateLimit.retryAfter });
|
||||||
|
return sendJson(res, 429, {
|
||||||
|
error: 'Too many password reset attempts. Please try again later.',
|
||||||
|
retryAfter: rateLimit.retryAfter || 60
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const body = await parseJsonBody(req);
|
const body = await parseJsonBody(req);
|
||||||
const email = (body.email || '').trim().toLowerCase();
|
const email = (body.email || '').trim().toLowerCase();
|
||||||
if (!email) return sendJson(res, 400, { error: 'Email is required' });
|
if (!email) return sendJson(res, 400, { error: 'Email is required' });
|
||||||
@@ -16280,12 +16354,15 @@ async function handlePasswordResetRequest(req, res) {
|
|||||||
const user = findUserByEmail(email);
|
const user = findUserByEmail(email);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
passwordResetAttempts.delete(clientIp);
|
||||||
return sendJson(res, 200, { ok: true, message: 'If an account exists, a reset link has been sent.' });
|
return sendJson(res, 200, { ok: true, message: 'If an account exists, a reset link has been sent.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
assignPasswordResetToken(user);
|
assignPasswordResetToken(user);
|
||||||
await persistUsersDb();
|
await persistUsersDb();
|
||||||
|
|
||||||
|
passwordResetAttempts.delete(clientIp);
|
||||||
|
|
||||||
// Send reset email in the background
|
// Send reset email in the background
|
||||||
sendPasswordResetEmail(user, resolveBaseUrl(req)).catch(err => {
|
sendPasswordResetEmail(user, resolveBaseUrl(req)).catch(err => {
|
||||||
log('background password reset email failed', { error: String(err), email: user.email });
|
log('background password reset email failed', { error: String(err), email: user.email });
|
||||||
@@ -18483,7 +18560,15 @@ async function handleNewMessage(req, res, sessionId, userId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const model = resolvePlanModel(userPlan, body.model || session.model);
|
const model = resolvePlanModel(userPlan, body.model || session.model);
|
||||||
const estimatedTokens = estimateTokensFromText(content) + TOKEN_ESTIMATION_BUFFER; // include headroom for reply
|
|
||||||
|
const sessionHistory = (session.messages || []).slice(-20);
|
||||||
|
let historyTokens = 0;
|
||||||
|
sessionHistory.forEach((m) => {
|
||||||
|
if (m.content) historyTokens += estimateTokensFromText(m.content);
|
||||||
|
if (m.reply) historyTokens += estimateTokensFromText(m.reply);
|
||||||
|
});
|
||||||
|
const estimatedTokens = estimateTokensFromText(content) + TOKEN_ESTIMATION_BUFFER + (sessionHistory.length * TOKEN_HISTORY_OVERHEAD) + historyTokens;
|
||||||
|
|
||||||
const allowance = canConsumeTokens(session.userId, userPlan, estimatedTokens);
|
const allowance = canConsumeTokens(session.userId, userPlan, estimatedTokens);
|
||||||
if (!allowance.allowed) {
|
if (!allowance.allowed) {
|
||||||
const friendlyRemaining = allowance.remaining > 0 ? `${allowance.remaining.toLocaleString()} remaining` : 'no remaining balance';
|
const friendlyRemaining = allowance.remaining > 0 ? `${allowance.remaining.toLocaleString()} remaining` : 'no remaining balance';
|
||||||
@@ -20263,8 +20348,15 @@ async function routeInternal(req, res, url, pathname) {
|
|||||||
if (req.method === 'GET' && pathname === '/api/verify-email') return handleVerifyEmailApi(req, res, url);
|
if (req.method === 'GET' && pathname === '/api/verify-email') return handleVerifyEmailApi(req, res, url);
|
||||||
if (req.method === 'POST' && pathname === '/api/password/forgot') return handlePasswordResetRequest(req, res);
|
if (req.method === 'POST' && pathname === '/api/password/forgot') return handlePasswordResetRequest(req, res);
|
||||||
|
|
||||||
// Dev helper: preview branded email templates without sending
|
// Dev helper: preview branded email templates without sending (admin only)
|
||||||
if (req.method === 'GET' && pathname === '/debug/email/preview') {
|
if (req.method === 'GET' && pathname === '/debug/email/preview') {
|
||||||
|
const adminSession = requireAdminAuth(req, res);
|
||||||
|
if (!adminSession) return;
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
return sendJson(res, 403, { error: 'Debug endpoints disabled in production' });
|
||||||
|
}
|
||||||
|
|
||||||
const type = url.searchParams.get('type') || 'verification';
|
const type = url.searchParams.get('type') || 'verification';
|
||||||
const email = url.searchParams.get('email') || 'user@example.com';
|
const email = url.searchParams.get('email') || 'user@example.com';
|
||||||
const token = url.searchParams.get('token') || 'sample-token';
|
const token = url.searchParams.get('token') || 'sample-token';
|
||||||
@@ -20901,6 +20993,9 @@ async function bootstrap() {
|
|||||||
// Start memory cleanup scheduler
|
// Start memory cleanup scheduler
|
||||||
startMemoryCleanup();
|
startMemoryCleanup();
|
||||||
|
|
||||||
|
// Start pending payment cleanup scheduler
|
||||||
|
startPendingCleanup();
|
||||||
|
|
||||||
// Start periodic resource monitoring for analytics
|
// Start periodic resource monitoring for analytics
|
||||||
startPeriodicMonitoring();
|
startPeriodicMonitoring();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user