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>
125 lines
6.8 KiB
JavaScript
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}`));
|