- Главная: тихая кнопка «Помахать» (web/js/wave.js), общий счётчик для всех посетителей - server/server.mjs: мини-API (/api/waves, /api/wave), счётчик хранится вне репозитория (/var/lib/vizitka-site/waves.json), лимит раз в секунду с IP, проверка Origin - deploy: systemd-сервис vizitka-api, проксирование /api в nginx, setup-server.sh ставит nodejs - CI: после git pull перезапускает API - Домен https://me.ibrash.duckdns.org вместо заглушки, исправлен пробел в шапке - README и заметки обновлены Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
// Мини-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}`));
|
||||
Reference in New Issue
Block a user