initial commit
This commit is contained in:
commit
b6f0791243
24 files changed
+3966
No files matched your search
@@ -0,0 +1,30 @@
|
||||
// Служебные команды. В Docker:
|
||||
// docker compose exec bingo node server/cli.mjs backup — копия базы прямо сейчас
|
||||
// docker compose exec bingo node server/cli.mjs reset --yes — стереть все отметки и ответы (после тестов)
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { openDb } from './db.mjs';
|
||||
|
||||
const dataDir = process.env.DATA_DIR || new URL('../data', import.meta.url).pathname;
|
||||
const [cmd, flag] = process.argv.slice(2);
|
||||
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
const db = openDb(join(dataDir, 'bingo.sqlite'));
|
||||
|
||||
if (cmd === 'backup') {
|
||||
mkdirSync(join(dataDir, 'backups'), { recursive: true });
|
||||
const file = join(dataDir, 'backups', `bingo-manual-${new Date().toISOString().replace(/[:.]/g, '-')}.sqlite`);
|
||||
db.prepare('VACUUM INTO ?').run(file);
|
||||
console.log(`Копия сохранена: ${file}`);
|
||||
} else if (cmd === 'reset') {
|
||||
if (flag !== '--yes') {
|
||||
console.log('Это сотрёт все отметки, ответы опроса и обнулит батарейку. Повторите с --yes, чтобы подтвердить.');
|
||||
process.exit(1);
|
||||
}
|
||||
db.exec("BEGIN; DELETE FROM marks; DELETE FROM pulse; UPDATE totals SET value = 0 WHERE key = 'steps'; COMMIT;");
|
||||
console.log('Данные очищены, батарейка обнулена.');
|
||||
} else {
|
||||
console.log('Команды: backup | reset --yes');
|
||||
process.exit(1);
|
||||
}
|
||||
db.close();
|
||||
@@ -0,0 +1,30 @@
|
||||
// Общая часть для сервера и утилит: открыть базу, создать таблицы, правила подсчёта линий.
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
export const LINE_BONUS = 5;
|
||||
// Диагонали карточки 4×4: 01-06-11-16 и 04-07-10-13.
|
||||
const DIAGONALS = [[1, 6, 11, 16], [4, 7, 10, 13]];
|
||||
|
||||
// Собранные линии — по одной строке на каждую: ряды, столбцы и диагонали.
|
||||
export const LINES_SQL = `
|
||||
SELECT device, week, 'row' AS kind FROM marks
|
||||
GROUP BY device, week, (cell - 1) / 4 HAVING COUNT(*) = 4
|
||||
UNION ALL
|
||||
SELECT device, week, 'col' AS kind FROM marks
|
||||
GROUP BY device, week, (cell - 1) % 4 HAVING COUNT(*) = 4
|
||||
UNION ALL
|
||||
SELECT device, week, 'diag' AS kind FROM marks WHERE cell IN (${DIAGONALS[0].join(', ')})
|
||||
GROUP BY device, week HAVING COUNT(*) = 4
|
||||
UNION ALL
|
||||
SELECT device, week, 'diag' AS kind FROM marks WHERE cell IN (${DIAGONALS[1].join(', ')})
|
||||
GROUP BY device, week HAVING COUNT(*) = 4`;
|
||||
|
||||
const SCHEMA = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
|
||||
|
||||
export function openDb(file) {
|
||||
const db = new DatabaseSync(file);
|
||||
db.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA busy_timeout = 5000;');
|
||||
db.exec(SCHEMA);
|
||||
return db;
|
||||
}
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user