Files
balance2/bingo/README.md
T
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

366 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Бинго «Баланс 2.0»
Одна страница без регистрации: личная карточка 4×4 + общая «батарейка команды».
Работает в одном Docker-контейнере: Node.js со встроенным SQLite. Нет npm-зависимостей, облачных
сервисов, CDN и сторонних шрифтов. После сборки образа контейнеру не нужен интернет. HTTPS и внешний
доступ даёт nginx на том же хосте.
```
Пользователь ──https──▶ nginx :443 ──http──▶ 127.0.0.1:8080 контейнер bingo ──▶ том bingo-data (SQLite)
```
```
public/index.html — страница бинго (всё в одном файле)
public/admin.html — статистика для организаторов (/admin)
public/fonts/ — Unbounded и Manrope (кириллица + латиница), свои копии
server/server.mjs — HTTP-сервер: страница, API, заголовки безопасности, ежедневные копии базы
server/db.mjs — база и правила подсчёта линий
server/cli.mjs — служебные команды: backup, reset
schema.sql — таблицы SQLite
Dockerfile, compose.yaml — образ и запуск
.env.example — все настройки
deploy/nginx/bingo.conf — nginx: бинго на своём поддомене
deploy/nginx/bingo-subpath.conf — nginx: бинго в подпапке существующего сайта
tools/build-demo.mjs — демо-версия без сервера (dist/demo.html)
```
## 1. Запустить контейнер
Нужен сервер с Docker и Docker Compose v2. При первой сборке скачивается образ `node:22.22-alpine`.
```bash
cp .env.example .env
# в .env: ADMIN_KEY=<длинный случайный ключ>, например результат openssl rand -base64 24
docker compose up -d --build
docker compose ps # статус должен стать healthy
curl -s http://127.0.0.1:8080/api/total # {"total":0,"goal":500,...}
```
Контейнер слушает только `127.0.0.1:8080` — снаружи он недоступен, пока не настроен nginx.
## 2. nginx
Teams открывает только https-ссылки, поэтому нужен сертификат: корпоративный или от Let's Encrypt
(certbot). Выберите один из двух вариантов.
### Вариант А. Свой поддомен (рекомендуется)
Например `https://bingo.example.com/`. Файл `deploy/nginx/bingo.conf`:
```nginx
# Бинго «Баланс 2.0» на своём поддомене (рекомендуемый вариант).
# Положить в /etc/nginx/conf.d/bingo.conf (или sites-available + симлинк в sites-enabled),
# заменить bingo.example.com и пути к сертификату, затем: nginx -t && systemctl reload nginx
#
# Контейнер слушает 127.0.0.1:8080 на этом же хосте (HOST_BIND / HOST_PORT в .env).
# Ограничение частоты запросов к API. Адреса держатся только в памяти nginx и никуда не пишутся.
# Вся команда может выходить через один корпоративный NAT, поэтому запас большой:
# 25 человек дают около 1–2 запросов в секунду.
limit_req_zone $binary_remote_addr zone=bingo_api:1m rate=10r/s;
upstream bingo_app {
server 127.0.0.1:8080;
keepalive 8;
}
server {
listen 80;
listen [::]:80;
server_name bingo.example.com;
access_log off;
return 301 https://$host$request_uri;
}
server {
# nginx 1.25.1 и новее: можно писать «listen 443 ssl;» и отдельной строкой «http2 on;».
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name bingo.example.com;
ssl_certificate /etc/nginx/ssl/bingo.example.com/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/bingo.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:bingo_ssl:1m;
ssl_session_timeout 1d;
# Анонимность: не писать журнал доступа. В журнал ошибок nginx пишет адрес клиента,
# поэтому уровень crit — туда попадают только поломки самого nginx. Для отладки временно поставьте error.
access_log off;
error_log /var/log/nginx/bingo.error.log crit;
# Страница сама отдаёт CSP, nosniff, Referrer-Policy и т. д. Здесь добавляется только HSTS.
# ВАЖНО: не добавляйте X-Frame-Options — он запретит вкладку «Веб-сайт» в Teams.
# Встраивание уже ограничено заголовком CSP frame-ancestors от приложения.
add_header Strict-Transport-Security "max-age=31536000" always;
client_max_body_size 8k; # приложению хватает 2 КБ
gzip on;
gzip_types application/json text/css text/javascript;
location / {
proxy_pass http://bingo_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
# X-Forwarded-For намеренно не передаётся: приложению IP не нужен.
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
}
location /api/ {
limit_req zone=bingo_api burst=40 nodelay;
limit_req_status 429;
limit_req_log_level warn;
proxy_pass http://bingo_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
}
# Служебная проверка контейнера — снаружи не нужна.
location = /healthz {
return 404;
}
}
```
Установка:
```bash
sudo cp deploy/nginx/bingo.conf /etc/nginx/conf.d/bingo.conf
sudo nano /etc/nginx/conf.d/bingo.conf # поменять bingo.example.com и пути к сертификату
sudo nginx -t && sudo systemctl reload nginx
```
На Debian/Ubuntu с `sites-available` положите файл туда и сделайте симлинк в `sites-enabled`.
Если сертификат выпускает certbot: `sudo certbot --nginx -d bingo.example.com` пропишет пути сам.
Строки `listen [::]:…` можно удалить, если на сервере нет IPv6.
### Вариант Б. Подпапка существующего сайта
Например `https://intranet.example.com/bingo/`. Страница и админка используют относительные пути,
поэтому работают в любой папке. Файл `deploy/nginx/bingo-subpath.conf`:
```nginx
# Бинго «Баланс 2.0» в подпапке существующего сайта: https://intranet.example.com/bingo/
# Используйте, если отдельного поддомена нет. Страница работает по относительным путям,
# поэтому имя папки может быть любым — поменяйте /bingo/ во всех строках ниже.
# --- 1. В контекст http (например, отдельный файл /etc/nginx/conf.d/bingo-zone.conf) ---
limit_req_zone $binary_remote_addr zone=bingo_api:1m rate=10r/s;
# --- 2. Внутрь существующего блока server { ... } сайта ---
# Без завершающего слэша относительные пути страницы указывали бы мимо папки.
location = /bingo {
access_log off;
return 301 /bingo/;
}
location /bingo/ {
access_log off;
client_max_body_size 8k;
# Любой add_header внутри location отменяет заголовки, унаследованные от server.
# Это нужно, если сайт добавляет X-Frame-Options: иначе Teams не покажет вкладку.
add_header X-Robots-Tag "noindex, nofollow" always;
proxy_pass http://127.0.0.1:8080/; # слэш в конце убирает префикс /bingo
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
}
location /bingo/api/ {
access_log off;
client_max_body_size 8k;
add_header X-Robots-Tag "noindex, nofollow" always;
limit_req zone=bingo_api burst=40 nodelay;
limit_req_status 429;
limit_req_log_level warn;
proxy_pass http://127.0.0.1:8080/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
}
location = /bingo/healthz {
access_log off;
return 404;
}
```
Строку `limit_req_zone` положите в контекст `http` (например, в `/etc/nginx/conf.d/bingo-zone.conf`),
блоки `location` — внутрь `server` вашего сайта. Затем `sudo nginx -t && sudo systemctl reload nginx`.
### На что обратить внимание
- **X-Frame-Options.** Не добавляйте его для бинго: он запретит вкладку «Веб-сайт» в Teams. Встраивание
уже ограничено заголовком приложения `Content-Security-Policy: frame-ancestors` (сам сайт и домены Teams/Office).
В варианте Б строка `add_header X-Robots-Tag` внутри `location` нужна как раз для этого: она отключает
наследование `add_header` от `server`, в том числе X-Frame-Options, если он там есть.
- **Журналы.** `access_log off` — чтобы IP-адреса участников нигде не сохранялись. Журнал ошибок nginx тоже
содержит адрес клиента, поэтому в варианте А у него уровень `crit`. Для отладки временно поставьте `error`.
- **IP не передаётся в приложение.** `X-Forwarded-For` и `X-Real-IP` намеренно не задаются: приложению адрес не нужен.
- **Ограничение частоты.** 10 запросов в секунду с адреса и запас 40 сверху, отказ — код 429. Если вся команда
выходит через один корпоративный NAT, этого хватает: 25 человек дают 1–2 запроса в секунду.
- **`/healthz`** закрыт снаружи. Docker проверяет его изнутри контейнера.
- **nginx в контейнере, а не на хосте.** Тогда `127.0.0.1` указывает на сам контейнер nginx. Подключите nginx
к сети этого compose-проекта (`balance-bingo_default`) и замените `127.0.0.1:8080` на `bingo:8080`.
Проброс порта на хост в `compose.yaml` в этом случае можно убрать.
- **nginx на другой машине.** Поставьте в `.env` `HOST_BIND=<внутренний IP сервера>` и закройте порт 8080
файрволом для всех, кроме nginx.
### Проверка
```bash
curl -sI https://bingo.example.com/ | grep -iE "^HTTP|strict-transport|content-security"
curl -s https://bingo.example.com/api/total
```
Затем откройте страницу с телефона, отметьте клетку и посмотрите, что батарейка выросла.
## 3. Ссылки
- **Для команды** — `https://bingo.example.com/`. Закрепите в канале Teams сообщением или вкладкой «Веб-сайт».
- **Для организаторов** — `https://bingo.example.com/admin`, вход по ключу из `.env`. Можно и ссылкой
для входа `https://bingo.example.com/admin#k=ВАШ_ADMIN_KEY`: страница сразу убирает ключ из адреса
и истории браузера, обменивает его на сессию, и дальше открыт обычный `/admin`. Такую ссылку не стоит
пересылать в чатах — она сама по себе пароль.
## Настройки (`.env`)
| Параметр | По умолчанию | Что значит |
|---|---|---|
| `ADMIN_KEY` | пусто | Ключ админки, от 16 символов. Пусто — админка выключена |
| `ADMIN_SESSION_HOURS` | `8` | Сколько часов действует вход в админку, см. «Админка» |
| `COOKIE_SECURE` | включено | `0` — cookie входа без флага Secure. Только для проверки по http, не в работе |
| `TIMEZONE` | `Europe/Moscow` | В каком поясе карточка обнуляется в понедельник 00:00 |
| `SEASON_START` | `2026-11-02` | Первый день, когда отметки идут в батарейку |
| `SEASON_END` | `2026-12-21` | Последний такой день (включительно) |
| `GOAL` | `500` | Цель сезона в шагах |
| `BACKUP_KEEP` | `14` | Сколько ежедневных копий базы хранить, `0` — не делать |
| `HOST_BIND` / `HOST_PORT` | `127.0.0.1` / `8080` | Куда пробросить порт на хосте. При смене порта поправьте `upstream` в nginx |
После изменения: `docker compose up -d`. Страница берёт настройки с сервера, править HTML не нужно.
Ошибку в датах, поясе или цели сервер покажет при старте (`docker compose logs bingo`) и не запустится.
Вместо `ADMIN_KEY` можно передать файл с ключом через `ADMIN_KEY_FILE` (например, Docker secret).
**Про даты.** 21 декабря 2026 — понедельник. При текущих настройках 8-я неделя длится один день
(финал в понедельник). Если нужны 8 полных недель, поставьте `SEASON_END=2026-12-27`.
## Проверить до старта
До `SEASON_START` страница работает в режиме тренировки: клетки отмечаются, но в батарейку не идут.
Чтобы проверить всё по-настоящему:
1. В `.env` временно поставьте `SEASON_START` на дату в прошлом, `docker compose up -d`.
2. Потыкайте с телефона и компьютера, откройте админку, закрепите вкладку в Teams.
3. Верните `SEASON_START=2026-11-02`, `docker compose up -d` и сотрите тестовые данные:
`docker compose exec bingo node server/cli.mjs reset --yes`
Без Docker (нужен Node.js 22.13+): `npm start` → http://localhost:8080, база появится в `./data`.
Демо без сервера — `…/#demo` или `npm run build:demo`.
## Данные и резервные копии
База — один файл SQLite в томе `bingo-data` (`/data/bingo.sqlite` внутри контейнера). Переживает
перезапуск, `docker compose down` и пересборку образа. Не переживает только `docker compose down -v`.
- Копия делается при старте и раз в сутки: `/data/backups/bingo-ГГГГ-ММ-ДД.sqlite`, хранятся `BACKUP_KEEP` последних.
- Копия прямо сейчас: `docker compose exec bingo node server/cli.mjs backup`
- Забрать копии на хост: `docker compose cp bingo:/data/backups ./backups`
- Восстановить: `docker compose stop bingo`, положить копию как `/data/bingo.sqlite`
(`docker compose cp ./копия.sqlite bingo:/data/bingo.sqlite`), удалить рядом `bingo.sqlite-wal`
и `bingo.sqlite-shm`, затем `docker compose start bingo`.
## Обновить
```bash
docker compose up -d --build # данные в томе сохраняются
```
## Как считается батарейка
- Отмеченная клетка = 1 шаг, каждая собранная линия = ещё +5. Линии: 4 ряда, 4 столбца
и 2 диагонали (01-06-11-16 и 04-07-10-13). Одна клетка может замкнуть сразу несколько линий —
тогда бонусы складываются. Полная карточка за неделю = 16 + 10 × 5 = 66 шагов.
- Снятие отметки на той же неделе вычитает шаг, а если линия распалась — и её бонус.
- Отметки прошлых недель не трогаются, поэтому батарейка копится весь сезон.
- Сервер пересчитывает сумму целиком при каждой отметке, в одной транзакции, так что она не «уплывает».
- Выше цели показывается «Овердрайв!», счёт продолжается.
Сервер принимает отметки только за текущую неделю и только в даты сезона; одна клетка с одного
устройства за неделю засчитывается один раз.
## Что хранится
| Где | Что |
|---|---|
| Браузер (localStorage) | случайный id устройства, отметки этой недели, ответ на мини-вопрос |
| SQLite `marks` | id устройства · понедельник недели · номер клетки |
| SQLite `pulse` | id устройства · понедельник недели · ответ (полная / средне / на нуле) |
| SQLite `totals` | общая сумма шагов |
Нет имён, email, логинов, IP, времени событий, аналитики и сторонних запросов. Приложение не ведёт
журнал запросов, nginx настроен без журнала доступа.
Публично видна только общая сумма. Сменил браузер или почистил данные — карточка начинается заново,
уже набранные шаги остаются в батарейке.
Защиты от накрутки нет: кто захочет, может открыть страницу в десяти браузерах. Для командной игры
это осознанный компромисс в пользу анонимности.
## Безопасность
- Процесс в контейнере работает не от root (пользователь `node`).
- Файловая система контейнера только для чтения, запись идёт только в том `/data`.
- Все capabilities сброшены, включён `no-new-privileges`.
- Порт слушает только `127.0.0.1`.
- Тело запроса ограничено: 2 КБ в приложении, 8 КБ в nginx.
- У контейнера есть healthcheck (`/healthz`).
- Админка: вход по ключу, дальше сессия в cookie (подробности — в разделе «Админка»). Если хочется закрыть её ещё и по сети, добавьте в nginx
`location /admin { allow <сеть офиса/VPN>; deny all; proxy_pass … }`.
## Админка
`/admin` показывает батарейку и шаги по неделям: клетки, собранные ряды, столбцы и диагонали,
число устройств. Там же ответы на мини-вопрос по неделям и популярность каждой клетки. Три кнопки
выгружают CSV (разделитель `;`, UTF-8 с BOM — открывается в Excel без мастера импорта).
**Вход.** Ключ вводится один раз в форме на странице (или через ссылку `/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`.
## Нагрузка
Страница опрашивает батарейку раз в 20 секунд и только пока вкладка видна. Каждый опрос — одно чтение
одной строки. Для 25 человек это доли процента одного ядра и несколько десятков МБ памяти. Частота опроса —
константа `POLL_MS` в `public/index.html`.