109 lines
7.2 KiB
JavaScript
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}`));
|