← back to Robet Site

server.js

193 lines

'use strict';
/*
 * robet-site — brutalist marketing site
 *   • config-driven brand/contact (config.json)
 *   • Booking via Robert's own scheduler link (standalone — no DW/venturacorridor infra)
 *   • /admin API-key entry page (basic-auth) → data/keys.json (gitignored)
 *
 * Zero external deps beyond express. A tiny inline .env loader avoids dotenv.
 */
const express = require('express');
const fs = require('fs');
const path = require('path');

// ── tiny .env loader (no dotenv dependency) ──
(function loadEnv() {
  const p = path.join(__dirname, '.env');
  if (!fs.existsSync(p)) return;
  for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
    const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
    if (m && !(m[1] in process.env)) process.env[m[1]] = m[2];
  }
})();

const PORT = process.env.PORT || 9788;
const ADMIN_USER = process.env.ADMIN_USER || 'admin';
const ADMIN_PASS = process.env.ADMIN_PASS || 'changeme';
const KEYS_FILE = path.join(__dirname, 'data', 'keys.json');
const CONTACTS_FILE = path.join(__dirname, 'data', 'contacts.jsonl');
const CONFIG_FILE = path.join(__dirname, 'config.json');

const app = express();
app.use(express.json({ limit: '64kb' }));
app.use(express.urlencoded({ extended: false }));

// ── security headers (defense-in-depth; nginx also sets some on prod) ──
const CSP = [
  "default-src 'self'",
  "script-src 'self'",
  "style-src 'self' 'unsafe-inline'",            // inline style="" attrs on the hero/admin
  "img-src 'self' data:",
  "font-src 'self'",
  "connect-src 'self'",
  "frame-src 'none'",
  "object-src 'none'",
  "base-uri 'self'",
  "form-action 'self'",
  "frame-ancestors 'self'",
].join('; ');
app.use((req, res, next) => {
  res.set('Content-Security-Policy', CSP);
  res.set('X-Content-Type-Options', 'nosniff');
  res.set('X-Frame-Options', 'SAMEORIGIN');
  res.set('Referrer-Policy', 'strict-origin-when-cross-origin');
  res.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
  next();
});

// ── helpers ──
const readJson = (f, fallback) => {
  try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return fallback; }
};
const writeJson = (f, obj) => {
  fs.mkdirSync(path.dirname(f), { recursive: true });
  fs.writeFileSync(f, JSON.stringify(obj, null, 2));
};
const last4 = (v) => (v && v.length >= 4 ? '…' + v.slice(-4) : '…');

// Basic-auth gate for admin surfaces. Never logs the password.
function adminAuth(req, res, next) {
  const hdr = req.headers.authorization || '';
  const [scheme, b64] = hdr.split(' ');
  if (scheme === 'Basic' && b64) {
    const [u, p] = Buffer.from(b64, 'base64').toString().split(':');
    if (u === ADMIN_USER && p === ADMIN_PASS) return next();
  }
  res.set('WWW-Authenticate', 'Basic realm="robet-admin"').status(401).send('Auth required');
}

// ── public config (secrets stripped) ──
app.get('/api/config', (_req, res) => res.json(readJson(CONFIG_FILE, {})));

// ── gallery: list real photos dropped into public/photos (newest first) ──
app.get('/api/photos', (_req, res) => {
  const dir = path.join(__dirname, 'public', 'photos');
  let files = [];
  try {
    files = fs.readdirSync(dir)
      .filter((f) => /\.(jpe?g|png|webp|gif)$/i.test(f))
      .map((f) => ({ f, t: fs.statSync(path.join(dir, f)).mtimeMs }))
      .sort((a, b) => b.t - a.t)
      .map((x) => '/photos/' + x.f);
  } catch (_) {}
  res.json(files);
});

// ── contact form (hardened: honeypot + validation + per-IP rate limit) ──
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const rateHits = new Map(); // ip -> [timestamps]
function rateLimited(ip, max = 5, windowMs = 10 * 60 * 1000) {
  const now = Date.now();
  const arr = (rateHits.get(ip) || []).filter((t) => now - t < windowMs);
  arr.push(now);
  rateHits.set(ip, arr);
  if (rateHits.size > 5000) rateHits.delete(rateHits.keys().next().value); // bound memory
  return arr.length > max;
}
const clientIp = (req) =>
  String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();

app.post('/api/contact', (req, res) => {
  const { name = '', email = '', message = '', website = '' } = req.body || {};
  if (website) return res.json({ ok: true });            // honeypot → silently drop bot
  if (rateLimited(clientIp(req))) return res.status(429).json({ ok: false, error: 'Too many submissions — try again later.' });
  const n = String(name).slice(0, 120).trim();
  const e = String(email).slice(0, 200).trim();
  const m = String(message).slice(0, 5000).trim();
  if (!EMAIL_RE.test(e)) return res.status(400).json({ ok: false, error: 'A valid email is required.' });
  if (!m) return res.status(400).json({ ok: false, error: 'A message is required.' });
  try {
    fs.mkdirSync(path.dirname(CONTACTS_FILE), { recursive: true });
    fs.appendFileSync(CONTACTS_FILE, JSON.stringify({ name: n, email: e, message: m, ip: clientIp(req), at: new Date().toISOString() }) + '\n');
  } catch (err) {
    console.error('contact write failed:', err.message);
    return res.status(500).json({ ok: false, error: 'Could not save your message — please try again.' });
  }
  res.json({ ok: true });
});

// ── API-key admin (basic-auth) ──
// GET → masked list; POST → upsert {name,value}; DELETE → remove. Full values never returned.
app.get('/api/keys', adminAuth, (_req, res) => {
  const keys = readJson(KEYS_FILE, {});
  res.json(Object.entries(keys).map(([name, v]) => ({
    name, masked: last4(v.value), updated_at: v.updated_at,
  })));
});

app.post('/api/keys', adminAuth, (req, res) => {
  const { name, value } = req.body || {};
  if (!name || !value) return res.status(400).json({ ok: false, error: 'name and value required' });
  const keys = readJson(KEYS_FILE, {});
  keys[name] = { value, updated_at: new Date().toISOString() };
  try { writeJson(KEYS_FILE, keys); } catch (err) { console.error('keys write failed:', err.message); return res.status(500).json({ ok: false, error: 'Could not save the key.' }); }
  res.json({ ok: true, name, masked: last4(value) });
});

app.delete('/api/keys/:name', adminAuth, (req, res) => {
  const keys = readJson(KEYS_FILE, {});
  delete keys[req.params.name];
  try { writeJson(KEYS_FILE, keys); } catch (err) { console.error('keys write failed:', err.message); return res.status(500).json({ ok: false, error: 'Could not delete the key.' }); }
  res.json({ ok: true });
});

app.get('/admin', adminAuth, (_req, res) => res.sendFile(path.join(__dirname, 'public', 'admin.html')));

// Differentiated caching: stable images get a real maxAge; HTML/CSS/JS revalidate
// (no fingerprinting → ETag/304 keeps revalidation cheap while updates stay instant).
app.use(express.static(path.join(__dirname, 'public'), {
  etag: true,
  lastModified: true,
  setHeaders(res, filePath) {
    if (/\.(png|jpe?g|gif|webp|svg|ico)$/i.test(filePath)) {
      res.setHeader('Cache-Control', 'public, max-age=86400');     // images: 1 day
    } else {
      res.setHeader('Cache-Control', 'no-cache');                   // html/css/js: revalidate
    }
  },
}));

// ── custom 404 (after static) ──
app.use((req, res) => {
  if (req.accepts('html')) return res.status(404).sendFile(path.join(__dirname, 'public', '404.html'));
  res.status(404).json({ ok: false, error: 'Not found' });
});

// ── global error handler: always clean JSON, never an HTML stack trace ──
app.use((err, req, res, _next) => {
  const code = err.status || err.statusCode || 500;
  console.error('route error:', code, err && err.message);
  if (res.headersSent) return;
  // browser navigations get a styled page (parity with 404); APIs/fetch get JSON
  if (code >= 500 && req.accepts('html') && req.method === 'GET') {
    return res.status(code).sendFile(path.join(__dirname, 'public', '500.html'));
  }
  res.status(code).json({ ok: false, error: code === 400 ? 'Bad request' : 'Server error' });
});

// keep the process alive on unexpected async faults (log, don't crash the booking site)
process.on('unhandledRejection', (r) => console.error('unhandledRejection:', r));
process.on('uncaughtException', (e) => console.error('uncaughtException:', e && e.message));

app.listen(PORT, () => console.log(`robet-site → http://127.0.0.1:${PORT}`));