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, ''') } 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, _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) const { error } = await resend.emails.send({ from: process.env.RESEND_FROM ?? 'Verse by Verse with Nate ', 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: `
` + `
` + `
` + `
Verse by Verse with Nate
` + `

New Contact Form Submission

` + `
` + `
` + `

A new message was sent from the website contact form. Reply directly to this email to respond to ${escapeHtml(trimmedName)}.

` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `` + `
Name${escapeHtml(trimmedName)}
Email${escapeHtml(trimmedEmail)}
Submitted${escapeHtml(submittedAt)}
` + `
` + `
Message
` + `
${escapeHtml(trimmedMessage)}
` + `
` + `
` + `
` + `
`, }) 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}`) })