diff --git a/backend/app/auth/login.py b/backend/app/auth/login.py index 5601f08..a69fe9e 100644 --- a/backend/app/auth/login.py +++ b/backend/app/auth/login.py @@ -10,6 +10,7 @@ from sqlmodel import Session from app.auth.provider import ExternalIdentity from app.core import security +from app.core.security import client_ip from app.core.errors import ForbiddenError from app.models import User from app.services import audit_service, user_service @@ -29,7 +30,7 @@ def establish_session( entity_type="user", entity_id=user.id, payload={"provider": provider}, - ip=request.client.host if request.client else None, + ip=client_ip(request), user_agent=request.headers.get("user-agent"), ) session.commit() diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 42dbf3d..14f9036 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -6,7 +6,7 @@ from datetime import datetime, timedelta, timezone import bcrypt import jwt -from fastapi import Response +from fastapi import Request, Response from app.core.config import settings @@ -118,3 +118,11 @@ def clear_user_session(response: Response) -> None: def clear_admin_session(response: Response) -> None: response.delete_cookie(ADMIN_COOKIE, path=_ADMIN_PATH, domain=settings.cookie_domain_value) + + +def client_ip(request: Request) -> str | None: + """IP клиента для журнала аудита. + + Одна точка на всё приложение: за VPS-привратником адрес придётся брать из + X-Forwarded-For, и менять это в двух десятках роутеров — не вариант.""" + return request.client.host if request.client else None diff --git a/backend/app/models.py b/backend/app/models.py index be047e8..d206e19 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -4,7 +4,7 @@ """ from __future__ import annotations -from datetime import date, datetime, timezone +from datetime import date, datetime from sqlalchemy import ( JSON, @@ -23,9 +23,9 @@ from sqlalchemy import ( ) from sqlmodel import Field, SQLModel +from app.core.timeutil import utcnow + -def _utcnow() -> datetime: - return datetime.now(timezone.utc) # ─── Справочники: дополнения и фракции ─────────────────────────────────────── @@ -38,7 +38,7 @@ class Expansion(SQLModel, table=True): name_ru: str = Field(sa_column=Column(String(64), nullable=False)) is_base: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0")) sort_order: int = Field(default=0, nullable=False) - created_at: datetime = Field(default_factory=_utcnow, nullable=False) + created_at: datetime = Field(default_factory=utcnow, nullable=False) class Faction(SQLModel, table=True): @@ -56,7 +56,7 @@ class Faction(SQLModel, table=True): ) ) sort_order: int = Field(default=0, nullable=False) - created_at: datetime = Field(default_factory=_utcnow, nullable=False) + created_at: datetime = Field(default_factory=utcnow, nullable=False) # ─── Пользователи и идентичности ───────────────────────────────────────────── @@ -125,10 +125,10 @@ class User(SQLModel, table=True): # Выбранный титул (slug ачивки), отображаемый под ником. Задел: пока всегда NULL # (выдача ачивок игрокам — следующий этап). title_achievement_slug: str | None = Field(sa_column=Column(String(64), nullable=True)) - created_at: datetime = Field(default_factory=_utcnow, nullable=False) + created_at: datetime = Field(default_factory=utcnow, nullable=False) updated_at: datetime = Field( - default_factory=_utcnow, - sa_column_kwargs={"onupdate": _utcnow}, + default_factory=utcnow, + sa_column_kwargs={"onupdate": utcnow}, nullable=False, ) @@ -150,7 +150,7 @@ class UserAchievement(SQLModel, table=True): ) ) achievement_slug: str = Field(sa_column=Column(String(64), nullable=False)) - earned_at: datetime = Field(default_factory=_utcnow, nullable=False) + earned_at: datetime = Field(default_factory=utcnow, nullable=False) class AuthIdentity(SQLModel, table=True): @@ -167,7 +167,7 @@ class AuthIdentity(SQLModel, table=True): ) provider: str = Field(sa_column=Column(String(16), nullable=False)) external_id: str = Field(sa_column=Column(String(64), nullable=False)) - created_at: datetime = Field(default_factory=_utcnow, nullable=False) + created_at: datetime = Field(default_factory=utcnow, nullable=False) # ─── Группы и членство ─────────────────────────────────────────────────────── @@ -182,10 +182,10 @@ class Group(SQLModel, table=True): Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True ) ) - created_at: datetime = Field(default_factory=_utcnow, nullable=False) + created_at: datetime = Field(default_factory=utcnow, nullable=False) updated_at: datetime = Field( - default_factory=_utcnow, - sa_column_kwargs={"onupdate": _utcnow}, + default_factory=utcnow, + sa_column_kwargs={"onupdate": utcnow}, nullable=False, ) @@ -209,7 +209,7 @@ class GroupMember(SQLModel, table=True): ) ) role: str = Field(default="member", sa_column=Column(String(16), nullable=False, server_default="member")) - joined_at: datetime = Field(default_factory=_utcnow, nullable=False) + joined_at: datetime = Field(default_factory=utcnow, nullable=False) class GroupInvitation(SQLModel, table=True): @@ -237,7 +237,7 @@ class GroupInvitation(SQLModel, table=True): Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True ) ) - created_at: datetime = Field(default_factory=_utcnow, nullable=False) + created_at: datetime = Field(default_factory=utcnow, nullable=False) class GroupExpansion(SQLModel, table=True): @@ -257,7 +257,7 @@ class GroupExpansion(SQLModel, table=True): Integer, ForeignKey("expansions.id", ondelete="RESTRICT"), nullable=False ) ) - created_at: datetime = Field(default_factory=_utcnow, nullable=False) + created_at: datetime = Field(default_factory=utcnow, nullable=False) # ─── Партии и участники ────────────────────────────────────────────────────── @@ -295,10 +295,10 @@ class Match(SQLModel, table=True): Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True ) ) - created_at: datetime = Field(default_factory=_utcnow, nullable=False) + created_at: datetime = Field(default_factory=utcnow, nullable=False) updated_at: datetime = Field( - default_factory=_utcnow, - sa_column_kwargs={"onupdate": _utcnow}, + default_factory=utcnow, + sa_column_kwargs={"onupdate": utcnow}, nullable=False, ) @@ -333,7 +333,7 @@ class MatchParticipant(SQLModel, table=True): eliminated: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0")) was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0")) comment: str | None = Field(sa_column=Column(Text, nullable=True)) - created_at: datetime = Field(default_factory=_utcnow, nullable=False) + created_at: datetime = Field(default_factory=utcnow, nullable=False) class MatchAttachment(SQLModel, table=True): @@ -359,7 +359,7 @@ class MatchAttachment(SQLModel, table=True): storage_path: str = Field(sa_column=Column(String(255), nullable=False)) mime_type: str = Field(sa_column=Column(String(64), nullable=False)) size_bytes: int = Field(sa_column=Column(Integer, nullable=False)) - created_at: datetime = Field(default_factory=_utcnow, nullable=False) + created_at: datetime = Field(default_factory=utcnow, nullable=False) # ─── Уведомления ───────────────────────────────────────────────────────────── @@ -387,7 +387,7 @@ class Notification(SQLModel, table=True): body: str | None = Field(default=None, sa_column=Column(Text, nullable=True)) link: str | None = Field(default=None, sa_column=Column(String(255), nullable=True)) read_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True)) - created_at: datetime = Field(default_factory=_utcnow, nullable=False, index=True) + created_at: datetime = Field(default_factory=utcnow, nullable=False, index=True) # ─── Журнал аудита ─────────────────────────────────────────────────────────── @@ -411,12 +411,6 @@ class AuditLog(SQLModel, table=True): payload: dict | None = Field(default=None, sa_column=Column(JSON, nullable=True)) ip: str | None = Field(sa_column=Column(String(45), nullable=True)) user_agent: str | None = Field(sa_column=Column(String(256), nullable=True)) - created_at: datetime = Field(default_factory=_utcnow, nullable=False) + created_at: datetime = Field(default_factory=utcnow, nullable=False) -# Заготовка под будущие вложения (НЕ в v1-миграции, добавится отдельно): -# class Attachment(SQLModel, table=True): -# id, match_id (FK CASCADE), participant_id (FK NULL SET NULL), -# uploaded_by (FK), kind ('photo'|'video'), storage_path, mime_type, -# size_bytes, created_at -# Файлы — на томе /data/uploads; в БД только метаданные и относительный путь. diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 44b889f..4e858b9 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -7,12 +7,14 @@ from sqlmodel import Session from app.auth.deps import get_current_admin from app.core import security -from app.core.errors import NotFoundError, ValidationError +from app.core.security import client_ip +from app.core.errors import NotFoundError from app.core.timeutil import iso_utc from app.db.session import get_session from app.models import User from app.routers.matches import attachment_read, build_match_read from app.schemas import api as s +from app.services.match_service import ParticipantInput from app.services import ( achievement_service, admin_service, @@ -25,8 +27,6 @@ from app.services import ( ) _ACHIEVEMENT_ICON_MAX_BYTES = 2 * 1024 * 1024 # 2 МБ -_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024 # 10 МБ -from app.services.match_service import ParticipantInput router = APIRouter(prefix="/admin", tags=["admin"]) @@ -48,7 +48,7 @@ def admin_login( action="login", entity_type="admin", entity_id=admin.id, - ip=request.client.host if request.client else None, + ip=client_ip(request), user_agent=request.headers.get("user-agent"), ) session.commit() @@ -104,7 +104,7 @@ def update_user( entity_type="user", entity_id=user_id, payload=body.model_dump(exclude_none=True), - ip=request.client.host if request.client else None, + ip=client_ip(request), ) session.commit() return s.AdminUserRead( @@ -114,7 +114,7 @@ def update_user( is_active=u.is_active, auth_provider=u.auth_provider, telegram_id=u.telegram_id, - created_at=u.created_at.isoformat(), + created_at=iso_utc(u.created_at), ) @@ -148,7 +148,7 @@ def delete_group( admin_service.delete_group(session, group_id) audit_service.record( session, actor_id=admin.id, action="delete", entity_type="group", entity_id=group_id, - ip=request.client.host if request.client else None, + ip=client_ip(request), ) session.commit() return s.OkResponse() @@ -239,7 +239,7 @@ def update_match( action="update", entity_type="match", entity_id=match.id, - ip=request.client.host if request.client else None, + ip=client_ip(request), ) session.commit() notify.match_changed(session, match) @@ -277,7 +277,7 @@ def rename_faction( entity_type="faction", entity_id=faction_id, payload={"name_ru": f.name_ru}, - ip=request.client.host if request.client else None, + ip=client_ip(request), ) session.commit() return s.FactionRead( @@ -296,7 +296,7 @@ def delete_match( admin_service.delete_match(session, match_id) audit_service.record( session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id, - ip=request.client.host if request.client else None, + ip=client_ip(request), ) session.commit() notify.match_removed(session, match_id, group_id) @@ -326,12 +326,9 @@ def admin_add_attachment( admin: User = Depends(get_current_admin), ) -> s.AttachmentRead: match = match_service.get_match(session, match_id) - content = file.file.read(_ATTACHMENT_MAX_BYTES + 1) - if len(content) > _ATTACHMENT_MAX_BYTES: - raise ValidationError("Файл слишком большой (макс. 10 МБ).") - ext = user_service.sniff_image_ext(content) - if ext is None: - raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.") + content, ext = user_service.read_capped_image( + file, attachment_service.MAX_ATTACHMENT_BYTES, "Файл слишком большой (макс. 10 МБ)." + ) att = attachment_service.add_photo( session, match, admin, content, ext, user_service.avatar_media_type(ext) ) @@ -389,7 +386,7 @@ def create_achievement( audit_service.record( session, actor_id=admin.id, action="create", entity_type="achievement", payload={"slug": ach["slug"], "name": ach["name"]}, - ip=request.client.host if request.client else None, + ip=client_ip(request), ) session.commit() return ach @@ -408,7 +405,7 @@ def update_achievement( ) audit_service.record( session, actor_id=admin.id, action="update", entity_type="achievement", - payload={"slug": slug}, ip=request.client.host if request.client else None, + payload={"slug": slug}, ip=client_ip(request), ) session.commit() return ach @@ -420,10 +417,9 @@ def upload_achievement_icon( file: UploadFile = File(...), _admin: User = Depends(get_current_admin), ) -> dict: - content = file.file.read(_ACHIEVEMENT_ICON_MAX_BYTES + 1) - if len(content) > _ACHIEVEMENT_ICON_MAX_BYTES: - raise ValidationError("Файл слишком большой (макс. 2 МБ).") - ext = achievement_service.validate_icon(content) + content, ext = user_service.read_capped_image( + file, _ACHIEVEMENT_ICON_MAX_BYTES, "Файл слишком большой (макс. 2 МБ)." + ) return achievement_service.set_icon(slug, content, ext) @@ -437,7 +433,7 @@ def delete_achievement( achievement_service.delete(slug) audit_service.record( session, actor_id=admin.id, action="delete", entity_type="achievement", - payload={"slug": slug}, ip=request.client.host if request.client else None, + payload={"slug": slug}, ip=client_ip(request), ) session.commit() return s.OkResponse() diff --git a/backend/app/routers/dev_admin.py b/backend/app/routers/dev_admin.py index dd8130d..9e699f3 100644 --- a/backend/app/routers/dev_admin.py +++ b/backend/app/routers/dev_admin.py @@ -18,6 +18,7 @@ from fastapi import APIRouter, Depends, Request from sqlmodel import Session, select from app.auth.deps import get_current_admin +from app.core.security import client_ip from app.core.errors import NotFoundError, ValidationError from app.db.session import get_session from app.models import Group, Match, MatchParticipant, User @@ -63,7 +64,7 @@ def delete_user_hard( entity_type="user", entity_id=user_id, payload={"hard": True, "nickname": nickname}, - ip=request.client.host if request.client else None, + ip=client_ip(request), ) session.commit() return s.OkResponse() diff --git a/backend/app/routers/groups.py b/backend/app/routers/groups.py index 80ee642..1a9bc9d 100644 --- a/backend/app/routers/groups.py +++ b/backend/app/routers/groups.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, Query, Request from sqlmodel import Session from app.auth.deps import get_current_user +from app.core.security import client_ip from app.core.timeutil import iso_utc from app.db.session import get_session from app.models import User @@ -61,7 +62,7 @@ def create_group( entity_type="group", entity_id=group.id, payload={"name": group.name}, - ip=request.client.host if request.client else None, + ip=client_ip(request), user_agent=request.headers.get("user-agent"), ) session.commit() @@ -157,7 +158,7 @@ def invite_member( entity_type="group_invitation", entity_id=group_id, payload={"invited_user_id": invited.id, "nickname": invited.nickname}, - ip=request.client.host if request.client else None, + ip=client_ip(request), user_agent=request.headers.get("user-agent"), ) session.commit() diff --git a/backend/app/routers/matches.py b/backend/app/routers/matches.py index ef4cb4d..e5d3b4c 100644 --- a/backend/app/routers/matches.py +++ b/backend/app/routers/matches.py @@ -6,7 +6,8 @@ from fastapi.responses import FileResponse from sqlmodel import Session from app.auth.deps import get_current_user -from app.core.errors import ConflictError, NoGroupError, NotFoundError, ValidationError +from app.core.security import client_ip +from app.core.errors import ConflictError, NoGroupError, NotFoundError from app.core.timeutil import iso_utc from app.db.session import get_session from app.models import Match, MatchAttachment, User @@ -24,7 +25,6 @@ from app.services.match_service import FinishInput, ParticipantInput, RosterInpu router = APIRouter(prefix="/matches", tags=["matches"]) -_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024 # 10 МБ def attachment_read(att: MatchAttachment, base: str) -> s.AttachmentRead: @@ -119,7 +119,7 @@ def start_match( entity_type="match", entity_id=match.id, payload={"group_id": match.group_id, "player_count": match.player_count, "status": "in_progress"}, - ip=request.client.host if request.client else None, + ip=client_ip(request), user_agent=request.headers.get("user-agent"), ) session.commit() @@ -165,7 +165,7 @@ def finish_match( entity_type="match", entity_id=match.id, payload={"event": "finish", "win_reason": match.win_reason, "duration_minutes": match.duration_minutes}, - ip=request.client.host if request.client else None, + ip=client_ip(request), user_agent=request.headers.get("user-agent"), ) session.commit() @@ -225,7 +225,7 @@ def update_match( action="update", entity_type="match", entity_id=match.id, - ip=request.client.host if request.client else None, + ip=client_ip(request), user_agent=request.headers.get("user-agent"), ) session.commit() @@ -250,12 +250,9 @@ def add_attachment( ) -> s.AttachmentRead: match = match_service.get_match(session, match_id) _assert_can_attach(session, match, user) - content = file.file.read(_ATTACHMENT_MAX_BYTES + 1) - if len(content) > _ATTACHMENT_MAX_BYTES: - raise ValidationError("Файл слишком большой (макс. 10 МБ).") - ext = user_service.sniff_image_ext(content) - if ext is None: - raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.") + content, ext = user_service.read_capped_image( + file, attachment_service.MAX_ATTACHMENT_BYTES, "Файл слишком большой (макс. 10 МБ)." + ) att = attachment_service.add_photo( session, match, user, content, ext, user_service.avatar_media_type(ext) ) @@ -315,7 +312,7 @@ def delete_match( entity_type="match", entity_id=match_id_val, payload={"group_id": group_id_val}, - ip=request.client.host if request.client else None, + ip=client_ip(request), user_agent=request.headers.get("user-agent"), ) session.commit() diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 0f374e4..cb0e36a 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -6,7 +6,8 @@ from fastapi.responses import FileResponse from sqlmodel import Session from app.auth.deps import get_current_user -from app.core.errors import NotFoundError, ValidationError +from app.core.security import client_ip +from app.core.errors import NotFoundError from app.db.session import get_session from app.models import User from app.schemas import api as s @@ -61,7 +62,7 @@ def update_me( entity_type="user", entity_id=user.id, payload={"nickname": user.nickname}, - ip=request.client.host if request.client else None, + ip=client_ip(request), user_agent=request.headers.get("user-agent"), ) session.commit() @@ -108,7 +109,7 @@ def update_my_profile( entity_type="user", entity_id=user.id, payload={key: True for key in changed}, - ip=request.client.host if request.client else None, + ip=client_ip(request), ) session.commit() return build_me(session, user) @@ -120,12 +121,9 @@ def upload_my_avatar( session: Session = Depends(get_session), user: User = Depends(get_current_user), ) -> s.MeRead: - content = file.file.read(_AVATAR_MAX_BYTES + 1) - if len(content) > _AVATAR_MAX_BYTES: - raise ValidationError("Файл слишком большой (макс. 2 МБ).") - ext = user_service.sniff_image_ext(content) - if ext is None: - raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.") + content, ext = user_service.read_capped_image( + file, _AVATAR_MAX_BYTES, "Файл слишком большой (макс. 2 МБ)." + ) user_service.set_avatar(session, user, content, ext) return build_me(session, user) diff --git a/backend/app/services/achievement_service.py b/backend/app/services/achievement_service.py index c01ec48..3bc1839 100644 --- a/backend/app/services/achievement_service.py +++ b/backend/app/services/achievement_service.py @@ -42,6 +42,10 @@ def _root() -> Path: return Path(settings.achievements_dir) +# Формат slug — то, что выдаёт _slugify: только латиница, цифры и дефис. +_SLUG_RE = re.compile(r"[a-z0-9][a-z0-9-]{0,63}") + + def _slugify(name: str) -> str: text = "".join(_TRANSLIT.get(ch, ch) for ch in (name or "").strip().lower()) slug = re.sub(r"[^a-z0-9]+", "-", text).strip("-") @@ -49,6 +53,11 @@ def _slugify(name: str) -> str: def _dir(slug: str) -> Path: + """Папка ачивки. Slug приходит из URL, поэтому формат проверяем здесь: без этого + `..` или `a/b` увели бы файловые операции (вплоть до rmtree в delete) за пределы + каталога ачивок.""" + if not _SLUG_RE.fullmatch(slug or ""): + raise NotFoundError("Ачивка не найдена.") return _root() / slug diff --git a/backend/app/services/admin_service.py b/backend/app/services/admin_service.py index 4616ea1..552f21d 100644 --- a/backend/app/services/admin_service.py +++ b/backend/app/services/admin_service.py @@ -6,6 +6,7 @@ from typing import Any from sqlmodel import Session, select from app.core.errors import ( + ConflictError, InvalidCredentialsError, NicknameTakenError, NotFoundError, @@ -75,6 +76,10 @@ def delete_group(session: Session, group_id: int) -> None: group = session.get(Group, group_id) if group is None: raise NotFoundError("Группа не найдена.") + # matches.group_id — ON DELETE RESTRICT, поэтому группу с партиями БД не отдаст. + # Проверяем сами, иначе IntegrityError уходит наружу голым 500 без AppError-конверта. + if session.exec(select(Match.id).where(Match.group_id == group_id)).first() is not None: + raise ConflictError("Нельзя удалить группу, в которой есть партии. Сначала удалите их.") session.delete(group) session.commit() @@ -134,11 +139,16 @@ def rename_faction(session: Session, faction_id: int, name_ru: str) -> Faction: def delete_match(session: Session, match_id: int) -> None: + from app.services import attachment_service # избегаем цикла импорта + match = session.get(Match, match_id) if match is None: raise NotFoundError("Партия не найдена.") session.delete(match) session.commit() + # Как и в игроцком пути (match_service.delete_match): строки вложений уходят + # каскадом, а файлы с тома нужно убрать руками, иначе они остаются навсегда. + attachment_service.delete_match_files(match_id) # ─── Журнал аудита ─────────────────────────────────────────────────────────── diff --git a/backend/app/services/attachment_service.py b/backend/app/services/attachment_service.py index 5491e33..e3019cb 100644 --- a/backend/app/services/attachment_service.py +++ b/backend/app/services/attachment_service.py @@ -14,6 +14,7 @@ from app.core.errors import ConflictError, NotFoundError from app.models import Match, MatchAttachment, User MAX_ATTACHMENTS = 10 +MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024 # 10 МБ _SUBDIR = "matches" @@ -42,6 +43,8 @@ def file_path(att: MatchAttachment) -> Path: def add_photo( session: Session, match: Match, user: User, content: bytes, ext: str, mime: str ) -> MatchAttachment: + from app.services import match_service # избегаем цикла импорта + if count(session, match.id) >= MAX_ATTACHMENTS: # type: ignore[arg-type] raise ConflictError(f"Можно прикрепить не более {MAX_ATTACHMENTS} файлов.") att = MatchAttachment( @@ -60,6 +63,7 @@ def add_photo( abs_path.write_bytes(content) att.storage_path = rel session.add(att) + match_service.touch(session, match) session.commit() session.refresh(att) return att @@ -73,6 +77,8 @@ def get_for_match(session: Session, match_id: int, att_id: int) -> MatchAttachme def delete(session: Session, match: Match, att_id: int) -> None: + from app.services import match_service # избегаем цикла импорта + att = get_for_match(session, match.id, att_id) # type: ignore[arg-type] path = file_path(att) if path.exists(): @@ -81,6 +87,7 @@ def delete(session: Session, match: Match, att_id: int) -> None: except OSError: pass session.delete(att) + match_service.touch(session, match) session.commit() diff --git a/backend/app/services/invitation_service.py b/backend/app/services/invitation_service.py index 5796e97..3b77c86 100644 --- a/backend/app/services/invitation_service.py +++ b/backend/app/services/invitation_service.py @@ -5,6 +5,7 @@ GroupMember). Лимит участников — общий с membership_servi """ from __future__ import annotations +from sqlalchemy.orm import aliased from sqlmodel import Session, select from app.core.errors import ConflictError, NotFoundError, ValidationError @@ -70,17 +71,17 @@ def create_invitation( def list_for_user(session: Session, user_id: int) -> list[dict]: + inviter = aliased(User) rows = session.exec( - select(GroupInvitation, Group.name).join(Group, Group.id == GroupInvitation.group_id) + select(GroupInvitation, Group.name, inviter.nickname) + .join(Group, Group.id == GroupInvitation.group_id) + # LEFT JOIN: пригласивший мог быть удалён (invited_by_id → SET NULL). + .join(inviter, inviter.id == GroupInvitation.invited_by_id, isouter=True) .where(GroupInvitation.user_id == user_id) .order_by(GroupInvitation.created_at.desc()) ).all() out = [] - for inv, gname in rows: - inviter_nick = None - if inv.invited_by_id is not None: - inviter = session.get(User, inv.invited_by_id) - inviter_nick = inviter.nickname if inviter else None + for inv, gname, inviter_nick in rows: out.append( { "id": inv.id, diff --git a/backend/app/services/match_service.py b/backend/app/services/match_service.py index 00ead8e..6639686 100644 --- a/backend/app/services/match_service.py +++ b/backend/app/services/match_service.py @@ -3,7 +3,7 @@ from __future__ import annotations import random from dataclasses import dataclass -from datetime import date, datetime, timezone +from datetime import date from sqlmodel import Session, select @@ -16,7 +16,7 @@ from app.core.errors import ( NotFoundError, ValidationError, ) -from app.core.timeutil import app_today, iso_utc +from app.core.timeutil import app_today, iso_utc, utcnow from app.models import Faction, GroupMember, Match, MatchParticipant, User from app.services import group_service @@ -56,10 +56,6 @@ class ParticipantInput: comment: str | None = None -def _utcnow() -> datetime: - return datetime.now(timezone.utc) - - def round_to_30(minutes: float) -> int: """Округление длительности до получаса, минимум 30 минут.""" return max(30, int(round(minutes / 30.0)) * 30) @@ -77,6 +73,17 @@ def match_version(match: Match) -> str: return iso_utc(match.updated_at) +def touch(session: Session, match: Match) -> None: + """Пометить партию изменённой (без commit). + + Версия для оптимистичной блокировки — это updated_at, а onupdate у SQLAlchemy + срабатывает только при реальном UPDATE строки matches. Правки, меняющие лишь + связанные сущности (участники, вложения), строку не трогают, поэтому каждая + такая точка обязана позвать touch — иначе конкурентная запись пройдёт молча.""" + match.updated_at = utcnow() + session.add(match) + + def assert_version(match: Match, expected: str | None) -> None: """Если клиент прислал версию и она устарела — отказываем (кто-то изменил партию).""" if expected is not None and expected != match_version(match): @@ -188,7 +195,7 @@ def create_match( session, group_id, [r.user_id for r in roster], [r.faction_id for r in roster] ) - now = _utcnow() + now = utcnow() match = Match( group_id=group_id, status="in_progress", @@ -264,7 +271,7 @@ def finish_match( p.faction_id = f.faction_id session.add(p) - now = _utcnow() + now = utcnow() match.finished_at = now started = match.started_at if started is not None: @@ -351,7 +358,7 @@ def update_match( ) match.player_count = len(participants) - session.add(match) + touch(session, match) session.commit() session.refresh(match) return match diff --git a/backend/app/services/membership_service.py b/backend/app/services/membership_service.py index d4a83b6..18b8fba 100644 --- a/backend/app/services/membership_service.py +++ b/backend/app/services/membership_service.py @@ -5,6 +5,7 @@ from sqlmodel import Session, select from app.core.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError from app.models import Group, GroupMember, User +from app.services import group_service MAX_GROUP_SIZE = 10 @@ -33,11 +34,7 @@ def add_member_by_nickname(session: Session, group: Group, nickname: str) -> tup if member_count >= MAX_GROUP_SIZE: raise ConflictError(f"В группе уже максимум участников ({MAX_GROUP_SIZE}).") - existing = session.exec( - select(GroupMember).where( - GroupMember.group_id == group.id, GroupMember.user_id == user.id - ) - ).first() + existing = group_service.get_membership(session, group.id, user.id) if existing is not None: raise ConflictError("Игрок уже в группе.") @@ -48,22 +45,26 @@ def add_member_by_nickname(session: Session, group: Group, nickname: str) -> tup return member, user -def remove_member(session: Session, group: Group, user_id: int) -> None: - member = session.exec( - select(GroupMember).where( - GroupMember.group_id == group.id, GroupMember.user_id == user_id +def _assert_not_last_owner(session: Session, group: Group, member: GroupMember, message: str) -> None: + """Группа без владельца неисправима: назначить нового становится некому.""" + if member.role != "owner": + return + owners = session.exec( + select(GroupMember.id).where( + GroupMember.group_id == group.id, GroupMember.role == "owner" ) - ).first() + ).all() + if len(owners) <= 1: + raise ForbiddenError(message) + + +def remove_member(session: Session, group: Group, user_id: int) -> None: + member = group_service.get_membership(session, group.id, user_id) if member is None: raise NotFoundError("Игрок не состоит в группе.") - if member.role == "owner": - owners = session.exec( - select(GroupMember).where( - GroupMember.group_id == group.id, GroupMember.role == "owner" - ) - ).all() - if len(owners) <= 1: - raise ForbiddenError("Нельзя удалить последнего владельца группы.") + _assert_not_last_owner( + session, group, member, "Нельзя удалить последнего владельца группы." + ) # Сбросить активную группу у тех, для кого она была активной. user = session.get(User, user_id) @@ -78,13 +79,13 @@ def remove_member(session: Session, group: Group, user_id: int) -> None: def change_role(session: Session, group: Group, user_id: int, role: str) -> GroupMember: if role not in ("owner", "member"): raise ValidationError("Недопустимая роль.") - member = session.exec( - select(GroupMember).where( - GroupMember.group_id == group.id, GroupMember.user_id == user_id - ) - ).first() + member = group_service.get_membership(session, group.id, user_id) if member is None: raise NotFoundError("Игрок не состоит в группе.") + if role != "owner": + _assert_not_last_owner( + session, group, member, "Нельзя снять роль с последнего владельца группы." + ) member.role = role session.add(member) session.commit() diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index cd3c80d..c7e33b2 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -137,6 +137,9 @@ def mark_read(session: Session, user_id: int, ids: list[int] | None = None) -> i session.add(row) if rows: session.commit() + # Счётчик непрочитанных изменился — толкаем тот же сигнал, что и create_for, + # иначе вкладка на другом устройстве держит устаревший бейдж до перезагрузки. + notify.notifications_changed(user_id) return len(rows) diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py index 690f537..d90575b 100644 --- a/backend/app/services/stats_service.py +++ b/backend/app/services/stats_service.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from sqlalchemy import text +from sqlalchemy import func, text from sqlmodel import Session, select from app.core.timeutil import iso_utc @@ -235,12 +235,13 @@ def profile_stats(session: Session, user_id: int, group_id: int | None = None) - def group_stats(session: Session, group_id: int) -> dict: board = leaderboard(session, group_id=group_id) - total_matches = session.exec( - select(Match).where(Match.group_id == group_id, Match.status == "finished") - ).all() - last_at = None - if total_matches: - last_at = str(max(m.played_at for m in total_matches)) + # Нужны только счётчик и дата последней партии — тянуть строки целиком незачем. + games_count, last_played = session.exec( + select(func.count(), func.max(Match.played_at)).where( + Match.group_id == group_id, Match.status == "finished" + ) + ).one() + last_at = str(last_played) if last_played else None available_ids = group_service.available_faction_ids(session, group_id) faction_meta = [] @@ -290,7 +291,7 @@ def group_stats(session: Session, group_id: int) -> dict: return { "group_id": group_id, - "total_matches": len(total_matches), + "total_matches": games_count, "last_match_at": last_at, "leaderboard": board["entries"], "provisional": board["provisional"], @@ -300,26 +301,44 @@ def group_stats(session: Session, group_id: int) -> dict: } +def _participant_row(p: MatchParticipant, u: User, f: Faction) -> dict: + return { + "user_id": u.id, + "nickname": u.nickname, + "faction_id": f.id, + "faction_name": f.name_ru, + "place": p.place, + "eliminated": p.eliminated, + "was_random": p.was_random, + "comment": p.comment, + } + + +def _participants_by_match(session: Session, match_ids: list[int]) -> dict[int, list[dict]]: + """Участники сразу всей страницы партий: иначе запрос на каждую партию (N+1).""" + if not match_ids: + return {} + rows = session.exec( + select(MatchParticipant, User, Faction) + .join(User, User.id == MatchParticipant.user_id) + .join(Faction, Faction.id == MatchParticipant.faction_id) + .where(MatchParticipant.match_id.in_(match_ids)) + # place может быть NULL (партия идёт) — NULL уходит в конец сортировки. + .order_by(MatchParticipant.place.is_(None), MatchParticipant.place, User.nickname) + ).all() + out: dict[int, list[dict]] = {} + for p, u, f in rows: + out.setdefault(p.match_id, []).append(_participant_row(p, u, f)) + return out + + def _match_items(session: Session, matches) -> list[dict]: """Элементы списка партий (общее для списка группы и истории игрока).""" - from app.services.match_service import participants_detail # избегаем цикла импорта + by_match = _participants_by_match(session, [m.id for m in matches]) items = [] for m in matches: - parts = [] - for p, u, f in participants_detail(session, m.id): # type: ignore[arg-type] - parts.append( - { - "user_id": u.id, - "nickname": u.nickname, - "faction_id": f.id, - "faction_name": f.name_ru, - "place": p.place, - "eliminated": p.eliminated, - "was_random": p.was_random, - "comment": p.comment, - } - ) + parts = by_match.get(m.id, []) items.append( { "id": m.id, @@ -339,7 +358,9 @@ def _match_items(session: Session, matches) -> list[dict]: def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict: - total = len(session.exec(select(Match.id).where(Match.group_id == group_id)).all()) + total = session.exec( + select(func.count()).select_from(Match).where(Match.group_id == group_id) + ).one() matches = session.exec( select(Match) .where(Match.group_id == group_id) @@ -385,13 +406,12 @@ def user_match_list( } where = (MatchParticipant.user_id == user_id, Match.status == "finished") - total = len( - session.exec( - select(Match.id) - .join(MatchParticipant, MatchParticipant.match_id == Match.id) - .where(*where) - ).all() - ) + total = session.exec( + select(func.count()) + .select_from(Match) + .join(MatchParticipant, MatchParticipant.match_id == Match.id) + .where(*where) + ).one() matches = session.exec( select(Match) .join(MatchParticipant, MatchParticipant.match_id == Match.id) @@ -410,8 +430,6 @@ def user_match_list( def user_in_progress_matches(session: Session, user_id: int) -> list[dict]: """Незавершённые партии во всех группах, где состоит пользователь (новые сверху).""" - from app.services.match_service import participants_detail # избегаем цикла импорта - group_ids = list( session.exec(select(GroupMember.group_id).where(GroupMember.user_id == user_id)).all() ) @@ -423,21 +441,10 @@ def user_in_progress_matches(session: Session, user_id: int) -> list[dict]: .where(Match.status == "in_progress", Match.group_id.in_(group_ids)) .order_by(Match.started_at.desc(), Match.id.desc()) ).all() + by_match = _participants_by_match(session, [m.id for m, _ in rows]) out = [] for m, gname in rows: - parts = [ - { - "user_id": u.id, - "nickname": u.nickname, - "faction_id": f.id, - "faction_name": f.name_ru, - "place": p.place, - "eliminated": p.eliminated, - "was_random": p.was_random, - "comment": p.comment, - } - for p, u, f in participants_detail(session, m.id) # type: ignore[arg-type] - ] + parts = by_match.get(m.id, []) out.append( { "id": m.id, diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index ea6d552..987eeda 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -1,9 +1,8 @@ """Пользователи: создание из внешней личности, ник, активная группа, профиль.""" from __future__ import annotations -import os import re -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from sqlmodel import Session, select @@ -11,7 +10,8 @@ from sqlmodel import Session, select from app.auth.provider import ExternalIdentity from app.core.config import settings from app.core.errors import NicknameTakenError, NotFoundError, ValidationError -from app.models import AuthIdentity, Faction, GroupMember, User +from app.core.timeutil import utcnow +from app.models import AuthIdentity, Faction, User _NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE) _BIO_MAX = 500 @@ -134,7 +134,7 @@ def register_from_identity( def update_nickname(session: Session, user: User, new_nickname: str) -> User: new_nickname = (new_nickname or "").strip() - if not _NICK_RE.match(new_nickname): + if not nickname_format_ok(new_nickname): raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_") if not nickname_available(session, new_nickname, exclude_user_id=user.id): raise NicknameTakenError() @@ -147,12 +147,9 @@ def update_nickname(session: Session, user: User, new_nickname: str) -> User: def set_active_group(session: Session, user: User, group_id: int | None) -> User: if group_id is not None: - member = session.exec( - select(GroupMember).where( - GroupMember.group_id == group_id, GroupMember.user_id == user.id - ) - ).first() - if member is None: + from app.services import group_service # избегаем цикла импорта + + if group_service.get_membership(session, group_id, user.id) is None: raise ValidationError("Нельзя сделать активной группу, в которой вы не состоите.") user.active_group_id = group_id session.add(user) @@ -170,7 +167,13 @@ def avatar_url_for(user_id: int, avatar_path: str | None, updated_at: datetime | подтягивал новую картинку после смены (файл перезаписывается по тому же пути).""" if not avatar_path: return None - version = int(updated_at.timestamp()) if updated_at else 0 + # В БД время наивное и хранится в UTC. .timestamp() у наивного значения считает + # его локальным, и версия разъезжалась с лидербордом, где то же поле считает SQL + # (strftime('%s') читает его как UTC) — один аватар качался браузером дважды. + version = 0 + if updated_at is not None: + aware = updated_at if updated_at.tzinfo else updated_at.replace(tzinfo=timezone.utc) + version = int(aware.timestamp()) return f"/api/users/{user_id}/avatar?v={version}" @@ -218,6 +221,21 @@ def update_favorite_faction(session: Session, user: User, faction_id: int | None return user +def read_capped_image(file, max_bytes: int, limit_message: str) -> tuple[bytes, str]: + """Прочитать загруженный файл с ограничением размера и убедиться, что это картинка. + + Читаем на байт больше лимита: так превышение видно, не загружая файл целиком. + Один хелпер на все загрузки (аватар, фото партии, иконка ачивки) — иначе + правка лимита или списка форматов расходится по четырём роутерам.""" + content = file.file.read(max_bytes + 1) + if len(content) > max_bytes: + raise ValidationError(limit_message) + ext = sniff_image_ext(content) + if ext is None: + raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.") + return content, ext + + def sniff_image_ext(content: bytes) -> str | None: """Расширение по магическим байтам (PNG/JPEG/WebP), без Pillow. None — не картинка.""" if content.startswith(b"\x89PNG\r\n\x1a\n"): @@ -253,6 +271,10 @@ def set_avatar(session: Session, user: User, content: bytes, ext: str) -> User: rel = f"{_AVATAR_SUBDIR}/{user.id}.{ext}" (Path(settings.upload_dir) / rel).write_bytes(content) user.avatar_path = rel + # Файл перезаписывается по тому же пути, поэтому при том же расширении avatar_path + # не меняется, UPDATE не эмитится и onupdate не срабатывает. Без явного бампа + # кэш-бастер остаётся прежним, и браузер час показывает старую картинку. + user.updated_at = utcnow() session.add(user) session.commit() session.refresh(user) @@ -267,6 +289,7 @@ def clear_avatar(session: Session, user: User) -> User: except OSError: pass user.avatar_path = None + user.updated_at = utcnow() session.add(user) session.commit() session.refresh(user) diff --git a/backend/tests/test_achievements.py b/backend/tests/test_achievements.py index d940ded..f83f845 100644 --- a/backend/tests/test_achievements.py +++ b/backend/tests/test_achievements.py @@ -111,3 +111,24 @@ def test_update_and_delete(client: TestClient, make_admin, monkeypatch, tmp_path f"/api/admin/achievements/{slug}", headers=csrf_headers(client) ).status_code == 200 assert all(a["slug"] != slug for a in client.get("/api/admin/achievements").json()) + + +def test_delete_rejects_traversal_slug(client: TestClient, make_admin, monkeypatch, tmp_path): + """Slug из URL не должен уводить файловые операции за каталог ачивок. + + Регрессия: `DELETE /api/admin/achievements/%2E%2E` снимал rmtree'ом родительскую + папку каталога (в проде это /data — БД, uploads и ачивки разом).""" + root = _use_tmp_achievements(monkeypatch, tmp_path / "achievements") + root.mkdir(parents=True, exist_ok=True) + sibling = tmp_path / "db.sqlite3" + sibling.write_bytes(b"data") + _admin(client, make_admin) + + # Именно percent-кодированная форма: обычные точки httpx нормализует ещё до + # отправки, запрос уходит на /api/admin/ и до обработчика вовсе не доходит. + r = client.request( + "DELETE", "/api/admin/achievements/%2E%2E", headers=csrf_headers(client) + ) + assert r.status_code == 404, r.text + assert r.json()["error"]["code"] == "NOT_FOUND" # ответ обработчика, а не промах роутинга + assert sibling.exists() and root.is_dir() diff --git a/backend/tests/test_admin_extra.py b/backend/tests/test_admin_extra.py index 604e142..9d603f0 100644 --- a/backend/tests/test_admin_extra.py +++ b/backend/tests/test_admin_extra.py @@ -203,3 +203,75 @@ def test_admin_rename_faction_system_wide(client: TestClient, make_admin, engine detail = client.get(f"/api/admin/matches/{mid}").json() ap = next(p for p in detail["participants"] if p["user_id"] == me["id"]) assert ap["faction_name"] == "Орки WAAAGH" + + +def test_admin_delete_group_with_matches_is_conflict(client: TestClient, make_admin, engine): + """Группу с партиями удалять нельзя — но ответ должен быть внятным 409. + + Регрессия: matches.group_id — ON DELETE RESTRICT, и голый session.delete ронял + IntegrityError наружу пятисоткой без конверта ошибки.""" + me = login(client, "Owner") + gid = client.post( + "/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client) + ).json()["id"] + p2 = add_group_member(engine, gid, "Игрок2") + fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()] + create_finished_match( + client, gid, + [ + {"user_id": me["id"], "faction_id": fids[0], "place": 1}, + {"user_id": p2, "faction_id": fids[1], "place": 2}, + ], + ) + + _admin_login(client, make_admin) + r = client.delete(f"/api/admin/groups/{gid}", headers=csrf_headers(client)) + assert r.status_code == 409, r.text + # Важен не только код ответа: группа и её партии должны пережить отказ. + assert any(g["id"] == gid for g in client.get("/api/admin/groups").json()) + assert any(m["group_id"] == gid for m in client.get("/api/admin/matches").json()) + + +def test_last_owner_cannot_demote_self(client: TestClient, engine): + """Единственный владелец не может разжаловать сам себя. + + Регрессия: change_role не проверял последнего владельца (в отличие от удаления), + и группа оставалась без владельца навсегда — назначить нового было некому.""" + me = login(client, "Owner") + gid = client.post( + "/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client) + ).json()["id"] + add_group_member(engine, gid, "Игрок2") + + r = client.patch( + f"/api/groups/{gid}/members/{me['id']}", + json={"role": "member"}, + headers=csrf_headers(client), + ) + assert r.status_code == 403, r.text + members = client.get(f"/api/groups/{gid}/members").json() + assert any(m["user_id"] == me["id"] and m["role"] == "owner" for m in members) + + +def test_ownership_transfer_still_works(client: TestClient, engine): + """Обратная сторона защиты последнего владельца: передать роль по-прежнему можно.""" + me = login(client, "Owner") + gid = client.post( + "/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client) + ).json()["id"] + p2 = add_group_member(engine, gid, "Игрок2") + + promote = client.patch( + f"/api/groups/{gid}/members/{p2}", json={"role": "owner"}, headers=csrf_headers(client) + ) + assert promote.status_code == 200, promote.text + + # Владельцев теперь двое — прежний может сложить полномочия. + demote = client.patch( + f"/api/groups/{gid}/members/{me['id']}", + json={"role": "member"}, + headers=csrf_headers(client), + ) + assert demote.status_code == 200, demote.text + members = client.get(f"/api/groups/{gid}/members").json() + assert [m["user_id"] for m in members if m["role"] == "owner"] == [p2] diff --git a/backend/tests/test_attachments.py b/backend/tests/test_attachments.py index 5bce5d0..d0a48f5 100644 --- a/backend/tests/test_attachments.py +++ b/backend/tests/test_attachments.py @@ -138,3 +138,23 @@ def test_non_member_cannot_view(client: TestClient, engine, monkeypatch, tmp_pat login(client, "Чужак") # не состоит в группе g = client.get(f"/api/matches/{mid}/attachments/{aid}") assert g.status_code in (401, 403) + + +def test_attachment_upload_moves_version(client: TestClient, engine, monkeypatch, tmp_path): + """Вложения видны в MatchRead, но строку matches не трогают. + + Регрессия: из-за этого версия партии не двигалась, и правка со старой версией + проходила мимо оптимистичной блокировки.""" + _use_tmp_uploads(monkeypatch, tmp_path) + me, gid, p2, mid = _start(client, engine) + v1 = client.get(f"/api/matches/{mid}").json()["version"] + + assert _upload(client, mid).status_code == 200 + v2 = client.get(f"/api/matches/{mid}").json()["version"] + assert v2 != v1 + + stale = client.delete( + f"/api/matches/{mid}", params={"expected_version": v1}, headers=csrf_headers(client) + ) + assert stale.status_code == 409, stale.text + assert stale.json()["error"]["code"] == "STALE_WRITE" diff --git a/backend/tests/test_concurrency.py b/backend/tests/test_concurrency.py index 516621f..fb7673d 100644 --- a/backend/tests/test_concurrency.py +++ b/backend/tests/test_concurrency.py @@ -75,3 +75,39 @@ def test_stale_finish_rejected(client: TestClient, engine): headers=csrf_headers(client), ) assert r.status_code == 409 and r.json()["error"]["code"] == "STALE_WRITE", r.text + + +def test_participant_edit_moves_version(client: TestClient, engine): + """Правка одних участников тоже двигает версию партии. + + Регрессия: updated_at менялся только при UPDATE строки matches, поэтому после + правки участников версия оставалась прежней и вторая правка со старой версией + проходила вместо 409 — ровно то, от чего защищает блокировка.""" + me, p2, mid = _start(client, engine) + fin = finish_match( + client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}], + win_reason="objectives", + ) + assert fin.status_code == 200, fin.text + v1 = client.get(f"/api/matches/{mid}").json()["version"] + + parts = {p["user_id"]: p["faction_id"] for p in client.get(f"/api/matches/{mid}").json()["participants"]} + swap = [ + {"user_id": me["id"], "faction_id": parts[me["id"]], "place": 2}, + {"user_id": p2, "faction_id": parts[p2], "place": 1}, + ] + r = client.patch( + f"/api/matches/{mid}", + json={"participants": swap, "expected_version": v1}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + assert client.get(f"/api/matches/{mid}").json()["version"] != v1 + + stale = client.patch( + f"/api/matches/{mid}", + json={"participants": swap, "expected_version": v1}, + headers=csrf_headers(client), + ) + assert stale.status_code == 409, stale.text + assert stale.json()["error"]["code"] == "STALE_WRITE" diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py index 477b25f..03b02fb 100644 --- a/backend/tests/test_profile.py +++ b/backend/tests/test_profile.py @@ -3,7 +3,9 @@ from __future__ import annotations from fastapi.testclient import TestClient +from sqlmodel import Session +from app.models import User from tests.conftest import ( add_group_member, create_finished_match, @@ -399,3 +401,49 @@ def test_history_uses_owner_mode_for_guests(client: TestClient, engine): assert data["mode"] == "best" assert data["detail"] == "full" assert data["total"] == 1 + + +def test_avatar_version_is_stable_across_surfaces(client: TestClient, engine, monkeypatch, tmp_path): + """Кэш-бастер аватара одинаков в профиле и в лидерборде, и меняется при перезаливке. + + Регрессия: версию профиля считал Python из наивного времени как из локального, + а лидерборд — SQL как из UTC, и браузер тянул одну картинку дважды. Плюс при + том же расширении файла updated_at не двигался и ссылка оставалась прежней.""" + _use_tmp_uploads(monkeypatch, tmp_path) + me = login(client, "Версия") + _finished_match_for(client, engine, me) + + def version_in(url: str) -> str: + return url.split("?v=")[1] + + def leaderboard_url() -> str: + board = client.get("/api/stats/leaderboard").json() + entry = next( + e for e in board["entries"] + board["provisional"] if e["user_id"] == me["id"] + ) + return entry["avatar_url"] + + first = client.put( + "/api/users/me/avatar", + files={"file": ("a.png", PNG, "image/png")}, + headers=csrf_headers(client), + ) + assert first.status_code == 200, first.text + v_profile = version_in(first.json()["avatar_url"]) + assert version_in(leaderboard_url()) == v_profile + + # Повторная загрузка с тем же расширением: avatar_path не меняется, поэтому UPDATE + # строки сам собой не эмитится — updated_at должен двигаться явно, иначе кэш-бастер + # замирает и браузер час показывает прежнюю картинку. Версия в ссылке считается с + # точностью до секунды, поэтому сдвиг проверяем по времени в БД. + with Session(engine) as s: + before = s.get(User, me["id"]).updated_at + second = client.put( + "/api/users/me/avatar", + files={"file": ("a.png", PNG + b"\x00", "image/png")}, + headers=csrf_headers(client), + ) + assert second.status_code == 200, second.text + with Session(engine) as s: + assert s.get(User, me["id"]).updated_at > before + assert version_in(leaderboard_url()) == version_in(second.json()["avatar_url"]) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 9baa563..f7a546b 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,7 +1,7 @@ import createClient from "openapi-fetch"; import type { paths } from "./schema"; -function readCookie(name: string): string | null { +export function readCookie(name: string): string | null { const m = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)")); return m ? decodeURIComponent(m[1]) : null; } @@ -53,3 +53,13 @@ export function unwrap(res: FetchResult): T { } return res.data as T; } + +/** + * Повторы для проб сессии (`/users/me`, `/admin/me`). «Не авторизован» — это + * только ответ сервера (401/403, обрабатывается в самих хуках); обрыв связи + * ответом не является, и без повторов гварды приняли бы его за разлогин и + * увели на страницу входа. + */ +export const authProbeRetry = (count: number, err: unknown): boolean => + !(err instanceof ApiError) && count < 2; + diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index 99e8c19..cab916c 100644 --- a/frontend/src/api/queryKeys.ts +++ b/frontend/src/api/queryKeys.ts @@ -26,3 +26,17 @@ export const qk = { adminLogs: ["adminLogs"] as const, adminAchievements: ["adminAchievements"] as const, }; + +/** + * Ключи, которые протухают от любой партии: конкретных участников мы не знаем + * (событие приходит на всю группу), поэтому инвалидируем по префиксу. Один + * список на SSE-обработчик и на завершение партии — иначе переименование ключа + * в этом файле тихо разойдётся с местами, где он написан строкой. + */ +export const matchAffectedKeys = [ + qk.home, + qk.leaderboard, + qk.myStats, + ["userMatches"], + ["publicProfile"], +] as const; diff --git a/frontend/src/auth/guards.tsx b/frontend/src/auth/guards.tsx index a468f42..c5f24c9 100644 --- a/frontend/src/auth/guards.tsx +++ b/frontend/src/auth/guards.tsx @@ -5,25 +5,33 @@ import { Spinner } from "../components/Spinner"; import { useMe } from "../hooks/auth"; import { useAdminMe } from "../hooks/admin"; +/** Запрос упал, а не ответил «не авторизован»: связи нет — это не повод разлогинивать. */ +function OfflineNotice() { + return
Нет связи с сервером. Проверьте подключение и обновите страницу.
; +} + export function RequireAuth({ children }: PropsWithChildren) { - const { data: me, isLoading } = useMe(); + const { data: me, isLoading, isError } = useMe(); const location = useLocation(); if (isLoading) return ; + if (isError) return ; if (!me) return ; return <>{children}; } export function RequireGroup({ children }: PropsWithChildren) { - const { data: me, isLoading } = useMe(); + const { data: me, isLoading, isError } = useMe(); if (isLoading) return ; + if (isError) return ; if (!me) return ; if (me.active_group_id == null) return ; return <>{children}; } export function RequireAdmin({ children }: PropsWithChildren) { - const { data: admin, isLoading } = useAdminMe(); + const { data: admin, isLoading, isError } = useAdminMe(); if (isLoading) return ; + if (isError) return ; if (!admin) return ; return <>{children}; } diff --git a/frontend/src/components/PlaceEditor.tsx b/frontend/src/components/PlaceEditor.tsx index 7deb09d..ebee8ba 100644 --- a/frontend/src/components/PlaceEditor.tsx +++ b/frontend/src/components/PlaceEditor.tsx @@ -53,6 +53,7 @@ export function PlaceEditor({ const elimRef = useRef(null); const indRef = useRef(null); const dragRef = useRef(null); + const hintRef = useRef(null); const byId = new Map(players.map((p) => [p.user_id, p])); const placeOf = (i: number) => @@ -80,6 +81,7 @@ export function PlaceEditor({ }; const clearHints = () => { + hintRef.current = null; if (indRef.current) indRef.current.style.display = "none"; listRef.current ?.querySelectorAll(".merge-target") @@ -88,7 +90,12 @@ export function PlaceEditor({ }; const showHint = (t: Target, d: DragState) => { + // pointermove срабатывает десятки раз в секунду, а цель меняется намного реже: + // без этой проверки на каждое движение шёл querySelectorAll и перестановка классов. + const key = `${t.type}:${t.type === "elim" ? "" : t.idx}`; + if (key === hintRef.current) return; clearHints(); + hintRef.current = key; if (t.type === "elim") { elimRef.current?.classList.add("highlight"); return; @@ -182,6 +189,10 @@ export function PlaceEditor({ onPointerDown: (e: React.PointerEvent) => startDrag(e, opts), onPointerMove: moveDrag, onPointerUp: endDrag, + // Браузер отменяет указатель (перехват прокрутки, входящий звонок) — без этого + // блок остаётся с классом dragging и сдвигом: они выставлены в обход React, + // и перерендер их не снимет. + onPointerCancel: endDrag, }); // ✂ — вынуть игрока из ничьей отдельным блоком сразу после неё. diff --git a/frontend/src/context/ToastContext.tsx b/frontend/src/context/ToastContext.tsx index ccb0969..819c647 100644 --- a/frontend/src/context/ToastContext.tsx +++ b/frontend/src/context/ToastContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useCallback, useContext, useRef, useState } from "react"; +import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react"; import type { PropsWithChildren } from "react"; interface ToastCtx { @@ -17,8 +17,13 @@ export function ToastProvider({ children }: PropsWithChildren) { timer.current = setTimeout(() => setMessage(null), 2800); }, []); + // Провайдер обёрнут вокруг всего приложения и перерисовывается на каждый тост: + // без useMemo новое значение контекста заставляло бы перерисоваться и всех + // потребителей, к самому тосту отношения не имеющих. + const value = useMemo(() => ({ show }), [show]); + return ( - + {children} {message &&
{message}
}
diff --git a/frontend/src/domain/format.ts b/frontend/src/domain/format.ts index 5885c61..f8cecd9 100644 --- a/frontend/src/domain/format.ts +++ b/frontend/src/domain/format.ts @@ -29,12 +29,9 @@ export function formatDuration(minutes: number | null | undefined): string { export function formatTime(iso: string | null | undefined): string { if (!iso) return "—"; - try { - const d = shifted(iso); - return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`; - } catch { - return iso; - } + const d = shifted(iso); + if (Number.isNaN(d.getTime())) return iso; + return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`; } // «Дата игры» приходит date-only строкой (YYYY-MM-DD) — просто переставляем части, @@ -47,10 +44,7 @@ export function formatDate(iso: string | null | undefined): string { export function formatDateTime(iso: string | null | undefined): string { if (!iso) return "—"; - try { - const d = shifted(iso); - return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)}.${d.getUTCFullYear()} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`; - } catch { - return iso; - } + const d = shifted(iso); + if (Number.isNaN(d.getTime())) return iso; + return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)}.${d.getUTCFullYear()} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`; } diff --git a/frontend/src/hooks/admin.ts b/frontend/src/hooks/admin.ts index 8c4fa7a..8828c15 100644 --- a/frontend/src/hooks/admin.ts +++ b/frontend/src/hooks/admin.ts @@ -1,14 +1,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ApiError, api, unwrap } from "../api/client"; +import { api, authProbeRetry, unwrap } from "../api/client"; import { qk } from "../api/queryKeys"; import type { AdminMe, MatchUpdate } from "../domain/types"; -import { resizeImage } from "../lib/image"; - -function readCsrfToken(): string | null { - const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/); - return m ? decodeURIComponent(m[1]) : null; -} +import { uploadFile } from "../lib/upload"; export function useAdminMe() { return useQuery({ @@ -18,6 +13,7 @@ export function useAdminMe() { if (r.response.status === 401 || r.response.status === 403) return null; return unwrap(r); }, + retry: authProbeRetry, }); } @@ -106,29 +102,8 @@ export function useAdminMatchAttachments(matchId: number | null) { export function useAdminUploadAttachment(matchId: number) { const qc = useQueryClient(); return useMutation({ - mutationFn: async (file: File) => { - const blob = await resizeImage(file); - const form = new FormData(); - form.append("file", blob, "photo.jpg"); - const csrf = readCsrfToken(); - const base = import.meta.env.VITE_API_BASE_URL || ""; - const r = await fetch(`${base}/api/admin/matches/${matchId}/attachments`, { - method: "POST", - body: form, - credentials: "include", - headers: csrf ? { "X-CSRF-Token": csrf } : {}, - }); - if (!r.ok) { - let env: { code?: string; message?: string } | undefined; - try { - env = ((await r.json()) as { error?: typeof env }).error; - } catch { - /* тело без JSON */ - } - throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status); - } - return await r.json(); - }, + mutationFn: async (file: File) => + uploadFile(`/api/admin/matches/${matchId}/attachments`, file), onSuccess: () => qc.invalidateQueries({ queryKey: ["adminMatchAttachments", matchId] }), }); } @@ -283,28 +258,12 @@ export function useDeleteAchievement() { export function useUploadAchievementIcon() { const qc = useQueryClient(); return useMutation({ - mutationFn: async ({ slug, file }: { slug: string; file: File }) => { - const form = new FormData(); - form.append("file", file, file.name); - const csrf = readCsrfToken(); - const base = import.meta.env.VITE_API_BASE_URL || ""; - const r = await fetch(`${base}/api/admin/achievements/${slug}/icon`, { + // Иконку шлём оригиналом: ресайз в JPEG убил бы прозрачность герба. + mutationFn: async ({ slug, file }: { slug: string; file: File }) => + uploadFile(`/api/admin/achievements/${slug}/icon`, file, { method: "PUT", - body: form, - credentials: "include", - headers: csrf ? { "X-CSRF-Token": csrf } : {}, - }); - if (!r.ok) { - let env: { code?: string; message?: string } | undefined; - try { - env = ((await r.json()) as { error?: typeof env }).error; - } catch { - /* тело без JSON */ - } - throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status); - } - return await r.json(); - }, + maxSide: null, + }), onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }), }); } diff --git a/frontend/src/hooks/auth.ts b/frontend/src/hooks/auth.ts index 2982779..9442c2d 100644 --- a/frontend/src/hooks/auth.ts +++ b/frontend/src/hooks/auth.ts @@ -1,7 +1,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ApiError, api, unwrap } from "../api/client"; +import { api, authProbeRetry, unwrap } from "../api/client"; import { qk } from "../api/queryKeys"; +import { uploadFile } from "../lib/upload"; import type { AuthConfig, Me } from "../domain/types"; export function useMe() { @@ -12,6 +13,7 @@ export function useMe() { if (r.response.status === 401) return null; return unwrap(r); }, + retry: authProbeRetry, }); } @@ -154,74 +156,16 @@ export function useUpdateHistoryPrefs() { }); } -// Аватар. Картинку уменьшаем на клиенте (≤512px) и грузим multipart'ом отдельным -// fetch (openapi-fetch неудобен для файлов); CSRF-токен из cookie ставим вручную. -function readCsrfToken(): string | null { - const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/); - return m ? decodeURIComponent(m[1]) : null; -} - -async function resizeImage(file: File, max = 512): Promise { - try { - const dataUrl = await new Promise((res, rej) => { - const fr = new FileReader(); - fr.onload = () => res(fr.result as string); - fr.onerror = () => rej(fr.error); - fr.readAsDataURL(file); - }); - const img = await new Promise((res, rej) => { - const i = new Image(); - i.onload = () => res(i); - i.onerror = () => rej(new Error("image load failed")); - i.src = dataUrl; - }); - let { width, height } = img; - if (width > max || height > max) { - const scale = Math.min(max / width, max / height); - width = Math.round(width * scale); - height = Math.round(height * scale); - } - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext("2d"); - if (!ctx) return file; - ctx.drawImage(img, 0, 0, width, height); - const blob = await new Promise((res) => - canvas.toBlob(res, "image/jpeg", 0.85), - ); - return blob ?? file; - } catch { - return file; // не вышло уменьшить — отправим как есть (сервер проверит тип/размер) - } -} - +// Аватар: картинку уменьшаем на клиенте (≤512px) и грузим общим загрузчиком. export function useUploadAvatar() { const qc = useQueryClient(); return useMutation({ - mutationFn: async (file: File): Promise => { - const blob = await resizeImage(file); - const form = new FormData(); - form.append("file", blob, "avatar.jpg"); - const csrf = readCsrfToken(); - const base = import.meta.env.VITE_API_BASE_URL || ""; - const r = await fetch(`${base}/api/users/me/avatar`, { + mutationFn: async (file: File): Promise => + uploadFile("/api/users/me/avatar", file, { method: "PUT", - body: form, - credentials: "include", - headers: csrf ? { "X-CSRF-Token": csrf } : {}, - }); - if (!r.ok) { - let env: { code?: string; message?: string; details?: unknown } | undefined; - try { - env = ((await r.json()) as { error?: typeof env }).error; - } catch { - /* тело без JSON */ - } - throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status, env?.details); - } - return (await r.json()) as Me; - }, + fieldName: "avatar.jpg", + maxSide: 512, + }), onSuccess: (me) => { qc.setQueryData(qk.me, me); qc.invalidateQueries(); diff --git a/frontend/src/hooks/matches.ts b/frontend/src/hooks/matches.ts index 90e2b94..fd8fc74 100644 --- a/frontend/src/hooks/matches.ts +++ b/frontend/src/hooks/matches.ts @@ -1,18 +1,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ApiError, api, unwrap } from "../api/client"; -import { qk } from "../api/queryKeys"; +import { api, unwrap } from "../api/client"; +import { matchAffectedKeys, qk } from "../api/queryKeys"; import type { FactionRead, MatchCreate, MatchFinish, MatchRead } from "../domain/types"; -import { resizeImage } from "../lib/image"; - -function readCsrfToken(): string | null { - const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/); - return m ? decodeURIComponent(m[1]) : null; -} +import { uploadFile } from "../lib/upload"; export function useMatch(matchId: number | null) { return useQuery({ - queryKey: matchId ? qk.match(matchId) : ["match", "none"], + // Ключ и enabled должны сходиться: иначе запрос без валидного id кэшировал бы + // свою ошибку под общим ключом-заглушкой. + queryKey: matchId != null ? qk.match(matchId) : ["match", "none"], enabled: matchId != null, queryFn: async () => unwrap( @@ -60,9 +57,7 @@ export function useFinishMatch() { qc.invalidateQueries({ queryKey: qk.match(m.id) }); qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) }); qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) }); - qc.invalidateQueries({ queryKey: qk.leaderboard }); - qc.invalidateQueries({ queryKey: qk.home }); - qc.invalidateQueries({ queryKey: qk.myStats }); + for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key }); }, }); } @@ -88,29 +83,8 @@ export function useDeleteMatch() { export function useUploadMatchAttachment(matchId: number) { const qc = useQueryClient(); return useMutation({ - mutationFn: async (file: File) => { - const blob = await resizeImage(file); - const form = new FormData(); - form.append("file", blob, "photo.jpg"); - const csrf = readCsrfToken(); - const base = import.meta.env.VITE_API_BASE_URL || ""; - const r = await fetch(`${base}/api/matches/${matchId}/attachments`, { - method: "POST", - body: form, - credentials: "include", - headers: csrf ? { "X-CSRF-Token": csrf } : {}, - }); - if (!r.ok) { - let env: { code?: string; message?: string } | undefined; - try { - env = ((await r.json()) as { error?: typeof env }).error; - } catch { - /* тело без JSON */ - } - throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status); - } - return await r.json(); - }, + mutationFn: async (file: File) => + uploadFile(`/api/matches/${matchId}/attachments`, file), onSuccess: () => qc.invalidateQueries({ queryKey: qk.match(matchId) }), }); } diff --git a/frontend/src/hooks/useServerEvents.ts b/frontend/src/hooks/useServerEvents.ts index 8efddda..3b853c4 100644 --- a/frontend/src/hooks/useServerEvents.ts +++ b/frontend/src/hooks/useServerEvents.ts @@ -1,7 +1,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; -import { qk } from "../api/queryKeys"; +import { matchAffectedKeys, qk } from "../api/queryKeys"; interface ServerEvent { type: "match" | "group" | "invitations" | "notifications"; @@ -38,8 +38,8 @@ export function useServerEvents(enabled: boolean) { qc.invalidateQueries({ queryKey: qk.groupMatches(ev.group_id) }); qc.invalidateQueries({ queryKey: qk.groupStats(ev.group_id) }); } - qc.invalidateQueries({ queryKey: qk.home }); - qc.invalidateQueries({ queryKey: qk.leaderboard }); + // История игр и публичные профили тоже меняются от чужой партии. + for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key }); } else if (ev.type === "group") { if (ev.group_id != null) { qc.invalidateQueries({ queryKey: qk.group(ev.group_id) }); diff --git a/frontend/src/lib/upload.ts b/frontend/src/lib/upload.ts new file mode 100644 index 0000000..1179d49 --- /dev/null +++ b/frontend/src/lib/upload.ts @@ -0,0 +1,52 @@ +import { ApiError, readCookie } from "../api/client"; + +import { resizeImage } from "./image"; + +/** + * Загрузка файла multipart'ом. openapi-fetch неудобен для файлов, поэтому идём + * обычным fetch — а значит CSRF-заголовок, базовый URL и разбор конверта ошибки + * приходится ставить руками. Один хелпер на все четыре загрузки (аватар, фото + * партии, фото партии из админки, иконка ачивки): иначе правка вроде таймаута + * или прогресса расходится по копиям. + * + * maxSide задаёт ресайз перед отправкой; `null` отправляет файл как есть + * (иконки ачивок грузятся оригиналом, у них свой лимит на сервере). + */ +export async function uploadFile( + url: string, + file: File, + opts: { + method?: "POST" | "PUT"; + fieldName?: string; + maxSide?: number | null; + errorMessage?: string; + } = {}, +): Promise { + const { + method = "POST", + maxSide = 1600, + fieldName = maxSide == null ? file.name : "photo.jpg", + errorMessage = "Не удалось загрузить", + } = opts; + const body = maxSide == null ? file : await resizeImage(file, maxSide); + const form = new FormData(); + form.append("file", body, fieldName); + const csrf = readCookie("csrf_token"); + const base = import.meta.env.VITE_API_BASE_URL || ""; + const r = await fetch(`${base}${url}`, { + method, + body: form, + credentials: "include", + headers: csrf ? { "X-CSRF-Token": csrf } : {}, + }); + if (!r.ok) { + let env: { code?: string; message?: string; details?: unknown } | undefined; + try { + env = ((await r.json()) as { error?: typeof env }).error; + } catch { + /* тело без JSON */ + } + throw new ApiError(env?.message || errorMessage, env?.code || "ERROR", r.status, env?.details); + } + return (await r.json()) as T; +} diff --git a/frontend/src/pages/CreateMatchPage.tsx b/frontend/src/pages/CreateMatchPage.tsx index e6aa1e9..d50afeb 100644 --- a/frontend/src/pages/CreateMatchPage.tsx +++ b/frontend/src/pages/CreateMatchPage.tsx @@ -15,13 +15,21 @@ import { useGroupMembers } from "../hooks/groups"; import { useRandomizeFaction, useStartMatch } from "../hooks/matches"; interface Draft { + // Стабильный ключ строки: индексы сдвигаются при удалении, а барабан рандома + // отложенно дописывает результат в свою строку и промахнулся бы по соседа. + id: number; user_id: number | ""; faction_id: number | ""; was_random: boolean; } +let draftSeq = 0; + function emptyDraft(): Draft { - return { user_id: "", faction_id: "", was_random: false }; + // Счётчик монотонный на весь модуль: id нужны лишь уникальные в пределах + // страницы, а сквозная нумерация избавляет от коллизий при перемонтировании. + draftSeq += 1; + return { id: draftSeq, user_id: "", faction_id: "", was_random: false }; } export function CreateMatchPage() { @@ -51,30 +59,39 @@ export function CreateMatchPage() { if (lm || lf) return ; if (!groupId) return
Нет активной группы.
; - const takenFactionIds = (exceptIdx: number) => + const takenFactionIds = (exceptId: number) => rows - .filter((_, i) => i !== exceptIdx) + .filter((r) => r.id !== exceptId) .map((r) => r.faction_id) .filter((x): x is number => typeof x === "number"); // Игроки, уже выбранные в других строках, — в списке не предлагаются. - const takenUserIds = (exceptIdx: number) => + const takenUserIds = (exceptId: number) => rows - .filter((_, i) => i !== exceptIdx) + .filter((r) => r.id !== exceptId) .map((r) => r.user_id) .filter((x): x is number => typeof x === "number"); - const update = (idx: number, patch: Partial) => - setRows((rs) => rs.map((r, i) => (i === idx ? { ...r, ...patch } : r))); + const update = (id: number, patch: Partial) => + setRows((rs) => rs.map((r) => (r.id === id ? { ...r, ...patch } : r))); const addRow = () => setRows((rs) => [...rs, emptyDraft()]); - const removeRow = (idx: number) => setRows((rs) => rs.filter((_, i) => i !== idx)); + const removeRow = (id: number) => { + const timer = reelTimers.current[id]; + if (timer) clearTimeout(timer); + delete reelTimers.current[id]; + setSpin((sp) => { + const { [id]: _dropped, ...rest } = sp; + return rest; + }); + setRows((rs) => rs.filter((r) => r.id !== id)); + }; // «Барабан»: мелькаем названиями с нарастающим интервалом (плавное замедление), // затем рулетка останавливается на выбранной фракции, держит её 0.5с — и только // после этого фракция «выбирается» (onReveal: показываем select с результатом). const animateReel = ( - idx: number, + id: number, names: string[], finalName: string, onReveal: () => void, @@ -82,16 +99,16 @@ export function CreateMatchPage() { new Promise((resolve) => { let delay = 50; const tick = () => { - setSpin((s) => ({ ...s, [idx]: names[Math.floor(Math.random() * names.length)] })); + setSpin((s) => ({ ...s, [id]: names[Math.floor(Math.random() * names.length)] })); delay *= 1.18; if (delay < 300) { - reelTimers.current[idx] = setTimeout(tick, delay); + reelTimers.current[id] = setTimeout(tick, delay); } else { - reelTimers.current[idx] = setTimeout(() => { - setSpin((s) => ({ ...s, [idx]: finalName })); // остановка на выбранной - reelTimers.current[idx] = setTimeout(() => { + reelTimers.current[id] = setTimeout(() => { + setSpin((s) => ({ ...s, [id]: finalName })); // остановка на выбранной + reelTimers.current[id] = setTimeout(() => { onReveal(); - setSpin((s) => ({ ...s, [idx]: null })); + setSpin((s) => ({ ...s, [id]: null })); resolve(); }, 500); // пауза перед «выбором» }, delay); @@ -100,20 +117,20 @@ export function CreateMatchPage() { tick(); }); - const randomizeRow = async (idx: number) => { + const randomizeRow = async (id: number) => { const pool = factions ?? []; - if (pool.length === 0 || spin[idx] != null) return; + if (pool.length === 0 || spin[id] != null) return; try { const faction = await randomize.mutateAsync({ group_id: groupId, - exclude_faction_ids: takenFactionIds(idx), + exclude_faction_ids: takenFactionIds(id), }); - await animateReel(idx, pool.map((f) => f.name_ru), faction.name_ru, () => - update(idx, { faction_id: faction.id, was_random: true }), + await animateReel(id, pool.map((f) => f.name_ru), faction.name_ru, () => + update(id, { faction_id: faction.id, was_random: true }), ); } catch (e) { - if (reelTimers.current[idx]) clearTimeout(reelTimers.current[idx]); - setSpin((s) => ({ ...s, [idx]: null })); + if (reelTimers.current[id]) clearTimeout(reelTimers.current[id]); + setSpin((s) => ({ ...s, [id]: null })); toast.show(e instanceof ApiError ? e.message : "Ошибка рандома"); } }; @@ -130,10 +147,10 @@ export function CreateMatchPage() { const names = all.map((f) => f.name_ru); // Барабан крутится во всех строках одновременно, каждая садится на свою фракцию. await Promise.all( - rows.map((_, idx) => { + rows.map((row, idx) => { const f = pool[idx % pool.length]; - return animateReel(idx, names, f.name_ru, () => - update(idx, { faction_id: f.id, was_random: true }), + return animateReel(row.id, names, f.name_ru, () => + update(row.id, { faction_id: f.id, was_random: true }), ); }), ); @@ -194,11 +211,11 @@ export function CreateMatchPage() { {rows.map((row, idx) => ( -
+
Игрок {idx + 1} {rows.length > 2 && ( - )} @@ -206,29 +223,29 @@ export function CreateMatchPage() { m.id === row.user_id) ?? null} - options={playerOptions.filter((m) => !takenUserIds(idx).includes(m.id))} + options={playerOptions.filter((m) => !takenUserIds(row.id).includes(m.id))} placeholder="— игрок —" renderOption={(m) => playerLabel(m, 28)} renderValue={(m) => playerLabel(m, 24)} - onPick={(m) => update(idx, { user_id: m.id })} + onPick={(m) => update(row.id, { user_id: m.id })} />
- {spin[idx] != null ? ( -
{spin[idx]}
+ {spin[row.id] != null ? ( +
{spin[row.id]}
) : ( f.id === row.faction_id) ?? null} - options={(factions ?? []).filter((f) => !takenFactionIds(idx).includes(f.id))} + options={(factions ?? []).filter((f) => !takenFactionIds(row.id).includes(f.id))} placeholder="— фракция —" renderOption={(f) => f.name_ru} - onPick={(f) => update(idx, { faction_id: f.id, was_random: false })} + onPick={(f) => update(row.id, { faction_id: f.id, was_random: false })} /> )}