"""Свои картинки пользователя для замены фона видео: лимит, файлы на диске, 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}"