Files
vizitka-site/server/server.mjs
T
aibrashandClaude Sonnet 5.5 6ddece7017
deploy / deploy (push) Successful in 4s
Кнопка «Помахать» с общим счётчиком, домен, шапка
- Главная: тихая кнопка «Помахать» (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>
2026-11-04 16:55:30 +05:00

125 lines
6.8 KiB
JavaScript

// Мини-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}`));