diff --git a/A_Study_of_Titus.pdf b/A_Study_of_Titus.pdf new file mode 100644 index 0000000..07628c3 Binary files /dev/null and b/A_Study_of_Titus.pdf differ diff --git a/Dockerfile b/Dockerfile index 05bf735..5745876 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,7 @@ RUN npm ci --omit=dev COPY --from=build /app/dist ./dist COPY server.js ./server.js +COPY --from=build /app/A_Study_of_Titus.pdf ./A_Study_of_Titus.pdf # Copy seed data to a separate directory so the entrypoint can seed /app/data # only when no live data exists yet — upgrades never overwrite existing data. diff --git a/README.md b/README.md index 8f5a3ec..eb24364 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,14 @@ Deployment note: - To keep Admin saves working on the internet, deploy with the Node API (`server.js`) and writable server storage for `data/admin-content.json`. +Titus study download gate: + +- The site now gates Titus study downloads behind a name/email form. +- Default source file is `A_Study_of_Titus.pdf` in the project root. +- Override source path with `TITUS_STUDY_FILE` (relative to project root or absolute). +- Override downloaded filename with `TITUS_STUDY_DOWNLOAD_NAME`. +- When the form checkbox is left enabled (default), contacts are synced to Resend using the same contact sync flow as the contact form. + Useful container commands: ```bash diff --git a/server.js b/server.js index 021184e..ff0382b 100644 --- a/server.js +++ b/server.js @@ -34,6 +34,10 @@ const CHATBOT_FILE = path.join(DATA_DIR, 'chatbot-content.json') const BACKUP_DIR = path.join(DATA_DIR, 'backups') const DIST_DIR = path.join(__dirname, 'dist') const INDEX_FILE = path.join(DIST_DIR, 'index.html') +const TITUS_STUDY_FILE = process.env.TITUS_STUDY_FILE + ? path.resolve(__dirname, process.env.TITUS_STUDY_FILE) + : path.join(__dirname, 'A_Study_of_Titus.pdf') +const TITUS_STUDY_DOWNLOAD_NAME = process.env.TITUS_STUDY_DOWNLOAD_NAME ?? 'A_Study_of_Titus.pdf' const EMPTY_HIT_STATS = { totalHits: 0, @@ -68,6 +72,8 @@ const EMPTY_VISITOR_STATS = { } const MAX_CONTACT_SUBMISSIONS = 5000 +const DOWNLOAD_TOKEN_TTL_MS = 10 * 60 * 1000 +const titusDownloadTokens = new Map() const MAX_QUESTIONS = 1000 let visitorStats = { ...EMPTY_VISITOR_STATS } @@ -267,6 +273,57 @@ function addContactSubmission({ name, email, message, messageType, subscribe }) return submission } +async function syncContactToResend(name, email) { + if (!process.env.RESEND_API_KEY) return + + const { firstName, lastName } = splitName(name) + const contactResend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY) + + try { + const { error: contactError } = await contactResend.contacts.create({ + email, + firstName, + lastName, + unsubscribed: false, + ...(process.env.RESEND_SEGMENT_ID + ? { segments: [{ id: process.env.RESEND_SEGMENT_ID }] } + : {}), + }) + + if (contactError) { + const { error: updateError } = await contactResend.contacts.update({ + email, + firstName, + lastName, + unsubscribed: false, + }) + + if (updateError) { + console.error('[resend] contact sync error:', updateError) + } + } + } catch (err) { + console.error('[resend] contact sync exception:', err) + } +} + +function createTitusDownloadToken(email) { + const token = randomUUID() + titusDownloadTokens.set(token, { + email, + expiresAt: Date.now() + DOWNLOAD_TOKEN_TTL_MS, + }) + return token +} + +function consumeTitusDownloadToken(token) { + const entry = titusDownloadTokens.get(token) + if (!entry) return false + titusDownloadTokens.delete(token) + if (entry.expiresAt <= Date.now()) return false + return true +} + function sanitizeUserAgent(userAgent) { if (!userAgent || typeof userAgent !== 'string') return 'unknown' return userAgent.trim().slice(0, 300) || 'unknown' @@ -1042,6 +1099,7 @@ app.use((req, res, next) => { // Rate-limit contact submissions: max 5 per IP per 10 minutes const contactHits = new Map() +const downloadHits = new Map() function contactRateLimit(req, res, next) { const ip = req.ip ?? 'unknown' const now = Date.now() @@ -1060,9 +1118,99 @@ function contactRateLimit(req, res, next) { next() } +function studyDownloadRateLimit(req, res, next) { + const ip = req.ip ?? 'unknown' + const now = Date.now() + const windowMs = 10 * 60 * 1000 + const entry = downloadHits.get(ip) ?? { count: 0, start: now } + if (now - entry.start > windowMs) { + entry.count = 0 + entry.start = now + } + entry.count += 1 + downloadHits.set(ip, entry) + if (entry.count > 10) { + res.status(429).json({ message: 'Too many download requests. Please wait a few minutes.' }) + return + } + next() +} + +app.post('/api/study-downloads/titus', studyDownloadRateLimit, async (req, res) => { + try { + const { firstName, lastName, email, subscribe, _honey } = req.body ?? {} + + if (_honey) { + res.json({ ok: true }) + return + } + + if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) { + res.status(400).json({ message: 'First name is required.' }) + return + } + + if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) { + res.status(400).json({ message: 'Last name is required.' }) + return + } + + if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) { + res.status(400).json({ message: 'A valid email address is required.' }) + return + } + + try { + await stat(TITUS_STUDY_FILE) + } catch { + res.status(503).json({ message: 'The Titus study file is not configured yet.' }) + return + } + + const trimmedFirstName = firstName.trim() + const trimmedLastName = lastName.trim() + const trimmedName = `${trimmedFirstName} ${trimmedLastName}`.trim() + const trimmedEmail = email.trim() + const wantsSubscribe = subscribe !== false + + addContactSubmission({ + name: trimmedName, + email: trimmedEmail, + message: 'Requested Titus study download.', + messageType: 'general', + subscribe: wantsSubscribe, + }) + + if (wantsSubscribe) { + await syncContactToResend(trimmedName, trimmedEmail) + } + + const token = createTitusDownloadToken(trimmedEmail) + res.json({ ok: true, downloadUrl: `/api/study-downloads/titus/file?token=${encodeURIComponent(token)}` }) + } catch (err) { + console.error('[study-download] request error:', err) + res.status(500).json({ message: 'Failed to process your request. Please try again.' }) + } +}) + +app.get('/api/study-downloads/titus/file', async (req, res) => { + const token = typeof req.query?.token === 'string' ? req.query.token : '' + if (!token || !consumeTitusDownloadToken(token)) { + res.status(403).json({ message: 'Invalid or expired download link. Submit the form again.' }) + return + } + + try { + await stat(TITUS_STUDY_FILE) + res.download(TITUS_STUDY_FILE, TITUS_STUDY_DOWNLOAD_NAME) + } catch { + res.status(503).json({ message: 'The Titus study file is not configured yet.' }) + } +}) + app.post('/api/contact', contactRateLimit, async (req, res) => { try { - const { name, email, message, messageType, subscribe, _honey } = req.body ?? {} + const { firstName, lastName, email, message, messageType, subscribe, _honey } = req.body ?? {} // Honeypot — silently discard if filled by a bot if (_honey) { @@ -1070,8 +1218,12 @@ app.post('/api/contact', contactRateLimit, async (req, res) => { return } - if (!name || typeof name !== 'string' || name.trim().length < 1 || name.trim().length > 200) { - res.status(400).json({ message: 'Name is required.' }) + if (!firstName || typeof firstName !== 'string' || firstName.trim().length < 1 || firstName.trim().length > 100) { + res.status(400).json({ message: 'First name is required.' }) + return + } + if (!lastName || typeof lastName !== 'string' || lastName.trim().length < 1 || lastName.trim().length > 100) { + res.status(400).json({ message: 'Last name is required.' }) return } if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) { @@ -1089,7 +1241,7 @@ app.post('/api/contact', contactRateLimit, async (req, res) => { return } - const trimmedName = name.trim() + const trimmedName = `${firstName.trim()} ${lastName.trim()}`.trim() const trimmedEmail = email.trim() const trimmedMessage = message.trim() const normalizedMessageType = normalizeMessageType(messageType) @@ -1126,35 +1278,7 @@ app.post('/api/contact', contactRateLimit, async (req, res) => { const resend = new Resend(process.env.RESEND_API_KEY) if (subscribe === true) { - const { firstName, lastName } = splitName(trimmedName) - const contactResend = new Resend(process.env.RESEND_CONTACTS_API_KEY ?? process.env.RESEND_API_KEY) - - try { - const { error: contactError } = await contactResend.contacts.create({ - email: trimmedEmail, - firstName, - lastName, - unsubscribed: false, - ...(process.env.RESEND_SEGMENT_ID - ? { segments: [{ id: process.env.RESEND_SEGMENT_ID }] } - : {}), - }) - - if (contactError) { - const { error: updateError } = await contactResend.contacts.update({ - email: trimmedEmail, - firstName, - lastName, - unsubscribed: false, - }) - - if (updateError) { - console.error('[contact] contact sync error:', updateError) - } - } - } catch (contactSyncErr) { - console.error('[contact] contact sync exception:', contactSyncErr) - } + await syncContactToResend(trimmedName, trimmedEmail) } const { error } = await resend.emails.send({ diff --git a/src/App.css b/src/App.css index f0a1c8d..986c3e8 100644 --- a/src/App.css +++ b/src/App.css @@ -1144,6 +1144,69 @@ padding: 0; } +.guide-actions { + margin-top: 0.9rem; +} + +.study-download-form { + margin-top: 0.8rem; + background: #121212; + border: 1px solid rgba(201, 168, 76, 0.22); + border-radius: 14px; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.study-download-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.85rem; +} + +.study-download-form label { + display: flex; + flex-direction: column; + gap: 0.4rem; + font-family: var(--brand-font-body); + font-weight: 500; + font-size: 0.82rem; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--brand-gold); +} + +.study-download-form input { + background: #0c0c0c; + border: 1px solid rgba(201, 168, 76, 0.25); + border-radius: 8px; + color: var(--brand-warm-white); + font-family: var(--brand-font-body); + font-size: 1rem; + font-weight: 300; + padding: 0.7rem 0.85rem; + outline: none; + transition: border-color 200ms; +} + +.study-download-form input:focus { + border-color: rgba(201, 168, 76, 0.7); +} + +.study-download-form .btn-primary { + justify-content: center; + width: 100%; + margin-top: 0.2rem; +} + +.study-download-success { + font-family: var(--brand-font-body); + font-size: 0.95rem; + color: #97cd85; + margin: 0; +} + /* ── Admin page ── */ .admin-page { min-height: 100vh; @@ -2231,6 +2294,10 @@ margin: 0 auto; } + .study-download-grid { + grid-template-columns: 1fr; + } + .admin-content-summary { grid-template-columns: 1fr; } diff --git a/src/App.tsx b/src/App.tsx index 515ca40..8fd789d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -381,7 +381,7 @@ function QASection() { function ContactForm() { const navigate = useNavigate() - const [fields, setFields] = useState({ name: '', email: '', message: '', messageType: 'question' }) + const [fields, setFields] = useState({ firstName: '', lastName: '', email: '', message: '', messageType: 'question' }) const [subscribe, setSubscribe] = useState(true) const [honey, setHoney] = useState('') const [status, setStatus] = useState<'idle' | 'submitting' | 'error'>('idle') @@ -430,8 +430,12 @@ function ContactForm() { onChange={e => setHoney(e.target.value)} /> +