Files
balance2/bingo/README.md
T
2026-10-30 17:20:47 +01:00

21 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#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.

Обновить

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.