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