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

Находки прохода /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
@@ -11,6 +11,7 @@ from sqlmodel import Session, select
from app.core.config import settings
from app.core.errors import ConflictError, NotFoundError
from app.core.timeutil import utcnow
from app.models import Match, MatchAttachment, User
MAX_ATTACHMENTS = 10
@@ -39,6 +40,14 @@ def file_path(att: MatchAttachment) -> 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(
session: Session, match: Match, user: User, content: bytes, ext: str, mime: str
) -> MatchAttachment:
@@ -60,6 +69,7 @@ def add_photo(
abs_path.write_bytes(content)
att.storage_path = rel
session.add(att)
_touch(session, match)
session.commit()
session.refresh(att)
return att
@@ -81,6 +91,7 @@ def delete(session: Session, match: Match, att_id: int) -> None:
except OSError:
pass
session.delete(att)
_touch(session, match)
session.commit()