← back to Bounce Studio
server.js
85 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.startsWith(PUB)) { res.writeHead(403); return res.end('forbidden'); }
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 = '';
req.on('data', (c) => { body += c; if (body.length > 1e4) req.destroy(); });
req.on('end', () => {
let email = '', note = '', budget = '';
try { const j = JSON.parse(body || '{}'); email = (j.email || '').trim(); note = (j.note || '').toString().slice(0, 500); budget = (j.budget || '').toString().slice(0, 40); } 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' })); }
const rec = { ts: new Date().toISOString(), email, budget, 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);
});