Files
vidconf/backend/services/backgrounds.py
Max Ronzhin fec9255baa feat(backend): модуль «замена фона» и хранилище своих картинок
Отключаемый в админке модуль `virtual_background` (дефолт — выключен, чтобы
обновление не меняло продукт у тех, кто ничего не просил). Флаг едет клиенту
двумя путями: на публичные страницы входа — через `GET /public/settings`,
участнику комнаты — в join-ответе (`JoinOut`), потому что значение нужно на
руках ДО первого рендера комнаты, а `/admin/settings` доступен только админу.

Свои картинки пользователя (`/users/me/backgrounds`, GET/POST/DELETE):
файлы на диске (`backgrounds/{user_id}/{id}.{ext}`), в БД только путь — как у
аватаров, «чтобы не грузили БД». Лимит в 10 штук проверяется на сервере под
блокировкой строки пользователя: две одновременные загрузки иначе обе увидели
бы «уже девять» и обе прошли бы. Удаление сносит и запись, и файл; чужую
картинку по её id удалить нельзя — владелец в условии запроса.

Валидация загрузки (допустимые форматы, магические байты, реальный размер)
выделена из `services/avatars.py` в общий `services/images.py`: правила у
аватара и фона одни и те же, а разъехавшись, они дали бы дыру ровно там, ради
чего проверка и написана. Публичный API аватаров не изменился.

Сжимает картинку клиент (Pillow на бэкенде нет), но серверная валидация
остаётся полноценной — запрос может прийти и мимо интерфейса.

Новый ключ настройки вписан в `_MANAGED_KEYS` тестов: без этого включённый
в общей dev-БД модуль ронял чужие тесты, которые считают себя изолированными.
2026-08-10 08:59:16 +03:00

136 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Свои картинки пользователя для замены фона видео: лимит, файлы на диске, URL.
Как и аватары (`services/avatars.py`), картинки лежат **файлами на диске**
(`MEDIA_ROOT/backgrounds/{user_id}/{background_id}.{ext}`), а в БД — только
путь (`user_backgrounds.path`): требование оператора «чтобы не грузили БД».
Раздаёт их nginx напрямую (`location /media/`), в обход backend.
Картинку ужимает КЛИЕНТ (canvas → WebP, см. `frontend/src/lib/imageResize.ts`):
фон всё равно рендерится в браузере, и ставить Pillow на сервер ради одной
операции не нужно. Но валидация здесь остаётся полноценной — запрос может
прийти и мимо интерфейса.
Имя файла — id самой записи, а не порядковый номер: запись никогда не
перезаписывается (загрузка всегда создаёт новую), поэтому URL картинки
неизменен и его можно кэшировать браузером без cache-busting-параметра,
в отличие от аватара.
"""
import uuid
from pathlib import Path
from fastapi import UploadFile
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from models.user import User
from models.user_background import UserBackground
from services.images import (
ImageInvalidTypeError,
ImageTooLargeError,
read_and_validate_image,
)
# Сколько своих картинок разрешено одному пользователю — прямое требование
# задачи («но не более 10»). Проверяется здесь, на сервере: ограничение только
# в интерфейсе обходится curl'ом.
MAX_BACKGROUNDS_PER_USER = 10
# Лимит размера загружаемого файла — 2 МБ, как у аватара. Клиент присылает
# ужатый WebP (обычно 100300 КБ), так что до лимита доходит только тот, кто
# шлёт запрос в обход интерфейса.
MAX_BACKGROUND_SIZE_BYTES = 2 * 1024 * 1024
class BackgroundTooLargeError(ImageTooLargeError):
"""Загружаемый файл превышает `MAX_BACKGROUND_SIZE_BYTES` (413)."""
class BackgroundInvalidTypeError(ImageInvalidTypeError):
"""`Content-Type` не входит в список допустимых либо не совпадает с содержимым (415)."""
class BackgroundLimitReachedError(Exception):
"""У пользователя уже `MAX_BACKGROUNDS_PER_USER` картинок (409)."""
async def list_backgrounds(session: AsyncSession, user_id: uuid.UUID) -> list[UserBackground]:
"""Все картинки пользователя, свежие сверху."""
result = await session.execute(
select(UserBackground)
.where(UserBackground.user_id == user_id)
.order_by(UserBackground.created_at.desc(), UserBackground.id.desc())
)
return list(result.scalars().all())
async def add_background(
session: AsyncSession, media_root: Path, user_id: uuid.UUID, file: UploadFile
) -> UserBackground:
"""Провалидировать, сохранить на диск и завести запись о новой картинке.
Бросает `BackgroundTooLargeError`/`BackgroundInvalidTypeError`/
`BackgroundLimitReachedError`. Коммит — за вызывающим (роутером), как и в
остальных эндпоинтах профиля.
Строка пользователя блокируется (`FOR UPDATE`) на время проверки лимита:
без этого две одновременные загрузки (двойной клик по кнопке) обе
увидели бы «уже 9» и обе прошли бы — лимит, проверяемый на сервере,
обязан держаться и в этом случае.
"""
await session.execute(select(User.id).where(User.id == user_id).with_for_update())
count = await session.scalar(
select(func.count()).select_from(UserBackground).where(UserBackground.user_id == user_id)
)
if (count or 0) >= MAX_BACKGROUNDS_PER_USER:
raise BackgroundLimitReachedError(f"limit is {MAX_BACKGROUNDS_PER_USER}")
try:
content, ext = await read_and_validate_image(file, MAX_BACKGROUND_SIZE_BYTES)
except ImageTooLargeError as exc:
raise BackgroundTooLargeError(str(exc)) from exc
except ImageInvalidTypeError as exc:
raise BackgroundInvalidTypeError(str(exc)) from exc
# id генерируем здесь, а не полагаемся на `server_default`: он нужен ДО
# вставки, чтобы собрать имя файла на диске.
background_id = uuid.uuid4()
relative_path = f"backgrounds/{user_id}/{background_id}.{ext}"
background = UserBackground(id=background_id, user_id=user_id, path=relative_path)
session.add(background)
# Запись сначала, файл потом: если вставка не пройдёт (лимит, гонка,
# отвалившаяся БД), на диске не останется мусора.
await session.flush()
file_path = media_root / relative_path
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_bytes(content)
return background
async def delete_background(
session: AsyncSession, media_root: Path, user_id: uuid.UUID, background_id: uuid.UUID
) -> bool:
"""Удалить картинку пользователя вместе с файлом. `False` — записи нет (404).
`user_id` в условии обязателен: без него владелец записи не проверялся бы
и любой аутентифицированный пользователь мог бы удалить чужую картинку,
зная её id.
"""
background = await session.scalar(
select(UserBackground).where(
UserBackground.id == background_id, UserBackground.user_id == user_id
)
)
if background is None:
return False
file_path = media_root / background.path
file_path.unlink(missing_ok=True)
await session.delete(background)
return True
def background_url(path: str) -> str:
"""Публичный URL картинки фона (раздаётся nginx из тома `media`)."""
return f"/media/{path}"