Files

491 lines
21 KiB
JavaScript
Raw Permalink Normal View History

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} → ответ на «Твоя батарейка сейчас?»
// 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';
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),
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 символов.');
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 });
}
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') {
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])),
})),
};
}
// ---------- вход в админку ----------
//
// Ключ вводится один раз (или приходит по ссылке /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();
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 || '';
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 выключена.');
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);