initial commit

This commit is contained in:
Olga Bregar Dunaeva committed 2026-10-30 17:20:47 +01:00
commit b6f0791243
24 files changed
+3966

No files matched your search

+418
View File
@@ -0,0 +1,418 @@
// Бинго «Баланс 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} → ответ на «Твоя батарейка сейчас?»
// GET /api/admin/stats → статистика (заголовок 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 } 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);