← 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);
});