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