Files
ForbiddenStarsApp/backend/app/services/attachment_service.py
T
NotBigGhostandClaude Opus 5 9d9e4a345a Ревью: безопасность и корректность в сервисах бэкенда
Находки прохода /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
2026-09-09 15:24:52 +03:00

103 lines
3.5 KiB
Python

"""Вложения партии (фото). Файлы — на томе settings.upload_dir, в БД метаданные.
Общий сервис для игрового и админского путей (роутеры лишь строят URL с нужным префиксом).
"""
from __future__ import annotations
import shutil
from pathlib import Path
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
_SUBDIR = "matches"
def list_for_match(session: Session, match_id: int) -> list[MatchAttachment]:
return list(
session.exec(
select(MatchAttachment)
.where(MatchAttachment.match_id == match_id)
.order_by(MatchAttachment.created_at, MatchAttachment.id)
).all()
)
def count(session: Session, match_id: int) -> int:
return len(
session.exec(
select(MatchAttachment.id).where(MatchAttachment.match_id == match_id)
).all()
)
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:
if count(session, match.id) >= MAX_ATTACHMENTS: # type: ignore[arg-type]
raise ConflictError(f"Можно прикрепить не более {MAX_ATTACHMENTS} файлов.")
att = MatchAttachment(
match_id=match.id, # type: ignore[arg-type]
uploaded_by=user.id,
kind="photo",
storage_path="",
mime_type=mime,
size_bytes=len(content),
)
session.add(att)
session.flush() # получаем id для имени файла
rel = f"{_SUBDIR}/{match.id}/{att.id}.{ext}"
abs_path = Path(settings.upload_dir) / rel
abs_path.parent.mkdir(parents=True, exist_ok=True)
abs_path.write_bytes(content)
att.storage_path = rel
session.add(att)
_touch(session, match)
session.commit()
session.refresh(att)
return att
def get_for_match(session: Session, match_id: int, att_id: int) -> MatchAttachment:
att = session.get(MatchAttachment, att_id)
if att is None or att.match_id != match_id:
raise NotFoundError("Вложение не найдено.")
return att
def delete(session: Session, match: Match, att_id: int) -> None:
att = get_for_match(session, match.id, att_id) # type: ignore[arg-type]
path = file_path(att)
if path.exists():
try:
path.unlink()
except OSError:
pass
session.delete(att)
_touch(session, match)
session.commit()
def delete_match_files(match_id: int) -> None:
"""Удалить всю папку медиа партии (при удалении партии; строки БД уйдут каскадом)."""
folder = Path(settings.upload_dir) / _SUBDIR / str(match_id)
if folder.is_dir():
shutil.rmtree(folder, ignore_errors=True)