# Бинго «Баланс 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#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` страница работает в режиме тренировки: клетки отмечаются, но в батарейку не идут. Чтобы проверить всё по-настоящему: 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`). - Админка закрыта ключом. Если хочется закрыть её ещё и по сети, добавьте в 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`.