v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
"""Конфигурация приложения из переменных окружения (12-factor)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
# Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково
|
||||
# и на деве (uvicorn из backend/), и где бы ни была рабочая папка. В Docker файла нет
|
||||
# (исключён из образа) — там настройки приходят переменными от docker compose.
|
||||
_ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env")
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=_ROOT_ENV,
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
# ── Главный переключатель окружения: development | test | production ───────
|
||||
# development — нативный dev (uvicorn + vite), БД в ./data/dev/, вход Telegram+ник.
|
||||
# test — прод-клон в Docker локально (порт 8080), ведёт себя как прод.
|
||||
# production — Docker на Pi; контейнер форсит это значение, игнорируя .env.
|
||||
app_env: str = "development"
|
||||
log_level: str = "INFO"
|
||||
|
||||
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3).
|
||||
# Хранение всегда в UTC; смещение применяется к «дате игры» и отображению.
|
||||
app_tz_offset_hours: int = 3
|
||||
|
||||
# ── БД: структура общая, файлы РАЗНЫЕ для dev и prod; выбор по app_env ─────
|
||||
# dev — в папке данных дева; prod — на постоянном томе контейнера.
|
||||
dev_database_url: str = "sqlite:///./data/dev/forbidden_stars.db"
|
||||
prod_database_url: str = "sqlite:////data/forbidden_stars.db"
|
||||
|
||||
# Каталог загрузок (зарезервировано под вложения), тоже раздельно.
|
||||
dev_upload_dir: str = "./data/dev/uploads"
|
||||
prod_upload_dir: str = "/data/uploads"
|
||||
|
||||
# JWT / cookie
|
||||
secret_key: str = "change-me-dev-secret-not-for-production"
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_user_ttl_minutes: int = 60 * 24 * 7
|
||||
jwt_admin_ttl_minutes: int = 60 * 8
|
||||
cookie_secure: bool = False
|
||||
cookie_domain: str | None = None
|
||||
|
||||
# Аутентификация. Методы входа определяются окружением (dev: telegram+stub,
|
||||
# prod: только telegram) — отдельного переключателя провайдера нет.
|
||||
telegram_bot_token: str | None = None
|
||||
telegram_bot_username: str | None = None
|
||||
public_base_url: str | None = None
|
||||
|
||||
# Бутстрап администратора
|
||||
admin_bootstrap_enabled: bool = True
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = "change-me-admin-password"
|
||||
admin_nickname: str = "Администратор"
|
||||
|
||||
# CORS (для раздельного dev-режима фронта). Строка из env, через запятую —
|
||||
# храним как str и режем в свойстве, чтобы pydantic-settings не пытался
|
||||
# распарсить значение как JSON (иначе "http://..." ломает разбор .env).
|
||||
cors_origins: str = "http://localhost:5173,http://127.0.0.1:5173"
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
||||
|
||||
@property
|
||||
def is_development(self) -> bool:
|
||||
"""Нативная разработка. Только это окружение включает dev-вход (stub),
|
||||
стартовый bootstrap в lifespan и синхронизацию админа из .env."""
|
||||
return self.app_env.lower() == "development"
|
||||
|
||||
@property
|
||||
def is_test(self) -> bool:
|
||||
return self.app_env.lower() == "test"
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return self.app_env.lower() == "production"
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
"""БД: dev — отдельный файл дева; test и prod — том контейнера (/data)."""
|
||||
return self.dev_database_url if self.is_development else self.prod_database_url
|
||||
|
||||
@property
|
||||
def upload_dir(self) -> str:
|
||||
return self.dev_upload_dir if self.is_development else self.prod_upload_dir
|
||||
|
||||
@property
|
||||
def cookie_domain_value(self) -> str | None:
|
||||
return self.cookie_domain or None
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Доменные исключения и единый конверт ошибок API.
|
||||
|
||||
Формат ответа: {"error": {"code": "...", "message": "...", "details": {...}}}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
|
||||
class AppError(Exception):
|
||||
"""Базовая ошибка приложения со стабильным машинным кодом."""
|
||||
|
||||
status_code: int = 400
|
||||
code: str = "BAD_REQUEST"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
code: str | None = None,
|
||||
status_code: int | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
if code is not None:
|
||||
self.code = code
|
||||
if status_code is not None:
|
||||
self.status_code = status_code
|
||||
self.details = details
|
||||
|
||||
def to_response(self) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=self.status_code,
|
||||
content={
|
||||
"error": {
|
||||
"code": self.code,
|
||||
"message": self.message,
|
||||
"details": self.details,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class NotFoundError(AppError):
|
||||
status_code = 404
|
||||
code = "NOT_FOUND"
|
||||
|
||||
|
||||
class ForbiddenError(AppError):
|
||||
status_code = 403
|
||||
code = "FORBIDDEN"
|
||||
|
||||
|
||||
class ValidationError(AppError):
|
||||
status_code = 422
|
||||
code = "VALIDATION_ERROR"
|
||||
|
||||
|
||||
class ConflictError(AppError):
|
||||
status_code = 409
|
||||
code = "CONFLICT"
|
||||
|
||||
|
||||
class AuthError(AppError):
|
||||
status_code = 401
|
||||
code = "UNAUTHORIZED"
|
||||
|
||||
|
||||
# ─── Конкретные ошибки со стабильными кодами для фронта ───────────────────────
|
||||
|
||||
class NoGroupError(AppError):
|
||||
status_code = 409
|
||||
code = "NO_GROUP"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Вы не состоите ни в одной группе.")
|
||||
|
||||
|
||||
class NotGroupMemberError(ForbiddenError):
|
||||
code = "NOT_GROUP_MEMBER"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Вы не являетесь участником этой группы.")
|
||||
|
||||
|
||||
class NicknameTakenError(ConflictError):
|
||||
code = "NICKNAME_TAKEN"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Никнейм уже занят.")
|
||||
|
||||
|
||||
class FactionNotAvailableError(ValidationError):
|
||||
code = "FACTION_NOT_AVAILABLE"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Фракция недоступна выбранной группе.")
|
||||
|
||||
|
||||
class DuplicateParticipantError(ValidationError):
|
||||
code = "DUPLICATE_PARTICIPANT"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Игрок или фракция повторяются в партии.")
|
||||
|
||||
|
||||
class InvalidRankingError(ValidationError):
|
||||
code = "INVALID_RANKING"
|
||||
|
||||
def __init__(self, message: str = "Некорректная расстановка мест.") -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class InvalidCredentialsError(AuthError):
|
||||
code = "INVALID_CREDENTIALS"
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Неверный логин или пароль.")
|
||||
|
||||
|
||||
async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse:
|
||||
return exc.to_response()
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Безопасность: bcrypt, JWT, cookie сессии и CSRF (double-submit)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
from fastapi import Response
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
USER_COOKIE = "fs_session"
|
||||
ADMIN_COOKIE = "fs_admin"
|
||||
CSRF_COOKIE = "csrf_token"
|
||||
CSRF_HEADER = "x-csrf-token"
|
||||
|
||||
AUDIENCE_USER = "user"
|
||||
AUDIENCE_ADMIN = "admin"
|
||||
|
||||
_ADMIN_PATH = "/api/admin"
|
||||
_USER_PATH = "/"
|
||||
|
||||
|
||||
# ─── Пароли ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
# ─── JWT ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def create_token(subject: str | int, audience: str, ttl_minutes: int, provider: str = "") -> str:
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": str(subject),
|
||||
"aud": audience,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int((now + timedelta(minutes=ttl_minutes)).timestamp()),
|
||||
"jti": secrets.token_hex(8),
|
||||
"provider": provider,
|
||||
}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def decode_token(token: str, audience: str) -> dict:
|
||||
return jwt.decode(
|
||||
token,
|
||||
settings.secret_key,
|
||||
algorithms=[settings.jwt_algorithm],
|
||||
audience=audience,
|
||||
)
|
||||
|
||||
|
||||
# ─── Cookie сессии + CSRF ────────────────────────────────────────────────────
|
||||
|
||||
def generate_csrf_token() -> str:
|
||||
return secrets.token_urlsafe(24)
|
||||
|
||||
|
||||
def _set_csrf_cookie(response: Response, max_age: int) -> str:
|
||||
csrf = generate_csrf_token()
|
||||
response.set_cookie(
|
||||
key=CSRF_COOKIE,
|
||||
value=csrf,
|
||||
max_age=max_age,
|
||||
httponly=False, # должен читаться JS, чтобы продублировать в заголовок
|
||||
secure=settings.cookie_secure,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
domain=settings.cookie_domain_value,
|
||||
)
|
||||
return csrf
|
||||
|
||||
|
||||
def set_user_session(response: Response, user_id: int, provider: str) -> None:
|
||||
ttl = settings.jwt_user_ttl_minutes
|
||||
token = create_token(user_id, AUDIENCE_USER, ttl, provider)
|
||||
response.set_cookie(
|
||||
key=USER_COOKIE,
|
||||
value=token,
|
||||
max_age=ttl * 60,
|
||||
httponly=True,
|
||||
secure=settings.cookie_secure,
|
||||
samesite="lax",
|
||||
path=_USER_PATH,
|
||||
domain=settings.cookie_domain_value,
|
||||
)
|
||||
_set_csrf_cookie(response, ttl * 60)
|
||||
|
||||
|
||||
def set_admin_session(response: Response, admin_id: int) -> None:
|
||||
ttl = settings.jwt_admin_ttl_minutes
|
||||
token = create_token(admin_id, AUDIENCE_ADMIN, ttl, "local")
|
||||
response.set_cookie(
|
||||
key=ADMIN_COOKIE,
|
||||
value=token,
|
||||
max_age=ttl * 60,
|
||||
httponly=True,
|
||||
secure=settings.cookie_secure,
|
||||
samesite="lax",
|
||||
path=_ADMIN_PATH,
|
||||
domain=settings.cookie_domain_value,
|
||||
)
|
||||
_set_csrf_cookie(response, ttl * 60)
|
||||
|
||||
|
||||
def clear_user_session(response: Response) -> None:
|
||||
response.delete_cookie(USER_COOKIE, path=_USER_PATH, domain=settings.cookie_domain_value)
|
||||
|
||||
|
||||
def clear_admin_session(response: Response) -> None:
|
||||
response.delete_cookie(ADMIN_COOKIE, path=_ADMIN_PATH, domain=settings.cookie_domain_value)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Работа со временем: хранение в UTC, отдача ISO с явным смещением,
|
||||
«сегодня» в часовом поясе приложения (по умолчанию +3)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
APP_TZ = timezone(timedelta(hours=settings.app_tz_offset_hours))
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
"""Текущее время в UTC (aware)."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def app_today() -> date:
|
||||
"""Текущая дата в часовом поясе приложения (для «даты игры»)."""
|
||||
return datetime.now(APP_TZ).date()
|
||||
|
||||
|
||||
def iso_utc(dt: datetime | None) -> str | None:
|
||||
"""ISO-строка с явным UTC-смещением. Наивное значение из SQLite считаем UTC,
|
||||
чтобы фронт корректно сконвертировал его в локальный пояс отображения."""
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc).isoformat()
|
||||
Reference in New Issue
Block a user