From 8d4f7a95df1bf1e905b65bde8e83276a81b4e83d Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sat, 19 Sep 2026 03:05:30 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D1=80=D0=B5=D0=BC=D1=8F:=20=D0=BE=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=20=D0=BF=D0=BE=D1=8F=D1=81=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8=D0=B7=20.en?= =?UTF-8?q?v=20=D0=B8=20=D0=BD=D0=B0=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B5,=20=D0=B8=20=D0=BD=D0=B0=20=D1=84=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd --- .env.example | 4 +++- backend/app/core/config.py | 14 ++++++++++++-- backend/app/routers/auth.py | 1 + backend/app/schemas/api.py | 3 +++ backend/tests/test_config_security.py | 17 +++++++++++++++++ frontend/src/App.tsx | 20 +++++++++++++++++++- frontend/src/api/schema.d.ts | 2 ++ frontend/src/domain/format.ts | 26 +++++++++++++++++--------- 8 files changed, 74 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index eede0a4..45e6e4f 100644 --- a/.env.example +++ b/.env.example @@ -129,7 +129,9 @@ 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/backend/app/core/config.py b/backend/app/core/config.py index b91f37e..338cb3e 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -42,8 +42,9 @@ class Settings(BaseSettings): # от этого зависят Secure-cookie и проверка секретов при старте. local_public: str = "local" - # Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3). - # Хранение всегда в UTC; смещение применяется к «дате игры» и отображению. + # Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3). Хранение всегда + # в UTC; смещение применяется к «дате игры» (timeutil.app_today) и к отображению времени + # на фронте — оно приходит туда в GET /api/auth/config (#68). app_tz_offset_hours: int = 3 # ── БД: структура общая, файлы РАЗНЫЕ для dev и prod; выбор по app_env ───── @@ -139,6 +140,15 @@ class Settings(BaseSettings): ) 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") def _forbid_default_secrets_when_published(self) -> "Settings": """Fail-fast: опубликованное приложение не стартует с дефолтными/слабыми diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index a9fd310..63b5bb6 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -28,6 +28,7 @@ def auth_config() -> s.AuthConfig: return s.AuthConfig( methods=enabled_methods(), telegram_bot_username=settings.telegram_bot_username, + tz_offset_hours=settings.app_tz_offset_hours, ) diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index 5b21bd9..d746302 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -22,6 +22,9 @@ class AuthConfig(BaseModel): # Доступные методы входа: ["password","telegram"] в проде, плюс "stub" в деве. methods: list[str] = [] telegram_bot_username: str | None = None + # Пояс приложения (APP_TZ_OFFSET_HOURS): в нём сервер считает «дату игры», а фронт + # показывает время всем игрокам — независимо от пояса устройства (#68). + tz_offset_hours: int # Верхняя граница длины пароля на входе API: отсекает мегабайтные тела до bcrypt. diff --git a/backend/tests/test_config_security.py b/backend/tests/test_config_security.py index 8c87325..7175fc4 100644 --- a/backend/tests/test_config_security.py +++ b/backend/tests/test_config_security.py @@ -140,3 +140,20 @@ def test_unknown_app_env_rejected(app_env): def test_app_env_case_insensitive(): s = config.Settings(app_env="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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ebb3606..ae88693 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,15 +1,33 @@ import { QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; import { RouterProvider } from "react-router-dom"; import { queryClient } from "./app/queryClient"; import { router } from "./app/router"; +import { Spinner } from "./components/Spinner"; 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 ; + return <>{children}; +} export function App() { return ( - + + + ); diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 651211e..705a381 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -1610,6 +1610,8 @@ export interface components { methods: string[]; /** Telegram Bot Username */ 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: { diff --git a/frontend/src/domain/format.ts b/frontend/src/domain/format.ts index 4c4ed53..ff31e5e 100644 --- a/frontend/src/domain/format.ts +++ b/frontend/src/domain/format.ts @@ -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"); -// iso — корректный момент (бэкенд отдаёт UTC со смещением). Сдвигаем в +3 -// и форматируем по UTC-частям, чтобы получить «настенное» время МСК. +// iso — корректный момент (бэкенд отдаёт UTC со смещением). Сдвигаем в пояс приложения +// и форматируем по UTC-частям, чтобы получить «настенное» время этого пояса. 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, "игрок", "игрока", "игроков"). @@ -50,18 +57,19 @@ export function formatDate(iso: string | null | undefined): string { return m ? `${m[3]}.${m[2]}.${m[1]}` : iso; } -// Значение — «настенное» время в том же поясе +3, что и всё -// отображение: админ вводит время показа объявления по МСК, где бы ни был его браузер. +// Значение — «настенное» время в том же поясе приложения, что +// и всё отображение: админ вводит время показа объявления в нём, где бы ни был браузер. export function toAppLocalInput(iso: string): string { const d = shifted(iso); 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 { const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value); 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(); }