223 lines
8.5 KiB
JavaScript
223 lines
8.5 KiB
JavaScript
import express from 'express'
|
|
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { Resend } from 'resend'
|
|
|
|
function escapeHtml(value) {
|
|
return String(value)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
}
|
|
|
|
function splitName(fullName) {
|
|
const parts = fullName.trim().split(/\s+/).filter(Boolean)
|
|
return {
|
|
firstName: parts[0] ?? '',
|
|
lastName: parts.slice(1).join(' '),
|
|
}
|
|
}
|
|
|
|
const __filename = fileURLToPath(import.meta.url)
|
|
const __dirname = path.dirname(__filename)
|
|
const DATA_DIR = path.join(__dirname, 'data')
|
|
const DATA_FILE = path.join(DATA_DIR, 'admin-content.json')
|
|
const DIST_DIR = path.join(__dirname, 'dist')
|
|
const INDEX_FILE = path.join(DIST_DIR, 'index.html')
|
|
|
|
const app = express()
|
|
app.use(express.json({ limit: '10mb' }))
|
|
|
|
app.get('/api/admin-content', async (_req, res) => {
|
|
try {
|
|
const raw = await readFile(DATA_FILE, 'utf8')
|
|
const parsed = JSON.parse(raw)
|
|
res.json(parsed)
|
|
} catch {
|
|
res.status(404).json({ message: 'No saved admin content file yet.' })
|
|
}
|
|
})
|
|
|
|
app.put('/api/admin-content', async (req, res) => {
|
|
try {
|
|
const { siteContent } = req.body ?? {}
|
|
|
|
if (!siteContent || typeof siteContent !== 'object' || Array.isArray(siteContent)) {
|
|
res.status(400).json({ message: 'Invalid payload: siteContent must be an object.' })
|
|
return
|
|
}
|
|
|
|
await mkdir(DATA_DIR, { recursive: true })
|
|
await writeFile(
|
|
DATA_FILE,
|
|
JSON.stringify({ siteContent, updatedAt: new Date().toISOString() }, null, 2),
|
|
'utf8',
|
|
)
|
|
|
|
res.json({ ok: true })
|
|
} catch {
|
|
res.status(500).json({ message: 'Failed to persist admin content.' })
|
|
}
|
|
})
|
|
|
|
// Rate-limit contact submissions: max 5 per IP per 10 minutes
|
|
const contactHits = new Map()
|
|
function contactRateLimit(req, res, next) {
|
|
const ip = req.ip ?? 'unknown'
|
|
const now = Date.now()
|
|
const windowMs = 10 * 60 * 1000
|
|
const entry = contactHits.get(ip) ?? { count: 0, start: now }
|
|
if (now - entry.start > windowMs) {
|
|
entry.count = 0
|
|
entry.start = now
|
|
}
|
|
entry.count += 1
|
|
contactHits.set(ip, entry)
|
|
if (entry.count > 5) {
|
|
res.status(429).json({ message: 'Too many messages. Please wait a few minutes.' })
|
|
return
|
|
}
|
|
next()
|
|
}
|
|
|
|
app.post('/api/contact', contactRateLimit, async (req, res) => {
|
|
try {
|
|
const { name, email, message, subscribe, _honey } = req.body ?? {}
|
|
|
|
// Honeypot — silently discard if filled by a bot
|
|
if (_honey) {
|
|
res.json({ ok: true })
|
|
return
|
|
}
|
|
|
|
if (!name || typeof name !== 'string' || name.trim().length < 1 || name.trim().length > 200) {
|
|
res.status(400).json({ message: '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
|
|
}
|
|
if (!message || typeof message !== 'string' || message.trim().length < 5 || message.trim().length > 3000) {
|
|
res.status(400).json({ message: 'Message must be between 5 and 3000 characters.' })
|
|
return
|
|
}
|
|
|
|
if (!process.env.RESEND_API_KEY) {
|
|
console.error('[contact] RESEND_API_KEY env var not set')
|
|
res.status(503).json({ message: 'The contact form is not yet configured on the server.' })
|
|
return
|
|
}
|
|
|
|
const trimmedName = name.trim()
|
|
const trimmedEmail = email.trim()
|
|
const trimmedMessage = message.trim()
|
|
const submittedAt = new Date().toLocaleString('en-US', {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short',
|
|
})
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
const { error } = await resend.emails.send({
|
|
from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate <hello@versebyversewithnate.us>',
|
|
to: [process.env.RESEND_TO ?? 'vbvwithnate@outlook.com'],
|
|
replyTo: trimmedEmail,
|
|
subject: `Verse by Verse contact form: ${trimmedName}`,
|
|
text:
|
|
`New contact form submission\n\n` +
|
|
`Name: ${trimmedName}\n` +
|
|
`Email: ${trimmedEmail}\n` +
|
|
`Submitted: ${submittedAt}\n\n` +
|
|
`Message:\n${trimmedMessage}`,
|
|
html:
|
|
`<div style="background:#f5f1e8;padding:24px;font-family:Georgia,serif;color:#201a10;">` +
|
|
`<div style="max-width:680px;margin:0 auto;background:#ffffff;border:1px solid #e1d3b2;border-radius:14px;overflow:hidden;">` +
|
|
`<div style="background:#111111;padding:20px 24px;border-bottom:3px solid #c8860a;">` +
|
|
`<div style="font-family:Arial,sans-serif;font-size:12px;letter-spacing:0.32em;text-transform:uppercase;color:#c8860a;">Verse by Verse with Nate</div>` +
|
|
`<h1 style="margin:10px 0 0;color:#f4ead5;font-size:28px;line-height:1.2;">New Contact Form Submission</h1>` +
|
|
`</div>` +
|
|
`<div style="padding:24px;">` +
|
|
`<p style="margin:0 0 18px;font-family:Arial,sans-serif;font-size:15px;line-height:1.6;color:#57452b;">A new message was sent from the website contact form. Reply directly to this email to respond to <strong>${escapeHtml(trimmedName)}</strong>.</p>` +
|
|
`<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;margin-bottom:20px;">` +
|
|
`<tr>` +
|
|
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Name</td>` +
|
|
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;">${escapeHtml(trimmedName)}</td>` +
|
|
`</tr>` +
|
|
`<tr>` +
|
|
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;border-bottom:1px solid #efe4cc;">Email</td>` +
|
|
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;border-bottom:1px solid #efe4cc;"><a href="mailto:${escapeHtml(trimmedEmail)}" style="color:#8f5f05;text-decoration:none;">${escapeHtml(trimmedEmail)}</a></td>` +
|
|
`</tr>` +
|
|
`<tr>` +
|
|
`<td style="width:120px;padding:10px 0;font-family:Arial,sans-serif;font-size:13px;font-weight:700;color:#8a6d35;">Submitted</td>` +
|
|
`<td style="padding:10px 0;font-family:Arial,sans-serif;font-size:15px;color:#201a10;">${escapeHtml(submittedAt)}</td>` +
|
|
`</tr>` +
|
|
`</table>` +
|
|
`<div style="background:#fbf7ef;border:1px solid #efe4cc;border-radius:12px;padding:18px 20px;">` +
|
|
`<div style="margin:0 0 10px;font-family:Arial,sans-serif;font-size:13px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:#8a6d35;">Message</div>` +
|
|
`<div style="font-family:Arial,sans-serif;font-size:15px;line-height:1.7;color:#201a10;white-space:pre-wrap;">${escapeHtml(trimmedMessage)}</div>` +
|
|
`</div>` +
|
|
`</div>` +
|
|
`</div>` +
|
|
`</div>`,
|
|
})
|
|
if (error) throw error
|
|
|
|
res.json({ ok: true })
|
|
} catch (err) {
|
|
console.error('[contact] send error:', err)
|
|
res.status(500).json({ message: 'Failed to send your message. Please try again or email us directly.' })
|
|
}
|
|
})
|
|
|
|
app.use(express.static(DIST_DIR))
|
|
|
|
app.use(async (_req, res) => {
|
|
try {
|
|
const html = await readFile(INDEX_FILE, 'utf8')
|
|
res.type('html').send(html)
|
|
} catch {
|
|
res.status(503).send('Frontend build not found. Run "npm run build" first.')
|
|
}
|
|
})
|
|
|
|
const PORT = Number(process.env.PORT ?? 4173)
|
|
app.listen(PORT, () => {
|
|
console.log(`Portfolio app listening on http://localhost:${PORT}`)
|
|
})
|