// 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(); apiRouter.post('/update-status', (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 { firstName, lastName, email, phone, message, eventType, eventDate } = req.body; const name = [firstName, lastName].filter(Boolean).join(' '); if (!firstName || !lastName || !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 ? `

Event Type: ${eventType}

` : '', eventDateFormatted ? `

Event Date: ${eventDateFormatted}

` : '', ].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}\${eventDateFormatted ? \` โ€” \${eventDateFormatted}\` : ''}\`, text: `Name: ${name}\nEmail: ${email}\nPhone: ${phone}\n${eventText}\n\nMessage:\n${message}`, html: `

Name: ${name}

Email: ${email}

Phone: ${phone}

${eventLines}

Message:

${message.replace(/\n/g, '
')}

`, 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: `

Hi ${name},

Thanks for reaching out to Beach Party Balloons! We've received your message and will get back to you as soon as possible.

Here's a copy of what you sent:

${eventLines}

Message:
${message.replace(/\n/g, '
')}


Beach Party Balloons
554 Boston Post Road, Milford, CT 06460
203.283.5626

`, }; try { await transporter.sendMail(notifyMail); transporter.sendMail(autoReply).catch(err => console.error(`[${new Date().toISOString()}] Auto-reply failed:`, err) ); if (process.env.NTFY_URL) { const ntfyBody = [ phone, eventDateFormatted || null, eventType || null, message.slice(0, 100) + (message.length > 100 ? 'โ€ฆ' : ''), ].filter(Boolean).join(' ยท '); fetch(process.env.NTFY_URL, { method: 'POST', headers: { 'Title': `๐ŸŽˆ New inquiry โ€” ${name}`, 'Priority': 'default', 'Content-Type': 'text/plain', }, body: ntfyBody, }).catch(err => console.error(`[${new Date().toISOString()}] ntfy 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')) { res.setHeader('Cache-Control', 'no-store'); } else if (/\.(js|css|svg|ico|png|jpg|jpeg|webp|avif|woff2?)$/i.test(filePath)) { 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`); } });