Ревью: безопасность и корректность в сервисах бэкенда

Находки прохода /code-review high по backend/app:

- achievement_service: slug из URL шёл в путь без проверки, из-за чего
  DELETE /api/admin/achievements/%2E%2E удалял rmtree'ом родительскую папку
  каталога ачивок (в проде это /data — БД, uploads, ачивки целиком).
- match_service/attachment_service: версия партии = updated_at, но onupdate
  срабатывает лишь при реальном UPDATE строки matches. Правка одних участников
  и работа с вложениями его не вызывали, и оптимистичная блокировка молча
  пропускала конкурентную запись — бампаем updated_at явно.
- admin_service: удаление группы с партиями упиралось в RESTRICT и уходило
  наружу голым 500; теперь понятная ошибка. Админское удаление партии не
  чистило файлы вложений с тома — они оставались навсегда.
- user_service: при повторной загрузке аватара с тем же расширением avatar_path
  не менялся, updated_at не двигался, и кэш-бастер оставлял старую картинку до
  часа. Плюс версия считалась из наивного времени как из локального и
  разъезжалась с лидербордом, где то же поле считает SQL.
- membership_service: единственный владелец мог разжаловать сам себя и группа
  оставалась без владельца навсегда.
- notification_service: mark_read не слал SSE-сигнал, и бейдж непрочитанных на
  других устройствах висел до перезагрузки.
- routers/admin: created_at после правки пользователя отдавался без смещения,
  и дата «создан» прыгала на часовой пояс до следующего обновления списка.

#8

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186Fk74jkkszahEHSjBzTjD
This commit is contained in:
2026-09-09 15:24:52 +03:00
co-authored by Claude Opus 5
parent 530cf3f4cd
commit 9d9e4a345a
8 changed files with 62 additions and 3 deletions
+1 -1
View File
@@ -114,7 +114,7 @@ def update_user(
is_active=u.is_active, is_active=u.is_active,
auth_provider=u.auth_provider, auth_provider=u.auth_provider,
telegram_id=u.telegram_id, telegram_id=u.telegram_id,
created_at=u.created_at.isoformat(), created_at=iso_utc(u.created_at),
) )
@@ -42,6 +42,10 @@ def _root() -> Path:
return Path(settings.achievements_dir) 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: def _slugify(name: str) -> str:
text = "".join(_TRANSLIT.get(ch, ch) for ch in (name or "").strip().lower()) text = "".join(_TRANSLIT.get(ch, ch) for ch in (name or "").strip().lower())
slug = re.sub(r"[^a-z0-9]+", "-", text).strip("-") slug = re.sub(r"[^a-z0-9]+", "-", text).strip("-")
@@ -49,6 +53,11 @@ def _slugify(name: str) -> str:
def _dir(slug: str) -> Path: def _dir(slug: str) -> Path:
"""Папка ачивки. Slug приходит из URL, поэтому формат проверяем здесь: без этого
`..` или `a/b` увели бы файловые операции (вплоть до rmtree в delete) за пределы
каталога ачивок."""
if not _SLUG_RE.fullmatch(slug or ""):
raise NotFoundError("Ачивка не найдена.")
return _root() / slug return _root() / slug
+10
View File
@@ -6,6 +6,7 @@ from typing import Any
from sqlmodel import Session, select from sqlmodel import Session, select
from app.core.errors import ( from app.core.errors import (
ConflictError,
InvalidCredentialsError, InvalidCredentialsError,
NicknameTakenError, NicknameTakenError,
NotFoundError, NotFoundError,
@@ -75,6 +76,10 @@ def delete_group(session: Session, group_id: int) -> None:
group = session.get(Group, group_id) group = session.get(Group, group_id)
if group is None: if group is None:
raise NotFoundError("Группа не найдена.") 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.delete(group)
session.commit() 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: def delete_match(session: Session, match_id: int) -> None:
from app.services import attachment_service # избегаем цикла импорта
match = session.get(Match, match_id) match = session.get(Match, match_id)
if match is None: if match is None:
raise NotFoundError("Партия не найдена.") raise NotFoundError("Партия не найдена.")
session.delete(match) session.delete(match)
session.commit() session.commit()
# Как и в игроцком пути (match_service.delete_match): строки вложений уходят
# каскадом, а файлы с тома нужно убрать руками, иначе они остаются навсегда.
attachment_service.delete_match_files(match_id)
# ─── Журнал аудита ─────────────────────────────────────────────────────────── # ─── Журнал аудита ───────────────────────────────────────────────────────────
@@ -11,6 +11,7 @@ from sqlmodel import Session, select
from app.core.config import settings from app.core.config import settings
from app.core.errors import ConflictError, NotFoundError from app.core.errors import ConflictError, NotFoundError
from app.core.timeutil import utcnow
from app.models import Match, MatchAttachment, User from app.models import Match, MatchAttachment, User
MAX_ATTACHMENTS = 10 MAX_ATTACHMENTS = 10
@@ -39,6 +40,14 @@ def file_path(att: MatchAttachment) -> Path:
return Path(settings.upload_dir) / att.storage_path return Path(settings.upload_dir) / att.storage_path
def _touch(session: Session, match: Match) -> None:
"""Двинуть версию партии: набор вложений виден в MatchRead, а сама строка matches
при работе с ними не меняется — без этого оптимистичная блокировка проспала бы
конкурентную правку."""
match.updated_at = utcnow()
session.add(match)
def add_photo( def add_photo(
session: Session, match: Match, user: User, content: bytes, ext: str, mime: str session: Session, match: Match, user: User, content: bytes, ext: str, mime: str
) -> MatchAttachment: ) -> MatchAttachment:
@@ -60,6 +69,7 @@ def add_photo(
abs_path.write_bytes(content) abs_path.write_bytes(content)
att.storage_path = rel att.storage_path = rel
session.add(att) session.add(att)
_touch(session, match)
session.commit() session.commit()
session.refresh(att) session.refresh(att)
return att return att
@@ -81,6 +91,7 @@ def delete(session: Session, match: Match, att_id: int) -> None:
except OSError: except OSError:
pass pass
session.delete(att) session.delete(att)
_touch(session, match)
session.commit() session.commit()
+4
View File
@@ -351,6 +351,10 @@ def update_match(
) )
match.player_count = len(participants) match.player_count = len(participants)
# Версия партии = updated_at, а onupdate срабатывает только при реальном UPDATE
# строки matches. Правка одних участников его не вызывает, и тогда оптимистичная
# блокировка молча пропускала бы конкурентную запись — поэтому бампаем явно.
match.updated_at = _utcnow()
session.add(match) session.add(match)
session.commit() session.commit()
session.refresh(match) session.refresh(match)
@@ -85,6 +85,16 @@ def change_role(session: Session, group: Group, user_id: int, role: str) -> Grou
).first() ).first()
if member is None: if member is None:
raise NotFoundError("Игрок не состоит в группе.") raise NotFoundError("Игрок не состоит в группе.")
if member.role == "owner" and role != "owner":
# Без этого единственный владелец мог разжаловать сам себя, и группа
# оставалась без владельца навсегда: назначить нового уже некому.
owners = session.exec(
select(GroupMember).where(
GroupMember.group_id == group.id, GroupMember.role == "owner"
)
).all()
if len(owners) <= 1:
raise ForbiddenError("Нельзя снять роль с последнего владельца группы.")
member.role = role member.role = role
session.add(member) session.add(member)
session.commit() session.commit()
@@ -137,6 +137,9 @@ def mark_read(session: Session, user_id: int, ids: list[int] | None = None) -> i
session.add(row) session.add(row)
if rows: if rows:
session.commit() session.commit()
# Счётчик непрочитанных изменился — толкаем тот же сигнал, что и create_for,
# иначе вкладка на другом устройстве держит устаревший бейдж до перезагрузки.
notify.notifications_changed(user_id)
return len(rows) return len(rows)
+14 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import os import os
import re import re
from datetime import datetime from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from sqlmodel import Session, select from sqlmodel import Session, select
@@ -11,6 +11,7 @@ from sqlmodel import Session, select
from app.auth.provider import ExternalIdentity from app.auth.provider import ExternalIdentity
from app.core.config import settings from app.core.config import settings
from app.core.errors import NicknameTakenError, NotFoundError, ValidationError 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, GroupMember, User
_NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE) _NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE)
@@ -170,7 +171,13 @@ def avatar_url_for(user_id: int, avatar_path: str | None, updated_at: datetime |
подтягивал новую картинку после смены (файл перезаписывается по тому же пути).""" подтягивал новую картинку после смены (файл перезаписывается по тому же пути)."""
if not avatar_path: if not avatar_path:
return None 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}" return f"/api/users/{user_id}/avatar?v={version}"
@@ -253,6 +260,10 @@ def set_avatar(session: Session, user: User, content: bytes, ext: str) -> User:
rel = f"{_AVATAR_SUBDIR}/{user.id}.{ext}" rel = f"{_AVATAR_SUBDIR}/{user.id}.{ext}"
(Path(settings.upload_dir) / rel).write_bytes(content) (Path(settings.upload_dir) / rel).write_bytes(content)
user.avatar_path = rel user.avatar_path = rel
# Файл перезаписывается по тому же пути, поэтому при том же расширении avatar_path
# не меняется, UPDATE не эмитится и onupdate не срабатывает. Без явного бампа
# кэш-бастер остаётся прежним, и браузер час показывает старую картинку.
user.updated_at = utcnow()
session.add(user) session.add(user)
session.commit() session.commit()
session.refresh(user) session.refresh(user)
@@ -267,6 +278,7 @@ def clear_avatar(session: Session, user: User) -> User:
except OSError: except OSError:
pass pass
user.avatar_path = None user.avatar_path = None
user.updated_at = utcnow()
session.add(user) session.add(user)
session.commit() session.commit()
session.refresh(user) session.refresh(user)