chris aee1f10179 Sync native contact form to main-site, replace iframe
Replaces the third-party iframe form on both the homepage and contact page
with the self-hosted form: drag-and-drop photo upload, honeypot, rate
limiting, inline validation, auto-reply email. Adds multer/sharp/nodemailer
dependencies and the /api/contact endpoint to server.js.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 20:32:05 -04:00

222 lines
9.1 KiB
JavaScript

// Load environment variables from .env file for development
if (process.env.NODE_ENV !== 'production') {
require('dotenv').config();
}
const express = require('express');
const bodyParser = require('body-parser');
const fs = require('fs');
const path = require('path');
const cors = require('cors');
const multer = require('multer');
const sharp = require('sharp');
const nodemailer = require('nodemailer');
const app = express();
const port = 3050;
// Admin password (shared secret). Defaults to "balloons" for development if not provided.
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'balloons';
// --- Production Security Check ---
if (process.env.NODE_ENV === 'production' && (!ADMIN_PASSWORD || ADMIN_PASSWORD === "balloons")) {
console.warn(`
****************************************************************
** WARNING: ADMIN_PASSWORD is not set or is insecure in production. **
** If the admin UI is already behind auth, this may be acceptable. **
** Otherwise, set a strong ADMIN_PASSWORD environment variable. **
****************************************************************
`);
}
// --- Middleware Setup ---
// More explicit CORS configuration to allow all origins
app.use(cors({
origin: '*'
}));
app.use(bodyParser.json());
// --- Contact form rate limiter (in-memory, 5 submissions per IP per 15 min) ---
const contactRateLimit = new Map();
setInterval(() => {
const now = Date.now();
for (const [ip, r] of contactRateLimit) {
if (now > r.resetAt) contactRateLimit.delete(ip);
}
}, 3_600_000);
// --- Email transporter ---
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '587'),
secure: process.env.SMTP_SECURE === 'true',
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
// --- File upload (memory storage, max 3 files) ---
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (!file.mimetype.startsWith('image/')) {
return cb(new Error('Only image files are allowed'));
}
cb(null, true);
},
});
// --- API Routes ---
const apiRouter = express.Router();
function requireAuth(req, res, next) {
const auth = req.headers['authorization'] || '';
const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
if (!token || token !== ADMIN_PASSWORD) {
return res.status(401).json({ success: false, message: 'Unauthorized' });
}
next();
}
apiRouter.post('/update-status', requireAuth, (req, res) => {
console.log(`[${new Date().toISOString()}] Received request for /api/update-status`);
const { data } = req.body;
if (!data) {
return res.status(400).json({ success: false, message: 'Bad Request: No data provided.' });
}
const jsonString = JSON.stringify(data, null, 4);
const filePath = path.join(__dirname, 'update.json');
fs.writeFile(filePath, jsonString, (err) => {
if (err) {
console.error(`[${new Date().toISOString()}] Error writing to update.json:`, err);
return res.status(500).json({ success: false, message: 'Internal Server Error: Could not write to file.' });
}
console.log(`[${new Date().toISOString()}] update.json was successfully updated.`);
res.json({ success: true, message: 'Status updated successfully.' });
});
});
apiRouter.post('/contact', upload.array('photos', 3), async (req, res) => {
// Rate limiting
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
const now = Date.now();
const rl = contactRateLimit.get(ip) || { count: 0, resetAt: now + 15 * 60 * 1000 };
if (now > rl.resetAt) { rl.count = 0; rl.resetAt = now + 15 * 60 * 1000; }
rl.count++;
contactRateLimit.set(ip, rl);
if (rl.count > 5) {
return res.status(429).json({ success: false, message: 'Too many submissions. Please wait a few minutes and try again.' });
}
// Honeypot — silently succeed so bots think they won
if (req.body.website) {
return res.json({ success: true });
}
const { name, email, phone, message, eventType, eventDate } = req.body;
if (!name || !email || !phone || !message) {
return res.status(400).json({ success: false, message: 'Please fill in all required fields.' });
}
const attachments = [];
for (const file of (req.files || [])) {
const webpBuffer = await sharp(file.buffer).webp({ quality: 85 }).toBuffer();
const baseName = path.parse(file.originalname).name;
attachments.push({ filename: `${baseName}.webp`, content: webpBuffer, contentType: 'image/webp' });
}
function formatDate(str) {
if (!str) return null;
const [y, m, d] = str.split('-');
const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
return `${months[parseInt(m, 10) - 1]} ${parseInt(d, 10)}, ${y}`;
}
const eventDateFormatted = formatDate(eventDate);
const eventLines = [
eventType ? `<p><strong>Event Type:</strong> ${eventType}</p>` : '',
eventDateFormatted ? `<p><strong>Event Date:</strong> ${eventDateFormatted}</p>` : '',
].join('');
const eventText = [
eventType ? `Event Type: ${eventType}` : '',
eventDateFormatted ? `Event Date: ${eventDateFormatted}` : '',
].filter(Boolean).join('\n');
const notifyMail = {
from: `"Beach Party Balloons" <${process.env.SMTP_USER}>`,
replyTo: `"${name}" <${email}>`,
to: process.env.CONTACT_TO,
subject: `New Inquiry from ${name}${eventType ? `${eventType}` : ''}`,
text: `Name: ${name}\nEmail: ${email}\nPhone: ${phone}\n${eventText}\n\nMessage:\n${message}`,
html: `<p><strong>Name:</strong> ${name}</p>
<p><strong>Email:</strong> <a href="mailto:${email}">${email}</a></p>
<p><strong>Phone:</strong> <a href="tel:${phone.replace(/\D/g,'')}">${phone}</a></p>
${eventLines}
<hr>
<p><strong>Message:</strong></p>
<p>${message.replace(/\n/g, '<br>')}</p>`,
attachments,
};
const autoReply = {
from: `"Beach Party Balloons" <${process.env.SMTP_USER}>`,
to: `"${name}" <${email}>`,
subject: `We got your message — Beach Party Balloons`,
text: `Hi ${name},\n\nThanks for reaching out to Beach Party Balloons! We've received your message and will get back to you as soon as possible.\n\nHere's a copy of what you sent:\n${eventText}${eventText ? '\n' : ''}\nMessage:\n${message}\n\n— The Beach Party Balloons Team\n554 Boston Post Road, Milford, CT 06460\n203.283.5626`,
html: `<p>Hi ${name},</p>
<p>Thanks for reaching out to <strong>Beach Party Balloons</strong>! We've received your message and will get back to you as soon as possible.</p>
<p>Here's a copy of what you sent:</p>
${eventLines}
<p><strong>Message:</strong><br>${message.replace(/\n/g, '<br>')}</p>
<hr>
<p><em>Beach Party Balloons</em><br>554 Boston Post Road, Milford, CT 06460<br><a href="tel:2032835626">203.283.5626</a></p>`,
};
try {
await transporter.sendMail(notifyMail);
transporter.sendMail(autoReply).catch(err =>
console.error(`[${new Date().toISOString()}] Auto-reply failed:`, err)
);
res.json({ success: true });
} catch (err) {
console.error(`[${new Date().toISOString()}] Contact form mail error:`, err);
res.status(500).json({ success: false, message: 'Failed to send message. Please try again or email us directly.' });
}
});
// Mount the API router under the /api path
app.use('/api', apiRouter);
// --- Static Files ---
const staticCacheOptions = {
maxAge: process.env.NODE_ENV === 'production' ? '30d' : 0,
setHeaders: (res, filePath) => {
if (filePath.endsWith('.html') || filePath.endsWith('update.json')) {
// Never cache HTML or live data
res.setHeader('Cache-Control', 'no-store');
} else if (/\.(js|css)$/i.test(filePath)) {
// JS/CSS: 1 hour, must revalidate — allows updates to reach users quickly
res.setHeader('Cache-Control', 'public, max-age=3600, must-revalidate');
} else if (/\.(png|jpg|jpeg|webp|avif|svg|ico|woff2?)$/i.test(filePath)) {
// Images/fonts: 30 days immutable (these are named by content, rarely change)
res.setHeader('Cache-Control', 'public, max-age=2592000, immutable');
}
}
};
// Serve bundled assets under /build with long cache
app.use('/build', express.static(path.join(__dirname, 'public/build'), staticCacheOptions));
// Serve static files from the root directory (handles all other GET requests)
app.use(express.static(path.join(__dirname), staticCacheOptions));
app.listen(port, () => {
console.log(`Server listening at http://localhost:${port}`);
if (process.env.NODE_ENV !== 'production') {
console.log(`Admin panel available at http://localhost:${port}/admin.html`);
}
});