// Мини-API для кнопки «Помахать» на главной: общий счётчик для всех посетителей. // GET /api/waves -> { "count": N } // POST /api/wave -> { "count": N+1 } (не чаще раза в секунду с одного IP) // Без зависимостей, Node 18+. Счётчик хранится в файле ВНЕ репозитория (по умолчанию /var/lib/vizitka-site/waves.json), // поэтому git pull, пуши и перезапуски его не сбрасывают. import http from 'node:http'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const PORT = Number(process.env.PORT || 8787); const HOST = process.env.HOST || '127.0.0.1'; const DATA_FILE = process.env.DATA_FILE || path.join(HERE, 'data', 'waves.json'); // Откуда разрешены POST (через запятую), например: https://me.ibrash.duckdns.org,http://192.168.10.112 const ORIGINS = (process.env.ALLOWED_ORIGINS || '').split(',').map((s) => s.trim().replace(/\/+$/, '')).filter(Boolean); // 1, если API стоит за nginx на этой же машине: тогда IP посетителя берём из X-Client-IP const TRUST_PROXY = process.env.TRUST_PROXY === '1'; // Только для локальной проверки: раздавать статику из этого каталога const STATIC_DIR = process.env.STATIC_DIR ? path.resolve(process.env.STATIC_DIR) : ''; /* ---------- счётчик: хранение ---------- */ const readCount = (file) => { try { const v = JSON.parse(fs.readFileSync(file, 'utf8')).count; return Number.isSafeInteger(v) && v >= 0 ? v : null; } catch { return null; } }; // основной файл, при порче берём резервную копию let count = readCount(DATA_FILE) ?? readCount(DATA_FILE + '.bak') ?? 0; let dirty = false, timer = null; function save() { if (!dirty) return; dirty = false; try { fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true }); if (fs.existsSync(DATA_FILE)) fs.copyFileSync(DATA_FILE, DATA_FILE + '.bak'); // предыдущее значение на всякий случай const tmp = DATA_FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify({ count })); fs.renameSync(tmp, DATA_FILE); // атомарно: файл не побьётся при падении } catch (e) { console.error('не удалось сохранить счётчик:', e.message); dirty = true; } } const scheduleSave = () => { dirty = true; if (!timer) timer = setTimeout(() => { timer = null; save(); }, 1000); }; for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { save(); process.exit(0); }); process.on('exit', save); /* ---------- лимиты ---------- */ const lastHit = new Map(); // ip -> время последнего махания let minuteStart = Date.now(), minuteHits = 0; const MAX_PER_MINUTE = 300; // общий предел на всех: защита от флуда с разных адресов function allowWave(ip) { const now = Date.now(); if (now - minuteStart > 60_000) { minuteStart = now; minuteHits = 0; } if (minuteHits >= MAX_PER_MINUTE) return false; const last = lastHit.get(ip); if (last !== undefined && now - last < 1000) return false; if (lastHit.size > 20000) lastHit.clear(); lastHit.set(ip, now); minuteHits++; return true; } setInterval(() => { const cut = Date.now() - 60_000; for (const [k, t] of lastHit) if (t < cut) lastHit.delete(k); }, 30_000).unref(); function clientIp(req) { const remote = req.socket.remoteAddress || ''; const loop = remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1'; if (TRUST_PROXY && loop) { const h = req.headers['x-client-ip']; if (typeof h === 'string' && h.length < 64) return h.trim(); } return remote; } /* ---------- HTTP ---------- */ function send(res, code, body, extra = {}) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', ...extra }); res.end(JSON.stringify(body)); } const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.webp': 'image/webp', '.jpg': 'image/jpeg', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml' }; function serveStatic(res, pathname) { let rel; try { rel = decodeURIComponent(pathname); } catch { return send(res, 400, { error: 'bad_request' }); } let file = path.join(STATIC_DIR, path.normalize(rel)); if (file !== STATIC_DIR && !file.startsWith(STATIC_DIR + path.sep)) return send(res, 403, { error: 'forbidden' }); try { if (fs.statSync(file).isDirectory()) file = path.join(file, 'index.html'); } catch { /* дальше 404 */ } fs.readFile(file, (err, buf) => { if (err) return fs.readFile(path.join(STATIC_DIR, '404.html'), (_, nf) => { res.writeHead(404, { 'Content-Type': MIME['.html'] }); res.end(nf || 'not found'); }); res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', 'X-Content-Type-Options': 'nosniff' }); res.end(buf); }); } const server = http.createServer((req, res) => { let url; try { url = new URL(req.url, 'http://localhost'); } catch { return send(res, 400, { error: 'bad_request' }); } const { pathname } = url; if (!pathname.startsWith('/api/')) { if (STATIC_DIR && (req.method === 'GET' || req.method === 'HEAD')) return serveStatic(res, pathname); return send(res, 404, { error: 'not_found' }); } if (pathname === '/api/waves' && req.method === 'GET') return send(res, 200, { count }); if (pathname === '/api/wave' && req.method === 'POST') { const origin = req.headers.origin; if (ORIGINS.length && origin && !ORIGINS.includes(origin)) return send(res, 403, { error: 'forbidden_origin' }); // тело игнорируем целиком: от клиента не принимаем никаких данных; читаем с лимитом, чтобы не копить let size = 0; req.on('data', (c) => { size += c.length; if (size > 1024) req.destroy(); }); req.on('error', () => {}); req.on('end', () => { if (!allowWave(clientIp(req))) return send(res, 429, { error: 'too_many_requests' }, { 'Retry-After': '1' }); count += 1; scheduleSave(); send(res, 200, { count }); }); return; } send(res, 404, { error: 'not_found' }); }); server.headersTimeout = 10_000; server.requestTimeout = 10_000; server.listen(PORT, HOST, () => console.log(`api: http://${HOST}:${PORT}, счётчик ${count}, файл ${DATA_FILE}`));