Files
balance2/bingo/server/server.mjs
T
Claude 9b421c9b47 Admin: log in once, keep a session cookie instead of the key in the URL
The admin key used to stay in the URL fragment and was sent with every
request. Now the key is exchanged once for a server-side session:

- POST /api/admin/login {key} sets bingo_admin=<random token>
  (HttpOnly, Secure, SameSite=Strict, no Path so it is scoped to the
  admin API directory, also behind a subpath proxy)
- POST /api/admin/logout ends the session
- sessions live in memory as sha256(token) -> expiry, absolute lifetime
  ADMIN_SESSION_HOURS (default 8); restart logs everyone out
- /admin#k=KEY still works as a login link; the page strips the fragment
  from the URL and history before logging in, then shows plain /admin
- wrong key answers 401 after 500 ms; Bearer header still works for scripts
- COOKIE_SECURE=0 for plain-http testing only

Tests: 11 passing (login, cookie flags, logout, expiry, admin disabled).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhHiznVwtQ55ZSguTBWrxn
2026-10-30 20:00:21 +00:00

492 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Бинго «Баланс 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>)
// 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';
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()),
};
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.');
// ---------- база ----------
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 });
}
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 });
}
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) → время окончания, мс
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);
}
const auth = req.headers.authorization || '';
if (auth.startsWith('Bearer ') && keyMatches(auth.slice(7))) return { expires: null };
return null;
}
// ---------- статика ----------
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 выключен.'}`);
});
function shutdown() {
server.close(() => { db.close(); process.exit(0); });
setTimeout(() => process.exit(0), 5000).unref();
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);