2026-10-30 17:20:47 +01:00
|
|
|
|
// Бинго «Баланс 2.0» — автономный сервер: Node.js ≥ 22.13 + встроенный SQLite, без npm-зависимостей.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Отдаёт страницу из ./public и API:
|
|
|
|
|
|
// GET /api/total → общая батарейка + настройки сезона (страница опрашивает раз в 20 с)
|
|
|
|
|
|
// GET /api/me?d=<id> → отметки и ответ опроса этого устройства за текущую неделю
|
|
|
|
|
|
// POST /api/mark {d, week, cell, on} → отметить / снять клетку
|
|
|
|
|
|
// POST /api/pulse {d, week, answer} → ответ на «Твоя батарейка сейчас?»
|
2026-10-30 20:53:04 +01:00
|
|
|
|
// POST /api/admin/login {key} → проверяет ключ, ставит cookie сессии (HttpOnly, SameSite=Strict)
|
|
|
|
|
|
// POST /api/admin/logout → завершает сессию
|
|
|
|
|
|
// GET /api/admin/stats → статистика (cookie сессии или заголовок Authorization: Bearer <ADMIN_KEY>)
|
2026-10-30 17:20:47 +01:00
|
|
|
|
// GET /healthz → проверка живости для Docker
|
|
|
|
|
|
//
|
|
|
|
|
|
// Хранится только: случайный id устройства, неделя, номер клетки, ответ опроса.
|
|
|
|
|
|
// IP-адреса, заголовки и время запросов не пишутся ни в базу, ни в логи.
|
|
|
|
|
|
|
|
|
|
|
|
import http from 'node:http';
|
|
|
|
|
|
import { readFileSync, readdirSync, statSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
|
|
|
|
import { join, extname, relative, sep } from 'node:path';
|
2026-10-30 20:53:04 +01:00
|
|
|
|
import { createHash, timingSafeEqual, randomBytes } from 'node:crypto';
|
2026-10-30 17:20:47 +01:00
|
|
|
|
import { openDb, LINES_SQL, LINE_BONUS } from './db.mjs';
|
|
|
|
|
|
|
|
|
|
|
|
const ROOT = new URL('..', import.meta.url).pathname;
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- настройки ----------
|
|
|
|
|
|
|
|
|
|
|
|
function readAdminKey() {
|
|
|
|
|
|
if (process.env.ADMIN_KEY_FILE) {
|
|
|
|
|
|
try { return readFileSync(process.env.ADMIN_KEY_FILE, 'utf8').trim(); } catch { return ''; }
|
|
|
|
|
|
}
|
|
|
|
|
|
return (process.env.ADMIN_KEY || '').trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
|
|
|
|
const CFG = {
|
|
|
|
|
|
port: parseInt(process.env.PORT || '8080', 10),
|
|
|
|
|
|
dataDir: process.env.DATA_DIR || join(ROOT, 'data'),
|
|
|
|
|
|
publicDir: process.env.PUBLIC_DIR || join(ROOT, 'public'),
|
|
|
|
|
|
tz: process.env.TIMEZONE || 'Europe/Moscow',
|
|
|
|
|
|
start: process.env.SEASON_START || '2026-11-02',
|
|
|
|
|
|
end: process.env.SEASON_END || '2026-12-21',
|
|
|
|
|
|
goal: parseInt(process.env.GOAL || '500', 10),
|
|
|
|
|
|
adminKey: readAdminKey(),
|
|
|
|
|
|
backupKeep: parseInt(process.env.BACKUP_KEEP || '14', 10),
|
2026-10-30 20:53:04 +01:00
|
|
|
|
sessionHours: Number(process.env.ADMIN_SESSION_HOURS || '8'),
|
|
|
|
|
|
cookieSecure: !['0', 'false', 'no'].includes(String(process.env.COOKIE_SECURE || '').toLowerCase()),
|
2026-10-30 17:20:47 +01:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function fail(msg) {
|
|
|
|
|
|
console.error(`Ошибка настройки: ${msg}`);
|
|
|
|
|
|
process.exit(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!DATE_RE.test(CFG.start) || !DATE_RE.test(CFG.end)) fail('SEASON_START и SEASON_END должны быть в формате ГГГГ-ММ-ДД.');
|
|
|
|
|
|
if (CFG.start > CFG.end) fail('SEASON_START позже SEASON_END.');
|
|
|
|
|
|
if (!Number.isInteger(CFG.goal) || CFG.goal < 1) fail('GOAL должен быть положительным целым числом.');
|
|
|
|
|
|
try { new Intl.DateTimeFormat('en-US', { timeZone: CFG.tz }); } catch { fail(`неизвестный часовой пояс TIMEZONE=${CFG.tz}.`); }
|
|
|
|
|
|
if (CFG.adminKey && CFG.adminKey.length < 16) fail('ADMIN_KEY короче 16 символов.');
|
2026-10-30 20:53:04 +01:00
|
|
|
|
if (!(CFG.sessionHours > 0 && CFG.sessionHours <= 720)) fail('ADMIN_SESSION_HOURS должен быть больше 0 и не больше 720.');
|
2026-10-30 17:20:47 +01:00
|
|
|
|
|
|
|
|
|
|
// ---------- база ----------
|
|
|
|
|
|
|
|
|
|
|
|
mkdirSync(CFG.dataDir, { recursive: true });
|
|
|
|
|
|
const db = openDb(join(CFG.dataDir, 'bingo.sqlite'));
|
|
|
|
|
|
|
|
|
|
|
|
const q = {
|
|
|
|
|
|
total: db.prepare("SELECT value FROM totals WHERE key = 'steps'"),
|
|
|
|
|
|
myCells: db.prepare('SELECT cell FROM marks WHERE device = ? AND week = ? ORDER BY cell'),
|
|
|
|
|
|
myPulse: db.prepare('SELECT answer FROM pulse WHERE device = ? AND week = ?'),
|
|
|
|
|
|
mark: db.prepare('INSERT OR IGNORE INTO marks (device, week, cell) VALUES (?, ?, ?)'),
|
|
|
|
|
|
unmark: db.prepare('DELETE FROM marks WHERE device = ? AND week = ? AND cell = ?'),
|
|
|
|
|
|
recount: db.prepare(`
|
|
|
|
|
|
UPDATE totals SET value =
|
|
|
|
|
|
(SELECT COUNT(*) FROM marks) + ${LINE_BONUS} * (SELECT COUNT(*) FROM (${LINES_SQL}))
|
|
|
|
|
|
WHERE key = 'steps'`),
|
|
|
|
|
|
pulse: db.prepare(`INSERT INTO pulse (device, week, answer) VALUES (?, ?, ?)
|
|
|
|
|
|
ON CONFLICT (device, week) DO UPDATE SET answer = excluded.answer`),
|
|
|
|
|
|
stWeeks: db.prepare('SELECT week, COUNT(*) AS cells, COUNT(DISTINCT device) AS devices FROM marks GROUP BY week ORDER BY week'),
|
|
|
|
|
|
stLines: db.prepare(`SELECT week, SUM(kind = 'row') AS rows, SUM(kind = 'col') AS cols, SUM(kind = 'diag') AS diags
|
|
|
|
|
|
FROM (${LINES_SQL}) GROUP BY week`),
|
|
|
|
|
|
stCells: db.prepare('SELECT cell, COUNT(*) AS n FROM marks GROUP BY cell'),
|
|
|
|
|
|
stCellWeeks: db.prepare('SELECT week, cell, COUNT(*) AS n FROM marks GROUP BY week, cell'),
|
|
|
|
|
|
stPulse: db.prepare('SELECT week, answer, COUNT(*) AS n FROM pulse GROUP BY week, answer ORDER BY week'),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Синхронный SQLite в одном процессе: транзакция не пересекается с другими запросами.
|
|
|
|
|
|
function tx(fn) {
|
|
|
|
|
|
db.exec('BEGIN IMMEDIATE');
|
|
|
|
|
|
try {
|
|
|
|
|
|
const out = fn();
|
|
|
|
|
|
db.exec('COMMIT');
|
|
|
|
|
|
return out;
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
db.exec('ROLLBACK');
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- даты и сезон ----------
|
|
|
|
|
|
|
|
|
|
|
|
function todayIn(tz, now = new Date()) {
|
|
|
|
|
|
const parts = new Intl.DateTimeFormat('en-US', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(now);
|
|
|
|
|
|
const get = (t) => parts.find((p) => p.type === t).value;
|
|
|
|
|
|
return `${get('year')}-${get('month')}-${get('day')}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
const dayNum = (iso) => Date.UTC(+iso.slice(0, 4), +iso.slice(5, 7) - 1, +iso.slice(8, 10)) / 864e5;
|
|
|
|
|
|
const isoOf = (n) => new Date(n * 864e5).toISOString().slice(0, 10);
|
|
|
|
|
|
function mondayOf(iso) {
|
|
|
|
|
|
const n = dayNum(iso);
|
|
|
|
|
|
return isoOf(n - ((new Date(n * 864e5).getUTCDay() + 6) % 7));
|
|
|
|
|
|
}
|
|
|
|
|
|
const weekNo = (week, start) => Math.floor((dayNum(week) - dayNum(mondayOf(start))) / 7) + 1;
|
|
|
|
|
|
|
|
|
|
|
|
function season() {
|
|
|
|
|
|
const today = todayIn(CFG.tz);
|
|
|
|
|
|
const week = mondayOf(today);
|
|
|
|
|
|
const phase = today < CFG.start ? 'pre' : today > CFG.end ? 'post' : 'active';
|
|
|
|
|
|
return {
|
|
|
|
|
|
goal: CFG.goal, tz: CFG.tz, start: CFG.start, end: CFG.end,
|
|
|
|
|
|
week, phase,
|
|
|
|
|
|
weekNo: phase === 'active' ? weekNo(week, CFG.start) : null,
|
|
|
|
|
|
weeks: weekNo(mondayOf(CFG.end), CFG.start),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- API ----------
|
|
|
|
|
|
|
|
|
|
|
|
const DEVICE_RE = /^[A-Za-z0-9-]{16,64}$/;
|
|
|
|
|
|
const ANSWERS = ['full', 'mid', 'low'];
|
|
|
|
|
|
const readTotal = () => q.total.get()?.value ?? 0;
|
|
|
|
|
|
|
|
|
|
|
|
async function handleApi(req, res, url) {
|
|
|
|
|
|
const path = url.pathname;
|
|
|
|
|
|
const method = req.method;
|
|
|
|
|
|
|
|
|
|
|
|
if (path === '/api/total' && (method === 'GET' || method === 'HEAD')) {
|
|
|
|
|
|
return json(res, 200, { total: readTotal(), ...season() });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (path === '/api/me' && method === 'GET') {
|
|
|
|
|
|
const d = url.searchParams.get('d') || '';
|
|
|
|
|
|
if (!DEVICE_RE.test(d)) return json(res, 400, { error: 'bad_device' });
|
|
|
|
|
|
const s = season();
|
|
|
|
|
|
return json(res, 200, {
|
|
|
|
|
|
week: s.week,
|
|
|
|
|
|
cells: q.myCells.all(d, s.week).map((r) => r.cell),
|
|
|
|
|
|
pulse: q.myPulse.get(d, s.week)?.answer || null,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (path === '/api/mark' && method === 'POST') {
|
|
|
|
|
|
const body = await readJson(req);
|
|
|
|
|
|
if (body === TOO_LARGE) return tooLarge(res);
|
|
|
|
|
|
if (!body) return json(res, 400, { error: 'bad_json' });
|
|
|
|
|
|
const { d, week, cell, on } = body;
|
|
|
|
|
|
if (!DEVICE_RE.test(String(d || ''))) return json(res, 400, { error: 'bad_device' });
|
|
|
|
|
|
if (!Number.isInteger(cell) || cell < 1 || cell > 16) return json(res, 400, { error: 'bad_cell' });
|
|
|
|
|
|
if (typeof on !== 'boolean') return json(res, 400, { error: 'bad_state' });
|
|
|
|
|
|
const s = season();
|
|
|
|
|
|
if (s.phase !== 'active') return json(res, 403, { error: 'season_closed', phase: s.phase });
|
|
|
|
|
|
if (week !== s.week) return json(res, 409, { error: 'week_changed', week: s.week });
|
|
|
|
|
|
|
|
|
|
|
|
const out = tx(() => {
|
|
|
|
|
|
(on ? q.mark : q.unmark).run(d, s.week, cell);
|
|
|
|
|
|
q.recount.run();
|
|
|
|
|
|
return { total: readTotal(), cells: q.myCells.all(d, s.week).map((r) => r.cell) };
|
|
|
|
|
|
});
|
|
|
|
|
|
return json(res, 200, { ok: true, week: s.week, ...out });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (path === '/api/pulse' && method === 'POST') {
|
|
|
|
|
|
const body = await readJson(req);
|
|
|
|
|
|
if (body === TOO_LARGE) return tooLarge(res);
|
|
|
|
|
|
if (!body) return json(res, 400, { error: 'bad_json' });
|
|
|
|
|
|
const { d, week, answer } = body;
|
|
|
|
|
|
if (!DEVICE_RE.test(String(d || ''))) return json(res, 400, { error: 'bad_device' });
|
|
|
|
|
|
if (!ANSWERS.includes(answer)) return json(res, 400, { error: 'bad_answer' });
|
|
|
|
|
|
const s = season();
|
|
|
|
|
|
if (s.phase !== 'active') return json(res, 403, { error: 'season_closed', phase: s.phase });
|
|
|
|
|
|
if (week !== s.week) return json(res, 409, { error: 'week_changed', week: s.week });
|
|
|
|
|
|
q.pulse.run(d, s.week, answer);
|
|
|
|
|
|
return json(res, 200, { ok: true, answer });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-30 20:53:04 +01:00
|
|
|
|
if (path === '/api/admin/login' && method === 'POST') {
|
|
|
|
|
|
if (!CFG.adminKey) return json(res, 403, { error: 'admin_disabled' });
|
|
|
|
|
|
const body = await readJson(req);
|
|
|
|
|
|
if (body === TOO_LARGE) return tooLarge(res);
|
|
|
|
|
|
if (!body || !keyMatches(String(body.key || ''))) {
|
|
|
|
|
|
await new Promise((r) => setTimeout(r, 500)); // замедляем подбор
|
|
|
|
|
|
return json(res, 401, { error: 'bad_key' });
|
|
|
|
|
|
}
|
|
|
|
|
|
const { token, expires } = createSession();
|
|
|
|
|
|
res.setHeader('Set-Cookie', sessionCookie(token, CFG.sessionHours * 3600));
|
|
|
|
|
|
return json(res, 200, { ok: true, expiresAt: new Date(expires).toISOString() });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (path === '/api/admin/logout' && method === 'POST') {
|
|
|
|
|
|
const token = readCookie(req, COOKIE);
|
|
|
|
|
|
if (token) sessions.delete(tokenId(token));
|
|
|
|
|
|
res.setHeader('Set-Cookie', sessionCookie('', 0));
|
|
|
|
|
|
return json(res, 200, { ok: true });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-30 17:20:47 +01:00
|
|
|
|
if (path === '/api/admin/stats' && method === 'GET') {
|
2026-10-30 20:53:04 +01:00
|
|
|
|
const auth = adminAuth(req);
|
|
|
|
|
|
if (!auth) return json(res, 401, { error: CFG.adminKey ? 'unauthorized' : 'admin_disabled' });
|
|
|
|
|
|
return json(res, 200, { ...adminStats(), session: auth.expires ? { expiresAt: new Date(auth.expires).toISOString() } : null });
|
2026-10-30 17:20:47 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return json(res, 404, { error: 'not_found' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function adminStats() {
|
|
|
|
|
|
const s = season();
|
|
|
|
|
|
const weeks = q.stWeeks.all();
|
|
|
|
|
|
const lines = Object.fromEntries(q.stLines.all().map((r) => [r.week, r]));
|
|
|
|
|
|
const cells = q.stCells.all();
|
|
|
|
|
|
const cellWeeks = q.stCellWeeks.all();
|
|
|
|
|
|
const pulseByWeek = {};
|
|
|
|
|
|
for (const r of q.stPulse.all()) {
|
|
|
|
|
|
pulseByWeek[r.week] ??= { full: 0, mid: 0, low: 0 };
|
|
|
|
|
|
pulseByWeek[r.week][r.answer] = r.n;
|
|
|
|
|
|
}
|
|
|
|
|
|
const weekKeys = [...new Set([...weeks.map((r) => r.week), ...Object.keys(pulseByWeek)])].sort();
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
generatedAt: new Date().toISOString(),
|
|
|
|
|
|
season: s,
|
|
|
|
|
|
total: readTotal(),
|
|
|
|
|
|
weeks: weekKeys.map((w) => {
|
|
|
|
|
|
const m = weeks.find((r) => r.week === w) || { cells: 0, devices: 0 };
|
|
|
|
|
|
const l = lines[w] || {};
|
|
|
|
|
|
const rows = l.rows || 0, cols = l.cols || 0, diags = l.diags || 0;
|
|
|
|
|
|
return {
|
|
|
|
|
|
week: w,
|
|
|
|
|
|
weekNo: weekNo(w, CFG.start),
|
|
|
|
|
|
cells: m.cells, rows, cols, diags,
|
|
|
|
|
|
steps: m.cells + LINE_BONUS * (rows + cols + diags),
|
|
|
|
|
|
devices: m.devices,
|
|
|
|
|
|
pulse: pulseByWeek[w] || { full: 0, mid: 0, low: 0 },
|
|
|
|
|
|
};
|
|
|
|
|
|
}),
|
|
|
|
|
|
cells: Array.from({ length: 16 }, (_, i) => ({
|
|
|
|
|
|
cell: i + 1,
|
|
|
|
|
|
total: cells.find((r) => r.cell === i + 1)?.n || 0,
|
|
|
|
|
|
byWeek: Object.fromEntries(cellWeeks.filter((r) => r.cell === i + 1).map((r) => [r.week, r.n])),
|
|
|
|
|
|
})),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-30 20:53:04 +01:00
|
|
|
|
// ---------- вход в админку ----------
|
|
|
|
|
|
//
|
|
|
|
|
|
// Ключ вводится один раз (или приходит по ссылке /admin#k=…, страница сразу убирает его из адреса).
|
|
|
|
|
|
// Сервер выдаёт случайный токен в cookie: HttpOnly (недоступен скриптам), SameSite=Strict, Secure.
|
|
|
|
|
|
// Path не задаётся: браузер берёт путь запроса (/api/admin или /<папка>/api/admin за прокси),
|
|
|
|
|
|
// так что cookie уходит только на адреса админского API.
|
|
|
|
|
|
// Срок жизни фиксированный — ADMIN_SESSION_HOURS с момента входа, без продления.
|
|
|
|
|
|
// Сессии хранятся в памяти процесса (в виде хешей токенов): перезапуск контейнера завершает все сессии.
|
|
|
|
|
|
|
|
|
|
|
|
const COOKIE = 'bingo_admin';
|
|
|
|
|
|
const MAX_SESSIONS = 200;
|
|
|
|
|
|
const sessions = new Map(); // sha256(token) → время окончания, мс
|
|
|
|
|
|
|
2026-10-30 17:20:47 +01:00
|
|
|
|
const sha = (s) => createHash('sha256').update(s).digest();
|
2026-10-30 20:53:04 +01:00
|
|
|
|
const tokenId = (token) => sha(token).toString('hex');
|
|
|
|
|
|
const keyMatches = (given) => Boolean(CFG.adminKey) && timingSafeEqual(sha(given), sha(CFG.adminKey));
|
|
|
|
|
|
|
|
|
|
|
|
function createSession() {
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
for (const [id, exp] of sessions) if (exp <= now) sessions.delete(id);
|
|
|
|
|
|
while (sessions.size >= MAX_SESSIONS) sessions.delete(sessions.keys().next().value); // самая старая
|
|
|
|
|
|
const token = randomBytes(32).toString('base64url');
|
|
|
|
|
|
const expires = now + CFG.sessionHours * 3600 * 1000;
|
|
|
|
|
|
sessions.set(tokenId(token), expires);
|
|
|
|
|
|
return { token, expires };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sessionCookie(token, maxAgeSec) {
|
|
|
|
|
|
return `${COOKIE}=${token}; Max-Age=${Math.floor(maxAgeSec)}; HttpOnly; SameSite=Strict${CFG.cookieSecure ? '; Secure' : ''}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function readCookie(req, name) {
|
|
|
|
|
|
for (const part of (req.headers.cookie || '').split(';')) {
|
|
|
|
|
|
const i = part.indexOf('=');
|
|
|
|
|
|
if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Возвращает { expires } для cookie-сессии, { expires: null } для Bearer-ключа (скрипты, curl) или null.
|
|
|
|
|
|
function adminAuth(req) {
|
|
|
|
|
|
if (!CFG.adminKey) return null; // ключ не задан — админка выключена
|
|
|
|
|
|
const token = readCookie(req, COOKIE);
|
|
|
|
|
|
if (token) {
|
|
|
|
|
|
const id = tokenId(token);
|
|
|
|
|
|
const exp = sessions.get(id);
|
|
|
|
|
|
if (exp && exp > Date.now()) return { expires: exp };
|
|
|
|
|
|
sessions.delete(id);
|
|
|
|
|
|
}
|
2026-10-30 17:20:47 +01:00
|
|
|
|
const auth = req.headers.authorization || '';
|
2026-10-30 20:53:04 +01:00
|
|
|
|
if (auth.startsWith('Bearer ') && keyMatches(auth.slice(7))) return { expires: null };
|
|
|
|
|
|
return null;
|
2026-10-30 17:20:47 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- статика ----------
|
|
|
|
|
|
|
|
|
|
|
|
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',
|
|
|
|
|
|
'.ico': 'image/x-icon',
|
|
|
|
|
|
'.woff2': 'font/woff2',
|
|
|
|
|
|
'.txt': 'text/plain; charset=utf-8',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Все файлы страницы читаются в память при старте: их мало, и так нет обращений к диску по путям из запроса.
|
|
|
|
|
|
function loadStatic(dir) {
|
|
|
|
|
|
const files = new Map();
|
|
|
|
|
|
const walk = (d) => {
|
|
|
|
|
|
for (const name of readdirSync(d)) {
|
|
|
|
|
|
const full = join(d, name);
|
|
|
|
|
|
if (statSync(full).isDirectory()) { walk(full); continue; }
|
|
|
|
|
|
const rel = '/' + relative(dir, full).split(sep).join('/');
|
|
|
|
|
|
const ext = extname(name).toLowerCase();
|
|
|
|
|
|
if (!MIME[ext]) continue;
|
|
|
|
|
|
const body = readFileSync(full);
|
|
|
|
|
|
files.set(rel, { body, type: MIME[ext], etag: `"${createHash('sha1').update(body).digest('base64url').slice(0, 16)}"` });
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
walk(dir);
|
|
|
|
|
|
// Чистые адреса: / → index.html, /admin → admin.html
|
|
|
|
|
|
for (const [path, file] of [...files]) {
|
|
|
|
|
|
if (path.endsWith('/index.html')) files.set(path.slice(0, -'index.html'.length), file);
|
|
|
|
|
|
if (path.endsWith('.html')) files.set(path.slice(0, -'.html'.length), file);
|
|
|
|
|
|
}
|
|
|
|
|
|
return files;
|
|
|
|
|
|
}
|
|
|
|
|
|
const STATIC = loadStatic(CFG.publicDir);
|
|
|
|
|
|
|
|
|
|
|
|
const CSP = [
|
|
|
|
|
|
"default-src 'self'",
|
|
|
|
|
|
"script-src 'self' 'unsafe-inline'",
|
|
|
|
|
|
"style-src 'self' 'unsafe-inline'",
|
|
|
|
|
|
"font-src 'self'",
|
|
|
|
|
|
"img-src 'self' data:",
|
|
|
|
|
|
"connect-src 'self'",
|
|
|
|
|
|
"base-uri 'none'",
|
|
|
|
|
|
"form-action 'none'",
|
|
|
|
|
|
// Teams может открывать вкладку «Веб-сайт» во фрейме — разрешаем встраивание только туда.
|
|
|
|
|
|
"frame-ancestors 'self' https://teams.microsoft.com https://*.teams.microsoft.com https://*.office.com https://*.cloud.microsoft",
|
|
|
|
|
|
].join('; ');
|
|
|
|
|
|
|
|
|
|
|
|
function serveStatic(req, res, url) {
|
|
|
|
|
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
|
|
|
|
res.writeHead(405, { Allow: 'GET, HEAD' });
|
|
|
|
|
|
return res.end();
|
|
|
|
|
|
}
|
|
|
|
|
|
const file = STATIC.get(url.pathname);
|
|
|
|
|
|
if (!file) {
|
|
|
|
|
|
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
|
|
|
|
return res.end('Не найдено');
|
|
|
|
|
|
}
|
|
|
|
|
|
const headers = {
|
|
|
|
|
|
'Content-Type': file.type,
|
|
|
|
|
|
'Content-Length': file.body.length,
|
|
|
|
|
|
ETag: file.etag,
|
|
|
|
|
|
'X-Content-Type-Options': 'nosniff',
|
|
|
|
|
|
'Referrer-Policy': 'no-referrer',
|
|
|
|
|
|
'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), interest-cohort=()',
|
|
|
|
|
|
};
|
|
|
|
|
|
if (file.type.startsWith('text/html')) {
|
|
|
|
|
|
headers['Content-Security-Policy'] = CSP;
|
|
|
|
|
|
headers['Cache-Control'] = 'no-cache';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
headers['Cache-Control'] = url.pathname.startsWith('/fonts/') ? 'public, max-age=31536000, immutable' : 'public, max-age=3600';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (url.pathname.startsWith('/admin')) headers['X-Robots-Tag'] = 'noindex, nofollow';
|
|
|
|
|
|
if (req.headers['if-none-match'] === file.etag) {
|
|
|
|
|
|
delete headers['Content-Length'];
|
|
|
|
|
|
res.writeHead(304, headers);
|
|
|
|
|
|
return res.end();
|
|
|
|
|
|
}
|
|
|
|
|
|
res.writeHead(200, headers);
|
|
|
|
|
|
res.end(req.method === 'HEAD' ? undefined : file.body);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- ответы ----------
|
|
|
|
|
|
|
|
|
|
|
|
const MAX_BODY = 2000;
|
|
|
|
|
|
const TOO_LARGE = Symbol('too_large');
|
|
|
|
|
|
function readJson(req) {
|
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
|
if (Number(req.headers['content-length'] || 0) > MAX_BODY) return resolve(TOO_LARGE);
|
|
|
|
|
|
let size = 0;
|
|
|
|
|
|
const chunks = [];
|
|
|
|
|
|
req.on('data', (c) => {
|
|
|
|
|
|
size += c.length;
|
|
|
|
|
|
if (size > MAX_BODY) { req.removeAllListeners('data'); req.pause(); resolve(TOO_LARGE); return; }
|
|
|
|
|
|
chunks.push(c);
|
|
|
|
|
|
});
|
|
|
|
|
|
req.on('end', () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
|
|
|
|
resolve(data && typeof data === 'object' ? data : null);
|
|
|
|
|
|
} catch { resolve(null); }
|
|
|
|
|
|
});
|
|
|
|
|
|
req.on('error', () => resolve(null));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function tooLarge(res) {
|
|
|
|
|
|
res.setHeader('Connection', 'close');
|
|
|
|
|
|
json(res, 413, { error: 'too_large' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function json(res, status, data) {
|
|
|
|
|
|
const body = JSON.stringify(data);
|
|
|
|
|
|
res.writeHead(status, {
|
|
|
|
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
|
|
|
|
'Content-Length': Buffer.byteLength(body),
|
|
|
|
|
|
'Cache-Control': 'no-store',
|
|
|
|
|
|
'X-Content-Type-Options': 'nosniff',
|
|
|
|
|
|
'Referrer-Policy': 'no-referrer',
|
|
|
|
|
|
});
|
|
|
|
|
|
res.end(body);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- резервные копии ----------
|
|
|
|
|
|
|
|
|
|
|
|
// Раз в сутки и при старте: копия базы в DATA_DIR/backups, хранится BACKUP_KEEP последних.
|
|
|
|
|
|
function backup() {
|
|
|
|
|
|
if (CFG.backupKeep <= 0) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const dir = join(CFG.dataDir, 'backups');
|
|
|
|
|
|
mkdirSync(dir, { recursive: true });
|
|
|
|
|
|
const stamp = new Date().toISOString().slice(0, 10);
|
|
|
|
|
|
const file = join(dir, `bingo-${stamp}.sqlite`);
|
|
|
|
|
|
try { unlinkSync(file); } catch { /* копии за сегодня ещё нет */ }
|
|
|
|
|
|
db.prepare('VACUUM INTO ?').run(file);
|
|
|
|
|
|
const old = readdirSync(dir).filter((f) => /^bingo-\d{4}-\d{2}-\d{2}\.sqlite$/.test(f)).sort();
|
|
|
|
|
|
for (const f of old.slice(0, Math.max(0, old.length - CFG.backupKeep))) unlinkSync(join(dir, f));
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Резервная копия не создана:', err.message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- запуск ----------
|
|
|
|
|
|
|
|
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
|
|
|
|
let url;
|
|
|
|
|
|
try {
|
|
|
|
|
|
url = new URL(req.url, 'http://localhost');
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
res.writeHead(400);
|
|
|
|
|
|
return res.end();
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (url.pathname === '/healthz') {
|
|
|
|
|
|
readTotal();
|
|
|
|
|
|
return json(res, 200, { ok: true });
|
|
|
|
|
|
}
|
|
|
|
|
|
if (url.pathname.startsWith('/api/')) return await handleApi(req, res, url);
|
|
|
|
|
|
return serveStatic(req, res, url);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Ошибка запроса', req.method, url.pathname, err);
|
|
|
|
|
|
if (!res.headersSent) json(res, 500, { error: 'server_error' });
|
|
|
|
|
|
else res.end();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
server.requestTimeout = 15000;
|
|
|
|
|
|
server.headersTimeout = 10000;
|
|
|
|
|
|
|
|
|
|
|
|
backup();
|
|
|
|
|
|
const backupTimer = setInterval(backup, 24 * 3600 * 1000);
|
|
|
|
|
|
backupTimer.unref();
|
|
|
|
|
|
|
|
|
|
|
|
server.listen(CFG.port, () => {
|
|
|
|
|
|
const s = season();
|
|
|
|
|
|
console.log(`Бинго «Баланс 2.0» слушает порт ${CFG.port}. Сезон ${CFG.start} — ${CFG.end} (${CFG.tz}), цель ${CFG.goal}, сейчас: ${s.phase}.`);
|
|
|
|
|
|
if (!CFG.adminKey) console.log('ADMIN_KEY не задан — страница /admin выключена.');
|
2026-10-30 20:53:04 +01:00
|
|
|
|
else console.log(`Вход в /admin действует ${CFG.sessionHours} ч.${CFG.cookieSecure ? '' : ' Внимание: COOKIE_SECURE выключен.'}`);
|
2026-10-30 17:20:47 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
function shutdown() {
|
|
|
|
|
|
server.close(() => { db.close(); process.exit(0); });
|
|
|
|
|
|
setTimeout(() => process.exit(0), 5000).unref();
|
|
|
|
|
|
}
|
|
|
|
|
|
process.on('SIGTERM', shutdown);
|
|
|
|
|
|
process.on('SIGINT', shutdown);
|