Отключаемый в админке модуль `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-БД модуль ронял чужие тесты, которые считают себя изолированными.
103 lines
5.1 KiB
Python
103 lines
5.1 KiB
Python
"""Проверка права входа в конференцию (статус/пароль) и генерация ответа join.
|
||
|
||
Заменяет `services/room_access.py`: комнаты больше не конкурируют за
|
||
время («правило часа» удалено вместе с бронированием, ADR-001, п.5), но
|
||
пароль закрытой конференции по-прежнему проверяется здесь же — для обычного
|
||
пользователя и гостя одинаково.
|
||
"""
|
||
|
||
import json
|
||
|
||
from core.config import get_settings
|
||
from core.plugins.config import PublishQualityCap, StageMaxTiles
|
||
from core.security import verify_password
|
||
from models.conference import Conference
|
||
from schemas.conferences import JoinOut
|
||
from services.livekit_tokens import create_room_access_token
|
||
|
||
|
||
class ConferenceEndedError(Exception):
|
||
"""Конференция завершена (терминальный статус) — повторный вход невозможен."""
|
||
|
||
|
||
class PasswordRequiredError(Exception):
|
||
"""Конференция закрыта паролем; пароль не передан."""
|
||
|
||
|
||
class InvalidPasswordError(Exception):
|
||
"""Указанный пароль не совпадает с паролем закрытой конференции."""
|
||
|
||
|
||
async def ensure_joinable(conference: Conference, *, password: str | None) -> None:
|
||
"""Проверить, что в конференцию можно войти прямо сейчас.
|
||
|
||
Бросает `ConferenceEndedError` для терминального статуса `ended`
|
||
(история и саммари остаются, но повторный вход невозможен — ADR-001,
|
||
п.2), либо `PasswordRequiredError`/`InvalidPasswordError` для закрытой
|
||
паролем конференции. Ничего не бросает для открытой конференции в
|
||
статусе `scheduled`/`active`.
|
||
"""
|
||
if conference.status == "ended":
|
||
raise ConferenceEndedError
|
||
if not conference.is_closed:
|
||
return
|
||
if conference.password_hash is None or password is None:
|
||
raise PasswordRequiredError
|
||
if not await verify_password(password, conference.password_hash):
|
||
raise InvalidPasswordError
|
||
|
||
|
||
def build_join(
|
||
conference: Conference,
|
||
*,
|
||
identity: str,
|
||
name: str,
|
||
chat_enabled: bool,
|
||
hand_queue_enabled: bool,
|
||
publish_quality_cap: PublishQualityCap,
|
||
stage_max_tiles: StageMaxTiles,
|
||
virtual_background_enabled: bool,
|
||
avatar_url: str | None = None,
|
||
is_organizer: bool = False,
|
||
) -> JoinOut:
|
||
"""Построить ответ join: LiveKit access-токен для входа в комнату конференции.
|
||
|
||
Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4).
|
||
`chat_enabled`/`hand_queue_enabled`/`publish_quality_cap`/`stage_max_tiles`/
|
||
`virtual_background_enabled`
|
||
— снятые вызывающей стороной значения `instance_settings`: читаются здесь
|
||
параметрами, а не заново из БД, чтобы не плодить отдельный запрос настроек
|
||
на каждый join.
|
||
`avatar_url`/`is_organizer`
|
||
прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}`
|
||
— поля добавляются, только если заданы (гость без аватара и не-организатор
|
||
получают токен вовсе без метаданных, как и раньше).
|
||
|
||
⚠️ `is_organizer` в метаданных — только подсказка для UI клиента (показать/
|
||
скрыть кнопки организатора). Метаданным токена доверять для АВТОРИЗАЦИИ
|
||
нельзя — участник технически может их подделать на своей стороне. Любое
|
||
серверное действие организатора (например, принудительный мьют) обязано
|
||
заново проверяться по `conference.owner_id` в БД, а не по этому полю.
|
||
"""
|
||
settings = get_settings()
|
||
metadata_payload: dict[str, object] = {}
|
||
if avatar_url:
|
||
metadata_payload["avatar_url"] = avatar_url
|
||
if is_organizer:
|
||
metadata_payload["is_organizer"] = True
|
||
metadata = json.dumps(metadata_payload) if metadata_payload else None
|
||
token = create_room_access_token(
|
||
room_name=conference.slug, identity=identity, name=name, metadata=metadata
|
||
)
|
||
return JoinOut(
|
||
livekit_url=settings.livekit_public_url,
|
||
token=token,
|
||
room_name=conference.slug,
|
||
conference_id=conference.id,
|
||
chat_enabled=chat_enabled,
|
||
hand_queue_enabled=hand_queue_enabled,
|
||
publish_quality_cap=publish_quality_cap,
|
||
stage_max_tiles=stage_max_tiles,
|
||
virtual_background_enabled=virtual_background_enabled,
|
||
)
|