initial commit

This commit is contained in:
Olga Bregar Dunaeva committed 2026-10-30 17:20:47 +01:00
commit b6f0791243
24 files changed
+3966

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
.git
node_modules
data
dist
tools
tests
deploy
docs
*.md
.env
.env.*
compose*.yaml
+20
View File
@@ -0,0 +1,20 @@
# Скопируйте в .env и заполните. cp .env.example .env
# Ключ для страницы /admin (от 16 символов). Сгенерировать: openssl rand -base64 24
# Пустой ключ = админка выключена.
ADMIN_KEY=
# Сезон
TIMEZONE=Europe/Moscow # в каком поясе карточка обнуляется в понедельник 00:00
SEASON_START=2026-11-02 # первый день, когда отметки идут в батарейку
SEASON_END=2026-12-21 # последний такой день (включительно)
GOAL=500 # цель сезона в шагах
# Резервные копии базы в томе (раз в сутки), сколько последних хранить. 0 = выключить.
BACKUP_KEEP=14
# Куда пробросить порт на хосте. 127.0.0.1 — доступ только для nginx на этом же хосте.
# Если nginx на другой машине или в другом контейнере — см. README, раздел «nginx».
HOST_BIND=127.0.0.1
HOST_PORT=8080
+3
View File
@@ -0,0 +1,3 @@
node_modules/
data/
.env
+206
View File
@@ -0,0 +1,206 @@
# CLAUDE.md — Balance 2.0 Bingo
Context for anyone (human or Claude Code) picking up this project. Read this first.
## What this is
A single web page for a team wellbeing initiative, **"Project: Balance — Season 2" ("Баланс 2.0")**,
run by Olga (project manager) for a remote team of ~20–25 people at Adacta Fintech, some of them
contractors from other companies. The season runs **2 Nov – 21 Dec 2026**, 8 weeks. The link gets
pinned in a Microsoft Teams channel.
- Each person sees a **personal 4×4 bingo card** of small wellbeing habits and taps the ones they did this week.
- Every tick feeds one shared **"team battery"** that fills up over the whole season.
- The card resets every Monday 00:00 (season timezone). The battery never resets.
- **No accounts and no personal data.** This is a hard requirement — see "Anonymity" below.
The UI and all user-facing copy are **in Russian** and must stay Russian. Code comments and README
are also Russian (historical). The owner and her husband now work in English — conversation, commit
messages and new docs can be English.
The original brief (Russian) is in `docs/original-brief.md`. Some of it has been overridden; see below.
## Decisions that changed the brief
| Brief said | Now | Why |
|---|---|---|
| Only full **rows** give +5 | **Rows, columns and both diagonals** each give +5 (10 lines total) | Owner asked: "a diagonal is bingo too", then "vertical too" |
| Goal 300 steps | **Goal 500** | Owner's call after lines were added |
| Hosting: Cloudflare / Netlify / Firebase | **Self-hosted Docker Compose + nginx**, no external services | Owner wants independence from third parties |
| Fonts from Google Fonts | Fonts **self-hosted** in `public/fonts/` | No third-party requests (privacy, offline) |
The "+5 per row" wording also appears in the project's planning docs (outside this repo); the page's
rule text now reads "Собрал ряд, столбец или диагональ — +5 в батарейку".
## Architecture
```
Browser ──https──▶ nginx :443 (host) ──http──▶ 127.0.0.1:8080 container "bingo" ──▶ volume bingo-data (/data/bingo.sqlite)
```
- **Server:** `server/server.mjs` — plain `node:http`, Node.js ≥ 22.13, **built-in `node:sqlite`**,
**zero npm dependencies** (keep it that way). Serves static files from `public/` (preloaded into
memory at startup) and a small JSON API.
- **DB:** one SQLite file, WAL mode. Schema in `schema.sql`, applied idempotently on every start.
- **Frontend:** `public/index.html` (page) and `public/admin.html` (stats) — each a single self-contained
file with inline CSS/JS, no build step, no framework. All asset/API paths are **relative**
(`api/...`, `fonts/...`) so the app works on its own subdomain *or* in a subfolder behind nginx.
- **Container:** `Dockerfile` (base `node:22.22-alpine`), `compose.yaml` (project name `balance-bingo`).
Runs as user `node`, read-only rootfs, `cap_drop: ALL`, `no-new-privileges`, healthcheck on `/healthz`.
Port bound to `127.0.0.1` only.
- **Reverse proxy:** nginx on the host terminates TLS. Configs in `deploy/nginx/`.
### File map
```
public/index.html page: card, row highlight + confetti, battery, weekly pulse question, demo mode
public/admin.html organiser stats, CSV export; key passed in URL fragment (#k=...)
public/fonts/*.woff2 Unbounded + Manrope, Cyrillic + Latin subsets
server/server.mjs HTTP server, API, season/week logic, security headers, daily backups
server/db.mjs openDb(), LINES_SQL (row/col/diag detection), LINE_BONUS = 5
server/cli.mjs `backup`, `reset --yes`
schema.sql tables marks, pulse, totals
tests/api.test.mjs end-to-end tests (spawns the real server on a temp DB)
deploy/nginx/bingo.conf nginx: own subdomain (recommended)
deploy/nginx/bingo-subpath.conf nginx: subfolder of an existing site
tools/build-demo.mjs builds dist/demo.html — serverless demo for previews
Dockerfile, compose.yaml, .env.example, .dockerignore
README.md deployment guide (Russian) incl. full nginx config
docs/original-brief.md original requirements (Russian)
```
## Commands
```bash
npm start # run locally on :8080, DB in ./data (needs Node >= 22.13)
npm test # 7 end-to-end API tests, no deps
npm run build:demo # dist/demo.html (forced demo mode, Google Fonts link for preview only)
cp .env.example .env # set ADMIN_KEY (>= 16 chars)
docker compose up -d --build
docker compose logs bingo
docker compose exec bingo node server/cli.mjs backup
docker compose exec bingo node server/cli.mjs reset --yes # wipe test data before the season
```
Local testing during the pre-season: set `SEASON_START` to a past Monday, otherwise the server
rejects marks (`season_closed`). `http://localhost:8080/#demo` forces demo mode (no server calls).
## Configuration (`.env`, read by the server at start)
| Var | Default | Notes |
|---|---|---|
| `ADMIN_KEY` / `ADMIN_KEY_FILE` | empty | empty = admin disabled; < 16 chars = refuses to start |
| `TIMEZONE` | `Europe/Moscow` | weekly reset is Monday 00:00 in this zone |
| `SEASON_START` | `2026-11-02` | before it: "practice mode", marks not accepted by server |
| `SEASON_END` | `2026-12-21` | inclusive; after it: season closed |
| `GOAL` | `500` | battery target; above it the UI shows "Овердрайв!" and keeps counting |
| `BACKUP_KEEP` | `14` | daily `VACUUM INTO` copies in `/data/backups`; 0 = off |
| `HOST_BIND` / `HOST_PORT` | `127.0.0.1` / `8080` | compose port mapping only |
The page fetches goal/dates/timezone from `/api/total`, so no HTML edits are needed for config changes.
Invalid config makes the server exit at startup with a clear message.
**Open question for the owner:** 21 Dec 2026 is a Monday, so "week 8" is one day long.
`SEASON_END=2026-12-27` would give 8 full weeks. Not decided yet.
## Scoring rules (server is the source of truth)
- 1 ticked cell = 1 step. Each completed line = +5. Lines: rows 1–4, columns 1–4,
diagonals 01-06-11-16 and 04-07-10-13. Bonuses stack (a full card = 16 + 10×5 = 66).
- Unticking in the same week removes the step and the bonus of every line it breaks.
- Only the **current week** can be changed (client sends `week`, server returns 409 if it's stale).
- Primary key `(device, week, cell)` prevents double-counting.
- After every mark the server **recomputes the total from scratch** inside one transaction
(`RECOUNT_SQL` = count(marks) + 5 × count(LINES_SQL)) and caches it in `totals`.
Polling `/api/total` reads that one row.
- The client mirrors the same rules (`LINES` in `index.html`) for instant feedback and celebrations,
then reconciles with the server response. **If you change scoring, change both** `server/db.mjs`
and the `LINES` array in `public/index.html`, and update the tests and README.
## API
| Method & path | Body / query | Response |
|---|---|---|
| `GET /api/total` | — | `{total, goal, tz, start, end, week, weekNo, weeks, phase}`; phase = `pre` / `active` / `post` |
| `GET /api/me?d=<id>` | — | `{week, cells:[...], pulse}` — this device's current week |
| `POST /api/mark` | `{d, week, cell 1–16, on: bool}` | `{ok, week, total, cells}`; 409 `week_changed`, 403 `season_closed`, 400 validation |
| `POST /api/pulse` | `{d, week, answer: full/mid/low}` | `{ok, answer}`; one answer per device per week, can be changed |
| `GET /api/admin/stats` | `Authorization: Bearer <ADMIN_KEY>` | weeks (cells, rows, cols, diags, steps, devices, pulse), per-cell counts |
| `GET /healthz` | — | `{ok:true}`; blocked by nginx from outside |
Device id `d` = random UUID generated in the browser and kept in localStorage (`balance2-bingo-v1`),
regex `^[A-Za-z0-9-]{16,64}$`. Request bodies are capped at 2 KB (413 above that).
## Anonymity — non-negotiable
- Store nothing beyond: random device id, week (Monday date), cell number, pulse answer, the total.
**No** names, emails, logins, IPs, timestamps, user agents, analytics or third-party requests.
- The server logs only startup and errors — never request lines or IPs.
- nginx: `access_log off`; `X-Forwarded-For` deliberately **not** forwarded; error log at `crit`.
- Only the team total is public. The admin page shows aggregates only.
- Accepted trade-off: no anti-cheat (someone can use 10 browsers). Don't add fingerprinting or IP limits that store data.
## Teams embedding gotcha
The page must be embeddable as a Teams "Website" tab. The server sends
`Content-Security-Policy: frame-ancestors 'self' https://teams.microsoft.com https://*.teams.microsoft.com https://*.office.com https://*.cloud.microsoft`.
**Never add `X-Frame-Options`** (app or nginx); it breaks the tab. The subpath nginx config uses
an `add_header` inside `location` specifically to stop inheriting a site-wide X-Frame-Options.
A test asserts the header is absent.
## Frontend notes
- Visual spec from the brief: background `#12233D`, cells `#1B3150` with border `#2A4468`, radius 20px,
text `#F2F5FA` / muted `#A9B8CC`. Row colours: Тело `#FF8A65`, Фокус `#7CC4FF`, Границы `#B9A6FF`,
Люди и радость `#C6F432`. Accent `#C6F432`. Fonts: Unbounded (display), Manrope (text). Tokens are in `:root`.
- Deliberately single dark theme (brand look), not light/dark.
- Cells are real `<button>`s with `aria-pressed`; announcements go to an `aria-live` region; respects `prefers-reduced-motion`.
- On phones (< 760px) each row becomes 2×2, so diagonals/columns are shown via a light ring on the cells
(`.on-line`) instead of visually.
- Completed row: whole row highlighted + confetti (canvas). Completed column/diagonal: ring on its cells + confetti bursts.
- Polls `/api/total` every 20 s (`POLL_MS`), only while the tab is visible.
- Falls back to **demo mode** automatically if the API is unreachable (example battery, local-only ticks).
- Weekly pulse question "Твоя батарейка сейчас?" (⚡ Полная / 🔋 Средне / 🪫 На нуле) — anonymous counts per week,
used for a before/after comparison at the finale.
## Verification status
- `npm test` passes (scoring incl. rows/cols/diagonals, stale week, validation, 413, pulse, admin auth/totals, headers).
- Manually verified (17–18 Oct 2026): desktop + 375px + 320px layouts, no horizontal overflow;
pre-season / overdrive / weekly reset states; container healthy, data persists across restart and
`down`/`up`, runs with network disconnected, non-root, read-only rootfs.
- nginx 1.24: both configs pass `nginx -t`; full page + admin worked through both in a real browser;
rate limit returns 429; large body 413; zero access-log lines written.
- **Not yet verified:** the build on the real `node:22.22-alpine` image (the build sandbox couldn't reach
Docker Hub; a local image with the same Node 22.22.0 was used). First real `docker compose up --build`
is the check. If the `22.22-alpine` tag doesn't exist anymore, use `node:22-alpine` (needs ≥ 22.13 for `node:sqlite`).
- Not tested inside an actual Teams tab yet.
## Deployment checklist (production)
1. `cp .env.example .env`; set `ADMIN_KEY` (`openssl rand -base64 24`); confirm dates/goal; decide `SEASON_END`.
2. `docker compose up -d --build`; `docker compose ps` → healthy; `curl -s 127.0.0.1:8080/api/total`.
3. nginx: copy `deploy/nginx/bingo.conf` → `/etc/nginx/conf.d/`, set `server_name` + cert paths
(or `certbot --nginx -d <domain>`), drop `listen [::]` lines if no IPv6; `nginx -t && systemctl reload nginx`.
4. Dry run: temporarily set `SEASON_START` to a past Monday, test on phone + desktop + Teams tab,
then restore `2026-11-02`, `docker compose up -d`, `docker compose exec bingo node server/cli.mjs reset --yes`.
5. Links: team → `https://<domain>/`; organisers → `https://<domain>/admin#k=<ADMIN_KEY>`.
6. Backups: `docker compose cp bingo:/data/backups ./backups` occasionally, or back up the `balance-bingo_bingo-data` volume.
## Conventions
- No dependencies, no build step, no frameworks — the whole thing should stay auditable in an afternoon.
- Keep `public/*.html` self-contained (inline CSS/JS), Russian copy, relative URLs.
- Russian UI copy style: warm, short, a little self-irony, no coaching/therapy language
("энергия, фокус, нагрузка", not "забота о себе"). Never single anyone out.
- After changes: `npm test`; if UI changed, check 375px width and the Teams-embedding headers.
- Commit messages in English.
## Wider project context (not code, but useful)
The bingo is one part of a season of activities (weekly Monday posts, Tuesday 20-minute "quiet Tuesday"
calls, Friday chat check-ins). Weekly themes: 0 teaser (26 Oct) · 1 start · 2 where the day leaks ·
3 body at the laptop · 4 saying "no" · 5 my best fail · 6 productivity myths · 7 recharging outside work ·
8 finale (21 Dec) with battery results. Part of the team is sceptical of "self-care" — playfulness and
humour over earnestness. After the finale there's a pre-New-Year release crunch, so only light async activities.
+24
View File
@@ -0,0 +1,24 @@
# Бинго «Баланс 2.0» — один контейнер: Node.js со встроенным SQLite, без npm-зависимостей.
# Шрифты и все файлы страницы внутри образа; в работе контейнеру не нужен интернет.
FROM node:22.22-alpine
ENV NODE_ENV=production \
PORT=8080 \
DATA_DIR=/data \
PUBLIC_DIR=/app/public
WORKDIR /app
COPY --chown=root:root package.json schema.sql ./
COPY --chown=root:root server ./server
COPY --chown=root:root public ./public
# База и резервные копии — в томе /data, владелец — непривилегированный пользователь node.
RUN mkdir -p /data && chown node:node /data
VOLUME ["/data"]
USER node
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:8080/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "--disable-warning=ExperimentalWarning", "server/server.mjs"]
+343
View File
@@ -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`.
+37
View File
@@ -0,0 +1,37 @@
# Бинго «Баланс 2.0»
#
# cp .env.example .env # задать ADMIN_KEY и при необходимости даты сезона
# docker compose up -d --build
#
# Страница слушает только http://127.0.0.1:8080 — HTTPS и внешний доступ даёт nginx на этом же хосте
# (конфигурация — в README, раздел «nginx»).
name: balance-bingo
services:
bingo:
build: .
image: balance-bingo:2.0
restart: unless-stopped
env_file: .env
environment:
PORT: "8080" # порт внутри контейнера; наружу пробрасывается HOST_PORT
DATA_DIR: /data
ports:
- "${HOST_BIND:-127.0.0.1}:${HOST_PORT:-8080}:8080"
volumes:
- bingo-data:/data
read_only: true
tmpfs:
- /tmp
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
logging:
driver: local
options:
max-size: 5m
max-file: "3"
volumes:
bingo-data:
+52
View File
@@ -0,0 +1,52 @@
# Бинго «Баланс 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;
}
+78
View File
@@ -0,0 +1,78 @@
# Бинго «Баланс 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;
}
}
+1074
View File
File diff suppressed because it is too large. Load diff
+66
View File
@@ -0,0 +1,66 @@
<!-- Original brief from the project owner (Russian), 17 Oct 2026. Later decisions that override it are listed in CLAUDE.md → "Decisions that changed the brief". -->
# Задача: интерактивная страница «Бинго «Баланс 2.0»» для команды
## Контекст
Командная wellbeing-инициатива на 8 недель (2 ноября — 21 декабря 2026), ~20–25 участников.
Люди работают в разных компаниях (часть — подрядчики), общий аккаунт или SSO у всех не гарантирован.
Ссылку закрепляем в канале Microsoft Teams. Нужна одна веб-страница без регистрации.
## Что делает страница
1. Каждый видит свою карточку 4×4 и кликает по клеткам, которые сделал на этой неделе
(повторный клик снимает отметку).
2. Когда собран целый ряд (4 клетки одного ряда) — ряд подсвечивается + конфетти.
3. Внизу общая «батарейка команды» — растёт в реальном времени (достаточно опроса сервера раз в 15–30 сек).
4. Личная карточка обнуляется каждый понедельник в 00:00 (часовой пояс — настройка, по умолчанию Europe/Moscow).
Батарейка НЕ обнуляется — копится весь сезон.
## Подсчёт батарейки
- 1 отмеченная клетка = 1 шаг. Собранный ряд = +5 бонусных шагов.
- Снятие отметки в ту же неделю вычитает шаг (и бонус, если ряд распался).
- Цель сезона: 300 шагов (настройка). Отображение: «N из 300 шагов», процент и 10 делений по 10%.
- Больше 300 — показывать «Овердрайв!» и продолжать считать.
## Анонимность (важно)
- Никаких имён, email, логинов и аналитики/трекеров.
- Личная карточка хранится в браузере (localStorage) + анонимный случайный id устройства.
- На сервер уходят только события «клетка X отмечена / снята, неделя W, анонимный id» —
чтобы сервер не дал отметить одну клетку дважды за неделю с одного устройства.
- Публично видна только общая сумма. Сменил браузер — карточка начинается заново, это допустимо.
## Опционально (если несложно)
- Раз в неделю мини-вопрос «Твоя батарейка сейчас?» ⚡ Полная / 🔋 Средне / 🪫 На нуле —
анонимные счётчики по неделям (нужно для сравнения «до/после» в финале).
- Админ-страница по секретной ссылке: шаги по неделям, популярность каждой клетки,
распределение ответов на мини-вопрос, экспорт CSV.
## Клетки (ряд = тема)
Тело: 01 Обед без экрана · 02 Разминка между созвонами · 03 Гимнастика для глаз · 04 Сон до полуночи
Фокус: 05 Фокус-блок на одну задачу · 06 3 главных дела на день · 07 Встреча превратилась в сообщение · 08 Время залогировано в тот же день
Границы: 09 Сказать «нет» или «не сейчас» · 10 Вечер без рабочих сообщений · 11 Попросить о помощи · 12 Ноутбук закрыт вовремя
Люди и радость: 13 Офлайн с друзьями или семьёй · 14 Хобби, спорт или что-то руками · 15 «Спасибо» или помощь коллеге · 16 Мем в чат Мемопятницы
## Тексты
- Надзаголовок: PROJECT: BALANCE · СЕЗОН 2 · 2 НОЯБРЯ — 21 ДЕКАБРЯ
- Заголовок: Бинго «Баланс 2.0» (цифры «2.0» — акцентным цветом)
- Три правила: 1 Делай маленькие шаги · 2 Отмечай клетки за неделю · 3 Собрал ряд — +5 в батарейку
- Подпись батарейки: «Батарейка команды» / «N из 300 шагов · заполняем вместе до 21 декабря»
## Визуальный стиль
- Фон #12233D, клетки #1B3150 с рамкой #2A4468, скругление 20px.
- Текст #F2F5FA, второстепенный #A9B8CC.
- Цвета рядов (номер клетки, иконка и название ряда): Тело #FF8A65, Фокус #7CC4FF, Границы #B9A6FF, Люди и радость #C6F432.
- Акцент (батарейка, «2.0», кружки правил): #C6F432.
- Шрифты Google Fonts: Unbounded (заголовки), Manrope (текст). Нужна кириллица.
- Слева у каждого ряда — иконка (сердце, мишень, щит, чашка, линейные) и название ряда.
- Отмеченная клетка: заливка цветом ряда, тёмный текст, галочка в кружке справа вверху.
- Должно хорошо работать на телефоне (ряды могут стать 2×2), клетки — настоящие <button> с aria-pressed.
## Хостинг (для того, кто будет деплоить)
Данные неперсональные (анонимные счётчики), но страница будет вне корпоративной инфраструктуры —
стоит один раз уточнить, ок ли это для проекта.
Варианты:
- Cloudflare Pages + Worker с KV/D1 (бесплатный тариф хватит с запасом);
- Netlify/Vercel + serverless-функция + Supabase/Upstash;
- Firebase Hosting + Firestore.
Нагрузка мизерная: десятки пользователей, сотни событий в неделю.
+16
View File
@@ -0,0 +1,16 @@
{
"name": "balance-bingo",
"version": "2.0.0",
"private": true,
"description": "Бинго «Баланс 2.0»: страница + автономный Node-сервер со встроенным SQLite",
"type": "module",
"engines": {
"node": ">=22.13"
},
"scripts": {
"start": "node --disable-warning=ExperimentalWarning server/server.mjs",
"backup": "node --disable-warning=ExperimentalWarning server/cli.mjs backup",
"build:demo": "node tools/build-demo.mjs",
"test": "node --test tests/*.test.mjs"
}
}
+285
View File
@@ -0,0 +1,285 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Бинго «Баланс 2.0» · статистика</title>
<meta name="robots" content="noindex, nofollow">
<meta name="referrer" content="no-referrer">
<style>
@font-face { font-family: 'Unbounded'; font-style: normal; font-display: swap; font-weight: 200 900;
src: url(fonts/unbounded-cyrillic-wght-normal.woff2) format('woff2'); unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; }
@font-face { font-family: 'Unbounded'; font-style: normal; font-display: swap; font-weight: 200 900;
src: url(fonts/unbounded-latin-wght-normal.woff2) format('woff2'); unicode-range: U+0000-00FF,U+2000-206F; }
@font-face { font-family: 'Manrope'; font-style: normal; font-display: swap; font-weight: 200 800;
src: url(fonts/manrope-cyrillic-wght-normal.woff2) format('woff2'); unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116; }
@font-face { font-family: 'Manrope'; font-style: normal; font-display: swap; font-weight: 200 800;
src: url(fonts/manrope-latin-wght-normal.woff2) format('woff2'); unicode-range: U+0000-00FF,U+2000-206F; }
/* Layout: сводка сверху, затем недели (таблица), опрос (полосы по неделям), клетки (полосы по рядам). */
:root {
color-scheme: dark;
--bg: #12233D; --panel: #1B3150; --line: #2A4468; --fg: #F2F5FA; --muted: #A9B8CC; --ink: #12233D; --accent: #C6F432;
--c-body: #FF8A65; --c-focus: #7CC4FF; --c-bounds: #B9A6FF; --c-people: #C6F432;
--p-full: #C6F432; --p-mid: #7CC4FF; --p-low: #FF8A65; --danger: #FF8A8A;
--display: 'Unbounded', 'Arial Black', system-ui, sans-serif;
--text: 'Manrope', 'Segoe UI', system-ui, sans-serif;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.45 var(--text); -webkit-font-smoothing: antialiased; }
button, input { font: inherit; color: inherit; }
:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
.page {
max-width: 1100px; margin: 0 auto; display: grid; gap: 28px;
padding-block: 40px 64px;
padding-left: max(16px, env(safe-area-inset-left, 0px));
padding-right: max(16px, env(safe-area-inset-right, 0px));
}
.eyebrow { margin: 0; font-size: 12px; font-weight: 700; letter-spacing: .14em; color: var(--muted); text-transform: uppercase; }
h1 { margin: 6px 0 0; font-family: var(--display); font-weight: 800; font-size: clamp(26px, 5vw, 40px); line-height: 1.1; }
h1 span { color: var(--accent); }
h2 { margin: 0; font-family: var(--display); font-weight: 700; font-size: 18px; }
.head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-end; gap: 16px; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
.btn {
cursor: pointer; padding: 9px 14px; border-radius: 999px; border: 1px solid var(--line);
background: transparent; font-weight: 700; font-size: 14px;
}
.btn:hover { border-color: var(--muted); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--ink); }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 20px; padding: 22px; display: grid; gap: 16px; min-width: 0; }
.panel-head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 8px 16px; }
.note { margin: 0; color: var(--muted); font-size: 13px; }
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.kpi { background: var(--panel); border: 1px solid var(--line); border-radius: 18px; padding: 16px 18px; }
.kpi dt { color: var(--muted); font-size: 13px; font-weight: 600; }
.kpi dd { margin: 4px 0 0; font-family: var(--display); font-weight: 700; font-size: 28px; font-variant-numeric: tabular-nums; }
.kpi dd small { font-family: var(--text); font-size: 13px; color: var(--muted); font-weight: 600; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; font-size: 14px; }
th, td { padding: 10px 12px; text-align: right; border-bottom: 1px solid var(--line); white-space: nowrap; }
th:first-child, td:first-child { text-align: left; }
th { color: var(--muted); font-weight: 700; font-size: 12px; letter-spacing: .04em; text-transform: uppercase; }
tbody tr:last-child td { border-bottom: 0; }
td.strong { font-weight: 800; }
.legend { display: flex; flex-wrap: wrap; gap: 14px; font-size: 13px; color: var(--muted); }
.legend i { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 6px; vertical-align: -1px; }
.pulse-rows { display: grid; gap: 10px; }
.pulse-row { display: grid; grid-template-columns: 110px minmax(0, 1fr) 60px; gap: 12px; align-items: center; font-size: 14px; }
.pulse-row .n { text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
.stack { display: flex; height: 26px; border-radius: 8px; overflow: hidden; background: color-mix(in srgb, var(--fg) 6%, transparent); gap: 2px; }
.stack span { display: grid; place-items: center; font-size: 12px; font-weight: 800; color: var(--ink); min-width: 0; overflow: hidden; }
.cells { display: grid; gap: 18px; }
.cell-group h3 { margin: 0 0 8px; font-size: 13px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; }
.bar-row { display: grid; grid-template-columns: minmax(0, 260px) minmax(0, 1fr) 44px; gap: 12px; align-items: center; padding: 4px 0; font-size: 14px; }
.bar-row .label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bar-row .label b { font-variant-numeric: tabular-nums; margin-right: 6px; }
.bar { height: 12px; border-radius: 6px; background: color-mix(in srgb, var(--fg) 6%, transparent); overflow: hidden; }
.bar i { display: block; height: 100%; border-radius: 6px; }
.bar-row .n { text-align: right; font-weight: 800; font-variant-numeric: tabular-nums; }
.login { max-width: 460px; }
.login form { display: flex; gap: 8px; flex-wrap: wrap; }
.login input { flex: 1 1 220px; padding: 10px 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--bg); }
.error { color: var(--danger); font-weight: 700; margin: 0; }
@media (max-width: 640px) {
.bar-row { grid-template-columns: minmax(0, 1fr) 44px; }
.bar-row .bar { grid-column: 1 / -1; grid-row: 2; }
.pulse-row { grid-template-columns: 80px minmax(0, 1fr) 40px; }
}
</style>
</head>
<body>
<div class="page">
<header class="head">
<div>
<p class="eyebrow">Project: Balance · сезон 2 · только для организаторов</p>
<h1>Статистика бинго <span>2.0</span></h1>
</div>
<div class="actions" id="actions" hidden>
<button class="btn" type="button" id="reload">Обновить</button>
<button class="btn primary" type="button" data-csv="weeks">CSV: недели</button>
<button class="btn primary" type="button" data-csv="cells">CSV: клетки</button>
<button class="btn primary" type="button" data-csv="pulse">CSV: опрос</button>
</div>
</header>
<section class="panel login" id="login" hidden>
<h2>Нужен ключ</h2>
<p class="note">Откройте секретную ссылку целиком (с частью после #) или вставьте ключ сюда. Ключ не уходит никуда, кроме запроса к этому сайту.</p>
<form id="login-form">
<input id="key-input" type="password" autocomplete="off" placeholder="Ключ администратора" aria-label="Ключ администратора">
<button class="btn primary" type="submit">Открыть</button>
</form>
<p class="error" id="login-error" hidden></p>
</section>
<div id="content" hidden>
<div style="display:grid;gap:28px">
<dl class="kpis" id="kpis"></dl>
<section class="panel">
<div class="panel-head">
<h2>Шаги по неделям</h2>
<p class="note">Шаги = отмеченные клетки + 5 за каждую собранную линию (ряд, столбец, диагональ). Устройства — сколько браузеров отметили хоть одну клетку.</p>
</div>
<div class="table-wrap"><table id="weeks-table"></table></div>
</section>
<section class="panel">
<div class="panel-head">
<h2>«Твоя батарейка сейчас?»</h2>
<div class="legend"><span><i style="background:var(--p-full)"></i>⚡ Полная</span><span><i style="background:var(--p-mid)"></i>🔋 Средне</span><span><i style="background:var(--p-low)"></i>🪫 На нуле</span></div>
</div>
<div class="pulse-rows" id="pulse-rows"></div>
</section>
<section class="panel">
<div class="panel-head">
<h2>Популярность клеток</h2>
<p class="note">Сколько раз клетку отметили за весь сезон.</p>
</div>
<div class="cells" id="cells"></div>
</section>
<p class="note" id="generated"></p>
</div>
</div>
</div>
<script>
(() => {
'use strict';
const ROWS = [
{ name: 'Тело', color: 'var(--c-body)', cells: ['Обед без экрана', 'Разминка между созвонами', 'Гимнастика для глаз', 'Сон до полуночи'] },
{ name: 'Фокус', color: 'var(--c-focus)', cells: ['Фокус-блок на одну задачу', '3 главных дела на день', 'Встреча превратилась в сообщение', 'Время залогировано в тот же день'] },
{ name: 'Границы', color: 'var(--c-bounds)', cells: ['Сказать «нет» или «не сейчас»', 'Вечер без рабочих сообщений', 'Попросить о помощи', 'Ноутбук закрыт вовремя'] },
{ name: 'Люди и радость', color: 'var(--c-people)', cells: ['Офлайн с друзьями или семьёй', 'Хобби, спорт или что-то руками', '«Спасибо» или помощь коллеге', 'Мем в чат Мемопятницы'] },
];
const MONTHS = ['янв', 'фев', 'мар', 'апр', 'мая', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'];
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const cellName = (n) => ROWS[Math.floor((n - 1) / 4)].cells[(n - 1) % 4];
const rowName = (n) => ROWS[Math.floor((n - 1) / 4)].name;
const dayLabel = (iso) => `${+iso.slice(8, 10)} ${MONTHS[+iso.slice(5, 7) - 1]}`;
function weekRange(iso) {
const d = new Date(iso + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + 6);
return `${dayLabel(iso)} — ${dayLabel(d.toISOString().slice(0, 10))}`;
}
const weekTitle = (w) => (w.weekNo >= 1 ? `Неделя ${w.weekNo}` : 'До старта');
let key = new URLSearchParams(location.hash.slice(1)).get('k') || '';
let data = null;
async function load() {
$('login-error').hidden = true;
try {
const res = await fetch('api/admin/stats', { headers: { Authorization: `Bearer ${key}` }, cache: 'no-store' });
if (res.status === 403) throw new Error('Ключ не подошёл. Проверьте ссылку или задайте ADMIN_KEY заново.');
if (!res.ok) throw new Error(`Сервер ответил ошибкой ${res.status}. Попробуйте обновить через минуту.`);
data = await res.json();
$('login').hidden = true;
$('content').hidden = false;
$('actions').hidden = false;
render();
} catch (err) {
$('login').hidden = false;
$('content').hidden = true;
$('actions').hidden = true;
if (key) { $('login-error').textContent = err.message; $('login-error').hidden = false; }
}
}
function render() {
const s = data.season;
const cur = data.weeks.find((w) => w.week === s.week);
const pulseTotal = data.weeks.reduce((n, w) => n + w.pulse.full + w.pulse.mid + w.pulse.low, 0);
const pct = Math.floor((data.total / s.goal) * 100);
$('kpis').innerHTML = [
['Батарейка', `${data.total} <small>из ${s.goal} · ${pct}%</small>`],
['Сейчас', s.phase === 'active' ? `Неделя ${s.weekNo} <small>из ${s.weeks}</small>` : s.phase === 'pre' ? 'До старта' : 'Сезон завершён'],
['Устройств на этой неделе', cur ? cur.devices : 0],
['Ответов на опрос', pulseTotal],
].map(([t, v]) => `<div class="kpi"><dt>${t}</dt><dd>${v}</dd></div>`).join('');
const wrows = data.weeks.map((w) => `<tr>
<td><b>${weekTitle(w)}</b> <span style="color:var(--muted)">${weekRange(w.week)}</span></td>
<td class="strong">${w.steps}</td><td>${w.cells}</td><td>${w.rows}</td><td>${w.cols}</td><td>${w.diags}</td><td>${w.devices}</td>
<td>${w.pulse.full} / ${w.pulse.mid} / ${w.pulse.low}</td></tr>`).join('');
$('weeks-table').innerHTML = `<thead><tr><th>Неделя</th><th>Шаги</th><th>Клетки</th><th>Ряды</th><th>Столбцы</th><th>Диагонали</th><th>Устройства</th><th>⚡ / 🔋 / 🪫</th></tr></thead>
<tbody>${wrows || '<tr><td colspan="8" style="color:var(--muted)">Пока нет ни одной отметки.</td></tr>'}</tbody>`;
const pulseWeeks = data.weeks.filter((w) => w.pulse.full + w.pulse.mid + w.pulse.low > 0);
$('pulse-rows').innerHTML = pulseWeeks.length ? pulseWeeks.map((w) => {
const n = w.pulse.full + w.pulse.mid + w.pulse.low;
const seg = (v, c) => v ? `<span style="flex:${v};background:var(${c})" title="${Math.round(v / n * 100)}%">${v / n >= 0.12 ? Math.round(v / n * 100) + '%' : ''}</span>` : '';
return `<div class="pulse-row"><span>${weekTitle(w)}</span><div class="stack">${seg(w.pulse.full, '--p-full')}${seg(w.pulse.mid, '--p-mid')}${seg(w.pulse.low, '--p-low')}</div><span class="n">${n}</span></div>`;
}).join('') : '<p class="note">Ответов пока нет.</p>';
const max = Math.max(1, ...data.cells.map((c) => c.total));
$('cells').innerHTML = ROWS.map((row, r) => `<div class="cell-group"><h3 style="color:${row.color}">${row.name}</h3>${
[1, 2, 3, 4].map((k) => {
const c = data.cells[r * 4 + k - 1];
return `<div class="bar-row"><span class="label" title="${esc(cellName(c.cell))}"><b>${String(c.cell).padStart(2, '0')}</b>${esc(cellName(c.cell))}</span><div class="bar"><i style="width:${c.total / max * 100}%;background:${row.color}"></i></div><span class="n">${c.total}</span></div>`;
}).join('')}</div>`).join('');
$('generated').textContent = `Данные на ${new Date(data.generatedAt).toLocaleString('ru-RU')}. Часовой пояс сезона: ${s.tz}.`;
}
// CSV с разделителем «;» и BOM — открывается в русском Excel без мастера импорта.
function csv(rows) {
return '' + rows.map((r) => r.map((v) => {
const s = String(v ?? '');
return /[;"\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}).join(';')).join('\r\n');
}
function download(name, text) {
const url = URL.createObjectURL(new Blob([text], { type: 'text/csv;charset=utf-8' }));
const a = document.createElement('a');
a.href = url; a.download = name;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
const exporters = {
weeks: () => csv([
['Неделя', 'Понедельник', 'Шаги', 'Клетки', 'Собранные ряды', 'Собранные столбцы', 'Собранные диагонали', 'Устройства', 'Полная', 'Средне', 'На нуле'],
...data.weeks.map((w) => [w.weekNo, w.week, w.steps, w.cells, w.rows, w.cols, w.diags, w.devices, w.pulse.full, w.pulse.mid, w.pulse.low]),
]),
cells: () => {
const weeks = data.weeks.map((w) => w.week);
return csv([
['Клетка', 'Ряд', 'Название', 'Всего', ...data.weeks.map((w) => `Неделя ${w.weekNo} (${w.week})`)],
...data.cells.map((c) => [c.cell, rowName(c.cell), cellName(c.cell), c.total, ...weeks.map((w) => c.byWeek[w] || 0)]),
]);
},
pulse: () => csv([
['Неделя', 'Понедельник', 'Полная', 'Средне', 'На нуле', 'Всего ответов'],
...data.weeks.map((w) => [w.weekNo, w.week, w.pulse.full, w.pulse.mid, w.pulse.low, w.pulse.full + w.pulse.mid + w.pulse.low]),
]),
};
document.addEventListener('click', (e) => {
const b = e.target.closest('[data-csv]');
if (b && data) download(`balance-bingo-${b.dataset.csv}-${new Date().toISOString().slice(0, 10)}.csv`, exporters[b.dataset.csv]());
});
$('reload').addEventListener('click', load);
$('login-form').addEventListener('submit', (e) => {
e.preventDefault();
key = $('key-input').value.trim();
history.replaceState(null, '', `#k=${encodeURIComponent(key)}`);
load();
});
load();
})();
</script>
</body>
</html>
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large. Load diff
+29
View File
@@ -0,0 +1,29 @@
-- Бинго «Баланс 2.0» — схема SQLite (применяется сервером при каждом старте, повторно безопасно).
-- Хранятся только анонимные данные: случайный id устройства, неделя, номер клетки, ответ опроса.
-- Никаких имён, email, IP и времени событий.
-- Текущие отметки: одна строка = одна клетка, отмеченная устройством на неделе.
-- Первичный ключ не даёт отметить одну клетку дважды за неделю с одного устройства.
CREATE TABLE IF NOT EXISTS marks (
device TEXT NOT NULL,
week TEXT NOT NULL, -- понедельник недели, 'YYYY-MM-DD'
cell INTEGER NOT NULL CHECK (cell BETWEEN 1 AND 16),
PRIMARY KEY (device, week, cell)
) WITHOUT ROWID;
-- Мини-опрос «Твоя батарейка сейчас?»: один ответ на устройство в неделю (можно поменять).
CREATE TABLE IF NOT EXISTS pulse (
device TEXT NOT NULL,
week TEXT NOT NULL,
answer TEXT NOT NULL CHECK (answer IN ('full', 'mid', 'low')),
PRIMARY KEY (device, week)
) WITHOUT ROWID;
-- Кэш общей суммы шагов: пересчитывается при каждой отметке,
-- поэтому опрос батарейки читает одну строку.
CREATE TABLE IF NOT EXISTS totals (
key TEXT PRIMARY KEY,
value INTEGER NOT NULL
);
INSERT OR IGNORE INTO totals (key, value) VALUES ('steps', 0);
+30
View File
@@ -0,0 +1,30 @@
// Служебные команды. В Docker:
// docker compose exec bingo node server/cli.mjs backup — копия базы прямо сейчас
// docker compose exec bingo node server/cli.mjs reset --yes — стереть все отметки и ответы (после тестов)
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { openDb } from './db.mjs';
const dataDir = process.env.DATA_DIR || new URL('../data', import.meta.url).pathname;
const [cmd, flag] = process.argv.slice(2);
mkdirSync(dataDir, { recursive: true });
const db = openDb(join(dataDir, 'bingo.sqlite'));
if (cmd === 'backup') {
mkdirSync(join(dataDir, 'backups'), { recursive: true });
const file = join(dataDir, 'backups', `bingo-manual-${new Date().toISOString().replace(/[:.]/g, '-')}.sqlite`);
db.prepare('VACUUM INTO ?').run(file);
console.log(`Копия сохранена: ${file}`);
} else if (cmd === 'reset') {
if (flag !== '--yes') {
console.log('Это сотрёт все отметки, ответы опроса и обнулит батарейку. Повторите с --yes, чтобы подтвердить.');
process.exit(1);
}
db.exec("BEGIN; DELETE FROM marks; DELETE FROM pulse; UPDATE totals SET value = 0 WHERE key = 'steps'; COMMIT;");
console.log('Данные очищены, батарейка обнулена.');
} else {
console.log('Команды: backup | reset --yes');
process.exit(1);
}
db.close();
+30
View File
@@ -0,0 +1,30 @@
// Общая часть для сервера и утилит: открыть базу, создать таблицы, правила подсчёта линий.
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
export const LINE_BONUS = 5;
// Диагонали карточки 4×4: 01-06-11-16 и 04-07-10-13.
const DIAGONALS = [[1, 6, 11, 16], [4, 7, 10, 13]];
// Собранные линии — по одной строке на каждую: ряды, столбцы и диагонали.
export const LINES_SQL = `
SELECT device, week, 'row' AS kind FROM marks
GROUP BY device, week, (cell - 1) / 4 HAVING COUNT(*) = 4
UNION ALL
SELECT device, week, 'col' AS kind FROM marks
GROUP BY device, week, (cell - 1) % 4 HAVING COUNT(*) = 4
UNION ALL
SELECT device, week, 'diag' AS kind FROM marks WHERE cell IN (${DIAGONALS[0].join(', ')})
GROUP BY device, week HAVING COUNT(*) = 4
UNION ALL
SELECT device, week, 'diag' AS kind FROM marks WHERE cell IN (${DIAGONALS[1].join(', ')})
GROUP BY device, week HAVING COUNT(*) = 4`;
const SCHEMA = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
export function openDb(file) {
const db = new DatabaseSync(file);
db.exec('PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA busy_timeout = 5000;');
db.exec(SCHEMA);
return db;
}
+418
View File
@@ -0,0 +1,418 @@
// Бинго «Баланс 2.0» — автономный сервер: Node.js ≥ 22.13 + встроенный SQLite, без npm-зависимостей.
//
// Отдаёт страницу из ./public и API:
// GET /api/total → общая батарейка + настройки сезона (страница опрашивает раз в 20 с)
// GET /api/me?d=<id> → отметки и ответ опроса этого устройства за текущую неделю
// POST /api/mark {d, week, cell, on} → отметить / снять клетку
// POST /api/pulse {d, week, answer} → ответ на «Твоя батарейка сейчас?»
// GET /api/admin/stats → статистика (заголовок Authorization: Bearer <ADMIN_KEY>)
// GET /healthz → проверка живости для Docker
//
// Хранится только: случайный id устройства, неделя, номер клетки, ответ опроса.
// IP-адреса, заголовки и время запросов не пишутся ни в базу, ни в логи.
import http from 'node:http';
import { readFileSync, readdirSync, statSync, mkdirSync, unlinkSync } from 'node:fs';
import { join, extname, relative, sep } from 'node:path';
import { createHash, timingSafeEqual } from 'node:crypto';
import { openDb, LINES_SQL, LINE_BONUS } from './db.mjs';
const ROOT = new URL('..', import.meta.url).pathname;
// ---------- настройки ----------
function readAdminKey() {
if (process.env.ADMIN_KEY_FILE) {
try { return readFileSync(process.env.ADMIN_KEY_FILE, 'utf8').trim(); } catch { return ''; }
}
return (process.env.ADMIN_KEY || '').trim();
}
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const CFG = {
port: parseInt(process.env.PORT || '8080', 10),
dataDir: process.env.DATA_DIR || join(ROOT, 'data'),
publicDir: process.env.PUBLIC_DIR || join(ROOT, 'public'),
tz: process.env.TIMEZONE || 'Europe/Moscow',
start: process.env.SEASON_START || '2026-11-02',
end: process.env.SEASON_END || '2026-12-21',
goal: parseInt(process.env.GOAL || '500', 10),
adminKey: readAdminKey(),
backupKeep: parseInt(process.env.BACKUP_KEEP || '14', 10),
};
function fail(msg) {
console.error(`Ошибка настройки: ${msg}`);
process.exit(1);
}
if (!DATE_RE.test(CFG.start) || !DATE_RE.test(CFG.end)) fail('SEASON_START и SEASON_END должны быть в формате ГГГГ-ММ-ДД.');
if (CFG.start > CFG.end) fail('SEASON_START позже SEASON_END.');
if (!Number.isInteger(CFG.goal) || CFG.goal < 1) fail('GOAL должен быть положительным целым числом.');
try { new Intl.DateTimeFormat('en-US', { timeZone: CFG.tz }); } catch { fail(`неизвестный часовой пояс TIMEZONE=${CFG.tz}.`); }
if (CFG.adminKey && CFG.adminKey.length < 16) fail('ADMIN_KEY короче 16 символов.');
// ---------- база ----------
mkdirSync(CFG.dataDir, { recursive: true });
const db = openDb(join(CFG.dataDir, 'bingo.sqlite'));
const q = {
total: db.prepare("SELECT value FROM totals WHERE key = 'steps'"),
myCells: db.prepare('SELECT cell FROM marks WHERE device = ? AND week = ? ORDER BY cell'),
myPulse: db.prepare('SELECT answer FROM pulse WHERE device = ? AND week = ?'),
mark: db.prepare('INSERT OR IGNORE INTO marks (device, week, cell) VALUES (?, ?, ?)'),
unmark: db.prepare('DELETE FROM marks WHERE device = ? AND week = ? AND cell = ?'),
recount: db.prepare(`
UPDATE totals SET value =
(SELECT COUNT(*) FROM marks) + ${LINE_BONUS} * (SELECT COUNT(*) FROM (${LINES_SQL}))
WHERE key = 'steps'`),
pulse: db.prepare(`INSERT INTO pulse (device, week, answer) VALUES (?, ?, ?)
ON CONFLICT (device, week) DO UPDATE SET answer = excluded.answer`),
stWeeks: db.prepare('SELECT week, COUNT(*) AS cells, COUNT(DISTINCT device) AS devices FROM marks GROUP BY week ORDER BY week'),
stLines: db.prepare(`SELECT week, SUM(kind = 'row') AS rows, SUM(kind = 'col') AS cols, SUM(kind = 'diag') AS diags
FROM (${LINES_SQL}) GROUP BY week`),
stCells: db.prepare('SELECT cell, COUNT(*) AS n FROM marks GROUP BY cell'),
stCellWeeks: db.prepare('SELECT week, cell, COUNT(*) AS n FROM marks GROUP BY week, cell'),
stPulse: db.prepare('SELECT week, answer, COUNT(*) AS n FROM pulse GROUP BY week, answer ORDER BY week'),
};
// Синхронный SQLite в одном процессе: транзакция не пересекается с другими запросами.
function tx(fn) {
db.exec('BEGIN IMMEDIATE');
try {
const out = fn();
db.exec('COMMIT');
return out;
} catch (err) {
db.exec('ROLLBACK');
throw err;
}
}
// ---------- даты и сезон ----------
function todayIn(tz, now = new Date()) {
const parts = new Intl.DateTimeFormat('en-US', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(now);
const get = (t) => parts.find((p) => p.type === t).value;
return `${get('year')}-${get('month')}-${get('day')}`;
}
const dayNum = (iso) => Date.UTC(+iso.slice(0, 4), +iso.slice(5, 7) - 1, +iso.slice(8, 10)) / 864e5;
const isoOf = (n) => new Date(n * 864e5).toISOString().slice(0, 10);
function mondayOf(iso) {
const n = dayNum(iso);
return isoOf(n - ((new Date(n * 864e5).getUTCDay() + 6) % 7));
}
const weekNo = (week, start) => Math.floor((dayNum(week) - dayNum(mondayOf(start))) / 7) + 1;
function season() {
const today = todayIn(CFG.tz);
const week = mondayOf(today);
const phase = today < CFG.start ? 'pre' : today > CFG.end ? 'post' : 'active';
return {
goal: CFG.goal, tz: CFG.tz, start: CFG.start, end: CFG.end,
week, phase,
weekNo: phase === 'active' ? weekNo(week, CFG.start) : null,
weeks: weekNo(mondayOf(CFG.end), CFG.start),
};
}
// ---------- API ----------
const DEVICE_RE = /^[A-Za-z0-9-]{16,64}$/;
const ANSWERS = ['full', 'mid', 'low'];
const readTotal = () => q.total.get()?.value ?? 0;
async function handleApi(req, res, url) {
const path = url.pathname;
const method = req.method;
if (path === '/api/total' && (method === 'GET' || method === 'HEAD')) {
return json(res, 200, { total: readTotal(), ...season() });
}
if (path === '/api/me' && method === 'GET') {
const d = url.searchParams.get('d') || '';
if (!DEVICE_RE.test(d)) return json(res, 400, { error: 'bad_device' });
const s = season();
return json(res, 200, {
week: s.week,
cells: q.myCells.all(d, s.week).map((r) => r.cell),
pulse: q.myPulse.get(d, s.week)?.answer || null,
});
}
if (path === '/api/mark' && method === 'POST') {
const body = await readJson(req);
if (body === TOO_LARGE) return tooLarge(res);
if (!body) return json(res, 400, { error: 'bad_json' });
const { d, week, cell, on } = body;
if (!DEVICE_RE.test(String(d || ''))) return json(res, 400, { error: 'bad_device' });
if (!Number.isInteger(cell) || cell < 1 || cell > 16) return json(res, 400, { error: 'bad_cell' });
if (typeof on !== 'boolean') return json(res, 400, { error: 'bad_state' });
const s = season();
if (s.phase !== 'active') return json(res, 403, { error: 'season_closed', phase: s.phase });
if (week !== s.week) return json(res, 409, { error: 'week_changed', week: s.week });
const out = tx(() => {
(on ? q.mark : q.unmark).run(d, s.week, cell);
q.recount.run();
return { total: readTotal(), cells: q.myCells.all(d, s.week).map((r) => r.cell) };
});
return json(res, 200, { ok: true, week: s.week, ...out });
}
if (path === '/api/pulse' && method === 'POST') {
const body = await readJson(req);
if (body === TOO_LARGE) return tooLarge(res);
if (!body) return json(res, 400, { error: 'bad_json' });
const { d, week, answer } = body;
if (!DEVICE_RE.test(String(d || ''))) return json(res, 400, { error: 'bad_device' });
if (!ANSWERS.includes(answer)) return json(res, 400, { error: 'bad_answer' });
const s = season();
if (s.phase !== 'active') return json(res, 403, { error: 'season_closed', phase: s.phase });
if (week !== s.week) return json(res, 409, { error: 'week_changed', week: s.week });
q.pulse.run(d, s.week, answer);
return json(res, 200, { ok: true, answer });
}
if (path === '/api/admin/stats' && method === 'GET') {
if (!isAdmin(req)) return json(res, 403, { error: 'forbidden' });
return json(res, 200, adminStats());
}
return json(res, 404, { error: 'not_found' });
}
function adminStats() {
const s = season();
const weeks = q.stWeeks.all();
const lines = Object.fromEntries(q.stLines.all().map((r) => [r.week, r]));
const cells = q.stCells.all();
const cellWeeks = q.stCellWeeks.all();
const pulseByWeek = {};
for (const r of q.stPulse.all()) {
pulseByWeek[r.week] ??= { full: 0, mid: 0, low: 0 };
pulseByWeek[r.week][r.answer] = r.n;
}
const weekKeys = [...new Set([...weeks.map((r) => r.week), ...Object.keys(pulseByWeek)])].sort();
return {
generatedAt: new Date().toISOString(),
season: s,
total: readTotal(),
weeks: weekKeys.map((w) => {
const m = weeks.find((r) => r.week === w) || { cells: 0, devices: 0 };
const l = lines[w] || {};
const rows = l.rows || 0, cols = l.cols || 0, diags = l.diags || 0;
return {
week: w,
weekNo: weekNo(w, CFG.start),
cells: m.cells, rows, cols, diags,
steps: m.cells + LINE_BONUS * (rows + cols + diags),
devices: m.devices,
pulse: pulseByWeek[w] || { full: 0, mid: 0, low: 0 },
};
}),
cells: Array.from({ length: 16 }, (_, i) => ({
cell: i + 1,
total: cells.find((r) => r.cell === i + 1)?.n || 0,
byWeek: Object.fromEntries(cellWeeks.filter((r) => r.cell === i + 1).map((r) => [r.week, r.n])),
})),
};
}
const sha = (s) => createHash('sha256').update(s).digest();
function isAdmin(req) {
if (!CFG.adminKey) return false; // ключ не задан — админка выключена
const auth = req.headers.authorization || '';
const given = auth.startsWith('Bearer ') ? auth.slice(7) : '';
return timingSafeEqual(sha(given), sha(CFG.adminKey));
}
// ---------- статика ----------
const MIME = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
'.txt': 'text/plain; charset=utf-8',
};
// Все файлы страницы читаются в память при старте: их мало, и так нет обращений к диску по путям из запроса.
function loadStatic(dir) {
const files = new Map();
const walk = (d) => {
for (const name of readdirSync(d)) {
const full = join(d, name);
if (statSync(full).isDirectory()) { walk(full); continue; }
const rel = '/' + relative(dir, full).split(sep).join('/');
const ext = extname(name).toLowerCase();
if (!MIME[ext]) continue;
const body = readFileSync(full);
files.set(rel, { body, type: MIME[ext], etag: `"${createHash('sha1').update(body).digest('base64url').slice(0, 16)}"` });
}
};
walk(dir);
// Чистые адреса: / → index.html, /admin → admin.html
for (const [path, file] of [...files]) {
if (path.endsWith('/index.html')) files.set(path.slice(0, -'index.html'.length), file);
if (path.endsWith('.html')) files.set(path.slice(0, -'.html'.length), file);
}
return files;
}
const STATIC = loadStatic(CFG.publicDir);
const CSP = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"font-src 'self'",
"img-src 'self' data:",
"connect-src 'self'",
"base-uri 'none'",
"form-action 'none'",
// Teams может открывать вкладку «Веб-сайт» во фрейме — разрешаем встраивание только туда.
"frame-ancestors 'self' https://teams.microsoft.com https://*.teams.microsoft.com https://*.office.com https://*.cloud.microsoft",
].join('; ');
function serveStatic(req, res, url) {
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405, { Allow: 'GET, HEAD' });
return res.end();
}
const file = STATIC.get(url.pathname);
if (!file) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
return res.end('Не найдено');
}
const headers = {
'Content-Type': file.type,
'Content-Length': file.body.length,
ETag: file.etag,
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), interest-cohort=()',
};
if (file.type.startsWith('text/html')) {
headers['Content-Security-Policy'] = CSP;
headers['Cache-Control'] = 'no-cache';
} else {
headers['Cache-Control'] = url.pathname.startsWith('/fonts/') ? 'public, max-age=31536000, immutable' : 'public, max-age=3600';
}
if (url.pathname.startsWith('/admin')) headers['X-Robots-Tag'] = 'noindex, nofollow';
if (req.headers['if-none-match'] === file.etag) {
delete headers['Content-Length'];
res.writeHead(304, headers);
return res.end();
}
res.writeHead(200, headers);
res.end(req.method === 'HEAD' ? undefined : file.body);
}
// ---------- ответы ----------
const MAX_BODY = 2000;
const TOO_LARGE = Symbol('too_large');
function readJson(req) {
return new Promise((resolve) => {
if (Number(req.headers['content-length'] || 0) > MAX_BODY) return resolve(TOO_LARGE);
let size = 0;
const chunks = [];
req.on('data', (c) => {
size += c.length;
if (size > MAX_BODY) { req.removeAllListeners('data'); req.pause(); resolve(TOO_LARGE); return; }
chunks.push(c);
});
req.on('end', () => {
try {
const data = JSON.parse(Buffer.concat(chunks).toString('utf8'));
resolve(data && typeof data === 'object' ? data : null);
} catch { resolve(null); }
});
req.on('error', () => resolve(null));
});
}
function tooLarge(res) {
res.setHeader('Connection', 'close');
json(res, 413, { error: 'too_large' });
}
function json(res, status, data) {
const body = JSON.stringify(data);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
});
res.end(body);
}
// ---------- резервные копии ----------
// Раз в сутки и при старте: копия базы в DATA_DIR/backups, хранится BACKUP_KEEP последних.
function backup() {
if (CFG.backupKeep <= 0) return;
try {
const dir = join(CFG.dataDir, 'backups');
mkdirSync(dir, { recursive: true });
const stamp = new Date().toISOString().slice(0, 10);
const file = join(dir, `bingo-${stamp}.sqlite`);
try { unlinkSync(file); } catch { /* копии за сегодня ещё нет */ }
db.prepare('VACUUM INTO ?').run(file);
const old = readdirSync(dir).filter((f) => /^bingo-\d{4}-\d{2}-\d{2}\.sqlite$/.test(f)).sort();
for (const f of old.slice(0, Math.max(0, old.length - CFG.backupKeep))) unlinkSync(join(dir, f));
} catch (err) {
console.error('Резервная копия не создана:', err.message);
}
}
// ---------- запуск ----------
const server = http.createServer(async (req, res) => {
let url;
try {
url = new URL(req.url, 'http://localhost');
} catch {
res.writeHead(400);
return res.end();
}
try {
if (url.pathname === '/healthz') {
readTotal();
return json(res, 200, { ok: true });
}
if (url.pathname.startsWith('/api/')) return await handleApi(req, res, url);
return serveStatic(req, res, url);
} catch (err) {
console.error('Ошибка запроса', req.method, url.pathname, err);
if (!res.headersSent) json(res, 500, { error: 'server_error' });
else res.end();
}
});
server.requestTimeout = 15000;
server.headersTimeout = 10000;
backup();
const backupTimer = setInterval(backup, 24 * 3600 * 1000);
backupTimer.unref();
server.listen(CFG.port, () => {
const s = season();
console.log(`Бинго «Баланс 2.0» слушает порт ${CFG.port}. Сезон ${CFG.start} — ${CFG.end} (${CFG.tz}), цель ${CFG.goal}, сейчас: ${s.phase}.`);
if (!CFG.adminKey) console.log('ADMIN_KEY не задан — страница /admin выключена.');
});
function shutdown() {
server.close(() => { db.close(); process.exit(0); });
setTimeout(() => process.exit(0), 5000).unref();
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
+132
View File
@@ -0,0 +1,132 @@
// End-to-end API tests: starts the real server on a free port with a temp database.
// Run: npm test (Node.js >= 22.13, no dependencies)
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createServer } from 'node:net';
const ADMIN_KEY = 'test-admin-key-1234567890';
let proc, base, dataDir, week;
const freePort = () => new Promise((resolve) => {
const s = createServer().listen(0, () => { const { port } = s.address(); s.close(() => resolve(port)); });
});
async function api(path, body, headers = {}) {
const res = await fetch(base + path, {
method: body ? 'POST' : 'GET',
headers: { 'Content-Type': 'application/json', ...headers },
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, data: await res.json().catch(() => null), headers: res.headers };
}
const dev = (n) => `test0000-0000-0000-0000-${String(n).padStart(12, '0')}`;
const mark = (d, cell, on = true, w = week) => api('/api/mark', { d, week: w, cell, on });
before(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'bingo-test-'));
const port = await freePort();
base = `http://127.0.0.1:${port}`;
proc = spawn(process.execPath, ['--disable-warning=ExperimentalWarning', 'server/server.mjs'], {
env: { ...process.env, PORT: String(port), DATA_DIR: dataDir, ADMIN_KEY, SEASON_START: '2020-01-06', SEASON_END: '2099-12-31', GOAL: '500', BACKUP_KEEP: '0' },
stdio: ['ignore', 'pipe', 'inherit'],
});
for (let i = 0; i < 50; i++) {
try { if ((await fetch(base + '/healthz')).ok) break; } catch { /* not up yet */ }
await new Promise((r) => setTimeout(r, 100));
}
week = (await api('/api/total')).data.week;
});
after(() => {
proc?.kill();
rmSync(dataDir, { recursive: true, force: true });
});
test('total reports season settings', async () => {
const { status, data } = await api('/api/total');
assert.equal(status, 200);
assert.equal(data.goal, 500);
assert.equal(data.phase, 'active');
assert.match(data.week, /^\d{4}-\d{2}-\d{2}$/);
assert.equal(new Date(data.week + 'T00:00:00Z').getUTCDay(), 1, 'week key is a Monday');
});
test('scoring: cells, row, column, diagonal, idempotency, unmark', async () => {
const d = dev(1);
const t0 = (await api('/api/total')).data.total;
const steps = async () => (await api('/api/total')).data.total - t0;
for (const c of [1, 2, 3]) await mark(d, c);
assert.equal(await steps(), 3);
await mark(d, 4); // row 1 complete
assert.equal(await steps(), 4 + 5);
await mark(d, 4); // same cell again: no change
assert.equal(await steps(), 9);
for (const c of [5, 9]) await mark(d, c);
await mark(d, 13); // column 01-13 complete
assert.equal(await steps(), 7 + 10);
for (const c of [6, 11]) await mark(d, c);
await mark(d, 16); // diagonal 01-16 complete
assert.equal(await steps(), 10 + 15);
await mark(d, 1, false); // breaks row, column and diagonal at once
assert.equal(await steps(), 9);
const me = (await api(`/api/me?d=${d}`)).data;
assert.deepEqual(me.cells, [2, 3, 4, 5, 6, 9, 11, 13, 16]);
});
test('full card is worth 66 steps', async () => {
const d = dev(2);
const t0 = (await api('/api/total')).data.total;
let last;
for (let c = 1; c <= 16; c++) last = await mark(d, c);
assert.equal(last.data.total - t0, 16 + 10 * 5);
});
test('rejects wrong week, bad input and oversized bodies', async () => {
assert.equal((await mark(dev(3), 1, true, '2020-01-06')).status, 409);
assert.equal((await mark(dev(3), 17)).status, 400);
assert.equal((await mark('short', 1)).status, 400);
assert.equal((await api('/api/mark', { d: dev(3), week, cell: 1, on: 'yes' })).status, 400);
const big = await fetch(base + '/api/mark', { method: 'POST', body: 'x'.repeat(5000) });
assert.equal(big.status, 413);
});
test('pulse answer is stored once per device and week', async () => {
const d = dev(4);
assert.equal((await api('/api/pulse', { d, week, answer: 'mid' })).status, 200);
assert.equal((await api('/api/pulse', { d, week, answer: 'low' })).status, 200);
assert.equal((await api(`/api/me?d=${d}`)).data.pulse, 'low');
assert.equal((await api('/api/pulse', { d, week, answer: 'meh' })).status, 400);
});
test('admin stats need the key and add up', async () => {
assert.equal((await api('/api/admin/stats')).status, 403);
assert.equal((await api('/api/admin/stats', null, { Authorization: 'Bearer wrong' })).status, 403);
const { status, data } = await api('/api/admin/stats', null, { Authorization: `Bearer ${ADMIN_KEY}` });
assert.equal(status, 200);
const w = data.weeks.find((x) => x.week === week);
assert.equal(w.steps, w.cells + 5 * (w.rows + w.cols + w.diags));
assert.equal(data.total, data.weeks.reduce((n, x) => n + x.steps, 0));
assert.equal(w.pulse.low, 1);
assert.equal(data.cells.length, 16);
});
test('static pages and security headers', async () => {
for (const p of ['/', '/admin', '/admin.html']) {
const res = await fetch(base + p);
assert.equal(res.status, 200, p);
assert.match(res.headers.get('content-security-policy'), /frame-ancestors 'self' https:\/\/teams\.microsoft\.com/);
assert.equal(res.headers.get('x-frame-options'), null, 'X-Frame-Options would break the Teams tab');
}
assert.equal((await fetch(base + '/fonts/manrope-latin-wght-normal.woff2')).headers.get('content-type'), 'font/woff2');
assert.equal((await fetch(base + '/../server/server.mjs')).status, 404);
assert.equal((await fetch(base + '/nope')).status, 404);
});
+15
View File
@@ -0,0 +1,15 @@
// Собирает демо-версию страницы (без сервера) для предпросмотра: dist/demo.html
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
let s = readFileSync(new URL('../public/index.html', import.meta.url), 'utf8');
s = s.replace(/<!--FONTS-->[\s\S]*?<!--\/FONTS-->/,
'<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>' +
'<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400..800&family=Unbounded:wght@500..900&display=swap">');
s = s.replace('<script>\n(() => {', '<script>window.BINGO_FORCE_DEMO = true;</script>\n<script>\n(() => {');
s = s.replace(/<!doctype html>\s*<html lang="ru">\s*<head>\s*/i, '')
.replace(/<meta charset="utf-8">\s*/, '')
.replace(/<meta name="viewport"[^>]*>\s*/, '')
.replace(/<\/head>\s*<body>\s*/, '\n')
.replace(/<\/body>\s*<\/html>\s*$/, '\n');
mkdirSync(new URL('../dist/', import.meta.url), { recursive: true });
writeFileSync(new URL('../dist/demo.html', import.meta.url), s);
console.log('dist/demo.html', s.length, 'bytes');