Files
nkelabs-landing-page/server.js
T
2026-10-23 18:54:25 +02:00

109 lines
7.2 KiB
JavaScript

// Zero-dependency static server + contact endpoint + admin area. Needs Node 18+.
const http = require('http'), fs = require('fs'), path = require('path'), crypto = require('crypto');
const PORT = process.env.PORT || 8080;
const PUBLIC = path.join(__dirname, 'public');
const STORE = path.join(__dirname, 'storage', 'messages.jsonl');
const ADMIN_SERVICES = path.join(__dirname, 'config', 'admin-services.json');
const WEBHOOK = process.env.WEBHOOK_URL || ''; // Discord webhook or ntfy topic URL
const BEHIND_PROXY = process.env.TRUST_PROXY === '1'; // trust X-Forwarded-* headers
const ADMIN_PW = process.env.ADMIN_PASSWORD || '';
const SECRET = process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex'); // set it to survive restarts
if (!ADMIN_PW) console.warn('ADMIN_PASSWORD is not set: the admin area is disabled.');
const MIME = { '.html':'text/html; charset=utf-8', '.css':'text/css', '.js':'text/javascript', '.json':'application/json',
'.svg':'image/svg+xml', '.png':'image/png', '.jpg':'image/jpeg', '.webp':'image/webp', '.ico':'image/x-icon', '.woff2':'font/woff2' };
// --- helpers
const send = (res, code, body, type = 'application/json', extra = {}) => {
res.writeHead(code, { 'Content-Type': type, 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', ...extra }); res.end(body);
};
const json = (res, code, obj, extra = {}) => send(res, code, JSON.stringify(obj), 'application/json', { 'Cache-Control': 'no-store', ...extra });
const readJson = req => new Promise((ok, no) => {
let r = ''; req.on('data', c => { r += c; if (r.length > 10000) { req.destroy(); no(new Error('too large')); } });
req.on('end', () => { try { ok(JSON.parse(r || '{}')); } catch { ok(null); } }); req.on('error', no);
});
const clientIp = req => (BEHIND_PROXY && req.headers['x-forwarded-for']?.split(',')[0].trim()) || req.socket.remoteAddress;
const recent = (map, ip, win) => { const n = Date.now(), a = (map.get(ip) || []).filter(t => n - t < win); map.set(ip, a); return a; };
const push = (map, ip) => map.get(ip).push(Date.now());
// --- sessions: signed, expiring cookie
const sha = s => crypto.createHash('sha256').update(s).digest();
const sign = v => crypto.createHmac('sha256', SECRET).update(v).digest('hex');
const makeToken = () => { const exp = Date.now() + 7 * 864e5; return exp + '.' + sign(String(exp)); };
const validToken = t => {
const [exp, sig] = (t || '').split('.'); if (!exp || !sig || Date.now() > +exp) return false;
const good = sign(exp); return sig.length === good.length && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(good));
};
const cookie = (req, name) => (req.headers.cookie || '').split(';').map(c => c.trim()).find(c => c.startsWith(name + '='))?.slice(name.length + 1);
const isAdmin = req => !!ADMIN_PW && validToken(cookie(req, 'nke_admin'));
const setCookie = (req, val, age) => 'nke_admin=' + val + `; HttpOnly; SameSite=Strict; Path=/; Max-Age=${age}` +
(BEHIND_PROXY && req.headers['x-forwarded-proto'] === 'https' ? '; Secure' : '');
// --- messages
const readMsgs = () => { try { return fs.readFileSync(STORE, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)); } catch { return []; } };
const writeMsgs = list => fs.writeFileSync(STORE, list.map(m => JSON.stringify(m)).join('\n') + (list.length ? '\n' : ''));
async function notify(m) {
if (!WEBHOOK) return;
const text = `New message from ${m.name} <${m.email}>\n\n${m.message}`;
try {
if (/discord(app)?\.com/.test(WEBHOOK)) await fetch(WEBHOOK, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: text.slice(0, 1900) }) });
else await fetch(WEBHOOK, { method: 'POST', headers: { Title: 'nkelabs contact form' }, body: text });
} catch (e) { console.error('webhook failed:', e.message); }
}
const contactHits = new Map(), loginFails = new Map();
async function api(req, res, url) {
const p = url.pathname, ip = clientIp(req);
if (p === '/api/contact' && req.method === 'POST') {
const d = await readJson(req); if (!d) return json(res, 400, { error: 'Invalid request.' });
if (d.website) return json(res, 200, { ok: true }); // honeypot
if (recent(contactHits, ip, 36e5).length >= 5) return json(res, 429, { error: 'Too many messages. Try again in an hour.' });
const m = { name: String(d.name || '').trim().slice(0, 80), email: String(d.email || '').trim().slice(0, 120), message: String(d.message || '').trim().slice(0, 4000) };
if (!m.name || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(m.email) || m.message.length < 5) return json(res, 400, { error: 'Check your name, email and message.' });
push(contactHits, ip);
fs.mkdirSync(path.dirname(STORE), { recursive: true });
fs.appendFileSync(STORE, JSON.stringify({ id: crypto.randomUUID(), ...m, ip, at: new Date().toISOString() }) + '\n');
notify(m); return json(res, 200, { ok: true });
}
if (p === '/api/session' && req.method === 'GET') return json(res, 200, { admin: isAdmin(req), enabled: !!ADMIN_PW });
if (p === '/api/login' && req.method === 'POST') {
if (!ADMIN_PW) return json(res, 503, { error: 'Admin is disabled. Set ADMIN_PASSWORD and restart.' });
if (recent(loginFails, ip, 9e5).length >= 5) return json(res, 429, { error: 'Too many attempts. Wait 15 minutes.' });
const d = await readJson(req);
if (d && crypto.timingSafeEqual(sha(String(d.password || '')), sha(ADMIN_PW))) return json(res, 200, { ok: true }, { 'Set-Cookie': setCookie(req, makeToken(), 604800) });
push(loginFails, ip); return json(res, 401, { error: 'Wrong password.' });
}
if (p === '/api/logout' && req.method === 'POST') return json(res, 200, { ok: true }, { 'Set-Cookie': setCookie(req, '', 0) });
if (p.startsWith('/api/admin/')) {
if (!isAdmin(req)) return json(res, 401, { error: 'Not signed in.' });
if (p === '/api/admin/services' && req.method === 'GET') {
try { return json(res, 200, JSON.parse(fs.readFileSync(ADMIN_SERVICES, 'utf8'))); } catch { return json(res, 200, []); }
}
if (p === '/api/admin/messages' && req.method === 'GET') return json(res, 200, readMsgs().reverse());
const del = p.match(/^\/api\/admin\/messages\/(.+)$/);
if (del && req.method === 'DELETE') {
const id = decodeURIComponent(del[1]); writeMsgs(readMsgs().filter(m => (m.id || m.at) !== id)); return json(res, 200, { ok: true });
}
}
return json(res, 404, { error: 'Not found.' });
}
http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://x');
try {
if (url.pathname.startsWith('/api/')) return await api(req, res, url);
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'Method not allowed.' });
let file = path.join(PUBLIC, path.normalize(decodeURIComponent(url.pathname)));
if (!file.startsWith(PUBLIC)) return json(res, 403, { error: 'Forbidden.' });
if (fs.existsSync(file) && fs.statSync(file).isDirectory()) file = path.join(file, 'index.html');
if (!fs.existsSync(file)) return send(res, 404, fs.readFileSync(path.join(PUBLIC, '404.html')), MIME['.html']);
send(res, 200, fs.readFileSync(file), MIME[path.extname(file)] || 'application/octet-stream');
} catch (e) { if (!res.headersSent) json(res, 500, { error: 'Server error.' }); }
}).listen(PORT, () => console.log(`nkelabs listening on :${PORT}`));