Files
vidconf/backend/services/images.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

80 lines
4.2 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.
"""Общая валидация загружаемых картинок: тип по содержимому и реальный размер.
Выделено из `services/avatars.py`, когда те же правила понадобились фонам
видео (`services/backgrounds.py`): списки допустимых форматов и сигнатур
должны быть в одном месте — разъехавшись, они дали бы дыру ровно в том
месте, ради которого проверка и написана.
Правила намеренно не смягчаются для «уже проверенных на клиенте» файлов:
браузер жмёт картинку фона перед отправкой (canvas → WebP), но запрос к API
может прийти и мимо интерфейса — клиенту верить нельзя.
"""
from collections.abc import Callable
from fastapi import UploadFile
# Читаем файл чанками, не доверяя заголовку `Content-Length` (клиент может
# солгать о размере) — реальный размер считается по факту прочитанных байт.
_CHUNK_SIZE_BYTES = 64 * 1024
# Допустимые типы изображений -> расширение файла на диске.
ALLOWED_IMAGE_CONTENT_TYPES: dict[str, str] = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
}
# Магические байты (сигнатуры) форматов — заголовку `Content-Type` от клиента
# доверять нельзя (легко подделать), реальный формат определяется по
# содержимому файла.
_MAGIC_CHECKS: dict[str, Callable[[bytes], bool]] = {
"image/jpeg": lambda head: head[:3] == b"\xff\xd8\xff",
"image/png": lambda head: head[:8] == b"\x89PNG\r\n\x1a\n",
"image/webp": lambda head: head[:4] == b"RIFF" and head[8:12] == b"WEBP",
}
# Достаточно первых 12 байт, чтобы проверить все сигнатуры выше (WebP —
# самая длинная проверка, требует байты 8..11 включительно).
_MAGIC_HEAD_SIZE = 12
class ImageTooLargeError(Exception):
"""Загружаемый файл превышает переданный лимит размера (413)."""
class ImageInvalidTypeError(Exception):
"""`Content-Type` не входит в список допустимых либо не совпадает с содержимым (415)."""
async def read_and_validate_image(file: UploadFile, max_size_bytes: int) -> tuple[bytes, str]:
"""Прочитать файл чанками и провалидировать тип/размер.
Возвращает `(содержимое, расширение)`. Порядок проверок: сначала
заявленный `Content-Type` (быстрый отсев), затем фактический размер по
мере чтения, затем магические байты содержимого — заявленный тип должен
совпасть с реальным (иначе подделка `Content-Type` не даст загрузить,
например, исполняемый файл под видом `image/png`).
"""
declared_type = file.content_type
if declared_type not in ALLOWED_IMAGE_CONTENT_TYPES:
raise ImageInvalidTypeError(f"unsupported_content_type: {declared_type}")
chunks: list[bytes] = []
total_size = 0
while True:
chunk = await file.read(_CHUNK_SIZE_BYTES)
if not chunk:
break
total_size += len(chunk)
if total_size > max_size_bytes:
raise ImageTooLargeError(f"file exceeds {max_size_bytes} bytes")
chunks.append(chunk)
content = b"".join(chunks)
magic_check = _MAGIC_CHECKS[declared_type]
if not magic_check(content[:_MAGIC_HEAD_SIZE]):
raise ImageInvalidTypeError("content_does_not_match_declared_content_type")
return content, ALLOWED_IMAGE_CONTENT_TYPES[declared_type]