initial commit
This commit is contained in:
commit
b6f0791243
24 files changed
+3966
No files matched your search
+343
@@ -0,0 +1,343 @@
|
||||
# Бинго «Баланс 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`.
|
||||
Reference in new issue
Block a user