Simplify: общие хелперы в роутерах

- IP клиента для аудита брался инлайном в 19 местах шести модулей; теперь
  security.client_ip — за привратником адрес придётся читать из
  X-Forwarded-For, и одна точка правки для этого обязательна.
- Чтение загруженной картинки (лимит размера + sniff формата) было скопировано
  в четыре обработчика; вынесено в user_service.read_capped_image.
- Лимит размера вложения жил двумя одинаковыми константами в игроцком и
  админском роутере — перенесён к самим вложениям.
- update_nickname и set_active_group переиспользуют nickname_format_ok и
  group_service.get_membership вместо собственных копий проверки.
- Убраны осиротевшие импорты и комментарий-заготовка о вложениях, которые
  давно реализованы (MatchAttachment).

#8

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
This commit is contained in:
2026-09-09 18:48:29 +03:00
co-authored by Claude Opus 5
parent 8ac2757cd6
commit 94e2c09952
10 changed files with 71 additions and 63 deletions
@@ -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"
+20 -9
View File
@@ -1,7 +1,6 @@
"""Пользователи: создание из внешней личности, ник, активная группа, профиль."""
from __future__ import annotations
import os
import re
from datetime import datetime, timezone
from pathlib import Path
@@ -12,7 +11,7 @@ from app.auth.provider import ExternalIdentity
from app.core.config import settings
from app.core.errors import NicknameTakenError, NotFoundError, ValidationError
from app.core.timeutil import utcnow
from app.models import AuthIdentity, Faction, GroupMember, User
from app.models import AuthIdentity, Faction, User
_NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE)
_BIO_MAX = 500
@@ -135,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()
@@ -148,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)
@@ -225,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"):