Fix 11 bugs: restore crash, draft leak, email failures, memory leaks

Critical fixes:
- sanitizeLoadedHitStats/VisitorStats: restore full state shape so a
  snapshot restore no longer crashes hit-counting middleware (missing
  byPathReal, byPathBot, byDayReal, byDayBot, botReasons, ipHashIndex)
- /questions/share/🆔 read state.questions only, not draft questions
- inbound-email: validate date with Number.isFinite before toISOString
- study-reminders: wrap each send in try/catch so one failure doesn't
  block remaining users; persist sent-markers after each success

Security:
- getClientIp: use req.ip (trust-proxy-resolved) instead of raw
  x-forwarded-for header to prevent IP spoofing
- env-snapshot.env: delete immediately after backup tar stream ends
  so secrets don't linger on disk between exports

Correctness / UX:
- contact form: email failures no longer 500 the user after the
  submission is already saved; log and fall through instead
- study-account profile: cap data URI avatar at 6 MB
- admin enrollment PATCH: validate slug against study catalog
- signup: return 503 at MAX_STUDY_USERS instead of silently dropping
  oldest accounts

Memory leaks:
- contactHits, downloadHits Maps: prune stale entries at 5000 entries
- resendEmailSubmissionIndex: trim to 2000 entries (oldest first)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-07-16 07:48:54 -04:00
parent f5e98826be
commit 1d43875e5a
11 changed files with 76 additions and 15 deletions
+14 -2
View File
@@ -65,6 +65,11 @@ function registerResendMessageForSubmission(submissionId, stream, sendResult) {
const resendMessageId = extractResendMessageId(sendResult)
if (!resendMessageId || !submissionId || !stream) return
state.resendEmailSubmissionIndex.set(resendMessageId, { submissionId, stream })
// Trim the index when it grows large; oldest entries are least likely to receive webhooks
if (state.resendEmailSubmissionIndex.size > 2000) {
const firstKey = state.resendEmailSubmissionIndex.keys().next().value
state.resendEmailSubmissionIndex.delete(firstKey)
}
upsertContactEmailStatus(submissionId, stream, { resendEmailId: resendMessageId })
}
@@ -82,6 +87,11 @@ function contactRateLimit(req, res, next) {
if (now - entry.start > windowMs) { entry.count = 0; entry.start = now }
entry.count += 1
contactHits.set(ip, entry)
// Prune stale entries to prevent unbounded growth
if (contactHits.size > 5000) {
const cutoff = now - windowMs
for (const [k, v] of contactHits) { if (v.start < cutoff) contactHits.delete(k) }
}
if (entry.count > 5) {
res.status(429).json({ message: 'Too many messages. Please wait a few minutes.' })
return
@@ -251,7 +261,8 @@ export function register(app) {
lastEventType: 'email.failed',
error: String(welcomeErr?.message ?? welcomeErr ?? 'unknown error').slice(0, 600),
})
throw welcomeErr
console.error('[contact] welcome email failed:', welcomeErr)
// Submission is already saved — don't 500 the user; fall through to admin notification.
}
} else if (shouldSendWelcome && USE_RESEND_AUTOMATION_WELCOME) {
upsertContactEmailStatus(submission.id, 'welcome', { status: 'automation-enabled', lastEventType: 'email.automation.enabled', error: null })
@@ -284,7 +295,8 @@ export function register(app) {
lastEventType: 'email.failed',
error: String(adminSendErr?.message ?? adminSendErr ?? 'unknown error').slice(0, 600),
})
throw adminSendErr
console.error('[contact] admin notification email failed:', adminSendErr)
// Submission is already saved — don't 500 the user.
}
res.json({ ok: true, welcomeSent, welcomeHandledByAutomation: shouldSendWelcome && USE_RESEND_AUTOMATION_WELCOME })