Ревью: безопасность и корректность в сервисах бэкенда
Находки прохода /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:
@@ -3,7 +3,7 @@ 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,6 +11,7 @@ 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.core.timeutil import utcnow
|
||||
from app.models import AuthIdentity, Faction, GroupMember, User
|
||||
|
||||
_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:
|
||||
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}"
|
||||
|
||||
|
||||
@@ -253,6 +260,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 +278,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)
|
||||
|
||||
Reference in New Issue
Block a user