344 lines
21 KiB
Markdown
344 lines
21 KiB
Markdown
# Бинго «Баланс 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`.
|