2026-10-30 17:20:47 +01:00
|
|
|
|
# Бинго «Баланс 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 сообщением или вкладкой «Веб-сайт».
|
2026-10-30 20:53:04 +01:00
|
|
|
|
- **Для организаторов** — `https://bingo.example.com/admin`, вход по ключу из `.env`. Можно и ссылкой
|
|
|
|
|
|
для входа `https://bingo.example.com/admin#k=ВАШ_ADMIN_KEY`: страница сразу убирает ключ из адреса
|
|
|
|
|
|
и истории браузера, обменивает его на сессию, и дальше открыт обычный `/admin`. Такую ссылку не стоит
|
|
|
|
|
|
пересылать в чатах — она сама по себе пароль.
|
2026-10-30 17:20:47 +01:00
|
|
|
|
|
|
|
|
|
|
## Настройки (`.env`)
|
|
|
|
|
|
|
|
|
|
|
|
| Параметр | По умолчанию | Что значит |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| `ADMIN_KEY` | пусто | Ключ админки, от 16 символов. Пусто — админка выключена |
|
2026-10-30 20:53:04 +01:00
|
|
|
|
| `ADMIN_SESSION_HOURS` | `8` | Сколько часов действует вход в админку, см. «Админка» |
|
|
|
|
|
|
| `COOKIE_SECURE` | включено | `0` — cookie входа без флага Secure. Только для проверки по http, не в работе |
|
2026-10-30 17:20:47 +01:00
|
|
|
|
| `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`).
|
2026-10-30 20:53:04 +01:00
|
|
|
|
- Админка: вход по ключу, дальше сессия в cookie (подробности — в разделе «Админка»). Если хочется закрыть её ещё и по сети, добавьте в nginx
|
2026-10-30 17:20:47 +01:00
|
|
|
|
`location /admin { allow <сеть офиса/VPN>; deny all; proxy_pass … }`.
|
|
|
|
|
|
|
|
|
|
|
|
## Админка
|
|
|
|
|
|
|
2026-10-30 20:53:04 +01:00
|
|
|
|
`/admin` показывает батарейку и шаги по неделям: клетки, собранные ряды, столбцы и диагонали,
|
2026-10-30 17:20:47 +01:00
|
|
|
|
число устройств. Там же ответы на мини-вопрос по неделям и популярность каждой клетки. Три кнопки
|
2026-10-30 20:53:04 +01:00
|
|
|
|
выгружают 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`.
|
2026-10-30 17:20:47 +01:00
|
|
|
|
|
|
|
|
|
|
## Нагрузка
|
|
|
|
|
|
|
|
|
|
|
|
Страница опрашивает батарейку раз в 20 секунд и только пока вкладка видна. Каждый опрос — одно чтение
|
|
|
|
|
|
одной строки. Для 25 человек это доли процента одного ядра и несколько десятков МБ памяти. Частота опроса —
|
|
|
|
|
|
константа `POLL_MS` в `public/index.html`.
|