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

23 KiB
Raw Blame History

Бинго «Баланс 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.

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:

# Бинго «Баланс 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;
    }
}

Установка:

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:

# Бинго «Баланс 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.

Проверка

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.

Обновить

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.