Files
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

333 lines
20 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Бинго «Баланс 2.0» · статистика</title>
<meta name="robots" content="noindex, nofollow">
<meta name="referrer" content="no-referrer">
<style>
@font-face { font-family: 'Unbounded'; font-style: normal; font-display: swap; font-weight: 200 900;
src: url(fonts/unbounded-cyrillic-wght-normal.woff2) format('woff2'); unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; }
@font-face { font-family: 'Unbounded'; font-style: normal; font-display: swap; font-weight: 200 900;
src: url(fonts/unbounded-latin-wght-normal.woff2) format('woff2'); unicode-range: U+0000-00FF,U+2000-206F; }
@font-face { font-family: 'Manrope'; font-style: normal; font-display: swap; font-weight: 200 800;
src: url(fonts/manrope-cyrillic-wght-normal.woff2) format('woff2'); unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; }
@font-face { font-family: 'Manrope'; font-style: normal; font-display: swap; font-weight: 200 800;
src: url(fonts/manrope-latin-wght-normal.woff2) format('woff2'); unicode-range: U+0000-00FF,U+2000-206F; }
/* Layout: сводка сверху, затем недели (таблица), опрос (полосы по неделям), клетки (полосы по рядам). */
:root {
color-scheme: dark;
--bg: #12233D; --panel: #1B3150; --line: #2A4468; --fg: #F2F5FA; --muted: #A9B8CC; --ink: #12233D; --accent: #C6F432;
--c-body: #FF8A65; --c-focus: #7CC4FF; --c-bounds: #B9A6FF; --c-people: #C6F432;
--p-full: #C6F432; --p-mid: #7CC4FF; --p-low: #FF8A65; --danger: #FF8A8A;
--display: 'Unbounded', 'Arial Black', system-ui, sans-serif;
--text: 'Manrope', 'Segoe UI', system-ui, sans-serif;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.45 var(--text); -webkit-font-smoothing: antialiased; }
button, input { font: inherit; color: inherit; }
:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
.page {
max-width: 1100px; margin: 0 auto; display: grid; gap: 28px;
padding-block: 40px 64px;
padding-left: max(16px, env(safe-area-inset-left, 0px));
padding-right: max(16px, env(safe-area-inset-right, 0px));
}
.eyebrow { margin: 0; font-size: 12px; font-weight: 700; letter-spacing: .14em; color: var(--muted); text-transform: uppercase; }
h1 { margin: 6px 0 0; font-family: var(--display); font-weight: 800; font-size: clamp(26px, 5vw, 40px); line-height: 1.1; }
h1 span { color: var(--accent); }
h2 { margin: 0; font-family: var(--display); font-weight: 700; font-size: 18px; }
.head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-end; gap: 16px; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
.btn {
cursor: pointer; padding: 9px 14px; border-radius: 999px; border: 1px solid var(--line);
background: transparent; font-weight: 700; font-size: 14px;
}
.btn:hover { border-color: var(--muted); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--ink); }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 20px; padding: 22px; display: grid; gap: 16px; min-width: 0; }
.panel-head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 8px 16px; }
.note { margin: 0; color: var(--muted); font-size: 13px; }
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.kpi { background: var(--panel); border: 1px solid var(--line); border-radius: 18px; padding: 16px 18px; }
.kpi dt { color: var(--muted); font-size: 13px; font-weight: 600; }
.kpi dd { margin: 4px 0 0; font-family: var(--display); font-weight: 700; font-size: 28px; font-variant-numeric: tabular-nums; }
.kpi dd small { font-family: var(--text); font-size: 13px; color: var(--muted); font-weight: 600; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; font-size: 14px; }
th, td { padding: 10px 12px; text-align: right; border-bottom: 1px solid var(--line); white-space: nowrap; }
th:first-child, td:first-child { text-align: left; }
th { color: var(--muted); font-weight: 700; font-size: 12px; letter-spacing: .04em; text-transform: uppercase; }
tbody tr:last-child td { border-bottom: 0; }
td.strong { font-weight: 800; }
.legend { display: flex; flex-wrap: wrap; gap: 14px; font-size: 13px; color: var(--muted); }
.legend i { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; }
.pulse-rows { display: grid; gap: 10px; }
.pulse-row { display: grid; grid-template-columns: 110px minmax(0, 1fr) 60px; gap: 12px; align-items: center; font-size: 14px; }
.pulse-row .n { text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
.stack { display: flex; height: 26px; border-radius: 8px; overflow: hidden; background: color-mix(in srgb, var(--fg) 6%, transparent); gap: 2px; }
.stack span { display: grid; place-items: center; font-size: 12px; font-weight: 800; color: var(--ink); min-width: 0; overflow: hidden; }
.cells { display: grid; gap: 18px; }
.cell-group h3 { margin: 0 0 8px; font-size: 13px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; }
.bar-row { display: grid; grid-template-columns: minmax(0, 260px) minmax(0, 1fr) 44px; gap: 12px; align-items: center; padding: 4px 0; font-size: 14px; }
.bar-row .label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bar-row .label b { font-variant-numeric: tabular-nums; margin-right: 6px; }
.bar { height: 12px; border-radius: 6px; background: color-mix(in srgb, var(--fg) 6%, transparent); overflow: hidden; }
.bar i { display: block; height: 100%; border-radius: 6px; }
.bar-row .n { text-align: right; font-weight: 800; font-variant-numeric: tabular-nums; }
.login { max-width: 460px; }
.login form { display: flex; gap: 8px; flex-wrap: wrap; }
.login input { flex: 1 1 220px; padding: 10px 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--bg); }
.error { color: var(--danger); font-weight: 700; margin: 0; }
@media (max-width: 640px) {
.bar-row { grid-template-columns: minmax(0, 1fr) 44px; }
.bar-row .bar { grid-column: 1 / -1; grid-row: 2; }
.pulse-row { grid-template-columns: 80px minmax(0, 1fr) 40px; }
}
</style>
</head>
<body>
<div class="page">
<header class="head">
<div>
<p class="eyebrow">Project: Balance · сезон 2 · только для организаторов</p>
<h1>Статистика бинго <span>2.0</span></h1>
</div>
<div class="actions" id="actions" hidden>
<button class="btn" type="button" id="reload">Обновить</button>
<button class="btn primary" type="button" data-csv="weeks">CSV: недели</button>
<button class="btn primary" type="button" data-csv="cells">CSV: клетки</button>
<button class="btn primary" type="button" data-csv="pulse">CSV: опрос</button>
<button class="btn" type="button" id="logout">Выйти</button>
</div>
</header>
<section class="panel login" id="login" hidden>
<h2>Вход для организаторов</h2>
<p class="note">Вставьте ключ администратора или откройте ссылку для входа. После входа ключ убирается из адреса, а браузер помнит вход ограниченное время.</p>
<form id="login-form">
<input id="key-input" type="password" autocomplete="current-password" placeholder="Ключ администратора" aria-label="Ключ администратора">
<button class="btn primary" type="submit" id="login-btn">Войти</button>
</form>
<p class="error" id="login-error" hidden></p>
</section>
<div id="content" hidden>
<div style="display:grid;gap:28px">
<dl class="kpis" id="kpis"></dl>
<section class="panel">
<div class="panel-head">
<h2>Шаги по неделям</h2>
<p class="note">Шаги = отмеченные клетки + 5 за каждую собранную линию (ряд, столбец, диагональ). Устройства — сколько браузеров отметили хоть одну клетку.</p>
</div>
<div class="table-wrap"><table id="weeks-table"></table></div>
</section>
<section class="panel">
<div class="panel-head">
<h2>«Твоя батарейка сейчас?»</h2>
<div class="legend"><span><i style="background:var(--p-full)"></i>⚡ Полная</span><span><i style="background:var(--p-mid)"></i>🔋 Средне</span><span><i style="background:var(--p-low)"></i>🪫 На нуле</span></div>
</div>
<div class="pulse-rows" id="pulse-rows"></div>
</section>
<section class="panel">
<div class="panel-head">
<h2>Популярность клеток</h2>
<p class="note">Сколько раз клетку отметили за весь сезон.</p>
</div>
<div class="cells" id="cells"></div>
</section>
<p class="note" id="generated"></p>
</div>
</div>
</div>
<script>
(() => {
'use strict';
const ROWS = [
{ name: 'Тело', color: 'var(--c-body)', cells: ['Обед без экрана', 'Разминка между созвонами', 'Гимнастика для глаз', 'Сон до полуночи'] },
{ name: 'Фокус', color: 'var(--c-focus)', cells: ['Фокус-блок на одну задачу', '3 главных дела на день', 'Встреча превратилась в сообщение', 'Время залогировано в тот же день'] },
{ name: 'Границы', color: 'var(--c-bounds)', cells: ['Сказать «нет» или «не сейчас»', 'Вечер без рабочих сообщений', 'Попросить о помощи', 'Ноутбук закрыт вовремя'] },
{ name: 'Люди и радость', color: 'var(--c-people)', cells: ['Офлайн с друзьями или семьёй', 'Хобби, спорт или что-то руками', '«Спасибо» или помощь коллеге', 'Мем в чат Мемопятницы'] },
];
const MONTHS = ['янв', 'фев', 'мар', 'апр', 'мая', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'];
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const cellName = (n) => ROWS[Math.floor((n - 1) / 4)].cells[(n - 1) % 4];
const rowName = (n) => ROWS[Math.floor((n - 1) / 4)].name;
const dayLabel = (iso) => `${+iso.slice(8, 10)} ${MONTHS[+iso.slice(5, 7) - 1]}`;
function weekRange(iso) {
const d = new Date(iso + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + 6);
return `${dayLabel(iso)} — ${dayLabel(d.toISOString().slice(0, 10))}`;
}
const weekTitle = (w) => (w.weekNo >= 1 ? `Неделя ${w.weekNo}` : 'До старта');
let data = null;
function showLogin(message) {
data = null;
$('login').hidden = false;
$('content').hidden = true;
$('actions').hidden = true;
$('login-error').textContent = message || '';
$('login-error').hidden = !message;
if (!message) $('key-input').focus();
}
async function postJson(url, body) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
credentials: 'same-origin',
cache: 'no-store',
});
return { status: res.status, data: await res.json().catch(() => null) };
}
// Ключ обменивается на сессию (cookie ставит сервер); сам ключ нигде не сохраняется.
async function login(key) {
$('login-btn').disabled = true;
try {
const { status, data: body } = await postJson('api/admin/login', { key });
if (status === 200) { $('key-input').value = ''; return load(); }
if (body?.error === 'admin_disabled') return showLogin('Админка выключена: на сервере не задан ADMIN_KEY.');
if (status === 401) return showLogin('Ключ не подошёл. Проверьте его и попробуйте ещё раз.');
return showLogin(`Сервер ответил ошибкой ${status}. Попробуйте через минуту.`);
} catch {
return showLogin('Нет связи с сервером. Проверьте интернет и попробуйте ещё раз.');
} finally {
$('login-btn').disabled = false;
}
}
async function load(expiredNotice) {
try {
const res = await fetch('api/admin/stats', { credentials: 'same-origin', cache: 'no-store' });
if (res.status === 401) {
const body = await res.json().catch(() => null);
if (body?.error === 'admin_disabled') return showLogin('Админка выключена: на сервере не задан ADMIN_KEY.');
return showLogin(expiredNotice ? 'Вход закончился. Введите ключ ещё раз.' : '');
}
if (!res.ok) throw new Error(`Сервер ответил ошибкой ${res.status}. Попробуйте обновить через минуту.`);
data = await res.json();
$('login').hidden = true;
$('content').hidden = false;
$('actions').hidden = false;
render();
} catch (err) {
showLogin(err.message.startsWith('Сервер') ? err.message : 'Нет связи с сервером. Попробуйте обновить через минуту.');
}
}
function render() {
const s = data.season;
const cur = data.weeks.find((w) => w.week === s.week);
const pulseTotal = data.weeks.reduce((n, w) => n + w.pulse.full + w.pulse.mid + w.pulse.low, 0);
const pct = Math.floor((data.total / s.goal) * 100);
$('kpis').innerHTML = [
['Батарейка', `${data.total} <small>из ${s.goal} · ${pct}%</small>`],
['Сейчас', s.phase === 'active' ? `Неделя ${s.weekNo} <small>из ${s.weeks}</small>` : s.phase === 'pre' ? 'До старта' : 'Сезон завершён'],
['Устройств на этой неделе', cur ? cur.devices : 0],
['Ответов на опрос', pulseTotal],
].map(([t, v]) => `<div class="kpi"><dt>${t}</dt><dd>${v}</dd></div>`).join('');
const wrows = data.weeks.map((w) => `<tr>
<td><b>${weekTitle(w)}</b> <span style="color:var(--muted)">${weekRange(w.week)}</span></td>
<td class="strong">${w.steps}</td><td>${w.cells}</td><td>${w.rows}</td><td>${w.cols}</td><td>${w.diags}</td><td>${w.devices}</td>
<td>${w.pulse.full} / ${w.pulse.mid} / ${w.pulse.low}</td></tr>`).join('');
$('weeks-table').innerHTML = `<thead><tr><th>Неделя</th><th>Шаги</th><th>Клетки</th><th>Ряды</th><th>Столбцы</th><th>Диагонали</th><th>Устройства</th><th>⚡ / 🔋 / 🪫</th></tr></thead>
<tbody>${wrows || '<tr><td colspan="8" style="color:var(--muted)">Пока нет ни одной отметки.</td></tr>'}</tbody>`;
const pulseWeeks = data.weeks.filter((w) => w.pulse.full + w.pulse.mid + w.pulse.low > 0);
$('pulse-rows').innerHTML = pulseWeeks.length ? pulseWeeks.map((w) => {
const n = w.pulse.full + w.pulse.mid + w.pulse.low;
const seg = (v, c) => v ? `<span style="flex:${v};background:var(${c})" title="${Math.round(v / n * 100)}%">${v / n >= 0.12 ? Math.round(v / n * 100) + '%' : ''}</span>` : '';
return `<div class="pulse-row"><span>${weekTitle(w)}</span><div class="stack">${seg(w.pulse.full, '--p-full')}${seg(w.pulse.mid, '--p-mid')}${seg(w.pulse.low, '--p-low')}</div><span class="n">${n}</span></div>`;
}).join('') : '<p class="note">Ответов пока нет.</p>';
const max = Math.max(1, ...data.cells.map((c) => c.total));
$('cells').innerHTML = ROWS.map((row, r) => `<div class="cell-group"><h3 style="color:${row.color}">${row.name}</h3>${
[1, 2, 3, 4].map((k) => {
const c = data.cells[r * 4 + k - 1];
return `<div class="bar-row"><span class="label" title="${esc(cellName(c.cell))}"><b>${String(c.cell).padStart(2, '0')}</b>${esc(cellName(c.cell))}</span><div class="bar"><i style="width:${c.total / max * 100}%;background:${row.color}"></i></div><span class="n">${c.total}</span></div>`;
}).join('')}</div>`).join('');
const until = data.session?.expiresAt
? ` Вход действует до ${new Date(data.session.expiresAt).toLocaleString('ru-RU', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}.`
: '';
$('generated').textContent = `Данные на ${new Date(data.generatedAt).toLocaleString('ru-RU')}. Часовой пояс сезона: ${s.tz}.${until}`;
}
// CSV с разделителем «;» и BOM — открывается в русском Excel без мастера импорта.
function csv(rows) {
return '' + rows.map((r) => r.map((v) => {
const s = String(v ?? '');
return /[;"\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}).join(';')).join('\r\n');
}
function download(name, text) {
const url = URL.createObjectURL(new Blob([text], { type: 'text/csv;charset=utf-8' }));
const a = document.createElement('a');
a.href = url; a.download = name;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
const exporters = {
weeks: () => csv([
['Неделя', 'Понедельник', 'Шаги', 'Клетки', 'Собранные ряды', 'Собранные столбцы', 'Собранные диагонали', 'Устройства', 'Полная', 'Средне', 'На нуле'],
...data.weeks.map((w) => [w.weekNo, w.week, w.steps, w.cells, w.rows, w.cols, w.diags, w.devices, w.pulse.full, w.pulse.mid, w.pulse.low]),
]),
cells: () => {
const weeks = data.weeks.map((w) => w.week);
return csv([
['Клетка', 'Ряд', 'Название', 'Всего', ...data.weeks.map((w) => `Неделя ${w.weekNo} (${w.week})`)],
...data.cells.map((c) => [c.cell, rowName(c.cell), cellName(c.cell), c.total, ...weeks.map((w) => c.byWeek[w] || 0)]),
]);
},
pulse: () => csv([
['Неделя', 'Понедельник', 'Полная', 'Средне', 'На нуле', 'Всего ответов'],
...data.weeks.map((w) => [w.weekNo, w.week, w.pulse.full, w.pulse.mid, w.pulse.low, w.pulse.full + w.pulse.mid + w.pulse.low]),
]),
};
document.addEventListener('click', (e) => {
const b = e.target.closest('[data-csv]');
if (b && data) download(`balance-bingo-${b.dataset.csv}-${new Date().toISOString().slice(0, 10)}.csv`, exporters[b.dataset.csv]());
});
$('reload').addEventListener('click', () => load(true));
$('logout').addEventListener('click', async () => {
try { await postJson('api/admin/logout'); } catch { /* сессия всё равно истечёт сама */ }
showLogin('');
});
$('login-form').addEventListener('submit', (e) => {
e.preventDefault();
const key = $('key-input').value.trim();
if (key) login(key);
});
// Ссылка для входа /admin#k=КЛЮЧ: сразу убираем ключ из адреса и истории, затем входим.
const linkKey = new URLSearchParams(location.hash.slice(1)).get('k');
if (location.hash) history.replaceState(null, '', location.pathname + location.search);
if (linkKey) login(linkKey);
else load();
})();
</script>
</body>
</html>