Add abuse protection to the ROI calculator form #24
12
.env.example
12
.env.example
@@ -7,4 +7,14 @@ AI_API_URL=https://integrate.api.nvidia.com/v1
|
|||||||
AI_API_KEY=your_nvidia_api_key_here
|
AI_API_KEY=your_nvidia_api_key_here
|
||||||
AI_MODEL=qwen/qwen3.5-397b-a17b
|
AI_MODEL=qwen/qwen3.5-397b-a17b
|
||||||
# Set to 'true' to enable detailed AI prompt/response logging
|
# Set to 'true' to enable detailed AI prompt/response logging
|
||||||
AI_DEBUG=false
|
AI_DEBUG=false
|
||||||
|
|
||||||
|
# Form abuse protection
|
||||||
|
# Cloudflare Turnstile (free): https://dash.cloudflare.com/?to=/:account/turnstile
|
||||||
|
# Leave blank to run without a visible CAPTCHA — the honeypot, signed form token
|
||||||
|
# and per-IP rate limits stay active either way.
|
||||||
|
TURNSTILE_SITE_KEY=
|
||||||
|
TURNSTILE_SECRET_KEY=
|
||||||
|
# Optional: stable secret for signing form tokens. If unset, a random one is
|
||||||
|
# generated per process (tokens simply stop validating across restarts).
|
||||||
|
FORM_TOKEN_SECRET=
|
||||||
103
app.js
103
app.js
@@ -34,10 +34,76 @@
|
|||||||
|
|
||||||
if (!overlay) return;
|
if (!overlay) return;
|
||||||
|
|
||||||
function open() { overlay.classList.add('open'); document.body.style.overflow = 'hidden'; }
|
// ── Abuse protection ───────────────────────────────────
|
||||||
|
// A signed, single-use token is fetched when the form is opened; the server
|
||||||
|
// uses it to prove the form was really loaded and that a human took at least a
|
||||||
|
// few seconds to fill it in. When Turnstile keys are configured server-side, a
|
||||||
|
// CAPTCHA widget is rendered too.
|
||||||
|
const captchaSlot = document.getElementById('calcCaptcha');
|
||||||
|
let formToken = null;
|
||||||
|
let captchaWidget = null;
|
||||||
|
let siteKeyPromise = null;
|
||||||
|
|
||||||
|
async function fetchFormToken() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/form-token', { cache: 'no-store' });
|
||||||
|
formToken = (await res.json()).token || null;
|
||||||
|
} catch (_) { formToken = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTurnstileScript() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (window.turnstile) return resolve();
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
||||||
|
s.async = true;
|
||||||
|
s.onload = resolve;
|
||||||
|
s.onerror = reject;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initCaptcha() {
|
||||||
|
if (!captchaSlot || captchaWidget !== null) return;
|
||||||
|
siteKeyPromise = siteKeyPromise || fetch('/api/form-config', { cache: 'no-store' })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(c => c.turnstileSiteKey)
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
const siteKey = await siteKeyPromise;
|
||||||
|
if (!siteKey) return; // CAPTCHA not configured — other layers still apply
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadTurnstileScript();
|
||||||
|
captchaWidget = window.turnstile.render(captchaSlot, {
|
||||||
|
sitekey: siteKey,
|
||||||
|
theme: 'light',
|
||||||
|
action: 'roi_calculator',
|
||||||
|
});
|
||||||
|
} catch (_) { /* widget unavailable — server decides whether to allow */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function captchaResponse() {
|
||||||
|
if (captchaWidget === null || !window.turnstile) return '';
|
||||||
|
return window.turnstile.getResponse(captchaWidget) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCaptcha() {
|
||||||
|
if (captchaWidget !== null && window.turnstile) window.turnstile.reset(captchaWidget);
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
overlay.classList.add('open');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
fetchFormToken(); // starts the minimum-fill-time clock
|
||||||
|
initCaptcha();
|
||||||
|
}
|
||||||
function close() { overlay.classList.remove('open'); document.body.style.overflow = ''; }
|
function close() { overlay.classList.remove('open'); document.body.style.overflow = ''; }
|
||||||
|
|
||||||
openBtn?.addEventListener('click', open);
|
openBtn?.addEventListener('click', open);
|
||||||
|
// Footer CTA also opens the modal (v2.js handles its analytics) — it needs the
|
||||||
|
// same form token and CAPTCHA set-up.
|
||||||
|
document.getElementById('openCalc2')?.addEventListener('click', open);
|
||||||
closeBtn?.addEventListener('click', close);
|
closeBtn?.addEventListener('click', close);
|
||||||
overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
|
overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
|
||||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
|
||||||
@@ -55,6 +121,12 @@
|
|||||||
const calcBtnText = submitBtn?.querySelector('.calc-btn-text');
|
const calcBtnText = submitBtn?.querySelector('.calc-btn-text');
|
||||||
const calcBtnLoading = submitBtn?.querySelector('.calc-btn-loading');
|
const calcBtnLoading = submitBtn?.querySelector('.calc-btn-loading');
|
||||||
|
|
||||||
|
function showCalcError(msg) {
|
||||||
|
if (!calcErr) return;
|
||||||
|
calcErr.textContent = msg;
|
||||||
|
calcErr.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
function setCalcLoading(on) {
|
function setCalcLoading(on) {
|
||||||
if (!submitBtn) return;
|
if (!submitBtn) return;
|
||||||
submitBtn.disabled = on;
|
submitBtn.disabled = on;
|
||||||
@@ -73,12 +145,19 @@
|
|||||||
const calcOptIn = document.getElementById('calcOptIn')?.checked ?? true;
|
const calcOptIn = document.getElementById('calcOptIn')?.checked ?? true;
|
||||||
|
|
||||||
if (!homesites || !annualIncome) {
|
if (!homesites || !annualIncome) {
|
||||||
calcErr.classList.remove('hidden');
|
showCalcError('Please fill in homesites and annual dues income to continue.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
calcErr.classList.add('hidden');
|
calcErr.classList.add('hidden');
|
||||||
setCalcLoading(true);
|
setCalcLoading(true);
|
||||||
|
|
||||||
|
// ── Abuse checks: server verifies the token, honeypot and CAPTCHA ──
|
||||||
|
const guardBody = {
|
||||||
|
formToken,
|
||||||
|
captchaToken: captchaResponse(),
|
||||||
|
hp_company_url: document.getElementById('hpCompanyUrl')?.value || '',
|
||||||
|
};
|
||||||
|
|
||||||
// ── Conservative investment assumptions ──
|
// ── Conservative investment assumptions ──
|
||||||
// Operating cash: depending on payment frequency, portion investable in high-yield savings
|
// Operating cash: depending on payment frequency, portion investable in high-yield savings
|
||||||
const opMultiplier = { monthly: 0.10, quarterly: 0.20, annually: 0.35 }[paymentFreq] || 0.10;
|
const opMultiplier = { monthly: 0.10, quarterly: 0.20, annually: 0.35 }[paymentFreq] || 0.10;
|
||||||
@@ -131,17 +210,35 @@
|
|||||||
|
|
||||||
// ── AI recommendation — call server to generate & save to DB (not displayed) ──
|
// ── AI recommendation — call server to generate & save to DB (not displayed) ──
|
||||||
try {
|
try {
|
||||||
await fetch('/api/calculate', {
|
const res = await fetch('/api/calculate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
...guardBody,
|
||||||
homesites, propertyType, annualIncome, paymentFreq, reserveFunds, interest2025,
|
homesites, propertyType, annualIncome, paymentFreq, reserveFunds, interest2025,
|
||||||
email: calcEmail, optIn: calcOptIn,
|
email: calcEmail, optIn: calcOptIn,
|
||||||
totalPotential, opInterest, resInterest,
|
totalPotential, opInterest, resInterest,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A blocked submission stops here — the estimate is not shown and nothing
|
||||||
|
// is stored. AI/service errors (502/503) fall through to the local result.
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (data.blocked) {
|
||||||
|
setCalcLoading(false);
|
||||||
|
showCalcError(data.error || 'We could not verify this submission. Please try again.');
|
||||||
|
resetCaptcha();
|
||||||
|
await fetchFormToken(); // tokens are single-use; issue a fresh one
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (_) { /* best-effort — DB save failed silently */ }
|
} catch (_) { /* best-effort — DB save failed silently */ }
|
||||||
|
|
||||||
|
// Token is spent on a successful submission; get another for a recalculation.
|
||||||
|
fetchFormToken();
|
||||||
|
resetCaptcha();
|
||||||
|
|
||||||
// ── Animate the main number ──
|
// ── Animate the main number ──
|
||||||
animateValue(document.getElementById('resultAmount'), 0, totalPotential);
|
animateValue(document.getElementById('resultAmount'), 0, totalPotential);
|
||||||
|
|
||||||
|
|||||||
@@ -509,8 +509,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Honeypot: hidden from humans, irresistible to bots. Leave it empty. -->
|
||||||
|
<div class="calc-hp" aria-hidden="true">
|
||||||
|
<label for="hpCompanyUrl">Company website</label>
|
||||||
|
<input type="text" id="hpCompanyUrl" name="hp_company_url" tabindex="-1" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="calc-error hidden" id="calcError">Please fill in homesites and annual dues income to continue.</p>
|
<p class="calc-error hidden" id="calcError">Please fill in homesites and annual dues income to continue.</p>
|
||||||
|
|
||||||
|
<!-- CAPTCHA widget — rendered only when Turnstile keys are configured -->
|
||||||
|
<div class="calc-captcha" id="calcCaptcha"></div>
|
||||||
|
|
||||||
<div class="calc-email-row">
|
<div class="calc-email-row">
|
||||||
<div class="calc-field calc-field--full">
|
<div class="calc-field calc-field--full">
|
||||||
<label for="calcEmail">Your email address <span class="calc-optional">(recommended)</span></label>
|
<label for="calcEmail">Your email address <span class="calc-optional">(recommended)</span></label>
|
||||||
|
|||||||
220
security.js
Normal file
220
security.js
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* HOA LedgerIQ — Form abuse protection
|
||||||
|
*
|
||||||
|
* Layered defence for public form endpoints (ROI calculator, lead capture):
|
||||||
|
*
|
||||||
|
* 1. Cloudflare Turnstile — real CAPTCHA, active when TURNSTILE_* keys are set.
|
||||||
|
* 2. Honeypot field — a hidden input humans never fill in.
|
||||||
|
* 3. Signed form token — proves the form was actually loaded, and enforces a
|
||||||
|
* minimum fill time (bots submit instantly).
|
||||||
|
* 4. Per-IP rate limiting — caps bursts and daily volume from one source.
|
||||||
|
*
|
||||||
|
* Layers 2–4 need no configuration and work on their own; adding Turnstile keys
|
||||||
|
* upgrades the protection to a full CAPTCHA challenge.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// ── Config ───────────────────────────────────────────────
|
||||||
|
const TURNSTILE_SITE_KEY = process.env.TURNSTILE_SITE_KEY || '';
|
||||||
|
const TURNSTILE_SECRET = process.env.TURNSTILE_SECRET_KEY || '';
|
||||||
|
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||||
|
|
||||||
|
// If no explicit secret is configured, generate one per process. Tokens then stop
|
||||||
|
// validating across restarts — harmless, the client just fetches a fresh one.
|
||||||
|
const FORM_SECRET = process.env.FORM_TOKEN_SECRET || crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
|
const MIN_FILL_MS = 4 * 1000; // faster than this is not a human
|
||||||
|
const MAX_TOKEN_AGE_MS = 2 * 60 * 60 * 1000; // tokens expire after 2 hours
|
||||||
|
|
||||||
|
// Sliding-window caps per IP: short burst window + daily ceiling.
|
||||||
|
const RATE_WINDOWS = [
|
||||||
|
{ windowMs: 10 * 60 * 1000, max: 5, label: '10 minutes' },
|
||||||
|
{ windowMs: 24 * 60 * 60 * 1000, max: 25, label: '24 hours' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const turnstileEnabled = Boolean(TURNSTILE_SITE_KEY && TURNSTILE_SECRET);
|
||||||
|
|
||||||
|
// ── Signed, single-use form tokens ───────────────────────
|
||||||
|
const usedTokens = new Map(); // token -> expiry ms
|
||||||
|
const hits = new Map(); // ip -> [timestamps]
|
||||||
|
|
||||||
|
function sign(payload) {
|
||||||
|
return crypto.createHmac('sha256', FORM_SECRET).update(payload).digest('hex').slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
function issueFormToken() {
|
||||||
|
const payload = `${Date.now()}.${crypto.randomBytes(9).toString('base64url')}`;
|
||||||
|
return `${payload}.${sign(payload)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyFormToken(token) {
|
||||||
|
if (typeof token !== 'string' || token.length > 200) {
|
||||||
|
return { ok: false, reason: 'missing_token' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) return { ok: false, reason: 'bad_token' };
|
||||||
|
|
||||||
|
const [tsRaw, nonce, sig] = parts;
|
||||||
|
const expected = sign(`${tsRaw}.${nonce}`);
|
||||||
|
if (sig.length !== expected.length ||
|
||||||
|
!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
|
||||||
|
return { ok: false, reason: 'bad_token' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const issuedAt = Number(tsRaw);
|
||||||
|
if (!Number.isFinite(issuedAt)) return { ok: false, reason: 'bad_token' };
|
||||||
|
|
||||||
|
const age = Date.now() - issuedAt;
|
||||||
|
if (age > MAX_TOKEN_AGE_MS || age < -60_000) return { ok: false, reason: 'expired_token' };
|
||||||
|
if (age < MIN_FILL_MS) return { ok: false, reason: 'too_fast' };
|
||||||
|
if (usedTokens.has(token)) return { ok: false, reason: 'replayed_token' };
|
||||||
|
|
||||||
|
usedTokens.set(token, Date.now() + MAX_TOKEN_AGE_MS);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rate limiting ────────────────────────────────────────
|
||||||
|
function clientIp(req) {
|
||||||
|
// Requires `app.set('trust proxy', ...)` when running behind nginx.
|
||||||
|
return req.ip || req.socket?.remoteAddress || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check the caps without consuming a slot. */
|
||||||
|
function checkRateLimit(ip) {
|
||||||
|
const now = Date.now();
|
||||||
|
const list = hits.get(ip) || [];
|
||||||
|
|
||||||
|
for (const { windowMs, max, label } of RATE_WINDOWS) {
|
||||||
|
const recent = list.filter(t => now - t < windowMs).length;
|
||||||
|
if (recent >= max) return { ok: false, reason: 'rate_limited', label };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record a successful submission against the caller's IP. */
|
||||||
|
function recordSubmission(ip) {
|
||||||
|
const now = Date.now();
|
||||||
|
const widest = Math.max(...RATE_WINDOWS.map(w => w.windowMs));
|
||||||
|
const list = (hits.get(ip) || []).filter(t => now - t < widest);
|
||||||
|
list.push(now);
|
||||||
|
hits.set(ip, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Turnstile ────────────────────────────────────────────
|
||||||
|
async function verifyTurnstile(token, ip) {
|
||||||
|
if (!turnstileEnabled) return { ok: true, skipped: true };
|
||||||
|
if (typeof token !== 'string' || !token) return { ok: false, reason: 'captcha_missing' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = new URLSearchParams({ secret: TURNSTILE_SECRET, response: token });
|
||||||
|
if (ip && ip !== 'unknown') body.set('remoteip', ip);
|
||||||
|
|
||||||
|
const resp = await fetch(TURNSTILE_VERIFY_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(8000),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
return { ok: false, reason: 'captcha_failed', codes: data['error-codes'] };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Turnstile verify error:', err.message);
|
||||||
|
// Fail closed: an unverifiable challenge is not a passed challenge.
|
||||||
|
return { ok: false, reason: 'captcha_unavailable' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Combined guard ───────────────────────────────────────
|
||||||
|
const MESSAGES = {
|
||||||
|
honeypot: 'Submission rejected.',
|
||||||
|
missing_token: 'Your session expired. Please reload the page and try again.',
|
||||||
|
bad_token: 'Your session expired. Please reload the page and try again.',
|
||||||
|
expired_token: 'Your session expired. Please reload the page and try again.',
|
||||||
|
replayed_token: 'This form was already submitted. Please reload the page to run another estimate.',
|
||||||
|
too_fast: 'That was a little too quick — please take a moment and try again.',
|
||||||
|
rate_limited: 'Too many submissions from this network. Please try again later.',
|
||||||
|
captcha_missing: 'Please complete the verification challenge.',
|
||||||
|
captcha_failed: 'Verification failed. Please try the challenge again.',
|
||||||
|
captcha_unavailable: 'Verification is temporarily unavailable. Please try again in a moment.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS = { rate_limited: 429, honeypot: 400 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run every protection layer for a public form POST.
|
||||||
|
* Returns { ok: true, ip } or { ok: false, status, error, reason }.
|
||||||
|
*/
|
||||||
|
async function guardSubmission(req, { honeypotField = 'hp_company_url' } = {}) {
|
||||||
|
const ip = clientIp(req);
|
||||||
|
const body = req.body ?? {};
|
||||||
|
|
||||||
|
const fail = ({ reason, label }) => ({
|
||||||
|
ok: false,
|
||||||
|
reason,
|
||||||
|
status: STATUS[reason] ?? 403,
|
||||||
|
error: label ? `${MESSAGES[reason]} (limit: ${label})` : MESSAGES[reason],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Honeypot — any value at all means a bot filled every field it found.
|
||||||
|
if (typeof body[honeypotField] === 'string' && body[honeypotField].trim() !== '') {
|
||||||
|
console.warn(`[abuse] honeypot tripped from ${ip}`);
|
||||||
|
return fail({ reason: 'honeypot' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Rate limit (checked before the outbound Turnstile call).
|
||||||
|
const rate = checkRateLimit(ip);
|
||||||
|
if (!rate.ok) {
|
||||||
|
console.warn(`[abuse] rate limit hit by ${ip}`);
|
||||||
|
return fail(rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Signed single-use token + minimum fill time.
|
||||||
|
const tok = verifyFormToken(body.formToken);
|
||||||
|
if (!tok.ok) {
|
||||||
|
console.warn(`[abuse] form token rejected (${tok.reason}) from ${ip}`);
|
||||||
|
return fail(tok);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. CAPTCHA.
|
||||||
|
const captcha = await verifyTurnstile(body.captchaToken, ip);
|
||||||
|
if (!captcha.ok) {
|
||||||
|
console.warn(`[abuse] captcha rejected (${captcha.reason}) from ${ip}`);
|
||||||
|
return fail(captcha);
|
||||||
|
}
|
||||||
|
|
||||||
|
recordSubmission(ip);
|
||||||
|
return { ok: true, ip };
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicConfig() {
|
||||||
|
return { turnstileSiteKey: turnstileEnabled ? TURNSTILE_SITE_KEY : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Housekeeping ─────────────────────────────────────────
|
||||||
|
const sweep = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [token, exp] of usedTokens) if (exp < now) usedTokens.delete(token);
|
||||||
|
|
||||||
|
const widest = Math.max(...RATE_WINDOWS.map(w => w.windowMs));
|
||||||
|
for (const [ip, list] of hits) {
|
||||||
|
const kept = list.filter(t => now - t < widest);
|
||||||
|
if (kept.length) hits.set(ip, kept); else hits.delete(ip);
|
||||||
|
}
|
||||||
|
}, 10 * 60 * 1000);
|
||||||
|
sweep.unref?.();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
turnstileEnabled,
|
||||||
|
issueFormToken,
|
||||||
|
guardSubmission,
|
||||||
|
publicConfig,
|
||||||
|
clientIp,
|
||||||
|
};
|
||||||
30
server.js
30
server.js
@@ -16,6 +16,8 @@ const express = require('express');
|
|||||||
const Database = require('better-sqlite3');
|
const Database = require('better-sqlite3');
|
||||||
const OpenAI = require('openai');
|
const OpenAI = require('openai');
|
||||||
|
|
||||||
|
const security = require('./security');
|
||||||
|
|
||||||
// ── Config ──────────────────────────────────────────────
|
// ── Config ──────────────────────────────────────────────
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
@@ -107,9 +109,22 @@ const getAllLeads = db.prepare(`
|
|||||||
|
|
||||||
// ── App ───────────────────────────────────────────────────
|
// ── App ───────────────────────────────────────────────────
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.set('trust proxy', 1); // behind nginx — needed for correct client IPs
|
||||||
|
app.use(express.json({ limit: '32kb' }));
|
||||||
app.use(express.static(__dirname)); // serve the marketing site
|
app.use(express.static(__dirname)); // serve the marketing site
|
||||||
|
|
||||||
|
// GET /api/form-config — public config the forms need (CAPTCHA site key)
|
||||||
|
app.get('/api/form-config', (_req, res) => {
|
||||||
|
res.set('Cache-Control', 'no-store');
|
||||||
|
res.json(security.publicConfig());
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/form-token — single-use, signed token proving the form was loaded
|
||||||
|
app.get('/api/form-token', (_req, res) => {
|
||||||
|
res.set('Cache-Control', 'no-store');
|
||||||
|
res.json({ token: security.issueFormToken() });
|
||||||
|
});
|
||||||
|
|
||||||
// POST /api/leads — capture a new preview sign-up
|
// POST /api/leads — capture a new preview sign-up
|
||||||
app.post('/api/leads', (req, res) => {
|
app.post('/api/leads', (req, res) => {
|
||||||
const { firstName, lastName, email, orgName, state, role, unitCount, betaInterest, source } = req.body ?? {};
|
const { firstName, lastName, email, orgName, state, role, unitCount, betaInterest, source } = req.body ?? {};
|
||||||
@@ -193,9 +208,11 @@ app.post('/api/calculate', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!aiClient) {
|
// ── Abuse protection: CAPTCHA, honeypot, form token, rate limit ──
|
||||||
saveCalcSubmission(null);
|
// Runs before anything is written to the DB or sent to the AI provider.
|
||||||
return res.status(503).json({ error: 'AI service not configured.' });
|
const guard = await security.guardSubmission(req);
|
||||||
|
if (!guard.ok) {
|
||||||
|
return res.status(guard.status).json({ error: guard.error, blocked: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -207,6 +224,11 @@ app.post('/api/calculate', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'homesites and annualIncome are required.' });
|
return res.status(400).json({ error: 'homesites and annualIncome are required.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!aiClient) {
|
||||||
|
saveCalcSubmission(null);
|
||||||
|
return res.status(503).json({ error: 'AI service not configured.' });
|
||||||
|
}
|
||||||
|
|
||||||
const fmt = n => '$' + Math.round(n).toLocaleString();
|
const fmt = n => '$' + Math.round(n).toLocaleString();
|
||||||
const typeLabel = { sfh: 'single-family home', townhomes: 'townhome', condos: 'condo', mixed: 'mixed-use' }[propertyType] || '';
|
const typeLabel = { sfh: 'single-family home', townhomes: 'townhome', condos: 'condo', mixed: 'mixed-use' }[propertyType] || '';
|
||||||
const freqDivisor = { monthly: 12, quarterly: 4, annually: 1 }[paymentFreq] || 12;
|
const freqDivisor = { monthly: 12, quarterly: 4, annually: 1 }[paymentFreq] || 12;
|
||||||
|
|||||||
17
styles.css
17
styles.css
@@ -821,6 +821,23 @@ a.feature-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-lg)
|
|||||||
}
|
}
|
||||||
.input-prefix-wrap input { padding-left: 28px; }
|
.input-prefix-wrap input { padding-left: 28px; }
|
||||||
.calc-error { color: var(--red); font-size: 13px; margin: 8px 0; font-weight: 500; }
|
.calc-error { color: var(--red); font-size: 13px; margin: 8px 0; font-weight: 500; }
|
||||||
|
|
||||||
|
/* Honeypot — visually and semantically hidden, but still fillable by bots.
|
||||||
|
Deliberately not `display:none`, which the better bots skip. */
|
||||||
|
.calc-hp {
|
||||||
|
position: absolute !important;
|
||||||
|
left: -9999px;
|
||||||
|
top: -9999px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CAPTCHA widget slot — collapsed until a challenge is actually rendered */
|
||||||
|
.calc-captcha:empty { display: none; }
|
||||||
|
.calc-captcha { margin: 12px 0 0; display: flex; justify-content: center; }
|
||||||
.calc-submit-btn { width: 100%; justify-content: center; margin-top: 16px; }
|
.calc-submit-btn { width: 100%; justify-content: center; margin-top: 16px; }
|
||||||
.calc-fine {
|
.calc-fine {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|||||||
Reference in New Issue
Block a user