diff --git a/.dockerignore b/.dockerignore index 54ede4e..7559c7c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,13 +1,23 @@ +# Шаблоны отсчитываются от корня контекста: без «**/» правило ловит только корень +# (так `data/` пропускал backend/data/dev/ с dev-БД и загрузками в образ, #71). + # Python **/__pycache__/ **/*.pyc +**/*.egg-info/ backend/.venv/ backend/.pytest_cache/ -backend/*.db -backend/*.db-wal -backend/*.db-shm backend/openapi.json +# Данные: dev-БД, загрузки и ачивки дева (backend/data/dev/), любые файлы SQLite и +# локальные копии бэкапов — ни в образ, ни в контекст сборки +backend/data/ +data/ +backups/ +**/*.db +**/*.db-wal +**/*.db-shm + # Node / сборка фронта frontend/node_modules/ frontend/dist/ @@ -23,8 +33,7 @@ backend/tests/ backend/pyproject.toml # Прочее -.env -data/ +**/.env **/.DS_Store *.md .claude/ diff --git a/.env.example b/.env.example index 50bda92..4d7bdbb 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,12 @@ # ═══════════════════════════════════════════════════════════════════════════ -# Forbidden Stars — единый .env (dev / test / prod) +# Forbidden Stars — единый .env (dev / prod) # Скопируйте в .env, заполните секреты. Реальный .env в git НЕ идёт. # Окружение — строкой APP_ENV (ниже); публикация локалки наружу — LOCAL_PUBLIC. # ═══════════════════════════════════════════════════════════════════════════ # ─── ГЛАВНЫЙ ПЕРЕКЛЮЧАТЕЛЬ ──────────────────────────────────────────────────── # Этот параметр читает ЛАУНЧЕР (run.ps1 / run.sh) и решает, что запускать: -# development — нативно: uvicorn --reload + vite, БД в ./data/dev/, вход TG+ник -# test — прод-клон в Docker локально (порт 8080), вход только TG +# development — нативно: uvicorn --reload + vite, БД в ./data/dev/, есть вход по нику (stub) # production — НЕ запускается лаунчером; деплой на Pi отдельно (docker compose up -d). # Прод-контейнер ИГНОРИРУЕТ это значение и всегда production. APP_ENV=development @@ -15,21 +14,24 @@ APP_ENV=development # ─── ПУБЛИКАЦИЯ ЧЕРЕЗ ДОМЕН (VPS-туннель) ───────────────────────────────────── # LOCAL_PUBLIC — только для DEV на твоём ПК: local = приложение лишь на localhost; # vps = лаунчер (run.ps1) дополнительно поднимает SSH-туннель → дев на forbidden-stars.ru. -# TEST и PROD выставляют себя сами через туннель-КОНТЕЙНЕР (docker-compose*.yml) — им -# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже они тоже читают. +# Тогда любому посетителю домена открыты dev-инструменты: вход по нику без пароля, +# список/создание игроков, жёсткое удаление аккаунтов, Swagger. Дефолтные SECRET_KEY +# и ADMIN_PASSWORD при vps не дают стартовать — задайте свои (#69). +# PROD выставляет себя сам через туннель-КОНТЕЙНЕР (docker-compose*.yml) — ему +# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже он тоже читает. LOCAL_PUBLIC=local -# Параметры VPS: их читают и dev-туннель (run.ps1), и туннель-контейнер test/prod. +# Параметры VPS: их читают и dev-туннель (run.ps1), и туннель-контейнер прода. # Ключ туннеля — в deploy/tunnel/id_tunnel (в git не идёт); pubkey → authorized_keys у tunnel@VPS. VPS_TUNNEL_HOST=186.246.51.17 VPS_TUNNEL_USER=tunnel # VPS_TUNNEL_PORT обычно НЕ задают — каждый контур берёт свой слот по умолчанию: -# dev (run.ps1) → 9001, прод-контейнер → 9000, test-контейнер → 9001. +# dev (run.ps1) → 9001, прод-контейнер → 9000. # Раскомментируй и переопредели, только если нужен нестандартный слот. #VPS_TUNNEL_PORT=9001 # Приватный ключ туннеля в base64 — чтобы на Pi хватило только docker-compose.yml + .env -# (без файла deploy/tunnel/id_tunnel). Нужен ТОЛЬКО для прод-контейнера на Pi; для dev/test -# на ПК ключ берётся из файла. Сгенерируй ключ на ПК и закодируй БЕЗ переносов строк: +# (без файла deploy/tunnel/id_tunnel). Нужен ТОЛЬКО для прод-контейнера на Pi; временный прод +# на ПК берёт ключ из файла. Сгенерируй ключ на ПК и закодируй БЕЗ переносов строк: # Git Bash / Linux: base64 -w0 deploy/tunnel/id_tunnel # PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy/tunnel/id_tunnel"))) # Pubkey (deploy/tunnel/id_tunnel.pub) добавь в authorized_keys у tunnel@VPS. @@ -42,30 +44,31 @@ ADMIN_NICKNAME=Администратор ADMIN_BOOTSTRAP_ENABLED=true # ─── АУТЕНТИФИКАЦИЯ ИГРОКОВ ─────────────────────────────────────────────────── -# Методы входа задаёт APP_ENV: dev → Telegram + stub (вход по нику), prod → только -# Telegram. Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у +# Вход везде — логин/пароль и Telegram; в development ещё stub (по нику без пароля). +# Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у # BotFather укажи на ОБА домена, где открывается виджет: forbiddenstars.ru (prod) -# и forbidden-stars.ru (dev/test). +# и forbidden-stars.ru (dev). TELEGRAM_BOT_TOKEN= TELEGRAM_BOT_USERNAME= -# Внешний адрес (зарезервировано): prod https://forbiddenstars.ru; dev/test https://forbidden-stars.ru. +# Внешний адрес (зарезервировано): prod https://forbiddenstars.ru; dev https://forbidden-stars.ru. PUBLIC_BASE_URL= # ─── БЕЗОПАСНОСТЬ / СЕССИИ ──────────────────────────────────────────────────── # Сгенерировать: python -c "import secrets;print(secrets.token_urlsafe(48))" # ВАЖНО: в секретах НЕ используйте символ '$' — docker compose трактует его как # подстановку переменной (token_urlsafe даёт только [A-Za-z0-9_-], это безопасно). +# У dev и prod ключи должны быть РАЗНЫМИ: иначе токен, подписанный на dev, примет прод. SECRET_KEY=change-me-dev-secret-not-for-production JWT_ALGORITHM=HS256 JWT_USER_TTL_MINUTES=10080 JWT_ADMIN_TTL_MINUTES=480 # COOKIE_SECURE задаётся АВТОМАТИЧЕСКИ по окружению (HTTPS-домен ⇒ Secure-cookie): -# dev+localhost → false; dev через VPS, test, prod → true. Вручную задавать НЕ нужно. +# dev+localhost → false; dev через VPS, prod → true. Вручную задавать НЕ нужно. COOKIE_DOMAIN= # ─── БАЗА ДАННЫХ (структура общая, файлы РАЗНЫЕ; выбор по APP_ENV) ──────────── -# dev → DEV_DATABASE_URL (файл в ./data/dev/); test и prod → PROD_DATABASE_URL -# (том /data; у test и prod это РАЗНЫЕ тома контейнера, см. docker-compose*.yml). +# dev → DEV_DATABASE_URL (файл в ./data/dev/); prod → PROD_DATABASE_URL +# (том /data контейнера, см. docker-compose*.yml). DEV_DATABASE_URL=sqlite:///./data/dev/forbidden_stars.db PROD_DATABASE_URL=sqlite:////data/forbidden_stars.db @@ -78,7 +81,7 @@ DEV_ACHIEVEMENTS_DIR=./data/dev/achievements PROD_ACHIEVEMENTS_DIR=/data/achievements # ─── ОБРАЗЫ ПРОДА (реестр для docker compose pull на Pi) ────────────────────── -# Образы собираются под arm64 на ПК (scripts/build-push.sh) и пушатся в Gitea-реестр, +# Образы собираются под arm64 на ПК (scripts/build-push.ps1; в Linux — .sh) и пушатся в Gitea-реестр, # а Pi их тянет (docker compose pull). Owner в пути — строчными. Тег можно версионировать. # Перед пушем/пуллом: docker login gitea.arseniev.info IMAGE_REGISTRY=gitea.arseniev.info/notbigghost @@ -86,7 +89,8 @@ IMAGE_TAG=latest # ─── РЕСУРСЫ ПРОД-КОНТЕЙНЕРА (docker-compose.yml) ───────────────────────────── # Лимиты под Raspberry Pi. Не заданы → дефолты compose (512m / 1.5 CPU). -# Подними, если у Pi больше RAM/ядер. +# Подними, если у Pi больше RAM/ядер. Лимит памяти работает, только если на Pi включён +# memory cgroup (cgroup_enable=memory в cmdline.txt) — см. deploy/pi/README.md, раздел 1. #APP_MEM_LIMIT=512m #APP_CPUS=1.5 @@ -120,13 +124,20 @@ BACKUP_VPS_DIR=/srv/fs-backups/restic # в deploy/backup/id_backup (в git НЕ идёт), pubkey — в authorized_keys у fsbackup@VPS. # PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy\backup\id_backup"))) BACKUP_SSH_KEY_B64= +# Отчёты о бэкапах в Telegram и команды /backups, /status (deploy/backup/README.md, раздел 10). +# id своего чата: напишите боту /start → `docker compose exec backup fs-backup telegram chats`. Несколько — через +# запятую. Пусто = отчёты выключены. Токен по умолчанию — TELEGRAM_BOT_TOKEN приложения. +BACKUP_TELEGRAM_CHAT_ID= +#BACKUP_TELEGRAM_BOT_TOKEN= # Только для ПК (scripts/fs-backup.ps1 / .sh): как зайти на Pi по SSH и где там лежит # docker-compose.yml прода. BACKUP_PI_SSH=pi@192.168.1.10 BACKUP_PI_DIR=~/forbidden-stars # ─── ПРОЧЕЕ ─────────────────────────────────────────────────────────────────── -# Часовой пояс приложения (фикс. смещение в часах; МСК = 3) +# Часовой пояс приложения (фикс. смещение в часах, −12..14; МСК = 3). Один на всех: в нём +# сервер ставит «дату игры», а фронт показывает время и принимает даты объявлений — +# пояс устройства игрока не учитывается. APP_TZ_OFFSET_HOURS=3 # CORS нужен только в dev (фронт и API на разных портах); в prod single-origin CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 diff --git a/.gitattributes b/.gitattributes index 741036e..eb036e9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,16 +3,3 @@ # Скрипты образов (entrypoint.sh, deploy/*/…) контейнер дополнительно чинит sed-ом при сборке. *.sh text eol=lf backend/entrypoint.sh text eol=lf - -# ── export-ignore: НЕ попадает в `git archive` (чистая выгрузка прод/тест) ───── -# В git эти файлы есть и доступны на всех ветках (нужны для разработки), -# но в архив деплоя (scripts/export-*.sh) не идут. На Docker-сборку НЕ влияет — -# там чистоту образа обеспечивает .dockerignore. -backend/tests/ export-ignore -backend/app/auth/dev_stub.py export-ignore -backend/app/routers/dev_auth.py export-ignore -backend/pyproject.toml export-ignore -README.md export-ignore -.gitignore export-ignore -.gitattributes export-ignore -.dockerignore export-ignore diff --git a/.gitignore b/.gitignore index 1858c8a..dea409e 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ venv/ data/ backend/dev.db* -# Env / секреты — коммитим ТОЛЬКО шаблон .env.example (единый .env для dev/test/prod) +# Env / секреты — коммитим ТОЛЬКО шаблон .env.example (единый .env для dev/prod) .env !.env.example diff --git a/README.md b/README.md index 74c4acd..827b9e4 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,37 @@ # Forbidden Stars — учёт партий Мобильное веб-приложение для учёта партий настольной игры **Forbidden Stars**: -профили игроков (вход по логину и паролю или через Telegram), группы, создание партий -с рандомом фракций и фильтром по дополнениям группы, статистика и общий топ, админ-панель. +профили игроков (вход по логину и паролю или через Telegram), группы с приглашениями, +создание партий с рандомом фракций и фильтром по дополнениям группы, фото партий, +уведомления, статистика и общий топ, админ-панель. - **Бэкенд / ядро + API:** Python · FastAPI · SQLModel · SQLite -- **Фронтенд:** React · Vite · TypeScript (SPA, общается с ядром только по REST API) -- **Хостинг:** Raspberry Pi 4 (ARM64) в Docker +- **Фронтенд:** React · Vite · TypeScript (SPA; с ядром общается по REST API, живые + обновления приходят SSE-потоком `/api/events`) +- **Хостинг:** Raspberry Pi 4 (ARM64) в Docker; наружу — через VPS-привратник (см. «Домен и публикация») ## Структура ``` -backend/ FastAPI: ядро, REST API, БД, миграции, seed -frontend/ React + Vite SPA -Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI) -docker-compose.yml -.env.example +backend/ FastAPI: ядро, REST API, БД, миграции Alembic, seed, тесты +frontend/ React + Vite SPA +deploy/ публикация и эксплуатация: vps/ (Caddy), pi/ (прод), tunnel/ и backup/ (образы) +scripts/ build-push.* (сборка и пуш образов), fs-backup.* (бэкапы с ПК) +Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI) +docker-compose.yml прод на Pi: app + tunnel + backup +docker-compose.temp.yml временный прод на ПК вместо Pi: app + tunnel +run.ps1 / run.sh единый лаунчер dev +.env.example шаблон единого .env ``` ## Локальная разработка +Всё проверяется в `development` — отдельного тестового контейнера нет. + ### Единый лаунчер (`run.ps1` / `run.sh`) -После разовой настройки (ниже) dev и test запускаются **одной командой** — что именно, -решает `APP_ENV` в корневом `.env`: +После разовой настройки (ниже) dev запускается **одной командой** (лаунчер читает +`APP_ENV` в корневом `.env`): ```powershell .\run.ps1 # Windows (Linux / macOS / Git Bash: ./run.sh) @@ -31,9 +39,9 @@ docker-compose.yml | `APP_ENV` в `.env` | что делает лаунчер | |---|---| -| `development` | `uvicorn --reload` (бэк) + `vite` (фронт) нативно, в двух окнах | -| `test` | `docker compose` прод-клон на :8080 (со сборкой образа) | -| `production` | не запускает — прод деплоится отдельно (см. «Git и деплой») | +| `development` | сначала `alembic upgrade head`, затем `uvicorn --reload` (бэк) + `vite` (фронт) нативно: `run.ps1` — в отдельных окнах, `run.sh` — в текущем терминале (Ctrl+C останавливает оба). При `LOCAL_PUBLIC=vps` дополнительно поднимает SSH-туннель на `forbidden-stars.ru` | +| `production` | не запускает — прод деплоится отдельно (см. «Production» и «Git и деплой») | +| другое значение | отказ: допустимы только `development` и `production` (бэкенд тоже не стартует) | Разовая настройка перед первым запуском — поднять venv бэка и зависимости фронта (после неё повседневный цикл — просто `.\run.ps1`). Vite проксирует `/api` на бэкенд. @@ -47,8 +55,8 @@ python -m venv .venv .\.venv\Scripts\Activate.ps1 # если ругается на политику: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned pip install -e ".[dev]" # .[dev] — один аргумент (пакет + dev-зависимости) Copy-Item ..\.env.example ..\.env # ЕДИНЫЙ .env лежит в КОРНЕ репозитория -alembic upgrade head # применит миграции и сидинг -python -m app.bootstrap # создаст/синхронизирует администратора из .env +alembic upgrade head # применит миграции и сидинг справочников +python -m app.bootstrap # справочники + создаст/синхронизирует администратора из .env (опционально) uvicorn app.main:app --reload --timeout-graceful-shutdown 2 # http://localhost:8000 (Swagger: /api/docs) ``` @@ -75,6 +83,10 @@ python -m app.bootstrap uvicorn app.main:app --reload --timeout-graceful-shutdown 2 ``` +> В dev тот же bootstrap (справочники + админ из `.env`) выполняется сам при старте uvicorn, +> поэтому `python -m app.bootstrap` вручную обычно не нужен. Dev-БД, загрузки и ачивки +> лежат в `backend/data/dev/` (пути в `.env` относительные — запускайте из `backend/`). + > **`--timeout-graceful-shutdown` обязателен.** Открытая вкладка держит SSE-поток > `/api/events`, и без лимита `--reload` ждёт его закрытия вечно — сайт висит на загрузке, > а в логе только `Reloading...`. @@ -96,45 +108,42 @@ npm run dev # http://127.0.0.1:5173 или http://localhost:5 ## Production (Docker на Pi) +На Pi нужны только **два файла** — `docker-compose.yml` и `.env`: образы `app`, `tunnel` и +`backup` собираются на ПК под arm64 и пушатся в Gitea-реестр, Pi тянет их сам. + +```powershell +# ПК (обычно с ветки main): собрать и опубликовать образы +docker login gitea.arseniev.info +.\scripts\build-push.ps1 +``` ```bash -cp .env.example .env # заполните SECRET_KEY, ADMIN_PASSWORD и пр. -docker compose build # на ARM64 собирается нативно -docker compose up -d # приложение на :8000, БД на томе +# Pi, папка с docker-compose.yml и .env +docker compose up -d # pull_policy: always — тянет свежие образы, без сборки ``` -FastAPI отдаёт собранный SPA и API с одного origin. Миграции и бутстрап админа -выполняются автоматически при старте (`entrypoint.sh`). +Портов на хост нет — прод доступен только на `https://forbiddenstars.ru` через +туннель-контейнер. FastAPI отдаёт собранный SPA и API с одного origin. Миграции, сидинг +справочников и создание админа выполняются автоматически при старте (`entrypoint.sh`). +В production закрыты OpenAPI/Swagger, а с дефолтным или коротким `SECRET_KEY` либо +дефолтным `ADMIN_PASSWORD` приложение не стартует. `ADMIN_PASSWORD` применяется сам только +при первом создании админа; сменить его потом — правкой `.env`, `docker compose up -d` и +`docker compose exec app python -m app.bootstrap --reset-admin-password` (все админские +сессии завершатся; подробно — в `deploy/pi/README.md`). +Пошагово — [`deploy/pi/README.md`](deploy/pi/README.md), бэкапы — [`deploy/backup/README.md`](deploy/backup/README.md). -## Test — локальный прод-клон в контейнере - -Тот же образ и поведение, что и прод (FastAPI отдаёт SPA, БД на томе, вход игроков -по логину/паролю или через Telegram), но на своей машине — для проверки прод-сборки до выката на Pi. -Изолированные тома и порт **8080** (не конфликтует с dev-uvicorn на :8000). - -Проще всего — через лаунчер: поставить `APP_ENV=test` в `.env` и запустить `.\run.ps1`. -Вручную (тот же эффект): -```bash -docker compose -f docker-compose.test.yml up -d --build -# открыть http://localhost:8080 (Swagger: /api/docs) -docker compose -f docker-compose.test.yml down -v # остановить и стереть тестовые данные -``` - -- Окружение `test` (прод-клон), но `COOKIE_SECURE=false` (локально по HTTP). -- Читает **тот же `.env`**, что dev/prod (отдельного `.env.test` больше нет); внутри - контейнера `APP_ENV` форсится в `test` (см. `docker-compose.test.yml`). -- Данные — на отдельных томах `db-data-test` / `uploads-data-test` (не пересекаются с dev и Pi). -- Вход: **админ-панель** (`/admin/login`) работает сразу по логину/паролю; **игроки** — - по логину/паролю сразу, через Telegram — при боте и публичном HTTPS/туннеле на `localhost:8080`. - **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно), - и docker compose (test/prod — `$` = подстановка переменной). Чтобы значение совпадало + и docker compose (prod — `$` = подстановка переменной). Чтобы значение совпадало везде, в `SECRET_KEY`/`ADMIN_PASSWORD` не должно быть `$`. Удобно генерировать так: `python -c "import secrets;print(secrets.token_urlsafe(48))"` (даёт только `[A-Za-z0-9_-]`). +- **Временный прод на ПК** (вместо Pi): `docker compose -f docker-compose.temp.yml up -d --build` — + поведение production, локальная сборка x86, ключ туннеля из файла `deploy/tunnel/id_tunnel`, + тот же слот VPS 9000, что у Pi (одновременно не запускать). ## Аутентификация Методы входа зависят от окружения (`APP_ENV`): -| | dev | test / prod | +| | dev | prod | |---|---|---| | Логин (= ник) и пароль | ✓ | ✓ (основной) | | Telegram Login Widget | ✓ | ✓ | @@ -142,57 +151,69 @@ docker compose -f docker-compose.test.yml down -v # остановить и с - **Логин и пароль** (`app/auth/password.py`) — основной вход. Логин — это ник игрока (смена ника меняет логин). Пароль: от 8 символов, не длиннее 72 байт, хранится bcrypt. - От перебора — окно 15 минут в памяти процесса: 5 неудач на пару «IP + логин» и 20 на IP, - дальше `429 TOO_MANY_ATTEMPTS`. Игрок без пароля (из Telegram или созданный до паролей) - после входа видит обязательное окно «Задайте пароль». В профиле пароль меняется (нужен - текущий) и привязывается Telegram (ник не меняется). Забытый пароль задаёт админ - на вкладке аккаунтов — почту приложение не хранит. -- **Stub-вход (по нику)** — только для разработки. Его код **физически не попадает в прод:** - файлы `backend/app/auth/dev_stub.py` и `backend/app/routers/dev_auth.py` исключены из - Docker-образа (`.dockerignore`), роутер подключается лишь при `APP_ENV=development` - (`app/main.py`), а на фронте dev-блок вырезается из прод-сборки (`import.meta.env.DEV`). -- **Telegram:** сервер проверяет подпись виджета (HMAC по `TELEGRAM_BOT_TOKEN`). - `GET /api/auth/config` отдаёт доступные методы и `telegram_bot_username` для виджета. + От перебора — окно 15 минут в памяти процесса: 5 неудач на пару «IP + логин», 20 на IP и + 50 на аккаунт (независимо от IP), дальше `429 TOO_MANY_ATTEMPTS`. Регистраций — не больше + 10 с одного IP за окно. Игрок без пароля (из Telegram или созданный до паролей) после входа + видит обязательное окно «Задайте пароль»: закрыть его нельзя, только задать пароль или + выйти; в dev-сборке есть кнопка «Позже (dev)». В профиле пароль меняется (нужен текущий) и + привязывается Telegram (ник не меняется). Забытый пароль задаёт админ на вкладке + аккаунтов — почту приложение не хранит. Смена или сброс пароля завершает все прежние + сессии игрока (при смене в профиле текущее устройство остаётся в системе); «Выйти» + отзывает токен этого устройства. +- **Stub-вход (по нику)** и **жёсткое удаление аккаунтов** в админке — только для разработки. + Их код **физически не попадает в прод:** файлы `backend/app/auth/dev_stub.py`, + `backend/app/routers/dev_auth.py` и `backend/app/routers/dev_admin.py` исключены из + Docker-образа (`.dockerignore`), роутеры подключаются лишь при `APP_ENV=development` + (`app/main.py`), а на фронте dev-блоки вырезаются из прод-сборки (`import.meta.env.DEV`). + В проде аккаунт можно только отключить. +- **Telegram:** сервер проверяет подпись виджета (HMAC по `TELEGRAM_BOT_TOKEN`) и свежесть + данных (не старше суток). Первый вход регистрирует игрока под Telegram-тегом; если такой ник + занят или некорректен, фронт просит выбрать другой. `GET /api/auth/config` отдаёт доступные + методы и `telegram_bot_username` для виджета. **Настройка Telegram (когда будете подключать реальный вход):** 1. Создать бота у [@BotFather](https://t.me/BotFather) → получить **токен** и **username**. -2. `/setdomain` у BotFather → оба домена: `forbiddenstars.ru` (prod) и `forbidden-stars.ru` (dev/test). +2. `/setdomain` у BotFather → оба домена: `forbiddenstars.ru` (prod) и `forbidden-stars.ru` (dev). 3. В `.env`: `TELEGRAM_BOT_TOKEN=...`, `TELEGRAM_BOT_USERNAME=...` (без `@`). 4. Виджету нужен HTTPS-домен (см. «Домен и публикация») — по голому HTTP/localhost он не работает. -Админ-вход (секретная панель, логин+пароль) — отдельный механизм, доступен во всех окружениях. +Админ-вход (секретная панель, логин+пароль) — отдельный механизм, доступен во всех +окружениях. Страница — `/admin/login`; из интерфейса туда ведёт удержание кнопки «Меню» 10 секунд. -## Окружения (dev / test / prod) +## Окружения (dev / prod) -Один и тот же код; контур задаёт `APP_ENV` в **едином** `.env` (его читает лаунчер): +Один и тот же код; контур задаёт `APP_ENV` в **едином** `.env`. Допустимы только +`development` и `production` — с любым другим значением бэкенд не стартует: -| | dev | test (прод-клон локально) | prod (Pi) | -|---|---|---|---| -| Запуск | `.\run.ps1` → `uvicorn --reload` + `vite` | `.\run.ps1` → `docker compose -f docker-compose.test.yml` | `docker compose up -d` | -| `APP_ENV` | `development` | `test` (форсится в compose) | `production` (форсится в compose) | -| Env-файл | единый `.env` | единый `.env` | единый `.env` (на Pi) | -| Раздача SPA | Vite (HMR), :5173 | FastAPI, :8080 | FastAPI, :8000 | -| База данных | `backend/data/dev/…` | том `db-data-test` (`/data`) | том `db-data` (`/data`) | -| Вход игроков | пароль + Telegram + ник (stub) | пароль + Telegram | пароль + Telegram | +| | dev | prod (Pi) | +|---|---|---| +| Запуск | `.\run.ps1` → `uvicorn --reload` + `vite` | `docker compose up -d` | +| `APP_ENV` | `development` | `production` (форсится в compose) | +| Env-файл | единый `.env` | единый `.env` (на Pi) | +| Раздача SPA | Vite (HMR), `:5173` | FastAPI, только через `https://forbiddenstars.ru` | +| База данных | `backend/data/dev/…` | том `db-data` (`/data`) | +| Вход игроков | пароль + Telegram + ник (stub) | пароль + Telegram | +| Swagger (`/api/docs`) | ✓ | ✗ | +| Fail-fast по дефолтным секретам | ✗ | ✓ | -- **Один `.env` на машину** в корне (рядом с `.env.example`). `APP_ENV` в нём решает, что - запустит лаунчер (`development`/`test`); прод-контейнер это значение **игнорирует** и всегда - `production`. Отдельного `.env.test` больше нет. +- **Один `.env` на машину** в корне (рядом с `.env.example`). Прод-контейнер значение + `APP_ENV` из него **игнорирует** и всегда `production`. - **Структура БД одна** (общие миграции Alembic), **файлы разные**: dev → `DEV_DATABASE_URL` - (`backend/data/dev/`), test и prod → `PROD_DATABASE_URL` (том `/data`; у test и prod это - РАЗНЫЕ тома). Данные дева в образ **не попадают** (`data/` в `.dockerignore`). -- **В Docker идёт только прод-код:** dev-вход (stub) и тесты физически исключены из образа - (`.dockerignore`); `test` — тот же образ, что и прод, просто локально и с `APP_ENV=test`. + (`backend/data/dev/`), prod → `PROD_DATABASE_URL` (том `/data`). Так же раздельно лежат + загрузки (`*_UPLOAD_DIR`) и ачивки (`*_ACHIEVEMENTS_DIR`). +- **В Docker идёт только прод-код:** dev-вход (stub), dev-удаление аккаунтов и тесты физически + исключены из образа (`.dockerignore`). Данные дева (`backend/data/`), любые файлы SQLite + и локальные бэкапы (`backups/`) в образ тоже не попадают. ## Git и деплой -- Ветка **`dev`** — рабочая: весь код, лаунчер, тесты. Повседневная разработка и `test` здесь. +- Ветка **`dev`** — рабочая: весь код, лаунчер, тесты. Повседневная разработка и проверка здесь. - Ветка **`main`** — релиз прода: готовое промоутишь из `dev` через `merge dev→main`. Файлы во всех ветках одинаковы (окружение задаёт `.env`/compose, а не ветка) → merge безболезненный; чистоту прод-образа обеспечивает `.dockerignore`, а не разные наборы файлов. -- **Деплой на Pi:** `git pull` ветки `main` → `scripts/build-push.ps1`. -- **Чистая выгрузка в папку без git** (опц.): `scripts/export-prod.sh
${_item#*:} · $(repo_summary "${_item%%:*}")"
+ done
+ for _repo in $_failed; do
+ _text="$_text
+• $_repo: ошибка"
+ done
+ [ "$1" -eq 0 ] || _text="$_text
+Подробности: docker compose logs backup"
+ tg_send "$_text"
+}
+
+notify_verify() { # notify_verify <код выхода>
+ tg_enabled || return 0
+ _when="$(date '+%d.%m %H:%M')"
+ if [ "$1" -eq 0 ]; then
+ _text="🔍 Проверка данных: OK $_when"
+ else
+ _text="❌ Проверка данных не прошла $_when
+$(printf '%s' "${LAST_ERROR:-прервалась с кодом $1}" | tg_escape)"
+ fi
+ for _item in $_verified; do
+ _rest="${_item#*:}"
+ _text="$_text
+• ${_item%%:*}: данные целы, в последнем снимке игроков ${_rest%%:*}, партий ${_rest#*:}"
+ done
+ for _repo in $_failed; do
+ _text="$_text
+• $_repo: ошибка"
+ done
+ [ "$1" -eq 0 ] || _text="$_text
+Подробности: docker compose logs backup"
+ tg_send "$_text"
+}
+
# Проверка SQLite без записи рядом с файлом (immutable: ни -wal, ни -shm не создаются).
db_ok() {
[ -s "$1" ] || return 1
@@ -178,6 +275,17 @@ forget_repo() {
# Защита истории от пустых данных: новый Pi до восстановления или случайно очищенная БД не
# должны становиться «последним снимком» (restore latest вернул бы пустоту).
guard_empty() { # guard_empty <игроков> <партий>
+ # «?» — счётчики не прочитались: в БД нет таблиц (миграции ещё не прошли). Такой снимок
+ # стал бы latest и сломал бы restore latest (#74) — отказ, как и для пустой БД.
+ case "$1$2" in
+ *'?'*)
+ for _r in $(repos); do mark "run-$_r" err "в БД нет таблиц users/matches — бэкап не сделан"; done
+ log "Не удалось прочитать число игроков и партий: в БД нет таблиц users/matches."
+ log "Похоже, приложение ещё не применило миграции. Бэкап НЕ сделан, чтобы снимок без данных"
+ log "не стал последним. Дождитесь запуска приложения; если так и задумано:"
+ die "fs-backup run --allow-empty"
+ ;;
+ esac
if [ "$1" != 0 ] || [ "$2" != 0 ]; then return 0; fi
for _repo in $(repos); do
_prev="$(r "$_repo" snapshots latest --host "$SNAP_HOST" --json 2>/dev/null | jq -r \
@@ -236,11 +344,18 @@ cmd_run() {
esac
shift
done
+ _players="?"
+ _matches="?"
+ _ok_repos=""
+ _failed=""
+ # Отчёт в Telegram уходит при любом исходе, в том числе при раннем отказе (занят lock,
+ # нет пароля). Копию БД убираем только после take_lock: до него она может быть чужой.
+ trap '_rc=$?; notify_run "$_rc"' EXIT
require_password
setup_ssh
take_lock
warn_leftovers
- trap 'rm -f "$SNAP_ROOT/$DB_NAME"' EXIT
+ trap '_rc=$?; rm -f "$SNAP_ROOT/$DB_NAME"; notify_run "$_rc"' EXIT
log "Снимок данных: консистентная копия БД…"
stage_db
@@ -252,7 +367,6 @@ cmd_run() {
log "БД в порядке: игроков $_players, партий $_matches."
if [ "$_allow_empty" = no ]; then guard_empty "$_players" "$_matches"; fi
- _failed=""
for _repo in $(repos); do
log "=== Репозиторий $_repo ($(repo_url "$_repo")) ==="
if backup_to "$_repo" --tag "$_kind" --tag "players:$_players" --tag "matches:$_matches" ${_tag:+--tag keep --tag "$_tag"} \
@@ -260,6 +374,7 @@ cmd_run() {
&& r "$_repo" check >&2; then
_sid="$(latest_short_id "$_repo")"
mark "run-$_repo" ok "снимок $_sid"
+ _ok_repos="$_ok_repos $_repo:$_sid"
log "OK: репозиторий $_repo, снимок $_sid."
else
mark "run-$_repo" err "бэкап/очистка/проверка не удались — см. docker compose logs backup"
@@ -365,10 +480,12 @@ cmd_has_snapshots() {
# ─── verify ───────────────────────────────────────────────────────────────────
cmd_verify() {
+ _verified=""
+ _failed=""
+ trap '_rc=$?; notify_verify "$_rc"' EXIT
require_password
setup_ssh
take_lock
- _failed=""
for _repo in $(repos); do
log "=== Проверка репозитория $_repo: структура + $VERIFY_SUBSET данных ==="
_tmp="$RUNTIME_DIR/verify.db"
@@ -378,6 +495,7 @@ cmd_verify() {
&& db_ok "$_tmp"; then
_counts="$(db_counts "$_tmp")"
mark "verify-$_repo" ok "данные целы; последний снимок: игроков ${_counts%%|*}, партий ${_counts##*|}"
+ _verified="$_verified $_repo:${_counts%%|*}:${_counts##*|}"
log "OK: $_repo — данные целы, БД последнего снимка открывается (игроков ${_counts%%|*}, партий ${_counts##*|})."
else
mark "verify-$_repo" err "проверка не прошла — см. docker compose logs backup"
@@ -779,6 +897,121 @@ cmd_init() {
for _repo in $(repos); do ensure_repo "$_repo" && log "Репозиторий $_repo готов."; done
}
+# ─── telegram ─────────────────────────────────────────────────────────────────
+tg_help_text() {
+ cat <<'EOF'
+Бэкапы Forbidden Stars
+/backups — хранящиеся снимки по репозиториям
+/status — последние бэкапы и проверки, размеры
+Отчёт о каждом бэкапе и проверке приходит сюда сам.
+EOF
+}
+
+tg_backups_text() { # хранящиеся снимки: по репозиторию итог и 10 последних
+ [ -n "$RESTIC_PASSWORD" ] || { echo "Бэкапы отключены: BACKUP_PASSWORD не задан."; return 0; }
+ setup_ssh
+ for _repo in $(repos); do
+ echo "$_repo · $(repo_summary "$_repo")"
+ if _json="$(r "$_repo" snapshots --json 2>/dev/null)"; then
+ printf '%s' "$_json" | jq -r '
+ def tagval($p): ([.tags[]? | select(startswith($p)) | ltrimstr($p)] | first) // "?";
+ def names: [.tags[]? | select(test("^(players:|matches:|scheduled$|manual$|keep$)") | not)];
+ sort_by(.time) | .[-10:] | reverse | .[] |
+ "\(.time[5:16] | sub("T"; " ")) игроков \(tagval("players:")), партий \(tagval("matches:"))"
+ + (if (names | length) > 0 then " · 📌 " + (names | join(", ")) else "" end)'
+ else
+ echo "репозиторий недоступен или занят — повторите позже"
+ fi
+ echo
+ done
+}
+
+tg_allowed() { # tg_allowed $(cmd_status 2>&1 | head -n 60 | tg_escape)" ;; + *) _reply="$(tg_help_text)" ;; + esac + [ -n "$_reply" ] || _reply="Не удалось получить данные — см. docker compose logs backup." + tg_post "$1" "$_reply" || log "Telegram: ответ в чат $1 не отправлен." +} + +# Бот: long polling getUpdates. Отвечает только чатам из BACKUP_TELEGRAM_CHAT_ID; про чужие +# пишет chat id в лог — так владелец узнаёт свой при настройке. Lock не берёт: чтение +# restic совместимо с идущим бэкапом. +cmd_telegram_bot() { + tg_enabled || die "Telegram не настроен: нужны BACKUP_TELEGRAM_CHAT_ID и токен бота." + _offset_file="$STATE_DIR/telegram.offset" + _updates="$RUNTIME_DIR/telegram-updates" + log "Telegram-бот: слушаю команды /backups, /status, /help." + while :; do + _offset="$(cat "$_offset_file" 2>/dev/null || echo 0)" + if ! _resp="$(tg_api getUpdates --data-urlencode "offset=$_offset" \ + --data-urlencode "timeout=50" --data-urlencode 'allowed_updates=["message"]')"; then + sleep 30 + continue + fi + # Разделитель — \037 (не пробельный): пустые поля (нет username/текста) не схлопываются. + printf '%s' "$_resp" | jq -r '.result[]? | + [.update_id, (.message.chat.id // ""), (.message.from.username // ""), + ((.message.text // "") | gsub("[\n]"; " "))] | map(tostring) | join("")' \ + > "$_updates" 2>/dev/null || { sleep 30; continue; } + while IFS="$(printf '\037')" read -r _uid _chat _from _text; do + echo $((_uid + 1)) > "$_offset_file" + [ -n "$_chat" ] || continue + if tg_allowed "$_chat"; then + tg_reply "$_chat" "$_text" + else + log "Telegram: сообщение из чужого чата $_chat (@${_from:-?}) — без ответа. Если это вы, впишите $_chat в BACKUP_TELEGRAM_CHAT_ID." + fi + done < "$_updates" + done +} + +# Кто писал боту: id чатов для BACKUP_TELEGRAM_CHAT_ID. Нужен только токен; апдейты не +# подтверждаются (без offset), так что ничего не теряется. Пока бот-слушатель запущен, он +# забирает сообщения сам — тогда id смотрите в журнале («сообщение из чужого чата …»). +cmd_telegram_chats() { + [ -n "$TG_TOKEN" ] || die "нет токена бота: задайте TELEGRAM_BOT_TOKEN или BACKUP_TELEGRAM_BOT_TOKEN." + _resp="$(tg_api getUpdates --data-urlencode "timeout=0")" \ + || die "Telegram не ответил (неверный токен или нет сети)." + _chats="$(printf '%s' "$_resp" | jq -r '[.result[]? | .message | select(. != null) + | "\(.chat.id)\t@\(.from.username // "?")\t\(.text // "")"] | unique | .[]')" + if [ -z "$_chats" ]; then + echo "Сообщений боту нет: напишите ему /start в Telegram и повторите." + return 0 + fi + echo "Чаты, писавшие боту (id — в BACKUP_TELEGRAM_CHAT_ID):" + printf '%s\n' "$_chats" +} + +cmd_telegram() { + case "${1:-}" in + bot) cmd_telegram_bot ;; + chats) cmd_telegram_chats ;; + test) + tg_enabled || die "Telegram не настроен: нужны BACKUP_TELEGRAM_CHAT_ID и токен бота (BACKUP_TELEGRAM_BOT_TOKEN или TELEGRAM_BOT_TOKEN)." + _sent=0 + for _chat in $(tg_chat_ids); do + if tg_post "$_chat" "🔔 Проверка связи: отчёты о бэкапах Forbidden Stars будут приходить сюда."; then + _sent=$((_sent + 1)) + log "Telegram: тестовое сообщение в чат $_chat отправлено." + else + log "Telegram: в чат $_chat отправить не удалось (неверный токен/chat id или боту не писали /start)." + fi + done + [ "$_sent" -gt 0 ] || die "ни одно тестовое сообщение не дошло." + ;; + *) die "telegram: укажите chats, test или bot" ;; + esac +} + usage() { cat <<'EOF' fs-backup — бэкапы Forbidden Stars (restic). Запуск на Pi из папки с docker-compose.yml: @@ -806,6 +1039,11 @@ fs-backup — бэкапы Forbidden Stars (restic). Запуск на Pi из export
+ Раунд, цели и миры необязательны, но делают рейтинг точнее: быстрая и крупная победа + весит больше. +
+ {warnings.length > 0 && ( ++ Действует на партии, начатые после смены: лимит раундов уже идущих и сыгранных + партий не меняется. +
+- Рейтинг считается только по завершённым партиям. Незавершённые и отменённые - в зачёт не идут. + Рейтинг — оценка силы игрока относительно соперников (система Elo). Каждый начинает + с 1500. Разница в 400 пунктов означает шансы 10 к 1 в пользу более + сильного.
- За каждую партию игрок получает «очки за место» — они нормированы по числу игроков - за столом, поэтому победа за большим столом ценится выше, чем за маленьким: + Считаются только завершённые партии, по порядку их игры. Правка или удаление + старой партии пересчитывает и все последующие.
-+ Партия раскладывается на пары игроков. В каждой паре фактический результат (выше — + 1, поровну — 0.5, ниже — 0) сравнивается с ожидаемым по рейтингам до партии: +
+- где N — число игроков в партии. Первое место даёт 1.0, последнее — 0.0. -
-- Итоговый рейтинг игрока — сглаженное среднее: к реальным партиям - «дописываются» 10 виртуальных со средним результатом 0.5 (шкала от 0 до 100): -
-- Пока партий мало, рейтинг держится около 50 и с опытом сходится к реальному - среднему — стабильные результаты на длинной дистанции ценятся выше короткой - удачной серии. + где N — число игроков, сумма — по всем соперникам. Поэтому победа над сильным + приносит больше, чем над слабым, а поражение от слабого отнимает больше. Выбывшие + делят последнее место, но между собой не сравниваются: в этой партии все они + проиграли, так что пара двух выбывших рейтинг не двигает.
+ Раунд окончания, цели и миры вводить необязательно: пропущенное считается + обычным значением и множитель не меняет. «Последний выживший» ставится сам, когда + все соперники выбыли, — отрыв по целям тогда максимальный. Лимит раундов — 8, + с правилом группы «9 раундов» при 5–6 игроках — 9. +
++ Единоличный победитель никогда не теряет рейтинг, а единоличное последнее место + и выбывание никогда его не приносят. Невыбывшие, поделившие место, сравниваются + между собой как в ничьей: слабый может получить рейтинг, сильный — потерять. +
++ Рейтинг показывается целым числом, но считается без округления. +
++ Рейтинг у игрока один — по всем его партиям во всех группах, поэтому он одинаковый + в общем топе, в профиле и на странице группы. На странице группы по партиям этой + группы считаются только игры, победы, винрейт и среднее место; рейтинг и статус + «Новичок» там общие. +
+ Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого игрока, чтобы + разделить место (ничья). Цели и миры — на конец партии. +
+ ); + return ({match.overall_comment}
} @@ -304,20 +404,16 @@ export function MatchDetailPage() { <>- Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого - игрока, чтобы разделить место (ничья). -
+ {placeHint}- Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого - игрока, чтобы разделить место (ничья). -
+ {placeHint} {match.finish_draft && match.finish_draft.updated_by !== me?.id && (Результаты заполняет также {match.finish_draft.updated_by_nickname ?? "другой игрок"} @@ -464,10 +560,10 @@ export function MatchDetailPage() { blocks={finishBlocks} eliminated={elim} comments={finishComments} + counts={finishCounts} onChange={(b, e) => { - setBlocks(b); - setElim(e); - queueDraft(draftOf({ blocks: b, eliminated: e })); + const reason = applyLayout(b, e); + queueDraft(draftOf({ blocks: b, eliminated: e, win_reason: reason })); }} onComment={(uid, text) => { const next = { ...finishComments, [uid]: text }; @@ -480,23 +576,33 @@ export function MatchDetailPage() { }), ); }} - /> -