25 Commits
Author SHA1 Message Date
NotBigGhost f0a5cf4dff v1.5 — Обновление системы рейтинга, переход на elo с модифицирующими коэффициентами. 2026-09-30 13:51:44 +03:00
NotBigGhost 0b33b442e9 Merge pull request 'Устаревшие комментарии и подсказки в коде, export-скрипты; мусорный файл в репозитории (#70)' (#99) from issue-70-stale-comments into dev
Reviewed-on: #99
2026-09-30 13:05:53 +03:00
NotBigGhost d12afe62d0 Merge pull request 'Защита бэкапов от пустой БД пропускает БД без таблиц (счётчики «?») (#74)' (#100) from issue-74-backup-guard into dev
Reviewed-on: #100
2026-09-30 13:04:37 +03:00
NotBigGhost 30270916a4 Merge pull request 'Часовой пояс отображения захардкожен на фронте и не зависит от APP_TZ_OFFSET_HOURS (#68)' (#98) from issue-68-tz-offset into dev
Reviewed-on: #98
2026-09-30 13:03:39 +03:00
NotBigGhostandClaude Opus 5 f5f8dc03ce Бэкапы: БД без таблиц не становится снимком, первый бэкап ждёт приложение
guard_empty считал неизвестные счётчики («?» — нет таблиц users/matches)
непустыми, и снимок без данных уходил в оба репозитория и становился latest —
restore latest в сценарии «Pi умер» падал на verify_staging. Теперь это отказ,
как для пустой БД (обход — --allow-empty).

entrypoint перед самым первым бэкапом ждёт /api/health приложения (до
BACKUP_APP_WAIT_SECONDS, 600 с): оно отвечает только после миграций. Не дождались —
пробуем всё равно, guard не пропустит БД без таблиц. README, раздел 9: новые
строки журнала, снимки с «?» больше не создаются. #74

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-19 03:15:49 +03:00
NotBigGhostandClaude Opus 5 ec1e94119c Чистка: устаревшие комментарии и подсказки, export-скрипт, мусорный файл
Комментарии про методы входа приведены к факту (логин/пароль и Telegram везде,
stub только в development): config.py, .env.example, telegram.py, dev_stub.py,
dev_auth.py, login.py. admin_login.py больше не обещает «строже игроцкого» —
описаны реальные лимиты; client_ip — адрес за прокси уже даёт uvicorn.

Подсказки деплоя: run.ps1/run.sh (build-push на ПК, `up -d` на Pi), compose,
.env.example (build-push.ps1), Caddyfile (туннель-контейнер вместо autossh);
ratelimit.py ссылается на core/events.py вместо CLAUDE.md.

Удалены неиспользуемые scripts/export-prod.sh (с блоком export-ignore в
.gitattributes и упоминаниями в README), db/init_db.py и случайно закоммиченный
файл «h -u origin dev…». #70

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-19 03:12:16 +03:00
NotBigGhostandClaude Opus 5 8d4f7a95df Время: один пояс приложения из .env и на сервере, и на фронте
APP_TZ_OFFSET_HOURS влиял только на «дату игры», а фронт показывал время в
захардкоженных +3 — при другом значении дата и время партии противоречили друг
другу. Теперь /api/auth/config отдаёт tz_offset_hours, App.tsx выставляет его в
format.ts до первой отрисовки страниц, и в нём же показывается всё время и
вводятся даты объявлений (запасное значение +3, если конфиг недоступен). Пояс
устройства не учитывается — решение владельца. Валидатор ограничивает смещение
−12..14. #68

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-19 03:05:30 +03:00
NotBigGhost 551032d66e Merge pull request 'Система объявлений в main без рейтинга (#84)' (#86) from issue-84-announcements-main into main
Reviewed-on: #86
2026-09-18 22:06:49 +03:00
NotBigGhostandClaude Opus 5 d31e998f87 Объявления: окно у игрока и редактор в админке
Окно — вариант E из макетов: заголовок в плашке-шапке, текст, точки очереди
и «Понятно», крестика нет. Очередь идёт от старого к новому; закрытое сразу
уходит из кэша, следующее открывается без ожидания сети. У повторно
показанного объявления под заголовком — красная пометка «обновлено».
AppShell показывает окно на любой странице и только когда пароль задан:
обязательное окно пароля всегда первое. Новые и изменённые объявления
приходят SSE-событием announcements, отложенное начало показа — перезапросом
раз в 5 минут.

Админка — вкладка «Объявления» с редактором R1: contenteditable и панель
(жирный, курсив, золотой и красный акцент, эмодзи, снятие оформления),
вставка и перетаскивание только простым текстом, счётчик символов, период
показа по МСК, переключатели «показывать новым игрокам» и «показать заново».
Предпросмотр — тем же окном, что у игрока. Список со статусом, счётчиком
«закрыли N из M» и действиями: изменить, снять с показа, дублировать,
удалить. Выделение текста в форме — полупрозрачный оранжевый вместо синего.

schema.d.ts пересобран из OpenAPI. #84

Перенесено в main без рейтинга из ab1fc3f; schema.d.ts сверен с OpenAPI этой ветки.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:59:32 +03:00
NotBigGhostandClaude Opus 5 976b8fc622 Объявления: модель, API игрока и админки, очистка HTML
Объявление администрации показывается игроку окном в свой период, пока игрок
не закроет его («Понятно»). Отметка о закрытии хранится на сервере с номером
версии: правка с «показать заново» поднимает версию, и закрывшие прежнюю
увидят объявление снова — ответ помечен updated («обновлено»). Флаг
show_to_new_players=false прячет объявление от зарегистрировавшихся после
начала показа. Пересекающиеся объявления идут от старого к новому.

Текст приходит HTML-ом из редактора админки и сохраняется только после
очистки по белому списку (b, em, mark и mark.red, p, br): атрибуты
отбрасываются, script/style/svg — вместе с содержимым, текст экранируется
заново. Фронт вставляет только этот HTML.

API: GET /api/announcements/pending, POST /api/announcements/{id}/ack;
админка — список со статусом и счётчиком «закрыли N из M», создание, правка,
«снять с показа», удаление, всё в аудит. SSE-событие announcements активным
игрокам. Миграция 0015 идемпотентная.

Тесты: очистка (XSS-попытки, вложенные div), права, период и порядок,
«новые игроки», повторный показ, снятие, удаление, валидация. #84

Перенесено в main без рейтинга из a0a0e52; миграция 0015 — сразу от 0013
(как в ec0445f).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:57:46 +03:00
NotBigGhostandClaude Opus 5 07a14ecc9b Гитигнор: локальный AGENTS.md не идёт в репозиторий
AGENTS.md — такой же локальный гайд для ассистента, как CLAUDE.md, только
для другого инструмента. Он лежал в рабочем дереве незакоммиченным и висел
в git status; теперь он в блоке «AI-ассистенты» рядом с CLAUDE.md
и .claude/, так что случайный `git add .` его не подхватит. #84

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:55:53 +03:00
NotBigGhost 9d77971b0f v1.4 — Настрйока бэкапа БД и хранимых медиа. Третьим контейнером с restic. Бэкап на VPS 2026-09-14 17:55:49 +03:00
NotBigGhost d3ecbca731 v1.37 — аутентификация через логин и пароль 2026-09-13 18:48:46 +03:00
NotBigGhost a3984638c3 v1.351 — пересмотр логики выдачи CSRF-токена 2026-09-13 13:17:37 +03:00
NotBigGhost 96c32d99c5 v1.35 — полировка мелких ошибок вёрстки, оптимизация сетевого кода, унификация и доработка элементов интерфейса 2026-09-13 12:31:04 +03:00
NotBigGhost 30e9669361 v1.3 — Глобальное изменение интерфейса завершения партии. Для выбора места теперь используется интуитивное перетаскивание тайлов 2026-07-19 15:31:30 +03:00
NotBigGhost 867b1f868c v1.21 — Полировка выпадающих списков в меню начала партии 2026-07-18 03:48:36 +03:00
NotBigGhost 74b9b6698f v1.2 — Новая логика подсчёта и отображения рейтинга (Байесовская оценка решения) 2026-07-17 03:12:46 +03:00
NotBigGhost 05e1f79c25 v1.11 — QoL улучшения интерфейса, унификация его эллементов 2026-07-17 02:46:13 +03:00
NotBigGhost 98cd82f045 v1.1 - Пересмотр деплоя прода 2026-06-29 22:26:26 +03:00
NotBigGhost e44a62f9cc v1.0 - Финальная проверка изоляции продакшна. Настройка бэкапа на VPS, подготовка образа 2026-06-27 01:58:34 +03:00
NotBigGhost 9f599f18d7 v0.9 - Справка и надёжная офлайн-страница
- Справка: новая страница /help с описанием подсчёта очков игроков (формула League
  Points, ничьи, выбывшие = последнее место, порог 3 партии для топа); кнопка
  «Справка» добавлена в боковое меню. Задел под будущие правила и подсказки.
- index.html больше не кэшируется (no-store на Caddy и в FastAPI): при недоступности
  хоста надёжно показывается страница-заглушка вместо зависшей «Загрузки».
2026-06-26 23:15:06 +03:00
NotBigGhost 90d0eb2546 v0.8 - Пересмотр элементов интерфейса, улучшены навигация по приложению, улучшена читаемость некоторых эллементов, добавлены анимации 2026-06-26 18:19:13 +03:00
NotBigGhost 64ac0efb36 v0.7 - Реализованы уведомления 2026-06-26 15:52:15 +03:00
NotBigGhost 5fad9dbea4 v0.6 - Настройка SSE-соединения для пуша данных с сервера 2026-06-17 23:22:09 +03:00
27 changed files with 137 additions and 130 deletions
+7 -5
View File
@@ -6,7 +6,7 @@
# ─── ГЛАВНЫЙ ПЕРЕКЛЮЧАТЕЛЬ ──────────────────────────────────────────────────── # ─── ГЛАВНЫЙ ПЕРЕКЛЮЧАТЕЛЬ ────────────────────────────────────────────────────
# Этот параметр читает ЛАУНЧЕР (run.ps1 / run.sh) и решает, что запускать: # Этот параметр читает ЛАУНЧЕР (run.ps1 / run.sh) и решает, что запускать:
# development — нативно: uvicorn --reload + vite, БД в ./data/dev/, вход TG+ник # development — нативно: uvicorn --reload + vite, БД в ./data/dev/, есть вход по нику (stub)
# production — НЕ запускается лаунчером; деплой на Pi отдельно (docker compose up -d). # production — НЕ запускается лаунчером; деплой на Pi отдельно (docker compose up -d).
# Прод-контейнер ИГНОРИРУЕТ это значение и всегда production. # Прод-контейнер ИГНОРИРУЕТ это значение и всегда production.
APP_ENV=development APP_ENV=development
@@ -44,8 +44,8 @@ ADMIN_NICKNAME=Администратор
ADMIN_BOOTSTRAP_ENABLED=true ADMIN_BOOTSTRAP_ENABLED=true
# ─── АУТЕНТИФИКАЦИЯ ИГРОКОВ ─────────────────────────────────────────────────── # ─── АУТЕНТИФИКАЦИЯ ИГРОКОВ ───────────────────────────────────────────────────
# Методы входа задаёт APP_ENV: dev → Telegram + stub (вход по нику), prod → только # Вход везде — логин/пароль и Telegram; в development ещё stub (по нику без пароля).
# Telegram. Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у # Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у
# BotFather укажи на ОБА домена, где открывается виджет: forbiddenstars.ru (prod) # BotFather укажи на ОБА домена, где открывается виджет: forbiddenstars.ru (prod)
# и forbidden-stars.ru (dev). # и forbidden-stars.ru (dev).
TELEGRAM_BOT_TOKEN= TELEGRAM_BOT_TOKEN=
@@ -81,7 +81,7 @@ DEV_ACHIEVEMENTS_DIR=./data/dev/achievements
PROD_ACHIEVEMENTS_DIR=/data/achievements PROD_ACHIEVEMENTS_DIR=/data/achievements
# ─── ОБРАЗЫ ПРОДА (реестр для docker compose pull на Pi) ────────────────────── # ─── ОБРАЗЫ ПРОДА (реестр для docker compose pull на Pi) ──────────────────────
# Образы собираются под arm64 на ПК (scripts/build-push.sh) и пушатся в Gitea-реестр, # Образы собираются под arm64 на ПК (scripts/build-push.ps1; в Linux — .sh) и пушатся в Gitea-реестр,
# а Pi их тянет (docker compose pull). Owner в пути — строчными. Тег можно версионировать. # а Pi их тянет (docker compose pull). Owner в пути — строчными. Тег можно версионировать.
# Перед пушем/пуллом: docker login gitea.arseniev.info # Перед пушем/пуллом: docker login gitea.arseniev.info
IMAGE_REGISTRY=gitea.arseniev.info/notbigghost IMAGE_REGISTRY=gitea.arseniev.info/notbigghost
@@ -135,7 +135,9 @@ BACKUP_PI_SSH=pi@192.168.1.10
BACKUP_PI_DIR=~/forbidden-stars BACKUP_PI_DIR=~/forbidden-stars
# ─── ПРОЧЕЕ ─────────────────────────────────────────────────────────────────── # ─── ПРОЧЕЕ ───────────────────────────────────────────────────────────────────
# Часовой пояс приложения (фикс. смещение в часах; МСК = 3) # Часовой пояс приложения (фикс. смещение в часах, −12..14; МСК = 3). Один на всех: в нём
# сервер ставит «дату игры», а фронт показывает время и принимает даты объявлений —
# пояс устройства игрока не учитывается.
APP_TZ_OFFSET_HOURS=3 APP_TZ_OFFSET_HOURS=3
# CORS нужен только в dev (фронт и API на разных портах); в prod single-origin # CORS нужен только в dev (фронт и API на разных портах); в prod single-origin
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
-13
View File
@@ -3,16 +3,3 @@
# Скрипты образов (entrypoint.sh, deploy/*/…) контейнер дополнительно чинит sed-ом при сборке. # Скрипты образов (entrypoint.sh, deploy/*/…) контейнер дополнительно чинит sed-ом при сборке.
*.sh text eol=lf *.sh text eol=lf
backend/entrypoint.sh text eol=lf backend/entrypoint.sh text eol=lf
# ── export-ignore: НЕ попадает в `git archive` (чистая выгрузка прода) ─────────
# В git эти файлы есть и доступны на всех ветках (нужны для разработки),
# но в архив деплоя (scripts/export-prod.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
+1 -5
View File
@@ -16,7 +16,7 @@
backend/ FastAPI: ядро, REST API, БД, миграции Alembic, seed, тесты backend/ FastAPI: ядро, REST API, БД, миграции Alembic, seed, тесты
frontend/ React + Vite SPA frontend/ React + Vite SPA
deploy/ публикация и эксплуатация: vps/ (Caddy), pi/ (прод), tunnel/ и backup/ (образы) deploy/ публикация и эксплуатация: vps/ (Caddy), pi/ (прод), tunnel/ и backup/ (образы)
scripts/ build-push.* (сборка и пуш образов), fs-backup.* (бэкапы с ПК), export-prod.sh scripts/ build-push.* (сборка и пуш образов), fs-backup.* (бэкапы с ПК)
Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI) Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI)
docker-compose.yml прод на Pi: app + tunnel + backup docker-compose.yml прод на Pi: app + tunnel + backup
docker-compose.temp.yml временный прод на ПК вместо Pi: app + tunnel docker-compose.temp.yml временный прод на ПК вместо Pi: app + tunnel
@@ -214,10 +214,6 @@ docker compose up -d # pull_policy: always — тянет свежие
- **Деплой на Pi:** на ПК с ветки `main` — `.\scripts\build-push.ps1` (собирает и пушит - **Деплой на Pi:** на ПК с ветки `main` — `.\scripts\build-push.ps1` (собирает и пушит
образы app + tunnel + backup под arm64), на Pi — `docker compose up -d`. На Windows нужна образы app + tunnel + backup под arm64), на Pi — `docker compose up -d`. На Windows нужна
именно PS-версия скрипта (`build-push.sh` из PowerShell уходит в WSL). именно PS-версия скрипта (`build-push.sh` из PowerShell уходит в WSL).
- **Чистая выгрузка в папку без git** (опц., к деплою на Pi не относится):
`scripts/export-prod.sh <dir> [ref]` — через `git archive` + `export-ignore` из
`.gitattributes` (без тестов, stub-входа, `pyproject.toml`, README-файлов и лаунчера).
`dev_admin.py` в `export-ignore` пока не внесён (задача #70).
Секреты (`.env`) и данные (`data/`, `*.db`) в git не идут — см. `.gitignore`. Секреты (`.env`) и данные (`data/`, `*.db`) в git не идут — см. `.gitignore`.
+3 -2
View File
@@ -2,8 +2,9 @@
Тонкий слой поверх `admin_service.authenticate_admin`: throttle по IP, по паре «IP + логин» Тонкий слой поверх `admin_service.authenticate_admin`: throttle по IP, по паре «IP + логин»
и по самому аккаунту через тот же `LoginThrottle`, что и вход игрока (`core/ratelimit`). и по самому аккаунту через тот же `LoginThrottle`, что и вход игрока (`core/ratelimit`).
Сервис остаётся чистым от инфраструктуры лимитов. Пароль администратора — единственный Сервис остаётся чистым от инфраструктуры лимитов. Лимиты те же, что у игрока (5 на пару,
барьер к полному контролю приложения, поэтому перебор здесь ограничиваем строже игроцкого. 20 на IP, 50 на аккаунт за 15 минут); отличие — при успешном входе снимаются все счётчики,
включая IP.
""" """
from __future__ import annotations from __future__ import annotations
+1 -1
View File
@@ -1,4 +1,4 @@
"""Dev-провайдер: вход без секрета по нику/идентификатору (только не-production).""" """Dev-провайдер: вход без секрета по нику/идентификатору (только development)."""
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
+2 -2
View File
@@ -1,7 +1,7 @@
"""Общий вход: внешняя личность → пользователь → сессия. """Общий вход: внешняя личность → пользователь → сессия.
Прод-безопасный модуль (без импортов dev-провайдера). Используется и Telegram-входом, Прод-безопасный модуль (без импортов dev-провайдера). establish_session зовут вход через
и dev-входом. Telegram, /auth/login, /auth/register и dev-вход.
""" """
from __future__ import annotations from __future__ import annotations
+1 -1
View File
@@ -2,7 +2,7 @@
Проверяет подпись данных виджета (HMAC-SHA256 ключом SHA256(BOT_TOKEN)) и свежесть Проверяет подпись данных виджета (HMAC-SHA256 ключом SHA256(BOT_TOKEN)) и свежесть
auth_date. Нужны TELEGRAM_BOT_TOKEN (+ TELEGRAM_BOT_USERNAME для виджета на фронте). auth_date. Нужны TELEGRAM_BOT_TOKEN (+ TELEGRAM_BOT_USERNAME для виджета на фронте).
Доступен и в dev, и в prod (в prod — единственный метод входа). Доступен во всех окружениях — наряду со входом по логину и паролю.
""" """
from __future__ import annotations from __future__ import annotations
+15 -5
View File
@@ -31,7 +31,7 @@ class Settings(BaseSettings):
) )
# ── Главный переключатель окружения: development | production ───────────── # ── Главный переключатель окружения: development | production ─────────────
# development — нативный dev (uvicorn + vite), БД в ./data/dev/, вход Telegram+ник. # development — нативный dev (uvicorn + vite), БД в ./data/dev/, есть stub-вход по нику.
# production — Docker на Pi; контейнер форсит это значение, игнорируя .env. # production — Docker на Pi; контейнер форсит это значение, игнорируя .env.
app_env: str = "development" app_env: str = "development"
log_level: str = "INFO" log_level: str = "INFO"
@@ -42,8 +42,9 @@ class Settings(BaseSettings):
# от этого зависят Secure-cookie и проверка секретов при старте. # от этого зависят Secure-cookie и проверка секретов при старте.
local_public: str = "local" local_public: str = "local"
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3). # Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3). Хранение всегда
# Хранение всегда в UTC; смещение применяется к «дате игры» и отображению. # в UTC; смещение применяется к «дате игры» (timeutil.app_today) и к отображению времени
# на фронте — оно приходит туда в GET /api/auth/config (#68).
app_tz_offset_hours: int = 3 app_tz_offset_hours: int = 3
# ── БД: структура общая, файлы РАЗНЫЕ для dev и prod; выбор по app_env ───── # ── БД: структура общая, файлы РАЗНЫЕ для dev и prod; выбор по app_env ─────
@@ -67,8 +68,8 @@ class Settings(BaseSettings):
# cookie_secure НЕ задаётся вручную — выводится из окружения (см. property ниже). # cookie_secure НЕ задаётся вручную — выводится из окружения (см. property ниже).
cookie_domain: str | None = None cookie_domain: str | None = None
# Аутентификация. Методы входа определяются окружением (dev: telegram+stub, # Аутентификация. Методы входа определяются окружением (auth/registry.py): везде
# prod: только telegram) — отдельного переключателя провайдера нет. # логин/пароль + Telegram, в development ещё stub — отдельного переключателя нет.
telegram_bot_token: str | None = None telegram_bot_token: str | None = None
telegram_bot_username: str | None = None telegram_bot_username: str | None = None
public_base_url: str | None = None public_base_url: str | None = None
@@ -139,6 +140,15 @@ class Settings(BaseSettings):
) )
return value return value
@field_validator("app_tz_offset_hours")
@classmethod
def _known_tz_offset(cls, value: int) -> int:
"""Реальные пояса — от −12 до +14: опечатка в .env — ошибка старта, а не время,
сдвинутое на сутки."""
if not -12 <= value <= 14:
raise ValueError(f"APP_TZ_OFFSET_HOURS={value} вне диапазона −12..14")
return value
@model_validator(mode="after") @model_validator(mode="after")
def _forbid_default_secrets_when_published(self) -> "Settings": def _forbid_default_secrets_when_published(self) -> "Settings":
"""Fail-fast: опубликованное приложение не стартует с дефолтными/слабыми """Fail-fast: опубликованное приложение не стартует с дефолтными/слабыми
+1 -1
View File
@@ -6,7 +6,7 @@
* рестарт (в т.ч. деплой) сбрасывает окно — злоумышленник получает новую квоту после * рестарт (в т.ч. деплой) сбрасывает окно — злоумышленник получает новую квоту после
перезапуска, но окно короткое, а рестарты редки; перезапуска, но окно короткое, а рестарты редки;
* при уходе от одного воркера лимит делится между процессами (каждый считает своё) — * при уходе от одного воркера лимит делится между процессами (каждый считает своё) —
тогда счётчики нужно вынести во внешний стор (Redis pub/sub, как отмечено в CLAUDE.md тогда счётчики нужно вынести во внешний стор (Redis pub/sub, как отмечено в core/events.py
про SSE-шину), общий для всех воркеров. про SSE-шину), общий для всех воркеров.
Помимо пары «IP + логин» и лимита по IP есть IP-независимый лимит на аккаунт Помимо пары «IP + логин» и лимита по IP есть IP-независимый лимит на аккаунт
(`login-user:*` / `admin-login-user:*`), чтобы ротация X-Forwarded-For / многих адресов (`login-user:*` / `admin-login-user:*`), чтобы ротация X-Forwarded-For / многих адресов
+2 -2
View File
@@ -175,6 +175,6 @@ def is_session_revoked(payload: dict) -> bool:
def client_ip(request: Request) -> str | None: def client_ip(request: Request) -> str | None:
"""IP клиента для журнала аудита. """IP клиента для журнала аудита.
Одна точка на всё приложение: за VPS-привратником адрес придётся брать из Одна точка на всё приложение. Реальный адрес за VPS-привратником уже подставляет
X-Forwarded-For, и менять это в двух десятках роутеров — не вариант.""" uvicorn (--proxy-headers + --forwarded-allow-ips в entrypoint.sh) — отсюда он и берётся."""
return request.client.host if request.client else None return request.client.host if request.client else None
-21
View File
@@ -1,21 +0,0 @@
"""Инициализация схемы и справочников (для тестов и локального быстрого старта).
В production схема создаётся миграциями Alembic; этот модуль удобен для тестов,
где БД поднимается из чистого состояния.
"""
from __future__ import annotations
from sqlmodel import SQLModel
from app.db.session import engine
from app.seed.reference_data import seed_reference_data
from sqlmodel import Session
# Импорт моделей обязателен, чтобы они зарегистрировались в SQLModel.metadata.
import app.models # noqa: F401
def create_db_and_seed() -> None:
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
seed_reference_data(session)
+1
View File
@@ -28,6 +28,7 @@ def auth_config() -> s.AuthConfig:
return s.AuthConfig( return s.AuthConfig(
methods=enabled_methods(), methods=enabled_methods(),
telegram_bot_username=settings.telegram_bot_username, telegram_bot_username=settings.telegram_bot_username,
tz_offset_hours=settings.app_tz_offset_hours,
) )
+2 -2
View File
@@ -1,8 +1,8 @@
"""DEV-ТОЛЬКО роутер: вход по нику (stub) + тестовые пользователи. """DEV-ТОЛЬКО роутер: вход по нику (stub) + тестовые пользователи.
Этот файл и app/auth/dev_stub.py ФИЗИЧЕСКИ исключены из прод-образа (.dockerignore), Этот файл и app/auth/dev_stub.py ФИЗИЧЕСКИ исключены из прод-образа (.dockerignore),
а подключается роутер лишь когда APP_ENV != production (см. app/main.py). Так код а подключается роутер лишь при APP_ENV=development (см. app/main.py). Так вход
входа по логину остаётся только на деве. без пароля остаётся только на деве (по логину и паролю входят везде).
""" """
from __future__ import annotations from __future__ import annotations
+3
View File
@@ -22,6 +22,9 @@ class AuthConfig(BaseModel):
# Доступные методы входа: ["password","telegram"] в проде, плюс "stub" в деве. # Доступные методы входа: ["password","telegram"] в проде, плюс "stub" в деве.
methods: list[str] = [] methods: list[str] = []
telegram_bot_username: str | None = None telegram_bot_username: str | None = None
# Пояс приложения (APP_TZ_OFFSET_HOURS): в нём сервер считает «дату игры», а фронт
# показывает время всем игрокам — независимо от пояса устройства (#68).
tz_offset_hours: int
# Верхняя граница длины пароля на входе API: отсекает мегабайтные тела до bcrypt. # Верхняя граница длины пароля на входе API: отсекает мегабайтные тела до bcrypt.
+17
View File
@@ -140,3 +140,20 @@ def test_unknown_app_env_rejected(app_env):
def test_app_env_case_insensitive(): def test_app_env_case_insensitive():
s = config.Settings(app_env="Development") s = config.Settings(app_env="Development")
assert s.is_development assert s.is_development
# ─── Пояс приложения (#68) ────────────────────────────────────────────────────
@pytest.mark.parametrize("offset", [-13, 15])
def test_tz_offset_out_of_range_rejected(offset):
with pytest.raises(ValidationError, match="APP_TZ_OFFSET_HOURS"):
config.Settings(app_env="development", app_tz_offset_hours=offset)
def test_auth_config_exposes_app_tz_offset(client, monkeypatch):
"""Фронт показывает время в поясе приложения — смещение приходит из настроек."""
from app.routers import auth
monkeypatch.setattr(auth.settings, "app_tz_offset_hours", 5)
assert client.get("/api/auth/config").json()["tz_offset_hours"] == 5
+12 -5
View File
@@ -737,16 +737,23 @@
``` ```
3. Дождитесь, пока приложение создаст пустую БД (`docker compose ps` → `app` `(healthy)`). 3. Дождитесь, пока приложение создаст пустую БД (`docker compose ps` → `app` `(healthy)`).
Контейнер `backup` стартует одновременно с `app` и сразу пробует сделать первый бэкап. В его Контейнер `backup` стартует одновременно с `app`, ждёт, пока приложение ответит (до
журнале (`docker compose logs backup`) нормально увидеть одно из двух: 10 минут), и пробует сделать первый бэкап. В его журнале (`docker compose logs backup`)
нормально увидеть одно из трёх:
- если `backup` успел раньше, чем `app` создал БД: - если приложение так и не поднялось и БД нет:
``` ```
ОШИБКА: БД /fs-db/forbidden_stars.db не найдена — приложение ещё ни разу не запускалось? ОШИБКА: БД /fs-db/forbidden_stars.db не найдена — приложение ещё ни разу не запускалось?
Первый бэкап не удался — следующая попытка по расписанию. Первый бэкап не удался — следующая попытка по расписанию.
``` ```
- если БД есть, но без таблиц (миграции ещё не прошли):
```
Не удалось прочитать число игроков и партий: в БД нет таблиц users/matches.
```
- если БД уже была: - если БД уже была:
``` ```
@@ -763,8 +770,8 @@
``` ```
Проверьте, что у выбранного снимка в столбцах `Игроков`/`Партий` числа, а не `0` и не `?`. Проверьте, что у выбранного снимка в столбцах `Игроков`/`Партий` числа, а не `0` и не `?`.
Защита выше не распознаёт БД без таблиц: если первый бэкап попал ровно в момент создания Снимки без данных защита больше не создаёт, но снимок с `?` мог остаться от старой версии
БД, в списке может появиться свежий снимок с `?` (задача #74). Такой снимок не выбирайте. бэкапов — такой не выбирайте.
5. Восстановите, подставив ID из `list vps`: 5. Восстановите, подставив ID из `list vps`:
+15 -1
View File
@@ -37,7 +37,21 @@ if [ ! -s "$CRONTAB" ]; then
fi fi
if ! fs-backup has-snapshots; then if ! fs-backup has-snapshots; then
log "Снимков в локальном репозитории ещё нет — делаю первый бэкап сразу." # Новый сервер: app и backup стартуют разом, и БД может быть ещё без таблиц. /api/health
# отвечает только после миграций и bootstrap — ждём его (#74). Не дождались — пробуем всё
# равно: guard_empty не сохранит БД без таблиц.
_app="${BACKUP_APP_HOST:-app}"
_wait="${BACKUP_APP_WAIT_SECONDS:-600}"
log "Снимков в локальном репозитории ещё нет — жду приложение (до ${_wait} с) и делаю первый бэкап."
_waited=0
until wget -q -T 3 -O /dev/null "http://$_app:8000/api/health" 2>/dev/null; do
if [ "$_waited" -ge "$_wait" ]; then
log "Приложение не ответило за ${_wait} с — пробую бэкап без него."
break
fi
sleep 10
_waited=$((_waited + 10))
done
fs-backup run --scheduled || log "Первый бэкап не удался — следующая попытка по расписанию." fs-backup run --scheduled || log "Первый бэкап не удался — следующая попытка по расписанию."
fi fi
+11
View File
@@ -275,6 +275,17 @@ forget_repo() {
# Защита истории от пустых данных: новый Pi до восстановления или случайно очищенная БД не # Защита истории от пустых данных: новый Pi до восстановления или случайно очищенная БД не
# должны становиться «последним снимком» (restore latest вернул бы пустоту). # должны становиться «последним снимком» (restore latest вернул бы пустоту).
guard_empty() { # guard_empty <игроков> <партий> 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 if [ "$1" != 0 ] || [ "$2" != 0 ]; then return 0; fi
for _repo in $(repos); do for _repo in $(repos); do
_prev="$(r "$_repo" snapshots latest --host "$SNAP_HOST" --json 2>/dev/null | jq -r \ _prev="$(r "$_repo" snapshots latest --host "$SNAP_HOST" --json 2>/dev/null | jq -r \
+1 -1
View File
@@ -1,7 +1,7 @@
# Caddy на VPS (186.246.51.17) — единственная публичная точка входа. # Caddy на VPS (186.246.51.17) — единственная публичная точка входа.
# Два домена, ОБА с твоими сертификатами; проксируют в SSH-туннели: # Два домена, ОБА с твоими сертификатами; проксируют в SSH-туннели:
# #
# forbiddenstars.ru → 127.0.0.1:9000 ← Pi (autossh, постоянно) PROD # forbiddenstars.ru → 127.0.0.1:9000 ← Pi (туннель-контейнер, ssh + restart) PROD
# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV # forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV
# #
# Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к # Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к
+2 -2
View File
@@ -21,8 +21,8 @@
services: services:
app: app:
build: . build: .
# Образ из реестра (Gitea): собирается под arm64 на ПК (scripts/build-push.sh) и тянется # Образ из реестра (Gitea): собирается под arm64 на ПК (scripts/build-push.ps1, в Linux —
# на Pi через `docker compose pull`. build: оставлен как локальный фолбэк (сборка на Pi). # .sh) и тянется на Pi. build: нужен только этой сборке на ПК — на Pi не используется.
image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars:${IMAGE_TAG:-latest} image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars:${IMAGE_TAG:-latest}
pull_policy: always # на Pi всегда тянем образ из реестра (без сборки) pull_policy: always # на Pi всегда тянем образ из реестра (без сборки)
restart: unless-stopped restart: unless-stopped
+18
View File
@@ -1,15 +1,33 @@
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { RouterProvider } from "react-router-dom"; import { RouterProvider } from "react-router-dom";
import { queryClient } from "./app/queryClient"; import { queryClient } from "./app/queryClient";
import { router } from "./app/router"; import { router } from "./app/router";
import { Spinner } from "./components/Spinner";
import { ToastProvider } from "./context/ToastContext"; import { ToastProvider } from "./context/ToastContext";
import { setAppTzOffsetHours } from "./domain/format";
import { useAuthConfig } from "./hooks/auth";
/**
* Пояс приложения (APP_TZ_OFFSET_HOURS) приходит с сервера и нужен до первой отрисовки:
* функции format.ts читают его синхронно (#68). Пока конфиг грузится — спиннер; если он
* недоступен (нет сети), рендерим с запасным поясом, чтобы не запереть приложение.
*/
function AppTimeZone({ children }: { children: ReactNode }) {
const { data, isPending } = useAuthConfig();
if (data) setAppTzOffsetHours(data.tz_offset_hours);
if (isPending) return <Spinner />;
return <>{children}</>;
}
export function App() { export function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ToastProvider> <ToastProvider>
<AppTimeZone>
<RouterProvider router={router} /> <RouterProvider router={router} />
</AppTimeZone>
</ToastProvider> </ToastProvider>
</QueryClientProvider> </QueryClientProvider>
); );
+2
View File
@@ -1610,6 +1610,8 @@ export interface components {
methods: string[]; methods: string[];
/** Telegram Bot Username */ /** Telegram Bot Username */
telegram_bot_username?: string | null; telegram_bot_username?: string | null;
/** Tz Offset Hours */
tz_offset_hours: number;
}; };
/** Body_add_attachment_api_matches__match_id__attachments_post */ /** Body_add_attachment_api_matches__match_id__attachments_post */
Body_add_attachment_api_matches__match_id__attachments_post: { Body_add_attachment_api_matches__match_id__attachments_post: {
+17 -9
View File
@@ -1,12 +1,19 @@
// Отображаем время в фиксированном поясе +3 (МСК) независимо от пояса браузера. // Время показываем в поясе приложения — одном для всех, независимо от пояса браузера.
const APP_TZ_OFFSET_MIN = 3 * 60; // Смещение задаёт APP_TZ_OFFSET_HOURS на бэкенде (в нём же считается «дата игры»);
// App.tsx получает его из /api/auth/config до первой отрисовки страниц (#68).
// 3 ч (МСК) — запасное значение, если конфиг недоступен.
let appTzOffsetMin = 3 * 60;
export function setAppTzOffsetHours(hours: number): void {
appTzOffsetMin = hours * 60;
}
const p2 = (n: number) => String(n).padStart(2, "0"); const p2 = (n: number) => String(n).padStart(2, "0");
// iso — корректный момент (бэкенд отдаёт UTC со смещением). Сдвигаем в +3 // iso — корректный момент (бэкенд отдаёт UTC со смещением). Сдвигаем в пояс приложения
// и форматируем по UTC-частям, чтобы получить «настенное» время МСК. // и форматируем по UTC-частям, чтобы получить «настенное» время этого пояса.
function shifted(iso: string): Date { function shifted(iso: string): Date {
return new Date(new Date(iso).getTime() + APP_TZ_OFFSET_MIN * 60_000); return new Date(new Date(iso).getTime() + appTzOffsetMin * 60_000);
} }
// Склонение существительного при числе: plural(3, "игрок", "игрока", "игроков"). // Склонение существительного при числе: plural(3, "игрок", "игрока", "игроков").
@@ -50,18 +57,19 @@ export function formatDate(iso: string | null | undefined): string {
return m ? `${m[3]}.${m[2]}.${m[1]}` : iso; return m ? `${m[3]}.${m[2]}.${m[1]}` : iso;
} }
// Значение <input type="datetime-local"> — «настенное» время в том же поясе +3, что и всё // Значение <input type="datetime-local"> — «настенное» время в том же поясе приложения, что
// отображение: админ вводит время показа объявления по МСК, где бы ни был его браузер. // и всё отображение: админ вводит время показа объявления в нём, где бы ни был браузер.
export function toAppLocalInput(iso: string): string { export function toAppLocalInput(iso: string): string {
const d = shifted(iso); const d = shifted(iso);
return `${d.getUTCFullYear()}-${p2(d.getUTCMonth() + 1)}-${p2(d.getUTCDate())}T${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`; return `${d.getUTCFullYear()}-${p2(d.getUTCMonth() + 1)}-${p2(d.getUTCDate())}T${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
} }
// Обратно: «настенное» время +3 из datetime-local → момент ISO (UTC). null — поле пустое. // Обратно: «настенное» время пояса приложения из datetime-local → момент ISO (UTC).
// null — поле пустое.
export function fromAppLocalInput(value: string): string | null { export function fromAppLocalInput(value: string): string | null {
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value); const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value);
if (!m) return null; if (!m) return null;
const ms = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]) - APP_TZ_OFFSET_MIN * 60_000; const ms = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]) - appTzOffsetMin * 60_000;
return new Date(ms).toISOString(); return new Date(ms).toISOString();
} }
-29
View File
@@ -1,29 +0,0 @@
commit 65bebf8b85fcbf921cbbc4f2599953a9fa3ca9c6 (HEAD -> dev)
Author: NotBigGhost <ivan@arseniev.info>
Date: Wed Jun 17 04:45:33 2026 +0300
Добавление ssh-ключа к контейнеру tunnel
commit 7e8b748caf5d9f4ab3d2d7f76d6a4b850b9b1366
Author: NotBigGhost <ivan@arseniev.info>
Date: Wed Jun 17 04:27:11 2026 +0300
Правки локального размещения дева, перенос тунеля в контейнер
commit b04fbb2e171a7fe3376f3633dfee9e29e4e4bb90
Author: NotBigGhost <ivan@arseniev.info>
Date: Tue Jun 16 19:15:54 2026 +0300
Завершена настройка домена для дева, теста и прода. Соединение через ssh-туннель
commit 56b5d09a4dc5e6f5d06e32c2635c84a66597e71f (origin/main, main)
Author: NotBigGhost <ivan@arseniev.info>
Date: Tue Jun 16 17:41:06 2026 +0300
v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика
commit 6ab74f01aaf1ac042a269c7660b3e564b93350c7
Author: NotBigGhost <ivan@arseniev.info>
Date: Tue Jun 16 16:53:41 2026 +0300
first commit
+1 -1
View File
@@ -135,7 +135,7 @@ switch ($appEnv) {
} }
"production" { "production" {
Write-Host "production is not started by the launcher - prod is separate." -ForegroundColor Yellow Write-Host "production is not started by the launcher - prod is separate." -ForegroundColor Yellow
Write-Host "Deploy on Pi (from main branch): docker compose up -d --build" Write-Host "Deploy: PC (main branch) scripts\build-push.ps1, then on Pi: docker compose up -d"
exit 1 exit 1
} }
default { default {
+1 -1
View File
@@ -85,7 +85,7 @@ case "$app_env" in
;; ;;
production) production)
echo "production лаунчером не запускается — прод обособлен." echo "production лаунчером не запускается — прод обособлен."
echo "Деплой на Pi (из ветки main): docker compose up -d --build" echo "Деплой: на ПК (ветка main) scripts/build-push.sh, затем на Pi: docker compose up -d"
exit 1 exit 1
;; ;;
*) *)
-20
View File
@@ -1,20 +0,0 @@
#!/usr/bin/env bash
# Выгрузка ПРОДА: в целевую папку попадают только файлы, нужные для запуска
# прод-контейнера (без тестов, dev-входа и dev-лаунчера).
#
# Использование: scripts/export-prod.sh <целевая-папка> [git-ref]
# git-ref по умолчанию HEAD; для прод-ветки: scripts/export-prod.sh /srv/fs prod
set -euo pipefail
DEST="${1:?Укажите целевую папку: scripts/export-prod.sh <dir> [ref]}"
REF="${2:-HEAD}"
mkdir -p "$DEST"
# git archive уважает export-ignore из .gitattributes (тесты, dev-вход и т.п. отсеяны)
git archive --format=tar "$REF" | tar -x -C "$DEST"
# dev-специфика в проде не нужна (лаунчер)
rm -f "$DEST/run.ps1" "$DEST/run.sh"
echo "[export-prod] Прод выгружен в: $DEST"
echo " дальше: cp .env.example .env && docker compose up -d --build"