initial commit
This commit is contained in:
commit
b6f0791243
24 files changed
+3966
No files matched your search
@@ -0,0 +1,12 @@
|
||||
.git
|
||||
node_modules
|
||||
data
|
||||
dist
|
||||
tools
|
||||
tests
|
||||
deploy
|
||||
docs
|
||||
*.md
|
||||
.env
|
||||
.env.*
|
||||
compose*.yaml
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
data/
|
||||
.env
|
||||
+206
@@ -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.
|
||||
@@ -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
@@ -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`.
|
||||
@@ -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:
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+1074
File diff suppressed because it is too large.
Load diff
@@ -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.
|
||||
Нагрузка мизерная: десятки пользователей, сотни событий в неделю.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
Reference in new issue
Block a user