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
This commit is contained in:
1 parent
b6f0791243
commit
9b421c9b47
8 files changed
+320
-66
No files matched your search
@@ -4,6 +4,9 @@
|
|||||||
# Пустой ключ = админка выключена.
|
# Пустой ключ = админка выключена.
|
||||||
ADMIN_KEY=
|
ADMIN_KEY=
|
||||||
|
|
||||||
|
# Сколько часов действует вход в /admin (с момента входа, без продления). Перезапуск контейнера тоже завершает вход.
|
||||||
|
ADMIN_SESSION_HOURS=8
|
||||||
|
|
||||||
# Сезон
|
# Сезон
|
||||||
TIMEZONE=Europe/Moscow # в каком поясе карточка обнуляется в понедельник 00:00
|
TIMEZONE=Europe/Moscow # в каком поясе карточка обнуляется в понедельник 00:00
|
||||||
SEASON_START=2026-11-02 # первый день, когда отметки идут в батарейку
|
SEASON_START=2026-11-02 # первый день, когда отметки идут в батарейку
|
||||||
|
|||||||
+26
-4
@@ -54,7 +54,7 @@ Browser ──https──▶ nginx :443 (host) ──http──▶ 127.0.0.1:808
|
|||||||
|
|
||||||
```
|
```
|
||||||
public/index.html page: card, row highlight + confetti, battery, weekly pulse question, demo mode
|
public/index.html page: card, row highlight + confetti, battery, weekly pulse question, demo mode
|
||||||
public/admin.html organiser stats, CSV export; key passed in URL fragment (#k=...)
|
public/admin.html organiser stats, CSV export; key login → session cookie, logout
|
||||||
public/fonts/*.woff2 Unbounded + Manrope, Cyrillic + Latin subsets
|
public/fonts/*.woff2 Unbounded + Manrope, Cyrillic + Latin subsets
|
||||||
server/server.mjs HTTP server, API, season/week logic, security headers, daily backups
|
server/server.mjs HTTP server, API, season/week logic, security headers, daily backups
|
||||||
server/db.mjs openDb(), LINES_SQL (row/col/diag detection), LINE_BONUS = 5
|
server/db.mjs openDb(), LINES_SQL (row/col/diag detection), LINE_BONUS = 5
|
||||||
@@ -91,6 +91,8 @@ rejects marks (`season_closed`). `http://localhost:8080/#demo` forces demo mode
|
|||||||
| Var | Default | Notes |
|
| Var | Default | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `ADMIN_KEY` / `ADMIN_KEY_FILE` | empty | empty = admin disabled; < 16 chars = refuses to start |
|
| `ADMIN_KEY` / `ADMIN_KEY_FILE` | empty | empty = admin disabled; < 16 chars = refuses to start |
|
||||||
|
| `ADMIN_SESSION_HOURS` | `8` | admin session lifetime from login, absolute (no sliding); 0 < h ≤ 720 |
|
||||||
|
| `COOKIE_SECURE` | on | `0` drops the `Secure` cookie flag — only for plain-http testing |
|
||||||
| `TIMEZONE` | `Europe/Moscow` | weekly reset is Monday 00:00 in this zone |
|
| `TIMEZONE` | `Europe/Moscow` | weekly reset is Monday 00:00 in this zone |
|
||||||
| `SEASON_START` | `2026-11-02` | before it: "practice mode", marks not accepted by server |
|
| `SEASON_START` | `2026-11-02` | before it: "practice mode", marks not accepted by server |
|
||||||
| `SEASON_END` | `2026-12-21` | inclusive; after it: season closed |
|
| `SEASON_END` | `2026-12-21` | inclusive; after it: season closed |
|
||||||
@@ -126,7 +128,9 @@ Invalid config makes the server exit at startup with a clear message.
|
|||||||
| `GET /api/me?d=<id>` | — | `{week, cells:[...], pulse}` — this device's current week |
|
| `GET /api/me?d=<id>` | — | `{week, cells:[...], pulse}` — this device's current week |
|
||||||
| `POST /api/mark` | `{d, week, cell 1–16, on: bool}` | `{ok, week, total, cells}`; 409 `week_changed`, 403 `season_closed`, 400 validation |
|
| `POST /api/mark` | `{d, week, cell 1–16, on: bool}` | `{ok, week, total, cells}`; 409 `week_changed`, 403 `season_closed`, 400 validation |
|
||||||
| `POST /api/pulse` | `{d, week, answer: full/mid/low}` | `{ok, answer}`; one answer per device per week, can be changed |
|
| `POST /api/pulse` | `{d, week, answer: full/mid/low}` | `{ok, answer}`; one answer per device per week, can be changed |
|
||||||
| `GET /api/admin/stats` | `Authorization: Bearer <ADMIN_KEY>` | weeks (cells, rows, cols, diags, steps, devices, pulse), per-cell counts |
|
| `POST /api/admin/login` | `{key}` | 200 + `Set-Cookie: bingo_admin=<token>`; 401 `bad_key` (after 500 ms); 403 `admin_disabled` |
|
||||||
|
| `POST /api/admin/logout` | — | deletes the session, clears the cookie |
|
||||||
|
| `GET /api/admin/stats` | session cookie, or `Authorization: Bearer <ADMIN_KEY>` for scripts | weeks (cells, rows, cols, diags, steps, devices, pulse), per-cell counts, `session.expiresAt`; 401 without auth |
|
||||||
| `GET /healthz` | — | `{ok:true}`; blocked by nginx from outside |
|
| `GET /healthz` | — | `{ok:true}`; blocked by nginx from outside |
|
||||||
|
|
||||||
Device id `d` = random UUID generated in the browser and kept in localStorage (`balance2-bingo-v1`),
|
Device id `d` = random UUID generated in the browser and kept in localStorage (`balance2-bingo-v1`),
|
||||||
@@ -141,6 +145,19 @@ regex `^[A-Za-z0-9-]{16,64}$`. Request bodies are capped at 2 KB (413 above that
|
|||||||
- Only the team total is public. The admin page shows aggregates only.
|
- Only the team total is public. The admin page shows aggregates only.
|
||||||
- Accepted trade-off: no anti-cheat (someone can use 10 browsers). Don't add fingerprinting or IP limits that store data.
|
- Accepted trade-off: no anti-cheat (someone can use 10 browsers). Don't add fingerprinting or IP limits that store data.
|
||||||
|
|
||||||
|
## Admin authentication
|
||||||
|
|
||||||
|
- The key is never kept in the URL. `/admin#k=KEY` still works as a login link: the page immediately
|
||||||
|
`history.replaceState`s the fragment away, POSTs the key to `/api/admin/login`, then shows plain `/admin`.
|
||||||
|
The login form does the same without touching the URL.
|
||||||
|
- The server answers with `bingo_admin=<32 random bytes, base64url>; Max-Age; HttpOnly; SameSite=Strict; Secure`.
|
||||||
|
**No `Path` on purpose**: the browser defaults it to the request directory (`/api/admin`, or
|
||||||
|
`/<prefix>/api/admin` behind the subpath nginx config), so the cookie only goes to admin API calls.
|
||||||
|
- Sessions live in an in-memory `Map` keyed by sha256(token) → expiry (max 200, oldest evicted).
|
||||||
|
Absolute lifetime `ADMIN_SESSION_HOURS` (default 8 h), no sliding. A container restart logs everyone out — accepted.
|
||||||
|
- Wrong key → 401 after a 500 ms delay. The key is compared with `timingSafeEqual` over sha256 digests.
|
||||||
|
- `Authorization: Bearer <ADMIN_KEY>` is still accepted on `/api/admin/stats` for curl/scripts.
|
||||||
|
|
||||||
## Teams embedding gotcha
|
## Teams embedding gotcha
|
||||||
|
|
||||||
The page must be embeddable as a Teams "Website" tab. The server sends
|
The page must be embeddable as a Teams "Website" tab. The server sends
|
||||||
@@ -166,7 +183,11 @@ A test asserts the header is absent.
|
|||||||
|
|
||||||
## Verification status
|
## Verification status
|
||||||
|
|
||||||
- `npm test` passes (scoring incl. rows/cols/diagonals, stale week, validation, 413, pulse, admin auth/totals, headers).
|
- `npm test` passes, 11 tests: scoring incl. rows/cols/diagonals, stale week, validation, 413, pulse,
|
||||||
|
admin totals, admin login/cookie flags/logout, session expiry, admin disabled, COOKIE_SECURE=0, headers.
|
||||||
|
- Admin login flow verified in a real browser over https through both nginx configs (30 Oct 2026):
|
||||||
|
key removed from URL/history, cookie HttpOnly + Secure + SameSite=Strict with path `/api/admin`
|
||||||
|
(or `/bingo/api/admin`), survives reload, logout ends it, wrong key shows an error.
|
||||||
- Manually verified (17–18 Oct 2026): desktop + 375px + 320px layouts, no horizontal overflow;
|
- Manually verified (17–18 Oct 2026): desktop + 375px + 320px layouts, no horizontal overflow;
|
||||||
pre-season / overdrive / weekly reset states; container healthy, data persists across restart and
|
pre-season / overdrive / weekly reset states; container healthy, data persists across restart and
|
||||||
`down`/`up`, runs with network disconnected, non-root, read-only rootfs.
|
`down`/`up`, runs with network disconnected, non-root, read-only rootfs.
|
||||||
@@ -185,7 +206,8 @@ A test asserts the header is absent.
|
|||||||
(or `certbot --nginx -d <domain>`), drop `listen [::]` lines if no IPv6; `nginx -t && systemctl reload nginx`.
|
(or `certbot --nginx -d <domain>`), drop `listen [::]` lines if no IPv6; `nginx -t && systemctl reload nginx`.
|
||||||
4. Dry run: temporarily set `SEASON_START` to a past Monday, test on phone + desktop + Teams tab,
|
4. Dry run: temporarily set `SEASON_START` to a past Monday, test on phone + desktop + Teams tab,
|
||||||
then restore `2026-11-02`, `docker compose up -d`, `docker compose exec bingo node server/cli.mjs reset --yes`.
|
then restore `2026-11-02`, `docker compose up -d`, `docker compose exec bingo node server/cli.mjs reset --yes`.
|
||||||
5. Links: team → `https://<domain>/`; organisers → `https://<domain>/admin#k=<ADMIN_KEY>`.
|
5. Links: team → `https://<domain>/`; organisers → `https://<domain>/admin` (log in with the key;
|
||||||
|
`/admin#k=<ADMIN_KEY>` also works as a one-click login link and is stripped from the URL).
|
||||||
6. Backups: `docker compose cp bingo:/data/backups ./backups` occasionally, or back up the `balance-bingo_bingo-data` volume.
|
6. Backups: `docker compose cp bingo:/data/backups ./backups` occasionally, or back up the `balance-bingo_bingo-data` volume.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|||||||
+28
-6
@@ -233,14 +233,18 @@ curl -s https://bingo.example.com/api/total
|
|||||||
## 3. Ссылки
|
## 3. Ссылки
|
||||||
|
|
||||||
- **Для команды** — `https://bingo.example.com/`. Закрепите в канале Teams сообщением или вкладкой «Веб-сайт».
|
- **Для команды** — `https://bingo.example.com/`. Закрепите в канале Teams сообщением или вкладкой «Веб-сайт».
|
||||||
- **Для организаторов** — `https://bingo.example.com/admin#k=ВАШ_ADMIN_KEY`. Часть после `#` не уходит
|
- **Для организаторов** — `https://bingo.example.com/admin`, вход по ключу из `.env`. Можно и ссылкой
|
||||||
на сервер и не попадает в журналы, ключ передаётся только в заголовке запроса.
|
для входа `https://bingo.example.com/admin#k=ВАШ_ADMIN_KEY`: страница сразу убирает ключ из адреса
|
||||||
|
и истории браузера, обменивает его на сессию, и дальше открыт обычный `/admin`. Такую ссылку не стоит
|
||||||
|
пересылать в чатах — она сама по себе пароль.
|
||||||
|
|
||||||
## Настройки (`.env`)
|
## Настройки (`.env`)
|
||||||
|
|
||||||
| Параметр | По умолчанию | Что значит |
|
| Параметр | По умолчанию | Что значит |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `ADMIN_KEY` | пусто | Ключ админки, от 16 символов. Пусто — админка выключена |
|
| `ADMIN_KEY` | пусто | Ключ админки, от 16 символов. Пусто — админка выключена |
|
||||||
|
| `ADMIN_SESSION_HOURS` | `8` | Сколько часов действует вход в админку, см. «Админка» |
|
||||||
|
| `COOKIE_SECURE` | включено | `0` — cookie входа без флага Secure. Только для проверки по http, не в работе |
|
||||||
| `TIMEZONE` | `Europe/Moscow` | В каком поясе карточка обнуляется в понедельник 00:00 |
|
| `TIMEZONE` | `Europe/Moscow` | В каком поясе карточка обнуляется в понедельник 00:00 |
|
||||||
| `SEASON_START` | `2026-11-02` | Первый день, когда отметки идут в батарейку |
|
| `SEASON_START` | `2026-11-02` | Первый день, когда отметки идут в батарейку |
|
||||||
| `SEASON_END` | `2026-12-21` | Последний такой день (включительно) |
|
| `SEASON_END` | `2026-12-21` | Последний такой день (включительно) |
|
||||||
@@ -326,15 +330,33 @@ docker compose up -d --build # данные в томе сохраняютс
|
|||||||
- Порт слушает только `127.0.0.1`.
|
- Порт слушает только `127.0.0.1`.
|
||||||
- Тело запроса ограничено: 2 КБ в приложении, 8 КБ в nginx.
|
- Тело запроса ограничено: 2 КБ в приложении, 8 КБ в nginx.
|
||||||
- У контейнера есть healthcheck (`/healthz`).
|
- У контейнера есть healthcheck (`/healthz`).
|
||||||
- Админка закрыта ключом. Если хочется закрыть её ещё и по сети, добавьте в nginx
|
- Админка: вход по ключу, дальше сессия в cookie (подробности — в разделе «Админка»). Если хочется закрыть её ещё и по сети, добавьте в nginx
|
||||||
`location /admin { allow <сеть офиса/VPN>; deny all; proxy_pass … }`.
|
`location /admin { allow <сеть офиса/VPN>; deny all; proxy_pass … }`.
|
||||||
|
|
||||||
## Админка
|
## Админка
|
||||||
|
|
||||||
`/admin#k=КЛЮЧ` показывает батарейку и шаги по неделям: клетки, собранные ряды, столбцы и диагонали,
|
`/admin` показывает батарейку и шаги по неделям: клетки, собранные ряды, столбцы и диагонали,
|
||||||
число устройств. Там же ответы на мини-вопрос по неделям и популярность каждой клетки. Три кнопки
|
число устройств. Там же ответы на мини-вопрос по неделям и популярность каждой клетки. Три кнопки
|
||||||
выгружают CSV (разделитель `;`, UTF-8 с BOM — открывается в Excel без мастера импорта). Ключ меняется в `.env`,
|
выгружают CSV (разделитель `;`, UTF-8 с BOM — открывается в Excel без мастера импорта).
|
||||||
затем `docker compose up -d`.
|
|
||||||
|
**Вход.** Ключ вводится один раз в форме на странице (или через ссылку `/admin#k=КЛЮЧ` — ключ сразу
|
||||||
|
убирается из адреса). Сервер проверяет его и ставит cookie сессии:
|
||||||
|
|
||||||
|
- в cookie лежит случайный токен, а не ключ; сервер хранит только хеш токена;
|
||||||
|
- `HttpOnly` — скрипты страницы его не видят, `Secure` — только по https, `SameSite=Strict` — не уходит
|
||||||
|
с запросами с чужих сайтов;
|
||||||
|
- cookie отправляется только на адреса админского API (`/api/admin/…`), не на страницу бинго;
|
||||||
|
- срок — `ADMIN_SESSION_HOURS` (по умолчанию 8 часов) с момента входа, без продления. Когда закончится,
|
||||||
|
написано внизу страницы, после этого страница снова попросит ключ;
|
||||||
|
- кнопка «Выйти» завершает сессию на сервере сразу;
|
||||||
|
- сессии хранятся в памяти, поэтому перезапуск контейнера (`docker compose up -d` после правки `.env`,
|
||||||
|
обновление) завершает все входы;
|
||||||
|
- неверный ключ отвечает с задержкой полсекунды, чтобы подбор был бессмысленным.
|
||||||
|
|
||||||
|
**Смена ключа:** новый `ADMIN_KEY` в `.env`, затем `docker compose up -d` — старые сессии тоже закончатся.
|
||||||
|
|
||||||
|
Для скриптов можно не входить, а передавать ключ в заголовке:
|
||||||
|
`curl -H "Authorization: Bearer $ADMIN_KEY" https://bingo.example.com/api/admin/stats`.
|
||||||
|
|
||||||
## Нагрузка
|
## Нагрузка
|
||||||
|
|
||||||
|
|||||||
+66
-19
@@ -108,15 +108,16 @@
|
|||||||
<button class="btn primary" type="button" data-csv="weeks">CSV: недели</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="cells">CSV: клетки</button>
|
||||||
<button class="btn primary" type="button" data-csv="pulse">CSV: опрос</button>
|
<button class="btn primary" type="button" data-csv="pulse">CSV: опрос</button>
|
||||||
|
<button class="btn" type="button" id="logout">Выйти</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="panel login" id="login" hidden>
|
<section class="panel login" id="login" hidden>
|
||||||
<h2>Нужен ключ</h2>
|
<h2>Вход для организаторов</h2>
|
||||||
<p class="note">Откройте секретную ссылку целиком (с частью после #) или вставьте ключ сюда. Ключ не уходит никуда, кроме запроса к этому сайту.</p>
|
<p class="note">Вставьте ключ администратора или откройте ссылку для входа. После входа ключ убирается из адреса, а браузер помнит вход ограниченное время.</p>
|
||||||
<form id="login-form">
|
<form id="login-form">
|
||||||
<input id="key-input" type="password" autocomplete="off" placeholder="Ключ администратора" aria-label="Ключ администратора">
|
<input id="key-input" type="password" autocomplete="current-password" placeholder="Ключ администратора" aria-label="Ключ администратора">
|
||||||
<button class="btn primary" type="submit">Открыть</button>
|
<button class="btn primary" type="submit" id="login-btn">Войти</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="error" id="login-error" hidden></p>
|
<p class="error" id="login-error" hidden></p>
|
||||||
</section>
|
</section>
|
||||||
@@ -176,14 +177,53 @@
|
|||||||
}
|
}
|
||||||
const weekTitle = (w) => (w.weekNo >= 1 ? `Неделя ${w.weekNo}` : 'До старта');
|
const weekTitle = (w) => (w.weekNo >= 1 ? `Неделя ${w.weekNo}` : 'До старта');
|
||||||
|
|
||||||
let key = new URLSearchParams(location.hash.slice(1)).get('k') || '';
|
|
||||||
let data = null;
|
let data = null;
|
||||||
|
|
||||||
async function load() {
|
function showLogin(message) {
|
||||||
$('login-error').hidden = true;
|
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 {
|
try {
|
||||||
const res = await fetch('api/admin/stats', { headers: { Authorization: `Bearer ${key}` }, cache: 'no-store' });
|
const { status, data: body } = await postJson('api/admin/login', { key });
|
||||||
if (res.status === 403) throw new Error('Ключ не подошёл. Проверьте ссылку или задайте ADMIN_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}. Попробуйте обновить через минуту.`);
|
if (!res.ok) throw new Error(`Сервер ответил ошибкой ${res.status}. Попробуйте обновить через минуту.`);
|
||||||
data = await res.json();
|
data = await res.json();
|
||||||
$('login').hidden = true;
|
$('login').hidden = true;
|
||||||
@@ -191,10 +231,7 @@
|
|||||||
$('actions').hidden = false;
|
$('actions').hidden = false;
|
||||||
render();
|
render();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
$('login').hidden = false;
|
showLogin(err.message.startsWith('Сервер') ? err.message : 'Нет связи с сервером. Попробуйте обновить через минуту.');
|
||||||
$('content').hidden = true;
|
|
||||||
$('actions').hidden = true;
|
|
||||||
if (key) { $('login-error').textContent = err.message; $('login-error').hidden = false; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +268,10 @@
|
|||||||
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>`;
|
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('');
|
}).join('')}</div>`).join('');
|
||||||
|
|
||||||
$('generated').textContent = `Данные на ${new Date(data.generatedAt).toLocaleString('ru-RU')}. Часовой пояс сезона: ${s.tz}.`;
|
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 без мастера импорта.
|
// CSV с разделителем «;» и BOM — открывается в русском Excel без мастера импорта.
|
||||||
@@ -270,15 +310,22 @@
|
|||||||
const b = e.target.closest('[data-csv]');
|
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]());
|
if (b && data) download(`balance-bingo-${b.dataset.csv}-${new Date().toISOString().slice(0, 10)}.csv`, exporters[b.dataset.csv]());
|
||||||
});
|
});
|
||||||
$('reload').addEventListener('click', load);
|
$('reload').addEventListener('click', () => load(true));
|
||||||
|
$('logout').addEventListener('click', async () => {
|
||||||
|
try { await postJson('api/admin/logout'); } catch { /* сессия всё равно истечёт сама */ }
|
||||||
|
showLogin('');
|
||||||
|
});
|
||||||
$('login-form').addEventListener('submit', (e) => {
|
$('login-form').addEventListener('submit', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
key = $('key-input').value.trim();
|
const key = $('key-input').value.trim();
|
||||||
history.replaceState(null, '', `#k=${encodeURIComponent(key)}`);
|
if (key) login(key);
|
||||||
load();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
load();
|
// Ссылка для входа /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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+81
-8
@@ -5,7 +5,9 @@
|
|||||||
// GET /api/me?d=<id> → отметки и ответ опроса этого устройства за текущую неделю
|
// GET /api/me?d=<id> → отметки и ответ опроса этого устройства за текущую неделю
|
||||||
// POST /api/mark {d, week, cell, on} → отметить / снять клетку
|
// POST /api/mark {d, week, cell, on} → отметить / снять клетку
|
||||||
// POST /api/pulse {d, week, answer} → ответ на «Твоя батарейка сейчас?»
|
// POST /api/pulse {d, week, answer} → ответ на «Твоя батарейка сейчас?»
|
||||||
// GET /api/admin/stats → статистика (заголовок Authorization: Bearer <ADMIN_KEY>)
|
// 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
|
// GET /healthz → проверка живости для Docker
|
||||||
//
|
//
|
||||||
// Хранится только: случайный id устройства, неделя, номер клетки, ответ опроса.
|
// Хранится только: случайный id устройства, неделя, номер клетки, ответ опроса.
|
||||||
@@ -14,7 +16,7 @@
|
|||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import { readFileSync, readdirSync, statSync, mkdirSync, unlinkSync } from 'node:fs';
|
import { readFileSync, readdirSync, statSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||||
import { join, extname, relative, sep } from 'node:path';
|
import { join, extname, relative, sep } from 'node:path';
|
||||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
import { createHash, timingSafeEqual, randomBytes } from 'node:crypto';
|
||||||
import { openDb, LINES_SQL, LINE_BONUS } from './db.mjs';
|
import { openDb, LINES_SQL, LINE_BONUS } from './db.mjs';
|
||||||
|
|
||||||
const ROOT = new URL('..', import.meta.url).pathname;
|
const ROOT = new URL('..', import.meta.url).pathname;
|
||||||
@@ -39,6 +41,8 @@ const CFG = {
|
|||||||
goal: parseInt(process.env.GOAL || '500', 10),
|
goal: parseInt(process.env.GOAL || '500', 10),
|
||||||
adminKey: readAdminKey(),
|
adminKey: readAdminKey(),
|
||||||
backupKeep: parseInt(process.env.BACKUP_KEEP || '14', 10),
|
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) {
|
function fail(msg) {
|
||||||
@@ -50,6 +54,7 @@ if (CFG.start > CFG.end) fail('SEASON_START позже SEASON_END.');
|
|||||||
if (!Number.isInteger(CFG.goal) || CFG.goal < 1) fail('GOAL должен быть положительным целым числом.');
|
if (!Number.isInteger(CFG.goal) || CFG.goal < 1) fail('GOAL должен быть положительным целым числом.');
|
||||||
try { new Intl.DateTimeFormat('en-US', { timeZone: CFG.tz }); } catch { fail(`неизвестный часовой пояс TIMEZONE=${CFG.tz}.`); }
|
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.adminKey && CFG.adminKey.length < 16) fail('ADMIN_KEY короче 16 символов.');
|
||||||
|
if (!(CFG.sessionHours > 0 && CFG.sessionHours <= 720)) fail('ADMIN_SESSION_HOURS должен быть больше 0 и не больше 720.');
|
||||||
|
|
||||||
// ---------- база ----------
|
// ---------- база ----------
|
||||||
|
|
||||||
@@ -175,9 +180,30 @@ async function handleApi(req, res, url) {
|
|||||||
return json(res, 200, { ok: true, 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') {
|
if (path === '/api/admin/stats' && method === 'GET') {
|
||||||
if (!isAdmin(req)) return json(res, 403, { error: 'forbidden' });
|
const auth = adminAuth(req);
|
||||||
return json(res, 200, adminStats());
|
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' });
|
return json(res, 404, { error: 'not_found' });
|
||||||
@@ -221,12 +247,58 @@ function adminStats() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- вход в админку ----------
|
||||||
|
//
|
||||||
|
// Ключ вводится один раз (или приходит по ссылке /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 sha = (s) => createHash('sha256').update(s).digest();
|
||||||
function isAdmin(req) {
|
const tokenId = (token) => sha(token).toString('hex');
|
||||||
if (!CFG.adminKey) return false; // ключ не задан — админка выключена
|
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 || '';
|
const auth = req.headers.authorization || '';
|
||||||
const given = auth.startsWith('Bearer ') ? auth.slice(7) : '';
|
if (auth.startsWith('Bearer ') && keyMatches(auth.slice(7))) return { expires: null };
|
||||||
return timingSafeEqual(sha(given), sha(CFG.adminKey));
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- статика ----------
|
// ---------- статика ----------
|
||||||
@@ -408,6 +480,7 @@ server.listen(CFG.port, () => {
|
|||||||
const s = season();
|
const s = season();
|
||||||
console.log(`Бинго «Баланс 2.0» слушает порт ${CFG.port}. Сезон ${CFG.start} — ${CFG.end} (${CFG.tz}), цель ${CFG.goal}, сейчас: ${s.phase}.`);
|
console.log(`Бинго «Баланс 2.0» слушает порт ${CFG.port}. Сезон ${CFG.start} — ${CFG.end} (${CFG.tz}), цель ${CFG.goal}, сейчас: ${s.phase}.`);
|
||||||
if (!CFG.adminKey) console.log('ADMIN_KEY не задан — страница /admin выключена.');
|
if (!CFG.adminKey) console.log('ADMIN_KEY не задан — страница /admin выключена.');
|
||||||
|
else console.log(`Вход в /admin действует ${CFG.sessionHours} ч.${CFG.cookieSecure ? '' : ' Внимание: COOKIE_SECURE выключен.'}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
function shutdown() {
|
function shutdown() {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Session expiry and the "admin disabled" case, each on its own server instance.
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { startServer, ADMIN_KEY } from './helpers.mjs';
|
||||||
|
|
||||||
|
const post = (base, path, body, headers = {}) => fetch(base + path, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
test('session expires after ADMIN_SESSION_HOURS', async () => {
|
||||||
|
const srv = await startServer({ ADMIN_SESSION_HOURS: String(1.5 / 3600) }); // 1.5 seconds
|
||||||
|
try {
|
||||||
|
const res = await post(srv.base, '/api/admin/login', { key: ADMIN_KEY });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const cookie = res.headers.get('set-cookie').split(';')[0];
|
||||||
|
assert.equal((await fetch(srv.base + '/api/admin/stats', { headers: { Cookie: cookie } })).status, 200);
|
||||||
|
await new Promise((r) => setTimeout(r, 1800));
|
||||||
|
assert.equal((await fetch(srv.base + '/api/admin/stats', { headers: { Cookie: cookie } })).status, 401);
|
||||||
|
} finally {
|
||||||
|
srv.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without ADMIN_KEY the admin API is disabled', async () => {
|
||||||
|
const srv = await startServer({ ADMIN_KEY: '' });
|
||||||
|
try {
|
||||||
|
const res = await post(srv.base, '/api/admin/login', { key: 'anything-anything-anything' });
|
||||||
|
assert.equal(res.status, 403);
|
||||||
|
assert.equal((await res.json()).error, 'admin_disabled');
|
||||||
|
assert.equal((await (await fetch(srv.base + '/api/admin/stats')).json()).error, 'admin_disabled');
|
||||||
|
} finally {
|
||||||
|
srv.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('COOKIE_SECURE=0 drops the Secure flag (plain-http testing only)', async () => {
|
||||||
|
const srv = await startServer({ COOKIE_SECURE: '0' });
|
||||||
|
try {
|
||||||
|
const res = await post(srv.base, '/api/admin/login', { key: ADMIN_KEY });
|
||||||
|
assert.doesNotMatch(res.headers.get('set-cookie'), /Secure/);
|
||||||
|
} finally {
|
||||||
|
srv.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
+39
-29
@@ -2,18 +2,9 @@
|
|||||||
// Run: npm test (Node.js >= 22.13, no dependencies)
|
// Run: npm test (Node.js >= 22.13, no dependencies)
|
||||||
import { test, before, after } from 'node:test';
|
import { test, before, after } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { spawn } from 'node:child_process';
|
import { startServer, ADMIN_KEY } from './helpers.mjs';
|
||||||
import { mkdtempSync, rmSync } from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { createServer } from 'node:net';
|
|
||||||
|
|
||||||
const ADMIN_KEY = 'test-admin-key-1234567890';
|
let server, base, week;
|
||||||
let proc, base, dataDir, week;
|
|
||||||
|
|
||||||
const freePort = () => new Promise((resolve) => {
|
|
||||||
const s = createServer().listen(0, () => { const { port } = s.address(); s.close(() => resolve(port)); });
|
|
||||||
});
|
|
||||||
|
|
||||||
async function api(path, body, headers = {}) {
|
async function api(path, body, headers = {}) {
|
||||||
const res = await fetch(base + path, {
|
const res = await fetch(base + path, {
|
||||||
@@ -27,24 +18,12 @@ const dev = (n) => `test0000-0000-0000-0000-${String(n).padStart(12, '0')}`;
|
|||||||
const mark = (d, cell, on = true, w = week) => api('/api/mark', { d, week: w, cell, on });
|
const mark = (d, cell, on = true, w = week) => api('/api/mark', { d, week: w, cell, on });
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
dataDir = mkdtempSync(join(tmpdir(), 'bingo-test-'));
|
server = await startServer();
|
||||||
const port = await freePort();
|
base = server.base;
|
||||||
base = `http://127.0.0.1:${port}`;
|
|
||||||
proc = spawn(process.execPath, ['--disable-warning=ExperimentalWarning', 'server/server.mjs'], {
|
|
||||||
env: { ...process.env, PORT: String(port), DATA_DIR: dataDir, ADMIN_KEY, SEASON_START: '2020-01-06', SEASON_END: '2099-12-31', GOAL: '500', BACKUP_KEEP: '0' },
|
|
||||||
stdio: ['ignore', 'pipe', 'inherit'],
|
|
||||||
});
|
|
||||||
for (let i = 0; i < 50; i++) {
|
|
||||||
try { if ((await fetch(base + '/healthz')).ok) break; } catch { /* not up yet */ }
|
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
|
||||||
}
|
|
||||||
week = (await api('/api/total')).data.week;
|
week = (await api('/api/total')).data.week;
|
||||||
});
|
});
|
||||||
|
|
||||||
after(() => {
|
after(() => server?.stop());
|
||||||
proc?.kill();
|
|
||||||
rmSync(dataDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('total reports season settings', async () => {
|
test('total reports season settings', async () => {
|
||||||
const { status, data } = await api('/api/total');
|
const { status, data } = await api('/api/total');
|
||||||
@@ -107,11 +86,12 @@ test('pulse answer is stored once per device and week', async () => {
|
|||||||
assert.equal((await api('/api/pulse', { d, week, answer: 'meh' })).status, 400);
|
assert.equal((await api('/api/pulse', { d, week, answer: 'meh' })).status, 400);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('admin stats need the key and add up', async () => {
|
test('admin stats need a session or the key, and add up', async () => {
|
||||||
assert.equal((await api('/api/admin/stats')).status, 403);
|
assert.equal((await api('/api/admin/stats')).status, 401);
|
||||||
assert.equal((await api('/api/admin/stats', null, { Authorization: 'Bearer wrong' })).status, 403);
|
assert.equal((await api('/api/admin/stats', null, { Authorization: 'Bearer wrong' })).status, 401);
|
||||||
const { status, data } = await api('/api/admin/stats', null, { Authorization: `Bearer ${ADMIN_KEY}` });
|
const { status, data } = await api('/api/admin/stats', null, { Authorization: `Bearer ${ADMIN_KEY}` });
|
||||||
assert.equal(status, 200);
|
assert.equal(status, 200);
|
||||||
|
assert.equal(data.session, null, 'Bearer access has no session');
|
||||||
const w = data.weeks.find((x) => x.week === week);
|
const w = data.weeks.find((x) => x.week === week);
|
||||||
assert.equal(w.steps, w.cells + 5 * (w.rows + w.cols + w.diags));
|
assert.equal(w.steps, w.cells + 5 * (w.rows + w.cols + w.diags));
|
||||||
assert.equal(data.total, data.weeks.reduce((n, x) => n + x.steps, 0));
|
assert.equal(data.total, data.weeks.reduce((n, x) => n + x.steps, 0));
|
||||||
@@ -119,6 +99,36 @@ test('admin stats need the key and add up', async () => {
|
|||||||
assert.equal(data.cells.length, 16);
|
assert.equal(data.cells.length, 16);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('admin login: key once, then HttpOnly session cookie; logout ends it', async () => {
|
||||||
|
const bad = await api('/api/admin/login', { key: 'wrong-key-wrong-key' });
|
||||||
|
assert.equal(bad.status, 401);
|
||||||
|
assert.equal(bad.headers.get('set-cookie'), null);
|
||||||
|
|
||||||
|
const ok = await api('/api/admin/login', { key: ADMIN_KEY });
|
||||||
|
assert.equal(ok.status, 200);
|
||||||
|
const setCookie = ok.headers.get('set-cookie');
|
||||||
|
assert.match(setCookie, /^bingo_admin=[A-Za-z0-9_-]{40,};/);
|
||||||
|
assert.match(setCookie, /HttpOnly/);
|
||||||
|
assert.match(setCookie, /SameSite=Strict/);
|
||||||
|
assert.match(setCookie, /Secure/);
|
||||||
|
assert.match(setCookie, /Max-Age=28800/, 'default 8 hours');
|
||||||
|
assert.doesNotMatch(setCookie, /Path=/, 'path defaults to /api/admin (or /<prefix>/api/admin behind nginx)');
|
||||||
|
assert.doesNotMatch(setCookie, new RegExp(ADMIN_KEY), 'cookie never contains the key');
|
||||||
|
const cookie = setCookie.split(';')[0];
|
||||||
|
|
||||||
|
const stats = await api('/api/admin/stats', null, { Cookie: cookie });
|
||||||
|
assert.equal(stats.status, 200);
|
||||||
|
const left = Date.parse(stats.data.session.expiresAt) - Date.now();
|
||||||
|
assert.ok(left > 7.9 * 3600e3 && left <= 8 * 3600e3, 'expires in ~8 h');
|
||||||
|
|
||||||
|
assert.equal((await api('/api/admin/stats', null, { Cookie: 'bingo_admin=forged-token-value' })).status, 401);
|
||||||
|
|
||||||
|
const out = await api('/api/admin/logout', {}, { Cookie: cookie });
|
||||||
|
assert.equal(out.status, 200);
|
||||||
|
assert.match(out.headers.get('set-cookie'), /bingo_admin=; Max-Age=0/);
|
||||||
|
assert.equal((await api('/api/admin/stats', null, { Cookie: cookie })).status, 401, 'old cookie is dead after logout');
|
||||||
|
});
|
||||||
|
|
||||||
test('static pages and security headers', async () => {
|
test('static pages and security headers', async () => {
|
||||||
for (const p of ['/', '/admin', '/admin.html']) {
|
for (const p of ['/', '/admin', '/admin.html']) {
|
||||||
const res = await fetch(base + p);
|
const res = await fetch(base + p);
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Starts the real server on a free port with a throwaway database.
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { createServer } from 'node:net';
|
||||||
|
|
||||||
|
export const ADMIN_KEY = 'test-admin-key-1234567890';
|
||||||
|
|
||||||
|
const freePort = () => new Promise((resolve) => {
|
||||||
|
const s = createServer().listen(0, () => { const { port } = s.address(); s.close(() => resolve(port)); });
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function startServer(env = {}) {
|
||||||
|
const dataDir = mkdtempSync(join(tmpdir(), 'bingo-test-'));
|
||||||
|
const port = await freePort();
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
const proc = spawn(process.execPath, ['--disable-warning=ExperimentalWarning', 'server/server.mjs'], {
|
||||||
|
env: {
|
||||||
|
...process.env, PORT: String(port), DATA_DIR: dataDir, ADMIN_KEY,
|
||||||
|
SEASON_START: '2020-01-06', SEASON_END: '2099-12-31', GOAL: '500', BACKUP_KEEP: '0', ...env,
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'pipe', 'inherit'],
|
||||||
|
});
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
try { if ((await fetch(base + '/healthz')).ok) break; } catch { /* not up yet */ }
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
base,
|
||||||
|
stop() { proc.kill(); rmSync(dataDir, { recursive: true, force: true }); },
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user