Бинго «Баланс 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 на другой машине. Поставьте в
.envHOST_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#k=ВАШ_ADMIN_KEY. Часть после#не уходит на сервер и не попадает в журналы, ключ передаётся только в заголовке запроса.
Настройки (.env)
| Параметр | По умолчанию | Что значит |
|---|---|---|
ADMIN_KEY |
пусто | Ключ админки, от 16 символов. Пусто — админка выключена |
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 страница работает в режиме тренировки: клетки отмечаются, но в батарейку не идут.
Чтобы проверить всё по-настоящему:
- В
.envвременно поставьтеSEASON_STARTна дату в прошлом,docker compose up -d. - Потыкайте с телефона и компьютера, откройте админку, закрепите вкладку в Teams.
- Верните
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). - Админка закрыта ключом. Если хочется закрыть её ещё и по сети, добавьте в nginx
location /admin { allow <сеть офиса/VPN>; deny all; proxy_pass … }.
Админка
/admin#k=КЛЮЧ показывает батарейку и шаги по неделям: клетки, собранные ряды, столбцы и диагонали,
число устройств. Там же ответы на мини-вопрос по неделям и популярность каждой клетки. Три кнопки
выгружают CSV (разделитель ;, UTF-8 с BOM — открывается в Excel без мастера импорта). Ключ меняется в .env,
затем docker compose up -d.
Нагрузка
Страница опрашивает батарейку раз в 20 секунд и только пока вкладка видна. Каждый опрос — одно чтение
одной строки. Для 25 человек это доли процента одного ядра и несколько десятков МБ памяти. Частота опроса —
константа POLL_MS в public/index.html.