Отключаемый в админке модуль `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-БД модуль ронял чужие тесты, которые считают себя изолированными.
136 lines
6.7 KiB
Python
136 lines
6.7 KiB
Python
"""Свои картинки пользователя для замены фона видео: лимит, файлы на диске, 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 (обычно 100–300 КБ), так что до лимита доходит только тот, кто
|
||
# шлёт запрос в обход интерфейса.
|
||
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}"
|