// Бинго «Баланс 2.0» — автономный сервер: Node.js ≥ 22.13 + встроенный SQLite, без npm-зависимостей. // // Отдаёт страницу из ./public и API: // GET /api/total → общая батарейка + настройки сезона (страница опрашивает раз в 20 с) // GET /api/me?d= → отметки и ответ опроса этого устройства за текущую неделю // POST /api/mark {d, week, cell, on} → отметить / снять клетку // POST /api/pulse {d, week, answer} → ответ на «Твоя батарейка сейчас?» // GET /api/admin/stats → статистика (заголовок Authorization: Bearer ) // 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 } 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), }; 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 символов.'); // ---------- база ---------- 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/stats' && method === 'GET') { if (!isAdmin(req)) return json(res, 403, { error: 'forbidden' }); return json(res, 200, adminStats()); } 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])), })), }; } const sha = (s) => createHash('sha256').update(s).digest(); function isAdmin(req) { if (!CFG.adminKey) return false; // ключ не задан — админка выключена const auth = req.headers.authorization || ''; const given = auth.startsWith('Bearer ') ? auth.slice(7) : ''; return timingSafeEqual(sha(given), sha(CFG.adminKey)); } // ---------- статика ---------- 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 выключена.'); }); function shutdown() { server.close(() => { db.close(); process.exit(0); }); setTimeout(() => process.exit(0), 5000).unref(); } process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown);