commit b6f07912436d9c386bbf410873b9e0cc501a4c52 Author: Olga Bregar Dunaeva Date: Fri Oct 30 17:20:47 2026 +0100 initial commit diff --git a/bingo/.dockerignore b/bingo/.dockerignore new file mode 100644 index 0000000..6c31a7e --- /dev/null +++ b/bingo/.dockerignore @@ -0,0 +1,12 @@ +.git +node_modules +data +dist +tools +tests +deploy +docs +*.md +.env +.env.* +compose*.yaml diff --git a/bingo/.env.example b/bingo/.env.example new file mode 100644 index 0000000..c1d3f1c --- /dev/null +++ b/bingo/.env.example @@ -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 + diff --git a/bingo/.gitignore b/bingo/.gitignore new file mode 100644 index 0000000..9f77d70 --- /dev/null +++ b/bingo/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +data/ +.env diff --git a/bingo/CLAUDE.md b/bingo/CLAUDE.md new file mode 100644 index 0000000..e43cb0f --- /dev/null +++ b/bingo/CLAUDE.md @@ -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=` | — | `{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 ` | 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 ` + + + + + +
+ +
+
+
+

+ + Батарейка команды +

+

… из 500 шагов · заполняем вместе до 21 декабря

+
+
0%
+
+
+
+
+
+ +
+ +

Анонимно: без имён, почты и логинов. Твоя карточка хранится только в этом браузере, на сервер уходит лишь «клетка отмечена или снята» со случайным номером устройства. Батарейка обновляется сама каждые 20 секунд.

+ + +
+
+ + + + + diff --git a/bingo/docs/original-brief.md b/bingo/docs/original-brief.md new file mode 100644 index 0000000..9057ed1 --- /dev/null +++ b/bingo/docs/original-brief.md @@ -0,0 +1,66 @@ + + +# Задача: интерактивная страница «Бинго «Баланс 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), клетки — настоящие + + + + + + + + + + + + + + diff --git a/bingo/public/fonts/manrope-cyrillic-wght-normal.woff2 b/bingo/public/fonts/manrope-cyrillic-wght-normal.woff2 new file mode 100644 index 0000000..3a06229 Binary files /dev/null and b/bingo/public/fonts/manrope-cyrillic-wght-normal.woff2 differ diff --git a/bingo/public/fonts/manrope-latin-wght-normal.woff2 b/bingo/public/fonts/manrope-latin-wght-normal.woff2 new file mode 100644 index 0000000..71eb731 Binary files /dev/null and b/bingo/public/fonts/manrope-latin-wght-normal.woff2 differ diff --git a/bingo/public/fonts/unbounded-cyrillic-wght-normal.woff2 b/bingo/public/fonts/unbounded-cyrillic-wght-normal.woff2 new file mode 100644 index 0000000..102b454 Binary files /dev/null and b/bingo/public/fonts/unbounded-cyrillic-wght-normal.woff2 differ diff --git a/bingo/public/fonts/unbounded-latin-wght-normal.woff2 b/bingo/public/fonts/unbounded-latin-wght-normal.woff2 new file mode 100644 index 0000000..3260a19 Binary files /dev/null and b/bingo/public/fonts/unbounded-latin-wght-normal.woff2 differ diff --git a/bingo/public/index.html b/bingo/public/index.html new file mode 100644 index 0000000..be3dae1 --- /dev/null +++ b/bingo/public/index.html @@ -0,0 +1,1096 @@ + + + + + +Бинго «Баланс 2.0» + + + + + + + + + + + +
+
+

PROJECT: BALANCE · СЕЗОН 2 · 2 НОЯБРЯ — 21 ДЕКАБРЯ

+

Бинго «Баланс 2.0»

+
    +
  1. 1Делай маленькие шаги
  2. +
  3. 2Отмечай клетки за неделю
  4. +
  5. 3Собрал ряд, столбец или диагональ — +5 в батарейку
  6. +
+
+ +
+
+ Неделя — + Карточка обнулится в понедельник в 00:00 +
+ +
+ + + + + +
+ +
+
+
+

+ + Батарейка команды +

+

… из 500 шагов · заполняем вместе до 21 декабря

+
+
0%
+
+
+
+
+
+ +
+ +

Анонимно: без имён, почты и логинов. Твоя карточка хранится только в этом браузере, на сервер уходит лишь «клетка отмечена или снята» со случайным номером устройства. Батарейка обновляется сама каждые 20 секунд.

+
+ +
+
+ + + + + diff --git a/bingo/schema.sql b/bingo/schema.sql new file mode 100644 index 0000000..2845aaa --- /dev/null +++ b/bingo/schema.sql @@ -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); diff --git a/bingo/server/cli.mjs b/bingo/server/cli.mjs new file mode 100644 index 0000000..ecd872d --- /dev/null +++ b/bingo/server/cli.mjs @@ -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(); diff --git a/bingo/server/db.mjs b/bingo/server/db.mjs new file mode 100644 index 0000000..cd5ba60 --- /dev/null +++ b/bingo/server/db.mjs @@ -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; +} diff --git a/bingo/server/server.mjs b/bingo/server/server.mjs new file mode 100644 index 0000000..7599839 --- /dev/null +++ b/bingo/server/server.mjs @@ -0,0 +1,418 @@ +// Бинго «Баланс 2.0» — автономный сервер: Node.js ≥ 22.13 + встроенный SQLite, без npm-зависимостей. +// +// Отдаёт страницу из ./public и API: +// GET /api/total → общая батарейка + настройки сезона (страница опрашивает раз в 20 с) +// GET /api/me?d= → отметки и ответ опроса этого устройства за текущую неделю +// POST /api/mark {d, week, cell, on} → отметить / снять клетку +// POST /api/pulse {d, week, answer} → ответ на «Твоя батарейка сейчас?» +// GET /api/admin/stats → статистика (заголовок Authorization: Bearer ) +// 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); diff --git a/bingo/tests/api.test.mjs b/bingo/tests/api.test.mjs new file mode 100644 index 0000000..a504dc4 --- /dev/null +++ b/bingo/tests/api.test.mjs @@ -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); +}); diff --git a/bingo/tools/build-demo.mjs b/bingo/tools/build-demo.mjs new file mode 100644 index 0000000..9d48e6a --- /dev/null +++ b/bingo/tools/build-demo.mjs @@ -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(/[\s\S]*?/, + '' + + ''); +s = s.replace('\n