← back to Bounce Studio
server.js
101 lines
// Bounce — productized web-studio landing.
// Static server + local waitlist capture. NO outbound email/send (gated).
// PORT=0 lets the OS pick a free port.
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = parseInt(process.env.PORT || '0', 10);
const PUB = path.join(__dirname, 'public');
const DATA = path.join(__dirname, 'data');
if (!fs.existsSync(DATA)) fs.mkdirSync(DATA, { recursive: true });
const TYPES = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.webp': 'image/webp',
'.ico': 'image/x-icon',
'.json': 'application/json; charset=utf-8',
'.woff2': 'font/woff2',
};
function serveStatic(req, res) {
const clean = req.url.split('?')[0];
let rel = clean === '/' ? 'index.html' : clean.replace(/^\/+/, '');
// prevent path traversal, keep nested public/ assets working
const fp = path.normalize(path.join(PUB, rel));
if (fp !== PUB && !fp.startsWith(PUB + path.sep)) { res.writeHead(403); return res.end('forbidden'); } // sep-guard: don't let a sibling like public-evil/ pass startsWith
if (fs.existsSync(fp) && fs.statSync(fp).isFile()) {
res.writeHead(200, { 'Content-Type': TYPES[path.extname(fp)] || 'application/octet-stream' });
return res.end(fs.readFileSync(fp));
}
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('not found');
}
// Simple in-memory rate limiter for the waitlist endpoint (bot / disk-fill guard).
// Max WL_MAX submissions per IP per WL_WINDOW ms. Not a substitute for a WAF, but
// stops a single script from filling the JSONL.
const WL_MAX = 5, WL_WINDOW = 10 * 60 * 1000;
const wlHits = new Map();
function wlAllowed(ip) {
const now = Date.now();
const arr = (wlHits.get(ip) || []).filter((t) => now - t < WL_WINDOW);
if (arr.length >= WL_MAX) { wlHits.set(ip, arr); return false; }
arr.push(now); wlHits.set(ip, arr);
if (wlHits.size > 5000) wlHits.clear(); // crude unbounded-growth guard
return true;
}
const server = http.createServer((req, res) => {
// Local waitlist capture. Appends to data/waitlist.jsonl. No email is sent.
if (req.method === 'POST' && req.url === '/api/waitlist') {
const ip = (req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
if (!wlAllowed(ip)) { res.writeHead(429, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: false, error: 'too many requests' })); }
let body = '', tooBig = false;
req.on('data', (c) => {
body += c;
if (body.length > 1e4 && !tooBig) { // cap + tell the client, don't just drop the socket
tooBig = true;
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'payload too large' }));
req.destroy();
}
});
req.on('error', () => {}); // swallow the ECONNRESET that follows req.destroy()
req.on('end', () => {
if (tooBig) return;
let email = '', note = '', budget = '', ref = '';
try { const j = JSON.parse(body || '{}'); email = (j.email || '').trim(); note = (j.note || '').toString().slice(0, 500); budget = (j.budget || '').toString().slice(0, 40); ref = (j.ref || '').toString().trim().slice(0, 300); } catch (_) {}
const ok = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email);
if (!ok) { res.writeHead(400, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: false, error: 'invalid email' })); }
// normalize a bare-domain reference to a URL so downstream (bounce-intake)
// always sees a link, then HARD-VALIDATE: only keep a real dot-domain
// http(s) url. Drops junk like "javascript:alert(1)" (which naive prepend
// would turn into "https://javascript:alert(1)") — store '' instead.
if (ref && !/^https?:\/\//i.test(ref)) ref = 'https://' + ref;
if (ref && !/^https?:\/\/[^\s/.]+\.[^\s]{2,}$/i.test(ref)) ref = '';
const rec = { ts: new Date().toISOString(), email, budget, ref, note, ua: (req.headers['user-agent'] || '').slice(0, 200) };
fs.appendFile(path.join(DATA, 'waitlist.jsonl'), JSON.stringify(rec) + '\n', (e) => {
if (e) { res.writeHead(500, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ ok: false, error: 'write failed' })); }
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
});
return;
}
if (req.method === 'GET' && req.url === '/healthz') {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ ok: true, service: 'bounce-studio' }));
}
serveStatic(req, res);
});
server.listen(PORT, function () {
console.log('[bounce-studio] http://localhost:' + this.address().port);
});