Compare commits

..

59 Commits

Author SHA1 Message Date
6554fa242b release: версия 0.0.36 2026-08-10 13:16:16 +03:00
1089d874af fix(room): замена фона в Safari и Firefox
Проверка поддержки была строже, чем требует библиотека, и молча отрезала два
браузера: кнопки выбора фона не было ни в превью, ни в комнате.

У `@livekit/track-processors` ДВА конвейера обработки кадров: современный
(`MediaStreamTrackProcessor`/`Generator`, только Chrome и производные) и
запасной — рисует кадры в canvas и отдаёт `canvas.captureStream()`. Проверка
требовала именно современный, хотя запасной путь доступен и в Safari, и в
Firefox. Заодно она проверяла `OffscreenCanvas`, но пропускала `VideoFrame`,
`createImageBitmap` и WebGL2, которые библиотеке реально нужны.

Теперь условие буквально повторяет `supportsBackgroundProcessors()` из самой
библиотеки: «умеет считать сегментацию» И «есть хоть какой-то конвейер».
При обновлении пакета сверять с ним.

Пробный WebGL2-контекст (иначе поддержку не определить) создаётся один раз на
жизнь страницы и сразу отпускается через `WEBGL_lose_context`: браузеры
держат ограниченное число живых контекстов.

Проверено: в Firefox 153 детект возвращает «поддерживается» (современного
конвейера нет, запасной есть), в Chrome регрессии нет — процессор
поднимается, трек живой.
2026-08-10 13:16:01 +03:00
bed041db75 release: версия 0.0.35
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-10 09:00:46 +03:00
b0e30ead57 chore(nginx): раздача ассетов сегментации со своего домена
Отдельный `location /mediapipe/` под рантайм MediaPipe: gzip (без него wasm
едет все 9 МБ, со сжатием — около трёх) и долгий кэш — файлы неизменны в
пределах сборки образа и скачиваются один раз на браузер. Модель (.tflite)
из списка gzip исключена намеренно: внутри она уже сжатый архив.
2026-08-10 08:59:58 +03:00
17437880b1 feat(room): замена фона видео на картинку — только на десктопе
Кнопка «Фон» в тулбаре комнаты и выбор фона в превью на входе: три готовые
сцены и свои картинки из профиля. Фон применяется процессором к самому
публикуемому треку (`LocalVideoTrack.setProcessor`), а НЕ пересозданием
`RoomOptions` — ссылка на них обязана оставаться стабильной, иначе
`LiveKitRoom` переподключается к комнате.

Фича только для ДЕСКТОПА, и «десктоп» определяется по возможностям устройства
(`pointer: fine` + `hover: hover` + `maxTouchPoints`), а НЕ по ширине окна:
узкое окно на десктопе — всё ещё десктоп, а широкий планшет — всё ещё планшет,
который сегментация греет. На мобильном кнопки нет вовсе, а не задизейбленной.

Ассеты сегментации отдаются СО СВОЕГО домена: библиотека по умолчанию тянет
wasm с jsdelivr, а модель с storage.googleapis.com, и в закрытом контуре фича
молча не работала бы. Модель (Apache 2.0, см. NOTICE.txt) лежит в репозитории,
wasm-рантайм (~19 МБ) копируется из node_modules плагином сборки. Сама
библиотека и модель грузятся ЛЕНИВО — только когда фон реально включают, вход
в конференцию не стал медленнее.

Три дефолтные сцены — собственные векторные рисунки (`design/backgrounds/`),
а не фотографии из интернета: у нарисованной сцены нет чужой лицензии, а
продукт расходится по инсталляциям, и проверять права на каждую копию некому.

Свои картинки — в профиле, до 10 штук, с уменьшением до 1280px и переводом в
WebP прямо в браузере перед отправкой. Удаление применённого сейчас фона
сбрасывает выбор на «без фона»: хранится ключ записи, а не URL картинки.

Смена камеры фон не теряет (`restartTrack` перезапускает процессор сам),
выключение и включение камеры — навешивает его на новый трек заново.

⚠️ Прокси dev-сервера для `/media/` — обязательно со слэшем: ключ `/media`
Vite матчит префиксом и перехватывает заодно `/mediapipe/...`, из-за чего
модель получала 404 и фон молча не включался.
2026-08-10 08:59:47 +03:00
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
88401d6aa1 release: версия 0.0.34
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-09 21:24:22 +03:00
ca0b1e23fa fix(auth): превью и проверка устройств на шаге "Подключиться к конференции"
Продолжение 33: раньше превью показывалось только на карточке "Как вас
зовут?" (guest-info), а авторизованный пользователь, входящий через
/join, этот шаг вообще не проходит (сразу connecting) — значит, никогда
не видел проверку устройств и не мог задать enterWithVideo/Audio.

Теперь превью и кнопки — на обоих шагах (input и guest-info), с одним
непрерывным потоком: hook enabled/release эффект завязаны на общий флаг
"мы на одном из шагов с превью", а не на конкретный step, иначе переход
input -> guest-info выглядел бы для эффекта как уход с гашением камеры.

Заодно нашёл и починил реальную грабли: <video> на разных шагах — это
разные DOM-узлы (разные позиции в JSX), обычный ref.current не пережил
бы переезд между ними — поток остаётся жив, но картинка гаснет в чёрный
прямоугольник. videoRef хука теперь callback-ref, переподключающий уже
открытый поток к любому новому узлу автоматически.
2026-08-09 21:22:34 +03:00
9463e64e73 release: версия 0.0.33
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-09 18:11:04 +03:00
7ee68b17ee feat(auth): проверка устройств на входе — запрос доступа и превью камеры
Отключаемый модуль (instance_settings.device_check, дефолт выключен):
запрос доступа к камере/микрофону на LoginPage и в карточке "Как вас
зовут?" (JoinPage), живое зеркальное превью и кнопки вкл/выкл камеры и
микрофона там же. На JoinPage кнопки определяют, с чем гость войдёт в
конференцию (RoomPage.LiveKitRoom audio/video вместо жёстких false) —
на LoginPage только пре-авторизуют разрешение, без UI (карточка ведёт
в лобби, применить выбор некуда). Вход в комнату по умолчанию, как и
раньше, с выключенными микрофоном/камерой.

Публичный GET /api/v1/public/settings отдаёт флаг модуля обеим
страницам до аутентификации.
2026-08-09 18:09:10 +03:00
ba01548088 release: версия 0.0.32
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-09 02:40:10 +03:00
3a290c7fc2 feat(monitoring): алерты на недоступность БД и исчерпание пулов + дашборд
DatabaseUnavailable (vidconf_db_up == 0, for: 30s, critical) и
DbConnectionPoolNearExhaustion/RedisConnectionPoolNearExhaustion (занято
> 80% дольше минуты, warning) — сигнал оператору, не автолечение:
healthcheck backend'а по решению оператора остаётся мягким, рестарт при
недоступной БД оборвал бы WS у всех, кто в конференциях.

Дашборд Grafana «БД и пулы соединений» — занятость пулов на графике,
следующий нагрузочный тест будут смотреть глазами.
2026-08-09 02:40:06 +03:00
0e56960714 feat(metrics): метрики доступности БД и занятости пулов БД/Redis
vidconf_db_up проверяется отдельным от основного пула соединением
(NullPool, короткий таймаут) — иначе в момент исчерпания пула проверка
сама встала бы в очередь и не отличила бы «БД лежит» от «пул занят».
vidconf_db_pool_* читаются синхронно из engine.pool, без единого запроса
к БД. metrics_endpoint больше не виснет и не падает при недоступном
основном пуле: критичные gauge'и считаются первыми и не зависят от него,
а vidconf_pipeline_sessions (по-прежнему через Depends(get_session) —
тестовый харнесс подменяет её на savepoint-сессию) обёрнут таймаутом
и try/except.
2026-08-09 02:39:59 +03:00
c906c97cb8 release: версия 0.0.31
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-09 01:07:41 +03:00
296ce60c78 fix(auth): не разлогинивать пользователя, когда серверу плохо
Silent-refresh считал неудачей любой не-2xx ответ и на каждую такую
неудачу сбрасывал access-токен с редиректом на /login. Ответ 500 — это
«серверу плохо», а не «вы не авторизованы»: 07.08.2026 refresh отвечал
500 из-за исчерпанного пула БД, и фронтенд разлогинивал людей посреди
работы, а повторный вход падал тем же 500.

`refreshAccessToken` теперь различает причины: `invalid` (backend отверг
сессию — 4xx, единственный случай для разлогина), `unavailable` (5xx,
таймаут, обрыв сети — сессия цела, токен сохраняется, пользователь
получает обычную ошибку запроса) и `ok`. Восстановление сессии при
старте приложения на `unavailable` повторяет попытку трижды с задержками
1/2/4 с, вместо того чтобы сразу объявить пользователя неавторизованным.
2026-08-09 01:06:15 +03:00
451c18e42b fix(redis): задать размер пула соединений явно
redis-py 8 поставил дефолт `max_connections=100`, а у нас на этом пуле
висят не только команды, но и долгоживущие pub/sub-подписки комнаты — по
одной на каждого участника, пока он в конференции. Сотый участник на
воркер выгребал пул, и WS-хендшейк падал уже на `hgetall` очереди рук с
`MaxConnectionsError`.

Второй потолок того же рода, что и пул БД, только этажом ниже.
Воспроизведён локально: при 99 одновременных WS вход переставал
работать; с `redis_max_connections=500` те же 120 подключений проходят
без единой ошибки. Соединения создаются по мере надобности, поэтому сам
по себе поднятый лимит ничего не стоит.
2026-08-09 01:06:15 +03:00
f7c4fb4176 fix(chat): не держать соединение с БД всю жизнь WS-подключения
Обработчик `WS /conferences/{id}/chat` получает `AsyncSession` через
`Depends(get_session)`, а хендшейк делает четыре SELECT'а (тоггл чата,
конференция, тоггл рук, история). SQLAlchemy открывает транзакцию на
первом из них и держит её — вместе с соединением из пула — всё время,
пока участник сидит в комнате. Соединений в пуле `db_pool_size +
db_max_overflow` = 20 на воркер, то есть 40 на инстанс из двух воркеров:
сороковой вошедший выгребал пул досуха.

Ровно это положило вход на нагрузочном тесте 07.08.2026: 245 ошибок
`QueuePool limit ... timed out`, 170 ответов 500 (из них 123 на резолве
конференции и 21 на гостевом входе), а `pg_stat_activity` показывал рост
`idle in transaction` 3 → 8 → 16 → 26 → 35 → 39 → 40 при одном `active`.
Число открытых WS чата в логах backend растёт синхронно и упирается в
те же 40 ровно к моменту первого таймаута пула.

Соединение освобождается сразу после хендшейка: дальше оба насоса
работают через Redis, а единственная запись в БД (`persist_and_publish`)
открывает и коммитит собственную транзакцию.

Замер на локальном стенде (один воркер, потолок пула 20), 15 посторонних
запросов на каждой ступени:

| участников | idle in transaction | 5xx | p95      |
|------------|---------------------|-----|----------|
| было  20   | 20                  | 10  | 10.05 с  |
| стало 20   | 0                   | 0   | 0.02 с   |
| стало 120  | 0                   | 0   | 0.03 с   |

До правки 21-й участник не мог войти вовсе (500 на guest-join), в логе
40 ошибок `QueuePool limit`; после — ни одной на 120 участниках.
2026-08-09 01:05:59 +03:00
e25b8c28de release: версия 0.0.30
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-04 22:21:36 +03:00
4f82ebe17a feat(auth): согласие на обработку персональных данных при регистрации
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Отключаемый модуль (instance_settings.consent_policy): галочка + ссылка на
публичную страницу регламента на форме регистрации, редактируемый в админке
текст с типовым шаблоном по умолчанию (плейсхолдеры под организацию, не
проходил юридическую проверку), версия текста растёт при каждой правке.
Факт согласия хранится в users (consent_version, consent_given_at) — второй
эшелон проверки на сервере, как и для отключаемых модулей ранее. Дефолт
(выключено) сохраняет поведение существующих инсталляций, у уже
зарегистрированных пользователей согласие не запрашивается.
2026-08-04 22:20:00 +03:00
0e029a2bf8 release: версия 0.0.29
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-04 21:13:49 +03:00
f89bf1ad64 feat(room): кнопка демонстрации экрана в мини-окне
В Document PiP (Chrome/Edge) своего тулбара нет, и начать демонстрацию,
не свернув мини-окно, было нельзя. Кнопка сделана по образцу кнопки
микрофона, добавленной в 0.0.15: тот же `useTrackToggle` через
`RoomContext`, поэтому она и кнопка основного тулбара — два вида одного
состояния и рассинхрону взяться неоткуда.

Главный вопрос задачи — пустит ли платформа `getDisplayMedia()`, вызванный
из кода основного окна по клику в ДРУГОМ окне. Замер в Chrome 150: после
клика в PiP `navigator.userActivation.isActive === true` в обоих окнах,
активация доезжает до опенера, вызов проходит, системный пикер выбора
экрана открывается отдельным окном поверх всего, а не прячется за
заглушкой основного окна.

Опции захвата (`SCREEN_SHARE_CAPTURE_OPTIONS`) вынесены из `RoomToolbar` в
`lib/screenShareOptions.ts`: кнопок демонстрации теперь две, и разойдись
они хотя бы в `audio`, демонстрация получалась бы разной в зависимости от
того, откуда её запустили.

Фокус в мини-окне не менялся: своя демонстрация показывается по тем же
правилам `pickStageFocus`, что и в основном окне.

В Safari мини-окно — нативный video-PiP без собственного DOM, кнопке там
негде жить; в Firefox мини-окна нет вовсе. Это ограничение платформы.
2026-08-04 21:13:27 +03:00
aee76329c4 release: версия 0.0.28 2026-08-04 18:29:22 +03:00
10a3f8b3b4 feat(room): очередь поднятых рук видна всем + отключаемый модуль
Раньше HandQueueMenu.tsx рендерился только организатору — теперь очередь
видит любой участник, но опустить чужую руку по-прежнему может только
организатор (сервер это уже проверял, менял только фронт). Кнопка
«Опустить» показывается у записи, только если это своя рука или
пользователь — организатор.

Модуль «поднятие руки» (кнопка «Рука» + очередь целиком) — отключаемый
в админке (instance_settings.hand_queue, дефолт enabled=true, как у
chat_enabled). Настройка едет участнику в JoinOut ещё до входа в
комнату; выключенный модуль гасит кнопки и на фронте, и на бэке —
raise_hand/lower_hand отклоняются кодом hand_queue_disabled, если
модуль выключен, даже если у клиента на руках старый JoinOut.
2026-08-04 18:27:14 +03:00
65fbcf952c release: версия 0.0.27
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-04 02:52:49 +03:00
b44652d6a6 fix(room): разрыв связи выбрасывал участника в лобби вместо возврата в конференцию
Телефон с погасшим экраном (и просто свёрнутый браузер) выпадал из
конференции: Chrome срезает фоновой вкладке ресурсы, ICE перестаёт
отвечать, и LiveKit закрывает участника через 5 с после потери
соединения. Замерено на проде: 37 с после блокировки экрана, 23 с
после сворачивания браузера. Восстановить сессию после этого нельзя
(сервер отвечает "could not restart participant") — нужен полный
повторный вход, и livekit-client его пытается сделать сам, но его
бюджет повторов в фоновой вкладке успевает сгореть. Тогда приходило
событие Disconnected, и страница уводила пользователя в лобби.

Теперь непреднамеренный разрыв не уводит со страницы, а сбрасывает
joinState — дальше работает уже написанный путь авто-перезахода:
резолв конференции, свежий токен, вход заново. Намеренный выход
отличается по флагу от кнопки "Выйти", а не по коду причины: причина
CLIENT_INITIATED приходит и от кнопки, и от самого livekit-client,
который при заморозке вкладки (событие freeze) вызывает disconnect()
сам — и эта его подписка не отключается опцией disconnectOnPageLeave.

Разрывы, после которых возвращаться нельзя (выгнал организатор,
конференция закрыта, вход той же личностью с другого устройства),
уводят в лобби как раньше. От бесконечного цикла "вошёл — сразу
выбросило" защищает лимит в 5 перезаходов подряд; соединение,
прожившее дольше 30 с, счётчик обнуляет.
2026-08-04 02:52:22 +03:00
d11b808e97 release: версия 0.0.26
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-04 00:01:22 +03:00
ed6f9fff44 feat(room): полноэкранный режим на мобильном с выезжающим тулбаром
Вход — кнопка «Экран» в тулбаре, теперь видна и на мобильном (по аналогии
с десктопом, а не спрятана в шторку настроек). В полноэкранном режиме
топбар и тулбар выходят из потока и лежат оверлеем поверх сцены: тап/клик
по сцене вне элементов управления показывает их, сами прячутся через
несколько секунд бездействия; на десктопе дополнительно — наведение мыши
в нижнюю полосу экрана. Работает одинаково на мобильном и десктопе.

Кнопка настроек устройств в тулбаре на мобильном подписана «Настройки»
вместо «Устройства». Раскладка кнопок мобильного тулбара, когда они не
помещаются в один ряд (7+, обычный случай с «Экраном» и «Очередью» у
организатора), стала равномерной сеткой на 4 колонки вместо переноса
«как получится» через flex-wrap.
2026-08-04 00:00:45 +03:00
06455f2401 fix(room): шторка настроек не закрывалась свайпом вниз
Обработчики висели только на ручке `.room-sheet-handle` (40×4px) —
попасть в неё пальцем практически невозможно, и палец почти всегда
приземлялся на панель, где обработчиков не было вовсе. Свайп теперь
закрывает шторку при жесте по любому месту панели, но только когда
содержимое проскроллено в самый верх — иначе палец должен листать
список устройств, как в любом стандартном bottom sheet.
2026-08-03 23:59:26 +03:00
3847476798 chore(deploy): render-templates.sh умеет читать другой файл значений
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Путь к файлу со значениями был жёстко зашит как `<корень>/.env`. На
машине разработчика корневой `.env` держит боевые адреса
(LIVEKIT_NODE_IP/TURN_EXTERNAL_IP смотрят на прод), поэтому рендерить из
него конфиги для локального стенда нельзя, а подменить нечем — локальные
сессии дважды повторяли логику скрипта вручную через envsubst, что
означало расхождение с реальным рендером при первой же правке шаблонов.

Теперь источник значений задаётся переменной ENV_FILE:

    ENV_FILE=.env.local ./deploy/render-templates.sh

Поведение по умолчанию не меняется — тот же корневой `.env`. install.sh
свою переменную ENV_FILE не экспортирует, так что она сюда не протекает;
вызов из install.sh и рендер на сервере работают как раньше. Скрипт
дополнительно печатает, из какого файла взяты значения, и подсказывает
про ENV_FILE, если файл не найден.
2026-08-03 18:45:20 +03:00
a895782250 release: версия 0.0.25
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-03 18:24:13 +03:00
fa8270c156 fix(room): мини-окно игнорировало закрепление, а демонстрация слетала от реплики
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Мини-плеер намеренно вёл себя иначе, чем основное окно: без удержания
демонстрации экрана (holdScreenShare), без приоритета говорящего с
включённой камерой, без антидребезга говорящего и с собственным чистым
useState для закрепления. На практике это читалось как поломка —
закрепление, сделанное в основном окне, в мини-окне не действовало, а
демонстрация экрана пропадала, стоило кому-то сказать слово.

Теперь pickStageFocus получает одинаковые правила в обоих вариантах
сцены. Единственное сознательное отличие — localKey («показать себя»
последним фолбэком), он остаётся только у мини-плеера: это защита от
дефекта 0.0.11, когда мини-окно открывалось на самом пользователе.

Закрепление переезжает между окнами тем же мостиком через RoomPage,
что и фокус (initialPinnedKey/onPinnedKeyChange). Отдельный общий
источник правды не нужен: экземпляр сцены в каждый момент ровно один —
пока открыт Document PiP, основное окно показывает заглушку.

Заодно в снятии закрепления «участник вышел из комнаты» добавлена
охрана tracksKnown. На первом рендере нового экземпляра сцены useTracks
отдаёт пустой массив, и пустой набор читался как «все вышли»: приехавшее
через initialPinnedKey закрепление обнулялось прямо при монтировании,
то есть мини-плеер терял его каждый раз.

Надпись на булавке — «Закрепить» вместо «Закрепить в основном окне»:
закрепление больше не ограничено основным окном.
2026-08-03 18:21:43 +03:00
a9e24f6692 fix(room): в Chrome пропадал звук после возврата из мини-окна
RoomAudioRenderer жил внутри RoomStage и рендерился дважды — в ветке
variant="pip" и в основной. При открытии Document PiP сцена
размонтируется в основном окне и монтируется в PiP-окне, поэтому
скрытые <audio> с чужими аудиотреками физически переезжали в ДРУГОЙ
документ, а при возврате — обратно. После такого переезда Chrome
теряет аудиовыход у remote-трека: пакеты продолжают приходить
(packetsReceived растёт), но totalSamplesDuration и totalAudioEnergy
замирают, и трек молчит даже в свежесозданном <audio> со свежим
MediaStream. Тот же цикл detach/attach в пределах одного документа
безвреден — дело именно в переезде между документами. В Safari бага не
было: там Document PiP не используется (video-фолбэк), сцена остаётся
в основном окне.

Рендерер вынесен в RoomPage — один экземпляр, всегда в основном
документе. В PiP-окне аудиоэлементов теперь нет вовсе, цикл «открыл
мини-окно → вернул» их не касается.
2026-08-03 18:19:19 +03:00
8b63c24332 release: версия 0.0.24
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-03 01:58:32 +03:00
cd5399f88a fix(auth): заголовок вылезал за брендовую панель на широком окне
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Кегль «Ваша инфраструктура» уменьшался только в @media по ширине ОКНА —
не спасало, когда окно широкое, а панель (42% от него, `.layout` flex
42/58) сама по себе узкая: слово не помещалось, overflow:hidden обрезал
его вместо переноса. Перешли на container query (`container-type:
inline-size` на .brand-panel + `cqw` на .brand-headline) — тот же приём,
что у аватара участника в комнате (container-type:size + cqmin,
room.css). Кегль теперь считается от реальной ширины панели, а не окна,
в любой раскладке.

Тем же механизмом клипались плашки .brand-stats («AI-саммари») — добавлен
flex-wrap в базовое правило вместо только-мобильного.
2026-08-03 01:56:42 +03:00
2be799b19d fix(room): эмодзи-поповер в чате — 5-я колонка вылезала за рамку
Настоящая причина (подтверждена вживую через getBoundingClientRect):
`.chat-input-row button` (стиль круглой кнопки «Отправить», width/height
42px) красил размер и кнопкам эмодзи внутри поповера — они тоже лежат в
.chat-input-row. Сетка на 5 колонок по 42px требовала ~238px при
фиксированной ширине попапа 220px, лишнее уезжало вправо за рамку.
Явные width/height: auto на .chat-emoji-option перебивают унаследованный
размер нужной специфичностью, дальше квадрат считает aspect-ratio от
реальной ширины колонки (~36px). Заодно — max-width на попап и
overflow:hidden/white-space:nowrap на кнопки как общая страховка от
переполнения на очень узких экранах.
2026-08-03 01:56:27 +03:00
6b9d0b833e fix(room): чат вылезает за панель в Firefox — min-width/min-height:0
`.chat-messages` (flex-колонка) без явного min-height:0 не сжимается до
flex:1 в Firefox и растёт по контенту списка сообщений. Textarea в
.chat-input-row без min-width:0 упирается в автоматическую минимальную
ширину, которую Firefox считает от атрибута cols (умолчание 20 символов) —
жёстче, чем Chrome/Safari.
2026-08-03 01:56:03 +03:00
f4e8f91839 release: версия 0.0.23
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-02 22:29:34 +03:00
fb50c5d8ea docs(deploy): таблица «профиль нагрузки → железо» на реальных замерах с прода
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Формула CPU/RAM/полосы для медиа-нагрузки (не AI): подписки = камер ×
(участников − 1), подтверждено точно двумя боевыми замерами (28.07 и
31.07.2026). Коэффициенты полосы на подписку и CPU на ядро — из тех же
замеров, с явными допущениями и предупреждением не путать пиковый трафик
с устойчивым. Профили — малая команда/совещание/большое собрание/
смешанная нагрузка, включая пример недостижимого профиля и как его
спасают лимит плиток и потолок качества публикации (0.0.21).

Перекрёстные ссылки из README, hardware-profiles.md, capacity.md.
2026-08-02 22:28:55 +03:00
826a0a391a fix(coturn): verbose-логирование — иначе ALLOCATE/CreatePermission не видны в docker logs
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Дефолтный simple-log в coturn не пишет построчно ALLOCATE/CreatePermission/
Refresh даже при реально работающем relay (найдено на релизе 0.0.22:
рабочий звонок через TURN, но grep -ci allocate по логам coturn = 0,
подтверждение пришлось брать из логов LiveKit). Добавлен флаг verbose
(умеренный режим, не Verbose/-V — тот слишком шумный). DEPLOYMENT.md
предупреждает, что проверка через grep -ci allocate надёжна только с
этим флагом.
2026-08-02 22:11:32 +03:00
4f5f336cd6 release: версия 0.0.22
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-02 20:27:36 +03:00
0441f4f72b docs(deploy): описать включение TURN over TLS, обновление сертификата, порты
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Раздел 8: как включить TURN_TLS_HOST, почему 443 недоступен coturn без
SNI-мультиплексора (порт уже занят nginx через Docker port-publish),
пошаговая проверка (openssl s_client, аллокации в логах). Раздел 5:
deploy-hook certbot теперь перекопирует сертификат в coturn-certs-init и
перезапускает coturn при продлении — без этого TLS-TURN тихо остановится
через ~60 дней со старым сертификатом.
2026-08-02 20:27:01 +03:00
9bc8d6174d feat(coturn): TURN over TLS на 5349 — сертификаты, монтирование, анонс клиентам
coturn (nobody:nogroup, без root-фазы в entrypoint) не может сам прочитать
приватный ключ Let's Encrypt — coturn-certs-init (по образцу
recordings-init/llm-models-init) копирует fullchain/privkey в отдельный
volume под правами 644, не трогая права на ключ на хосте.

TURN_TLS_HOST в .env — единственный переключатель фичи: пусто (dev-дефолт)
вырезает TLS-блоки из turnserver.conf и rtc.turn_servers целиком (маркеры
BEGIN/END-TLS-* в *.template, render-templates.sh), непустое значение
включает оба сразу — TLS без анонса LiveKit клиентам не имеет смысла
(история 0.0.14: coturn работал healthy, но клиенты о нём не знали, и не
было ни одной аллокации). Значение обязано быть доменом сертификата, а не
IP — иначе браузер не пройдёт TLS-валидацию по имени хоста для turns:.

TLS-запись в rtc.turn_servers стоит последней в списке (фолбэк дороже
прямого UDP/TCP).
2026-08-02 20:26:56 +03:00
45c997380f release: версия 0.0.21
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-02 19:57:51 +03:00
173d384f06 feat(admin): рычаги нагрузки медиа — потолок качества публикации и лимит плиток
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
instance_settings.media_limits (publish_quality_cap: off/720p/360p/180p,
stage_max_tiles: 4/9/16/25) — новая вкладка «Нагрузка» в админке, дефолты
(off/25) сохраняют текущее поведение существующих инсталляций.

Настройка отдаётся не только GET /admin/settings, но и в join-ответе
(JoinOut) — участнику нужно иметь её на руках ДО публикации трека, а
/admin/settings доступен только администратору.

Потолок качества применяется через RoomOptions.publishDefaults
(videoEncoding + videoSimulcastLayers на пресетах VideoPresets LiveKit) —
режет битрейт верхнего слоя симулкаста, реальное разрешение WebRTC
подстраивает сам. Лимит плиток — фильтрация STAGE_GRID_LAYOUTS по
columns*rows в StageGrid, лишние участники уходят на страницу пагинации
вместо подписки.

Значение приезжает в joinState вместе с токеном ДО первого рендера
LiveKitRoom (RoomPage не рендерит его, пока joinState не заполнен целиком),
поэтому смена настройки не переподключает уже вошедшего участника —
roomOptions пересчитывается по стабильной ссылке на joinState, которая
после подключения не меняется.

Проверено вживую на локальном стенде (docker compose --profile media):
сохранение/персист настроек, join отдаёт актуальные значения, уже
подключённый участник не разрывается при смене настройки в другом окне.
2026-08-02 19:53:23 +03:00
7b2427535a release: версия 0.0.20
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-02 12:34:04 +03:00
8e45038251 fix(livekit): один UDP-порт вместо диапазона на 101 порт
Диапазон 54000-54100/udp заставлял Docker поднимать по отдельному
docker-proxy на каждый порт — весь медиатрафик шёл лишним userland-хопом.
rtc.udp_port переключает LiveKit на мультиплексирование ICE-сессий через
один порт; TURN и остальные связи (redis/nginx/webhook/prometheus) не
задеты. Проверено локально lk load-test — 0% потерь пакетов, ICE во всех
сессиях выбирает новый порт.
2026-08-02 12:33:53 +03:00
286c01d93b release: версия 0.0.19
Some checks failed
CI / frontend (push) Has been cancelled
CI / backend (push) Has been cancelled
2026-08-02 05:03:05 +03:00
e8af2fce10 fix(room): адаптивный тулбар — плавное сжатие кнопок вместо оверфлоу
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Задачи B1/B2 добавили в тулбар «Рука» и «Очередь» (до 11 кнопок вместо
исходных 8) — на промежуточных ширинах (~600–1100px) кнопки вылезали за
края тулбара: жёсткого мобильного брейкпоинта (≤600px, скрывает три
кнопки) не хватало, а до него сжатия не было вовсе.

Иконка/отступы/зазоры/шрифт кнопок теперь плавно уменьшаются на диапазоне
1200px → 600px через `clamp()` с явной линейной интерполяцией (обычный
`clamp(min, Nvw, max)` не подошёл — подобранный N упирался в потолок уже на
довольно широких экранах, сжатие получалось резким скачком заметно раньше
нужной ширины, а не плавным). Нижние границы совпадают с тем, что жёстко
выставляет мобильный медиа-запрос на 600px — переход в него визуально
бесшовный.

Побочный эффект сжатия: подпись «Мини-окно» на промежуточных ширинах
переносилась на 2 строки и делала эту кнопку выше соседних. Ниже 1200px
показываем короткое «Мини» вместо полной подписи — кнопка остаётся
однострочной; aria-label по-прежнему несёт полный смысл для скринридеров.
2026-08-02 05:00:22 +03:00
d7ae462ed5 fix(room): очередь поднятых рук — компактный поповер вместо панели во весь экран
HandQueuePanel рендерился как боковая панель на весь экран на мобильном
(.chat-panel, унаследовано от чата) — для короткого списка поднятых рук это
избыточно, история переписки тут ни при чём. Переделано в HandQueueMenu —
тот же самодостаточный поповер над кнопкой, что и «Вид» (StageViewMenu):
своё состояние открытия, закрытие по клику вне/Escape, .tb-menu вместо
боковой панели. Кнопка «Очередь» больше не получает open-состояние
пропсами сверху — сама решает, открыта ли, и сама проверяет
useIsOrganizer().

Список внутри растёт вместе с очередью (компактно при 1–2 поднятых руках),
но не бесконечно — после ~10 строк упирается в max-height и скроллится
дальше.
2026-08-02 04:58:56 +03:00
826b7639b1 fix(chat): специфичность CSS эмодзи-поповера + сетка 6×5
Кнопка эмодзи и все кнопки внутри поповера лежат в DOM внутри
.chat-input-row (форма отправки) — той же формы, что и круглая зелёная
кнопка «Отправить» (.chat-input-row button, специфичность 0,1,1). Голого
класса .chat-emoji-trigger/.chat-emoji-option (0,1,0) для победы над этим
правилом не хватало: все кнопки поповера красились в зелёный независимо от
порядка объявления в файле — оттуда и «поплывший» вид после добавления
коня, дело было не в самом коне. Каждый селектор уточнён родительским
классом (.chat-emoji-wrap/.chat-emoji-popover), чтобы обойти гонку
специфичности.

Заодно сетка приведена к ровным 5 колонкам × 6 строкам — добавлены
🦾 🚀 🦞 💯 🤷‍♂️, теперь 30 эмодзи без неполной последней строки.
2026-08-02 04:57:39 +03:00
daaa480f03 release: версия 0.0.18
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-01 23:42:53 +03:00
c60047c594 fix(conferences): rate limit блокировал вход всей конференции сразу
Два бага в одном месте, оба вскрылись на нагрузочном тесте 31.07.2026.

1. Ключ лимита строился по `request.client.host`. Backend стоит за nginx,
   поэтому это адрес КОНТЕЙНЕРА NGINX, одинаковый для всех пользователей.
   Проверено на проде: в Redis лежал единственный ключ
   `rate_limit:resolve:172.18.0.13`. То есть лимит «10 запросов в минуту»
   действовал на весь инстанс разом, а не на клиента.

2. Считались все запросы подряд, включая успешные. Одиннадцатый человек,
   открывший ссылку на конференцию в течение минуты, получал 429 — и видел
   «Не удалось найти конференцию» для существующей и активной конференции.
   Люди попадали внутрь с пятой-десятой попытки, попадая в новое окно.

Что изменилось:
- адрес клиента берётся из `X-Real-IP` (nginx его уже передаёт). Именно
  `X-Real-IP`, а не первый элемент `X-Forwarded-For`: последний заполняется
  через `$proxy_add_x_forwarded_for`, то есть дописывается к присланному
  клиентом, и лимит обходился бы одним заголовком;
- жёсткий счётчик (10/мин, как было) теперь считает только ПРОМАХИ:
  конференция не найдена или пароль неверен. Именно так выглядит перебор
  номера, от которого лимит и защищает по ADR-001, п.4;
- на общий поток с адреса оставлен мягкий потолок 300/мин — против тупого
  флуда. Офис за общим NAT это один адрес, поэтому потолок заведомо выше
  правдоподобного числа участников одной конференции.

Тесты: успешные резолвы и гостевые входы не упираются в лимит (50 и 30
подряд); перебор номера, несуществующий идентификатор и подбор пароля
по-прежнему упираются; лимит одного клиента не задевает другого.
2026-08-01 23:42:36 +03:00
f39c21e7e1 release: версия 0.0.17
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-01 23:20:31 +03:00
84b7f807f7 fix(auth): проверка пароля больше не блокирует весь backend
На нагрузочном тесте 31.07.2026 около 70 человек заходили одновременно.
Вход развалился: p95 `/api/v1/auth/token` — 7.28 с, p95 `guest-join` —
7.06 с, в БД 33 соединения `idle in transaction` при ОДНОМ активном
запросе. Люди попадали внутрь с пятой-десятой попытки, часть не попала
вовсе. Медиа при этом работало штатно: 30 участников с 27 камерами в
следующем окне прошли без единого лага.

Причина — argon2 считался синхронно внутри async-обработчика. Замер на
боевом сервере: 95–155 мс на одну проверку, и всё это время event loop
процесса стоит целиком. Транзакция БД к тому моменту уже открыта
(`get_by_email` сделал SELECT), поэтому соединение висело без работы, пул
из 40 выбирался, и отказы получали совершенно посторонние ручки — включая
вход в конференцию, где никакого пароля не проверялось.

Что изменилось:
- `hash_password`/`verify_password` стали асинхронными и считаются в пуле
  потоков (`asyncio.to_thread`). argon2-cffi освобождает GIL, поэтому
  проверки идут по-настоящему параллельно;
- параметры argon2id заменены с дефолтов библиотеки (t=3, m=64 МБ, p=4) на
  рекомендацию OWASP (t=2, m=19 МБ, p=1): 95 мс → 42 мс. Отдельно важен
  `parallelism`: при p=4 одна проверка пароля занимала все четыре ядра
  сервера — те же, на которых работает LiveKit;
- добавлен `needs_rehash`: существующие хэши проверяются как прежде
  (параметры зашиты в саму строку) и лениво перевыпускаются при первом
  успешном входе.

Расчёт по замерам: пачка из 70 логинов — 6.7–10.9 с блокировки против
~0.36 с без неё.

Тесты: event loop продолжает тикать во время проверки; 8 параллельных
проверок укладываются заметно быстрее восьми последовательных; хэш со
старыми параметрами принимается и перевыпускается при входе.
2026-08-01 23:19:52 +03:00
e5c596f2bf release: версия 0.0.16
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
2026-08-01 22:08:34 +03:00
4c60e092e5 feat(room): принудительный мьют участника организатором
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
Новый эндпоинт POST /conferences/{id}/mute-participant: права проверяются
ЗАНОВО по владельцу конференции в БД (ConferenceService.mute_participant),
не по метаданным LiveKit-токена вызывающего — те лишь подсказка для UI и
потенциально подделываемы клиентом. Обычный участник получает 403, чужая/
несуществующая конференция — 404, участник не в комнате LiveKit — отдельный
404 (participant_not_in_room).

Само выключение — серверный вызов api.LiveKitAPI (services/room_control.py,
тот же паттерн, что services/egress.py): backend аутентифицируется
СОБСТВЕННЫМИ api_key/api_secret, а не токеном организатора, поэтому
дополнительный LiveKit-грант в токене организатора не нужен — мьютит сервер
от своего имени. Если трек данного source не опубликован (с 0.0.15 участники
заходят с выключенными микрофоном/камерой) — не ошибка, а no-op: искомое
состояние уже достигнуто, ответ muted:false.

Уведомление участника — тот же общий канал комнаты, что и очередь рук
(hand_queue_channel): рассылается всем, получатель сам сверяет identity
(ForcedMuteWatcher, рендерится внутри LiveKitRoom). Само выключение трека
участник видит сразу через штатный useTrackToggle (LiveKit сам присылает
TrackMuted), тост только поясняет причину — иначе не отличить от глюка.
Включить себя обратно можно сразу тем же тулбаром, сервер это не блокирует.

Кнопки — на чужой плитке камеры, видны только организатору по наведению
(на тач-устройствах — всегда, как и булавка закрепления).

Тесты: владелец мьютит успешно и публикует broadcast, уже-выключенный трек —
muted:false без broadcast, администратор мьютит чужую конференцию, обычный
участник получает 403 без обращения к LiveKit, конференция не найдена и
участник не в комнате — соответствующие 404.
2026-08-01 22:07:06 +03:00
8e5eda88a2 feat(room): поднятие руки и очередь для организатора
Транспорт — существующий аутентифицированный WS чата (api/chat.py), а не
отдельный эндпоинт: сервер уже держит это соединение на каждого участника
(обоснование — докстринг chat_websocket и useChat.ts). Состояние очереди —
Redis (services/hand_queue.py), не Postgres: это эфемерное состояние звонка,
а не история, и два процесса uvicorn делают наивную память одного процесса
недостаточной. HSETNX даёт идемпотентное «поднять» (повторный клик не
переставляет в конец очереди), снапшот шлётся всем участникам при любом
изменении — организатор, зашедший позже, сразу видит актуальную картину.

Опустить чужую руку может организатор (решение оператора) — проверка через
conference.owner_id, не через identity клиента. Участник, вышедший из
комнаты LiveKit (webhook participant_left), теряет место в очереди
автоматически; переподключение WS чата место не сбрасывает (Redis не привязан
к жизни соединения). room_finished чистит очередь целиком — она не должна
пережить завершение звонка.

Побочный эффект транспортного решения: поднять руку нельзя, если чат выключен
настройкой инстанса (WS вообще не открывается) — принятый компромисс ради
переиспользования уже готового канала.

UI: кнопка «Рука» в тулбаре (у всех, бейдж — общий счётчик), бейдж на плитке
говорящего (видно всем), панель «Очередь» организатору (HandQueuePanel).
Кнопка «Рука» и панель «Очередь» намеренно НЕ прячутся в мобильную шторку
настроек, в отличие от «Вида», — поднятие руки посреди разговора требует
кнопки под рукой, а не в два клика вглубь настроек.

Этим же коммитом (файлы разделяемые с задачей B2, RoomParticipantTile.tsx/
useChat.ts/RoomStage.tsx/RoomPage.tsx/room.css) — проброс conferenceId и
каркас forced_mute-обработки, без которых кнопки принудительного мьюта не
скомпилировались бы; сама реализация мьюта — следующим коммитом.
2026-08-01 22:06:43 +03:00
42bfb88a22 feat(room): роль организатора в метаданных LiveKit-токена
Предварительная работа для очереди рук и принудительного мьюта (B1/B2):
build_join кладёт is_organizer:true в метаданные токена организатора
(создатель мгновенной конференции и владелец при обычном входе). Метаданные
токена — только подсказка для UI (см. предупреждение в докстринге
build_join), любое серверное действие организатора обязано перепроверяться
по conference.owner_id в БД — так и сделано в mute_participant (B2).

На фронте — общий парсер метаданных участника (lib/participantMetadata.ts,
переиспользован в RoomParticipantTile вместо локальной копии) и хук
useIsOrganizer (читает подсказку для локального участника через
useLocalParticipant — вызывается только внутри LiveKitRoom).
2026-08-01 22:06:10 +03:00
07dc1aaeef feat(chat): выбор эмодзи скачущего коня
Добавлен 🐎 в набор EMOJI_OPTIONS по просьбе оператора.
2026-08-01 22:04:44 +03:00
134 changed files with 9342 additions and 436 deletions

View File

@@ -59,6 +59,16 @@ TURN_STATIC_AUTH_SECRET=change-me-turn-secret
# скриптом deploy/render-templates.sh (вызывается install.sh). # скриптом deploy/render-templates.sh (вызывается install.sh).
TURN_EXTERNAL_IP=127.0.0.1 TURN_EXTERNAL_IP=127.0.0.1
# TURN over TLS (5349) — единственный переключатель во всём проекте: пусто =
# TLS выключен везде (dev-дефолт, как ниже), непустое значение = coturn
# слушает TLS на 5349 (сертификат смонтирован из /etc/letsencrypt через
# coturn-certs-init, docker-compose.yml) И LiveKit объявляет клиентам запись
# protocol: tls. ⚠️ Обязан быть ДОМЕНОМ сертификата (например, vidconf.ru —
# тем же, что и NGINX_CERT_NAME), а НЕ IP-адресом, в отличие от
# TURN_EXTERNAL_IP выше: браузер проверяет TLS-сертификат TURN-сервера по
# имени хоста, а Let's Encrypt выписывает сертификат на домен.
TURN_TLS_HOST=
# --- Nginx: TLS (443) + список доменов — deploy/nginx/nginx.conf.template --- # --- Nginx: TLS (443) + список доменов — deploy/nginx/nginx.conf.template ---
# Домены, которые обслуживает nginx (через пробел, все — в server_name). # Домены, которые обслуживает nginx (через пробел, все — в server_name).
NGINX_SERVER_NAMES=example.com www.example.com NGINX_SERVER_NAMES=example.com www.example.com
@@ -94,6 +104,12 @@ UVICORN_WORKERS=2
DB_POOL_SIZE=10 DB_POOL_SIZE=10
DB_MAX_OVERFLOW=10 DB_MAX_OVERFLOW=10
DB_POOL_TIMEOUT=10 DB_POOL_TIMEOUT=10
# Пул соединений с Redis НА КАЖДЫЙ воркер. Считается по УЧАСТНИКАМ, а не по
# запросам: WS-подключение комнаты держит собственную pub/sub-подписку всё
# время, пока человек в конференции. Дефолт redis-py (100) упирался в потолок
# примерно на сотом одновременном участнике на воркер. Сверху ограничивает
# maxclients самого Redis (по умолчанию 10000) — на все процессы разом.
REDIS_MAX_CONNECTIONS=500
# --- Email (рассылка саммари + .ics-приглашения) --- # --- Email (рассылка саммари + .ics-приглашения) ---
# `console` — дефолт для dev (письмо только логируется, ссылка подтверждения # `console` — дефолт для dev (письмо только логируется, ссылка подтверждения
@@ -112,7 +128,7 @@ SMTP_TIMEOUT_S=30
# --- Версия инстанса (релиз v0.0.1) --- # --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из корневого файла VERSION при каждой # install.sh копирует значение из корневого файла VERSION при каждой
# установке/обновлении — руками менять не нужно. # установке/обновлении — руками менять не нужно.
VIDCONF_VERSION=0.0.15 VIDCONF_VERSION=0.0.36
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного # --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг, # `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,

8
.gitignore vendored
View File

@@ -54,3 +54,11 @@ backend/media/
# Локальные конфиги инструментов сессий (launch.json dev-сервера и т.п.) — # Локальные конфиги инструментов сессий (launch.json dev-сервера и т.п.) —
# привязаны к конкретной машине, в репозиторий не идут. # привязаны к конкретной машине, в репозиторий не идут.
.claude/ .claude/
# Wasm-рантайм MediaPipe — копируется из node_modules плагином `mediapipeWasm`
# (frontend/vite.config.ts), ~19 МБ, в репозитории ему не место.
frontend/public/mediapipe/wasm/
# Рабочая страница замера CPU (сессия 35), в репозиторий не идёт.
frontend/bg-bench.html
frontend/bg-support.html

View File

@@ -3,6 +3,555 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/), Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
проект придерживается [семантического версионирования](https://semver.org/lang/ru/). проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
## [0.0.36] — 2026-08-10
Исправление к 0.0.35: замена фона не появлялась в Safari и Firefox.
### Исправлено
- Проверка поддержки замены фона была строже, чем требует библиотека, и
молча отрезала два браузера — кнопки выбора фона не было ни в превью, ни
в комнате. У библиотеки два конвейера обработки кадров: современный
(только Chrome и производные) и запасной через `canvas.captureStream()`,
который работает и в Safari, и в Firefox; проверка требовала именно
современный. Теперь условие буквально повторяет проверку самой библиотеки.
## [0.0.35] — 2026-08-10
Замена фона видео на картинку — отключаемый модуль (по умолчанию выключен).
Три готовые сцены и свои картинки (до 10 штук, загружаются в профиле).
**Только на компьютере:** сегментацию силуэта считает нейросеть на каждом
кадре, и на телефоне это греет устройство, ест батарею и просаживает FPS
в самой встрече — портить основное ради украшения нельзя.
### Добавлено
- Кнопка «Фон» в тулбаре комнаты и выбор фона в превью на входе. Выбранный
до входа фон применяется к публикуемому треку сразу в конференции.
- Три дефолтные сцены (офис, пляж с пальмами, космическая станция) —
собственные векторные рисунки (`design/backgrounds/`), а не фотографии
из интернета: у нарисованной сцены нет чужой лицензии, а продукт
расходится по инсталляциям, и проверять права на каждую копию некому.
- Раздел «Свои фоны» в профиле: загрузка (не больше 10 штук) и удаление.
Картинка уменьшается до 1280px по длинной стороне и переводится в WebP
прямо в браузере перед отправкой — «чтобы не грузили БД». Файлы лежат
на диске, в БД только пути, как у аватаров.
- Тумблер «Замена фона видео» в админке. Лимит на число картинок и формат
файла проверяются на сервере, а не только в интерфейсе.
### Особенности реализации
- **Модель сегментации и wasm-рантайм отдаются со своего домена.** По
умолчанию библиотека тянет их с внешних CDN (jsdelivr и
storage.googleapis.com), и в закрытом контуре фича молча не работала бы.
Модель (Apache 2.0) лежит в репозитории, рантайм копируется из
node_modules при сборке; nginx отдаёт их сжатыми и с долгим кэшем.
- Библиотека и модель грузятся **лениво**, только когда фон реально
включают: вход в конференцию не стал медленнее, основной бандл не вырос.
- «Десктоп» определяется по возможностям устройства (точный указатель +
hover + число точек касания), а НЕ по ширине окна: узкое окно на
десктопе — всё ещё десктоп, а широкий планшет — всё ещё планшет.
На мобильном кнопки нет вовсе, а не задизейбленной.
- Фон переживает выключение и включение камеры и смену устройства съёмки.
Удаление применённой сейчас картинки сбрасывает выбор на «без фона».
## [0.0.34] — 2026-08-09
Правка к проверке устройств на входе (0.0.33): превью и кнопки
камеры/микрофона теперь и на шаге «Подключиться к конференции», не только
на карточке «Как вас зовут?». Закрывает реальный пробел — авторизованный
пользователь, входящий через `/join`, карточку «Как вас зовут?» не проходит
вовсе (сразу подключение) и раньше проверку устройств не видел никогда.
### Исправлено
- Превью камеры и кнопки вкл/выкл — на шаге ввода ссылки/номера конференции,
тем же компонентом, что и на карточке гостя. Поток живёт непрерывно на
обоих шагах — переход между ними не гасит и не переоткрывает камеру.
- Состояние кнопок «войти с камерой/микрофоном» теперь доезжает до комнаты
и для гостя (`input → guest-info → комната`), и для авторизованного
пользователя (`input → connecting → комната`).
- Починена грабля с превью между шагами: `<video>` на разных шагах —
разные DOM-узлы, обычный `ref` не переживал переезд между ними (картинка
гасла в чёрный прямоугольник, хотя поток оставался жив). `videoRef` хука
стал callback-ref, переподключающим поток к новому узлу автоматически.
## [0.0.33] — 2026-08-09
Проверка устройств на входе — отключаемый модуль (по умолчанию выключен).
Раньше при первом включении микрофона/камеры уже внутри конференции у
участника всплывал системный диалог разрешения браузера; теперь его можно
пройти заранее, на странице логина и в карточке «Как вас зовут?» при
гостевом входе, вместе с живым превью камеры.
### Добавлено
- Запрос доступа к камере и микрофону на `LoginPage` и в карточке
гостевого входа `JoinPage` — привязан к первому жесту на карточке
(клик/тап/клавиша), не спрашивается повторно, если браузер уже помнит
разрешение (`navigator.permissions`, с фолбэком на жест там, где API
недоступен, например в Safari). Камера и микрофон запрашиваются
независимо — отказ в одном не блокирует другой.
- Живое зеркальное превью камеры на `JoinPage` (карточка «Как вас
зовут?») с кнопками-пиктограммами «микрофон»/«камера»: реально
останавливают и перезапускают поток, а не просто прячут картинку.
На `JoinPage` кнопки определяют, с чем гость войдёт в конференцию
(передаётся в `RoomPage`); на `LoginPage` — только пре-авторизация
разрешения без интерфейса (страница ведёт в лобби, а не в конкретную
конференцию, применить выбор там негде).
- Публичный `GET /api/v1/public/settings` — флаг модуля, нужный обеим
страницам до аутентификации.
- Тумблер «Проверка устройств на входе» в админке (Настройки → Модули).
### Изменено
- Отказ в доступе не блокирует ни вход в систему, ни в конференцию —
показывается понятная подсказка, дальше можно идти как раньше.
- Камера гарантированно освобождается при уходе с карточки, сабмите
формы, размонтировании и любой ошибке — включая явный `release()`
перед переходом в комнату, чтобы устройство не досталось LiveKit
«занятым».
## [0.0.32] — 2026-08-09
Метрики состояния БД и пулов соединений + алерты в Prometheus — по решению
оператора на отказ БД реагируем сигналом, а не автолечением (перезапуск
контейнера при недоступной БД оборвал бы WS у всех, кто в конференциях).
См. разбор инцидента 07.08.2026 (0.0.31): `/api/health` во время отказа
отдавал 200 с `db: false`, а Prometheus скрейпит `/metrics`, где метрик
состояния БД не было вообще — строить алерт было не на чем.
### Добавлено
- **`vidconf_db_up`** — доступность БД (1/0), проверяется отдельным от
основного пула соединением с коротким таймаутом. Позволяет отличить
«БД лежит» от «основной пул занят под нагрузкой» — это два разных
состояния, и до этого релиза их нечем было различить.
- **`vidconf_db_pool_size`/`_max_overflow`/`_checked_out`** — конфигурация
и занятость основного пула SQLAlchemy. Читаются синхронно из объекта
пула (`engine.pool`), без единого запроса к БД — это единственный
способ получить сигнал именно в момент, когда пул исчерпан.
- **`vidconf_redis_pool_in_use`/`_max_connections`** — занятость пула
Redis (второй потолок того же рода, закрыт в 0.0.31).
- Алерты `deploy/monitoring/alerts.yml` (группа `vidconf-db`):
`DatabaseUnavailable` (`vidconf_db_up == 0`, `for: 30s`, critical) и
`DbConnectionPoolNearExhaustion`/`RedisConnectionPoolNearExhaustion`
(занято > 80% дольше минуты, warning) — ранний сигнал: в инциденте
07.08 пул заполнялся постепенно по мере входа участников в комнату,
а не рывком от HTTP-нагрузки.
- Дашборд Grafana **«БД и пулы соединений»**
(`deploy/monitoring/grafana/dashboards/db-pool.json`).
### Технические детали
- `GET /metrics` больше не падает и не виснет при недоступности основного
пула БД: gauge'и о состоянии пула читаются первыми и не зависят от него
(отдельное NullPool-соединение для `db_up`, синхронный снимок для
занятости пула), а зависящий от основного пула `vidconf_pipeline_sessions`
обёрнут таймаутом (2с) — при недоступности оставляет прежнее значение,
не роняя остальные метрики. Полностью развести его с основным пулом не
стали: тестовый харнесс подменяет `get_session` на savepoint-сессию
(`tests/conftest.py`), отдельное соединение не увидело бы несознанные
тестом данные — тот же компромисс, что и в 0.0.31 для `api/chat.py`.
- Проверено вживую на локальном стенде (не только по синтаксису конфига):
остановка Postgres → `vidconf_db_up` = 0, `/metrics` продолжает отвечать,
алерт `DatabaseUnavailable` переходит в `firing`; временно урезанный
пул под нагрузкой → `DbConnectionPoolNearExhaustion` переходит в
`firing` ровно через заявленный `for: 1m`; снятие нагрузки/восстановление
БД — алерты гаснут.
## [0.0.31] — 2026-08-09
Разбор провала входа на нагрузочном тесте 07.08.2026: комната держала
соединения с БД и Redis на каждого участника.
### Исправлено
- **Вход в систему переставал работать, когда в конференции набиралось
около сорока человек.** WS-подключение комнаты (чат и очередь рук)
держало занятым одно соединение с БД всё время, пока участник сидел
в конференции: SELECT'ы хендшейка открывали транзакцию, а закрыть её
было некому. Пул — 20 соединений на воркер (40 на инстанс), поэтому
сороковой вошедший выгребал его досуха, и все остальные запросы —
резолв конференции, гостевой вход, логин, обновление токена — начинали
отвечать 500. Теперь соединение возвращается в пул сразу после
хендшейка; на локальном стенде 120 участников на одном воркере не
занимают ни одного соединения в простое (было: 20 из 20 при 20
участниках, дальше вход не работал вовсе).
- **Пользователя выкидывало из системы, когда серверу было плохо.**
Фоновое обновление access-токена считало неудачей любой отрицательный
ответ и на каждую такую неудачу сбрасывало сессию с переходом на
страницу входа. Ответ 5xx (и обрыв сети) теперь означает «сервер
временно недоступен»: сессия сохраняется, пользователь остаётся
в системе и получает обычную ошибку запроса. Разлогинивание осталось
только там, где backend прямо сказал, что сессия недействительна.
Восстановление сессии при старте приложения повторяет попытку трижды,
прежде чем показать страницу входа.
### Технические детали
- Размер пула соединений с Redis задан явно (`REDIS_MAX_CONNECTIONS`,
по умолчанию 500): на нём висят долгоживущие pub/sub-подписки комнаты —
по одной на участника, — а дефолт redis-py 8 (100) упирался в потолок
примерно на сотом участнике на воркер. Второй потолок того же рода,
что и пул БД; найден при проверке правки выше на 120 участниках.
- Размеры пулов БД (`DB_POOL_SIZE`/`DB_MAX_OVERFLOW`) не менялись
осознанно: соединение больше не удерживается впустую, поэтому
расширение пула лечило бы симптом и лишь отодвинуло порог.
## [0.0.30] — 2026-08-04
Согласие на обработку персональных данных при регистрации + отключаемый модуль.
### Добавлено
- На форме регистрации — галочка согласия на обработку персональных данных
со ссылкой на публичную страницу регламента (`/legal/personal-data-consent`).
Кнопка регистрации неактивна, пока галочка не отмечена; сервер тоже
отказывает без согласия (`POST /auth/register` → 400 `consent_required`,
второй эшелон проверки — тот же принцип, что у `hand_queue_disabled`).
- Текст регламента — настройка инстанса, редактируемая в админке
(вкладка «Настройки» → карточка «Согласие на обработку персональных
данных»): текстовое поле + тумблер «требовать согласие при регистрации».
Дефолтный текст — типовой шаблон с плейсхолдерами под организацию
(наименование оператора, адрес, контакты, цели и срок обработки),
**не проходил юридическую проверку** — в карточке администратора
об этом явное предупреждение.
- Номер редакции текста растёт автоматически при каждой правке —
у каждого пользователя, давшего согласие, в БД фиксируется и версия
документа, и дата согласия (`users.consent_version`, `consent_given_at`).
- Модуль отключаем (`instance_settings.consent_policy`), по умолчанию
выключен — поведение существующих инсталляций не меняется. У уже
зарегистрированных пользователей согласие не запрашивалось и не
запрашивается задним числом, вход не блокируется.
### Технические детали
- Миграция Alembic добавляет `users.consent_version`/`consent_given_at`
(nullable — `NULL` означает «согласие не запрашивалось»).
- Публичный `GET /auth/registration-options` (уже существующий, без нового
эндпоинта) дополнен полями `consent_required`/`consent_text`/`consent_version`
тем же ответом пользуется и страница регламента, доступная всегда,
независимо от того, включён ли модуль.
## [0.0.29] — 2026-08-04
Кнопка «демонстрация экрана» в мини-окне конференции.
### Добавлено
- В мини-окне (Document PiP) рядом с кнопкой микрофона появилась кнопка
демонстрации экрана: начать и остановить показ можно, не разворачивая
основное окно. Кнопка и кнопка основного тулбара отражают одно
состояние — обе читают его из комнаты, а не из разметки.
- Своя демонстрация показывается в мини-окне по обычным правилам сцены:
забирает крупную плитку при старте, держится, пока говорят другие, и
уступает говорящему либо закреплённому участнику после остановки.
### Примечания
- Кнопка есть только там, где мини-окно — настоящее окно со своей
разметкой, то есть в Chrome и Edge. В Safari мини-окно выводится
средствами системы (нативный «картинка в картинке»), собственных
кнопок в нём быть не может; в Firefox мини-окна нет вовсе. Это
ограничение браузеров, а не недоработка.
- При выборе «весь экран» мини-окно попадает в собственную
демонстрацию — как и у всех остальных участников. Чтобы этого
избежать, показывайте конкретное окно, а не экран целиком.
## [0.0.28] — 2026-08-04
Очередь поднятых рук видна всем участникам + отключаемый модуль.
### Добавлено
- Очередь поднятых рук больше не спрятана от обычных участников — её
видит любой, кто в конференции, не только организатор. Опустить чужую
руку по-прежнему может только организатор: сервер это уже проверял
(`api/chat.py`, задача B1), правки — только на фронте, кнопка
«Опустить» показывается у записи, если это своя рука либо пользователь
сам организатор.
- «Поднятие руки» (кнопка «Рука» + очередь целиком) — отключаемый модуль
в админке (`instance_settings.hand_queue`, дефолт `enabled=true`
поведение существующих инсталляций не меняется). Настройка едет
участнику в `JoinOut` (как `chat_enabled`) до входа в комнату.
Выключенный модуль гасит кнопки на фронте и отклоняет
`raise_hand`/`lower_hand` на сервере кодом `hand_queue_disabled`
вторая линия защиты для клиента со старым `JoinOut` на руках.
Переключение применяется со следующего входа в комнату (та же
застылость на время жизни соединения, что и у `chat_enabled`).
## [0.0.27] — 2026-08-04
Разрыв связи больше не выбрасывает участника из конференции.
### Исправлено
- Телефон с погасшим экраном выпадал из конференции, а при возвращении
мог оказаться в лобби вместо комнаты. Причина установлена по логам
прода: Chrome срезает ресурсы фоновой вкладке, телефон перестаёт
отвечать по ICE, и LiveKit закрывает участника через 5 с после потери
соединения (замерено: 37 с после блокировки экрана, 23 с после
сворачивания браузера — то есть достаточно просто убрать вкладку в
фон, гасить экран не обязательно). Восстановить сессию после этого
нельзя — участника на сервере уже нет; нужен полный повторный вход,
и `livekit-client` пытается сделать его сам, но его бюджет повторов
(10 попыток за ~44 с) в фоновой вкладке успевает сгореть. Тогда
приходило событие `Disconnected`, и страница комнаты уводила
пользователя в лобби.
Теперь непреднамеренный разрыв не уводит со страницы: сбрасывается
состояние входа, и работает уже имевшийся путь авто-перезахода —
резолв конференции, свежий токен, вход заново. Намеренный выход
отличается по нажатию кнопки «Выйти», а не по коду причины: причину
`CLIENT_INITIATED` присылает и кнопка, и сам `livekit-client`, который
при заморозке вкладки (событие `freeze`) вызывает `disconnect()`
самостоятельно. Разрывы, после которых возвращаться нельзя (участника
выгнал организатор, конференция закрыта, вход той же личностью с
другого устройства), уводят в лобби как раньше. От цикла «вошёл —
сразу выбросило» защищает лимит в 5 перезаходов подряд; соединение,
прожившее дольше 30 с, счётчик обнуляет.
## [0.0.26] — 2026-08-04
Мобильная комната: свайп шторки настроек и полноэкранный режим.
### Исправлено
- Шторка «Настройки» на мобильном не закрывалась свайпом вниз почти никогда:
обработчики висели только на ручке-волоске (`.room-sheet-handle`,
40×4px) — палец в неё практически невозможно попасть, и палец почти
всегда приземлялся на панель, где обработчиков не было вовсе. Теперь
свайп закрывает шторку при жесте по любому месту панели, но только
когда её содержимое проскроллено в самый верх (`scrollTop === 0` на
начало жеста) — иначе свайп вниз листает список устройств, как и должен
стандартный bottom sheet.
### Добавлено
- Полноэкранный режим комнаты — на мобильном кнопка «Экран» в тулбаре (как
на десктопе), при активации топбар и тулбар уходят из потока и лежат
оверлеем поверх сцены: показываются по тапу/клику по сцене вне элементов
управления и сами прячутся через несколько секунд бездействия. На
десктопе — та же логика (топбар/тулбар тоже прячутся в полноэкранном
режиме), дополнительный способ вернуть их — навести мышь в нижнюю полосу
экрана.
### Изменено
- Кнопка настроек устройств в тулбаре на мобильном подписана «Настройки»
вместо «Устройства».
- Раскладка кнопок мобильного тулбара, когда они не помещаются в один ряд
(7 и больше — с полноэкранным режимом и «Очередью» у организатора это
обычный случай), стала равномерной сеткой на 4 колонки (7 → 4+3,
8 → 4+4) вместо переноса «как получится» через `flex-wrap`.
- `deploy/render-templates.sh` умеет читать значения из файла, заданного
переменной `ENV_FILE`, а не только из корневого `.env` (см. коммит
`3847476`, вошёл в этот релиз) — для локальных стендов, где корневой
`.env` указывает на боевые адреса.
## [0.0.25] — 2026-08-03
Два дефекта мини-окна конференции (Document PiP), оба видны только в Chrome.
### Исправлено
- В Chrome пропадал звук других участников после возврата сцены из
мини-окна в основное. `RoomAudioRenderer` жил внутри `RoomStage` и
рендерился в обеих её ветках, поэтому скрытые `<audio>` с чужими
аудиотреками физически переезжали в документ PiP-окна и обратно.
После такого переезда Chrome теряет аудиовыход у remote-трека: пакеты
продолжают приходить (`packetsReceived` растёт), а
`totalSamplesDuration` и `totalAudioEnergy` замирают, и трек молчит
даже в свежесозданном `<audio>` со свежим `MediaStream`. Тот же цикл
detach/attach в пределах одного документа безвреден — дело именно в
переезде между документами. Рендерер вынесен в `RoomPage`: один
экземпляр, всегда в основном документе, в PiP-окне аудиоэлементов нет
вовсе. В Safari бага не было — там Document PiP не используется
(video-фолбэк), сцена из основного окна не уезжает.
- В мини-окне не действовало закрепление участника, а демонстрация
экрана слетала на говорящего от любой чужой реплики. Мини-плеер
намеренно ходил с упрощёнными правилами выбора фокуса (без
`holdScreenShare`, без приоритета говорящего с камерой, без
антидребезга) и с собственным локальным состоянием закрепления.
Теперь `pickStageFocus` получает одинаковые правила в обоих вариантах
сцены, а закрепление переезжает между окнами тем же мостиком через
`RoomPage`, что и фокус. Сознательно оставлено одно отличие —
фолбэк «показать себя» (`localKey`) только у мини-плеера: это защита
от дефекта 0.0.11, когда мини-окно открывалось на самом пользователе.
- Закрепление сбрасывалось при каждом монтировании сцены: на первом
рендере `useTracks` отдаёт пустой набор треков, и правило «закреплённый
вышел из комнаты» принимало это за уход участника.
### Изменено
- Кнопка-булавка на плитке подписана «Закрепить» вместо «Закрепить в
основном окне» — закрепление больше не ограничено основным окном.
## [0.0.24] — 2026-08-03
Три артефакта вёрстки, вылезающие за границы блоков (эмодзи-поповер чата,
заголовок брендовой панели, чат в Firefox) + аудит похожих мест.
### Исправлено
- Заголовок «Ваша инфраструктура» на странице входа вылезал за край
брендовой панели на широком окне с узкой панелью (`.layout` — flex
42/58) — старый фикс уменьшал кегль только по ширине ОКНА (`@media`),
а не панели. Кегль `.brand-headline` теперь считается через container
query (`container-type: inline-size` + `cqw`) — тот же приём, что у
аватара участника в комнате. Тем же механизмом обрезались плашки
статистики («AI-саммари») — `.brand-stats` теперь переносит их на
мобильном и десктопе одинаково.
- Эмодзи-поповер в чате комнаты: последняя (5-я) колонка вылезала за
правый край поповера. Причина — гонка CSS-специфичности: правило
круглой кнопки «Отправить» (`.chat-input-row button`, 42×42px)
продолжало красить размер и кнопкам эмодзи внутри поповера (та же
гонка чинилась для цвета в 0.0.19, но не для размера). Добавлены явные
`width`/`height: auto` нужной специфичности + `max-width` на попап как
общая страховка.
- Чат комнаты вылезал за границы панели в Firefox: `.chat-messages`
(flex-колонка) без `min-height: 0` не сжималась в Firefox, `textarea`
поля ввода без `min-width: 0` упиралась в автоматическую минимальную
ширину (Firefox считает её от атрибута `cols`, жёстче Chrome/Safari).
## [0.0.23] — 2026-08-02
Документация по сайзингу под медиа-нагрузку + видимость TURN-аллокаций в логах.
### Добавлено
- `docs/deploy/hardware-sizing.md` — таблица «профиль нагрузки → CPU/RAM/
полоса» для медиа (видеоконференции), на реальных боевых замерах
28.07 и 31.07.2026, с формулой для расчёта под свой сценарий.
### Исправлено
- coturn: включено verbose-логирование — дефолтный уровень не писал
построчно `ALLOCATE`/`CreatePermission`/`Refresh` даже при рабочем
relay-соединении (найдено на релизе 0.0.22 — звонок через TURN работал,
а `grep -ci allocate` по логам coturn показывал 0).
## [0.0.22] — 2026-08-02
TURN over TLS (5349) — для клиентов из сетей, где наружу открыт только 443.
### Добавлено
- coturn слушает TLS на `5349` (сертификат Let's Encrypt, обновляется
автоматически). Включается одним ключом `TURN_TLS_HOST` в `.env` (домен
сертификата, НЕ IP) — пусто оставляет прежнее поведение без единого следа
в рендеренных конфигах.
- LiveKit объявляет клиентам запись `protocol: tls` в `rtc.turn_servers`
(последней в списке, как самый дорогой фолбэк) — без анонса включённый
TLS был бы бесполезен: именно так уже случалось с обычным TURN до 0.0.14
(сервис работал healthy, но не обслужил ни одной аллокации).
- Новый init-контейнер `coturn-certs-init`: копирует fullchain/privkey из
`/etc/letsencrypt` в отдельный volume под правами `644` — coturn
(`nobody:nogroup`, без root-фазы в entrypoint) не может прочитать
оригинальный ключ (`root`, `0600`), а права на хосте ослаблять нельзя.
- Deploy-hook certbot дополнительно перекопирует сертификат и перезапускает
`coturn` при продлении — без этого TLS-TURN тихо остановился бы
обслуживать новые TLS-хендшейки примерно через 60 дней.
### Не сделано
- TURN over `443` — недостижимо без SNI-мультиплексора: порт уже занят
nginx (Docker port-publish), а coturn слушает в `network_mode: host` и не
может разделить с ним один и тот же сокет.
## [0.0.21] — 2026-08-02
Рычаги нагрузки медиа в админке: потолок качества публикации и лимит плиток.
### Добавлено
- Настройка инстанса «Потолок качества публикации видео» (без ограничения /
720p / 360p / 180p) — режет битрейт исходящего видео публикующего через
`publishDefaults` LiveKit, снижает нагрузку на его канал и устройство.
Дефолт — без ограничения, поведение существующих инсталляций не меняется.
- Настройка инстанса «Максимум плиток на экране» (25 / 16 / 9 / 4) — участники
сверх лимита уходят на следующую страницу сетки вместо подписки на их
видеотреки, меньше одновременных видеопотоков на канал и экран участника.
Дефолт — 25 (текущий максимум сетки 5×5), без изменений.
- Обе настройки доступны в новой карточке «Нагрузка» вкладки «Настройки»
админки и отдаются участнику вместе с токеном входа в конференцию — ещё до
подключения к комнате, чтобы применяться до публикации трека и не вызывать
переподключение уже вошедших участников при смене настройки.
## [0.0.20] — 2026-08-02
Сеть LiveKit: один UDP-порт вместо диапазона на 101 порт.
### Исправлено
- Весь медиа-трафик конференций шёл через userland-прокси Docker: диапазон
`54000-54100/udp` заставлял поднимать по отдельному процессу `docker-proxy`
на каждый порт. LiveKit переведён на `rtc.udp_port` (один порт,
мультиплексирование ICE-сессий по ufrag внутри самого сервера) — проброс
портов схлопнут до одного, TURN не затронут. Проверено локально
синтетической нагрузкой (`lk load-test`, 2 видео + 2 аудио publisher'а,
2 subscriber'а) — 0% потерь пакетов, ICE во всех сессиях выбирает новый
единственный порт.
## [0.0.19] — 2026-08-02
Правки по замечаниям к части B (комната конференции).
### Исправлено
- Очередь поднятых рук открывалась боковой панелью во весь экран на
мобильном — теперь компактный поповер над кнопкой (как «Вид»), размер
подстраивается под число записей, после ~10 строк список скроллится.
- Кнопки нижнего тулбара при сужении окна вылезали за края блока (задачи
B1/B2 добавили «Рука»/«Очередь», в тулбаре стало до 11 кнопок вместо
восьми) — теперь плавно уменьшаются на диапазоне 1200600px вместо
жёсткого скачка на мобильный вид.
- Подпись «Мини-окно» на промежуточных ширинах переносилась на 2 строки и
делала эту кнопку выше соседних — ниже 1200px показывается короткое
«Мини».
- Эмодзи-поповер в чате красил все свои кнопки в зелёный цвет кнопки
«Отправить» (гонка специфичности CSS-селекторов) — исправлено; заодно
сетка приведена к ровным 5×6 без неполной строки, добавлены
🦾 🚀 🦞 💯 🤷‍♂️.
## [0.0.18] — 2026-08-01
Ограничение частоты запросов больше не блокирует вход целой конференции.
### Исправлено
- Лимит на резолв конференции и гостевой вход считался по адресу контейнера
nginx, а не клиента, — то есть «10 запросов в минуту» действовали на весь
сервер разом. Одиннадцатый человек, открывший ссылку в течение минуты,
получал отказ и видел «Не удалось найти конференцию» для существующей и
активной конференции. Теперь адрес берётся из заголовка, который nginx уже
передаёт.
- Счётчик считает только неудачные попытки — конференция не найдена или
пароль неверен. Именно так выглядит перебор номера, от которого защищает
ограничение; массовый вход по рабочей ссылке к нему отношения не имеет.
На общий поток с адреса оставлен потолок в 300 запросов в минуту.
## [0.0.17] — 2026-08-01
Массовый вход в систему и в конференцию перестаёт упираться в проверку пароля.
### Исправлено
- Проверка пароля больше не останавливает весь backend. Хэширование argon2 —
это десятки миллисекунд счёта, и выполнялось оно синхронно внутри
асинхронного обработчика: пока считался один пароль, процесс не обслуживал
ничего другого. На нагрузочном тесте 31.07 с примерно семью десятками
одновременных входов это дало p95 логина 7.28 секунды, p95 входа в
конференцию 7.06 секунды и 33 соединения к базе, висящих в открытой
транзакции при одном активном запросе. Страдали и посторонние запросы —
вход в конференцию отказывал, хотя пароль там не проверялся вовсе.
Теперь хэширование считается в пуле потоков.
- Параметры argon2id приведены к рекомендации OWASP (t=2, m=19 МБ, p=1)
вместо дефолтов библиотеки (t=3, m=64 МБ, p=4): 95 мс против 42 мс на
проверку. Прежнее значение `parallelism=4` вдобавок занимало все четыре
ядра сервера — те же, на которых работает медиа-сервер.
- Пароли, сохранённые со старыми параметрами, продолжают работать и
перевыпускаются автоматически при первом успешном входе.
## [0.0.16] — 2026-08-01
Роль организатора в комнате: поднятие руки с очередью и принудительный мьют.
### Добавлено
- Роль организатора прокинута в комнату — метаданные LiveKit-токена несут
подсказку `is_organizer` для UI (владелец конференции/администратор);
любое серверное действие организатора перепроверяется по владельцу
конференции в БД, метаданным токена для авторизации не доверяем.
- Поднятие руки: кнопка «Рука» в тулбаре (у всех участников, с общим
счётчиком), бейдж на плитке говорящего, видимый всем, и панель «Очередь»
для организатора — участники в порядке поднятия руки, с возможностью
опустить любую руку. Состояние — в Redis (не в БД): очередь существует
ровно во время звонка. Участник, вышедший из конференции, автоматически
исчезает из очереди; переподключение место не теряет.
- Принудительный мьют: организатор может выключить микрофон или камеру
любого участника — кнопки на его плитке. Участник получает уведомление,
что его выключил организатор, и может включить себя обратно сразу тем же
тулбаром.
- Эмодзи скачущего коня 🐎 в наборе смайликов чата.
### Изменено
- Транспорт чата (`WS /conferences/{id}/chat`) расширен: очередь поднятых
рук и уведомления о мьюте едут по тому же аутентифицированному
соединению, что и сообщения чата — без нового эндпоинта.
Миграций БД нет — новое состояние (очередь поднятых рук) хранится в Redis,
не в Postgres.
## [0.0.15] — 2026-07-30 ## [0.0.15] — 2026-07-30
Шесть доработок UI комнаты конференции. Шесть доработок UI комнаты конференции.

View File

@@ -264,6 +264,8 @@ VidConf использует единый инсталлятор `install.sh` с
``` ```
Требования к оборудованию и полное описание см. в [docs/deploy/install.md](docs/deploy/install.md) и [docs/architecture/adr/004-ai-tier-matrix.md](docs/architecture/adr/004-ai-tier-matrix.md). Требования к оборудованию и полное описание см. в [docs/deploy/install.md](docs/deploy/install.md) и [docs/architecture/adr/004-ai-tier-matrix.md](docs/architecture/adr/004-ai-tier-matrix.md).
Это требования под AI; сколько CPU/RAM/полосы нужно под саму видео-нагрузку (профиль
«сколько человек и камер») — [docs/deploy/hardware-sizing.md](docs/deploy/hardware-sizing.md).
### Docker Compose профили (низкоуровневый контроль) ### Docker Compose профили (низкоуровневый контроль)

View File

@@ -1 +1 @@
0.0.15 0.0.36

View File

@@ -0,0 +1,41 @@
"""user consent to personal data processing
Согласие на обработку персональных данных при регистрации (сессия 30):
- `users.consent_version` — редакция регламента (`instance_settings.consent_policy.version`),
с которой согласился пользователь;
- `users.consent_given_at` — момент согласия.
Оба поля nullable: у существующих пользователей и у зарегистрированных при
выключенном модуле согласие не запрашивалось — `NULL` трактуется как
«согласие не запрашивалось», вход таким пользователям не блокируется.
Revision ID: 4d08a44ad153
Revises: d87681e12784
Create Date: 2026-08-04 21:41:33.813206
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '4d08a44ad153'
down_revision: Union[str, Sequence[str], None] = 'd87681e12784'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.add_column('users', sa.Column('consent_version', sa.Integer(), nullable=True))
op.add_column(
'users', sa.Column('consent_given_at', sa.DateTime(timezone=True), nullable=True)
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_column('users', 'consent_given_at')
op.drop_column('users', 'consent_version')

View File

@@ -0,0 +1,61 @@
"""user virtual background images
Свои картинки пользователя для замены фона видео (сессия 35):
- таблица `user_backgrounds` — id, владелец (`ON DELETE CASCADE`), путь к файлу
относительно `MEDIA_ROOT`, время загрузки.
Сами файлы лежат на диске в томе `media` (`backgrounds/{user_id}/{id}.{ext}`),
в БД только путь — как у аватаров (`users.avatar_path`). Лимит «не более 10
картинок на пользователя» — политика продукта, проверяется в
`services/backgrounds.py`, а не ограничением БД.
Настройка отключаемого модуля (`instance_settings.virtual_background`)
миграции не требует: `instance_settings` — key-value JSONB, новая настройка
это новая строка (см. `services/instance_settings.py`).
Revision ID: a37c1b9e0f42
Revises: 4d08a44ad153
Create Date: 2026-08-09 23:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'a37c1b9e0f42'
down_revision: Union[str, Sequence[str], None] = '4d08a44ad153'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.create_table(
'user_backgrounds',
sa.Column(
'id',
postgresql.UUID(as_uuid=True),
server_default=sa.text('gen_random_uuid()'),
nullable=False,
),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('path', sa.String(length=512), nullable=False),
sa.Column(
'created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False
),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(
'ix_user_backgrounds_user_created', 'user_backgrounds', ['user_id', 'created_at']
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_index('ix_user_backgrounds_user_created', table_name='user_backgrounds')
op.drop_table('user_backgrounds')

View File

@@ -62,6 +62,7 @@ from services.email import EmailSendError, create_email_backend
from services.instance_settings import ( from services.instance_settings import (
InstanceSettingsService, InstanceSettingsService,
InvalidAiLevelError, InvalidAiLevelError,
InvalidConsentPolicyError,
InvalidContactEmailError, InvalidContactEmailError,
InvalidEmailDomainError, InvalidEmailDomainError,
InvalidTimezoneError, InvalidTimezoneError,
@@ -217,7 +218,7 @@ async def create_user(
user = await repo.create( user = await repo.create(
email=data.email, email=data.email,
name_user=data.name_user, name_user=data.name_user,
password_hash=hash_password(data.password), password_hash=await hash_password(data.password),
team_id=data.team_id, team_id=data.team_id,
) )
user.email_verified = True user.email_verified = True
@@ -404,6 +405,7 @@ async def update_settings(
InvalidTimezoneError, InvalidTimezoneError,
InvalidEmailDomainError, InvalidEmailDomainError,
InvalidContactEmailError, InvalidContactEmailError,
InvalidConsentPolicyError,
) as exc: ) as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
queue_served = await anyio.to_thread.run_sync(transcription_queue_served) queue_served = await anyio.to_thread.run_sync(transcription_queue_served)
@@ -456,6 +458,7 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -
"""Собрать `SettingsOut` из эффективной конфигурации + доступность уровней AI.""" """Собрать `SettingsOut` из эффективной конфигурации + доступность уровней AI."""
return SettingsOut( return SettingsOut(
chat_enabled=cfg.chat.enabled, chat_enabled=cfg.chat.enabled,
hand_queue_enabled=cfg.hand_queue.enabled,
transcription_enabled=cfg.transcriber.enabled, transcription_enabled=cfg.transcriber.enabled,
ai_level=cfg.ai_level, ai_level=cfg.ai_level,
ai_levels=detect_ai_levels(cfg), ai_levels=detect_ai_levels(cfg),
@@ -467,6 +470,13 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -
registration_email_domains=cfg.registration_email_domains, registration_email_domains=cfg.registration_email_domains,
contact_email_enabled=cfg.contact_email_enabled, contact_email_enabled=cfg.contact_email_enabled,
contact_email=cfg.contact_email, contact_email=cfg.contact_email,
publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles,
consent_required=cfg.consent_required,
consent_policy_text=cfg.consent_policy_text,
consent_policy_version=cfg.consent_policy_version,
device_check_enabled=cfg.device_check_enabled,
virtual_background_enabled=cfg.virtual_background_enabled,
) )

View File

@@ -21,6 +21,7 @@ from schemas.auth import (
) )
from services.auth import ( from services.auth import (
AuthService, AuthService,
ConsentRequiredError,
EmailAlreadyRegisteredError, EmailAlreadyRegisteredError,
EmailNotVerifiedError, EmailNotVerifiedError,
InvalidCredentialsError, InvalidCredentialsError,
@@ -63,7 +64,12 @@ async def registration_options(
teams = [RegistrationTeamOptionOut(id=team.id, name=team.name) for team in items] teams = [RegistrationTeamOptionOut(id=team.id, name=team.name) for team in items]
email_domains = cfg.registration_email_domains if cfg.registration_email_domain_enabled else [] email_domains = cfg.registration_email_domains if cfg.registration_email_domain_enabled else []
return RegistrationOptionsOut( return RegistrationOptionsOut(
team_choice_enabled=cfg.registration_team_choice, teams=teams, email_domains=email_domains team_choice_enabled=cfg.registration_team_choice,
teams=teams,
email_domains=email_domains,
consent_required=cfg.consent_required,
consent_text=cfg.consent_policy_text,
consent_version=cfg.consent_policy_version,
) )
@@ -78,6 +84,7 @@ async def register(
name_user=data.name_user, name_user=data.name_user,
password=data.password, password=data.password,
team_id=data.team_id, team_id=data.team_id,
consent_accepted=data.consent_accepted,
) )
except EmailAlreadyRegisteredError as exc: except EmailAlreadyRegisteredError as exc:
raise HTTPException( raise HTTPException(
@@ -91,6 +98,10 @@ async def register(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_email_domain" status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_email_domain"
) from exc ) from exc
except ConsentRequiredError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="consent_required"
) from exc
@router.post("/verify-email", status_code=status.HTTP_204_NO_CONTENT) @router.post("/verify-email", status_code=status.HTTP_204_NO_CONTENT)

View File

@@ -1,10 +1,17 @@
"""WS-роутер текстового чата конференции: `WS /api/v1/conferences/{id}/chat`. """WS-роутер комнаты конференции: `WS /api/v1/conferences/{id}/chat`.
Протокол: `connect` -> `accept()` -> клиент шлёт `{"type":"auth","token":...}` Протокол: `connect` -> `accept()` -> клиент шлёт `{"type":"auth","token":...}`
первым сообщением (таймаут 10 с; токен не query-параметр — не палим его в первым сообщением (таймаут 10 с; токен не query-параметр — не палим его в
логах nginx) -> сервер проверяет тоггл `chat.enabled` и LiveKit-токен -> логах nginx) -> сервер проверяет тоггл `chat.enabled` и LiveKit-токен ->
история последних 50 сообщений открытой сессии -> двунаправленный обмен история последних 50 сообщений чата + текущая очередь поднятых рук ->
`{"type":"message","text":...}` через Redis pub/sub (echo отправителю тоже). двунаправленный обмен: `{"type":"message","text":...}` (чат, Redis pub/sub,
echo отправителю тоже), `{"type":"raise_hand"}`/`{"type":"lower_hand"}`
(очередь рук, задача B1 — состояние в Redis, см. `services/hand_queue.py`,
НЕ в БД: это эфемерное состояние звонка, а не история). Название файла и
эндпоинта («чат») оставлено как есть — эндпоинт исторически первый и
единственный аутентифицированный WS комнаты, поэтому очередь рук едет по
нему же, а не заводит отдельное соединение (дешевле: сервер уже держит
это соединение на каждого участника).
""" """
import asyncio import asyncio
@@ -13,7 +20,7 @@ import uuid
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
from pydantic import ValidationError from pydantic import Field, TypeAdapter, ValidationError
from redis.asyncio.client import PubSub from redis.asyncio.client import PubSub
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -28,6 +35,8 @@ from schemas.chat import (
ChatMessageIn, ChatMessageIn,
ChatMessageOut, ChatMessageOut,
) )
from schemas.room_events import LowerHandIn, RaiseHandIn
from services import hand_queue
from services.chat import ChatAuthError, ChatIdentity, ChatService, InvalidTokenError, chat_channel from services.chat import ChatAuthError, ChatIdentity, ChatService, InvalidTokenError, chat_channel
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -37,6 +46,14 @@ router = APIRouter(prefix="/api/v1/conferences", tags=["chat"])
# Таймаут ожидания первого (auth) сообщения клиента. # Таймаут ожидания первого (auth) сообщения клиента.
AUTH_TIMEOUT_SECONDS = 10.0 AUTH_TIMEOUT_SECONDS = 10.0
# Дискриминированное объединение сообщений клиента ПОСЛЕ auth — по полю `type`.
_ClientEnvelope = Annotated[
ChatMessageIn | RaiseHandIn | LowerHandIn, Field(discriminator="type")
]
_client_envelope_adapter: TypeAdapter[ChatMessageIn | RaiseHandIn | LowerHandIn] = TypeAdapter(
_ClientEnvelope
)
@router.websocket("/{conference_id}/chat") @router.websocket("/{conference_id}/chat")
async def chat_websocket( async def chat_websocket(
@@ -44,7 +61,7 @@ async def chat_websocket(
conference_id: uuid.UUID, conference_id: uuid.UUID,
session: Annotated[AsyncSession, Depends(get_session)], session: Annotated[AsyncSession, Depends(get_session)],
) -> None: ) -> None:
"""WS-эндпоинт текстового чата конференции — единая аутентификация LiveKit-токеном.""" """WS-эндпоинт комнаты конференции — единая аутентификация LiveKit-токеном."""
await websocket.accept() await websocket.accept()
service = ChatService(session) service = ChatService(session)
@@ -54,25 +71,55 @@ async def chat_websocket(
except ChatAuthError as exc: except ChatAuthError as exc:
await _close_quietly(websocket, exc.close_code) await _close_quietly(websocket, exc.close_code)
return return
hand_queue_enabled = await service.hand_queue_enabled()
pubsub = redis_client.pubsub() pubsub = redis_client.pubsub()
channel = chat_channel(conference.id) channel = chat_channel(conference.id)
# Подписка ДО чтения истории: сообщение, room_channel = hand_queue.hand_queue_channel(conference.id)
# опубликованное другим клиентом в окне между SELECT истории и # Подписка ДО чтения истории/снапшота очереди: событие,
# subscribe, иначе теряется для подключающегося клиента — Redis начинает # опубликованное другим клиентом в окне между SELECT/HGETALL и subscribe,
# иначе теряется для подключающегося клиента — Redis начинает
# буферизовать входящие publish для этого соединения сразу после # буферизовать входящие publish для этого соединения сразу после
# subscribe, до первого вызова `get_message`. На стыке возможен дубликат # subscribe, до первого вызова `get_message`. На стыке возможен дубликат
# (то же сообщение и в history, и в первом pub/sub-сообщении) — безопаснее # (то же сообщение чата и в history, и в первом pub/sub-сообщении) —
# дедуплицировать по `id`, чем потерять сообщение. # безопаснее дедуплицировать по `id`, чем потерять сообщение; снапшот
await pubsub.subscribe(channel) # очереди дублировать безвредно (полная замена состояния на клиенте).
await pubsub.subscribe(channel, room_channel)
try: try:
history = await service.history(conference) history = await service.history(conference)
# 🔑 Вернуть соединение с БД в пул ДО входа в долгоживущие насосы.
#
# Хендшейк выше сделал несколько SELECT'ов (тоггл чата, конференция,
# тоггл рук, история) — SQLAlchemy открыла транзакцию на первом же из
# них и держала бы её, а с ней и соединение из пула, ВСЁ время жизни
# WS: участник сидит в комнате час — час занято соединение. Пул это
# `db_pool_size + db_max_overflow` на воркер (10 + 10), то есть
# 40 на инстанс из двух воркеров, и сороковой вошедший выгребал его
# досуха: `pg_stat_activity` показывал 40 соединений
# `idle in transaction` при одном `active`, а посторонние ручки
# (резолв, гостевой вход, логин, refresh) начинали падать в
# `QueuePool limit ... timed out` и отдавать 500. Ровно это положило
# вход на нагрузочном тесте 07.08.2026 при ~50 участниках.
#
# Соединение здесь больше не нужно: оба насоса ниже работают через
# Redis, а единственная запись в БД (`persist_and_publish`) сама
# открывает транзакцию и коммитит её, освобождая соединение сразу.
# ⚠️ Любое чтение из БД, добавленное между этой строкой и концом
# обработчика, обязано так же завершаться commit/rollback.
await session.commit()
await websocket.send_json(ChatHistoryOut(messages=history).model_dump(mode="json")) await websocket.send_json(ChatHistoryOut(messages=history).model_dump(mode="json"))
seen_ids = {item.id for item in history} seen_ids = {item.id for item in history}
queue_out = await hand_queue.get_snapshot_out(conference.id)
await websocket.send_json(queue_out.model_dump(mode="json"))
async with asyncio.TaskGroup() as tg: async with asyncio.TaskGroup() as tg:
tg.create_task(_pump_pubsub_to_websocket(websocket, pubsub, seen_ids)) tg.create_task(_pump_pubsub_to_websocket(websocket, pubsub, channel, seen_ids))
tg.create_task(_pump_websocket_to_service(websocket, service, conference, identity)) tg.create_task(
_pump_websocket_to_service(
websocket, service, conference, identity, hand_queue_enabled
)
)
except* WebSocketDisconnect: except* WebSocketDisconnect:
# Штатное закрытие соединения клиентом — не ошибка. # Штатное закрытие соединения клиентом — не ошибка.
pass pass
@@ -91,7 +138,7 @@ async def chat_websocket(
finally: finally:
# Всегда отписываемся и закрываем pubsub-соединение, иначе при частых # Всегда отписываемся и закрываем pubsub-соединение, иначе при частых
# обрывах соединений копятся забытые подписки на стороне Redis. # обрывах соединений копятся забытые подписки на стороне Redis.
await pubsub.unsubscribe(channel) await pubsub.unsubscribe(channel, room_channel)
# `PubSub.aclose` в redis-py не аннотирован (untyped def) несмотря на # `PubSub.aclose` в redis-py не аннотирован (untyped def) несмотря на
# `py.typed` пакета — узкий игнор именно этого вызова. # `py.typed` пакета — узкий игнор именно этого вызова.
await pubsub.aclose() # type: ignore[no-untyped-call] await pubsub.aclose() # type: ignore[no-untyped-call]
@@ -111,37 +158,92 @@ async def _authenticate(websocket: WebSocket, service: ChatService) -> ChatIdent
async def _pump_pubsub_to_websocket( async def _pump_pubsub_to_websocket(
websocket: WebSocket, pubsub: PubSub, seen_ids: set[int] websocket: WebSocket, pubsub: PubSub, chat_channel_name: str, seen_ids: set[int]
) -> None: ) -> None:
"""Читать сообщения Redis pub/sub канала чата и пересылать их подключённому клиенту. """Читать оба Redis pub/sub канала комнаты (чат + очередь рук) и пересылать клиенту.
`seen_ids` — id сообщений, уже отправленных клиенту в `history` (на `seen_ids` — id сообщений чата, уже отправленных клиенту в `history` (на
стыке подписки и SELECT истории возможен дубликат, см. докстринг стыке подписки и SELECT истории возможен дубликат, см. докстринг
`chat_websocket`) — такие сообщения не пересылаются повторно. `chat_websocket`) — такие сообщения не пересылаются повторно. Снапшоты
очереди рук такой дедупликации не требуют (полная замена состояния).
""" """
while True: while True:
raw = await pubsub.get_message(ignore_subscribe_messages=True, timeout=None) raw = await pubsub.get_message(ignore_subscribe_messages=True, timeout=None)
if raw is None: if raw is None:
continue continue
if raw["channel"] == chat_channel_name:
message = ChatMessageOut.model_validate_json(raw["data"]) message = ChatMessageOut.model_validate_json(raw["data"])
if message.id in seen_ids: if message.id in seen_ids:
continue continue
seen_ids.add(message.id) seen_ids.add(message.id)
await websocket.send_json(ChatMessageEventOut(message=message).model_dump(mode="json")) await websocket.send_json(
ChatMessageEventOut(message=message).model_dump(mode="json")
)
else:
# Канал комнаты (`hand_queue.hand_queue_channel`) — уже готовый
# JSON исходящего конверта (`HandQueueOut`/`ForcedMuteOut`,
# см. `services/hand_queue.py::publish_snapshot` и эндпоинт мьюта
# в `api/conferences.py`), пересылаем как есть без пересборки.
await websocket.send_text(raw["data"])
async def _pump_websocket_to_service( async def _pump_websocket_to_service(
websocket: WebSocket, service: ChatService, conference: Conference, identity: ChatIdentity websocket: WebSocket,
service: ChatService,
conference: Conference,
identity: ChatIdentity,
hand_queue_enabled: bool,
) -> None: ) -> None:
"""Читать текстовые сообщения клиента, валидировать и сохранять+публиковать их.""" """Читать сообщения клиента (текст чата / поднять-опустить руку), валидировать и обработать.
`hand_queue_enabled` — снятый один раз при подключении тоггл модуля
«поднятие руки» (см. `ChatService.hand_queue_enabled`): при `False`
`raise_hand`/`lower_hand` отклоняются кодом `hand_queue_disabled` — вторая
линия защиты сверх того, что фронт при выключенном модуле вообще не
рисует кнопки (см. `RoomToolbar`/`HandQueueMenu`).
"""
is_organizer = conference.owner_id is not None and conference.owner_id == identity.user_id
while True: while True:
raw = await websocket.receive_text() raw = await websocket.receive_text()
try: try:
envelope = ChatMessageIn.model_validate_json(raw) envelope = _client_envelope_adapter.validate_json(raw)
except ValidationError: except ValidationError:
await websocket.send_json(ChatErrorOut(code="invalid_message").model_dump(mode="json")) await websocket.send_json(ChatErrorOut(code="invalid_message").model_dump(mode="json"))
continue continue
if isinstance(envelope, ChatMessageIn):
await service.persist_and_publish(conference, identity=identity, text=envelope.text) await service.persist_and_publish(conference, identity=identity, text=envelope.text)
elif isinstance(envelope, RaiseHandIn):
if not hand_queue_enabled:
await websocket.send_json(
ChatErrorOut(code="hand_queue_disabled").model_dump(mode="json")
)
continue
await hand_queue.raise_hand(
conference.id, identity=_identity_key(identity), name=identity.author_name
)
await hand_queue.publish_snapshot(conference.id)
else:
if not hand_queue_enabled:
await websocket.send_json(
ChatErrorOut(code="hand_queue_disabled").model_dump(mode="json")
)
continue
target = envelope.identity or _identity_key(identity)
if target != _identity_key(identity) and not is_organizer:
await websocket.send_json(
ChatErrorOut(code="forbidden").model_dump(mode="json")
)
continue
await hand_queue.lower_hand(conference.id, identity=target)
await hand_queue.publish_snapshot(conference.id)
def _identity_key(identity: ChatIdentity) -> str:
"""Identity участника в формате LiveKit/очереди рук — `str(user_id)` либо `guest:{id}`."""
if identity.user_id is not None:
return str(identity.user_id)
return f"guest:{identity.guest_access_id}"
async def _close_quietly(websocket: WebSocket, code: int) -> None: async def _close_quietly(websocket: WebSocket, code: int) -> None:

View File

@@ -9,7 +9,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.deps import get_current_user from api.deps import get_current_user
from core.db import get_session from core.db import get_session
from core.rate_limit import enforce_rate_limit from core.rate_limit import (
RATE_LIMIT_MISS_MAX_REQUESTS,
RATE_LIMIT_SOFT_MAX_REQUESTS,
client_ip,
enforce_rate_limit,
)
from models.user import User from models.user import User
from schemas.conferences import ( from schemas.conferences import (
ConferenceCreateIn, ConferenceCreateIn,
@@ -18,6 +23,8 @@ from schemas.conferences import (
GuestJoinIn, GuestJoinIn,
JoinIn, JoinIn,
JoinOut, JoinOut,
MuteParticipantIn,
MuteParticipantOut,
OccurrenceOut, OccurrenceOut,
ResolveOut, ResolveOut,
) )
@@ -34,6 +41,7 @@ from services.conferences import (
InviteeUserNotFoundError, InviteeUserNotFoundError,
NotConferenceOwnerError, NotConferenceOwnerError,
) )
from services.room_control import ParticipantNotInRoomError
router = APIRouter(prefix="/api/v1/conferences", tags=["conferences"]) router = APIRouter(prefix="/api/v1/conferences", tags=["conferences"])
@@ -100,10 +108,18 @@ async def resolve_conference(
п.4, уточнение резолва): вход в неё невозможен в любом случае (410 у п.4, уточнение резолва): вход в неё невозможен в любом случае (410 у
join/guest-join), а признак закрытости неактуален для мёртвой конференции. join/guest-join), а признак закрытости неактуален для мёртвой конференции.
""" """
await enforce_rate_limit(f"resolve:{_client_ip(request)}") ip = client_ip(request)
# Мягкий потолок против флуда: успешные резолвы легитимны и массовы —
# вся конференция открывает ссылку в одну минуту.
await enforce_rate_limit(f"resolve:{ip}", max_requests=RATE_LIMIT_SOFT_MAX_REQUESTS)
service = ConferenceService(session) service = ConferenceService(session)
conference = await service.resolve(q) conference = await service.resolve(q)
if conference is None: if conference is None:
# Жёсткий счётчик — только на промахи: перебор номера конференции
# выглядит именно так (см. core/rate_limit.py и ADR-001, п.4).
await enforce_rate_limit(
f"resolve_miss:{ip}", max_requests=RATE_LIMIT_MISS_MAX_REQUESTS
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not_found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not_found")
if conference.status == "ended": if conference.status == "ended":
return ResolveOut(id=conference.id, title=conference.title, status=conference.status) return ResolveOut(id=conference.id, title=conference.title, status=conference.status)
@@ -151,11 +167,15 @@ async def guest_join_conference(
session: Annotated[AsyncSession, Depends(get_session)], session: Annotated[AsyncSession, Depends(get_session)],
) -> JoinOut: ) -> JoinOut:
"""Войти гостем: представиться (имя обязательно, email факультативен) — без auth, rate limit.""" """Войти гостем: представиться (имя обязательно, email факультативен) — без auth, rate limit."""
await enforce_rate_limit(f"guest_join:{_client_ip(request)}") ip = client_ip(request)
# Мягкий потолок: успешный гостевой вход — обычное дело для всей
# конференции сразу, ограничивать его числом «10 в минуту» нельзя.
await enforce_rate_limit(f"guest_join:{ip}", max_requests=RATE_LIMIT_SOFT_MAX_REQUESTS)
service = ConferenceService(session) service = ConferenceService(session)
try: try:
return await service.join_as_guest(conference_id, data=data) return await service.join_as_guest(conference_id, data=data)
except ConferenceNotFoundError as exc: except ConferenceNotFoundError as exc:
await _count_guest_join_miss(ip)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found" status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found"
) from exc ) from exc
@@ -166,11 +186,45 @@ async def guest_join_conference(
status_code=status.HTTP_403_FORBIDDEN, detail="password_required" status_code=status.HTTP_403_FORBIDDEN, detail="password_required"
) from exc ) from exc
except InvalidPasswordError as exc: except InvalidPasswordError as exc:
# Подбор пароля закрытой конференции — тот же класс атаки, что и
# перебор номера, поэтому считается жёстким счётчиком.
await _count_guest_join_miss(ip)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="invalid_password" status_code=status.HTTP_403_FORBIDDEN, detail="invalid_password"
) from exc ) from exc
@router.post("/{conference_id}/mute-participant", response_model=MuteParticipantOut)
async def mute_participant(
conference_id: uuid.UUID,
data: MuteParticipantIn,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> MuteParticipantOut:
"""Принудительно выключить микрофон/камеру участника (задача B2) — владелец/администратор.
Права проверяются ЗАНОВО по владельцу конференции в БД
(`ConferenceService.mute_participant`), а не по метаданным LiveKit-токена
вызывающего — те лишь подсказка для UI и потенциально подделываемы клиентом.
"""
service = ConferenceService(session)
try:
muted = await service.mute_participant(
conference_id, actor=user, target_identity=data.identity, source=data.source
)
except ConferenceNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found"
) from exc
except NotConferenceOwnerError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="not_owner") from exc
except ParticipantNotInRoomError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="participant_not_in_room"
) from exc
return MuteParticipantOut(muted=muted)
@router.get("/{conference_id}", response_model=ConferenceOut) @router.get("/{conference_id}", response_model=ConferenceOut)
async def get_conference( async def get_conference(
conference_id: uuid.UUID, conference_id: uuid.UUID,
@@ -250,6 +304,11 @@ def _require_utc(value: datetime) -> datetime:
return value.astimezone(UTC) return value.astimezone(UTC)
def _client_ip(request: Request) -> str: async def _count_guest_join_miss(ip: str) -> None:
"""IP-адрес клиента для rate limit (без auth — ключ по IP, а не по пользователю).""" """Учесть неудачную попытку гостевого входа в жёстком счётчике.
return request.client.host if request.client else "unknown"
Вынесено отдельно, потому что вызывается из двух веток обработки ошибок
(несуществующая конференция и неверный пароль) и обязано бросать 429
ровно так же, как обычный `enforce_rate_limit`.
"""
await enforce_rate_limit(f"guest_join_miss:{ip}", max_requests=RATE_LIMIT_MISS_MAX_REQUESTS)

View File

@@ -1,4 +1,4 @@
"""Метрики Prometheus: латентность HTTP + gauge'и пайплайна, очередей и железа. """Метрики Prometheus: латентность HTTP + gauge'и пайплайна, очередей, БД и железа.
`GET /metrics` — без авторизации (снаружи закрывается на уровне nginx, вне `GET /metrics` — без авторизации (снаружи закрывается на уровне nginx, вне
периметра backend, см. `docs/deploy/scaling.md`/monitoring-часть devops): периметра backend, см. `docs/deploy/scaling.md`/monitoring-часть devops):
@@ -12,8 +12,22 @@ Gauge'и `vidconf_pipeline_sessions`/`vidconf_celery_queue_depth`/
Redis) можно опросить обычным `await` вместо реализации синхронного Redis) можно опросить обычным `await` вместо реализации синхронного
`prometheus_client.registry.Collector` (у `vidconf_host_info` источник `prometheus_client.registry.Collector` (у `vidconf_host_info` источник
и вовсе синхронный — настройки уже в памяти процесса). и вовсе синхронный — настройки уже в памяти процесса).
🔴 Метрики о состоянии основного пула БД (`vidconf_db_up`,
`vidconf_db_pool_*`) обязаны читаться БЕЗ обращения к самому пулу — иначе
в момент его исчерпания (см. `.forcc/session-results/32-loadtest-07-08-debug.md`)
эндпоинт метрик падал бы вместе со всем остальным ровно тогда, когда нужнее
всего. `vidconf_db_pool_*` — синхронный снимок `engine.pool` (см.
`core/db.py::db_pool_stats`), `vidconf_db_up` — отдельное соединение вне
основного пула (`core/db.py::check_db_up`). `_refresh_pipeline_sessions_gauge`
по-прежнему ходит через основной пул (`Depends(get_session)`, тестовый
харнесс подменяет её на savepoint-сессию — см. `tests/conftest.py`; развести
полностью, как `vidconf_db_up`, значило бы переделывать харнесс ради того же
эффекта — цена не оправдана, см. прецедент `f7c4fb4`/session 32), но обёрнута
таймаутом и try/except, чтобы её недоступность не роняла остальные метрики.
""" """
import asyncio
import time import time
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
@@ -23,7 +37,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from starlette.routing import Match from starlette.routing import Match
from core.config import get_settings from core.config import get_settings
from core.db import get_session from core.db import check_db_up, db_pool_checked_out, get_session
from core.redis import redis_client from core.redis import redis_client
from models.session import PIPELINE_STATUSES from models.session import PIPELINE_STATUSES
from repositories.conferences import ConferenceSessionRepository from repositories.conferences import ConferenceSessionRepository
@@ -80,9 +94,30 @@ PIPELINE_SESSIONS = Gauge(
) )
# Сколько ждать основной пул под этой конкретной метрикой, прежде чем
# сдаться и оставить прежнее значение gauge. Меньше `db_pool_timeout` (10с,
# `core/config.py`) — Prometheus скрейпит раз в 15с, и эта метрика не должна
# в одиночку съедать бюджет всего окна scrape.
_PIPELINE_GAUGE_TIMEOUT_S = 2.0
async def _refresh_pipeline_sessions_gauge(session: AsyncSession) -> None: async def _refresh_pipeline_sessions_gauge(session: AsyncSession) -> None:
"""Пересчитать `vidconf_pipeline_sessions` по всем статусам `pipeline_status`.""" """Пересчитать `vidconf_pipeline_sessions` по всем статусам `pipeline_status`.
counts = await ConferenceSessionRepository(session).count_by_pipeline_status()
Ходит через основной пул (`session` — из `Depends(get_session)`, см.
докстринг модуля про ограничения тестового харнесса). Если пул занят
или БД недоступна, запрос не должен держать весь `/metrics` — таймаут
короче `db_pool_timeout`, ошибка гасится, gauge остаётся на прежнем
значении (не обнуляется — обнулять его при недоступности БД так же
неверно, как считать сеансы пропавшими).
"""
try:
counts = await asyncio.wait_for(
ConferenceSessionRepository(session).count_by_pipeline_status(),
timeout=_PIPELINE_GAUGE_TIMEOUT_S,
)
except Exception: # noqa: BLE001
return
for status in PIPELINE_STATUSES: for status in PIPELINE_STATUSES:
PIPELINE_SESSIONS.labels(status=status).set(counts.get(status, 0)) PIPELINE_SESSIONS.labels(status=status).set(counts.get(status, 0))
@@ -113,6 +148,70 @@ async def _refresh_celery_queue_depth_gauge() -> None:
CELERY_QUEUE_DEPTH.labels(queue=queue).set(depth) CELERY_QUEUE_DEPTH.labels(queue=queue).set(depth)
# --- Доступность БД и занятость основного пула (сессия 33) -----------------
#
# Ранний сигнал важнее самого факта отказа: в инциденте 07.08 пул заполнялся
# постепенно (`idle in transaction` 3→8→16→26→35→39→40 участников) —
# `vidconf_db_pool_checked_out` показал бы это задолго до первого 500.
# Обе метрики читаются без обращения к основному пулу (см. докстринг модуля
# и `core/db.py`), поэтому доступны и в момент, когда сам пул исчерпан.
DB_UP = Gauge(
"vidconf_db_up",
"Доступность БД (1/0) — проверяется отдельным соединением вне основного пула",
)
DB_POOL_SIZE = Gauge(
"vidconf_db_pool_size",
"Настроенный размер основного пула БД без overflow (db_pool_size)",
)
DB_POOL_MAX_OVERFLOW = Gauge(
"vidconf_db_pool_max_overflow",
"Настроенный максимум overflow-соединений сверх db_pool_size (db_max_overflow)",
)
DB_POOL_CHECKED_OUT = Gauge(
"vidconf_db_pool_checked_out",
"Число соединений основного пула БД, занятых прямо сейчас (в пуле + overflow)",
)
async def _refresh_db_up_gauge() -> None:
"""Пересчитать `vidconf_db_up` отдельным от основного пула соединением."""
DB_UP.set(1 if await check_db_up() else 0)
def _refresh_db_pool_gauges() -> None:
"""Пересчитать gauge'и занятости основного пула — синхронно, без I/O."""
settings = get_settings()
DB_POOL_SIZE.set(settings.db_pool_size)
DB_POOL_MAX_OVERFLOW.set(settings.db_max_overflow)
DB_POOL_CHECKED_OUT.set(db_pool_checked_out())
# --- Занятость пула Redis (сессия 33, второй потолок из session 32) --------
#
# Тот же класс отказа, что и у пула БД: каждое WS-подключение комнаты держит
# pub/sub-соединение всё время, пока участник в конференции (`core/redis.py`,
# `redis_max_connections`). Снимок — синхронный (атрибуты пула в памяти
# процесса redis-py), Redis для этого спрашивать не нужно.
REDIS_POOL_IN_USE = Gauge(
"vidconf_redis_pool_in_use",
"Число занятых соединений пула Redis прямо сейчас",
)
REDIS_POOL_MAX = Gauge(
"vidconf_redis_pool_max_connections",
"Настроенный максимум соединений пула Redis (redis_max_connections)",
)
def _refresh_redis_pool_gauges() -> None:
"""Пересчитать gauge'и занятости пула Redis — синхронно, без I/O."""
pool = redis_client.connection_pool
REDIS_POOL_IN_USE.set(len(pool._in_use_connections)) # noqa: SLF001
REDIS_POOL_MAX.set(pool.max_connections)
# --- Info-метрика обнаруженного железа (install.sh, ADR-004) --------------- # --- Info-метрика обнаруженного железа (install.sh, ADR-004) ---------------
HOST_INFO = Gauge( HOST_INFO = Gauge(
@@ -158,7 +257,16 @@ async def metrics_endpoint(session: AsyncSession = Depends(get_session)) -> Resp
ценой одного SELECT (группировка по `pipeline_status`) и `LLEN` на ценой одного SELECT (группировка по `pipeline_status`) и `LLEN` на
каждую из 4 отслеживаемых очередей per запрос — Prometheus скрейпит каждую из 4 отслеживаемых очередей per запрос — Prometheus скрейпит
редко (обычно раз в 1530с), нагрузка пренебрежимо мала. редко (обычно раз в 1530с), нагрузка пренебрежимо мала.
Порядок важен: метрики о состоянии основного пула БД (`_refresh_db_up_gauge`,
`_refresh_db_pool_gauges`) считаются первыми и не зависят от самого пула
(см. докстринг модуля) — они гарантированно попадут в ответ, даже если
следующий за ними `_refresh_pipeline_sessions_gauge` (основной пул) зависнет
или упадёт под нагрузкой.
""" """
await _refresh_db_up_gauge()
_refresh_db_pool_gauges()
_refresh_redis_pool_gauges()
await _refresh_pipeline_sessions_gauge(session) await _refresh_pipeline_sessions_gauge(session)
await _refresh_celery_queue_depth_gauge() await _refresh_celery_queue_depth_gauge()
_refresh_host_info_gauge() _refresh_host_info_gauge()

24
backend/api/public.py Normal file
View File

@@ -0,0 +1,24 @@
"""Роутер публичных настроек клиента — доступен без аутентификации."""
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from core.db import get_session
from schemas.public import PublicSettingsOut
from services.instance_settings import InstanceSettingsService
router = APIRouter(prefix="/api/v1/public", tags=["public"])
@router.get("/settings", response_model=PublicSettingsOut)
async def public_settings(
session: Annotated[AsyncSession, Depends(get_session)],
) -> PublicSettingsOut:
"""Флаги инстанса, нужные публичным страницам логина/входа гостя до аутентификации."""
cfg = await InstanceSettingsService(session).get()
return PublicSettingsOut(
device_check_enabled=cfg.device_check_enabled,
virtual_background_enabled=cfg.virtual_background_enabled,
)

View File

@@ -1,5 +1,6 @@
"""Роутер профиля текущего пользователя, аватара и списка пользователей.""" """Роутер профиля текущего пользователя, аватара, картинок фона и списка пользователей."""
import uuid
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated
@@ -11,9 +12,27 @@ from core.config import get_settings
from core.db import get_session from core.db import get_session
from core.security import hash_password, verify_password from core.security import hash_password, verify_password
from models.user import User from models.user import User
from models.user_background import UserBackground
from repositories.users import UserRepository from repositories.users import UserRepository
from schemas.auth import PasswordChangeIn, ProfileUpdateIn, UserListItemOut, UserProfileOut from schemas.auth import (
PasswordChangeIn,
ProfileUpdateIn,
UserBackgroundOut,
UserBackgroundsOut,
UserListItemOut,
UserProfileOut,
)
from services.avatars import AvatarInvalidTypeError, AvatarTooLargeError, avatar_url from services.avatars import AvatarInvalidTypeError, AvatarTooLargeError, avatar_url
from services.backgrounds import (
MAX_BACKGROUNDS_PER_USER,
BackgroundInvalidTypeError,
BackgroundLimitReachedError,
BackgroundTooLargeError,
add_background,
background_url,
delete_background,
list_backgrounds,
)
from services.profile import ( from services.profile import (
TeamNotFoundError, TeamNotFoundError,
clear_avatar, clear_avatar,
@@ -89,6 +108,60 @@ async def delete_current_user_avatar(
await session.commit() await session.commit()
@router.get("/me/backgrounds", response_model=UserBackgroundsOut)
async def list_current_user_backgrounds(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> UserBackgroundsOut:
"""Свои картинки для замены фона видео + лимит на их число."""
items = await list_backgrounds(session, user.id)
return _to_backgrounds_out(items)
@router.post(
"/me/backgrounds", response_model=UserBackgroundsOut, status_code=status.HTTP_201_CREATED
)
async def upload_current_user_background(
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
file: Annotated[UploadFile, File()],
) -> UserBackgroundsOut:
"""Загрузить свою картинку фона (jpeg/png/webp, до 2 МБ, не более 10 штук).
Возвращает весь список заново, а не одну добавленную запись: интерфейсу всё
равно нужен свежий список с актуальным остатком лимита, и лишний GET следом
за POST не нужен.
"""
try:
await add_background(session, _media_root(), user.id, file)
except BackgroundLimitReachedError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="background_limit_reached"
) from exc
except BackgroundTooLargeError as exc:
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail="background_too_large"
) from exc
except BackgroundInvalidTypeError as exc:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail="background_invalid_type"
) from exc
await session.commit()
return _to_backgrounds_out(await list_backgrounds(session, user.id))
@router.delete("/me/backgrounds/{background_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_current_user_background(
background_id: uuid.UUID,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
) -> None:
"""Удалить свою картинку фона вместе с файлом на диске; чужую — 404."""
if not await delete_background(session, _media_root(), user.id, background_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="background_not_found")
await session.commit()
@router.post("/me/password", status_code=status.HTTP_204_NO_CONTENT) @router.post("/me/password", status_code=status.HTTP_204_NO_CONTENT)
async def change_current_user_password( async def change_current_user_password(
data: PasswordChangeIn, data: PasswordChangeIn,
@@ -101,11 +174,11 @@ async def change_current_user_password(
вместе со сбросом пароля по email (v0.1.0, см. ADR-005 вместе со сбросом пароля по email (v0.1.0, см. ADR-005
`docs/architecture/adr/005-password-reset-deferred.md`). `docs/architecture/adr/005-password-reset-deferred.md`).
""" """
if not verify_password(data.current_password, user.password_hash): if not await verify_password(data.current_password, user.password_hash):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_current_password" status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_current_password"
) )
user.password_hash = hash_password(data.new_password) user.password_hash = await hash_password(data.new_password)
await session.commit() await session.commit()
@@ -126,6 +199,14 @@ async def list_users(
] ]
def _to_backgrounds_out(items: list[UserBackground]) -> UserBackgroundsOut:
"""Собрать ответ списка картинок фона: id + публичный URL, плюс лимит с сервера."""
return UserBackgroundsOut(
items=[UserBackgroundOut(id=item.id, url=background_url(item.path)) for item in items],
limit=MAX_BACKGROUNDS_PER_USER,
)
def _media_root() -> Path: def _media_root() -> Path:
"""Каталог загруженных медиа-файлов (см. `core/config.py::Settings.media_root`).""" """Каталог загруженных медиа-файлов (см. `core/config.py::Settings.media_root`)."""
return Path(get_settings().media_root) return Path(get_settings().media_root)

View File

@@ -38,6 +38,27 @@ class Settings(BaseSettings):
# и показывает проблему, а не висит полминуты, делая вид, что всё живо. # и показывает проблему, а не висит полминуты, делая вид, что всё живо.
db_pool_timeout: int = 10 db_pool_timeout: int = 10
# --- Проверка доступности БД вне основного пула (`core/db.py::check_db_up`) ---
# Таймаут TCP/auth отдельного соединения-пробы (не путать с
# `db_pool_timeout` выше — тот про очередь на основной пул). Дефолт
# asyncpg — 60с, для сигнала мониторинга это неприемлемо долго: пусть
# `vidconf_db_up` станет 0 за секунды, а не через минуту.
db_probe_timeout_s: float = 3.0
# --- Пул соединений с Redis ---
# Считается по УЧАСТНИКАМ, а не по запросам: каждое WS-подключение комнаты
# (`api/chat.py`) держит собственное pub/sub-соединение всё время, пока
# человек сидит в конференции, — и берёт его из этого же пула, что и
# обычные команды. redis-py 8 поставил дефолт `max_connections=100`
# (раньше предел был условно бесконечным), поэтому сотый участник на
# воркер выгребал пул досуха и WS падал уже на `hgetall` очереди рук —
# воспроизведено локально при 99 одновременных подключениях.
# 500 — с запасом на инстанс, рассчитанный на пару сотен участников
# на воркер; соединения создаются по мере надобности, само по себе
# значение ничего не стоит. Потолок сверху — `maxclients` у Redis
# (дефолт 10000) на ВСЕ процессы вместе, включая Celery-воркеры.
redis_max_connections: int = 500
# --- Версия инстанса (релиз v0.0.1) --- # --- Версия инстанса (релиз v0.0.1) ---
# install.sh копирует значение из файла `VERSION` (корень репозитория) в # install.sh копирует значение из файла `VERSION` (корень репозитория) в
# `.env` при каждой установке/обновлении — здесь только чтение готового # `.env` при каждой установке/обновлении — здесь только чтение готового

View File

@@ -1,13 +1,16 @@
"""Настройка асинхронного движка SQLAlchemy и сеанса.""" """Настройка асинхронного движка SQLAlchemy и сеанса."""
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from typing import cast
from sqlalchemy import text
from sqlalchemy.ext.asyncio import ( from sqlalchemy.ext.asyncio import (
AsyncEngine, AsyncEngine,
AsyncSession, AsyncSession,
async_sessionmaker, async_sessionmaker,
create_async_engine, create_async_engine,
) )
from sqlalchemy.pool import NullPool, QueuePool
from core.config import get_settings from core.config import get_settings
@@ -31,3 +34,45 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
"""Зависимость FastAPI, возвращающая `AsyncSession`.""" """Зависимость FastAPI, возвращающая `AsyncSession`."""
async with async_session_maker() as session: async with async_session_maker() as session:
yield session yield session
# --- Проверка доступности БД вне основного пула (сессия 33) ----------------
#
# Отдельный движок с `NullPool`: каждый вызов открывает новое соединение и
# закрывает его сразу после — бюджет соединений не пересекается с
# `engine.pool` (10 + 10 overflow × число воркеров uvicorn). Это единственный
# способ отличить «БД лежит» от «основной пул занят под нагрузкой»: проверка
# через `get_session()` в момент исчерпания пула сама встала бы в очередь на
# `db_pool_timeout` и не смогла бы ответить, пока не появится случайно
# освободившееся место — то есть не отличила бы два принципиально разных
# состояния. Короткий `timeout` на соединение (не путать с `db_pool_timeout`
# основного пула) — чтобы зависший, а не оборванный TCP (Postgres отвечает,
# но не может продвинуться) не держал проверку до дефолтных 60 секунд asyncpg.
_probe_engine: AsyncEngine = create_async_engine(
settings.database_url,
poolclass=NullPool,
connect_args={"timeout": settings.db_probe_timeout_s},
)
async def check_db_up() -> bool:
"""`True`, если БД отвечает на `SELECT 1` по отдельному от основного пула соединению."""
try:
async with _probe_engine.connect() as connection:
await connection.execute(text("SELECT 1"))
except Exception: # noqa: BLE001
return False
return True
def db_pool_checked_out() -> int:
"""Число соединений основного пула, занятых прямо сейчас — без обращения к БД.
SQLAlchemy держит счётчик в памяти самого объекта пула (`engine.pool`),
поэтому его можно прочитать в любой момент, даже когда все соединения
заняты или БД недоступна — именно это нужно алерту на исчерпание пула
(метрика не должна зависеть от того, что измеряет). Размер и лимит
overflow — конфигурация (`Settings.db_pool_size`/`db_max_overflow`),
их не нужно снимать с объекта пула отдельно.
"""
return cast(QueuePool, engine.pool).checkedout()

View File

@@ -33,12 +33,19 @@ class ChatConfig(BaseModel):
enabled: bool = True enabled: bool = True
class HandQueueConfig(BaseModel):
"""Конфигурация переключателя модуля «поднятие руки» (кнопка + очередь целиком)."""
enabled: bool = True
class PluginsConfig(BaseModel): class PluginsConfig(BaseModel):
"""Корневая модель конфигурации для `config/plugins.yaml`.""" """Корневая модель конфигурации для `config/plugins.yaml`."""
transcriber: TranscriberConfig = Field(default_factory=TranscriberConfig) transcriber: TranscriberConfig = Field(default_factory=TranscriberConfig)
summarizer: SummarizerConfig = Field(default_factory=SummarizerConfig) summarizer: SummarizerConfig = Field(default_factory=SummarizerConfig)
chat: ChatConfig = Field(default_factory=ChatConfig) chat: ChatConfig = Field(default_factory=ChatConfig)
hand_queue: HandQueueConfig = Field(default_factory=HandQueueConfig)
def load_plugins_config(path: str | Path) -> PluginsConfig: def load_plugins_config(path: str | Path) -> PluginsConfig:
@@ -62,6 +69,28 @@ SummaryRecipientsMode = Literal["all", "owner"]
"""Режим рассылки саммари по умолчанию: всем участникам либо только """Режим рассылки саммари по умолчанию: всем участникам либо только
владельцу конференции (переопределяется на уровне `conferences.summary_recipients`).""" владельцу конференции (переопределяется на уровне `conferences.summary_recipients`)."""
PublishQualityCap = Literal["off", "180p", "360p", "720p"]
"""Потолок качества исходящего видео участника (задача «рычаги качества
медиа»). `off` — без ограничения (дефолт, поведение как до появления
настройки). Остальные значения режут `publishDefaults.videoEncoding` и
`videoSimulcastLayers` на клиенте (`frontend/src/lib/publishQualityCap.ts`) —
именно битрейт верхнего слоя симулкаста, а не жёсткое разрешение захвата
камеры; фактическое разрешение WebRTC подстраивает под битрейт сам."""
StageMaxTiles = Literal[4, 9, 16, 25]
"""Потолок числа одновременно видимых плиток на сцене (`StageGrid`) —
режет набор доступных раскладок сетки, что бросает лишних участников на
следующую страницу пагинации вместо подписки на их треки. `25` — дефолт,
совпадает с текущим максимумом сетки (5×5), то есть без ограничения."""
class MediaLimitsConfig(BaseModel):
"""Рычаги нагрузки медиа для администратора инстанса (не логика состояния
конференции — статичные потолки, применяются на клиенте при входе)."""
publish_quality_cap: PublishQualityCap = "off"
stage_max_tiles: StageMaxTiles = 25
class InstanceConfig(BaseModel): class InstanceConfig(BaseModel):
"""Эффективная конфигурация инстанса (значения `instance_settings` поверх дефолтов """Эффективная конфигурация инстанса (значения `instance_settings` поверх дефолтов
@@ -70,6 +99,7 @@ class InstanceConfig(BaseModel):
transcriber: TranscriberConfig transcriber: TranscriberConfig
summarizer: SummarizerConfig summarizer: SummarizerConfig
chat: ChatConfig chat: ChatConfig
hand_queue: HandQueueConfig = Field(default_factory=HandQueueConfig)
ai_level: AiLevel = "min" ai_level: AiLevel = "min"
summary_recipients: SummaryRecipientsMode = "all" summary_recipients: SummaryRecipientsMode = "all"
display_timezone: str = "Europe/Moscow" display_timezone: str = "Europe/Moscow"
@@ -87,3 +117,30 @@ class InstanceConfig(BaseModel):
# адреса) — см. `services/instance_settings.py`, `services/email.py`. # адреса) — см. `services/instance_settings.py`, `services/email.py`.
contact_email_enabled: bool = False contact_email_enabled: bool = False
contact_email: str | None = None contact_email: str | None = None
# Потолок качества публикации + максимум плиток сцены — см.
# `services/instance_settings.py`. Отдаётся участнику ДО входа в
# LiveKit-комнату (в ответе join, `schemas/conferences.py::JoinOut`), а
# не только в админке — настройка должна быть на руках у клиента до
# публикации трека.
media_limits: MediaLimitsConfig = Field(default_factory=MediaLimitsConfig)
# Согласие на обработку персональных данных при регистрации: галочка
# обязательна только при `consent_required=True`, текст/версия — редактируемая
# администратором настройка (дефолт — типовой шаблон, не юридический документ) —
# см. `services/instance_settings.py`. `consent_policy_text`/`_version`
# отдаются публично (`GET /auth/registration-options`) независимо от
# `consent_required`, чтобы страница регламента была осмысленной и при
# выключенном модуле.
consent_required: bool = False
consent_policy_text: str = ""
consent_policy_version: int = 1
# Проверка устройств на входе (сессия 33): запрос доступа к микрофону/камере
# + превью камеры на странице логина и в карточке «Как вас зовут?» (JoinPage).
# Дефолт False сохраняет поведение существующих инсталляций — см.
# `services/instance_settings.py`.
device_check_enabled: bool = False
# Замена фона видео на картинку (сессия 35): отключаемый модуль, дефолт
# False сохраняет поведение существующих инсталляций. Нужен клиенту в двух
# местах и потому едет двумя путями: на публичные страницы входа — через
# `GET /public/settings`, участнику комнаты — в ответе join
# (`schemas/conferences.py::JoinOut`), см. `services/instance_settings.py`.
virtual_background_enabled: bool = False

View File

@@ -3,15 +3,59 @@
Используется резолвом конференций и гостевым входом (`api/conferences.py`) — Используется резолвом конференций и гостевым входом (`api/conferences.py`) —
эндпоинтами без аутентификации, уязвимыми к перебору номера/ссылки конференции эндпоинтами без аутентификации, уязвимыми к перебору номера/ссылки конференции
(см. ADR-001, п.4 — оценка энтропии и рекомендуемый лимит 10 запросов/мин на IP). (см. ADR-001, п.4 — оценка энтропии и рекомендуемый лимит 10 запросов/мин на IP).
## Два счётчика вместо одного (0.0.18)
Прежняя схема считала ВСЕ запросы подряд с лимитом 10/мин. На нагрузочном
тесте 31.07.2026 это остановило вход целой конференции: люди открывали ссылку
одновременно, одиннадцатый получал 429, а фронтенд показывал «Не удалось найти
конференцию» — при том что конференция существовала и была активна.
Смысл лимита по ADR-001 — защита от ПЕРЕБОРА номера конференции. Перебор — это
поток промахов; легитимный участник открывает существующую ссылку и получает
успех. Поэтому:
- `RATE_LIMIT_MISS_MAX_REQUESTS` — жёсткий счётчик промахов (конференция не
найдена, неверный пароль). Именно он защищает от перебора, и он остался
прежним — 10/мин;
- `RATE_LIMIT_SOFT_MAX_REQUESTS` — мягкий потолок на общее число обращений с
одного адреса. Нужен только против тупого флуда; рассчитан так, чтобы сотня
человек из офиса за общим NAT спокойно зашла в одну конференцию.
""" """
from fastapi import HTTPException, status from fastapi import HTTPException, Request, status
from core.redis import redis_client from core.redis import redis_client
RATE_LIMIT_MAX_REQUESTS = 10 RATE_LIMIT_MAX_REQUESTS = 10
RATE_LIMIT_WINDOW_SECONDS = 60 RATE_LIMIT_WINDOW_SECONDS = 60
# Промахи: перебор номера/ссылки или подбор пароля конференции.
RATE_LIMIT_MISS_MAX_REQUESTS = 10
# Общий поток с одного IP. Офис за общим NAT — это ОДИН адрес, поэтому потолок
# заведомо выше правдоподобного числа участников одной конференции.
RATE_LIMIT_SOFT_MAX_REQUESTS = 300
def client_ip(request: Request) -> str:
"""IP клиента для rate limit — с учётом того, что backend стоит за nginx.
`request.client.host` — это TCP-peer, то есть контейнер nginx, один и тот же
для всех пользователей. С ним лимит превращался в общий на весь инстанс:
на проде в Redis лежал единственный ключ `rate_limit:resolve:172.18.0.13`,
и десяти запросов в минуту хватало, чтобы заблокировать вход всем сразу.
Берём `X-Real-IP`, а НЕ первый элемент `X-Forwarded-For`: nginx заполняет
его через `$proxy_add_x_forwarded_for`, то есть ДОПИСЫВАЕТ к присланному
клиентом. Первый элемент там подделывается одним заголовком, и лимит
обходился бы тривиально. `X-Real-IP` nginx всегда перезаписывает своим
`$remote_addr` (см. deploy/nginx/nginx.conf.template).
"""
real_ip = request.headers.get("x-real-ip")
if real_ip:
return real_ip.strip()
return request.client.host if request.client else "unknown"
async def enforce_rate_limit( async def enforce_rate_limit(
key: str, key: str,

View File

@@ -6,4 +6,11 @@ from core.config import get_settings
settings = get_settings() settings = get_settings()
redis_client: Redis = Redis.from_url(settings.redis_url, decode_responses=True) redis_client: Redis = Redis.from_url(
settings.redis_url,
decode_responses=True,
# Размер пула задаём явно: дефолт redis-py (100) рассчитан на команды, а у
# нас на нём же висят долгоживущие pub/sub-подписки комнаты — по одной на
# участника (см. `core/config.py`, `redis_max_connections`).
max_connections=settings.redis_max_connections,
)

View File

@@ -1,33 +1,85 @@
"""Хэширование паролей (argon2) и выпуск/проверка JWT (access + refresh).""" """Хэширование паролей (argon2) и выпуск/проверка JWT (access + refresh)."""
import asyncio
import uuid import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
import jwt import jwt
from argon2 import PasswordHasher from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError from argon2.exceptions import InvalidHashError, VerifyMismatchError
from core.config import get_settings from core.config import get_settings
JWT_ALGORITHM = "HS256" JWT_ALGORITHM = "HS256"
_hasher = PasswordHasher() # Параметры argon2id по рекомендации OWASP (Password Storage Cheat Sheet):
# t=2, m=19 МБ, p=1. Раньше использовались дефолты argon2-cffi
# (t=3, m=64 МБ, p=4) — это был не выбор, а «что было в коробке».
#
# Замер на боевом сервере (4 ядра): 95 мс против 42 мс на одну проверку.
# Отдельно важен `parallelism`: при p=4 ОДНА проверка пароля занимала все
# четыре ядра, конкурируя с LiveKit за то же железо ровно в момент, когда
# люди массово заходят в конференцию.
#
# Существующие хэши не ломаются: параметры хранятся внутри самой строки хэша
# и читаются при verify. Старые хэши перевыпускаются постепенно — см.
# `needs_rehash` и его использование при успешном входе.
_hasher = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
def hash_password(password: str) -> str: def _hash_password_sync(password: str) -> str:
"""Захэшировать пароль алгоритмом argon2 для хранения в БД."""
return _hasher.hash(password) return _hasher.hash(password)
def verify_password(password: str, password_hash: str) -> bool: def _verify_password_sync(password: str, password_hash: str) -> bool:
"""Сверить пароль с сохранённым argon2-хэшем; пароль/хэш никогда не логируются."""
try: try:
return _hasher.verify(password_hash, password) return _hasher.verify(password_hash, password)
except VerifyMismatchError: except VerifyMismatchError:
return False return False
async def hash_password(password: str) -> str:
"""Захэшировать пароль алгоритмом argon2 для хранения в БД.
Считается в отдельном потоке — argon2 это CPU-bound работа на десятки
миллисекунд, и в event loop ей не место (см. `verify_password`).
"""
return await asyncio.to_thread(_hash_password_sync, password)
async def verify_password(password: str, password_hash: str) -> bool:
"""Сверить пароль с сохранённым argon2-хэшем; пароль/хэш никогда не логируются.
Выполняется в пуле потоков, а не в event loop. Причина — нагрузочный тест
31.07.2026: синхронный вызов останавливал весь процесс на 95155 мс, и при
массовом входе (около 70 человек разом) это давало p95 логина 7.28 секунды,
33 соединения к БД в состоянии `idle in transaction` при одном активном
запросе и отказы на совершенно посторонних ручках — включая вход в
конференцию, где никакого пароля не проверялось.
Потоки здесь работают по-настоящему параллельно: argon2-cffi — это
C-расширение, освобождающее GIL на время вычисления.
"""
return await asyncio.to_thread(_verify_password_sync, password, password_hash)
def needs_rehash(password_hash: str) -> bool:
"""Проверить, что хэш выпущен устаревшими параметрами argon2.
Дешёвая операция: разбор строки хэша, без вычислений. Вызывается после
успешной проверки пароля — только тогда у нас на руках открытый пароль,
которым можно перевыпустить хэш.
Невалидную строку считаем требующей перевыпуска: если в базе оказался
мусор, лучше заменить его корректным хэшем, чем падать при каждом входе.
"""
try:
return _hasher.check_needs_rehash(password_hash)
except InvalidHashError:
return True
def create_access_token(user_id: uuid.UUID, role: str) -> str: def create_access_token(user_id: uuid.UUID, role: str) -> str:
"""Выпустить access-токен: `sub`=user_id, `role`=роль, TTL из настроек.""" """Выпустить access-токен: `sub`=user_id, `role`=роль, TTL из настроек."""
settings = get_settings() settings = get_settings()

View File

@@ -16,6 +16,7 @@ from api.health import router as health_router
from api.livekit_webhook import router as livekit_webhook_router from api.livekit_webhook import router as livekit_webhook_router
from api.metrics import prometheus_latency_middleware from api.metrics import prometheus_latency_middleware
from api.metrics import router as metrics_router from api.metrics import router as metrics_router
from api.public import router as public_router
from api.teams import router as teams_router from api.teams import router as teams_router
from api.users import router as users_router from api.users import router as users_router
from core.config import get_settings from core.config import get_settings
@@ -76,6 +77,7 @@ def create_app() -> FastAPI:
app.middleware("http")(prometheus_latency_middleware) app.middleware("http")(prometheus_latency_middleware)
app.include_router(health_router) app.include_router(health_router)
app.include_router(metrics_router) app.include_router(metrics_router)
app.include_router(public_router)
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(users_router) app.include_router(users_router)
app.include_router(teams_router) app.include_router(teams_router)

View File

@@ -18,6 +18,7 @@ from models.phrase import Phrase
from models.session import ConferenceSession from models.session import ConferenceSession
from models.team import Team from models.team import Team
from models.user import User from models.user import User
from models.user_background import UserBackground
from models.webhook_event import LivekitWebhookEvent from models.webhook_event import LivekitWebhookEvent
__all__ = [ __all__ = [
@@ -36,4 +37,5 @@ __all__ = [
"SessionAudioTrack", "SessionAudioTrack",
"Team", "Team",
"User", "User",
"UserBackground",
] ]

View File

@@ -3,7 +3,17 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, String, Text, func, text from sqlalchemy import (
Boolean,
CheckConstraint,
DateTime,
ForeignKey,
Integer,
String,
Text,
func,
text,
)
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -35,6 +45,13 @@ class User(Base):
# Путь к загруженному аватару (относительно `MEDIA_ROOT`): # Путь к загруженному аватару (относительно `MEDIA_ROOT`):
# `avatars/{user_id}.{ext}`; `NULL` — заглушка с инициалами на фронте. # `avatars/{user_id}.{ext}`; `NULL` — заглушка с инициалами на фронте.
avatar_path: Mapped[str | None] = mapped_column(String(512), nullable=True) avatar_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
# Согласие на обработку персональных данных при регистрации: редакция
# регламента (`instance_settings.consent_policy.version` на момент
# согласия) и время. `NULL` у обоих — согласие не запрашивалось (модуль
# был выключен либо пользователь зарегистрирован до появления этой
# настройки); вход таким пользователям не блокируется.
consent_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
consent_given_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now() DateTime(timezone=True), nullable=False, server_default=func.now()
) )

View File

@@ -0,0 +1,45 @@
"""Модель UserBackground — своя картинка пользователя для замены фона видео.
В БД хранится только путь к файлу относительно `MEDIA_ROOT`
(`backgrounds/{user_id}/{background_id}.{ext}`) — ровно тот же приём, что и у
аватаров (`users.avatar_path`, `services/avatars.py`): «чтобы не грузили БД»
(требование оператора). Сами файлы лежат на диске в томе `media`, который
nginx раздаёт напрямую по `location /media/`.
Лимит на число картинок (`MAX_BACKGROUNDS_PER_USER`) проверяется в сервисе, а
не ограничением БД: он про политику продукта, а не про целостность данных, и
администратор может захотеть его поменять.
"""
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, func, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from models.base import Base
class UserBackground(Base):
"""Загруженная пользователем картинка фона."""
__tablename__ = "user_backgrounds"
__table_args__ = (
# Выборка всегда одна и та же — «все фоны этого пользователя, свежие
# сверху» (`services/backgrounds.py::list_backgrounds`), и она же
# считает лимит при загрузке.
Index("ix_user_backgrounds_user_created", "user_id", "created_at"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
# Путь относительно `MEDIA_ROOT`: `backgrounds/{user_id}/{id}.{ext}`.
path: Mapped[str] = mapped_column(String(512), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)

View File

@@ -1,6 +1,7 @@
"""Репозиторий доступа к таблице `users`.""" """Репозиторий доступа к таблице `users`."""
import uuid import uuid
from datetime import datetime
from sqlalchemy import or_, select from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -30,9 +31,24 @@ class UserRepository:
name_user: str, name_user: str,
password_hash: str, password_hash: str,
team_id: uuid.UUID | None = None, team_id: uuid.UUID | None = None,
consent_version: int | None = None,
consent_given_at: datetime | None = None,
) -> User: ) -> User:
"""Создать нового пользователя (role='user', email_verified=False по умолчанию).""" """Создать нового пользователя (role='user', email_verified=False по умолчанию).
user = User(email=email, name_user=name_user, password_hash=password_hash, team_id=team_id)
`consent_version`/`consent_given_at` — редакция регламента обработки
персональных данных, с которой согласился пользователь, и время
согласия; `None` у обоих, если согласие не запрашивалось (модуль
выключен) — см. `services.auth.AuthService.register`.
"""
user = User(
email=email,
name_user=name_user,
password_hash=password_hash,
team_id=team_id,
consent_version=consent_version,
consent_given_at=consent_given_at,
)
self._session.add(user) self._session.add(user)
await self._session.flush() await self._session.flush()
return user return user

View File

@@ -6,7 +6,7 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field from pydantic import BaseModel, ConfigDict, EmailStr, Field
from core.plugins.config import AiLevel, SummaryRecipientsMode from core.plugins.config import AiLevel, PublishQualityCap, StageMaxTiles, SummaryRecipientsMode
from schemas.conferences import ConferenceOut from schemas.conferences import ConferenceOut
from services.ai_levels import AiLevelStatus from services.ai_levels import AiLevelStatus
@@ -147,6 +147,7 @@ class SettingsOut(BaseModel):
""" """
chat_enabled: bool chat_enabled: bool
hand_queue_enabled: bool
transcription_enabled: bool transcription_enabled: bool
ai_level: AiLevel ai_level: AiLevel
ai_levels: list[AiLevelStatus] ai_levels: list[AiLevelStatus]
@@ -158,6 +159,19 @@ class SettingsOut(BaseModel):
registration_email_domains: list[str] = Field(default_factory=list) registration_email_domains: list[str] = Field(default_factory=list)
contact_email_enabled: bool contact_email_enabled: bool
contact_email: str | None = None contact_email: str | None = None
# Рычаги нагрузки медиа (`InstanceConfig.media_limits`) — потолок
# качества публикации и максимум плиток сцены, см. `core/plugins/config.py`.
publish_quality_cap: PublishQualityCap
stage_max_tiles: StageMaxTiles
# Согласие на обработку персональных данных при регистрации — см.
# `core/plugins/config.py::InstanceConfig`.
consent_required: bool
consent_policy_text: str
consent_policy_version: int
# Проверка устройств на входе (сессия 33) — см. `core/plugins/config.py::InstanceConfig`.
device_check_enabled: bool
# Замена фона видео (сессия 35) — см. `core/plugins/config.py::InstanceConfig`.
virtual_background_enabled: bool
class TestEmailIn(BaseModel): class TestEmailIn(BaseModel):

View File

@@ -11,12 +11,18 @@ class RegisterIn(BaseModel):
`team_id` допустим только при включённой настройке инстанса `team_id` допустим только при включённой настройке инстанса
`registration_team_choice` (см. `GET /auth/registration-options`) и `registration_team_choice` (см. `GET /auth/registration-options`) и
существующей команде — иначе `POST /auth/register` вернёт 400. существующей команде — иначе `POST /auth/register` вернёт 400.
`consent_accepted` обязан быть `True`, если в настройках инстанса
включено `consent_required` (согласие на обработку персональных
данных) — иначе `POST /auth/register` вернёт 400. Игнорируется, если
настройка выключена (второй эшелон проверки — фронт тоже блокирует
кнопку, но сервер не полагается на это).
""" """
email: EmailStr email: EmailStr
name_user: str = Field(min_length=1, max_length=255) name_user: str = Field(min_length=1, max_length=255)
password: str = Field(min_length=8) password: str = Field(min_length=8)
team_id: uuid.UUID | None = None team_id: uuid.UUID | None = None
consent_accepted: bool = False
class VerifyEmailIn(BaseModel): class VerifyEmailIn(BaseModel):
@@ -80,6 +86,29 @@ class UserProfileOut(UserOut):
team_name: str | None = None team_name: str | None = None
class UserBackgroundOut(BaseModel):
"""Своя картинка пользователя для замены фона видео (`GET /users/me/backgrounds`).
Отдаётся только URL файла (`/media/backgrounds/...`, раздаёт nginx) и id для
удаления — путь на диске наружу не показывается.
"""
id: uuid.UUID
url: str
class UserBackgroundsOut(BaseModel):
"""Список своих картинок фона вместе с лимитом.
Лимит приезжает с сервера, а не зашит в интерфейс: он проверяется на
сервере (`services/backgrounds.py`), и фронт не должен угадывать его
отдельной константой, которая разъедется при первой же правке.
"""
items: list[UserBackgroundOut]
limit: int
class PasswordChangeIn(BaseModel): class PasswordChangeIn(BaseModel):
"""Тело смены пароля текущим пользователем (`POST /users/me/password`). """Тело смены пароля текущим пользователем (`POST /users/me/password`).
@@ -111,3 +140,10 @@ class RegistrationOptionsOut(BaseModel):
team_choice_enabled: bool team_choice_enabled: bool
teams: list[RegistrationTeamOptionOut] teams: list[RegistrationTeamOptionOut]
email_domains: list[str] = Field(default_factory=list) email_domains: list[str] = Field(default_factory=list)
# Согласие на обработку персональных данных: `consent_required` — обязательна
# ли галочка на форме регистрации; `consent_text`/`consent_version` отдаются
# ВСЕГДА, независимо от `consent_required` — той же строкой пользуется
# публичная страница регламента, доступная и при выключенном модуле.
consent_required: bool = False
consent_text: str = ""
consent_version: int = 1

View File

@@ -5,7 +5,8 @@ from datetime import UTC, datetime, timedelta
from pydantic import BaseModel, EmailStr, Field, field_serializer, field_validator, model_validator from pydantic import BaseModel, EmailStr, Field, field_serializer, field_validator, model_validator
from core.plugins.config import SummaryRecipientsMode from core.plugins.config import PublishQualityCap, StageMaxTiles, SummaryRecipientsMode
from schemas.room_events import ForcedMuteSource
from services.recurrence import RecurrenceRule from services.recurrence import RecurrenceRule
# Допуск в прошлое при плановом создании/правке — небольшой запас на задержку # Допуск в прошлое при плановом создании/правке — небольшой запас на задержку
@@ -129,6 +130,19 @@ class JoinOut(BaseModel):
# Тоггл инстанса `chat.enabled` на момент входа — клиент решает, # Тоггл инстанса `chat.enabled` на момент входа — клиент решает,
# показывать ли UI чата, не дожидаясь ошибки WS-подключения. # показывать ли UI чата, не дожидаясь ошибки WS-подключения.
chat_enabled: bool chat_enabled: bool
# Тоггл инстанса `hand_queue.enabled` на момент входа — гасит модуль
# «поднятие руки» целиком (кнопка + очередь), см. `HandQueueConfig`.
hand_queue_enabled: bool
# Рычаги нагрузки медиа (`instance_settings.media_limits`) — отдаются
# прямо в join-ответе, а не только в админке: участнику нужно иметь их
# на руках ДО публикации своего трека (см. `services/conference_access.py`).
publish_quality_cap: PublishQualityCap
stage_max_tiles: StageMaxTiles
# Тоггл инстанса `virtual_background.enabled` — гасит замену фона целиком
# (кнопка в тулбаре комнаты и применение процессора к треку). Едет здесь,
# а не через `/admin/settings` (доступен только админу): значение нужно
# участнику — в том числе гостю — до первого рендера комнаты.
virtual_background_enabled: bool
class ConferenceOut(BaseModel): class ConferenceOut(BaseModel):
@@ -210,3 +224,21 @@ class GuestJoinIn(BaseModel):
display_name: str = Field(min_length=1, max_length=255) display_name: str = Field(min_length=1, max_length=255)
email: EmailStr | None = None email: EmailStr | None = None
password: str | None = None password: str | None = None
class MuteParticipantIn(BaseModel):
"""Тело запроса принудительного мьюта участника организатором (задача B2).
`identity` — тот же формат, что и `Participant.identity` в LiveKit
(`str(user_id)` либо `guest:{id}`); клиент берёт его из `useParticipants()`
LiveKit, не подбирает вручную.
"""
identity: str = Field(min_length=1)
source: ForcedMuteSource
class MuteParticipantOut(BaseModel):
"""Ответ на принудительный мьют — `muted=False`, если трек и так не был опубликован."""
muted: bool

23
backend/schemas/public.py Normal file
View File

@@ -0,0 +1,23 @@
"""Схемы публичного эндпоинта настроек клиента (`GET /public/settings`)."""
from pydantic import BaseModel
class PublicSettingsOut(BaseModel):
"""Настройки инстанса, нужные клиенту ДО аутентификации.
Общая точка для флагов, которые должны быть на руках у страницы логина
и гостевой карточки входа (`LoginPage`/`JoinPage`) — обе публичные,
`GET /admin/settings` им недоступен (только для админа). Отдельно от
`GET /auth/registration-options`: тот про опции конкретно карточки
регистрации, а не про настройки инстанса в целом (сессия 33).
"""
# Проверка устройств на входе (сессия 33) — см.
# `core/plugins/config.py::InstanceConfig.device_check_enabled`.
device_check_enabled: bool
# Замена фона видео (сессия 35) — нужен превью на `JoinPage`, чтобы
# показать выбор фона ещё до входа в комнату. Участнику УЖЕ в комнате тот
# же флаг приезжает в join-ответе (`JoinOut.virtual_background_enabled`):
# эта страница публичная и `GET /admin/settings` ей недоступен.
virtual_background_enabled: bool

View File

@@ -0,0 +1,61 @@
"""Pydantic-схемы событий комнаты, мультиплексируемых поверх WS-чата (`api/chat.py`).
Отдельный протокол от собственно чата (`schemas/chat.py`): очередь поднятых
рук и уведомления о принудительном мьюте — эфемерное состояние звонка
(Redis, не БД, см. `services/hand_queue.py`) и не должны попадать в
персистентную историю сообщений чата, хотя и едут по тому же соединению.
"""
from datetime import UTC, datetime
from typing import Literal
from pydantic import BaseModel, Field, field_serializer
class RaiseHandIn(BaseModel):
"""Клиент поднимает свою руку."""
type: Literal["raise_hand"]
class LowerHandIn(BaseModel):
"""Клиент опускает руку — свою (без `identity`) либо, только для организатора, чужую."""
type: Literal["lower_hand"]
identity: str | None = None
class HandQueueEntryOut(BaseModel):
"""Один участник в очереди поднятых рук."""
identity: str
name: str
raised_at: datetime
@field_serializer("raised_at")
def _serialize_raised_at(self, value: datetime) -> str:
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
class HandQueueOut(BaseModel):
"""Снапшот очереди поднятых рук — рассылается всем участникам при любом изменении."""
type: Literal["hand_queue"] = "hand_queue"
queue: list[HandQueueEntryOut] = Field(default_factory=list)
ForcedMuteSource = Literal["microphone", "camera"]
class ForcedMuteOut(BaseModel):
"""Организатор принудительно выключил трек участника (задача B2) — уведомление всем.
Рассылается всем (не только затронутому), как и `HandQueueOut`: канал —
общий broadcast, а не адресная доставка одному соединению; получатели,
для которых `identity` не совпадает с их собственной, событие
игнорируют.
"""
type: Literal["forced_mute"] = "forced_mute"
identity: str
source: ForcedMuteSource

View File

@@ -24,6 +24,7 @@ from core.security import (
create_refresh_token, create_refresh_token,
decode_token, decode_token,
hash_password, hash_password,
needs_rehash,
verify_password, verify_password,
) )
from models.email_verification import EmailVerificationToken from models.email_verification import EmailVerificationToken
@@ -57,6 +58,15 @@ class InvalidEmailDomainError(Exception):
""" """
class ConsentRequiredError(Exception):
"""Согласие на обработку персональных данных не отмечено.
Поднимается только при включённой настройке инстанса `consent_required`
(см. `InstanceSettingsService`) — второй эшелон проверки, фронт уже не
даёт отправить форму без галочки, но сервер не полагается на это.
"""
class InvalidVerificationTokenError(Exception): class InvalidVerificationTokenError(Exception):
"""Токен подтверждения email не найден, просрочен или уже использован.""" """Токен подтверждения email не найден, просрочен или уже использован."""
@@ -98,6 +108,7 @@ class AuthService:
name_user: str, name_user: str,
password: str, password: str,
team_id: uuid.UUID | None = None, team_id: uuid.UUID | None = None,
consent_accepted: bool = False,
) -> User: ) -> User:
"""Зарегистрировать пользователя и отправить письмо для подтверждения email. """Зарегистрировать пользователя и отправить письмо для подтверждения email.
@@ -108,7 +119,12 @@ class AuthService:
email (`registration_email_domain_enabled`), домен `email` (часть email (`registration_email_domain_enabled`), домен `email` (часть
после `@`, без учёта регистра) должен совпадать с одним из после `@`, без учёта регистра) должен совпадать с одним из
эталонных доменов (`registration_email_domains`) — иначе эталонных доменов (`registration_email_domains`) — иначе
`InvalidEmailDomainError`. Обе проверки — до создания пользователя. `InvalidEmailDomainError`. Если включено согласие на обработку
персональных данных (`consent_required`), `consent_accepted` обязан
быть `True` — иначе `ConsentRequiredError`; при принятии согласия
в `User` пишутся `consent_version`/`consent_given_at` (редакция
регламента на момент согласия и время). Все проверки — до создания
пользователя.
""" """
existing = await self._users.get_by_email(email) existing = await self._users.get_by_email(email)
if existing is not None: if existing is not None:
@@ -128,11 +144,22 @@ class AuthService:
if team is None: if team is None:
raise InvalidTeamSelectionError(team_id) raise InvalidTeamSelectionError(team_id)
if cfg.consent_required and not consent_accepted:
raise ConsentRequiredError
consent_version: int | None = None
consent_given_at: datetime | None = None
if cfg.consent_required and consent_accepted:
consent_version = cfg.consent_policy_version
consent_given_at = datetime.now(UTC)
user = await self._users.create( user = await self._users.create(
email=email, email=email,
name_user=name_user, name_user=name_user,
password_hash=hash_password(password), password_hash=await hash_password(password),
team_id=team_id, team_id=team_id,
consent_version=consent_version,
consent_given_at=consent_given_at,
) )
reply_to = cfg.contact_email if cfg.contact_email_enabled else None reply_to = cfg.contact_email if cfg.contact_email_enabled else None
await self._issue_verification_email(user, reply_to=reply_to) await self._issue_verification_email(user, reply_to=reply_to)
@@ -161,10 +188,21 @@ class AuthService:
async def login(self, *, email: str, password: str) -> TokenPair: async def login(self, *, email: str, password: str) -> TokenPair:
"""Проверить учётные данные и выдать пару access/refresh токенов.""" """Проверить учётные данные и выдать пару access/refresh токенов."""
user = await self._users.get_by_email(email) user = await self._users.get_by_email(email)
if user is None or not verify_password(password, user.password_hash): if user is None or not await verify_password(password, user.password_hash):
raise InvalidCredentialsError raise InvalidCredentialsError
if not user.email_verified: if not user.email_verified:
raise EmailNotVerifiedError raise EmailNotVerifiedError
# Постепенная миграция на актуальные параметры argon2 (см. core/security.py):
# параметры зашиты в саму строку хэша, поэтому старые записи так и
# проверялись бы вдвое дольше. Открытый пароль есть только здесь и
# только сейчас — другого места для перевыпуска не будет.
if needs_rehash(user.password_hash):
user.password_hash = await hash_password(password)
# Явный commit: выдача токенов идёт через Redis и БД не трогает,
# поэтому без него перевыпущенный хэш откатился бы вместе с сессией.
await self._session.commit()
return await self._issue_token_pair(user.id, user.role) return await self._issue_token_pair(user.id, user.role)
async def refresh(self, refresh_token: str) -> TokenPair: async def refresh(self, refresh_token: str) -> TokenPair:

View File

@@ -3,80 +3,49 @@
Файл лежит на диске `MEDIA_ROOT/avatars/{user_id}.{ext}`; в БД (`users.avatar_path`) Файл лежит на диске `MEDIA_ROOT/avatars/{user_id}.{ext}`; в БД (`users.avatar_path`)
хранится путь относительно `MEDIA_ROOT` (`avatars/{user_id}.{ext}`) — тот же хранится путь относительно `MEDIA_ROOT` (`avatars/{user_id}.{ext}`) — тот же
приём, что и у записей аудиотреков (`recordings_dir`, `core/config.py`). приём, что и у записей аудиотреков (`recordings_dir`, `core/config.py`).
Сама проверка содержимого (допустимые форматы, магические байты, реальный
размер) живёт в `services/images.py` — она общая с картинками фона видео
(`services/backgrounds.py`).
""" """
import uuid import uuid
from collections.abc import Callable
from pathlib import Path from pathlib import Path
from fastapi import UploadFile from fastapi import UploadFile
from services.images import (
ImageInvalidTypeError,
ImageTooLargeError,
read_and_validate_image,
)
# Лимит размера загружаемого аватара — 2 МБ. # Лимит размера загружаемого аватара — 2 МБ.
MAX_AVATAR_SIZE_BYTES = 2 * 1024 * 1024 MAX_AVATAR_SIZE_BYTES = 2 * 1024 * 1024
# Читаем файл чанками, не доверяя заголовку `Content-Length` (клиент может
# солгать о размере) — реальный размер считается по факту прочитанных байт.
_CHUNK_SIZE_BYTES = 64 * 1024
# Допустимые типы изображений -> расширение файла на диске. class AvatarTooLargeError(ImageTooLargeError):
_ALLOWED_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 AvatarTooLargeError(Exception):
"""Загружаемый файл превышает `MAX_AVATAR_SIZE_BYTES` (413).""" """Загружаемый файл превышает `MAX_AVATAR_SIZE_BYTES` (413)."""
class AvatarInvalidTypeError(Exception): class AvatarInvalidTypeError(ImageInvalidTypeError):
"""`Content-Type` не входит в список допустимых либо не совпадает с содержимым (415).""" """`Content-Type` не входит в список допустимых либо не совпадает с содержимым (415)."""
async def read_and_validate_avatar(file: UploadFile) -> tuple[bytes, str]: async def read_and_validate_avatar(file: UploadFile) -> tuple[bytes, str]:
"""Прочитать содержимое файла аватара чанками и провалидировать тип/размер. """Прочитать содержимое файла аватара и провалидировать тип/размер.
Возвращает `(содержимое, расширение)`. Порядок проверок: сначала Возвращает `(содержимое, расширение)`. Ошибки общего валидатора
заявленный `Content-Type` (быстрый отсев), затем фактический размер по перезаворачиваются в «аватарные» — вызывающий код (`api/users.py`,
мере чтения, затем магические байты содержимого — заявленный тип должен `api/admin.py`) отображает их в 413/415 и не должен знать про
совпасть с реальным (иначе подделка `Content-Type` не даст загрузить, `services/images.py`.
например, исполняемый файл под видом `image/png`).
""" """
declared_type = file.content_type try:
if declared_type not in _ALLOWED_CONTENT_TYPES: return await read_and_validate_image(file, MAX_AVATAR_SIZE_BYTES)
raise AvatarInvalidTypeError(f"unsupported_content_type: {declared_type}") except ImageTooLargeError as exc:
raise AvatarTooLargeError(str(exc)) from exc
chunks: list[bytes] = [] except ImageInvalidTypeError as exc:
total_size = 0 raise AvatarInvalidTypeError(str(exc)) from exc
while True:
chunk = await file.read(_CHUNK_SIZE_BYTES)
if not chunk:
break
total_size += len(chunk)
if total_size > MAX_AVATAR_SIZE_BYTES:
raise AvatarTooLargeError(f"file exceeds {MAX_AVATAR_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 AvatarInvalidTypeError("content_does_not_match_declared_content_type")
return content, _ALLOWED_CONTENT_TYPES[declared_type]
def _avatar_relative_path(user_id: uuid.UUID, ext: str) -> str: def _avatar_relative_path(user_id: uuid.UUID, ext: str) -> str:

View File

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

View File

@@ -148,6 +148,15 @@ class ChatService:
raise WrongRoomError raise WrongRoomError
return conference return conference
async def hand_queue_enabled(self) -> bool:
"""Тоггл инстанса `hand_queue.enabled` — снимается один раз при подключении WS
(см. `api/chat.py::chat_websocket`), а не на каждое сообщение: та же
осознанная «застылость» на время жизни соединения, что и у
`is_organizer` в `_pump_websocket_to_service` — переключение модуля
администратором применяется со следующего подключения."""
cfg = await InstanceSettingsService(self._session).get()
return cfg.hand_queue.enabled
async def history(self, conference: Conference) -> list[ChatMessageOut]: async def history(self, conference: Conference) -> list[ChatMessageOut]:
"""Последние сообщения открытой сессии конференции (пусто, если сессии ещё нет).""" """Последние сообщения открытой сессии конференции (пусто, если сессии ещё нет)."""
session_record = await self._sessions.get_open_by_conference(conference.id) session_record = await self._sessions.get_open_by_conference(conference.id)

View File

@@ -9,6 +9,7 @@
import json import json
from core.config import get_settings from core.config import get_settings
from core.plugins.config import PublishQualityCap, StageMaxTiles
from core.security import verify_password from core.security import verify_password
from models.conference import Conference from models.conference import Conference
from schemas.conferences import JoinOut from schemas.conferences import JoinOut
@@ -27,7 +28,7 @@ class InvalidPasswordError(Exception):
"""Указанный пароль не совпадает с паролем закрытой конференции.""" """Указанный пароль не совпадает с паролем закрытой конференции."""
def ensure_joinable(conference: Conference, *, password: str | None) -> None: async def ensure_joinable(conference: Conference, *, password: str | None) -> None:
"""Проверить, что в конференцию можно войти прямо сейчас. """Проверить, что в конференцию можно войти прямо сейчас.
Бросает `ConferenceEndedError` для терминального статуса `ended` Бросает `ConferenceEndedError` для терминального статуса `ended`
@@ -42,7 +43,7 @@ def ensure_joinable(conference: Conference, *, password: str | None) -> None:
return return
if conference.password_hash is None or password is None: if conference.password_hash is None or password is None:
raise PasswordRequiredError raise PasswordRequiredError
if not verify_password(password, conference.password_hash): if not await verify_password(password, conference.password_hash):
raise InvalidPasswordError raise InvalidPasswordError
@@ -52,20 +53,39 @@ def build_join(
identity: str, identity: str,
name: str, name: str,
chat_enabled: bool, chat_enabled: bool,
hand_queue_enabled: bool,
publish_quality_cap: PublishQualityCap,
stage_max_tiles: StageMaxTiles,
virtual_background_enabled: bool,
avatar_url: str | None = None, avatar_url: str | None = None,
is_organizer: bool = False,
) -> JoinOut: ) -> JoinOut:
"""Построить ответ join: LiveKit access-токен для входа в комнату конференции. """Построить ответ join: LiveKit access-токен для входа в комнату конференции.
Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4). Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4).
`chat_enabled` — снятый вызывающей стороной тоггл `instance_settings`: `chat_enabled`/`hand_queue_enabled`/`publish_quality_cap`/`stage_max_tiles`/
читается здесь параметром, а не заново из БД, чтобы не плодить `virtual_background_enabled`
отдельный запрос настроек на каждый join. `avatar_url` прокидывается — снятые вызывающей стороной значения `instance_settings`: читаются здесь
в метаданные токена как JSON параметрами, а не заново из БД, чтобы не плодить отдельный запрос настроек
`{"avatar_url": ...}`; `None` (гость либо пользователь без аватара) — на каждый join.
метаданные не выставляются вовсе. `avatar_url`/`is_organizer`
прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}`
— поля добавляются, только если заданы (гость без аватара и не-организатор
получают токен вовсе без метаданных, как и раньше).
⚠️ `is_organizer` в метаданных — только подсказка для UI клиента (показать/
скрыть кнопки организатора). Метаданным токена доверять для АВТОРИЗАЦИИ
нельзя — участник технически может их подделать на своей стороне. Любое
серверное действие организатора (например, принудительный мьют) обязано
заново проверяться по `conference.owner_id` в БД, а не по этому полю.
""" """
settings = get_settings() settings = get_settings()
metadata = json.dumps({"avatar_url": avatar_url}) if avatar_url else None 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( token = create_room_access_token(
room_name=conference.slug, identity=identity, name=name, metadata=metadata room_name=conference.slug, identity=identity, name=name, metadata=metadata
) )
@@ -75,4 +95,8 @@ def build_join(
room_name=conference.slug, room_name=conference.slug,
conference_id=conference.id, conference_id=conference.id,
chat_enabled=chat_enabled, 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,
) )

View File

@@ -33,12 +33,14 @@ from schemas.conferences import (
JoinOut, JoinOut,
OccurrenceOut, OccurrenceOut,
) )
from services import hand_queue
from services.avatars import avatar_url as resolve_avatar_url from services.avatars import avatar_url as resolve_avatar_url
from services.conference_access import build_join, ensure_joinable from services.conference_access import build_join, ensure_joinable
from services.conference_ids import generate_number, generate_slug from services.conference_ids import generate_number, generate_slug
from services.instance_settings import InstanceSettingsService from services.instance_settings import InstanceSettingsService
from services.invitations_producer import enqueue_invitations from services.invitations_producer import enqueue_invitations
from services.recurrence import RecurrenceRule, expand_occurrences from services.recurrence import RecurrenceRule, expand_occurrences
from services.room_control import MuteSource, mute_participant_track
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -99,7 +101,7 @@ class ConferenceService:
повторением без явного `scheduled_at` — плановая конференция, повторением без явного `scheduled_at` — плановая конференция,
ожидающая своего первого вхождения, а не мгновенный вход. ожидающая своего первого вхождения, а не мгновенный вход.
""" """
password_hash = hash_password(data.password) if data.password else None password_hash = await hash_password(data.password) if data.password else None
is_instant = data.scheduled_at is None and data.recurrence is None is_instant = data.scheduled_at is None and data.recurrence is None
conference_status = "active" if is_instant else "scheduled" conference_status = "active" if is_instant else "scheduled"
recurrence_json = data.recurrence.model_dump(mode="json") if data.recurrence else None recurrence_json = data.recurrence.model_dump(mode="json") if data.recurrence else None
@@ -147,13 +149,18 @@ class ConferenceService:
join = None join = None
if is_instant: if is_instant:
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled cfg = await InstanceSettingsService(self._session).get()
join = build_join( join = build_join(
conference, conference,
identity=str(owner_id), identity=str(owner_id),
name=owner_name, name=owner_name,
chat_enabled=chat_enabled, chat_enabled=cfg.chat.enabled,
hand_queue_enabled=cfg.hand_queue.enabled,
publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles,
virtual_background_enabled=cfg.virtual_background_enabled,
avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path), avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path),
is_organizer=True,
) )
else: else:
# Плановая (разовая) либо закреплённая с повторением/датой — есть # Плановая (разовая) либо закреплённая с повторением/датой — есть
@@ -239,20 +246,25 @@ class ConferenceService:
) -> JoinOut: ) -> JoinOut:
"""Войти в конференцию зарегистрированным пользователем.""" """Войти в конференцию зарегистрированным пользователем."""
conference = await self._get_or_raise(conference_id) conference = await self._get_or_raise(conference_id)
ensure_joinable(conference, password=password) await ensure_joinable(conference, password=password)
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled cfg = await InstanceSettingsService(self._session).get()
return build_join( return build_join(
conference, conference,
identity=str(user.id), identity=str(user.id),
name=user.name_user, name=user.name_user,
chat_enabled=chat_enabled, chat_enabled=cfg.chat.enabled,
hand_queue_enabled=cfg.hand_queue.enabled,
publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles,
virtual_background_enabled=cfg.virtual_background_enabled,
avatar_url=resolve_avatar_url(self._media_root, user.avatar_path), avatar_url=resolve_avatar_url(self._media_root, user.avatar_path),
is_organizer=conference.owner_id is not None and conference.owner_id == user.id,
) )
async def join_as_guest(self, conference_id: uuid.UUID, *, data: GuestJoinIn) -> JoinOut: async def join_as_guest(self, conference_id: uuid.UUID, *, data: GuestJoinIn) -> JoinOut:
"""Войти в конференцию гостем: создать `GuestAccess` и выдать токен.""" """Войти в конференцию гостем: создать `GuestAccess` и выдать токен."""
conference = await self._get_or_raise(conference_id) conference = await self._get_or_raise(conference_id)
ensure_joinable(conference, password=data.password) await ensure_joinable(conference, password=data.password)
guest = GuestAccess( guest = GuestAccess(
conference_id=conference.id, display_name=data.display_name, email=data.email conference_id=conference.id, display_name=data.display_name, email=data.email
@@ -261,14 +273,48 @@ class ConferenceService:
await self._session.flush() await self._session.flush()
await self._session.commit() await self._session.commit()
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled cfg = await InstanceSettingsService(self._session).get()
return build_join( return build_join(
conference, conference,
identity=f"guest:{guest.id}", identity=f"guest:{guest.id}",
name=data.display_name, name=data.display_name,
chat_enabled=chat_enabled, chat_enabled=cfg.chat.enabled,
hand_queue_enabled=cfg.hand_queue.enabled,
publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles,
virtual_background_enabled=cfg.virtual_background_enabled,
) )
async def mute_participant(
self,
conference_id: uuid.UUID,
*,
actor: User,
target_identity: str,
source: MuteSource,
) -> bool:
"""Принудительно замьютить трек участника (задача B2); владелец/администратор.
Права — ТОЛЬКО отсюда (`_ensure_owner_or_admin` по `conference.owner_id`
в БД), не по метаданным LiveKit-токена вызывающего: те лишь подсказка
для UI (см. `services/conference_access.py::build_join`) и потенциально
подделываемы клиентом. `target_identity` НИКАК не валидируется против
состава участников заранее — если его сейчас нет в комнате LiveKit,
`mute_participant_track` бросит `ParticipantNotInRoomError` (ловит
API-роутер).
"""
conference = await self._get_or_raise(conference_id)
self._ensure_owner_or_admin(conference, actor)
muted = await mute_participant_track(
conference.slug, identity=target_identity, source=source
)
if muted:
await hand_queue.publish_forced_mute(
conference.id, identity=target_identity, source=source
)
return muted
async def update( async def update(
self, conference_id: uuid.UUID, *, actor: User, data: ConferenceUpdateIn self, conference_id: uuid.UUID, *, actor: User, data: ConferenceUpdateIn
) -> Conference: ) -> Conference:
@@ -296,7 +342,7 @@ class ConferenceService:
if data.is_closed is not None: if data.is_closed is not None:
conference.is_closed = data.is_closed conference.is_closed = data.is_closed
if data.password is not None: if data.password is not None:
conference.password_hash = hash_password(data.password) conference.password_hash = await hash_password(data.password)
if "summary_recipients" in data.model_fields_set: if "summary_recipients" in data.model_fields_set:
# Явная передача (в т.ч. `null`) — сбросить/установить # Явная передача (в т.ч. `null`) — сбросить/установить
# переопределение; отсутствие поля в запросе значение не трогает. # переопределение; отсутствие поля в запросе значение не трогает.

View File

@@ -0,0 +1,144 @@
"""Очередь поднятых рук конференции — состояние в Redis, не в Postgres (задача B1).
Транспорт для клиентов — тот же аутентифицированный WS чата (`api/chat.py`):
переиспользуем уже открытые и держащиеся сервером соединения вместо отдельного
эндпоинта. Хранение — Redis, а не БД: очередь существует ровно во время звонка
и не должна переживать его завершение (в отличие от истории чата), а два
процесса uvicorn (`UVICORN_WORKERS`) делают наивное состояние в памяти одного
процесса недостаточным — организатор и участник могут оказаться на разных
воркерах.
Один Redis-ключ (HASH) на конференцию: поле — identity участника (тот же
формат, что в LiveKit-токене и вебхуках — `str(user_id)` или
`guest:{guest_access.id}`), значение — JSON `{"name": ..., "raised_at": <unix
epoch>}`. `HSETNX` даёт атомарное «добавить, только если ещё нет» — повторное
поднятие уже поднятой руки НЕ сбрасывает её место в очереди (идемпотентно).
Порядок — сортировкой по `raised_at` при чтении снапшота (участников в одной
конференции — единицы-десятки, сортировка в Python здесь дешевле, чем держать
вторую структуру (ZSET) синхронно с первой).
"""
import json
import time
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from core.redis import redis_client
from schemas.room_events import ForcedMuteOut, ForcedMuteSource, HandQueueEntryOut, HandQueueOut
# TTL ключа очереди — подстраховка на случай пропущенного webhook
# `room_finished` (см. `services/webhook_handlers.py::_on_room_finished`,
# который чистит очередь явно при штатном завершении). Сама конференция
# столько не длится ни при каких сценариях.
HAND_QUEUE_TTL_SECONDS = 24 * 60 * 60
def hand_queue_key(conference_id: uuid.UUID) -> str:
"""Redis-ключ HASH очереди поднятых рук конкретной конференции."""
return f"hand_queue:{conference_id}"
def hand_queue_channel(conference_id: uuid.UUID) -> str:
"""Redis pub/sub канал событий комнаты (очередь рук + принудительный мьют, задача B2)."""
return f"room_events:{conference_id}"
@dataclass(frozen=True, slots=True)
class HandQueueEntry:
"""Один участник в очереди поднятых рук."""
identity: str
name: str
raised_at: float
async def raise_hand(conference_id: uuid.UUID, *, identity: str, name: str) -> bool:
"""Поднять руку участника; `True` — рука реально поднялась (не была поднята раньше).
`HSETNX` — атомарная проверка-и-запись: если участник уже в очереди,
ничего не меняет (в т.ч. НЕ обновляет `raised_at`) — переподключение и
повторный клик не переставляют его в конец очереди.
"""
key = hand_queue_key(conference_id)
payload = json.dumps({"name": name, "raised_at": time.time()})
added = await redis_client.hsetnx(key, identity, payload)
await redis_client.expire(key, HAND_QUEUE_TTL_SECONDS)
return bool(added)
async def lower_hand(conference_id: uuid.UUID, *, identity: str) -> bool:
"""Опустить руку участника; `True` — рука была поднята и теперь снята."""
removed = await redis_client.hdel(hand_queue_key(conference_id), identity)
return bool(removed)
async def snapshot(conference_id: uuid.UUID) -> list[HandQueueEntry]:
"""Текущая очередь, упорядоченная по времени поднятия (раньше — раньше в списке)."""
raw = await redis_client.hgetall(hand_queue_key(conference_id))
entries = []
for identity, payload in raw.items():
try:
data = json.loads(payload)
entries.append(
HandQueueEntry(
identity=str(identity), name=data["name"], raised_at=data["raised_at"]
)
)
except (ValueError, KeyError, TypeError):
# Побитый/устаревшего формата элемент — пропускаем, а не роняем всю очередь.
continue
entries.sort(key=lambda entry: entry.raised_at)
return entries
async def clear(conference_id: uuid.UUID) -> None:
"""Полностью снести очередь конференции (штатное завершение — `room_finished`)."""
await redis_client.delete(hand_queue_key(conference_id))
def _to_out(entries: list[HandQueueEntry]) -> HandQueueOut:
"""Собрать исходящий снапшот из внутренних записей очереди."""
return HandQueueOut(
queue=[
HandQueueEntryOut(
identity=entry.identity,
name=entry.name,
raised_at=datetime.fromtimestamp(entry.raised_at, tz=UTC),
)
for entry in entries
]
)
async def get_snapshot_out(conference_id: uuid.UUID) -> HandQueueOut:
"""Текущая очередь в исходящем формате — для отправки сразу после подключения к WS."""
return _to_out(await snapshot(conference_id))
async def publish_snapshot(conference_id: uuid.UUID) -> None:
"""Опубликовать текущий снапшот очереди всем подписчикам канала комнаты.
Вызывается после любого изменения очереди (`raise_hand`/`lower_hand` —
из `api/chat.py`, а также `participant_left`/`room_finished` — из
`services/webhook_handlers.py`), чтобы у всех участников (и особенно у
организатора, зашедшего позже) была всегда актуальная картина.
"""
payload = _to_out(await snapshot(conference_id))
await redis_client.publish(hand_queue_channel(conference_id), payload.model_dump_json())
async def publish_forced_mute(
conference_id: uuid.UUID, *, identity: str, source: ForcedMuteSource
) -> None:
"""Оповестить всех участников комнаты о принудительном мьюте (задача B2).
Тот же канал, что и у очереди рук (`hand_queue_channel`) — `api/chat.py`
пересылает с него ЛЮБОЙ JSON как есть, различая события по полю `type`
(см. `_pump_pubsub_to_websocket`). Рассылается ВСЕМ, а не адресно
затронутому участнику: канал общий на конференцию, адресной доставки
одному соединению тут нет, поэтому клиент сам сверяет `identity` со
своей (см. `ForcedMuteOut` в `schemas/room_events.py`).
"""
payload = ForcedMuteOut(identity=identity, source=source)
await redis_client.publish(hand_queue_channel(conference_id), payload.model_dump_json())

View File

@@ -0,0 +1,79 @@
"""Общая валидация загружаемых картинок: тип по содержимому и реальный размер.
Выделено из `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]

View File

@@ -1,8 +1,9 @@
"""Хранилище настроек инстанса (`instance_settings`, key-value JSONB) и их бутстрап. """Хранилище настроек инстанса (`instance_settings`, key-value JSONB) и их бутстрап.
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`, Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
`ai_level`, `summary_recipients`, `display_timezone`, `hand_queue`, `ai_level`, `summary_recipients`, `display_timezone`,
`registration_team_choice`, `registration_email_domain`, `contact_email`) — `registration_team_choice`, `registration_email_domain`, `contact_email`,
`media_limits`) —
новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`) новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO
NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов
@@ -27,8 +28,12 @@ from core.config import Settings
from core.plugins.config import ( from core.plugins.config import (
AiLevel, AiLevel,
ChatConfig, ChatConfig,
HandQueueConfig,
InstanceConfig, InstanceConfig,
MediaLimitsConfig,
PluginsConfig, PluginsConfig,
PublishQualityCap,
StageMaxTiles,
SummarizerConfig, SummarizerConfig,
SummaryRecipientsMode, SummaryRecipientsMode,
TranscriberConfig, TranscriberConfig,
@@ -41,12 +46,17 @@ from services.ai_tiers import TIERS
_KEY_TRANSCRIBER = "transcriber" _KEY_TRANSCRIBER = "transcriber"
_KEY_SUMMARIZER = "summarizer" _KEY_SUMMARIZER = "summarizer"
_KEY_CHAT = "chat" _KEY_CHAT = "chat"
_KEY_HAND_QUEUE = "hand_queue"
_KEY_AI_LEVEL = "ai_level" _KEY_AI_LEVEL = "ai_level"
_KEY_SUMMARY_RECIPIENTS = "summary_recipients" _KEY_SUMMARY_RECIPIENTS = "summary_recipients"
_KEY_DISPLAY_TIMEZONE = "display_timezone" _KEY_DISPLAY_TIMEZONE = "display_timezone"
_KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice" _KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice"
_KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain" _KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain"
_KEY_CONTACT_EMAIL = "contact_email" _KEY_CONTACT_EMAIL = "contact_email"
_KEY_MEDIA_LIMITS = "media_limits"
_KEY_CONSENT_POLICY = "consent_policy"
_KEY_DEVICE_CHECK = "device_check"
_KEY_VIRTUAL_BACKGROUND = "virtual_background"
BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = ( BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
_KEY_CHAT, _KEY_CHAT,
@@ -70,6 +80,46 @@ _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "d
для обратной совместимости с уже развёрнутыми инстансами; при первом же для обратной совместимости с уже развёрнутыми инстансами; при первом же
`update()` значение переписывается в новую форму (см. `update`).""" `update()` значение переписывается в новую форму (см. `update`)."""
_DEFAULT_CONTACT_EMAIL_VALUE: dict[str, Any] = {"enabled": False, "email": None} _DEFAULT_CONTACT_EMAIL_VALUE: dict[str, Any] = {"enabled": False, "email": None}
_DEFAULT_MEDIA_LIMITS_VALUE: dict[str, Any] = {"publish_quality_cap": "off", "stage_max_tiles": 25}
_DEFAULT_DEVICE_CHECK_VALUE = {"enabled": False}
_DEFAULT_VIRTUAL_BACKGROUND_VALUE = {"enabled": False}
"""Замена фона видео (сессия 35). Дефолт — выключено: фича постоянно считает
нейросеть сегментации на клиенте, и включать её самим фактом обновления у тех,
кто ничего не просил, нельзя (то же правило, что и у остальных модулей)."""
DEFAULT_CONSENT_POLICY_TEXT = """Это типовой шаблон для предварительной демонстрации. Текст не проходил проверку юриста и не может использоваться как окончательная редакция без такой проверки. Администратор обязан заменить плейсхолдеры в квадратных скобках и, при необходимости, весь текст — под свою организацию и юрисдикцию.
1. Оператор персональных данных
Оператором персональных данных, обрабатываемых при использовании сервиса [название сервиса], является: [полное наименование организации], [ОГРН/ИНН], адрес места нахождения: [адрес]. Контакты по вопросам обработки персональных данных: [email], [телефон].
2. Правовое основание обработки
Обработка персональных данных осуществляется в соответствии с Конституцией Российской Федерации, Федеральным законом от 27.07.2006 № 152-ФЗ «О персональных данных» и принятыми в соответствии с ним нормативными правовыми актами, на основании согласия субъекта персональных данных (статья 9 Федерального закона № 152-ФЗ).
3. Состав и цели обработки
При регистрации в сервисе обрабатываются следующие персональные данные: адрес электронной почты, имя и фамилия (или иное указанное пользователем имя), пароль (в виде хеша) [дополнить при необходимости].
Цели обработки: [указать цели — например: создание учётной записи, идентификация пользователя, обеспечение доступа к видеоконференциям, направление служебных уведомлений].
4. Срок обработки и хранения
Персональные данные хранятся в течение [указать срок — например: срока действия учётной записи и установленного законом срока после её удаления] либо до отзыва согласия, если это не противоречит требованиям законодательства.
5. Действия с персональными данными
В отношении персональных данных совершаются следующие действия: сбор, запись, систематизация, накопление, хранение, уточнение, извлечение, использование, передача (в объёме, необходимом для функционирования сервиса), обезличивание, блокирование, удаление, уничтожение.
6. Права субъекта персональных данных
Субъект персональных данных вправе получать информацию о том, как обрабатываются его персональные данные, требовать их уточнения, блокирования или уничтожения, а также отозвать согласие на обработку, обратившись по контактам, указанным в разделе 1.
7. Согласие
Регистрируясь в сервисе, пользователь подтверждает, что ознакомлен с настоящим регламентом и даёт согласие на обработку своих персональных данных на условиях, изложенных выше."""
"""Дефолтный текст регламента (ключ `consent_policy`) — согласован с оператором
до встраивания в код (сессия 30). Шаблон с плейсхолдерами в квадратных
скобках, без указания конкретной организации — администратор обязан
заменить их под свою организацию перед вводом в эксплуатацию."""
_DEFAULT_CONSENT_POLICY_VALUE: dict[str, Any] = {
"enabled": False,
"text": DEFAULT_CONSENT_POLICY_TEXT,
"version": 1,
}
# Простой паттерн доменного имени: минимум один символ, минимум одна точка, # Простой паттерн доменного имени: минимум один символ, минимум одна точка,
# метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса), # метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса),
@@ -94,6 +144,7 @@ class SettingsUpdateIn(BaseModel):
""" """
chat_enabled: bool | None = None chat_enabled: bool | None = None
hand_queue_enabled: bool | None = None
transcription_enabled: bool | None = None transcription_enabled: bool | None = None
ai_level: AiLevel | None = None ai_level: AiLevel | None = None
summary_recipients: SummaryRecipientsMode | None = None summary_recipients: SummaryRecipientsMode | None = None
@@ -103,6 +154,12 @@ class SettingsUpdateIn(BaseModel):
registration_email_domains: list[str] | None = None registration_email_domains: list[str] | None = None
contact_email_enabled: bool | None = None contact_email_enabled: bool | None = None
contact_email: str | None = None contact_email: str | None = None
publish_quality_cap: PublishQualityCap | None = None
stage_max_tiles: StageMaxTiles | None = None
consent_required: bool | None = None
consent_policy_text: str | None = None
device_check_enabled: bool | None = None
virtual_background_enabled: bool | None = None
class BootstrapOverrides(BaseModel): class BootstrapOverrides(BaseModel):
@@ -146,12 +203,17 @@ def build_bootstrap_defaults(
_KEY_TRANSCRIBER: plugins.transcriber.model_dump(mode="json"), _KEY_TRANSCRIBER: plugins.transcriber.model_dump(mode="json"),
_KEY_SUMMARIZER: plugins.summarizer.model_dump(mode="json"), _KEY_SUMMARIZER: plugins.summarizer.model_dump(mode="json"),
_KEY_CHAT: plugins.chat.model_dump(mode="json"), _KEY_CHAT: plugins.chat.model_dump(mode="json"),
_KEY_HAND_QUEUE: plugins.hand_queue.model_dump(mode="json"),
_KEY_AI_LEVEL: dict(_DEFAULT_AI_LEVEL_VALUE), _KEY_AI_LEVEL: dict(_DEFAULT_AI_LEVEL_VALUE),
_KEY_SUMMARY_RECIPIENTS: dict(_DEFAULT_SUMMARY_RECIPIENTS_VALUE), _KEY_SUMMARY_RECIPIENTS: dict(_DEFAULT_SUMMARY_RECIPIENTS_VALUE),
_KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE), _KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE),
_KEY_REGISTRATION_TEAM_CHOICE: dict(_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE), _KEY_REGISTRATION_TEAM_CHOICE: dict(_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE),
_KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE), _KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE),
_KEY_CONTACT_EMAIL: dict(_DEFAULT_CONTACT_EMAIL_VALUE), _KEY_CONTACT_EMAIL: dict(_DEFAULT_CONTACT_EMAIL_VALUE),
_KEY_MEDIA_LIMITS: dict(_DEFAULT_MEDIA_LIMITS_VALUE),
_KEY_CONSENT_POLICY: dict(_DEFAULT_CONSENT_POLICY_VALUE),
_KEY_DEVICE_CHECK: dict(_DEFAULT_DEVICE_CHECK_VALUE),
_KEY_VIRTUAL_BACKGROUND: dict(_DEFAULT_VIRTUAL_BACKGROUND_VALUE),
} }
if overrides is None: if overrides is None:
return defaults return defaults
@@ -188,6 +250,11 @@ class InvalidEmailDomainError(ValueError):
""" """
class InvalidConsentPolicyError(ValueError):
"""Попытка включить обязательное согласие при пустом тексте регламента
(`consent_required=True` без непустого `consent_policy_text`)."""
class InvalidContactEmailError(ValueError): class InvalidContactEmailError(ValueError):
"""Некорректная настройка контактного адреса инстанса. """Некорректная настройка контактного адреса инстанса.
@@ -277,6 +344,18 @@ class InstanceSettingsService:
cfg.chat = ChatConfig(enabled=patch.chat_enabled) cfg.chat = ChatConfig(enabled=patch.chat_enabled)
await self._set(_KEY_CHAT, cfg.chat.model_dump(mode="json")) await self._set(_KEY_CHAT, cfg.chat.model_dump(mode="json"))
if patch.hand_queue_enabled is not None:
cfg.hand_queue = HandQueueConfig(enabled=patch.hand_queue_enabled)
await self._set(_KEY_HAND_QUEUE, cfg.hand_queue.model_dump(mode="json"))
if patch.device_check_enabled is not None:
cfg.device_check_enabled = patch.device_check_enabled
await self._set(_KEY_DEVICE_CHECK, {"enabled": patch.device_check_enabled})
if patch.virtual_background_enabled is not None:
cfg.virtual_background_enabled = patch.virtual_background_enabled
await self._set(_KEY_VIRTUAL_BACKGROUND, {"enabled": patch.virtual_background_enabled})
if patch.registration_team_choice is not None: if patch.registration_team_choice is not None:
cfg.registration_team_choice = patch.registration_team_choice cfg.registration_team_choice = patch.registration_team_choice
await self._set( await self._set(
@@ -340,6 +419,48 @@ class InstanceSettingsService:
await self._set(_KEY_TRANSCRIBER, cfg.transcriber.model_dump(mode="json")) await self._set(_KEY_TRANSCRIBER, cfg.transcriber.model_dump(mode="json"))
await self._set(_KEY_SUMMARIZER, cfg.summarizer.model_dump(mode="json")) await self._set(_KEY_SUMMARIZER, cfg.summarizer.model_dump(mode="json"))
if patch.publish_quality_cap is not None or patch.stage_max_tiles is not None:
cap = (
patch.publish_quality_cap
if patch.publish_quality_cap is not None
else cfg.media_limits.publish_quality_cap
)
max_tiles = (
patch.stage_max_tiles
if patch.stage_max_tiles is not None
else cfg.media_limits.stage_max_tiles
)
cfg.media_limits = MediaLimitsConfig(publish_quality_cap=cap, stage_max_tiles=max_tiles)
await self._set(_KEY_MEDIA_LIMITS, cfg.media_limits.model_dump(mode="json"))
if patch.consent_required is not None or patch.consent_policy_text is not None:
consent_required = (
patch.consent_required if patch.consent_required is not None else cfg.consent_required
)
consent_text = (
patch.consent_policy_text.strip()
if patch.consent_policy_text is not None
else cfg.consent_policy_text
)
if consent_required and not consent_text:
raise InvalidConsentPolicyError(
"нельзя включить обязательное согласие с пустым текстом регламента"
)
# Версия — счётчик редакций текста, а не хеш/дата: администратору
# проще сослаться на «редакцию №3», чем на хеш, а инкремент (в
# отличие от даты) однозначно фиксирует факт правки даже при
# повторном сохранении одного и того же текста в одну секунду.
consent_version = cfg.consent_policy_version
if consent_text != cfg.consent_policy_text:
consent_version += 1
cfg.consent_required = consent_required
cfg.consent_policy_text = consent_text
cfg.consent_policy_version = consent_version
await self._set(
_KEY_CONSENT_POLICY,
{"enabled": consent_required, "text": consent_text, "version": consent_version},
)
await self._session.commit() await self._session.commit()
return cfg return cfg
@@ -376,6 +497,7 @@ async def load_effective_config(session: AsyncSession) -> InstanceConfig:
transcriber=plugins.transcriber, transcriber=plugins.transcriber,
summarizer=plugins.summarizer, summarizer=plugins.summarizer,
chat=plugins.chat, chat=plugins.chat,
hand_queue=plugins.hand_queue,
) )
else: else:
cfg = _build_config(rows) cfg = _build_config(rows)
@@ -469,6 +591,7 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
transcriber=TranscriberConfig.model_validate(rows.get(_KEY_TRANSCRIBER, {})), transcriber=TranscriberConfig.model_validate(rows.get(_KEY_TRANSCRIBER, {})),
summarizer=SummarizerConfig.model_validate(rows.get(_KEY_SUMMARIZER, {})), summarizer=SummarizerConfig.model_validate(rows.get(_KEY_SUMMARIZER, {})),
chat=ChatConfig.model_validate(rows.get(_KEY_CHAT, {})), chat=ChatConfig.model_validate(rows.get(_KEY_CHAT, {})),
hand_queue=HandQueueConfig.model_validate(rows.get(_KEY_HAND_QUEUE, {})),
ai_level=rows.get(_KEY_AI_LEVEL, _DEFAULT_AI_LEVEL_VALUE).get("level", "min"), ai_level=rows.get(_KEY_AI_LEVEL, _DEFAULT_AI_LEVEL_VALUE).get("level", "min"),
summary_recipients=rows.get(_KEY_SUMMARY_RECIPIENTS, _DEFAULT_SUMMARY_RECIPIENTS_VALUE).get( summary_recipients=rows.get(_KEY_SUMMARY_RECIPIENTS, _DEFAULT_SUMMARY_RECIPIENTS_VALUE).get(
"mode", "all" "mode", "all"
@@ -489,4 +612,22 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
"enabled", False "enabled", False
), ),
contact_email=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get("email"), contact_email=rows.get(_KEY_CONTACT_EMAIL, _DEFAULT_CONTACT_EMAIL_VALUE).get("email"),
media_limits=MediaLimitsConfig.model_validate(
rows.get(_KEY_MEDIA_LIMITS, _DEFAULT_MEDIA_LIMITS_VALUE)
),
consent_required=rows.get(_KEY_CONSENT_POLICY, _DEFAULT_CONSENT_POLICY_VALUE).get(
"enabled", False
),
consent_policy_text=rows.get(_KEY_CONSENT_POLICY, _DEFAULT_CONSENT_POLICY_VALUE).get(
"text", DEFAULT_CONSENT_POLICY_TEXT
),
consent_policy_version=rows.get(_KEY_CONSENT_POLICY, _DEFAULT_CONSENT_POLICY_VALUE).get(
"version", 1
),
device_check_enabled=rows.get(_KEY_DEVICE_CHECK, _DEFAULT_DEVICE_CHECK_VALUE).get(
"enabled", False
),
virtual_background_enabled=rows.get(
_KEY_VIRTUAL_BACKGROUND, _DEFAULT_VIRTUAL_BACKGROUND_VALUE
).get("enabled", False),
) )

View File

@@ -0,0 +1,81 @@
"""Управление комнатой LiveKit от имени организатора (задача B2): принудительный мьют.
Тонкая обёртка над `RoomServiceClient` — тот же паттерн, что и
`services/egress.py` (единственная точка мокирования в тестах, свой
`api.LiveKitAPI` на вызов, аутентификация СЕРВЕРНЫМИ `api_key`/`api_secret`,
а не токеном организатора). Именно поэтому организатору не нужен отдельный
LiveKit-грант в собственном access-токене под это действие — мьютит backend
от своего имени, клиент лишь инициирует вызов, а право на это проверяется
по владельцу конференции в БД (`services/conferences.py::mute_participant`),
ДО обращения сюда.
"""
import logging
from livekit import api
from livekit.protocol.models import TrackSource
from core.config import get_settings
from schemas.room_events import ForcedMuteSource
logger = logging.getLogger(__name__)
# Переэкспорт под более общим именем — этот модуль не завязан на протокол WS
# (`schemas/room_events.py`), которому концептуально принадлежит `ForcedMuteSource`.
MuteSource = ForcedMuteSource
_TRACK_SOURCE_BY_NAME: dict[MuteSource, int] = {
"microphone": TrackSource.MICROPHONE,
"camera": TrackSource.CAMERA,
}
class ParticipantNotInRoomError(Exception):
"""Участника с таким identity сейчас нет в комнате LiveKit (уже вышел/не заходил)."""
async def mute_participant_track(room_name: str, *, identity: str, source: MuteSource) -> bool:
"""Принудительно замьютить опубликованный трек участника; `True` — трек реально замьючен.
Если трек данного `source` сейчас не опубликован — не ошибка, а no-op:
искомое состояние («трек не идёт») уже достигнуто. Обычный случай с
0.0.15 — участники заходят с выключенными микрофоном/камерой (задача
A1), трек попросту не существует, пока человек не включит его сам;
мьютить в этот момент нечего, и это НЕ повод отвечать клиенту ошибкой.
"""
settings = get_settings()
lkapi = api.LiveKitAPI(
settings.livekit_url,
api_key=settings.livekit_api_key,
api_secret=settings.livekit_api_secret,
)
try:
try:
participant = await lkapi.room.get_participant(
api.RoomParticipantIdentity(room=room_name, identity=identity)
)
except api.TwirpError as exc:
if exc.status == 404:
raise ParticipantNotInRoomError from exc
raise
target_source = _TRACK_SOURCE_BY_NAME[source]
track = next((t for t in participant.tracks if t.source == target_source), None)
if track is None or track.muted:
return False
await lkapi.room.mute_published_track(
api.MuteRoomTrackRequest(
room=room_name, identity=identity, track_sid=track.sid, muted=True
)
)
logger.info(
"room_control: принудительный мьют — комната=%s identity=%s source=%s трек=%s",
room_name,
identity,
source,
track.sid,
)
return True
finally:
await lkapi.aclose()

View File

@@ -27,6 +27,7 @@ from repositories.conferences import (
ConferenceRepository, ConferenceRepository,
ConferenceSessionRepository, ConferenceSessionRepository,
) )
from services import hand_queue
from services.egress import run_track_egress from services.egress import run_track_egress
from services.instance_settings import InstanceSettingsService from services.instance_settings import InstanceSettingsService
from services.pipeline_producer import enqueue_pipeline from services.pipeline_producer import enqueue_pipeline
@@ -154,6 +155,17 @@ class WebhookDispatcher:
return return
user_id, guest_id = identity user_id, guest_id = identity
# Очередь поднятых рук живёт в Redis по `conference.id`, независимо
# от `ConferenceSession` (задача B1) — снимаем руку СРАЗУ, до guard'а
# на отсутствующий открытый сеанс ниже: пропущенный/задержанный
# `room_started` не должен оставлять фантомную запись в очереди у
# реально вышедшего участника. Не путать с обрывом WS-соединения
# самой очереди рук — то живёт своей жизнью и переживается без
# потери места (см. `services/hand_queue.py`).
removed = await hand_queue.lower_hand(conference.id, identity=event.participant.identity)
if removed:
await hand_queue.publish_snapshot(conference.id)
session_record = await self._sessions.get_open_by_conference(conference.id) session_record = await self._sessions.get_open_by_conference(conference.id)
if session_record is None: if session_record is None:
logger.warning( logger.warning(
@@ -308,6 +320,10 @@ class WebhookDispatcher:
now = datetime.now(UTC) now = datetime.now(UTC)
await self._sessions.close(session_record, t_end=now) await self._sessions.close(session_record, t_end=now)
await self._sessions.close_all_open_participants(session_id=session_record.id, left_at=now) await self._sessions.close_all_open_participants(session_id=session_record.id, left_at=now)
# Очередь поднятых рук — состояние звонка, не история; следующий
# заход (в т.ч. у закреплённой конференции) должен начинать с чистой
# очереди, а не наследовать поднятые руки из прошлого раза.
await hand_queue.clear(conference.id)
# Незакреплённая умирает по завершении (история/саммари остаются); # Незакреплённая умирает по завершении (история/саммари остаются);
# закреплённая возвращается в ожидание следующего вхождения (ADR-001, п.2). # закреплённая возвращается в ожидание следующего вхождения (ADR-001, п.2).

View File

@@ -33,7 +33,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
user = User( user = User(
email=f"{uuid.uuid4()}@example.com", email=f"{uuid.uuid4()}@example.com",
name_user="Admin API Tester", name_user="Admin API Tester",
password_hash=hash_password("password123"), password_hash=await hash_password("password123"),
email_verified=True, email_verified=True,
role=role, role=role,
) )
@@ -646,12 +646,17 @@ async def test_put_settings_partial_update(
response = await client.put( response = await client.put(
"/api/v1/admin/settings", "/api/v1/admin/settings",
json={"chat_enabled": False, "display_timezone": "Asia/Yekaterinburg"}, json={
"chat_enabled": False,
"hand_queue_enabled": False,
"display_timezone": "Asia/Yekaterinburg",
},
headers=_auth_headers(admin), headers=_auth_headers(admin),
) )
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
body = response.json() body = response.json()
assert body["chat_enabled"] is False assert body["chat_enabled"] is False
assert body["hand_queue_enabled"] is False
assert body["display_timezone"] == "Asia/Yekaterinburg" assert body["display_timezone"] == "Asia/Yekaterinburg"
assert body["ai_level"] == "min" assert body["ai_level"] == "min"
assert body["transcription_queue_served"] is False assert body["transcription_queue_served"] is False
@@ -706,6 +711,68 @@ async def test_put_settings_contact_email_invalid_returns_400(
assert response.status_code == 400 assert response.status_code == 400
async def test_get_settings_media_limits_defaults(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Дефолты рычагов нагрузки — без ограничения качества и с текущим
максимумом сетки (5×5) — существующие инсталляции после обновления не
получают внезапно ухудшенное качество."""
monkeypatch.setattr(admin_module, "transcription_queue_served", lambda: False)
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.get("/api/v1/admin/settings", headers=_auth_headers(admin))
assert response.status_code == 200, response.text
body = response.json()
assert body["publish_quality_cap"] == "off"
assert body["stage_max_tiles"] == 25
async def test_put_settings_media_limits_partial_update(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(admin_module, "transcription_queue_served", lambda: False)
admin = await _make_user(db_session, role="admin")
await db_session.commit()
response = await client.put(
"/api/v1/admin/settings",
json={"publish_quality_cap": "360p", "stage_max_tiles": 9},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
body = response.json()
assert body["publish_quality_cap"] == "360p"
assert body["stage_max_tiles"] == 9
reloaded = await client.get("/api/v1/admin/settings", headers=_auth_headers(admin))
assert reloaded.json()["publish_quality_cap"] == "360p"
assert reloaded.json()["stage_max_tiles"] == 9
async def test_put_settings_media_limits_invalid_values_return_422(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Значения вне разрешённого набора (`Literal`) — ошибка валидации тела запроса
ДО сервисного слоя, ещё на уровне FastAPI/pydantic."""
admin = await _make_user(db_session, role="admin")
await db_session.commit()
bad_cap = await client.put(
"/api/v1/admin/settings",
json={"publish_quality_cap": "4k"},
headers=_auth_headers(admin),
)
assert bad_cap.status_code == 422
bad_tiles = await client.put(
"/api/v1/admin/settings",
json={"stage_max_tiles": 100},
headers=_auth_headers(admin),
)
assert bad_tiles.status_code == 422
# --- Тестовое письмо ---------------------------------------------------------------- # --- Тестовое письмо ----------------------------------------------------------------

View File

@@ -20,7 +20,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
user = User( user = User(
email=f"{uuid.uuid4()}@example.com", email=f"{uuid.uuid4()}@example.com",
name_user="Team API Tester", name_user="Team API Tester",
password_hash=hash_password("password123"), password_hash=await hash_password("password123"),
email_verified=True, email_verified=True,
role=role, role=role,
) )

View File

@@ -9,6 +9,7 @@ from typing import Annotated
import httpx import httpx
import pytest_asyncio import pytest_asyncio
from argon2 import PasswordHasher
from fastapi import Depends, FastAPI from fastapi import Depends, FastAPI
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.auth import get_auth_service from api.auth import get_auth_service
from core.db import get_session from core.db import get_session
from core.redis import redis_client from core.redis import redis_client
from core.security import needs_rehash
from models.team import Team from models.team import Team
from models.user import User from models.user import User
from services.auth import AuthService from services.auth import AuthService
@@ -38,7 +40,11 @@ async def _reset_registration_gating(db_session: AsyncSession) -> None:
`conftest.py`). `conftest.py`).
""" """
await InstanceSettingsService(db_session).update( await InstanceSettingsService(db_session).update(
SettingsUpdateIn(registration_team_choice=False, registration_email_domain_enabled=False) SettingsUpdateIn(
registration_team_choice=False,
registration_email_domain_enabled=False,
consent_required=False,
)
) )
await db_session.commit() await db_session.commit()
@@ -491,3 +497,119 @@ async def test_register_no_reply_to_when_contact_email_disabled(
assert response.status_code == 201, response.text assert response.status_code == 201, response.text
assert email_backend.reply_to[-1] is None assert email_backend.reply_to[-1] is None
async def test_registration_options_returns_consent_fields(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""`consent_text`/`consent_version` отдаются ВСЕГДА (нужны странице регламента),
`consent_required` — по факту настройки инстанса."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(consent_policy_text="Текст регламента для теста")
)
await db_session.commit()
response = await client.get("/api/v1/auth/registration-options")
assert response.status_code == 200, response.text
body = response.json()
assert body["consent_required"] is False
assert body["consent_text"] == "Текст регламента для теста"
assert isinstance(body["consent_version"], int)
async def test_register_without_consent_when_required_returns_400(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
"""Сервер отказывает в регистрации без галочки, даже если фронт её не прислал —
второй эшелон проверки (тот же принцип, что `hand_queue_disabled` в 0.0.28)."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(consent_required=True, consent_policy_text="Текст регламента")
)
await db_session.commit()
response = await client.post(
"/api/v1/auth/register",
json={"email": "no-consent@example.com", "name_user": "No Consent", "password": "supersecret1"},
)
assert response.status_code == 400
assert response.json()["detail"] == "consent_required"
result = await db_session.execute(select(User).where(User.email == "no-consent@example.com"))
assert result.scalar_one_or_none() is None
async def test_register_with_consent_when_required_writes_version_and_date(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
"""Принятое согласие пишется в БД вместе с редакцией регламента и датой."""
cfg = await InstanceSettingsService(db_session).update(
SettingsUpdateIn(consent_required=True, consent_policy_text="Текст регламента для приёмки")
)
await db_session.commit()
response = await client.post(
"/api/v1/auth/register",
json={
"email": "with-consent@example.com",
"name_user": "With Consent",
"password": "supersecret1",
"consent_accepted": True,
},
)
assert response.status_code == 201, response.text
result = await db_session.execute(select(User).where(User.email == "with-consent@example.com"))
created = result.scalar_one()
assert created.consent_version == cfg.consent_policy_version
assert created.consent_given_at is not None
async def test_register_without_consent_when_module_disabled_succeeds_and_leaves_it_null(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
"""Модуль выключен (дефолт `_reset_registration_gating`) — регистрация не требует
галочки, `consent_version`/`consent_given_at` остаются `NULL`."""
response = await client.post(
"/api/v1/auth/register",
json={"email": "consent-disabled@example.com", "name_user": "Consent Disabled", "password": "supersecret1"},
)
assert response.status_code == 201, response.text
result = await db_session.execute(
select(User).where(User.email == "consent-disabled@example.com")
)
created = result.scalar_one()
assert created.consent_version is None
assert created.consent_given_at is None
async def test_login_rehashes_legacy_password(
client: httpx.AsyncClient, db_session: AsyncSession, email_backend: _CapturingEmailBackend
) -> None:
"""Вход с паролем, захэшированным старыми параметрами, перевыпускает хэш.
Параметры argon2 зашиты в саму строку хэша, поэтому смена настроек
(0.0.17: дефолты библиотеки → рекомендации OWASP) сама по себе не ускоряет
проверку уже существующих паролей. Миграция идёт лениво — при первом
успешном входе, когда открытый пароль есть на руках.
"""
email = "legacy-hash@example.com"
password = "supersecret1"
await _register_and_verify(client, email_backend, email=email, password=password)
# Подменяем хэш на выданный прежними параметрами (t=3, m=64 МБ, p=4).
legacy_hash = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4).hash(password)
user = await db_session.scalar(select(User).where(User.email == email))
assert user is not None
user.password_hash = legacy_hash
await db_session.commit()
response = await client.post(
"/api/v1/auth/token", data={"username": email, "password": password}
)
assert response.status_code == 200, response.text
await db_session.refresh(user)
assert user.password_hash != legacy_hash, "старый хэш не был перевыпущен"
assert "m=19456" in user.password_hash
assert needs_rehash(user.password_hash) is False

View File

@@ -44,7 +44,7 @@ async def _make_user(session: AsyncSession, *, name: str = "Chat Tester") -> Use
user = User( user = User(
email=f"{uuid.uuid4()}@example.com", email=f"{uuid.uuid4()}@example.com",
name_user=name, name_user=name,
password_hash=hash_password("password123"), password_hash=await hash_password("password123"),
email_verified=True, email_verified=True,
) )
session.add(user) session.add(user)
@@ -85,11 +85,19 @@ def _guest_token(conference: Conference, guest: GuestAccess) -> str:
async def _connect_and_auth(session: ASGIWebSocketSession, token: str) -> dict[str, Any]: async def _connect_and_auth(session: ASGIWebSocketSession, token: str) -> dict[str, Any]:
"""Подключиться, аутентифицироваться и вернуть первое сообщение (`history`).""" """Подключиться, аутентифицироваться и вернуть первое сообщение (`history`).
После `history` сервер сразу шлёт снапшот очереди поднятых рук
(`{"type":"hand_queue",...}`, задача B1) — здесь он молча вычитывается
и отбрасывается, чтобы не путать существующие тесты чата, которым он
не интересен (см. `tests/test_hand_queue_ws.py` для тестов самой очереди).
"""
accept = await session.connect() accept = await session.connect()
assert accept["type"] == "websocket.accept" assert accept["type"] == "websocket.accept"
await session.send_json({"type": "auth", "token": token}) await session.send_json({"type": "auth", "token": token})
return await session.receive_json() history = await session.receive_json()
await session.receive_json()
return history
# --- Основной сценарий: обмен сообщениями + история ------------------------- # --- Основной сценарий: обмен сообщениями + история -------------------------
@@ -229,6 +237,42 @@ async def test_no_duplicate_when_message_already_in_history(
assert received["message"]["text"] == "genuinely new" assert received["message"]["text"] == "genuinely new"
# --- Удержание соединения с БД ------------------------------------------------
async def test_handshake_releases_db_connection(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
"""Regression: после хендшейка WS не держит открытую транзакцию БД.
Обработчик получает `AsyncSession` на ВСЁ время жизни соединения, а
SELECT'ы хендшейка (тоггл чата, конференция, тоггл рук, история)
открывают транзакцию. Без явного `commit` она висела бы, пока участник
сидит в комнате: одно занятое соединение из пула на каждого человека
в конференции. На нагрузочном тесте 07.08.2026 это выгребло пул
(`db_pool_size + db_max_overflow` = 20 на воркер, 40 на инстанс) при
сорока участниках — и вход в систему начал отдавать 500 всем
остальным. Проверяем именно отсутствие открытой транзакции, а не
состояние пула: тестовая сессия привязана к своему соединению
(см. докстринг `tests/conftest.py`) и пул не задействует.
"""
conference = await _make_conference(db_session)
user = await _make_user(db_session)
await db_session.commit()
ws = ws_client(_chat_path(conference.id))
await _connect_and_auth(ws, _user_token(conference, user))
assert not db_session.in_transaction()
# Запись сообщения открывает транзакцию заново — и тоже обязана её
# закрыть, иначе первый же чат вернул бы прежнее поведение.
await ws.send_json({"type": "message", "text": "проверка"})
echo = await ws.receive_json()
assert echo["type"] == "message"
assert not db_session.in_transaction()
# --- Auth: коды закрытия ---------------------------------------------------- # --- Auth: коды закрытия ----------------------------------------------------

View File

@@ -19,7 +19,7 @@ async def _make_user(session: AsyncSession) -> User:
user = User( user = User(
email=f"{uuid.uuid4()}@example.com", email=f"{uuid.uuid4()}@example.com",
name_user="Invitee Tester", name_user="Invitee Tester",
password_hash=hash_password("password123"), password_hash=await hash_password("password123"),
email_verified=True, email_verified=True,
) )
session.add(user) session.add(user)

View File

@@ -25,7 +25,7 @@ async def _make_user(session: AsyncSession, *, name: str = "Service Tester") ->
user = User( user = User(
email=f"{uuid.uuid4()}@example.com", email=f"{uuid.uuid4()}@example.com",
name_user=name, name_user=name,
password_hash=hash_password("password123"), password_hash=await hash_password("password123"),
email_verified=True, email_verified=True,
) )
session.add(user) session.add(user)

View File

@@ -21,6 +21,7 @@ from models.guest import GuestAccess
from models.invitee import ConferenceInvitee from models.invitee import ConferenceInvitee
from models.user import User from models.user import User
from services.conference_ids import generate_number, generate_slug from services.conference_ids import generate_number, generate_slug
from services.instance_settings import InstanceSettingsService, SettingsUpdateIn
FUTURE = datetime.now(UTC) + timedelta(days=3) FUTURE = datetime.now(UTC) + timedelta(days=3)
@@ -33,7 +34,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
user = User( user = User(
email=f"{uuid.uuid4()}@example.com", email=f"{uuid.uuid4()}@example.com",
name_user="Conference Tester", name_user="Conference Tester",
password_hash=hash_password("password123"), password_hash=await hash_password("password123"),
email_verified=True, email_verified=True,
role=role, role=role,
) )
@@ -67,7 +68,7 @@ async def _make_conference(
status=status, status=status,
is_pinned=is_pinned, is_pinned=is_pinned,
is_closed=is_closed, is_closed=is_closed,
password_hash=hash_password(password) if password else None, password_hash=await hash_password(password) if password else None,
ended_at=ended_at, ended_at=ended_at,
scheduled_at=scheduled_at, scheduled_at=scheduled_at,
duration_minutes=duration_minutes, duration_minutes=duration_minutes,
@@ -113,6 +114,16 @@ async def test_create_instant_conference_returns_active_with_join(
assert body["join"]["conference_id"] == body["id"] assert body["join"]["conference_id"] == body["id"]
assert body["join"]["room_name"] == body["slug"] assert body["join"]["room_name"] == body["slug"]
assert body["join"]["token"] assert body["join"]["token"]
# Рычаги нагрузки медиа — дефолты без ограничения (существующие
# инсталляции не должны получить внезапно ухудшенное качество).
assert body["join"]["publish_quality_cap"] == "off"
assert body["join"]["stage_max_tiles"] == 25
# Замена фона — отключаемый модуль (сессия 35); значение нужно клиенту ДО
# первого рендера комнаты, поэтому едет в join. Здесь проверяется только
# НАЛИЧИЕ поля: тумблер живёт в общей dev-БД, и сравнение с дефолтом
# ломалось бы от того, что разработчик включил модуль руками (см.
# `test_join_reports_virtual_background_toggle` — там значение задаётся явно).
assert isinstance(body["join"]["virtual_background_enabled"], bool)
async def test_create_instant_conference_join_metadata_contains_owner_avatar_url( async def test_create_instant_conference_join_metadata_contains_owner_avatar_url(
@@ -573,17 +584,75 @@ async def test_resolve_unknown_returns_uniform_404(client: httpx.AsyncClient) ->
assert response.json()["detail"] == "not_found" assert response.json()["detail"] == "not_found"
async def test_resolve_is_rate_limited_after_10_requests_per_minute( def _ip_headers() -> dict[str, str]:
client: httpx.AsyncClient, """Уникальный `X-Real-IP` на каждый тест.
) -> None:
Счётчики rate limit живут в Redis 60 секунд и общие для всего инстанса,
поэтому без изоляции тесты влияли бы друг на друга через остаточные ключи.
Заодно это проверяет, что заголовок вообще читается: раньше ключ строился
по `request.client.host`, то есть по адресу nginx, одинаковому для всех.
"""
return {"X-Real-IP": f"198.51.100.{uuid.uuid4().int % 250 + 1}-{uuid.uuid4().hex[:8]}"}
async def test_resolve_misses_are_rate_limited(client: httpx.AsyncClient) -> None:
"""Перебор номера конференции упирается в жёсткий лимит промахов (ADR-001, п.4)."""
headers = _ip_headers()
for _ in range(10): for _ in range(10):
response = await client.get("/api/v1/conferences/resolve", params={"q": "irrelevant-query"}) response = await client.get(
"/api/v1/conferences/resolve", params={"q": "irrelevant-query"}, headers=headers
)
assert response.status_code == 404 assert response.status_code == 404
limited = await client.get("/api/v1/conferences/resolve", params={"q": "irrelevant-query"}) limited = await client.get(
"/api/v1/conferences/resolve", params={"q": "irrelevant-query"}, headers=headers
)
assert limited.status_code == 429 assert limited.status_code == 429
async def test_successful_resolves_are_not_limited_by_miss_counter(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Вся конференция может открыть ссылку одновременно (регресс теста 31.07.2026).
Прежняя схема считала любые запросы с лимитом 10/мин, и одиннадцатый
участник получал 429 — фронтенд показывал «Не удалось найти конференцию»
для существующей и активной конференции.
"""
conference = await _make_conference(db_session)
await db_session.commit()
headers = _ip_headers()
for _ in range(50):
response = await client.get(
"/api/v1/conferences/resolve", params={"q": conference.slug}, headers=headers
)
assert response.status_code == 200, response.text
async def test_rate_limit_is_per_client_ip(client: httpx.AsyncClient) -> None:
"""Счётчик привязан к адресу клиента, а не к адресу nginx.
Исчерпав лимит промахов с одного адреса, с другого по-прежнему можно
работать. До исправления ключ был общим на весь инстанс.
"""
first, second = _ip_headers(), _ip_headers()
for _ in range(11):
await client.get(
"/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=first
)
exhausted = await client.get(
"/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=first
)
assert exhausted.status_code == 429
other = await client.get(
"/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=second
)
assert other.status_code == 404, "лимит одного клиента не должен задевать другого"
# --- Вход зарегистрированным пользователем --------------------------------------- # --- Вход зарегистрированным пользователем ---------------------------------------
@@ -656,6 +725,30 @@ async def test_join_closed_conference_correct_password_returns_200(
assert response.json()["room_name"] == conference.slug assert response.json()["room_name"] == conference.slug
@pytest.mark.parametrize("enabled", [True, False])
async def test_join_reports_virtual_background_toggle(
client: httpx.AsyncClient, db_session: AsyncSession, enabled: bool
) -> None:
"""Тумблер модуля «замена фона» доезжает до участника в ответе join.
Значение задаётся ЯВНО, а не берётся из общей dev-БД: клиенту оно нужно до
первого рендера комнаты, и подмена тумблера обязана менять ответ join, а не
только `/admin/settings` (тот доступен лишь администратору).
"""
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(virtual_background_enabled=enabled)
)
await db_session.commit()
response = await client.post(
f"/api/v1/conferences/{conference.id}/join", json={}, headers=_auth_headers(owner)
)
assert response.status_code == 200, response.text
assert response.json()["virtual_background_enabled"] is enabled
# --- Аватар в метаданных LiveKit-токена ----------------------------------------- # --- Аватар в метаданных LiveKit-токена -----------------------------------------
@@ -708,6 +801,38 @@ async def test_join_metadata_absent_for_user_without_avatar(
assert "metadata" not in payload assert "metadata" not in payload
async def test_join_metadata_contains_is_organizer_for_owner(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
response = await client.post(
f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(owner)
)
assert response.status_code == 200, response.text
payload = _decode_livekit_token(response.json()["token"])
metadata = json.loads(str(payload["metadata"]))
assert metadata["is_organizer"] is True
async def test_join_metadata_absent_is_organizer_for_non_owner(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
other = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
response = await client.post(
f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(other)
)
assert response.status_code == 200, response.text
payload = _decode_livekit_token(response.json()["token"])
assert "metadata" not in payload
async def test_guest_join_metadata_is_absent( async def test_guest_join_metadata_is_absent(
client: httpx.AsyncClient, db_session: AsyncSession client: httpx.AsyncClient, db_session: AsyncSession
) -> None: ) -> None:
@@ -751,6 +876,33 @@ async def test_guest_join_creates_guest_access_and_returns_join(
assert guests[0].email == "alice-guest@example.com" assert guests[0].email == "alice-guest@example.com"
async def test_guest_join_reflects_admin_configured_media_limits(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Настройки, сохранённые администратором в `PUT /admin/settings`, доезжают
до гостя в join-ответе ДО входа в комнату — публичный путь доставки
(см. `services/conference_access.py::build_join`), гость админку не видит."""
admin = await _make_user(db_session, role="admin")
conference = await _make_conference(db_session)
await db_session.commit()
settings_response = await client.put(
"/api/v1/admin/settings",
json={"publish_quality_cap": "180p", "stage_max_tiles": 4},
headers=_auth_headers(admin),
)
assert settings_response.status_code == 200, settings_response.text
response = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join",
json={"display_name": "Guest Bob"},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["publish_quality_cap"] == "180p"
assert body["stage_max_tiles"] == 4
async def test_guest_join_without_email_is_allowed( async def test_guest_join_without_email_is_allowed(
client: httpx.AsyncClient, db_session: AsyncSession client: httpx.AsyncClient, db_session: AsyncSession
) -> None: ) -> None:
@@ -803,21 +955,69 @@ async def test_guest_join_ended_conference_returns_410(
assert response.json()["detail"] == "conference_ended" assert response.json()["detail"] == "conference_ended"
async def test_guest_join_is_rate_limited_after_10_requests_per_minute( async def test_guest_join_allows_a_whole_conference_to_enter(
client: httpx.AsyncClient, db_session: AsyncSession client: httpx.AsyncClient, db_session: AsyncSession
) -> None: ) -> None:
"""Успешные гостевые входы не упираются в лимит промахов.
На нагрузочном тесте 31.07.2026 конференцию из семи десятков человек не
пускало внутрь именно это ограничение — счётчик не различал легитимный
массовый вход и перебор.
"""
conference = await _make_conference(db_session) conference = await _make_conference(db_session)
await db_session.commit() await db_session.commit()
headers = _ip_headers()
for i in range(30):
response = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join",
json={"display_name": f"Guest {i}"},
headers=headers,
)
assert response.status_code == 200, response.text
async def test_guest_join_misses_are_rate_limited(client: httpx.AsyncClient) -> None:
"""Перебор идентификатора конференции по-прежнему упирается в лимит."""
headers = _ip_headers()
missing_id = uuid.uuid4()
for _ in range(10):
response = await client.post(
f"/api/v1/conferences/{missing_id}/guest-join",
json={"display_name": "Bruteforce"},
headers=headers,
)
assert response.status_code == 404
limited = await client.post(
f"/api/v1/conferences/{missing_id}/guest-join",
json={"display_name": "Bruteforce"},
headers=headers,
)
assert limited.status_code == 429
async def test_guest_join_wrong_password_is_rate_limited(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Подбор пароля закрытой конференции считается тем же жёстким счётчиком."""
conference = await _make_conference(db_session, is_closed=True, password="right-password")
await db_session.commit()
headers = _ip_headers()
for _ in range(10): for _ in range(10):
response = await client.post( response = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join", f"/api/v1/conferences/{conference.id}/guest-join",
json={"display_name": "Repeat Guest"}, json={"display_name": "Guesser", "password": "wrong"},
headers=headers,
) )
assert response.status_code == 200 assert response.status_code == 403
limited = await client.post( limited = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Repeat Guest"} f"/api/v1/conferences/{conference.id}/guest-join",
json={"display_name": "Guesser", "password": "wrong"},
headers=headers,
) )
assert limited.status_code == 429 assert limited.status_code == 429

View File

@@ -0,0 +1,337 @@
"""Тесты очереди поднятых рук поверх WS комнаты (`WS /api/v1/conferences/{id}/chat`, задача B1).
Протокол и аутентификация — общие с чатом (`api/chat.py`), поэтому структура
тестов и хелперы намеренно зеркалят `tests/test_chat_ws.py`.
"""
import uuid
from collections.abc import Callable
from typing import Any
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from core.security import create_access_token, hash_password
from models.conference import Conference
from models.guest import GuestAccess
from models.user import User
from services.conference_ids import generate_number, generate_slug
from services.instance_settings import InstanceSettingsService, SettingsUpdateIn
from services.livekit_tokens import create_room_access_token
from tests.conftest import ASGIWebSocketSession
WSFactory = Callable[[str], ASGIWebSocketSession]
# --- Хелперы (см. tests/test_chat_ws.py) ------------------------------------
async def _make_user(session: AsyncSession, *, name: str = "Hand Tester") -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user=name,
password_hash=await hash_password("password123"),
email_verified=True,
)
session.add(user)
await session.flush()
return user
async def _make_conference(
session: AsyncSession, *, owner_id: uuid.UUID | None = None, status: str = "active"
) -> Conference:
conference = Conference(
number=generate_number(),
slug=generate_slug(),
title="Hand Queue Test",
status=status,
owner_id=owner_id,
)
session.add(conference)
await session.flush()
return conference
async def _make_guest(session: AsyncSession, conference: Conference, *, name: str) -> GuestAccess:
guest = GuestAccess(conference_id=conference.id, display_name=name)
session.add(guest)
await session.flush()
return guest
def _chat_path(conference_id: uuid.UUID) -> str:
return f"/api/v1/conferences/{conference_id}/chat"
def _user_token(conference: Conference, user: User) -> str:
return create_room_access_token(
room_name=conference.slug, identity=str(user.id), name=user.name_user
)
def _guest_token(conference: Conference, guest: GuestAccess) -> str:
return create_room_access_token(
room_name=conference.slug, identity=f"guest:{guest.id}", name=guest.display_name
)
async def _connect_auth_and_queue(
session: ASGIWebSocketSession, token: str
) -> dict[str, Any]:
"""Подключиться, аутентифицироваться, вычитать `history` и вернуть снапшот очереди."""
accept = await session.connect()
assert accept["type"] == "websocket.accept"
await session.send_json({"type": "auth", "token": token})
await session.receive_json() # history — не интересен этим тестам
return await session.receive_json()
def _identities(queue_frame: dict[str, Any]) -> list[str]:
return [entry["identity"] for entry in queue_frame["queue"]]
# --- Поднять/опустить свою руку -----------------------------------------------
async def test_raise_and_lower_own_hand_broadcasts_to_everyone(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
conference = await _make_conference(db_session)
alice = await _make_user(db_session, name="Alice")
bob = await _make_user(db_session, name="Bob")
await db_session.commit()
path = _chat_path(conference.id)
ws1 = ws_client(path)
await _connect_auth_and_queue(ws1, _user_token(conference, alice))
ws2 = ws_client(path)
initial2 = await _connect_auth_and_queue(ws2, _user_token(conference, bob))
assert initial2 == {"type": "hand_queue", "queue": []}
await ws1.send_json({"type": "raise_hand"})
queue1 = await ws1.receive_json()
assert _identities(queue1) == [str(alice.id)]
assert queue1["queue"][0]["name"] == "Alice"
assert queue1["queue"][0]["raised_at"].endswith("Z")
queue2 = await ws2.receive_json()
assert queue2 == queue1
await ws1.send_json({"type": "lower_hand"})
queue1_after = await ws1.receive_json()
assert queue1_after == {"type": "hand_queue", "queue": []}
queue2_after = await ws2.receive_json()
assert queue2_after == queue1_after
async def test_raise_hand_order_is_preserved(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
"""Порядок в очереди — по времени поднятия, не по алфавиту/подключению."""
conference = await _make_conference(db_session)
alice = await _make_user(db_session, name="Alice")
bob = await _make_user(db_session, name="Bob")
await db_session.commit()
path = _chat_path(conference.id)
ws1 = ws_client(path)
await _connect_auth_and_queue(ws1, _user_token(conference, alice))
ws2 = ws_client(path)
await _connect_auth_and_queue(ws2, _user_token(conference, bob))
# Боб поднимает руку ПЕРВЫМ, хотя подключился вторым — он и должен
# оказаться первым в очереди.
await ws2.send_json({"type": "raise_hand"})
await ws2.receive_json()
await ws1.receive_json()
await ws1.send_json({"type": "raise_hand"})
queue = await ws1.receive_json()
assert _identities(queue) == [str(bob.id), str(alice.id)]
async def test_re_raising_hand_does_not_move_position(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
"""Повторное поднятие уже поднятой руки — идемпотентно, место в очереди не меняется."""
conference = await _make_conference(db_session)
alice = await _make_user(db_session, name="Alice")
bob = await _make_user(db_session, name="Bob")
await db_session.commit()
path = _chat_path(conference.id)
ws1 = ws_client(path)
await _connect_auth_and_queue(ws1, _user_token(conference, alice))
ws2 = ws_client(path)
await _connect_auth_and_queue(ws2, _user_token(conference, bob))
await ws1.send_json({"type": "raise_hand"})
first = await ws1.receive_json()
await ws2.receive_json()
await ws2.send_json({"type": "raise_hand"})
await ws2.receive_json()
await ws1.receive_json()
# Алиса (уже в очереди первой) поднимает руку ещё раз.
await ws1.send_json({"type": "raise_hand"})
repeated = await ws1.receive_json()
await ws2.receive_json()
assert _identities(repeated) == [str(alice.id), str(bob.id)]
assert repeated["queue"][0]["raised_at"] == first["queue"][0]["raised_at"]
async def test_guest_can_raise_hand(db_session: AsyncSession, ws_client: WSFactory) -> None:
conference = await _make_conference(db_session)
guest = await _make_guest(db_session, conference, name="Guest Carl")
await db_session.commit()
ws = ws_client(_chat_path(conference.id))
await _connect_auth_and_queue(ws, _guest_token(conference, guest))
await ws.send_json({"type": "raise_hand"})
queue = await ws.receive_json()
assert _identities(queue) == [f"guest:{guest.id}"]
assert queue["queue"][0]["name"] == "Guest Carl"
# --- Права организатора -------------------------------------------------------
async def test_non_organizer_cannot_lower_someone_elses_hand(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
owner = await _make_user(db_session, name="Owner")
conference = await _make_conference(db_session, owner_id=owner.id)
alice = await _make_user(db_session, name="Alice")
bob = await _make_user(db_session, name="Bob")
await db_session.commit()
path = _chat_path(conference.id)
ws1 = ws_client(path)
await _connect_auth_and_queue(ws1, _user_token(conference, alice))
ws2 = ws_client(path)
await _connect_auth_and_queue(ws2, _user_token(conference, bob))
await ws1.send_json({"type": "raise_hand"})
await ws1.receive_json()
await ws2.receive_json()
# Боб (обычный участник, не организатор) пытается опустить руку Алисы.
await ws2.send_json({"type": "lower_hand", "identity": str(alice.id)})
error = await ws2.receive_json()
assert error == {"type": "error", "code": "forbidden"}
async def test_organizer_can_lower_someone_elses_hand(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
owner = await _make_user(db_session, name="Owner")
conference = await _make_conference(db_session, owner_id=owner.id)
alice = await _make_user(db_session, name="Alice")
await db_session.commit()
path = _chat_path(conference.id)
ws_alice = ws_client(path)
await _connect_auth_and_queue(ws_alice, _user_token(conference, alice))
ws_owner = ws_client(path)
await _connect_auth_and_queue(ws_owner, _user_token(conference, owner))
await ws_alice.send_json({"type": "raise_hand"})
await ws_alice.receive_json()
await ws_owner.receive_json()
await ws_owner.send_json({"type": "lower_hand", "identity": str(alice.id)})
queue_owner = await ws_owner.receive_json()
queue_alice = await ws_alice.receive_json()
assert queue_owner == {"type": "hand_queue", "queue": []}
assert queue_alice == queue_owner
async def test_organizer_joining_late_sees_already_raised_hands(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
"""Организатор зашёл позже, когда руки уже подняты, — видит актуальную очередь сразу."""
owner = await _make_user(db_session, name="Owner")
conference = await _make_conference(db_session, owner_id=owner.id)
alice = await _make_user(db_session, name="Alice")
await db_session.commit()
path = _chat_path(conference.id)
ws_alice = ws_client(path)
await _connect_auth_and_queue(ws_alice, _user_token(conference, alice))
await ws_alice.send_json({"type": "raise_hand"})
await ws_alice.receive_json()
ws_owner = ws_client(path)
initial_queue = await _connect_auth_and_queue(ws_owner, _user_token(conference, owner))
assert _identities(initial_queue) == [str(alice.id)]
# --- Отключаемый модуль (`instance_settings.hand_queue`) ---------------------
async def test_hand_queue_disabled_rejects_raise_and_lower(
db_session: AsyncSession, ws_client: WSFactory
) -> None:
"""Выключенный модуль — вторая линия защиты сверх фронта: `raise_hand`/
`lower_hand` отклоняются кодом `hand_queue_disabled`, очередь не меняется."""
conference = await _make_conference(db_session)
alice = await _make_user(db_session, name="Alice")
await db_session.commit()
await InstanceSettingsService(db_session).update(SettingsUpdateIn(hand_queue_enabled=False))
ws = ws_client(_chat_path(conference.id))
await _connect_auth_and_queue(ws, _user_token(conference, alice))
await ws.send_json({"type": "raise_hand"})
error = await ws.receive_json()
assert error == {"type": "error", "code": "hand_queue_disabled"}
await ws.send_json({"type": "lower_hand"})
error = await ws.receive_json()
assert error == {"type": "error", "code": "hand_queue_disabled"}
async def test_join_out_reflects_hand_queue_enabled_toggle(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session, name="Toggle Tester")
await db_session.commit()
headers = {"Authorization": f"Bearer {create_access_token(user.id, user.role)}"}
response = await client.post(
"/api/v1/conferences", json={"title": "Standup"}, headers=headers
)
assert response.status_code == 201, response.text
assert response.json()["join"]["hand_queue_enabled"] is True
await InstanceSettingsService(db_session).update(SettingsUpdateIn(hand_queue_enabled=False))
response = await client.post(
"/api/v1/conferences", json={"title": "Standup 2"}, headers=headers
)
assert response.status_code == 201, response.text
assert response.json()["join"]["hand_queue_enabled"] is False
async def test_guest_join_out_reflects_hand_queue_enabled(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
conference = await _make_conference(db_session)
await db_session.commit()
await InstanceSettingsService(db_session).update(SettingsUpdateIn(hand_queue_enabled=False))
response = await client.post(
f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Dave"}
)
assert response.status_code == 200, response.text
assert response.json()["hand_queue_enabled"] is False

View File

@@ -48,6 +48,7 @@ from services.instance_settings import (
BootstrapOverrides, BootstrapOverrides,
InstanceSettingsService, InstanceSettingsService,
InvalidAiLevelError, InvalidAiLevelError,
InvalidConsentPolicyError,
InvalidContactEmailError, InvalidContactEmailError,
InvalidEmailDomainError, InvalidEmailDomainError,
InvalidTimezoneError, InvalidTimezoneError,
@@ -65,12 +66,17 @@ _MANAGED_KEYS = (
"transcriber", "transcriber",
"summarizer", "summarizer",
"chat", "chat",
"hand_queue",
"ai_level", "ai_level",
"summary_recipients", "summary_recipients",
"display_timezone", "display_timezone",
"registration_team_choice", "registration_team_choice",
"registration_email_domain", "registration_email_domain",
"contact_email", "contact_email",
"media_limits",
"consent_policy",
"device_check",
"virtual_background",
) )
@@ -120,16 +126,24 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
"transcriber", "transcriber",
"summarizer", "summarizer",
"chat", "chat",
"hand_queue",
"ai_level", "ai_level",
"summary_recipients", "summary_recipients",
"display_timezone", "display_timezone",
"registration_team_choice", "registration_team_choice",
"registration_email_domain", "registration_email_domain",
"contact_email", "contact_email",
"media_limits",
"consent_policy",
"device_check",
"virtual_background",
} }
cfg = await service.get() cfg = await service.get()
assert cfg.transcriber.provider == "faster_whisper_cpu" assert cfg.transcriber.provider == "faster_whisper_cpu"
assert cfg.ai_level == "min" assert cfg.ai_level == "min"
# Дефолт обязан сохранять поведение существующих инсталляций — модуль
# «поднятие руки» был доступен всегда, тоггл включён по умолчанию.
assert cfg.hand_queue.enabled is True
assert cfg.summary_recipients == "all" assert cfg.summary_recipients == "all"
assert cfg.display_timezone == "Europe/Moscow" assert cfg.display_timezone == "Europe/Moscow"
assert cfg.registration_team_choice is False assert cfg.registration_team_choice is False
@@ -137,6 +151,24 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
assert cfg.registration_email_domains == [] assert cfg.registration_email_domains == []
assert cfg.contact_email_enabled is False assert cfg.contact_email_enabled is False
assert cfg.contact_email is None assert cfg.contact_email is None
# Дефолты сохраняют текущее (до появления настройки) поведение —
# без ограничения качества и с максимумом сетки, равным фактическому
# потолку `StageGrid` (5×5).
assert cfg.media_limits.publish_quality_cap == "off"
assert cfg.media_limits.stage_max_tiles == 25
# Согласие на обработку персональных данных выключено по умолчанию
# (дефолт сохраняет поведение существующих инсталляций), но дефолтный
# текст-шаблон уже на месте — публичная страница регламента осмысленна
# даже при выключенном модуле.
assert cfg.consent_required is False
assert cfg.consent_policy_text != ""
assert cfg.consent_policy_version == 1
# Проверка устройств на входе (сессия 33) — выключена по умолчанию,
# существующие инсталляции не должны молча начать спрашивать доступ.
assert cfg.device_check_enabled is False
# Замена фона видео (сессия 35) — выключена по умолчанию: фича постоянно
# считает сегментацию на клиенте, включать её обновлением нельзя.
assert cfg.virtual_background_enabled is False
async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits( async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits(
@@ -155,6 +187,54 @@ async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits(
assert cfg.display_timezone == "Asia/Yekaterinburg" assert cfg.display_timezone == "Asia/Yekaterinburg"
async def test_update_hand_queue_enabled(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
cfg = await service.update(SettingsUpdateIn(hand_queue_enabled=False))
assert cfg.hand_queue.enabled is False
cfg = await service.get()
assert cfg.hand_queue.enabled is False
cfg = await service.update(SettingsUpdateIn(hand_queue_enabled=True))
assert cfg.hand_queue.enabled is True
async def test_update_device_check_enabled(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
cfg = await service.update(SettingsUpdateIn(device_check_enabled=True))
assert cfg.device_check_enabled is True
cfg = await service.get()
assert cfg.device_check_enabled is True
cfg = await service.update(SettingsUpdateIn(device_check_enabled=False))
assert cfg.device_check_enabled is False
async def test_update_virtual_background_enabled(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
cfg = await service.update(SettingsUpdateIn(virtual_background_enabled=True))
assert cfg.virtual_background_enabled is True
cfg = await service.get()
assert cfg.virtual_background_enabled is True
cfg = await service.update(SettingsUpdateIn(virtual_background_enabled=False))
assert cfg.virtual_background_enabled is False
@pytest.mark.parametrize( @pytest.mark.parametrize(
("preset", "chat_enabled", "ai_enabled", "ai_level"), ("preset", "chat_enabled", "ai_enabled", "ai_level"),
[ [
@@ -323,6 +403,61 @@ async def test_registration_team_choice_toggle(
assert reloaded.registration_team_choice is True assert reloaded.registration_team_choice is True
async def test_consent_policy_toggle_without_text_change_keeps_version(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Включение флага без правки текста не увеличивает версию."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
baseline = await service.get()
assert baseline.consent_required is False
cfg = await service.update(SettingsUpdateIn(consent_required=True))
assert cfg.consent_required is True
assert cfg.consent_policy_version == baseline.consent_policy_version
reloaded = await service.get()
assert reloaded.consent_required is True
assert reloaded.consent_policy_version == baseline.consent_policy_version
async def test_consent_policy_text_change_bumps_version(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Правка текста регламента увеличивает версию — иначе «версия согласия» в БД бессмысленна."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
baseline = await service.get()
cfg = await service.update(SettingsUpdateIn(consent_policy_text="Новый текст регламента"))
assert cfg.consent_policy_text == "Новый текст регламента"
assert cfg.consent_policy_version == baseline.consent_policy_version + 1
# Повторное сохранение ТОГО ЖЕ текста версию больше не двигает.
cfg2 = await service.update(SettingsUpdateIn(consent_policy_text="Новый текст регламента"))
assert cfg2.consent_policy_version == cfg.consent_policy_version
reloaded = await service.get()
assert reloaded.consent_policy_version == cfg.consent_policy_version
async def test_consent_policy_enable_with_empty_text_rejected(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Нельзя включить обязательное согласие, если текст регламента пуст."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
with pytest.raises(InvalidConsentPolicyError):
await service.update(
SettingsUpdateIn(consent_required=True, consent_policy_text=" ")
)
cfg = await service.get()
assert cfg.consent_required is False
async def test_registration_email_domain_enable_without_domain_rejected( async def test_registration_email_domain_enable_without_domain_rejected(
db_session: AsyncSession, clean_instance_settings: None db_session: AsyncSession, clean_instance_settings: None
) -> None: ) -> None:
@@ -538,6 +673,28 @@ async def test_transcription_enabled_flag_toggles_both_transcriber_and_summarize
assert cfg.summarizer.enabled is False assert cfg.summarizer.enabled is False
async def test_media_limits_partial_update_keeps_untouched_field(
db_session: AsyncSession, clean_instance_settings: None
) -> None:
"""Патч только одного поля `media_limits` не сбрасывает соседнее — оба поля
живут в одной строке JSON, `update()` обязан подставлять текущее значение
несменённого поля, а не дефолт модели."""
service = InstanceSettingsService(db_session)
await service.ensure_bootstrapped(PLUGINS_YAML)
cfg = await service.update(SettingsUpdateIn(publish_quality_cap="360p"))
assert cfg.media_limits.publish_quality_cap == "360p"
assert cfg.media_limits.stage_max_tiles == 25 # дефолт не тронут
cfg = await service.update(SettingsUpdateIn(stage_max_tiles=9))
assert cfg.media_limits.stage_max_tiles == 9
assert cfg.media_limits.publish_quality_cap == "360p" # предыдущая правка сохранилась
reloaded = await service.get()
assert reloaded.media_limits.publish_quality_cap == "360p"
assert reloaded.media_limits.stage_max_tiles == 9
async def test_update_rejects_unavailable_ai_level( async def test_update_rejects_unavailable_ai_level(
db_session: AsyncSession, clean_instance_settings: None db_session: AsyncSession, clean_instance_settings: None
) -> None: ) -> None:

View File

@@ -34,6 +34,7 @@ from models.instance_setting import InstanceSetting
from models.participant import ConferenceParticipant from models.participant import ConferenceParticipant
from models.session import ConferenceSession from models.session import ConferenceSession
from models.user import User from models.user import User
from services import hand_queue
from services.conference_ids import generate_number, generate_slug from services.conference_ids import generate_number, generate_slug
from services.egress import EgressStartResult from services.egress import EgressStartResult
@@ -130,7 +131,7 @@ async def _make_user(session: AsyncSession, email: str) -> User:
user = User( user = User(
email=email, email=email,
name_user="Participant", name_user="Participant",
password_hash=hash_password("password123"), password_hash=await hash_password("password123"),
email_verified=True, email_verified=True,
) )
session.add(user) session.add(user)
@@ -217,6 +218,53 @@ async def test_full_cycle_joined_left_finished(
assert conference.ended_at is not None assert conference.ended_at is not None
async def test_participant_left_removes_raised_hand_from_queue(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Задача B1: участник с поднятой рукой вышел из конференции — рука исчезает из очереди."""
conference = await _make_conference(db_session, generate_slug())
user = await _make_user(db_session, "webhook-hand-1@example.com")
await db_session.commit()
identity = str(user.id)
await hand_queue.raise_hand(conference.id, identity=identity, name=user.name_user)
assert [e.identity for e in await hand_queue.snapshot(conference.id)] == [identity]
left = _load_fixture(
"participant_left.json",
event_id=f"evt-{uuid.uuid4()}",
room_name=conference.slug,
identity=identity,
)
resp = await _post_webhook(client, left)
assert resp.status_code == 200
assert await hand_queue.snapshot(conference.id) == []
async def test_room_finished_clears_hand_queue(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Задача B1: очередь поднятых рук — состояние звонка, не переживает его завершение."""
conference = await _make_conference(db_session, generate_slug())
await db_session.commit()
started = _load_fixture(
"room_started.json", event_id=f"evt-{uuid.uuid4()}", room_name=conference.slug
)
assert (await _post_webhook(client, started)).status_code == 200
await hand_queue.raise_hand(conference.id, identity="guest:leftover", name="Leftover Guest")
assert len(await hand_queue.snapshot(conference.id)) == 1
finished = _load_fixture(
"room_finished.json", event_id=f"evt-{uuid.uuid4()}", room_name=conference.slug
)
assert (await _post_webhook(client, finished)).status_code == 200
assert await hand_queue.snapshot(conference.id) == []
async def test_pinned_conference_returns_to_scheduled_on_finish( async def test_pinned_conference_returns_to_scheduled_on_finish(
client: httpx.AsyncClient, db_session: AsyncSession client: httpx.AsyncClient, db_session: AsyncSession
) -> None: ) -> None:

View File

@@ -59,6 +59,12 @@ async def test_metrics_endpoint_returns_prometheus_exposition_format(
assert "vidconf_pipeline_sessions" in families assert "vidconf_pipeline_sessions" in families
assert "vidconf_celery_queue_depth" in families assert "vidconf_celery_queue_depth" in families
assert "vidconf_host_info" in families assert "vidconf_host_info" in families
assert "vidconf_db_up" in families
assert "vidconf_db_pool_size" in families
assert "vidconf_db_pool_max_overflow" in families
assert "vidconf_db_pool_checked_out" in families
assert "vidconf_redis_pool_in_use" in families
assert "vidconf_redis_pool_max_connections" in families
async def test_metrics_host_info_gauge_reflects_settings( async def test_metrics_host_info_gauge_reflects_settings(
@@ -129,6 +135,72 @@ async def test_metrics_pipeline_sessions_gauge_reflects_new_session(
assert after == before + 1 assert after == before + 1
async def test_metrics_db_up_gauge_reflects_real_connectivity(client: httpx.AsyncClient) -> None:
"""Против реального тестового Postgres (см. докстринг conftest) `vidconf_db_up` == 1."""
response = await client.get("/metrics")
value = _sample_value(_samples(response.text, "vidconf_db_up"), suffix="vidconf_db_up")
assert value == 1
async def test_metrics_db_up_gauge_reports_down_without_crashing_endpoint(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Недоступность БД (проверка вне пула не удалась) не роняет `/metrics` — отдаёт 0, не 500."""
async def _fail() -> bool:
return False
monkeypatch.setattr(metrics_module, "check_db_up", _fail)
response = await client.get("/metrics")
assert response.status_code == 200
value = _sample_value(_samples(response.text, "vidconf_db_up"), suffix="vidconf_db_up")
assert value == 0
async def test_metrics_db_pool_gauges_reflect_settings_not_usage(
client: httpx.AsyncClient,
) -> None:
"""`vidconf_db_pool_size`/`_max_overflow` — конфигурация из `Settings`, не текущая занятость."""
settings = get_settings()
response = await client.get("/metrics")
samples_size = _samples(response.text, "vidconf_db_pool_size")
samples_overflow = _samples(response.text, "vidconf_db_pool_max_overflow")
size = _sample_value(samples_size, suffix="vidconf_db_pool_size")
max_overflow = _sample_value(samples_overflow, suffix="vidconf_db_pool_max_overflow")
assert size == settings.db_pool_size
assert max_overflow == settings.db_max_overflow
async def test_metrics_endpoint_survives_pipeline_gauge_failure(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Падение/таймаут основного пула на одном gauge не роняет весь `/metrics`.
Симулирует ровно ситуацию инцидента 07.08 (`api/metrics.py` падал вместе
со всем остальным при исчерпанном пуле): `count_by_pipeline_status`
поднимает исключение — `vidconf_db_up`/`vidconf_db_pool_*` (не зависящие
от основного пула) при этом всё равно приходят в ответе.
"""
async def _raise(*args: object, **kwargs: object) -> dict[str, int]:
raise TimeoutError("основной пул занят (симуляция теста)")
monkeypatch.setattr(
"repositories.conferences.ConferenceSessionRepository.count_by_pipeline_status",
_raise,
)
response = await client.get("/metrics")
assert response.status_code == 200
db_up = _sample_value(_samples(response.text, "vidconf_db_up"), suffix="vidconf_db_up")
assert db_up == 1
async def test_metrics_celery_queue_depth_gauge( async def test_metrics_celery_queue_depth_gauge(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:

View File

@@ -0,0 +1,245 @@
"""Тесты `POST /api/v1/conferences/{id}/mute-participant` (задача B2).
`mute_participant_track` (реальный вызов LiveKit `RoomServiceClient`) мокается
на уровне `services.conferences` — тот же паттерн, что и `start_track_egress`
в `tests/test_livekit_webhook.py`: сетевой вызов к LiveKit в тестах не нужен,
важна только бизнес-логика (права, маршрутизация ошибок, broadcast).
"""
import asyncio
import uuid
from typing import Any
from unittest.mock import AsyncMock
import httpx
import pytest
from redis.asyncio.client import PubSub
from sqlalchemy.ext.asyncio import AsyncSession
import services.conferences as conferences_module
from core.redis import redis_client
from core.security import create_access_token, hash_password
from models.conference import Conference
from models.user import User
from services.conference_ids import generate_number, generate_slug
from services.hand_queue import hand_queue_channel
from services.room_control import ParticipantNotInRoomError
MUTE_URL = "{base}/mute-participant"
async def _receive_within(pubsub: PubSub, *, max_wait: float) -> dict[str, Any] | None:
"""Дождаться СОДЕРЖАТЕЛЬНОГО сообщения канала в пределах `max_wait` секунд.
`ignore_subscribe_messages=True` у `get_message` фильтрует служебное
подтверждение подписки, но при этом всё равно может вернуть `None` для
ЭТОГО конкретного вызова (см. `api/chat.py::_pump_pubsub_to_websocket`,
ровно поэтому там `while True: ... if raw is None: continue`) — здесь тот
же цикл, но с общим дедлайном вместо бесконечного ожидания.
"""
deadline = asyncio.get_event_loop().time() + max_wait
while True:
remaining = deadline - asyncio.get_event_loop().time()
if remaining <= 0:
return None
raw: dict[str, Any] | None = await pubsub.get_message(
ignore_subscribe_messages=True, timeout=remaining
)
if raw is not None:
return raw
async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
user = User(
email=f"{uuid.uuid4()}@example.com",
name_user="Mute Tester",
password_hash=await hash_password("password123"),
email_verified=True,
role=role,
)
session.add(user)
await session.flush()
return user
async def _make_conference(
session: AsyncSession, *, owner_id: uuid.UUID | None = None
) -> Conference:
conference = Conference(
number=generate_number(),
slug=generate_slug(),
title="Mute Test",
status="active",
owner_id=owner_id,
)
session.add(conference)
await session.flush()
return conference
def _auth_headers(user: User) -> dict[str, str]:
return {"Authorization": f"Bearer {create_access_token(user.id, user.role)}"}
def _url(conference_id: uuid.UUID) -> str:
return MUTE_URL.format(base=f"/api/v1/conferences/{conference_id}")
async def test_owner_can_mute_participant_and_broadcast_is_published(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
mock_mute = AsyncMock(return_value=True)
monkeypatch.setattr(conferences_module, "mute_participant_track", mock_mute)
pubsub = redis_client.pubsub()
channel = hand_queue_channel(conference.id)
await pubsub.subscribe(channel)
try:
response = await client.post(
_url(conference.id),
json={"identity": "some-identity", "source": "microphone"},
headers=_auth_headers(owner),
)
assert response.status_code == 200, response.text
assert response.json() == {"muted": True}
mock_mute.assert_awaited_once_with(
conference.slug, identity="some-identity", source="microphone"
)
raw = await _receive_within(pubsub, max_wait=2)
assert raw is not None
assert raw["data"] == (
'{"type":"forced_mute","identity":"some-identity","source":"microphone"}'
)
finally:
await pubsub.unsubscribe(channel)
await pubsub.aclose() # type: ignore[no-untyped-call]
async def test_mute_already_off_does_not_broadcast(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Трек не был опубликован (камера/мьют и так выключены, задача A1) — не ошибка.
Ответ `muted=false` (искомое состояние уже достигнуто), без broadcast'а.
"""
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
monkeypatch.setattr(
conferences_module, "mute_participant_track", AsyncMock(return_value=False)
)
pubsub = redis_client.pubsub()
channel = hand_queue_channel(conference.id)
await pubsub.subscribe(channel)
try:
response = await client.post(
_url(conference.id),
json={"identity": "some-identity", "source": "camera"},
headers=_auth_headers(owner),
)
assert response.status_code == 200, response.text
assert response.json() == {"muted": False}
raw = await _receive_within(pubsub, max_wait=0.5)
assert raw is None
finally:
await pubsub.unsubscribe(channel)
await pubsub.aclose() # type: ignore[no-untyped-call]
async def test_admin_can_mute_participant_of_someone_elses_conference(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
admin = await _make_user(db_session, role="admin")
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
monkeypatch.setattr(conferences_module, "mute_participant_track", AsyncMock(return_value=True))
response = await client.post(
_url(conference.id),
json={"identity": "some-identity", "source": "microphone"},
headers=_auth_headers(admin),
)
assert response.status_code == 200, response.text
async def test_regular_participant_cannot_mute_someone_else(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
other = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
mock_mute = AsyncMock()
monkeypatch.setattr(conferences_module, "mute_participant_track", mock_mute)
response = await client.post(
_url(conference.id),
json={"identity": str(owner.id), "source": "microphone"},
headers=_auth_headers(other),
)
assert response.status_code == 403
assert response.json()["detail"] == "not_owner"
mock_mute.assert_not_awaited()
async def test_mute_conference_not_found(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
response = await client.post(
_url(uuid.uuid4()),
json={"identity": "some-identity", "source": "microphone"},
headers=_auth_headers(user),
)
assert response.status_code == 404
assert response.json()["detail"] == "conference_not_found"
async def test_mute_participant_not_in_room_returns_404(
client: httpx.AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
monkeypatch.setattr(
conferences_module,
"mute_participant_track",
AsyncMock(side_effect=ParticipantNotInRoomError()),
)
response = await client.post(
_url(conference.id),
json={"identity": "ghost", "source": "microphone"},
headers=_auth_headers(owner),
)
assert response.status_code == 404
assert response.json()["detail"] == "participant_not_in_room"
async def test_mute_rejects_invalid_source(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
conference = await _make_conference(db_session, owner_id=owner.id)
await db_session.commit()
response = await client.post(
_url(conference.id),
json={"identity": "some-identity", "source": "screen_share"},
headers=_auth_headers(owner),
)
assert response.status_code == 422

View File

@@ -0,0 +1,128 @@
"""Проверка пароля не должна блокировать event loop (регресс после теста 31.07.2026).
Синхронный `verify_password` останавливал весь процесс backend на 95155 мс.
При массовом входе (около 70 человек разом) это давало p95 логина 7.28 секунды,
33 соединения к БД в состоянии `idle in transaction` при одном активном запросе
и отказы на посторонних ручках — включая вход в конференцию, где пароль вообще
не проверялся. Разбор — `.forcc/LOGIN-BOTTLENECK.md`.
Тесты ниже проверяют не скорость (она зависит от железа), а **свойства**:
event loop остаётся живым, проверки идут параллельно, старые хэши мигрируют.
"""
import asyncio
import time
from argon2 import PasswordHasher
from core.security import hash_password, needs_rehash, verify_password
PASSWORD = "correct-horse-battery-staple"
async def test_verify_password_does_not_block_event_loop() -> None:
"""Пока считается argon2, event loop продолжает обслуживать другие задачи.
Это главное свойство правки. Фоновая корутина тикает каждую миллисекунду;
если проверка пароля выполняется синхронно в loop, тиков за её время будет
ноль или единицы — именно так и вело себя приложение до исправления.
"""
password_hash = await hash_password(PASSWORD)
ticks = 0
stop = False
async def ticker() -> None:
nonlocal ticks
while not stop:
ticks += 1
await asyncio.sleep(0.001)
ticker_task = asyncio.create_task(ticker())
await asyncio.sleep(0.005) # даём тикеру стартовать
ticks_before = ticks
assert await verify_password(PASSWORD, password_hash) is True
ticks_during = ticks - ticks_before
stop = True
await ticker_task
# Даже на быстром железе argon2 занимает десятки миллисекунд — за это время
# loop обязан прокрутить заметное число тиков. Порог намеренно щадящий:
# при блокировке тиков будет 01, а не «мало».
assert ticks_during >= 5, (
f"event loop простоял во время проверки пароля: {ticks_during} тиков — "
"похоже, argon2 снова считается синхронно"
)
async def test_parallel_verifications_are_concurrent() -> None:
"""Параллельные проверки идут одновременно, а не выстраиваются в очередь.
argon2-cffi — C-расширение и освобождает GIL, поэтому пул потоков даёт
настоящий параллелизм. Проверяем, что 8 проверок занимают заметно меньше,
чем 8 последовательных: иначе массовый вход снова упрётся в сериализацию.
"""
password_hash = await hash_password(PASSWORD)
start = time.perf_counter()
await verify_password(PASSWORD, password_hash)
single = time.perf_counter() - start
start = time.perf_counter()
results = await asyncio.gather(*(verify_password(PASSWORD, password_hash) for _ in range(8)))
parallel = time.perf_counter() - start
assert all(results)
# На 4-ядерном сервере 8 проверок идеально легли бы в 2×single; берём 5×
# с большим запасом на шум CI и разное железо — важно лишь то, что это
# НЕ 8× (последовательное выполнение).
assert parallel < single * 5, (
f"8 параллельных проверок заняли {parallel:.3f} с при {single:.3f} с на одну — "
"похоже, они выполняются последовательно"
)
async def test_wrong_password_is_rejected() -> None:
"""Асинхронная обёртка не сломала саму проверку."""
password_hash = await hash_password(PASSWORD)
assert await verify_password(PASSWORD, password_hash) is True
assert await verify_password("wrong-password", password_hash) is False
async def test_hasher_uses_owasp_parameters() -> None:
"""Параметры argon2id — по рекомендации OWASP, а не дефолт библиотеки.
Дефолт argon2-cffi (t=3, m=64 МБ, p=4) стоил 95 мс на проверку, причём
`parallelism=4` занимал все четыре ядра сервера — те же, на которых
работает LiveKit.
"""
password_hash = await hash_password(PASSWORD)
# Параметры зашиты в саму строку хэша: $argon2id$v=19$m=19456,t=2,p=1$...
assert "m=19456" in password_hash
assert "t=2" in password_hash
assert "p=1" in password_hash
async def test_legacy_hash_is_verified_and_marked_for_rehash() -> None:
"""Хэш со старыми параметрами проверяется, но помечается на перевыпуск.
Гарантия обратной совместимости: пароли, выданные до смены параметров,
продолжают работать. `AuthService.login` перевыпускает такой хэш при
первом же успешном входе — другого момента, когда открытый пароль есть
на руках, не будет.
"""
legacy_hasher = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4)
legacy_hash = legacy_hasher.hash(PASSWORD)
assert await verify_password(PASSWORD, legacy_hash) is True
assert needs_rehash(legacy_hash) is True
fresh_hash = await hash_password(PASSWORD)
assert needs_rehash(fresh_hash) is False
async def test_broken_hash_is_marked_for_rehash() -> None:
"""Мусор вместо хэша не роняет вход, а помечается на замену."""
assert needs_rehash("not-a-valid-argon2-hash") is True

View File

@@ -0,0 +1,59 @@
"""Тесты публичного эндпоинта настроек клиента (`/api/v1/public/settings`, сессия 33)."""
import httpx
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession
from services.instance_settings import InstanceSettingsService, SettingsUpdateIn
@pytest_asyncio.fixture(autouse=True)
async def _reset_public_flags(db_session: AsyncSession) -> None:
"""Сбросить публичные тогглы перед каждым тестом — общая dev-БД не изолирована
от ручных правок администратора (та же дисциплина, что и `_reset_registration_gating`
в `test_auth.py`); `db_session` не коммитится в реальную БД, см. `conftest.py`."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(device_check_enabled=False, virtual_background_enabled=False)
)
async def test_public_settings_disabled_by_default(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
response = await client.get("/api/v1/public/settings")
assert response.status_code == 200, response.text
assert response.json() == {"device_check_enabled": False, "virtual_background_enabled": False}
async def test_public_settings_reflects_enabled(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
await InstanceSettingsService(db_session).update(SettingsUpdateIn(device_check_enabled=True))
await db_session.commit()
response = await client.get("/api/v1/public/settings")
assert response.status_code == 200, response.text
assert response.json()["device_check_enabled"] is True
async def test_public_settings_reflects_virtual_background_enabled(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Замена фона нужна и на публичном превью входа (`JoinPage`) — до аутентификации."""
await InstanceSettingsService(db_session).update(
SettingsUpdateIn(virtual_background_enabled=True)
)
await db_session.commit()
response = await client.get("/api/v1/public/settings")
assert response.status_code == 200, response.text
assert response.json()["virtual_background_enabled"] is True
async def test_public_settings_requires_no_auth(
client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Эндпоинт публичный — работает без заголовка `Authorization`, как и обязан
(обе страницы, которым он нужен, доступны до входа в систему)."""
response = await client.get("/api/v1/public/settings")
assert response.status_code == 200, response.text

View File

@@ -20,7 +20,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
user = User( user = User(
email=f"{uuid.uuid4()}@example.com", email=f"{uuid.uuid4()}@example.com",
name_user="Test User", name_user="Test User",
password_hash=hash_password("password123"), password_hash=await hash_password("password123"),
role=role, role=role,
email_verified=True, email_verified=True,
) )

View File

@@ -18,7 +18,7 @@ async def _make_user(session: AsyncSession) -> User:
user = User( user = User(
email=f"{uuid.uuid4()}@example.com", email=f"{uuid.uuid4()}@example.com",
name_user="Teams API Tester", name_user="Teams API Tester",
password_hash=hash_password("password123"), password_hash=await hash_password("password123"),
email_verified=True, email_verified=True,
) )
session.add(user) session.add(user)

View File

@@ -1,4 +1,4 @@
"""Интеграционные тесты `/api/v1/users`: список пользователей, профиль, аватар.""" """Интеграционные тесты `/api/v1/users`: список пользователей, профиль, аватар, картинки фона."""
import uuid import uuid
from collections.abc import Generator from collections.abc import Generator
@@ -12,6 +12,7 @@ from core.config import get_settings
from core.security import create_access_token, hash_password from core.security import create_access_token, hash_password
from models.team import Team from models.team import Team
from models.user import User from models.user import User
from services.backgrounds import MAX_BACKGROUNDS_PER_USER
# Минимальные валидные по магическим байтам содержимые (без полноценного # Минимальные валидные по магическим байтам содержимые (без полноценного
# декодирования — `services/avatars.py` проверяет только сигнатуру/размер). # декодирования — `services/avatars.py` проверяет только сигнатуру/размер).
@@ -24,7 +25,7 @@ async def _make_user(session: AsyncSession) -> User:
user = User( user = User(
email=f"{uuid.uuid4()}@example.com", email=f"{uuid.uuid4()}@example.com",
name_user="List Tester", name_user="List Tester",
password_hash=hash_password("password123"), password_hash=await hash_password("password123"),
email_verified=True, email_verified=True,
) )
session.add(user) session.add(user)
@@ -98,7 +99,7 @@ async def test_get_me_with_reserved_tld_email_does_not_500(
user = User( user = User(
email=legacy_email, email=legacy_email,
name_user="Legacy Admin", name_user="Legacy Admin",
password_hash=hash_password("password123"), password_hash=await hash_password("password123"),
email_verified=True, email_verified=True,
role="admin", role="admin",
) )
@@ -380,7 +381,7 @@ async def test_list_users_search_by_q_filters_by_name_or_email(
match = User( match = User(
email=f"{unique_marker}@example.com", email=f"{unique_marker}@example.com",
name_user=f"Findable {unique_marker}", name_user=f"Findable {unique_marker}",
password_hash=hash_password("password123"), password_hash=await hash_password("password123"),
email_verified=True, email_verified=True,
) )
db_session.add(match) db_session.add(match)
@@ -393,3 +394,150 @@ async def test_list_users_search_by_q_filters_by_name_or_email(
ids = {item["id"] for item in response.json()} ids = {item["id"] for item in response.json()}
assert str(match.id) in ids assert str(match.id) in ids
assert str(requester.id) not in ids assert str(requester.id) not in ids
# --- Свои картинки фона (`/users/me/backgrounds`) -----------------
async def _upload_background(
client: httpx.AsyncClient, user: User, *, name: str = "bg.webp"
) -> httpx.Response:
return await client.post(
"/api/v1/users/me/backgrounds",
headers=_auth_headers(user),
files={"file": (name, _WEBP_BYTES, "image/webp")},
)
async def test_backgrounds_list_is_empty_by_default_and_reports_limit(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
response = await client.get("/api/v1/users/me/backgrounds", headers=_auth_headers(user))
assert response.status_code == 200, response.text
body = response.json()
assert body["items"] == []
assert body["limit"] == MAX_BACKGROUNDS_PER_USER
async def test_upload_background_saves_file_and_returns_url(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
response = await _upload_background(client, user)
assert response.status_code == 201, response.text
items = response.json()["items"]
assert len(items) == 1
url = items[0]["url"]
assert url == f"/media/backgrounds/{user.id}/{items[0]['id']}.webp"
# Файл лежит на диске, в БД только путь — см. `services/backgrounds.py`.
assert (media_root / url.removeprefix("/media/")).read_bytes() == _WEBP_BYTES
async def test_upload_background_spoofed_content_type_returns_415(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""`Content-Type: image/webp`, но байты — JPEG: клиенту не верим и здесь."""
user = await _make_user(db_session)
await db_session.commit()
response = await client.post(
"/api/v1/users/me/backgrounds",
headers=_auth_headers(user),
files={"file": ("bg.webp", _JPEG_BYTES, "image/webp")},
)
assert response.status_code == 415
assert response.json()["detail"] == "background_invalid_type"
async def test_upload_background_too_large_returns_413(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
oversized = _WEBP_BYTES + b"\x00" * (2 * 1024 * 1024)
response = await client.post(
"/api/v1/users/me/backgrounds",
headers=_auth_headers(user),
files={"file": ("bg.webp", oversized, "image/webp")},
)
assert response.status_code == 413
assert response.json()["detail"] == "background_too_large"
async def test_upload_background_over_limit_returns_409_and_keeps_ten(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Лимит держится на СЕРВЕРЕ: интерфейс можно обойти прямым запросом."""
user = await _make_user(db_session)
await db_session.commit()
for _ in range(MAX_BACKGROUNDS_PER_USER):
assert (await _upload_background(client, user)).status_code == 201
response = await _upload_background(client, user)
assert response.status_code == 409
assert response.json()["detail"] == "background_limit_reached"
listing = await client.get("/api/v1/users/me/backgrounds", headers=_auth_headers(user))
assert len(listing.json()["items"]) == MAX_BACKGROUNDS_PER_USER
async def test_delete_background_removes_file_and_row(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
user = await _make_user(db_session)
await db_session.commit()
created = (await _upload_background(client, user)).json()["items"][0]
file_path = media_root / created["url"].removeprefix("/media/")
assert file_path.exists()
response = await client.delete(
f"/api/v1/users/me/backgrounds/{created['id']}", headers=_auth_headers(user)
)
assert response.status_code == 204
assert not file_path.exists()
listing = await client.get("/api/v1/users/me/backgrounds", headers=_auth_headers(user))
assert listing.json()["items"] == []
async def test_delete_other_users_background_returns_404(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
"""Знание id чужой картинки не даёт её удалить — владелец в условии запроса."""
owner = await _make_user(db_session)
stranger = await _make_user(db_session)
await db_session.commit()
created = (await _upload_background(client, owner)).json()["items"][0]
response = await client.delete(
f"/api/v1/users/me/backgrounds/{created['id']}", headers=_auth_headers(stranger)
)
assert response.status_code == 404
assert (media_root / created["url"].removeprefix("/media/")).exists()
async def test_backgrounds_list_does_not_leak_other_users_images(
media_root: Path, client: httpx.AsyncClient, db_session: AsyncSession
) -> None:
owner = await _make_user(db_session)
stranger = await _make_user(db_session)
await db_session.commit()
await _upload_background(client, owner)
response = await client.get("/api/v1/users/me/backgrounds", headers=_auth_headers(stranger))
assert response.json()["items"] == []
async def test_backgrounds_require_authentication(
media_root: Path, client: httpx.AsyncClient
) -> None:
assert (await client.get("/api/v1/users/me/backgrounds")).status_code == 401
assert (
await client.delete(f"/api/v1/users/me/backgrounds/{uuid.uuid4()}")
).status_code == 401

View File

@@ -33,3 +33,6 @@ summarizer:
chat: chat:
enabled: true enabled: true
hand_queue:
enabled: true

View File

@@ -12,8 +12,8 @@
# затрутся при следующем рендере. # затрутся при следующем рендере.
listening-port=3478 listening-port=3478
# Установить 5349 в проде для TURN over TLS, если будут смонтированы # TLS-порт объявлен всегда; реально слушать TLS coturn начинает только когда
# реальные TLS-сертификаты (см. закомментированные cert/pkey ниже). # заданы cert/pkey ниже (блок TLS-CERT) — без них строка ничего не включает.
tls-listening-port=5349 tls-listening-port=5349
# Диапазон relay-портов для TURN-аллокаций. # Диапазон relay-портов для TURN-аллокаций.
@@ -31,14 +31,30 @@ fingerprint
# Без CLI/telnet admin-интерфейса в этой поставке. # Без CLI/telnet admin-интерфейса в этой поставке.
no-cli no-cli
# Раскомментировать и смонтировать реальные сертификаты, чтобы включить # Блок ниже рендерится ТОЛЬКО когда в .env задан TURN_TLS_HOST (см.
# TURN over TLS на 443: # render-templates.sh) — тогда coturn-certs-init (docker-compose.yml) уже
# cert=/etc/coturn/certs/cert.pem # скопировал fullchain/privkey из /etc/letsencrypt в volume coturn-certs.
# pkey=/etc/coturn/certs/key.pem # Без TURN_TLS_HOST маркеры и всё, что между ними, вырезаются целиком —
# в файле не остаётся ни следа cert/pkey, а не просто закомментированных строк.
# BEGIN-TLS-CERT
cert=/etc/coturn/certs/cert.pem
pkey=/etc/coturn/certs/key.pem
# END-TLS-CERT
log-file=stdout log-file=stdout
simple-log simple-log
# Умеренная verbose-логика (`-v`/`verbose` в терминах coturn, НЕ
# `Verbose`/`-V` — тот режим сам coturn документирует как "very annoying",
# построчный дамп пакетов). Без этого флага дефолтный уровень логов не
# печатает ни строки на ALLOCATE/CreatePermission/Refresh, даже когда TURN
# реально обслуживает relay — проверено на релизе 0.0.22 (`docker logs
# vidconf-coturn-1 | grep -ci allocate` = 0 при живом рабочем звонке,
# подтверждение пришлось брать из логов LiveKit). С этим флагом coturn
# печатает по сессии: create/delete allocation, create permission, refresh —
# достаточно, чтобы дальше проверять относительно скромный вывод.
verbose
# Внешний IP сервера — обязателен для клиентов вне docker-сети # Внешний IP сервера — обязателен для клиентов вне docker-сети
# (network_mode: host здесь не даёт coturn определить публичный IP # (network_mode: host здесь не даёт coturn определить публичный IP
# автоматически). Для локальной разработки (без внешних участников) # автоматически). Для локальной разработки (без внешних участников)

View File

@@ -89,7 +89,7 @@ services:
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media} MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение # Версия инстанса (релиз v0.0.1) — install.sh копирует значение
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health. # из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.15} VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.36}
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан # Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение, # на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
# проверьте бюджет соединений с БД: каждый воркер держит свой пул # проверьте бюджет соединений с БД: каждый воркер держит свой пул
@@ -407,16 +407,19 @@ services:
# 7880 (signaling) — ТОЛЬКО loopback: nginx проксирует /livekit/ по имени # 7880 (signaling) — ТОЛЬКО loopback: nginx проксирует /livekit/ по имени
# `livekit:7880` внутри docker-сети (см. nginx.conf.template), браузеры # `livekit:7880` внутри docker-сети (см. nginx.conf.template), браузеры
# снаружи ходят через nginx/443 (wss://), прямой доступ к 7880 им не # снаружи ходят через nginx/443 (wss://), прямой доступ к 7880 им не
# нужен. 7881/tcp и UDP-диапазон ниже — реальные медиа-порты, остаются # нужен. 7881/tcp и UDP-порт ниже — реальные медиа-порты, остаются
# публичными. # публичными.
ports: ports:
- "127.0.0.1:7880:7880" # HTTP/WebSocket signaling - "127.0.0.1:7880:7880" # HTTP/WebSocket signaling
- "7881:7881" # RTC TCP fallback - "7881:7881" # RTC TCP fallback
# Узкий диапазон для dev на macOS: широкий (50000-60000) почти всегда # Один порт вместо диапазона (был 54000-54100/udp) — LiveKit
# конфликтует с занятыми UDP-портами хоста и тормозит Docker Desktop. # мультиплексирует все ICE-сессии через него (rtc.udp_port в
# 54000+ выбран после конфликтов: нижние диапазоны (50000+, 52000+) # livekit.yaml.template), а не открывает по порту на участника.
# занимают Steam/системные процессы macOS и эфемерные QUIC-соединения. # На диапазон Docker поднимал по docker-proxy на КАЖДЫЙ порт —
- "54000-54100:54000-54100/udp" # WebRTC media (ICE) # 101 порт держали 101 лишний userland-процесс на медиапути.
# 54000 выбран, как раньше: нижние диапазоны (50000+, 52000+) на
# macOS заняты Steam/системными процессами и эфемерными QUIC.
- "54000:54000/udp" # WebRTC media (ICE, мультиплекс)
depends_on: depends_on:
redis: redis:
condition: service_healthy condition: service_healthy
@@ -429,6 +432,49 @@ services:
profiles: ["media"] profiles: ["media"]
logging: *default-logging logging: *default-logging
# Образ coturn/coturn — Dockerfile прописывает `USER nobody:nogroup`, и это
# НЕ runtime-привилегия, которую можно сбросить: Docker exec'ает entrypoint
# сразу от этого uid, root-фазы внутри контейнера нет вовсе (в отличие от
# официального образа nginx, который стартует entrypoint от root и только
# nginx-воркеры позже понижают права по директиве в конфиге — см.
# deploy/nginx/docker-entrypoint-certs.sh). Значит coturn физически не может
# сам прочитать приватный ключ Let's Encrypt (root:root, обычно 0600) —
# никакой volume-опцией это не обойти, не ослабляя права на ключ на хосте.
#
# Решение — по образцу уже существующего `recordings-init`/`llm-models-init`
# в этом файле: отдельный init-контейнер (busybox, дефолтный root) читает
# /etc/letsencrypt (той же ro-монтировкой, что и у nginx) и копирует
# fullchain/privkey в СВОЙ volume под правами 644 — это копия, а не
# оригинал, оригинальный ключ на хосте прав не меняет. Копия достаточно
# открыта, чтобы её прочитал nobody:nogroup внутри coturn.
#
# Если /etc/letsencrypt/live/<домен> не существует (dev, нет реальных
# сертификатов) — команда ниже просто ничего не копирует и завершается
# успешно; coturn стартует как раньше, без TLS (см. TURN_TLS_HOST в
# render-templates.sh — вторая половина того же переключателя).
coturn-certs-init:
image: busybox:1.36
command: >
sh -c '
SRC="/etc/letsencrypt/live/$$NGINX_CERT_NAME";
if [ -f "$$SRC/fullchain.pem" ] && [ -f "$$SRC/privkey.pem" ]; then
cp "$$SRC/fullchain.pem" /certs/cert.pem;
cp "$$SRC/privkey.pem" /certs/key.pem;
chmod 644 /certs/cert.pem /certs/key.pem;
echo "[coturn-certs-init] сертификат $$SRC скопирован в volume coturn-certs";
else
echo "[coturn-certs-init] $$SRC не найден — TLS для coturn не настроен (норма для dev без TURN_TLS_HOST)";
fi
'
environment:
NGINX_CERT_NAME: ${NGINX_CERT_NAME:?NGINX_CERT_NAME не задан в .env}
volumes:
- /etc/letsencrypt:/etc/letsencrypt:ro
- coturn-certs:/certs
restart: "no"
profiles: ["media"]
logging: *default-logging
coturn: coturn:
image: coturn/coturn:latest image: coturn/coturn:latest
restart: unless-stopped restart: unless-stopped
@@ -441,6 +487,10 @@ services:
# (deploy/render-templates.sh, вызывается install.sh). # (deploy/render-templates.sh, вызывается install.sh).
volumes: volumes:
- ./coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro - ./coturn/turnserver.conf:/etc/coturn/turnserver.conf:ro
- coturn-certs:/etc/coturn/certs:ro
depends_on:
coturn-certs-init:
condition: service_completed_successfully
network_mode: host network_mode: host
# Образ coturn/coturn — минимальный (debian-slim), в нём нет pgrep/ps/nc/ # Образ coturn/coturn — минимальный (debian-slim), в нём нет pgrep/ps/nc/
# curl/wget, поэтому проверка процесса по имени не работает # curl/wget, поэтому проверка процесса по имени не работает
@@ -884,6 +934,11 @@ volumes:
# Загруженные пользователями файлы (аватары) — общий том между # Загруженные пользователями файлы (аватары) — общий том между
# backend (запись при загрузке) и nginx (раздача статики, `location /media/`). # backend (запись при загрузке) и nginx (раздача статики, `location /media/`).
media: media:
# Копия fullchain/privkey Let's Encrypt под правами 644 для coturn
# (nobody:nogroup) — источник в /etc/letsencrypt не трогаем, см.
# coturn-certs-init выше. Обновляется при каждом перезапуске
# coturn-certs-init (deploy-hook certbot делает это при продлении).
coturn-certs:
# Метрики Prometheus (профиль `monitoring`) — переживают пересоздание контейнера. # Метрики Prometheus (профиль `monitoring`) — переживают пересоздание контейнера.
prometheus_data: prometheus_data:
# Дашборды/настройки Grafana (профиль `monitoring`) — переживают пересоздание. # Дашборды/настройки Grafana (профиль `monitoring`) — переживают пересоздание.

View File

@@ -15,16 +15,20 @@ port: 7880
rtc: rtc:
tcp_port: 7881 tcp_port: 7881
# Диапазон сужен для dev (см. комментарий в docker-compose.yml); в проде # Один UDP-порт с мультиплексированием ICE вместо диапазона портов.
# расширить и синхронизировать с пробросом портов. # Раньше здесь был port_range_start/port_range_end (54000-54100) — под
port_range_start: 54000 # каждый порт диапазона Docker поднимал отдельный процесс docker-proxy
port_range_end: 54100 # (userland-прокси на весь медиатрафик), на 101 порт — 101 процесс.
# udp_port переключает LiveKit на единственный сокет с демультиплексацией
# по ICE ufrag; port_range_start/end при заданном udp_port игнорируются
# (проверено по исходникам сервера) — оставлять их рядом бессмысленно.
udp_port: 54000
# use_external_ip: false + node_ip=127.0.0.1 — режим для локальной # use_external_ip: false + node_ip=127.0.0.1 — режим для локальной
# разработки (Docker Desktop): use_external_ip=true определяет публичный # разработки (Docker Desktop): use_external_ip=true определяет публичный
# IP через STUN, что в контейнере на macOS даёт недостижимый изнутри хоста # IP через STUN, что в контейнере на macOS даёт недостижимый изнутри хоста
# внутренний IP (172.18.x.x) — DTLS-хендшейк по data-каналам не проходит # внутренний IP (172.18.x.x) — DTLS-хендшейк по data-каналам не проходит
# ("dtls timeout" в логах). node_ip=127.0.0.1 работает, потому что порты # ("dtls timeout" в логах). node_ip=127.0.0.1 работает, потому что порты
# 7881/tcp и 54000-54100/udp проброшены на loopback хоста, а браузер-клиент # 7881/tcp и 54000/udp проброшены на loopback хоста, а браузер-клиент
# запускается на том же хосте. # запускается на том же хосте.
# В проде (LIVEKIT_USE_EXTERNAL_IP=true, LIVEKIT_NODE_IP=<внешний IP/домен # В проде (LIVEKIT_USE_EXTERNAL_IP=true, LIVEKIT_NODE_IP=<внешний IP/домен
# сервера> в .env) клиенты снаружи хоста подключаются по этому адресу — # сервера> в .env) клиенты снаружи хоста подключаются по этому адресу —
@@ -50,10 +54,24 @@ rtc:
# оба рендерятся из одного TURN_STATIC_AUTH_SECRET (deploy/render-templates.sh). # оба рендерятся из одного TURN_STATIC_AUTH_SECRET (deploy/render-templates.sh).
# Логин/пароль LiveKit генерирует сам по механизму TURN REST API. # Логин/пароль LiveKit генерирует сам по механизму TURN REST API.
# #
# UDP и TCP на 3478 — оба порта уже открыты в ufw. TLS (5349) намеренно не # UDP и TCP на 3478 — оба порта уже открыты в ufw.
# объявляем: в turnserver.conf сертификаты не смонтированы, и анонс #
# неработающего `turns:` заставил бы клиента впустую ждать таймаута, # TLS (5349) объявляется ТОЛЬКО когда в .env задан TURN_TLS_HOST (см.
# прежде чем перейти к рабочему кандидату. # render-templates.sh) — до тех пор блок между маркерами вырезается
# целиком, и клиент его не увидит вовсе. Это осознанно: анонс
# неработающего `turns:` (без смонтированных в coturn сертификатов)
# заставил бы клиента впустую ждать TLS-таймаута, прежде чем перейти
# к рабочему кандидату — именно так это и стояло здесь до включения TLS.
#
# Хост для TLS-записи обязан быть ДОМЕНОМ, а не IP (в отличие от udp/tcp
# выше): браузер проверяет TLS-сертификат TURN-сервера по имени хоста,
# а сертификат Let's Encrypt выписан на домен, не на IP — с IP в host
# TLS-хендшейк упадёт на проверке имени, и это будет выглядеть как ещё
# один вариант «coturn healthy, но relay не работает».
#
# TLS-запись стоит ПОСЛЕДНЕЙ: клиент перебирает кандидатов по порядку,
# а TLS через TCP дороже прямого UDP — она должна быть фолбэком, а не
# выбираться первой.
turn_servers: turn_servers:
- host: ${TURN_EXTERNAL_IP} - host: ${TURN_EXTERNAL_IP}
port: 3478 port: 3478
@@ -65,6 +83,13 @@ rtc:
protocol: tcp protocol: tcp
secret: ${TURN_STATIC_AUTH_SECRET} secret: ${TURN_STATIC_AUTH_SECRET}
ttl: 14400 ttl: 14400
# BEGIN-TLS-TURN
- host: ${TURN_TLS_HOST}
port: 5349
protocol: tls
secret: ${TURN_STATIC_AUTH_SECRET}
ttl: 14400
# END-TLS-TURN
# Redis обязателен для сервиса egress (см. deploy/egress/) — он использует # Redis обязателен для сервиса egress (см. deploy/egress/) — он использует
# его как pub/sub и key-value хранилище состояния запущенных записей; # его как pub/sub и key-value хранилище состояния запущенных записей;

View File

@@ -61,6 +61,89 @@ groups:
# см. также алерт QueueGrowing). Проверить # см. также алерт QueueGrowing). Проверить
# `docker compose ps llm` / `llm-gpu` и `docker compose logs llm`. # `docker compose ps llm` / `llm-gpu` и `docker compose logs llm`.
# Состояние БД и пулов соединений (сессия 33, разбор инцидента 07.08 —
# `.forcc/session-results/32-loadtest-07-08-debug.md`). `/api/health`
# отдавал 200 с `db: false` во время отказа — Prometheus его не скрейпит и
# не умеет разобрать JSON-тело, поэтому оба сигнала строятся на метриках
# `backend/api/metrics.py`, которые читаются вне основного пула.
- name: vidconf-db
rules:
# `vidconf_db_up` — отдельное соединение вне основного пула
# (`core/db.py::check_db_up`), поэтому 0 означает именно «БД не
# отвечает», а не «пул занят» (для второго см. DbConnectionPoolNearExhaustion
# ниже — раздельные метрики нарочно, см. «Главное требование» промпта
# сессии 33). `for: 30s` — два цикла скрейпа (`scrape_interval: 15s`),
# чтобы не среагировать на одиночный неудачный `connect()` (сеть/GC-пауза),
# но не тянуть с сигналом дольше: это самый критичный алерт в проекте.
- alert: DatabaseUnavailable
expr: vidconf_db_up == 0
for: 30s
labels:
severity: critical
annotations:
summary: "БД недоступна"
description: >-
vidconf_db_up == 0 дольше 30 секунд — backend не может открыть
отдельное (вне основного пула) соединение с Postgres. НЕ значит
автоматически «нужен рестарт контейнера» — по решению оператора
от 09.08 healthcheck backend'а остаётся мягким (не хардфейлится
на недоступной БД — рестарт-петля в разгар инцидента оборвала бы
WS у всех, кто в конференциях), это сигнал оператору, не
автолечение. Смотреть
`docker compose ps postgres`, `docker compose logs postgres`,
`pg_isready`.
# Раннее предупреждение — тот самый сигнал, которого не хватило
# 07.08: пул заполнялся постепенно (idle in transaction 3→8→16→26→35→
# 39→40 участников комнаты, см. session 32), а `up{job="backend"}`
# ничего не показывал, потому что backend отвечал исправно вплоть до
# самого потолка. Порог 80% — предложение из промпта сессии 33,
# `for: 1m` — фильтр от секундных всплесков (короткий пик параллельных
# запросов рассасывается за секунды, устойчивый рост участников
# комнаты — нет). На нагрузочном тесте 07.08 от пересечения 80% до
# исчерпания пула прошло по грубой оценке меньше двух минут — порог
# НЕ даёт большого запаса и это осознанный компромисс, а не идеал:
# цель — успеть до 500-х у пользователей, а не за много минут
# заранее. Перепроверить оба числа на следующем нагрузочном тесте
# (см. .forcc/session-results/33-db-health-alert.md) и подстроить,
# если реальный запас окажется у́же ожидаемого.
- alert: DbConnectionPoolNearExhaustion
expr: >-
(vidconf_db_pool_checked_out
/ (vidconf_db_pool_size + vidconf_db_pool_max_overflow)) * 100 > 80
for: 1m
labels:
severity: warning
annotations:
summary: "Основной пул соединений с БД близок к исчерпанию"
description: >-
Занято {{ $value | printf "%.0f" }}% основного пула БД дольше
минуты (порог 80%). Частая причина в этом проекте — долгоживущие
WS-подключения комнат (`api/chat.py`) держат соединение на
каждого сидящего в конференции; смотреть
`vidconf_db_pool_checked_out` и число открытых WS чата в логах,
не только текущую HTTP-нагрузку.
# Тот же класс отказа, что у пула БД (см. выше), только пул Redis —
# закрыт в 0.0.31 (`451c18e`) заданием явного max_connections, но без
# метрики занятости прошлый потолок нашёлся только руками на
# нагрузочном тесте. Бонус к задаче сессии 33 («потолки в этом
# проекте стоят лесенкой»), не отдельно запрошен промптом — пороги
# взяты по аналогии с пулом БД, не проверялись отдельным нагрузочным
# тестом именно на Redis.
- alert: RedisConnectionPoolNearExhaustion
expr: (vidconf_redis_pool_in_use / vidconf_redis_pool_max_connections) * 100 > 80
for: 1m
labels:
severity: warning
annotations:
summary: "Пул соединений Redis близок к исчерпанию"
description: >-
Занято {{ $value | printf "%.0f" }}% пула Redis дольше минуты
(порог 80%). Каждое WS-подключение комнаты держит собственную
pub/sub-подписку из этого же пула — смотреть число открытых WS
чата, не только команды Celery/кэша.
# Железо хоста (job `node` — node-exporter). Пороги подобраны под # Железо хоста (job `node` — node-exporter). Пороги подобраны под
# конкретный сервер 1gb: 8 ГБ RAM, 4 CPU, 50 ГБ диска — если сервер # конкретный сервер 1gb: 8 ГБ RAM, 4 CPU, 50 ГБ диска — если сервер
# сменится, пересчитать. # сменится, пересчитать.

View File

@@ -0,0 +1,173 @@
{
"title": "БД и пулы соединений",
"description": "Доступность БД (vidconf_db_up) и занятость основных пулов (SQLAlchemy/БД, Redis) — метрики читаются вне самих пулов, доступны и при их исчерпании (сессия 33, разбор инцидента 07.08 — .forcc/session-results/32-loadtest-07-08-debug.md). Пороги алертов см. deploy/monitoring/alerts.yml (группа vidconf-db).",
"uid": "vidconf-db-pool",
"editable": false,
"timezone": "browser",
"schemaVersion": 39,
"version": 1,
"time": { "from": "now-1h", "to": "now" },
"refresh": "10s",
"tags": ["vidconf", "db", "pool"],
"panels": [
{
"id": 1,
"title": "БД доступна",
"description": "vidconf_db_up — отдельное соединение вне основного пула (core/db.py::check_db_up). Алерт DatabaseUnavailable, for: 30s.",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"mappings": [
{ "type": "value", "options": { "0": { "text": "DOWN", "color": "red" }, "1": { "text": "UP", "color": "green" } } }
],
"thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] }
},
"overrides": []
},
"targets": [
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_db_up", "refId": "A" }
]
},
{
"id": 2,
"title": "Занятость пула БД сейчас, %",
"description": "vidconf_db_pool_checked_out / (vidconf_db_pool_size + vidconf_db_pool_max_overflow) * 100. Порог алерта DbConnectionPoolNearExhaustion — 80% дольше минуты.",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 60 }, { "color": "red", "value": 80 }] }
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "(vidconf_db_pool_checked_out / (vidconf_db_pool_size + vidconf_db_pool_max_overflow)) * 100",
"refId": "A"
}
]
},
{
"id": 3,
"title": "Занятость пула Redis сейчас, %",
"description": "vidconf_redis_pool_in_use / vidconf_redis_pool_max_connections * 100. Порог алерта RedisConnectionPoolNearExhaustion — 80% дольше минуты.",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100,
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 60 }, { "color": "red", "value": 80 }] }
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "(vidconf_redis_pool_in_use / vidconf_redis_pool_max_connections) * 100",
"refId": "A"
}
]
},
{
"id": 4,
"title": "Активных алертов группы vidconf-db",
"description": "ALERTS{alertname=~\"DatabaseUnavailable|.*PoolNearExhaustion\", alertstate=\"firing\"} — снимок того, что прямо сейчас видит Alertmanager/страница Alerts.",
"type": "stat",
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 1 }] }
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "count(ALERTS{alertname=~\"DatabaseUnavailable|.*PoolNearExhaustion\", alertstate=\"firing\"}) OR on() vector(0)",
"refId": "A"
}
]
},
{
"id": 5,
"title": "Занятость пула БД (соединений)",
"description": "vidconf_db_pool_checked_out на фоне вместимости (size + max_overflow) — эта картина должна расти под нагрузочным тестом до срабатывания алерта. Ранний сигнал: в инциденте 07.08 занятость росла постепенно по мере входа участников в комнату, а не рывком от общей HTTP-нагрузки.",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": { "custom": { "drawStyle": "line", "fillOpacity": 10 } },
"overrides": []
},
"targets": [
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_db_pool_checked_out", "legendFormat": "занято", "refId": "A" },
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_db_pool_size + vidconf_db_pool_max_overflow", "legendFormat": "вместимость (size+overflow)", "refId": "B" }
]
},
{
"id": 6,
"title": "Занятость пула Redis (соединений)",
"description": "vidconf_redis_pool_in_use на фоне vidconf_redis_pool_max_connections. Второй потолок того же класса, что у БД (закрыт в 0.0.31, redis-py 8 сменил дефолт max_connections на 100).",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": { "custom": { "drawStyle": "line", "fillOpacity": 10 } },
"overrides": []
},
"targets": [
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_redis_pool_in_use", "legendFormat": "занято", "refId": "A" },
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_redis_pool_max_connections", "legendFormat": "вместимость", "refId": "B" }
]
},
{
"id": 7,
"title": "БД доступна во времени",
"description": "vidconf_db_up как временной ряд — удобно видеть провал целиком (начало/длительность отказа), не только текущее состояние.",
"type": "timeseries",
"gridPos": { "h": 6, "w": 12, "x": 0, "y": 12 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"min": 0,
"max": 1,
"custom": { "drawStyle": "line", "fillOpacity": 20, "lineInterpolation": "stepAfter" },
"mappings": [
{ "type": "value", "options": { "0": { "text": "DOWN" }, "1": { "text": "UP" } } }
]
},
"overrides": []
},
"targets": [
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_db_up", "legendFormat": "db_up", "refId": "A" }
]
},
{
"id": 8,
"title": "Сеансы failed / очереди Celery (для сверки с общей нагрузкой)",
"description": "Тот же контекст, что на дашборде «Пайплайны пост-обработки» — здесь рядом с пулами, чтобы не переключаться между дашбордами при разборе инцидента.",
"type": "timeseries",
"gridPos": { "h": 6, "w": 12, "x": 12, "y": 12 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": { "custom": { "drawStyle": "line", "fillOpacity": 5 } },
"overrides": []
},
"targets": [
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "vidconf_pipeline_sessions{status=\"failed\"}", "legendFormat": "сеансов failed", "refId": "A" },
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "sum(vidconf_celery_queue_depth)", "legendFormat": "глубина очередей (сумма)", "refId": "B" }
]
}
]
}

View File

@@ -4,7 +4,8 @@
# сервис `prometheus`). # сервис `prometheus`).
# #
# Имена метрик backend (`vidconf_http_request_duration_seconds`, # Имена метрик backend (`vidconf_http_request_duration_seconds`,
# `vidconf_pipeline_sessions`, `vidconf_celery_queue_depth`) — КОНТРАКТ с # `vidconf_pipeline_sessions`, `vidconf_celery_queue_depth`, `vidconf_db_up`,
# `vidconf_db_pool_*`, `vidconf_redis_pool_*`) — КОНТРАКТ с
# `backend/api/metrics.py`; правила в `alerts.yml` используют их буквально — # `backend/api/metrics.py`; правила в `alerts.yml` используют их буквально —
# при переименовании метрик в backend поправить оба файла одновременно. # при переименовании метрик в backend поправить оба файла одновременно.
global: global:

View File

@@ -170,12 +170,33 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# --- Медиа (аватары): раздача напрямую из volume, в обход backend. --- # --- Медиа (аватары, картинки фона): раздача напрямую из volume, в обход backend. ---
location /media/ { location /media/ {
alias /media/; alias /media/;
autoindex off; autoindex off;
} }
# --- Рантайм MediaPipe для замены фона видео (сессия 35) ---
# Ассеты сегментации (wasm-рантайм ~9 МБ + модель ~250 КБ) отдаются СО
# СВОЕГО домена, а не с внешних CDN: VidConf ставят в закрытых контурах,
# где jsdelivr и storage.googleapis.com недоступны, и там фича молча не
# работала бы (см. frontend/src/lib/virtualBackground.ts).
#
# gzip именно здесь, а не на весь сервер: без сжатия wasm едет все 9 МБ,
# со сжатием — около 3. Файлы неизменны в пределах сборки образа и
# скачиваются один раз на браузер, поэтому кэшируются надолго.
location /mediapipe/ {
root /usr/share/nginx/html;
gzip on;
# Модель (.tflite) в список не входит намеренно: внутри она уже
# сжатый архив, gzip дал бы 0% выигрыша за полную цену по CPU.
gzip_types application/wasm application/javascript;
gzip_min_length 1024;
gzip_proxied any;
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# --- Frontend SPA (React + Vite): статика вкомпилирована в образ nginx # --- Frontend SPA (React + Vite): статика вкомпилирована в образ nginx
# (frontend/Dockerfile) в /usr/share/nginx/html. `try_files` с # (frontend/Dockerfile) в /usr/share/nginx/html. `try_files` с
# history-fallback на /index.html нужен для клиентского роутинга # history-fallback на /index.html нужен для клиентского роутинга

View File

@@ -9,27 +9,57 @@
# запускайте ПЕРЕД `docker compose up` (и после каждого изменения .env, # запускайте ПЕРЕД `docker compose up` (и после каждого изменения .env,
# влияющего на эти конфиги) — из корня репозитория: # влияющего на эти конфиги) — из корня репозитория:
# ./deploy/render-templates.sh # ./deploy/render-templates.sh
#
# Источник значений можно подменить переменной ENV_FILE:
# ENV_FILE=.env.local ./deploy/render-templates.sh
# Это нужно для локальных стендов: корневой `.env` рабочего чекаута обычно
# держит боевые значения (LIVEKIT_NODE_IP/TURN_EXTERNAL_IP смотрят на прод), и
# рендерить конфиги из него для локального запуска нельзя. Без этой переменной
# путь был жёстко зашит, и локальные сессии повторяли логику скрипта вручную
# через envsubst — расхождение с реальным рендером ждало своего часа.
# По умолчанию — как раньше, `<корень репозитория>/.env`, поэтому install.sh
# и сервер ничего не замечают (свою переменную ENV_FILE install.sh не
# экспортирует, так что она сюда не протекает).
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$SCRIPT_DIR/../.env" # Относительный путь разрешаем от текущей директории вызова (`ENV_FILE=.env.local`
# из корня репозитория), абсолютный — как есть.
ENV_FILE="${ENV_FILE:-$SCRIPT_DIR/../.env}"
if [ ! -f "$ENV_FILE" ]; then if [ ! -f "$ENV_FILE" ]; then
echo "render-templates.sh: не найден $ENV_FILE — сначала запустите ./install.sh или скопируйте .env.example в .env" >&2 echo "render-templates.sh: не найден $ENV_FILE — сначала запустите ./install.sh или скопируйте .env.example в .env" >&2
echo "render-templates.sh: другой файл значений можно задать так: ENV_FILE=.env.local $0" >&2
exit 1 exit 1
fi fi
echo "[render] источник значений: $ENV_FILE"
# Читаем только нужные ключи через grep/cut (НЕ `source .env`) — .env содержит # Читаем только нужные ключи через grep/cut (НЕ `source .env`) — .env содержит
# значения вроде `SMTP_FROM=VidConf <no-reply@vidconf.example>`, где `<` — # значения вроде `SMTP_FROM=VidConf <no-reply@vidconf.example>`, где `<` —
# валидный литерал для docker-compose/pydantic, но невалидный bash-синтаксис # валидный литерал для docker-compose/pydantic, но невалидный bash-синтаксис
# (интерпретируется как редирект) при попытке `source` файла целиком. # (интерпретируется как редирект) при попытке `source` файла целиком.
#
# `|| true` в конце обязателен: под `set -e -o pipefail` (см. выше) сборка
# `"$(env_var VAR)"` для ключа, которого в файле нет ВООБЩЕ (не просто
# пустое значение, а отсутствующая строка) иначе завершает весь скрипт
# ошибкой grep ДО того, как сработает дружелюбная проверка `:?` ниже —
# найдено на TURN_TLS_HOST (новый необязательный ключ, есть не во всех
# существующих .env).
env_var() { env_var() {
grep -E "^${1}=" "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2- grep -E "^${1}=" "$ENV_FILE" 2>/dev/null | tail -1 | cut -d= -f2- || true
} }
TURN_STATIC_AUTH_SECRET="$(env_var TURN_STATIC_AUTH_SECRET)" TURN_STATIC_AUTH_SECRET="$(env_var TURN_STATIC_AUTH_SECRET)"
TURN_REALM="$(env_var TURN_REALM)" TURN_REALM="$(env_var TURN_REALM)"
TURN_EXTERNAL_IP="$(env_var TURN_EXTERNAL_IP)" TURN_EXTERNAL_IP="$(env_var TURN_EXTERNAL_IP)"
# TURN_TLS_HOST — единственный переключатель TURN over TLS (5349) во всём
# проекте: непустой = TLS смонтирован и объявляется клиентам, пустой = TLS
# отсутствует везде (dev по умолчанию). Поэтому НЕ обязателен (без `:?`) —
# в отличие от TURN_EXTERNAL_IP, который должен быть IP хоста, TURN_TLS_HOST
# обязан быть ДОМЕНОМ сертификата (иначе браузер не пройдёт TLS-валидацию
# по имени хоста для `turns:`, см. комментарий в livekit.yaml.template).
TURN_TLS_HOST="$(env_var TURN_TLS_HOST)"
LIVEKIT_API_KEY="$(env_var LIVEKIT_API_KEY)" LIVEKIT_API_KEY="$(env_var LIVEKIT_API_KEY)"
LIVEKIT_NODE_IP="$(env_var LIVEKIT_NODE_IP)" LIVEKIT_NODE_IP="$(env_var LIVEKIT_NODE_IP)"
LIVEKIT_USE_EXTERNAL_IP="$(env_var LIVEKIT_USE_EXTERNAL_IP)" LIVEKIT_USE_EXTERNAL_IP="$(env_var LIVEKIT_USE_EXTERNAL_IP)"
@@ -43,17 +73,31 @@ REDIS_PASSWORD="$(env_var REDIS_PASSWORD)"
: "${LIVEKIT_USE_EXTERNAL_IP:?LIVEKIT_USE_EXTERNAL_IP не задан в .env (true/false)}" : "${LIVEKIT_USE_EXTERNAL_IP:?LIVEKIT_USE_EXTERNAL_IP не задан в .env (true/false)}"
: "${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env (redis запускается с --requirepass, см. docker-compose.yml)}" : "${REDIS_PASSWORD:?REDIS_PASSWORD не задан в .env (redis запускается с --requirepass, см. docker-compose.yml)}"
export TURN_STATIC_AUTH_SECRET TURN_REALM TURN_EXTERNAL_IP LIVEKIT_API_KEY LIVEKIT_NODE_IP LIVEKIT_USE_EXTERNAL_IP REDIS_PASSWORD export TURN_STATIC_AUTH_SECRET TURN_REALM TURN_EXTERNAL_IP TURN_TLS_HOST LIVEKIT_API_KEY LIVEKIT_NODE_IP LIVEKIT_USE_EXTERNAL_IP REDIS_PASSWORD
# Вырезает блок между парой маркеров-комментариев (не только их самих), если
# TURN_TLS_HOST пуст — так рендер отражает реальное наличие TLS-сертификатов,
# а не просто закомментированные "на будущее" строки.
strip_tls_block_if_disabled() {
local file="$1" begin_marker="$2" end_marker="$3"
if [ -z "$TURN_TLS_HOST" ]; then
sed -i.bak "/${begin_marker}/,/${end_marker}/d" "$file" && rm -f "$file.bak"
else
sed -i.bak "/${begin_marker}/d; /${end_marker}/d" "$file" && rm -f "$file.bak"
fi
}
envsubst '${TURN_STATIC_AUTH_SECRET} ${TURN_REALM} ${TURN_EXTERNAL_IP}' \ envsubst '${TURN_STATIC_AUTH_SECRET} ${TURN_REALM} ${TURN_EXTERNAL_IP}' \
< "$SCRIPT_DIR/coturn/turnserver.conf.template" > "$SCRIPT_DIR/coturn/turnserver.conf" < "$SCRIPT_DIR/coturn/turnserver.conf.template" > "$SCRIPT_DIR/coturn/turnserver.conf"
echo "[render] deploy/coturn/turnserver.conf готов" strip_tls_block_if_disabled "$SCRIPT_DIR/coturn/turnserver.conf" "# BEGIN-TLS-CERT" "# END-TLS-CERT"
echo "[render] deploy/coturn/turnserver.conf готов$([ -n "$TURN_TLS_HOST" ] && echo " (TLS включён)" || echo " (TLS выключен — TURN_TLS_HOST пуст)")"
# TURN_EXTERNAL_IP и TURN_STATIC_AUTH_SECRET нужны и здесь: с 0.0.14 LiveKit # TURN_EXTERNAL_IP и TURN_STATIC_AUTH_SECRET нужны и здесь: с 0.0.14 LiveKit
# анонсирует клиентам внешний coturn (секция `rtc.turn_servers`), и секрет # анонсирует клиентам внешний coturn (секция `rtc.turn_servers`), и секрет
# обязан совпадать с `static-auth-secret` в turnserver.conf выше. # обязан совпадать с `static-auth-secret` в turnserver.conf выше.
envsubst '${LIVEKIT_USE_EXTERNAL_IP} ${LIVEKIT_NODE_IP} ${LIVEKIT_API_KEY} ${REDIS_PASSWORD} ${TURN_EXTERNAL_IP} ${TURN_STATIC_AUTH_SECRET}' \ envsubst '${LIVEKIT_USE_EXTERNAL_IP} ${LIVEKIT_NODE_IP} ${LIVEKIT_API_KEY} ${REDIS_PASSWORD} ${TURN_EXTERNAL_IP} ${TURN_STATIC_AUTH_SECRET} ${TURN_TLS_HOST}' \
< "$SCRIPT_DIR/livekit/livekit.yaml.template" > "$SCRIPT_DIR/livekit/livekit.yaml" < "$SCRIPT_DIR/livekit/livekit.yaml.template" > "$SCRIPT_DIR/livekit/livekit.yaml"
strip_tls_block_if_disabled "$SCRIPT_DIR/livekit/livekit.yaml" "# BEGIN-TLS-TURN" "# END-TLS-TURN"
echo "[render] deploy/livekit/livekit.yaml готов" echo "[render] deploy/livekit/livekit.yaml готов"
envsubst '${REDIS_PASSWORD}' \ envsubst '${REDIS_PASSWORD}' \

View File

@@ -0,0 +1,33 @@
# Дефолтные фоны замены фона видео
Три сцены, которые предлагаются участнику «из коробки» при включённом модуле
«Замена фона» (`instance_settings.virtual_background`):
| файл | сцена |
|---|---|
| `office.svg` | офис: окно с городом, картина, лампа, стеллаж, растение |
| `beach.svg` | пляж: море, песок, пальмы по краям кадра |
| `space-station.svg` | космическая станция: панели, иллюминатор с Землёй |
## Откуда взялись картинки
**Это собственные векторные рисунки VidConf, а не фотографии из интернета.**
Так решено сознательно (решение оператора от 09.08.2026): чужая фотография
тянет за собой чужую лицензию, а продукт самоуправляемый и расходится по
инсталляциям — проверять права на каждую копию некому. У нарисованной сцены
правовых вопросов нет вовсе.
Сцены намеренно расфокусированы (`feGaussianBlur`) и оставляют спокойным центр
кадра: там находится человек, а резкая графика за спиной сильнее всего выдаёт
подмену фона.
## Как пересобрать
```bash
python3 design/backgrounds/render.py
```
Скрипт растеризует каждый `*.svg` в 1280×720 через headless Chrome и кладёт
результат в `frontend/public/backgrounds/*.webp` (именно эти файлы и
раздаются фронтендом). Нужны установленный Google Chrome и Pillow.
Правьте SVG — WebP пересобирается из них, вручную растровые файлы не трогаем.

View File

@@ -0,0 +1,106 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
<title>Пляж с пальмами — дефолтный фон замены фона видео</title>
<desc>Собственная векторная сцена VidConf. Море, песок и пальмы по краям кадра;
центр оставлен спокойным — там находится человек.</desc>
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#7fc4e8"/>
<stop offset="0.55" stop-color="#bfe4f2"/>
<stop offset="1" stop-color="#f2e7c9"/>
</linearGradient>
<linearGradient id="sea" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#2a8fb5"/>
<stop offset="0.5" stop-color="#3aa8c4"/>
<stop offset="1" stop-color="#63c8cf"/>
</linearGradient>
<linearGradient id="sand" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#f0dfb8"/>
<stop offset="1" stop-color="#d8bf90"/>
</linearGradient>
<linearGradient id="trunk" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#8c6239"/>
<stop offset="1" stop-color="#5f4126"/>
</linearGradient>
<radialGradient id="sun" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#fffbe8" stop-opacity="0.95"/>
<stop offset="1" stop-color="#fff3c4" stop-opacity="0"/>
</radialGradient>
<radialGradient id="beachVignette" cx="0.5" cy="0.45" r="0.8">
<stop offset="0.55" stop-color="#000000" stop-opacity="0"/>
<stop offset="1" stop-color="#1f4a5c" stop-opacity="0.26"/>
</radialGradient>
<filter id="seaSoft" x="-5%" y="-5%" width="110%" height="110%">
<feGaussianBlur stdDeviation="4"/>
</filter>
<filter id="palmSoft" x="-8%" y="-8%" width="116%" height="116%">
<feGaussianBlur stdDeviation="7"/>
</filter>
</defs>
<rect width="1280" height="720" fill="url(#sky)"/>
<ellipse cx="900" cy="150" rx="300" ry="230" fill="url(#sun)"/>
<g filter="url(#seaSoft)">
<!-- Облака -->
<ellipse cx="270" cy="128" rx="130" ry="34" fill="#ffffff" opacity="0.72"/>
<ellipse cx="350" cy="112" rx="88" ry="28" fill="#ffffff" opacity="0.6"/>
<ellipse cx="1010" cy="96" rx="112" ry="28" fill="#ffffff" opacity="0.5"/>
<!-- Море -->
<rect x="0" y="352" width="1280" height="176" fill="url(#sea)"/>
<!-- Блики на воде -->
<g fill="#ffffff" opacity="0.4">
<rect x="120" y="392" width="150" height="6" rx="3"/>
<rect x="360" y="420" width="210" height="6" rx="3"/>
<rect x="700" y="400" width="170" height="6" rx="3"/>
<rect x="980" y="440" width="190" height="6" rx="3"/>
<rect x="240" y="466" width="130" height="5" rx="2.5"/>
<rect x="820" y="474" width="150" height="5" rx="2.5"/>
</g>
<!-- Пена у берега -->
<path d="M0 512c180 26 360-12 540 8s360 34 740-8v36H0z" fill="#ffffff" opacity="0.75"/>
</g>
<!-- Песок -->
<rect x="0" y="530" width="1280" height="190" fill="url(#sand)"/>
<g fill="#c9ac7c" opacity="0.45">
<ellipse cx="300" cy="640" rx="180" ry="18"/>
<ellipse cx="960" cy="682" rx="220" ry="20"/>
</g>
<!-- Пальмы по краям кадра -->
<g filter="url(#palmSoft)">
<g>
<path d="M168 704c-6-190 6-320 40-420l26 8c-30 100-40 226-34 412z" fill="url(#trunk)"/>
<g fill="#2f7a4f">
<path d="M234 292c-84-30-150-16-196 40 66-16 128-14 186 12z"/>
<path d="M234 292c-58-72-124-96-196-76 62 22 116 58 164 102z"/>
<path d="M240 288c50-72 116-98 190-82-62 26-114 62-158 108z"/>
<path d="M242 296c86-24 154-4 198 56-66-22-128-24-186 4z"/>
<path d="M238 282c14-78-8-140-70-176 26 66 38 128 40 184z"/>
</g>
<g fill="#245f3e">
<path d="M236 300c-46 16-88 46-124 90 48-32 94-52 138-60z"/>
<path d="M240 300c50 14 94 46 130 92-48-34-96-56-142-64z"/>
</g>
<circle cx="228" cy="306" r="12" fill="#7a5a30"/>
<circle cx="252" cy="312" r="11" fill="#7a5a30"/>
</g>
<g>
<path d="M1136 720c10-166 2-278-26-360l-24 8c26 84 34 190 26 352z" fill="url(#trunk)"/>
<g fill="#2f7a4f">
<path d="M1084 352c72-30 134-20 178 30-60-16-118-14-172 8z"/>
<path d="M1084 352c50-66 110-90 176-72-56 20-106 54-150 96z"/>
<path d="M1080 348c-46-64-104-88-170-74 56 24 102 58 142 100z"/>
<path d="M1078 356c-78-22-140-4-180 50 60-20 116-22 168 4z"/>
</g>
<g fill="#245f3e">
<path d="M1082 360c42 14 80 42 112 82-42-30-84-48-124-56z"/>
</g>
<circle cx="1090" cy="364" r="11" fill="#7a5a30"/>
</g>
</g>
<rect width="1280" height="720" fill="url(#beachVignette)"/>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -0,0 +1,130 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
<title>Офис — дефолтный фон замены фона видео</title>
<desc>Собственная векторная сцена VidConf. Светлый переговорный угол: окно с
тёплым светом, стеллаж с книгами и растениями. Центр кадра намеренно спокойный —
там будет человек, детали вынесены к краям.</desc>
<defs>
<linearGradient id="wall" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#f3ede4"/>
<stop offset="0.62" stop-color="#e7ded1"/>
<stop offset="1" stop-color="#d9cebe"/>
</linearGradient>
<linearGradient id="floor" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#c9b49a"/>
<stop offset="1" stop-color="#a98e70"/>
</linearGradient>
<linearGradient id="daylight" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fdfaf2"/>
<stop offset="1" stop-color="#e8eef1"/>
</linearGradient>
<linearGradient id="wood" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#b58a5f"/>
<stop offset="1" stop-color="#8d6540"/>
</linearGradient>
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#fff6de" stop-opacity="0.85"/>
<stop offset="1" stop-color="#fff6de" stop-opacity="0"/>
</radialGradient>
<radialGradient id="vignette" cx="0.5" cy="0.45" r="0.78">
<stop offset="0.55" stop-color="#000000" stop-opacity="0"/>
<stop offset="1" stop-color="#3b2d1e" stop-opacity="0.3"/>
</radialGradient>
<!-- Мягкое расфокусирование: фон за спиной человека в кадре камеры никогда
не бывает резким, и резкая графика выдаёт подмену сильнее всего. -->
<filter id="soft" x="-6%" y="-6%" width="112%" height="112%">
<feGaussianBlur stdDeviation="5"/>
</filter>
<filter id="softer" x="-8%" y="-8%" width="116%" height="116%">
<feGaussianBlur stdDeviation="10"/>
</filter>
</defs>
<rect width="1280" height="720" fill="url(#wall)"/>
<g filter="url(#soft)">
<!-- Окно слева -->
<rect x="52" y="86" width="330" height="392" rx="10" fill="#cbb9a3"/>
<rect x="66" y="100" width="302" height="364" rx="6" fill="url(#daylight)"/>
<rect x="212" y="100" width="10" height="364" fill="#cbb9a3"/>
<rect x="66" y="276" width="302" height="10" fill="#cbb9a3"/>
<!-- Городская дымка за стеклом -->
<rect x="66" y="352" width="302" height="112" fill="#dfe6e6" opacity="0.85"/>
<rect x="96" y="300" width="46" height="164" fill="#d3dcde" opacity="0.7"/>
<rect x="158" y="330" width="34" height="134" fill="#cfd8db" opacity="0.6"/>
<rect x="248" y="316" width="52" height="148" fill="#d3dcde" opacity="0.65"/>
<rect x="314" y="344" width="30" height="120" fill="#cfd8db" opacity="0.55"/>
<!-- Стеллаж справа -->
<rect x="876" y="120" width="352" height="392" rx="8" fill="url(#wood)"/>
<rect x="892" y="136" width="320" height="112" fill="#e9dcc9" opacity="0.55"/>
<rect x="892" y="264" width="320" height="112" fill="#e9dcc9" opacity="0.5"/>
<rect x="892" y="392" width="320" height="104" fill="#e9dcc9" opacity="0.45"/>
<!-- Книги -->
<g>
<rect x="906" y="156" width="18" height="92" fill="#8a5a4a"/>
<rect x="928" y="168" width="14" height="80" fill="#6c7f6a"/>
<rect x="946" y="150" width="20" height="98" fill="#c08a4a"/>
<rect x="970" y="172" width="16" height="76" fill="#4f6478"/>
<rect x="990" y="160" width="12" height="88" fill="#9d5f5f"/>
<rect x="1010" y="176" width="22" height="72" fill="#7a6a55"/>
<rect x="906" y="292" width="16" height="84" fill="#5f7382"/>
<rect x="926" y="284" width="20" height="92" fill="#a86f4e"/>
<rect x="950" y="300" width="14" height="76" fill="#7f8f76"/>
<rect x="968" y="288" width="18" height="88" fill="#8d5a63"/>
</g>
<!-- Растение на полке -->
<g>
<path d="M1140 392c-26-14-40-42-34-72 26 6 44 30 44 60z" fill="#4f7a52"/>
<path d="M1150 392c26-16 38-46 30-76-26 8-42 34-40 64z" fill="#5f8f5f"/>
<rect x="1128" y="386" width="40" height="30" rx="5" fill="#b98a63"/>
</g>
<!-- Картина на стене между окном и стеллажом -->
<g>
<rect x="486" y="150" width="196" height="146" rx="6" fill="#c2a882"/>
<rect x="498" y="162" width="172" height="122" fill="#eae3d3"/>
<path d="M498 284l52-58 40 34 44-52 36 76z" fill="#9fb5a2"/>
<circle cx="622" cy="196" r="16" fill="#e8c579"/>
</g>
<!-- Настольная лампа и край стола справа от картины -->
<g>
<rect x="700" y="452" width="220" height="14" rx="4" fill="#c8a878"/>
<rect x="712" y="466" width="12" height="46" fill="#b2915f"/>
<rect x="896" y="466" width="12" height="46" fill="#b2915f"/>
<rect x="792" y="392" width="8" height="60" fill="#6f7b82"/>
<path d="M760 392l36-52 36 52z" fill="#7f8d95"/>
<ellipse cx="796" cy="424" rx="54" ry="18" fill="#ffe9b8" opacity="0.5"/>
</g>
</g>
<!-- Пол -->
<rect x="0" y="512" width="1280" height="208" fill="url(#floor)"/>
<rect x="0" y="506" width="1280" height="14" fill="#e9e0d3" opacity="0.7"/>
<!-- Крупное растение в левом углу — передний план, размыто сильнее -->
<g filter="url(#softer)" opacity="0.95">
<g fill="#3f6b45">
<ellipse cx="88" cy="508" rx="52" ry="34" transform="rotate(-24 88 508)"/>
<ellipse cx="206" cy="556" rx="56" ry="36" transform="rotate(18 206 556)"/>
<ellipse cx="70" cy="596" rx="48" ry="32" transform="rotate(-8 70 596)"/>
</g>
<g fill="#4c7f52">
<ellipse cx="158" cy="486" rx="50" ry="32" transform="rotate(6 158 486)"/>
<ellipse cx="248" cy="626" rx="46" ry="30" transform="rotate(32 248 626)"/>
<ellipse cx="124" cy="574" rx="54" ry="34" transform="rotate(-14 124 574)"/>
</g>
<g stroke="#35603c" stroke-width="7" fill="none" stroke-linecap="round">
<path d="M120 668c-16-56-24-108-32-160"/>
<path d="M132 668c14-52 34-96 62-136"/>
<path d="M126 668c2-44 20-76 46-96"/>
</g>
<rect x="52" y="656" width="150" height="86" rx="14" fill="#9d7550"/>
<rect x="52" y="656" width="150" height="16" rx="8" fill="#b08a63"/>
</g>
<!-- Свет из окна и общая виньетка -->
<ellipse cx="300" cy="250" rx="460" ry="330" fill="url(#glow)"/>
<rect width="1280" height="720" fill="url(#vignette)"/>
</svg>

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Запекает векторные сцены дефолтных фонов (`*.svg`) в WebP для фронтенда.
Исходники сцен — собственные рисунки VidConf (см. README рядом), поэтому
лежат в репозитории вместе с результатом: чужих фотографий с их лицензиями в
продукте нет.
Запуск (нужен установленный Google Chrome и Pillow):
python3 design/backgrounds/render.py
Результат — `frontend/public/backgrounds/*.webp`, 1280×720. Chrome нужен
только для растеризации SVG (rsvg/ImageMagick на машине может не быть),
Pillow — для перевода PNG в WebP: браузерный скриншот WebP не отдаёт.
"""
import subprocess
import sys
import tempfile
from pathlib import Path
from PIL import Image
CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
WIDTH, HEIGHT = 1280, 720
# 82 — визуально неотличимо от исходника на плавных градиентах сцены, а вес
# втрое меньше, чем при 95. Фон грузится по сети при каждом первом показе.
WEBP_QUALITY = 82
SOURCE_DIR = Path(__file__).parent
TARGET_DIR = SOURCE_DIR.parents[1] / "frontend" / "public" / "backgrounds"
def render(svg_path: Path, out_path: Path, workdir: Path) -> None:
"""Растеризовать одну сцену и сохранить её в WebP."""
png_path = workdir / f"{svg_path.stem}.png"
subprocess.run(
[
CHROME,
"--headless",
"--disable-gpu",
"--hide-scrollbars",
"--default-background-color=00000000",
f"--screenshot={png_path}",
f"--window-size={WIDTH},{HEIGHT}",
svg_path.resolve().as_uri(),
],
check=True,
capture_output=True,
)
with Image.open(png_path) as image:
image.convert("RGB").save(out_path, "WEBP", quality=WEBP_QUALITY, method=6)
def main() -> int:
if not Path(CHROME).exists():
print(f"не найден Chrome: {CHROME}", file=sys.stderr)
return 1
TARGET_DIR.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as tmp:
workdir = Path(tmp)
for svg_path in sorted(SOURCE_DIR.glob("*.svg")):
out_path = TARGET_DIR / f"{svg_path.stem}.webp"
render(svg_path, out_path, workdir)
print(
f"{svg_path.name}{out_path.relative_to(SOURCE_DIR.parents[1])}"
f" ({out_path.stat().st_size // 1024} КБ)"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,150 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720">
<title>Космическая станция — дефолтный фон замены фона видео</title>
<desc>Собственная векторная сцена VidConf. Интерьер модуля станции: панели,
подсветка, иллюминатор с Землёй. Центр кадра спокойный — там человек.</desc>
<defs>
<linearGradient id="hull" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#2b3446"/>
<stop offset="0.5" stop-color="#222a3a"/>
<stop offset="1" stop-color="#161c28"/>
</linearGradient>
<linearGradient id="panel" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#39445a"/>
<stop offset="1" stop-color="#28304180"/>
</linearGradient>
<linearGradient id="strip" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#4fd7ff" stop-opacity="0.1"/>
<stop offset="0.5" stop-color="#7fe8ff" stop-opacity="0.9"/>
<stop offset="1" stop-color="#4fd7ff" stop-opacity="0.1"/>
</linearGradient>
<radialGradient id="earth" cx="0.36" cy="0.32" r="0.78">
<stop offset="0" stop-color="#7fd1f5"/>
<stop offset="0.55" stop-color="#2f7fc4"/>
<stop offset="1" stop-color="#0c2c56"/>
</radialGradient>
<radialGradient id="atmo" cx="0.5" cy="0.5" r="0.5">
<stop offset="0.78" stop-color="#8fd8ff" stop-opacity="0"/>
<stop offset="0.9" stop-color="#8fd8ff" stop-opacity="0.5"/>
<stop offset="1" stop-color="#8fd8ff" stop-opacity="0"/>
</radialGradient>
<radialGradient id="portGlow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#8fd8ff" stop-opacity="0.4"/>
<stop offset="1" stop-color="#8fd8ff" stop-opacity="0"/>
</radialGradient>
<radialGradient id="spaceVignette" cx="0.5" cy="0.45" r="0.78">
<stop offset="0.5" stop-color="#000000" stop-opacity="0"/>
<stop offset="1" stop-color="#05080f" stop-opacity="0.65"/>
</radialGradient>
<filter id="stationSoft" x="-6%" y="-6%" width="112%" height="112%">
<feGaussianBlur stdDeviation="4"/>
</filter>
<filter id="stationSofter" x="-8%" y="-8%" width="116%" height="116%">
<feGaussianBlur stdDeviation="9"/>
</filter>
<!-- Всё, что «за стеклом», обрезается по стеклу: без этого Земля вылезала
за переплёт иллюминатора и сцена читалась как наклейка на стене. -->
<clipPath id="portholeGlass">
<circle cx="640" cy="318" r="192"/>
</clipPath>
</defs>
<rect width="1280" height="720" fill="url(#hull)"/>
<g filter="url(#stationSoft)">
<!-- Рёбра модуля -->
<g fill="#39445a" opacity="0.55">
<rect x="0" y="0" width="1280" height="52"/>
<rect x="0" y="668" width="1280" height="52"/>
<rect x="150" y="52" width="26" height="616"/>
<rect x="1104" y="52" width="26" height="616"/>
</g>
<!-- Приборные панели слева -->
<g>
<rect x="46" y="150" width="220" height="300" rx="14" fill="url(#panel)"/>
<rect x="66" y="172" width="180" height="86" rx="6" fill="#101826"/>
<g fill="#5ee0a8" opacity="0.85">
<rect x="78" y="230" width="120" height="4" rx="2"/>
<rect x="78" y="216" width="82" height="4" rx="2"/>
<rect x="78" y="202" width="146" height="4" rx="2"/>
</g>
<g>
<circle cx="92" cy="292" r="11" fill="#ff8a5c"/>
<circle cx="126" cy="292" r="11" fill="#ffd166"/>
<circle cx="160" cy="292" r="11" fill="#5ee0a8"/>
</g>
<g fill="#4a5670">
<rect x="66" y="322" width="180" height="16" rx="8"/>
<rect x="66" y="352" width="180" height="16" rx="8"/>
<rect x="66" y="382" width="180" height="16" rx="8"/>
</g>
<rect x="66" y="322" width="104" height="16" rx="8" fill="#7fe8ff" opacity="0.8"/>
<rect x="66" y="352" width="62" height="16" rx="8" fill="#7fe8ff" opacity="0.6"/>
</g>
<!-- Стеллаж оборудования справа -->
<g>
<rect x="1014" y="128" width="230" height="344" rx="14" fill="url(#panel)"/>
<g fill="#101826">
<rect x="1034" y="150" width="190" height="70" rx="6"/>
<rect x="1034" y="238" width="190" height="70" rx="6"/>
<rect x="1034" y="326" width="190" height="70" rx="6"/>
</g>
<g fill="#7fe8ff" opacity="0.6">
<rect x="1050" y="176" width="86" height="5" rx="2.5"/>
<rect x="1050" y="264" width="126" height="5" rx="2.5"/>
<rect x="1050" y="352" width="64" height="5" rx="2.5"/>
</g>
<circle cx="1206" cy="424" r="14" fill="#ff8a5c" opacity="0.8"/>
</g>
<!-- Иллюминатор -->
<g>
<circle cx="640" cy="318" r="212" fill="#39445a"/>
<circle cx="640" cy="318" r="192" fill="#0b1120"/>
<g clip-path="url(#portholeGlass)">
<!-- Звёзды -->
<g fill="#ffffff">
<circle cx="530" cy="200" r="2.5" opacity="0.9"/>
<circle cx="596" cy="168" r="1.8" opacity="0.7"/>
<circle cx="712" cy="188" r="2.2" opacity="0.8"/>
<circle cx="768" cy="252" r="1.6" opacity="0.6"/>
<circle cx="500" cy="300" r="1.8" opacity="0.65"/>
<circle cx="742" cy="356" r="2" opacity="0.7"/>
<circle cx="556" cy="424" r="1.7" opacity="0.6"/>
<circle cx="676" cy="456" r="2.3" opacity="0.75"/>
</g>
<!-- Земля -->
<circle cx="596" cy="386" r="150" fill="url(#earth)"/>
<g fill="#3f8f5f" opacity="0.75">
<path d="M520 330c40-16 74-10 96 14-34 8-64 12-96 6z"/>
<path d="M596 430c46-8 84 6 108 40-42 4-80-8-108-30z"/>
<path d="M498 404c26 6 46 22 58 46-28-4-50-18-66-38z"/>
</g>
<g fill="#ffffff" opacity="0.35">
<ellipse cx="560" cy="352" rx="70" ry="16"/>
<ellipse cx="656" cy="416" rx="86" ry="18"/>
</g>
<circle cx="596" cy="386" r="150" fill="url(#atmo)"/>
</g>
<!-- Переплёт иллюминатора -->
<circle cx="640" cy="318" r="192" fill="none" stroke="#4a5670" stroke-width="14"/>
<circle cx="640" cy="318" r="206" fill="none" stroke="#2b3446" stroke-width="16"/>
<g fill="#4a5670">
<rect x="622" y="98" width="36" height="34" rx="8"/>
<rect x="622" y="504" width="36" height="34" rx="8"/>
<rect x="420" y="300" width="34" height="36" rx="8"/>
<rect x="826" y="300" width="34" height="36" rx="8"/>
</g>
</g>
</g>
<!-- Свечение из иллюминатора и световые полосы модуля -->
<ellipse cx="640" cy="318" rx="420" ry="330" fill="url(#portGlow)"/>
<g filter="url(#stationSofter)">
<rect x="176" y="60" width="928" height="10" rx="5" fill="url(#strip)"/>
<rect x="176" y="650" width="928" height="10" rx="5" fill="url(#strip)"/>
</g>
<rect width="1280" height="720" fill="url(#spaceVignette)"/>
</svg>

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

@@ -393,7 +393,9 @@ Email сразу считается подтверждённым (письмо
"registration_team_choice": false, "registration_team_choice": false,
"registration_email_domain_enabled": false, "registration_email_domain_enabled": false,
"registration_email_domain": null, "registration_email_domain": null,
"transcription_queue_served": true "transcription_queue_served": true,
"publish_quality_cap": "off",
"stage_max_tiles": 25
} }
``` ```
@@ -408,6 +410,8 @@ Email сразу считается подтверждённым (письмо
- `registration_email_domain_enabled` — включена ли верификация регистрирующихся по домену email (дефолт `false`) - `registration_email_domain_enabled` — включена ли верификация регистрирующихся по домену email (дефолт `false`)
- `registration_email_domain` — эталонный домен email (нормализован: без ведущего `@`, в нижнем регистре); `null`, пока верификация не настроена - `registration_email_domain` — эталонный домен email (нормализован: без ведущего `@`, в нижнем регистре); `null`, пока верификация не настроена
- `transcription_queue_served``true`, если хотя бы один Celery-воркер `transcriber` активно обслуживает очередь транскрибации; `false` = предупреждение в админке (см. ниже) - `transcription_queue_served``true`, если хотя бы один Celery-воркер `transcriber` активно обслуживает очередь транскрибации; `false` = предупреждение в админке (см. ниже)
- `publish_quality_cap` — потолок качества исходящего видео публикующего: `off` (без ограничения, дефолт), `720p`, `360p` или `180p`; отдаётся участнику ещё и в join-ответе (`JoinOut`, `POST /api/v1/conferences/{id}/join`/`guest-join`) — до входа в LiveKit-комнату
- `stage_max_tiles` — максимум одновременно видимых плиток сетки конференции: `4`, `9`, `16` или `25` (дефолт, совпадает с текущим потолком сетки 5×5, то есть без ограничения); участники сверх лимита уходят на следующую страницу пагинации
--- ---

View File

@@ -81,7 +81,7 @@ ufw allow 22/tcp # SSH — сузьте до вашей сети, если
ufw allow 80/tcp # HTTP (редирект на HTTPS + ACME-challenge) ufw allow 80/tcp # HTTP (редирект на HTTPS + ACME-challenge)
ufw allow 443/tcp # HTTPS ufw allow 443/tcp # HTTPS
ufw allow 7881/tcp # LiveKit RTC TCP fallback (профиль media) ufw allow 7881/tcp # LiveKit RTC TCP fallback (профиль media)
ufw allow 54000:54100/udp # LiveKit WebRTC media (ICE), см. docker-compose.yml ufw allow 54000/udp # LiveKit WebRTC media (ICE, мультиплекс), см. docker-compose.yml
# TURN (coturn) — только если включаете раздел 8. Нужны ОБА пункта: # TURN (coturn) — только если включаете раздел 8. Нужны ОБА пункта:
# сигнальные порты И диапазон relay-аллокаций (min-port/max-port из # сигнальные порты И диапазон relay-аллокаций (min-port/max-port из
# deploy/coturn/turnserver.conf). Без второго TURN отвечает на запросы, но # deploy/coturn/turnserver.conf). Без второго TURN отвечает на запросы, но
@@ -191,6 +191,14 @@ chmod 600 .env # секреты внутри — только root может
собирает образы, поднимает `postgres`/`redis`, применяет миграции Alembic + собирает образы, поднимает `postgres`/`redis`, применяет миграции Alembic +
seed, поднимает остальной стек (`up -d --wait`). seed, поднимает остальной стек (`up -d --wait`).
По умолчанию `render-templates.sh` читает корневой `.env`. Другой файл
значений задаётся переменной окружения — это нужно, когда рядом с рабочим
`.env` (боевые адреса) поднимается локальный стенд:
```bash
ENV_FILE=.env.local ./deploy/render-templates.sh
```
**Не запускайте `docker compose` вручную без `--env-file .env`** — без него **Не запускайте `docker compose` вручную без `--env-file .env`** — без него
compose не подхватывает корневой `.env` (файл на уровень выше compose не подхватывает корневой `.env` (файл на уровень выше
`deploy/docker-compose.yml`) и подставляет небезопасные дефолты из самого `deploy/docker-compose.yml`) и подставляет небезопасные дефолты из самого
@@ -237,7 +245,22 @@ docker compose -f deploy/docker-compose.yml --env-file .env restart nginx
```bash ```bash
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh << 'EOF' cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh << 'EOF'
#!/bin/bash #!/bin/bash
# certbot renew копирует новый сертификат в /etc/letsencrypt/live/<домен>/,
# но и nginx, и coturn держат СВОИ копии (docker-entrypoint-certs.sh и
# coturn-certs-init в deploy/docker-compose.yml) — обе копируются только
# при СТАРТЕ/пересоздании соответствующего контейнера. Reload недостаточен
# ни для того, ни для другого — нужен restart.
docker restart vidconf-nginx-1 docker restart vidconf-nginx-1
# TURN over TLS (см. docs/deploy/DEPLOYMENT.md §8) — только если включён
# (TURN_TLS_HOST задан в .env). Без coturn-certs-init coturn продолжит
# держать в памяти старый сертификат ещё ~60 дней, до следующего продления,
# и TLS-хендшейк начнёт падать с ошибкой валидации сертификата у клиентов.
cd /opt/vidconf || exit 1
if grep -qE '^TURN_TLS_HOST=.+' .env; then
docker compose -f deploy/docker-compose.yml --env-file .env up -d coturn-certs-init
docker restart vidconf-coturn-1
fi
EOF EOF
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
@@ -276,8 +299,9 @@ docker compose -f deploy/docker-compose.yml --env-file .env --profile monitoring
# 1. Все сервисы healthy # 1. Все сервисы healthy
docker compose -f deploy/docker-compose.yml --env-file .env ps docker compose -f deploy/docker-compose.yml --env-file .env ps
# 2. Наружу открыто только ожидаемое (80/443/7881 + udp 54000-54100, # 2. Наружу открыто только ожидаемое (80/443/7881 + udp 54000,
# плюс 3478 tcp+udp, если включили TURN — раздел 8) # плюс 3478 tcp+udp и 49160:49200/udp, если включили TURN, плюс 5349/tcp,
# если включили TURN over TLS — раздел 8)
ss -ltnp ss -ltnp
# 3. Redis требует пароль (НЕ должен пускать без него) # 3. Redis требует пароль (НЕ должен пускать без него)
@@ -318,7 +342,7 @@ Protocols`. Проверьте **гостевой вход** (`/j/<slug>` в п
**Статус: НЕ обязателен.** Реальное кросс-сетевое тестирование (участники в **Статус: НЕ обязателен.** Реальное кросс-сетевое тестирование (участники в
разных сетях/на разных устройствах) прошло успешно **без** раздачи TURN разных сетях/на разных устройствах) прошло успешно **без** раздачи TURN
клиентам — комбинации `LIVEKIT_USE_EXTERNAL_IP=false` + реальный клиентам — комбинации `LIVEKIT_USE_EXTERNAL_IP=false` + реальный
`LIVEKIT_NODE_IP` + проброшенный UDP-диапазон `54000-54100` (шаг 1, `LIVEKIT_NODE_IP` + проброшенный UDP-порт `54000` (шаг 1,
firewall) хватает для подавляющего большинства сетей. Включайте этот firewall) хватает для подавляющего большинства сетей. Включайте этот
раздел только если у вас есть конкретные пользователи за CGNAT или раздел только если у вас есть конкретные пользователи за CGNAT или
жёстким корпоративным firewall, которые не могут установить медиа-соединение жёстким корпоративным firewall, которые не могут установить медиа-соединение
@@ -358,15 +382,107 @@ state: connected`, но собеседник не видит видео/не с
`docker logs vidconf-coturn-1 --since 10m 2>&1 | grep -ci allocate`. `docker logs vidconf-coturn-1 --since 10m 2>&1 | grep -ci allocate`.
Ноль при живом звонке из-за NAT означает, что до coturn не дошли — Ноль при живом звонке из-за NAT означает, что до coturn не дошли —
смотрите ufw и `TURN_EXTERNAL_IP`. смотрите ufw и `TURN_EXTERNAL_IP`.
⚠️ **Эта проверка работает только с `verbose` в
`deploy/coturn/turnserver.conf.template`** (включён по умолчанию). Без
этого флага coturn пишет в лог только служебные строки старта
(листенеры, `Total auth threads`) и НИКОГДА не логирует
ALLOCATE/CreatePermission/Refresh — `grep -ci allocate` даёт `0` даже
когда relay реально обслуживает звонок. Это не гипотеза: на релизе
0.0.22 именно так и обнаружили — рабочий relay-звонок (LiveKit
`connectionType: turn`, реальные relay-кандидаты в `49160-49200`) при
дефолтном `simple-log` без `verbose` дал `grep -ci allocate` = `0`,
подтверждение пришлось брать из логов LiveKit. Если ваш конфиг старее
и `verbose` в нём нет — добавьте флаг, перерендерите
(`./deploy/render-templates.sh`) и пересоздайте `coturn`, прежде чем
доверять этой проверке.
**TURN over TLS (порт 5349 или 443) — не настроен.** Это самый надёжный ### TURN over TLS (порт 5349)
фолбэк (проходит там, где режут UDP и нестандартные порты), но требует
смонтировать в coturn TLS-сертификат: раскомментировать `cert`/`pkey` в Самый надёжный фолбэк: в жёстких корпоративных сетях наружу часто разрешён
`deploy/coturn/turnserver.conf.template`, добавить volume с только `443/tcp`, и TLS-соединение на нестандартный порт (5349) выглядит для
`/etc/letsencrypt` (nginx его уже монтирует, coturn — нет), открыть порт и firewall как обычный HTTPS. UDP/TCP на 3478 такие сети режут целиком.
не забыть про перезапуск coturn при обновлении сертификата. Пока этого нет,
`turns:` намеренно не анонсируется: анонс неработающего адреса заставил бы **443 вместо 5349 невозможен без доп. усложнений**: `443/tcp` на хосте уже
клиента ждать таймаута перед переходом к рабочему кандидату. занят nginx (Docker port-publish биндит хостовый сокет), а coturn слушает в
`network_mode: host`оба не могут забрать один и тот же порт без
SNI-мультиплексора перед ними. Такой мультиплексор — отдельная, более
сложная система; в этом проекте её нет, и заводить её только ради 443 не
оправдано, пока 5349 проходит через те же firewall, что и 443.
**Включение — один флаг, `TURN_TLS_HOST` в `.env`:**
```bash
# Домен сертификата (НЕ IP — см. предупреждение ниже), тот же, что в
# NGINX_CERT_NAME:
TURN_TLS_HOST=vidconf.ru
```
Пусто (dev-дефолт) — TLS выключен полностью и без следов: `render-templates.sh`
вырезает cert/pkey из `turnserver.conf` и запись `protocol: tls` из
`rtc.turn_servers` в `livekit.yaml` (маркеры `BEGIN-TLS-*`/`END-TLS-*` в
`.template`-файлах). Непустое значение включает оба сразу — TLS без анонса
клиентам (и наоборот) не бывает, ровно один переключатель на всё.
⚠️ **`TURN_TLS_HOST` обязан быть ДОМЕНОМ, не IP** — в отличие от
`TURN_EXTERNAL_IP`, который остаётся IP-адресом для udp/tcp-записей. Браузер
проверяет TLS-сертификат TURN-сервера по имени хоста в `turns:`-URL, а
Let's Encrypt выписывает сертификат на домен. С IP в этом поле TLS-хендшейк
упадёт на проверке имени сертификата — внешне это будет выглядеть как ещё
один вариант «coturn healthy, а relay не работает», только на новом порту.
**Права на приватный ключ.** coturn (`nobody:nogroup` внутри контейнера, без
root-фазы в entrypoint — не то что у nginx, где `docker-entrypoint-certs.sh`
выполняется от root) физически не может прочитать
`/etc/letsencrypt/live/<домен>/privkey.pem` (root, обычно `0600`). Решение —
одноразовый init-контейнер `coturn-certs-init` (busybox, дефолтный root,
образец — уже существующий `recordings-init`/`llm-models-init` в этом же
compose-файле): копирует `fullchain.pem`/`privkey.pem` в свой volume
`coturn-certs` под правами `644`. Это копия, не оригинал — права на ключ на
хосте не меняются. coturn просто монтирует `coturn-certs:/etc/coturn/certs:ro`
и ждёт (`depends_on: condition: service_completed_successfully`), пока init
отработает.
**Обновление сертификата.** certbot продлевает Let's Encrypt раз в ~60 дней
и обновляет файлы в `/etc/letsencrypt`, но и nginx, и coturn держат свои
копии, которые перечитываются только при (пере)старте контейнера — reload
недостаточен ни для одного из них. Deploy-hook (см. шаг 5 выше,
`/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh`) после `nginx`
дополнительно пересоздаёт `coturn-certs-init` (перекопировать свежий
сертификат в volume) и перезапускает `coturn`. Без этого шага TLS-TURN
тихо остановится обслуживать новые TLS-хендшейки примерно через два
месяца — coturn будет держать в памяти сертификат, у которого истёк срок
действия, и клиенты начнут получать ошибку валидации сертификата при
попытке TLS-хендшейка.
⚠️ Перезапуск `coturn` (и hook, и ручной после включения TLS) может задеть
активные звонки, идущие через relay, — как и с `livekit` (см. выше),
выбирайте окно или закладывайтесь на автопродление certbot (раз в ~60 дней,
непредсказуемое время суток).
Порядок включения:
1. `TURN_TLS_HOST=<домен>` в `.env`.
2. `./deploy/render-templates.sh` (перерендерит `turnserver.conf` и
`livekit.yaml` с TLS-блоками).
3. Открыть `5349/tcp` в ufw (IPv4 и IPv6).
4. `docker compose … up -d --force-recreate coturn-certs-init coturn`
пересоздать (не просто restart: новый volume/depends_on).
5. Проверить, что TLS реально отвечает:
`openssl s_client -connect <домен>:5349 -servername <домен>` — должен
показать сертификат Let's Encrypt (`issuer=Let's Encrypt`), а не ошибку
соединения.
6. `docker compose … up -d --force-recreate livekit` — подхватить новую
запись `rtc.turn_servers`. ⚠️ Разрывает активные конференции.
7. Обновить deploy-hook certbot (см. шаг 5) и проверить
`certbot renew --dry-run`.
8. Провести звонок, принудительно загнав клиента в relay-режим (ICE
transport policy `relay` в браузере), и убедиться, что аллокации в
`docker logs vidconf-coturn-1` растут именно через TLS-соединение, а
обычный TURN на 3478 продолжает работать для остальных клиентов
(с `verbose`, см. предупреждение в п.4 выше, каждая аллокация видна
отдельной строкой `ALLOCATE processed, success` — можно отличить
TLS-сессию от обычной по времени и по тому, что порт входящего
соединения — 5349).
--- ---

View File

@@ -1,5 +1,9 @@
# Нагрузочное тестирование SFU (LiveKit): методика и ёмкость # Нагрузочное тестирование SFU (LiveKit): методика и ёмкость
> Цифры здесь — с dev-Mac (см. предупреждение ниже), для реальных прод-замеров
> и готовой таблицы «профиль нагрузки → железо» см.
> [hardware-sizing.md](hardware-sizing.md).
Оценивает, Оценивает,
сколько одновременных издателей аудио+видео и подписчиков выдерживает сколько одновременных издателей аудио+видео и подписчиков выдерживает
LiveKit SFU в текущей конфигурации compose (`deploy/livekit/livekit.yaml`), LiveKit SFU в текущей конфигурации compose (`deploy/livekit/livekit.yaml`),
@@ -179,12 +183,15 @@ dev-стека.
профилей битрейта заводить не требуется; при необходимости ограничить профилей битрейта заводить не требуется; при необходимости ограничить
верхнюю границу — `videoEncoding`/`simulcastLayers` на фронтенде верхнюю границу — `videoEncoding`/`simulcastLayers` на фронтенде
(клиентский SDK, вне скоупа devops-части). (клиентский SDK, вне скоупа devops-части).
4. **UDP-диапазон 54000-54100 (101 порт)** не был узким местом ни на одной 4. **UDP-диапазон 54000-54100 (101 порт)**, на котором проводился этот тест,
ступени (максимум 60 участников в тесте) — при планировании прод-узла с не был узким местом ни на одной ступени (максимум 60 участников). Тогда
ожидаемым бОльшим числом одновременных участников across все комнаты же с ним была цена: под каждый порт диапазона Docker держал отдельный
узла держать `port_range_end - port_range_start` заметно больше пикового процесс `docker-proxy` — 101 порт-101 процесс на медиапути, весь трафик
числа участников на узле (LiveKit резервирует пару портов на участника шёл лишним userland-хопом. С переходом на `rtc.udp_port` (один порт,
на медиа-транспорт). мультиплексирование по ICE ufrag внутри LiveKit) рекомендация «держать
диапазон шире пикового числа участников» больше не актуальна — портов
для планирования ёмкости не остаётся вовсе, LiveKit разводит участников
поверх одного сокета сам.
5. **STUN/TURN-находка (см. «Методика») —** рекомендуется отдельной задачей 5. **STUN/TURN-находка (см. «Методика») —** рекомендуется отдельной задачей
зарегистрировать `deploy/coturn/` в `rtc.turn_servers` LiveKit и на зарегистрировать `deploy/coturn/` в `rtc.turn_servers` LiveKit и на
проде, а не только для теста — иначе клиенты в вырожденном случае проде, а не только для теста — иначе клиенты в вырожденном случае

View File

@@ -61,4 +61,6 @@ VidConf использует **5 пресетов инсталлятора** (н
- **[LLM Setup](llm-setup.md)** — ручная установка/скачивание моделей - **[LLM Setup](llm-setup.md)** — ручная установка/скачивание моделей
- **[Deploy: Мониторинг](monitoring.md)** — Prometheus/Grafana, алерты - **[Deploy: Мониторинг](monitoring.md)** — Prometheus/Grafana, алерты
- **[Deploy: Масштабирование](scaling.md)** — горизонтальное масштабирование - **[Deploy: Масштабирование](scaling.md)** — горизонтальное масштабирование
- **[Deploy: Ёмкость](capacity.md)** — калькулятор нагрузки и IOPS - **[Deploy: Ёмкость](capacity.md)** — калькулятор нагрузки и IOPS (синтетический тест)
- **[Deploy: Профиль нагрузки → железо](hardware-sizing.md)** — сайзинг под саму
видео-нагрузку (участники/камеры/полоса), на реальных замерах с прода

View File

@@ -0,0 +1,243 @@
# Профиль нагрузки → рекомендуемое железо (медиа)
Отвечает на вопрос «сколько CPU, RAM и полосы нужно под ожидаемую видео-нагрузку»
для тех, кто разворачивает VidConf у себя. Разговор именно про **медиа**
(конференции, LiveKit SFU) — сайзинг под AI (транскрибация/суммаризация) описан
отдельно: [install.md](install.md), [hardware-profiles.md](hardware-profiles.md),
[ADR-004](../architecture/adr/004-ai-tier-matrix.md). Базовый пресет инсталлятора
без AI (`4 vCPU / 8 ГБ RAM / 40 ГБ диска`) рассчитан именно на медиа-нагрузку —
этот документ объясняет, какую конференцию такое железо реально держит и когда
его уже мало.
От синтетического нагрузочного теста [capacity.md](capacity.md) (SFU на dev-Mac,
формула по CPU) этот документ отличается источником цифр: здесь — **измерения на
боевом сервере с реальными людьми**, не эмуляция.
⚠️ Все числа ниже — **ориентировочные**, при указанных допущениях. Реальная
нагрузка зависит от поведения людей: сколько включат камеру, будет ли демонстрация
экрана, как долго говорят несколько человек одновременно. Используйте таблицу как
отправную точку для выбора железа, не как гарантию.
## Точка привязки к реальности
Единственные цифры ниже, которые не расчёт, а прямое измерение на боевом сервере
(`4 CPU, 8 ГБ RAM, Ubuntu 24.04`, тот же узел, где сейчас работает `vidconf.ru`):
| Дата | Версия | Участников | Камер | Подписок на видео | Исходящий трафик LiveKit | CPU LiveKit |
|---|---|---|---|---|---|---|
| 28.07.2026 | 0.0.11, **до** `adaptiveStream` | 19 | 17 | ~306 | 140169 Мбит/с устойчиво (пик 240) | 1.62 ядра из 4 |
| 31.07.2026 | 0.0.13, **после** `adaptiveStream`+`dynacast` | 30 | 27 | 783 | **70.6 Мбит/с** | 1.83 ядра из 4 |
Второе измерение и есть якорь для формулы ниже — оно снято на конфигурации,
максимально близкой к дефолтной (пагинация сетки участников, `adaptiveStream`,
`dynacast`), без ручной настройки под тест.
**Почему не пиковые 240 Мбит/с.** Пик — кратковременный всплеск, а не режим, в
котором сервер работал устойчиво; 1.62 ядра CPU намерены именно под устойчивые
140169 Мбит/с. Если посчитать коэффициент «Мбит/с на ядро» по пиковому числу,
получится оптимистичнее примерно в полтора раза, чем в реальности — расчёт по
такому коэффициенту недооценит нужное железо.
**Что показывает разница двух строк.** Во второй нагрузка выше (участников ×1.6,
подписок ×2.6), а трафик почти вдвое **ниже**, при том что CPU почти не
изменился. Это эффект `adaptiveStream`: клиент подписывается на трек, но получает
битрейт под фактический размер плитки на экране, а невидимые (не помещающиеся на
текущую страницу сетки) треки почти не занимают полосы. Всё, что дальше в этом
документе, посчитано **для конфигурации с `adaptiveStream`** (релиз ≥0.0.13, в
проекте включён с этой версии по умолчанию) — без него числа нужно умножать в
разы, см. следующий раздел.
## Почему нельзя считать «все видят всех»
SFU (LiveKit) пересылает пакеты, а не микширует их. Наивная формула трафика —
`N × (N1) × битрейт` (каждый участник получает поток от каждого) — при 80
участниках даёт единицы **гигабит в секунду**: недостижимо на одном сервере и не
имеет отношения к тому, что видит пользователь на экране.
Реальная модель другая: клиент подписан не на всех, а на **видимые плитки**, и
получает под каждую подписку битрейт по фактическому размеру плитки — благодаря
пагинации сетки (с релиза 0.0.11) и `adaptiveStream`/`dynacast` (с 0.0.13).
Отсюда рабочая формула:
```
подписок на видео = камер × (участников 1)
```
Она подтверждена обоими измерениями выше **точно**: 17 × 18 = 306, 27 × 29 = 783.
Это формула для типичного «видят всех камер» размещения (сетка без ручного
скрытия участников) — при включённом лимите плиток (см. ниже) число подписок не
растёт дальше лимита, даже если камер больше.
**Насколько наивная формула хуже.** Гипотетически, если бы все 75 участников
большого собрания (профиль ниже) были источником видео и каждый получал полный
поток от каждого — `75 × 74 × 1.5 Мбит/с` (типичный битрейт публикации без
адаптации) — это **9.48 Гбит/с**: недостижимо ни на одном разумном сервере.
Модель выше на сопоставимом масштабе (профиль «Большое собрание», 15 камер из
75) даёт около 182 Мбит/с**в 50 с лишним раз меньше**. Разница — не оптимизация
в мелочах, а другая по порядку величины задача, и именно поэтому SFU вообще
годится для конференций на десятки участников.
Аудио в этой формуле — единицы процентов трафика: микрофоны обычно включены у
25 человек одновременно, замьюченный трек полосу не занимает, DTX/RED включены
по умолчанию. Дальше считаем аудио отдельным слагаемым, не путая с видео.
## Формула
```
1. подписок = камер × (участников 1)
(если включён лимит плиток в настройках инстанса — camер заменить на min(камер, лимит))
2. видео = подписок × битрейт_на_подписку
битрейт_на_подписку зависит от того, помещаются ли все плитки на одну страницу:
- крупная плитка / говорящий в фокусе (мало плиток на экране) → 450 кбит/с
- мелкая плитка сетки, все участники на одной странице → 150 кбит/с
- сетка с пагинацией (участников больше лимита плиток) → 100 кбит/с
(часть подписок физически не на экране — почти не потребляет полосы)
3. аудио = активных_микрофонов × участников × 40 кбит/с
4. исходящая полоса сервера = видео + аудио ← главный параметр, см. ниже
5. ядер CPU (LiveKit) ≈ max(2, ⌈исходящая_полоса_Мбит/с ÷ 90⌉)
+ 12 ядра на остальной стек (backend, БД, Redis, coturn, nginx, ОС)
6. RAM ≈ 4 ГБ база (без AI-профилей) — на этом масштабе RAM не была узким
местом ни на одном реальном или синтетическом тесте, планировать по CPU и полосе
```
**Откуда коэффициенты.**
- 450 / 150 кбит/с — измерение одного трека клиентом в крупной и мелкой плитке
(релиз 0.0.13), округлено вверх от 453 и 147 для запаса.
- 100 кбит/с — обратный расчёт по якорному замеру 31.07.2026:
70.6 Мбит/с ÷ 783 подписки ≈ 90 кбит/с в среднем, округлено вверх. Число ниже,
чем «мелкая плитка» (147), потому что в комнате на 30 участников часть из 783
подписок физически не помещалась на текущую страницу сетки — `adaptiveStream`
почти обнулил их битрейт, а среднее по всем подпискам это отражает.
- 90 Мбит/с на ядро — из 28.07.2026: 140169 Мбит/с устойчиво ÷ 1.62 ядра =
86104 Мбит/с/ядро, округлено вниз (консервативно, в пользу большего числа
ядер).
- Минимум 2 ядра и запас 12 ядра на остальной стек — эмпирический пол: на
обоих реальных замерах LiveKit не опускался ниже 1.6 ядра независимо от
трафика, а весь остальной стек (backend, Postgres, Redis, coturn, nginx) на
боевом сервере устойчиво укладывается в разницу между занятым LiveKit и 4
доступными ядрами.
⚠️ **Формула по полосе — не единственная граница.** Между двумя замерами
подписок стало в 2.6 раза больше, трафик упал вдвое, а CPU почти не изменился
(1.62 → 1.83 ядра) — то есть процессор тратится в первую очередь на
обработку пакетов/подписок, а не на байты. На сценариях с очень большим числом
мелких подписок (много участников, лимит плиток не выставлен) реальный CPU
может обогнать то, что предсказывает формула по полосе быстрее, чем ожидается
— держите эмпирический пол (2 ядра LiveKit минимум под любую активную
конференцию) и не полагайтесь только на деление на 90.
## Профили нагрузки
Все профили — при `adaptiveStream`+`dynacast` (по умолчанию с 0.0.13) и без
AI-профилей (`transcribe`/`llm`). Допущения по камерам/микрофонам — решение,
не измерение; подставьте свои, если знаете реальный сценарий.
| Профиль | Сценарий | Камер | Подписок | Полоса (видео+аудио) | CPU (LiveKit) | RAM | Узкое место |
|---|---|---|---|---|---|---|---|
| Малая команда | 10 параллельных созвонов по 5 чел., 60% с камерой, 2 микрофона в каждом | 3×10 | 12×10=120 | ~58 Мбит/с | 2 ядра | 4 ГБ | нет — запас большой |
| Совещание | 1 конференция × 25 чел., 70% с камерой, 4 микрофона | 18 | 432 | ~69 Мбит/с | 2 ядра | 4 ГБ | полоса — близко к нашему якорю (70.6 Мбит/с на 30 чел.) |
| Большое собрание | 1 конференция × 75 чел., 20% с камерой (камер меньше лимита плиток), 5 микрофонов | 15 | 1110 | ~182 Мбит/с | 3 ядра | 46 ГБ | полоса и её цена у хостера |
| Смешанная нагрузка | «Малая команда» + «Совещание» одновременно на одном сервере | — | — | ~127 Мбит/с | 2 ядра | 4 ГБ | суммируется линейно |
Малая команда и совещание укладываются в базовый пресет инсталлятора без AI
(`4 vCPU / 8 ГБ`) с большим запасом — это ровно тот масштаб, что подтверждён
якорным замером (30 чел./27 камер на этом же железе, CPU занят на 46%). Большое
собрание уже требует железа **больше** базового пресета — 3 ядра под сам
LiveKit плюс 12 под остальной стек означают, что 4 vCPU становятся тесными.
### Когда профиль недостижим — и как его спасти
«Большое собрание, все 75 человек с камерой» без ограничений: подписок
75 × 74 = 5550, полоса по коэффициенту пагинации (100 кбит/с) — уже **~555
Мбит/с** только видео, плюс аудио. Такой канал недостижим на типичном железе
и его аренде — это не вопрос выбора сервера мощнее, это упирается в канал
и его стоимость у хостера.
Спасает **лимит плиток на экране** (настройка инстанса «Максимум плиток на
странице», 25/16/9/4, с релиза 0.0.21): он ограничивает число подписок сверху
независимо от числа камер — `min(камер, лимит) × (участников 1)`. При лимите
16 и том же собрании: 16 × 74 = 1184 подписки × 150 кбит/с (все 16 видны
одновременно, без пагинации) ≈ **178 Мбит/с** только видео — втрое меньше, чем
без лимита, но всё ещё требует железа заметно больше базового пресета (3+ ядра
LiveKit по формуле). Лимит делает профиль реалистичным, не дешёвым. Тот же
эффект даёт «Потолок качества публикации» (720p/360p/180p, тот же релиз) —
режет битрейт публикации у источника, а не только у подписчика.
Если и это не помогает — речь уже не про один сервер, а про горизонтальное
масштабирование LiveKit-кластера, вне рамок этого документа.
## Входящая полоса и канал клиента — отдельная история
Таблица выше — **исходящая** полоса сервера (каждому подписчику отдельная
копия), она и есть главный параметр: растёт с числом участников и подписок.
**Входящая** полоса (от клиентов к серверу) на порядок меньше: она равна сумме
битрейтов публикуемых потоков — `камер × ~0.31.5 Мбит/с` (зависит от «Потолка
качества публикации», 0.0.21) — и **не** умножается на число зрителей. Для
собрания на 75 человек с 15 камерами это 4.522.5 Мбит/с входящих — заметно
меньше 182 Мбит/с исходящих, узким местом почти никогда не становится.
Отдельно — канал **самого клиента**, не сервера. Офис, откуда заходит половина
участников совещания, может упереться в свой исходящий/входящий канал раньше,
чем сервер упрётся в свой. Серверный сайзинг эту часть не решает — это забота
сетевой инфраструктуры на стороне участников.
## Оговорка про канал и его стоимость
На реалистичных профилях (кроме экстремальных, см. выше) узким местом
оказывается почти всегда не CPU — оба реальных замера показали комфортный
запас (1.61.83 ядра из 4) — а **исходящая полоса и её стоимость у хостера**.
Большинство тарифов VPS считают трафик либо лимитом с доплатой за перебор,
либо по 95-му перцентилю канала; при планировании крупных конференций
сверяйтесь с тарифом хостера на трафик/канал, а не только с числом ядер и
объёмом RAM.
## Как посчитать под свой сценарий
1. Оцените участников (N), долю с камерой, число одновременно активных
микрофонов.
2. `подписок = камер × (N 1)`; если планируете включить лимит плиток —
`min(камер, лимит) × (N 1)`.
3. Выберите битрейт на подписку по разделу «Формула» (450 / 150 / 100 кбит/с)
в зависимости от того, помещаются ли все камеры на одну страницу.
4. `видео = подписок × битрейт`, `аудио = микрофонов × N × 40 кбит/с`,
`полоса = видео + аудио`.
5. `ядер CPU ≈ max(2, ⌈полоса ÷ 90⌉) + 12` на остальной стек.
6. RAM — 4 ГБ база, не растёт заметно с этим масштабом участников (растёт с
выбранным уровнем AI, см. [ADR-004](../architecture/adr/004-ai-tier-matrix.md),
если он используется).
7. Сверьте полосу с тарифом хостера на трафик/канал — часто это упрётся раньше
железа.
## Что может измениться
⚠️ Коэффициент «90 Мбит/с на ядро» и оба якорных замера сняты **до** перевода
LiveKit с диапазона UDP-портов на `rtc.udp_port` (задача «Сеть LiveKit», релиз
0.0.20, задеплоено 02.08.2026) — до этой правки медиатрафик на хосте шёл через
процессы `docker-proxy` (userland-прокси Docker). Сама оптимизация меняет путь
пакетов на хосте, а не логику LiveKit, поэтому полоса из таблиц, скорее всего,
не изменится, а запас по CPU/сети хоста на практике может оказаться больше
указанного здесь. Новый нагрузочный тест с реальными участниками после этой
оптимизации пока не проводился — числа в этом документе консервативны и не
переоценивают требуемое железо, но при появлении нового замера на текущей
сети коэффициенты стоит пересчитать.
## Смотрите также
- [capacity.md](capacity.md) — синтетический нагрузочный тест SFU (`lk load-test`
на dev-Mac) и формула по CPU для верхней оценки ёмкости узла; используйте вместе
с этим документом, если нужна методика для собственного повторного теста.
- [install.md](install.md), [hardware-profiles.md](hardware-profiles.md),
[ADR-004](../architecture/adr/004-ai-tier-matrix.md) — сайзинг под AI
(транскрибация/суммаризация), отдельно от медиа.
- [monitoring.md](monitoring.md) — как снять реальные цифры со своего сервера
(CPU/RAM/сеть по контейнерам, Grafana).
- [DEPLOYMENT.md](DEPLOYMENT.md) — TURN, порты, что открыть в файрволе под
медиа-трафик.

View File

@@ -20,6 +20,7 @@
"@fullcalendar/timegrid": "^6.1.21", "@fullcalendar/timegrid": "^6.1.21",
"@livekit/components-react": "^2.9.23", "@livekit/components-react": "^2.9.23",
"@livekit/components-styles": "^1.2.0", "@livekit/components-styles": "^1.2.0",
"@livekit/track-processors": "^0.7.2",
"@tailwindcss/vite": "^4.3.2", "@tailwindcss/vite": "^4.3.2",
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.101.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@@ -1220,6 +1221,25 @@
"@bufbuild/protobuf": "^1.10.0" "@bufbuild/protobuf": "^1.10.0"
} }
}, },
"node_modules/@livekit/track-processors": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/@livekit/track-processors/-/track-processors-0.7.2.tgz",
"integrity": "sha512-lzARBKTbBwqycdR/SwTu6//N0l20BzfDd7grxCXl07676SwRApNtZAK1GJjL1m3dCM3KBqH1aVxjMpNcbOw5uQ==",
"license": "Apache-2.0",
"dependencies": {
"@mediapipe/tasks-vision": "0.10.14"
},
"peerDependencies": {
"@types/dom-mediacapture-transform": "^0.1.9",
"livekit-client": "^1.12.0 || ^2.1.0"
}
},
"node_modules/@mediapipe/tasks-vision": {
"version": "0.10.14",
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
"license": "Apache-2.0"
},
"node_modules/@modelcontextprotocol/sdk": { "node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0", "version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -1921,6 +1941,23 @@
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
"node_modules/@types/dom-mediacapture-transform": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-transform/-/dom-mediacapture-transform-0.1.12.tgz",
"integrity": "sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/dom-webcodecs": "*"
}
},
"node_modules/@types/dom-webcodecs": {
"version": "0.1.18",
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.18.tgz",
"integrity": "sha512-vAvE8C9DGWR+tkb19xyjk1TSUlJ7RUzzp4a9Anu7mwBT+fpyePWK1UxmH14tMO5zHmrnrRIMg5NutnnDztLxgg==",
"license": "MIT",
"peer": true
},
"node_modules/@types/esrecurse": { "node_modules/@types/esrecurse": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",

View File

@@ -22,6 +22,7 @@
"@fullcalendar/timegrid": "^6.1.21", "@fullcalendar/timegrid": "^6.1.21",
"@livekit/components-react": "^2.9.23", "@livekit/components-react": "^2.9.23",
"@livekit/components-styles": "^1.2.0", "@livekit/components-styles": "^1.2.0",
"@livekit/track-processors": "^0.7.2",
"@tailwindcss/vite": "^4.3.2", "@tailwindcss/vite": "^4.3.2",
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.101.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,16 @@
selfie_segmenter.tflite
=======================
Модель сегментации силуэта человека MediaPipe Selfie Segmenter (float16),
используется заменой фона видео (см. frontend/src/lib/virtualBackground.ts).
Источник: https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter/float16/latest/selfie_segmenter.tflite
Документация: https://ai.google.dev/edge/mediapipe/solutions/vision/image_segmenter
Правообладатель: Google LLC
Лицензия: Apache License 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
Файл лежит в репозитории намеренно: npm-пакетом модель не поставляется, а
VidConf — самоуправляемый продукт и обязан работать в контуре без доступа к
внешним CDN. Wasm-рантайм MediaPipe (тоже Apache 2.0) в репозиторий не
коммитится — он копируется в public/mediapipe/wasm/ из node_modules при
сборке, см. плагин `mediapipeWasm` в frontend/vite.config.ts.

Binary file not shown.

View File

@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'
import { LoginPage } from '@/pages/LoginPage' import { LoginPage } from '@/pages/LoginPage'
import { RegisterPage } from '@/pages/RegisterPage' import { RegisterPage } from '@/pages/RegisterPage'
import { VerifyEmailPage } from '@/pages/VerifyEmailPage' import { VerifyEmailPage } from '@/pages/VerifyEmailPage'
import { ConsentPolicyPage } from '@/pages/ConsentPolicyPage'
import { LobbyPage } from '@/pages/LobbyPage' import { LobbyPage } from '@/pages/LobbyPage'
import { JoinPage } from '@/pages/JoinPage' import { JoinPage } from '@/pages/JoinPage'
import { RoomPage } from '@/pages/RoomPage' import { RoomPage } from '@/pages/RoomPage'
@@ -19,6 +20,10 @@ function App() {
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} /> <Route path="/register" element={<RegisterPage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} /> <Route path="/verify-email" element={<VerifyEmailPage />} />
{/* Публичная страница регламента обработки ПДн — читается до регистрации,
когда пользователя ещё нет; ссылка на неё — рядом с галочкой согласия
на RegisterPage. */}
<Route path="/legal/personal-data-consent" element={<ConsentPolicyPage />} />
<Route <Route
path="/lobby" path="/lobby"
element={ element={

View File

@@ -4,7 +4,7 @@
* конверте пагинации `items`/`total`. * конверте пагинации `items`/`total`.
*/ */
import { apiRequest } from '@/api/client' import { apiRequest } from '@/api/client'
import type { ConferenceRecurrence, ConferenceStatus, SummaryRecipientsMode } from '@/api/conferences' import type { ConferenceRecurrence, ConferenceStatus, PublishQualityCap, SummaryRecipientsMode } from '@/api/conferences'
/** Уровень качества AI-обработки (транскрибация + суммаризация). */ /** Уровень качества AI-обработки (транскрибация + суммаризация). */
export type AiLevel = 'min' | 'medium' | 'max' export type AiLevel = 'min' | 'medium' | 'max'
@@ -19,6 +19,8 @@ export interface AiLevelStatus {
/** Эффективные настройки инстанса. */ /** Эффективные настройки инстанса. */
export interface SettingsOut { export interface SettingsOut {
chat_enabled: boolean chat_enabled: boolean
/** Включён ли модуль «поднятие руки» — кнопка «Рука» и очередь целиком. */
hand_queue_enabled: boolean
/** Единый переключатель модуля AI (транскрибация + суммаризация). */ /** Единый переключатель модуля AI (транскрибация + суммаризация). */
transcription_enabled: boolean transcription_enabled: boolean
/** Есть ли хотя бы один Celery-воркер, обслуживающий очередь транскрибации. */ /** Есть ли хотя бы один Celery-воркер, обслуживающий очередь транскрибации. */
@@ -39,11 +41,28 @@ export interface SettingsOut {
contact_email_enabled: boolean contact_email_enabled: boolean
/** Контактный адрес — `null`, если не задан/выключен. */ /** Контактный адрес — `null`, если не задан/выключен. */
contact_email: string | null contact_email: string | null
/** Потолок качества исходящего видео публикующего — см. `PublishQualityCap`. */
publish_quality_cap: PublishQualityCap
/** Максимум одновременно видимых плиток сцены (`StageGrid`). */
stage_max_tiles: number
/** Обязательна ли галочка согласия на обработку персональных данных при регистрации. */
consent_required: boolean
/** Текст регламента (редактируемый шаблон, дефолт — типовой образец без юридической силы). */
consent_policy_text: string
/** Номер редакции текста — растёт при каждом изменении `consent_policy_text`. */
consent_policy_version: number
/** Проверка устройств на входе (сессия 33) — запрос доступа к камере/микрофону
* и превью камеры на странице логина и в карточке «Как вас зовут?». */
device_check_enabled: boolean
/** Замена фона видео на картинку (сессия 35) — кнопка «Фон» в комнате,
* выбор фона в превью на входе и раздел «Свои фоны» в профиле. */
virtual_background_enabled: boolean
} }
/** Тело частичного обновления настроек инстанса — все поля опциональны. */ /** Тело частичного обновления настроек инстанса — все поля опциональны. */
export interface SettingsUpdateIn { export interface SettingsUpdateIn {
chat_enabled?: boolean chat_enabled?: boolean
hand_queue_enabled?: boolean
transcription_enabled?: boolean transcription_enabled?: boolean
/** Недоступный уровень (см. `ai_levels`) — backend отвечает 400. */ /** Недоступный уровень (см. `ai_levels`) — backend отвечает 400. */
ai_level?: AiLevel ai_level?: AiLevel
@@ -56,6 +75,13 @@ export interface SettingsUpdateIn {
/** Включение без email или невалидный email — backend отвечает 400. */ /** Включение без email или невалидный email — backend отвечает 400. */
contact_email_enabled?: boolean contact_email_enabled?: boolean
contact_email?: string | null contact_email?: string | null
publish_quality_cap?: PublishQualityCap
stage_max_tiles?: number
/** Включение с пустым текстом регламента — backend отвечает 400. */
consent_required?: boolean
consent_policy_text?: string
device_check_enabled?: boolean
virtual_background_enabled?: boolean
} }
/** Тело запроса тестовой отправки письма (`POST /admin/settings/test-email`). */ /** Тело запроса тестовой отправки письма (`POST /admin/settings/test-email`). */

View File

@@ -9,6 +9,8 @@ export interface RegisterPayload {
password: string password: string
/** Выбранная команда — только если выбор команды включён в настройках инстанса. */ /** Выбранная команда — только если выбор команды включён в настройках инстанса. */
team_id?: string | null team_id?: string | null
/** Согласие на обработку персональных данных — обязано быть `true`, если `consent_required`. */
consent_accepted?: boolean
} }
/** Команда, доступная для выбора на экране регистрации. */ /** Команда, доступная для выбора на экране регистрации. */
@@ -23,6 +25,12 @@ export interface RegistrationOptions {
teams: RegistrationTeamOption[] teams: RegistrationTeamOption[]
/** Эталонные домены почты при включённой верификации (email подходит под любой), иначе пуст. */ /** Эталонные домены почты при включённой верификации (email подходит под любой), иначе пуст. */
email_domains: string[] email_domains: string[]
/** Обязательна ли галочка согласия на обработку персональных данных на форме регистрации. */
consent_required: boolean
/** Текст регламента — отдаётся всегда, независимо от `consent_required` (нужен и странице регламента). */
consent_text: string
/** Номер редакции текста, с которой согласится пользователь при регистрации. */
consent_version: number
} }
export interface CurrentUser { export interface CurrentUser {

View File

@@ -4,8 +4,15 @@
* - Access-токен подставляется из authStore (память, не localStorage). * - Access-токен подставляется из authStore (память, не localStorage).
* - На 401 выполняется один silent-refresh (POST /auth/refresh, * - На 401 выполняется один silent-refresh (POST /auth/refresh,
* credentials: 'include' — сессия читается из httpOnly-cookie) и повтор * credentials: 'include' — сессия читается из httpOnly-cookie) и повтор
* исходного запроса. Если refresh не удался — access-токен сбрасывается и * исходного запроса.
* выполняется редирект на /login. * - ⚠️ Причина неудачи refresh различается (`RefreshOutcome`). Сессия
* сбрасывается ТОЛЬКО когда backend сказал, что она недействительна
* (`invalid`). Ответ 5xx или обрыв сети — это «серверу плохо», а не «вы не
* авторизованы»: токен сохраняется, пользователь остаётся в системе и
* получает обычную ошибку запроса. Раньше различия не было, и на
* нагрузочном тесте 07.08.2026 (когда refresh отвечал 500 из-за
* исчерпанного пула БД) фронтенд разлогинивал людей посреди работы, а
* повторный вход падал тем же 500.
* - Параллельные 401 схлопываются в один refresh-запрос (refreshPromise). * - Параллельные 401 схлопываются в один refresh-запрос (refreshPromise).
*/ */
import { authStore } from '@/auth/authStore' import { authStore } from '@/auth/authStore'
@@ -44,26 +51,48 @@ interface RequestOptions extends Omit<RequestInit, 'body'> {
skipAuthRefresh?: boolean skipAuthRefresh?: boolean
} }
let refreshPromise: Promise<boolean> | null = null /**
* Итог silent-refresh.
*
* - `ok` — выдан новый access-токен;
* - `invalid` — backend отверг refresh-сессию (просрочена, отозвана, reuse):
* единственный случай, когда пользователя правда надо разлогинить;
* - `unavailable` — до ответа «сессия недействительна» дело не дошло: 5xx,
* таймаут или обрыв сети. Сессия при этом цела, `status` — HTTP-код
* ответа или `null`, если запрос не доехал вовсе.
*/
export type RefreshOutcome =
| { result: 'ok' }
| { result: 'invalid' }
| { result: 'unavailable'; status: number | null }
let refreshPromise: Promise<RefreshOutcome> | null = null
/** /**
* Выполняет silent-refresh access-токена через httpOnly refresh-cookie. * Выполняет silent-refresh access-токена через httpOnly refresh-cookie.
* Возвращает true при успехе. Параллельные вызовы переиспользуют один запрос. * Параллельные вызовы переиспользуют один запрос.
*/ */
export async function refreshAccessToken(): Promise<boolean> { export async function refreshAccessToken(): Promise<RefreshOutcome> {
if (!refreshPromise) { if (!refreshPromise) {
refreshPromise = (async () => { refreshPromise = (async (): Promise<RefreshOutcome> => {
try { try {
const response = await fetch(`${API_BASE}/auth/refresh`, { const response = await fetch(`${API_BASE}/auth/refresh`, {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
}) })
if (!response.ok) return false if (response.ok) {
const data = (await response.json()) as { access_token: string } const data = (await response.json()) as { access_token: string }
authStore.setAccessToken(data.access_token) authStore.setAccessToken(data.access_token)
return true return { result: 'ok' }
}
// Про недействительность сессии backend говорит только кодом 4xx.
// Всё остальное (500/502/503/504) — состояние сервера, а не сессии.
return response.status >= 500
? { result: 'unavailable', status: response.status }
: { result: 'invalid' }
} catch { } catch {
return false // Сеть не доехала — про сессию мы так ничего и не узнали.
return { result: 'unavailable', status: null }
} finally { } finally {
refreshPromise = null refreshPromise = null
} }
@@ -124,9 +153,17 @@ export async function apiRequest<T = unknown>(path: string, options: RequestOpti
let response = await doFetch() let response = await doFetch()
if (response.status === 401 && !skipAuthRefresh) { if (response.status === 401 && !skipAuthRefresh) {
const refreshed = await refreshAccessToken() const outcome = await refreshAccessToken()
if (refreshed) { if (outcome.result === 'ok') {
response = await doFetch() response = await doFetch()
} else if (outcome.result === 'unavailable') {
// Серверу плохо — сессию не трогаем и на /login не выкидываем:
// как только backend оживёт, следующий запрос обновит токен сам.
throw new ApiError(
outcome.status ?? 0,
null,
'Сервер временно недоступен. Попробуйте ещё раз через минуту.',
)
} else { } else {
redirectToLogin() redirectToLogin()
throw new ApiError(401, null, 'Сессия истекла') throw new ApiError(401, null, 'Сессия истекла')

View File

@@ -19,6 +19,15 @@ export type RecurrenceType = 'weekly' | 'biweekly' | 'monthly' | 'every_n_days'
*/ */
export type SummaryRecipientsMode = 'all' | 'owner' export type SummaryRecipientsMode = 'all' | 'owner'
/**
* Потолок качества исходящего видео участника (`instance_settings.media_limits`,
* см. `SettingsOut`/`SettingsUpdateIn` в `src/api/admin.ts`). `off` — без
* ограничения. Применяется на клиенте через `publishDefaults`
* (`lib/publishQualityCap.ts`) — режет битрейт верхнего слоя симулкаста, а не
* жёсткое разрешение захвата камеры.
*/
export type PublishQualityCap = 'off' | '180p' | '360p' | '720p'
/** /**
* Правило повторения закреплённой конференции — форма 1:1 с pydantic-моделью * Правило повторения закреплённой конференции — форма 1:1 с pydantic-моделью
* `backend/services/recurrence.py::RecurrenceRule` (истина о форме — там). * `backend/services/recurrence.py::RecurrenceRule` (истина о форме — там).
@@ -52,6 +61,14 @@ export interface ConferenceJoinData {
conference_id: string conference_id: string
/** Включён ли чат для этой конференции — при `false` панель/кнопка чата не рендерятся. */ /** Включён ли чат для этой конференции — при `false` панель/кнопка чата не рендерятся. */
chat_enabled: boolean chat_enabled: boolean
/** Включён ли модуль «поднятие руки» — при `false` кнопка «Рука» и очередь не рендерятся. */
hand_queue_enabled: boolean
/** Потолок качества публикации видео на момент входа — см. `PublishQualityCap`. */
publish_quality_cap: PublishQualityCap
/** Максимум одновременно видимых плиток сцены (`StageGrid`) на момент входа. */
stage_max_tiles: number
/** Включён ли модуль «замена фона» — при `false` кнопка «Фон» в тулбаре не рендерится. */
virtual_background_enabled: boolean
} }
/** /**
@@ -145,6 +162,14 @@ export interface ConferenceGuestJoinPayload {
password?: string password?: string
} }
/** Источник трека, который организатор может принудительно выключить (задача B2). */
export type MuteSource = 'microphone' | 'camera'
/** Ответ на принудительный мьют — `false`, если трек и так не был опубликован (нечего было мьютить). */
export interface MuteParticipantResult {
muted: boolean
}
/** Тело частичного обновления конференции — те же поля, что и при создании, все опциональны. */ /** Тело частичного обновления конференции — те же поля, что и при создании, все опциональны. */
export type ConferenceUpdatePayload = Partial<ConferenceCreatePayload> export type ConferenceUpdatePayload = Partial<ConferenceCreatePayload>
@@ -202,6 +227,23 @@ export async function guestJoinConference(
}) })
} }
/**
* Принудительно выключить микрофон/камеру участника (задача B2) — только
* владелец конференции/администратор, иначе 403 (`not_owner`). 404
* (`participant_not_in_room`) — участника с таким `identity` сейчас нет в
* комнате LiveKit.
*/
export async function muteParticipant(
conferenceId: string,
identity: string,
source: MuteSource,
): Promise<MuteParticipantResult> {
return apiRequest<MuteParticipantResult>(`/conferences/${conferenceId}/mute-participant`, {
method: 'POST',
body: { identity, source },
})
}
/** Список «моих» конференций — закреплённые (повторяющиеся) и предстоящие разовые владельца. */ /** Список «моих» конференций — закреплённые (повторяющиеся) и предстоящие разовые владельца. */
export async function getMyConferences(): Promise<ConferenceOut[]> { export async function getMyConferences(): Promise<ConferenceOut[]> {
return apiRequest<ConferenceOut[]>('/conferences/my') return apiRequest<ConferenceOut[]>('/conferences/my')

View File

@@ -0,0 +1,16 @@
/** Публичные настройки инстанса — доступны без аутентификации (`GET /public/settings`). */
import { apiRequest } from '@/api/client'
/** Флаги, нужные странице логина и гостевой карточке входа до аутентификации. */
export interface PublicSettingsOut {
/** Проверка устройств на входе (сессия 33) — запрос доступа к камере/микрофону
* и превью камеры на LoginPage/JoinPage. */
device_check_enabled: boolean
/** Замена фона видео (сессия 35) — нужен превью на JoinPage; участнику в
* комнате тот же флаг приезжает в join-ответе (`JoinOut`). */
virtual_background_enabled: boolean
}
export async function getPublicSettings(): Promise<PublicSettingsOut> {
return apiRequest<PublicSettingsOut>('/public/settings', { skipAuthRefresh: true })
}

View File

@@ -62,6 +62,42 @@ export async function deleteMyAvatar(): Promise<void> {
await apiRequest('/users/me/avatar', { method: 'DELETE' }) await apiRequest('/users/me/avatar', { method: 'DELETE' })
} }
/** Своя картинка пользователя для замены фона видео. */
export interface MyBackground {
id: string
/** Путь на своём домене (`/media/backgrounds/...`), раздаёт nginx. */
url: string
}
/** Ответ списка своих картинок фона — вместе с серверным лимитом на их число. */
export interface MyBackgroundsResponse {
items: MyBackground[]
limit: number
}
/** Свои картинки фона текущего пользователя. Гостю недоступно (401) — у него нет профиля. */
export async function listMyBackgrounds(): Promise<MyBackgroundsResponse> {
return apiRequest<MyBackgroundsResponse>('/users/me/backgrounds')
}
/**
* Загрузить свою картинку фона; возвращается весь список заново.
*
* Картинка ужимается ДО отправки (`lib/imageResize.ts`) — сервер её не
* пережимает (Pillow на backend нет). Коды ошибок: 409 — упёрлись в лимит,
* 413 — файл больше 2 МБ, 415 — недопустимый тип.
*/
export async function uploadMyBackground(file: File | Blob): Promise<MyBackgroundsResponse> {
const form = new FormData()
form.append('file', file, 'background.webp')
return apiRequest<MyBackgroundsResponse>('/users/me/backgrounds', { method: 'POST', body: form })
}
/** Удалить свою картинку фона — 204 без тела; файл на диске удаляется вместе с записью. */
export async function deleteMyBackground(id: string): Promise<void> {
await apiRequest(`/users/me/backgrounds/${id}`, { method: 'DELETE' })
}
/** Тело смены пароля текущего пользователя. */ /** Тело смены пароля текущего пользователя. */
export interface PasswordChangePayload { export interface PasswordChangePayload {
current_password: string current_password: string

View File

@@ -4,6 +4,20 @@ import { authStore } from '@/auth/authStore'
import { refreshAccessToken } from '@/api/client' import { refreshAccessToken } from '@/api/client'
import { AuthContext, type AuthContextValue, type AuthStatus } from '@/auth/authContext' import { AuthContext, type AuthContextValue, type AuthStatus } from '@/auth/authContext'
/**
* Задержки повторов восстановления сессии, если backend отвечает 5xx.
*
* Недоступность сервера — не повод объявлять пользователя неавторизованным:
* refresh-cookie цела, и через несколько секунд сессия обычно поднимается
* сама. Повторов ровно три (суммарно ~7 с) — дальше показываем страницу
* входа, потому что бесконечный спиннер хуже честного «войдите заново»:
* cookie при этом не стирается, и повторная попытка входа сработает, как
* только backend оживёт.
*/
const BOOTSTRAP_RETRY_DELAYS_MS = [1000, 2000, 4000]
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
/** /**
* Провайдер сессии пользователя. * Провайдер сессии пользователя.
* При монтировании приложения пытается восстановить сессию через * При монтировании приложения пытается восстановить сессию через
@@ -18,9 +32,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
let cancelled = false let cancelled = false
async function bootstrap() { async function bootstrap() {
const restored = await refreshAccessToken() let outcome = await refreshAccessToken()
for (const delay of BOOTSTRAP_RETRY_DELAYS_MS) {
if (cancelled || outcome.result !== 'unavailable') break
await sleep(delay)
if (cancelled) return if (cancelled) return
if (!restored) { outcome = await refreshAccessToken()
}
if (cancelled) return
if (outcome.result !== 'ok') {
setStatus('unauthenticated') setStatus('unauthenticated')
return return
} }

View File

@@ -10,7 +10,7 @@ import {
type SettingsUpdateIn, type SettingsUpdateIn,
type TestEmailOut, type TestEmailOut,
} from '@/api/admin' } from '@/api/admin'
import type { SummaryRecipientsMode } from '@/api/conferences' import type { PublishQualityCap, SummaryRecipientsMode } from '@/api/conferences'
import { ApiError, errorDetail } from '@/api/client' import { ApiError, errorDetail } from '@/api/client'
import { useAuth } from '@/auth/useAuth' import { useAuth } from '@/auth/useAuth'
import { Select } from '@/components/ui/Select' import { Select } from '@/components/ui/Select'
@@ -21,6 +21,20 @@ const SUMMARY_RECIPIENTS_OPTIONS = [
{ value: 'owner', label: 'Только организатору' }, { value: 'owner', label: 'Только организатору' },
] ]
const PUBLISH_QUALITY_CAP_OPTIONS = [
{ value: 'off', label: 'Без ограничения' },
{ value: '720p', label: 'Не выше 720p' },
{ value: '360p', label: 'Не выше 360p' },
{ value: '180p', label: 'Не выше 180p' },
]
const STAGE_MAX_TILES_OPTIONS = [
{ value: '25', label: '25 (5×5, без ограничения)' },
{ value: '16', label: '16 (4×4)' },
{ value: '9', label: '9 (3×3)' },
{ value: '4', label: '4 (2×2)' },
]
const AI_LEVEL_LABEL: Record<AiLevel, string> = { const AI_LEVEL_LABEL: Record<AiLevel, string> = {
min: 'Минимальный (CPU, faster-whisper small + Qwen2.5-3B)', min: 'Минимальный (CPU, faster-whisper small + Qwen2.5-3B)',
medium: 'Средний', medium: 'Средний',
@@ -54,6 +68,11 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
const { user } = useAuth() const { user } = useAuth()
const [chatEnabled, setChatEnabled] = useState(data.chat_enabled) const [chatEnabled, setChatEnabled] = useState(data.chat_enabled)
const [handQueueEnabled, setHandQueueEnabled] = useState(data.hand_queue_enabled)
const [deviceCheckEnabled, setDeviceCheckEnabled] = useState(data.device_check_enabled)
const [virtualBackgroundEnabled, setVirtualBackgroundEnabled] = useState(
data.virtual_background_enabled,
)
const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled) const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled)
const [aiLevel, setAiLevel] = useState<AiLevel>(data.ai_level) const [aiLevel, setAiLevel] = useState<AiLevel>(data.ai_level)
const [recipients, setRecipients] = useState<SummaryRecipientsMode>(data.summary_recipients) const [recipients, setRecipients] = useState<SummaryRecipientsMode>(data.summary_recipients)
@@ -64,6 +83,10 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
const [newDomainInput, setNewDomainInput] = useState('') const [newDomainInput, setNewDomainInput] = useState('')
const [contactEmailEnabled, setContactEmailEnabled] = useState(data.contact_email_enabled) const [contactEmailEnabled, setContactEmailEnabled] = useState(data.contact_email_enabled)
const [contactEmail, setContactEmail] = useState(data.contact_email ?? '') const [contactEmail, setContactEmail] = useState(data.contact_email ?? '')
const [publishQualityCap, setPublishQualityCap] = useState<PublishQualityCap>(data.publish_quality_cap)
const [stageMaxTiles, setStageMaxTiles] = useState(data.stage_max_tiles)
const [consentRequired, setConsentRequired] = useState(data.consent_required)
const [consentPolicyText, setConsentPolicyText] = useState(data.consent_policy_text)
const [testEmailTo, setTestEmailTo] = useState('') const [testEmailTo, setTestEmailTo] = useState('')
const [testEmailResult, setTestEmailResult] = useState<TestEmailOut | null>(null) const [testEmailResult, setTestEmailResult] = useState<TestEmailOut | null>(null)
@@ -75,7 +98,11 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 400) { if (err instanceof ApiError && err.status === 400) {
toast.show(errorDetail(err) ?? 'Недоступное значение — проверьте уровень AI, таймзону и домен почты', 'error') toast.show(
errorDetail(err) ??
'Недоступное значение — проверьте уровень AI, таймзону, домен почты и текст регламента',
'error',
)
} else { } else {
toast.show('Не удалось сохранить настройки', 'error') toast.show('Не удалось сохранить настройки', 'error')
} }
@@ -116,6 +143,10 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
// валился бы в 400, блокируя правку вообще любой другой настройки. // валился бы в 400, блокируя правку вообще любой другой настройки.
const payload: SettingsUpdateIn = {} const payload: SettingsUpdateIn = {}
if (chatEnabled !== data.chat_enabled) payload.chat_enabled = chatEnabled if (chatEnabled !== data.chat_enabled) payload.chat_enabled = chatEnabled
if (handQueueEnabled !== data.hand_queue_enabled) payload.hand_queue_enabled = handQueueEnabled
if (deviceCheckEnabled !== data.device_check_enabled) payload.device_check_enabled = deviceCheckEnabled
if (virtualBackgroundEnabled !== data.virtual_background_enabled)
payload.virtual_background_enabled = virtualBackgroundEnabled
if (aiEnabled !== data.transcription_enabled) payload.transcription_enabled = aiEnabled if (aiEnabled !== data.transcription_enabled) payload.transcription_enabled = aiEnabled
if (aiLevel !== data.ai_level) payload.ai_level = aiLevel if (aiLevel !== data.ai_level) payload.ai_level = aiLevel
if (recipients !== data.summary_recipients) payload.summary_recipients = recipients if (recipients !== data.summary_recipients) payload.summary_recipients = recipients
@@ -136,6 +167,10 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
if (trimmedContactEmail !== (data.contact_email ?? null)) { if (trimmedContactEmail !== (data.contact_email ?? null)) {
payload.contact_email = trimmedContactEmail payload.contact_email = trimmedContactEmail
} }
if (publishQualityCap !== data.publish_quality_cap) payload.publish_quality_cap = publishQualityCap
if (stageMaxTiles !== data.stage_max_tiles) payload.stage_max_tiles = stageMaxTiles
if (consentRequired !== data.consent_required) payload.consent_required = consentRequired
if (consentPolicyText !== data.consent_policy_text) payload.consent_policy_text = consentPolicyText
mutation.mutate(payload) mutation.mutate(payload)
} }
@@ -157,6 +192,60 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
</label> </label>
</div> </div>
<div className="toggle-row">
<div className="toggle-copy">
<strong>Поднятие руки</strong>
<span>Кнопка «Рука» и очередь поднятых рук в комнате целиком</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={handQueueEnabled}
onChange={(e) => setHandQueueEnabled(e.target.checked)}
/>
<span className="slider" />
</label>
</div>
<div className="toggle-row">
<div className="toggle-copy">
<strong>Проверка устройств на входе</strong>
<span>
Запрос доступа к камере и микрофону и превью камеры на странице входа и в
карточке «Как вас зовут?» чтобы разрешение не выскакивало уже внутри
конференции. Микрофон и камера в самой конференции по-прежнему выключены при
входе
</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={deviceCheckEnabled}
onChange={(e) => setDeviceCheckEnabled(e.target.checked)}
/>
<span className="slider" />
</label>
</div>
<div className="toggle-row">
<div className="toggle-copy">
<strong>Замена фона видео</strong>
<span>
Выбор фона в конференции: три готовые сцены и свои картинки (до 10 штук,
загружаются в профиле). Работает только на компьютере фон считает нейросеть
на каждом кадре, и на телефоне это греет устройство и просаживает встречу
</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={virtualBackgroundEnabled}
onChange={(e) => setVirtualBackgroundEnabled(e.target.checked)}
/>
<span className="slider" />
</label>
</div>
<div className="toggle-row"> <div className="toggle-row">
<div className="toggle-copy"> <div className="toggle-copy">
<strong>Транскрибация и суммаризация (AI)</strong> <strong>Транскрибация и суммаризация (AI)</strong>
@@ -361,6 +450,51 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
</div> </div>
</section> </section>
<section className="settings-card">
<h2>Нагрузка</h2>
<p className="desc">
Рычаги для инстансов на слабом канале/железе режут исходящий трафик и нагрузку на
устройство участника. Дефолты сохраняют прежнее поведение (без ограничений).
</p>
<div className="settings-card-body settings-card-body--spread">
<div className="field" style={{ marginBottom: 0 }}>
<label id="settings-quality-cap-label" htmlFor="settings-quality-cap">
Потолок качества публикации видео
</label>
<Select
id="settings-quality-cap"
aria-labelledby="settings-quality-cap-label"
value={publishQualityCap}
onChange={(v) => setPublishQualityCap(v as PublishQualityCap)}
options={PUBLISH_QUALITY_CAP_OPTIONS}
/>
<p className="field-hint">
Ограничивает битрейт исходящего видео публикующего снижает нагрузку на его канал и
устройство, независимо от размера плитки у смотрящих (adaptiveStream режет с их
стороны отдельно)
</p>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label id="settings-max-tiles-label" htmlFor="settings-max-tiles">
Максимум плиток на экране
</label>
<Select
id="settings-max-tiles"
aria-labelledby="settings-max-tiles-label"
value={String(stageMaxTiles)}
onChange={(v) => setStageMaxTiles(Number(v))}
options={STAGE_MAX_TILES_OPTIONS}
/>
<p className="field-hint">
Сверх лимита участники уходят на следующую страницу сетки вместо подписки меньше
одновременных видеопотоков на канал и экран участника
</p>
</div>
</div>
</section>
<section className="settings-card"> <section className="settings-card">
<h2>Контактный адрес</h2> <h2>Контактный адрес</h2>
<p className="desc"> <p className="desc">
@@ -398,6 +532,55 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
</div> </div>
</section> </section>
<section className="settings-card">
<h2>Согласие на обработку персональных данных</h2>
<p className="desc">
Галочка на форме регистрации со ссылкой на регламент (страница{' '}
<code>/legal/personal-data-consent</code>). Факт согласия хранится в БД вместе с
номером редакции текста и датой.
</p>
<div className="settings-card-body">
<div className="toggle-row" style={{ borderTop: 'none', paddingTop: 0 }}>
<div className="toggle-copy">
<strong>Требовать согласие при регистрации</strong>
<span>Без отмеченной галочки кнопка регистрации неактивна, сервер тоже откажет</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={consentRequired}
onChange={(e) => setConsentRequired(e.target.checked)}
/>
<span className="slider" />
</label>
</div>
<div className="field" style={{ marginBottom: 0, marginTop: 'var(--space-4)' }}>
<label htmlFor="settings-consent-text">
Текст регламента редакция {data.consent_policy_version}
</label>
<p className="field-hint" style={{ color: 'var(--color-danger)' }}>
<AlertTriangle style={{ width: 13, height: 13 }} aria-hidden="true" /> Дефолтный
текст типовой образец, не проходил проверку юриста. Замените плейсхолдеры в
квадратных скобках (наименование оператора, адрес, контакты, цели и срок
обработки) под свою организацию, прежде чем включать требование согласия.
</p>
<textarea
id="settings-consent-text"
rows={12}
value={consentPolicyText}
onChange={(e) => setConsentPolicyText(e.target.value)}
style={{ width: '100%', fontFamily: 'inherit', resize: 'vertical' }}
/>
<p className="field-hint">
Сохранение изменённого текста автоматически увеличивает номер редакции это
значение фиксируется у каждого пользователя вместе с датой согласия.
</p>
</div>
</div>
</section>
<section className="settings-card"> <section className="settings-card">
<h2>Тестовое письмо</h2> <h2>Тестовое письмо</h2>
<p className="desc">Отправить проверочное письмо синхронно, чтобы сразу увидеть результат почтовой конфигурации.</p> <p className="desc">Отправить проверочное письмо синхронно, чтобы сразу увидеть результат почтовой конфигурации.</p>

View File

@@ -0,0 +1,103 @@
import type { ReactNode } from 'react'
import { Mic, MicOff, Video, VideoOff } from 'lucide-react'
import type { DeviceCheckStatus } from '@/hooks/useDeviceCheckAccess'
import '@/styles/device-check.css'
interface DeviceCheckCardProps {
/** Коллбэк-реф из `useDeviceCheckAccess` — см. докстринг там: карточка
* может рендериться в разных местах JSX-дерева (разные шаги `JoinPage`),
* обычный объект-реф не пережил бы такой переезд. */
videoRef: (node: HTMLVideoElement | null) => void
videoStatus: DeviceCheckStatus
audioStatus: DeviceCheckStatus
/** `useDeviceCheckAccess().hint` — `null`, пока отказа нет (см. докстринг хука). */
hint: string | null
/** Одновременно и «войти с камерой/микрофоном включёнными», и (для видео)
* реальное состояние превью — см. докстринг `useDeviceCheckAccess`. */
videoEnabled: boolean
audioEnabled: boolean
onToggleVideo: () => void
onToggleAudio: () => void
/**
* Выбор фона под кнопками (сессия 35) — приходит готовым узлом, а не набором
* пропсов: карточка про доступ к устройствам и знать про фоны, их лимиты и
* загрузку не обязана. `undefined` — модуль выключен, устройство не
* десктопное либо браузер не умеет сегментацию; тогда блока нет вовсе.
*/
backgroundPicker?: ReactNode
}
/**
* Превью камеры + переключатели «микрофон/камера» — окошко превью и кнопки
* составляют одну композицию (кнопки не шире окошка, см. `device-check.css`).
* Кнопки без подписи — только пиктограмма (иначе не умещаются под окошком
* шириной в половину карточки); машиночитаемое состояние — `aria-label`.
* Кликабельны только после реального разрешения (`granted`) — до этого
* непонятно, что вообще включать.
*
* `<video>` рендерится ВСЕГДА (не только при `videoEnabled`) — коллбэк-реф
* хука подключает `srcObject` при КАЖДОМ монтировании узла (см. докстринг
* `useDeviceCheckAccess`); если бы элемент монтировался условно, в момент
* присвоения его ещё не было бы в DOM и поток повис бы никуда не
* подключённым. Плейсхолдер лежит поверх, пока превью не готово ИЛИ камера
* выключена кнопкой (`!videoEnabled`) — кнопка камеры реально останавливает
* поток (см. докстринг хука), а не просто прячет картинку.
*
* `muted`+`playsInline`+`autoPlay` — обязательны: без них iOS Safari не
* запустит воспроизведение живого потока, и вместо своего лица пользователь
* увидит чёрный прямоугольник.
*/
export function DeviceCheckCard({
videoRef,
videoStatus,
audioStatus,
hint,
videoEnabled,
audioEnabled,
onToggleVideo,
onToggleAudio,
backgroundPicker,
}: DeviceCheckCardProps) {
const showPlaceholder = videoStatus !== 'granted' || !videoEnabled
return (
<div className="device-check">
<div className="device-check-inner">
<div className="device-check-frame">
<video ref={videoRef} muted playsInline autoPlay className="device-check-video" />
{showPlaceholder && (
<div className="device-check-placeholder" aria-hidden="true">
<VideoOff className="lucide" />
</div>
)}
</div>
<div className="device-check-toggles">
<button
type="button"
className={`device-check-toggle${audioEnabled ? ' is-on' : ''}`}
disabled={audioStatus !== 'granted'}
onClick={onToggleAudio}
aria-pressed={audioEnabled}
aria-label={audioEnabled ? 'Войти с выключенным микрофоном' : 'Войти с включённым микрофоном'}
>
{audioEnabled ? <Mic className="lucide" aria-hidden="true" /> : <MicOff className="lucide" aria-hidden="true" />}
</button>
<button
type="button"
className={`device-check-toggle${videoEnabled ? ' is-on' : ''}`}
disabled={videoStatus !== 'granted'}
onClick={onToggleVideo}
aria-pressed={videoEnabled}
aria-label={videoEnabled ? 'Выключить камеру' : 'Включить камеру'}
>
{videoEnabled ? <Video className="lucide" aria-hidden="true" /> : <VideoOff className="lucide" aria-hidden="true" />}
</button>
</div>
</div>
{backgroundPicker && <div className="device-check-backgrounds">{backgroundPicker}</div>}
{hint && <p className="field-hint device-check-hint">{hint}</p>}
</div>
)
}

View File

@@ -0,0 +1,73 @@
import { Ban, Check, Loader2 } from 'lucide-react'
import type { MyBackground } from '@/api/users'
import { DEFAULT_BACKGROUNDS, NO_BACKGROUND, type BackgroundKey } from '@/lib/virtualBackground'
import '@/styles/virtual-background.css'
interface BackgroundPickerProps {
value: BackgroundKey
onChange: (key: BackgroundKey) => void
/** Свои картинки пользователя; у гостя список всегда пуст — профиля у него нет. */
customBackgrounds: MyBackground[]
/** Идёт применение фона (первый раз — ещё и загрузка модели) — показать индикатор на выбранной плитке. */
busy?: boolean
}
/**
* Сетка выбора фона: «без фона», три дефолтные сцены и свои картинки.
*
* Один компонент на все три места, где выбирают фон (превью входа, комната,
* профиль): плитки везде выглядят одинаково, а цвета берутся из токенов темы —
* внутри комнаты (`[data-theme="room"]`) те же классы перекрашиваются в тёмную
* палитру, см. `styles/virtual-background.css`.
*
* Кнопки — `aria-pressed`, а не радиогруппа: выбор применяется немедленно и
* никуда не отправляется формой.
*/
export function BackgroundPicker({
value,
onChange,
customBackgrounds,
busy = false,
}: BackgroundPickerProps) {
const renderTile = (key: BackgroundKey, label: string, url: string | null) => {
const selected = value === key
return (
<button
key={key}
type="button"
className={`bg-tile${selected ? ' is-selected' : ''}`}
aria-pressed={selected}
aria-label={label}
title={label}
onClick={() => onChange(key)}
>
{url ? (
<img src={url} alt="" loading="lazy" />
) : (
<span className="bg-tile-none">
<Ban className="lucide" aria-hidden="true" />
</span>
)}
{selected && (
<span className="bg-tile-mark" aria-hidden="true">
{busy ? (
<Loader2 className="lucide bg-tile-spinner" />
) : (
<Check className="lucide" />
)}
</span>
)}
</button>
)
}
return (
<div className="bg-grid">
{renderTile(NO_BACKGROUND, 'Без фона', null)}
{DEFAULT_BACKGROUNDS.map((item) => renderTile(`default:${item.id}`, item.label, item.url))}
{customBackgrounds.map((item, index) =>
renderTile(`custom:${item.id}`, `Своя картинка ${index + 1}`, item.url),
)}
</div>
)
}

View File

@@ -0,0 +1,143 @@
import { useRef, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Trash2, Upload } from 'lucide-react'
import { deleteMyBackground, uploadMyBackground } from '@/api/users'
import { ApiError } from '@/api/client'
import { useToast } from '@/components/ui/ToastProvider'
import { MY_BACKGROUNDS_QUERY_KEY, useMyBackgrounds } from '@/hooks/useMyBackgrounds'
import { ImageDecodeError, resizeImageForBackground } from '@/lib/imageResize'
import { isDesktopDevice } from '@/lib/isDesktopDevice'
import '@/styles/virtual-background.css'
/** Что вообще можно выбрать в диалоге файлов — совпадает с проверкой на сервере. */
const ACCEPTED_TYPES = 'image/jpeg,image/png,image/webp'
/**
* Секция профиля «Свои фоны» — загрузка и удаление картинок для замены фона
* видео («личный кабинет» из задачи).
*
* Загрузка идёт с предварительным СЖАТИЕМ в браузере (`lib/imageResize.ts`):
* «ужимать до приемлемого размера, чтобы не грузили БД» — требование задачи.
* Сервер картинку не пережимает, но полноценно валидирует.
*
* Лимит на число картинок приходит с сервера (`limit` в ответе списка) — он
* там же и проверяется; здесь кнопка просто гаснет заранее, чтобы не отправлять
* запрос, заведомо обречённый на 409.
*
* Удаление применённого сейчас фона отдельно обрабатывать не нужно: выбор
* хранится ключом `custom:<id>`, и как только записи с таким id не стало,
* `useVirtualBackground` сам сбрасывает фон на «без фона».
*/
export function MyBackgroundsSection() {
const toast = useToast()
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [error, setError] = useState<string | null>(null)
const { items, limit } = useMyBackgrounds(true)
// Раздел показывается на любом устройстве (загрузить картинки заранее с
// телефона — нормальный сценарий), но на не-десктопе честно предупреждаем,
// что применить фон получится только на компьютере: см. `isDesktopDevice`.
const [desktop] = useState(isDesktopDevice)
const invalidate = () => queryClient.invalidateQueries({ queryKey: MY_BACKGROUNDS_QUERY_KEY })
const uploadMutation = useMutation({
mutationFn: async (file: File) => uploadMyBackground(await resizeImageForBackground(file)),
onSuccess: async () => {
await invalidate()
toast.show('Фон добавлен', 'success')
},
onError: (err: unknown) => {
if (err instanceof ImageDecodeError) {
setError('Не удалось прочитать картинку — возможно, файл повреждён')
} else if (err instanceof ApiError && err.status === 409) {
setError(`Больше ${limit} картинок хранить нельзя — удалите ненужную`)
} else if (err instanceof ApiError && err.status === 413) {
setError('Файл слишком большой даже после сжатия')
} else if (err instanceof ApiError && err.status === 415) {
setError('Недопустимый формат — только JPEG, PNG или WEBP')
} else {
toast.show('Не удалось загрузить фон', 'error')
}
},
})
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteMyBackground(id),
onSuccess: async () => {
await invalidate()
toast.show('Фон удалён', 'success')
},
onError: () => toast.show('Не удалось удалить фон', 'error'),
})
const limitReached = limit > 0 && items.length >= limit
const busy = uploadMutation.isPending || deleteMutation.isPending
return (
<section className="profile-card">
<h2>Свои фоны</h2>
<p className="field-hint" style={{ margin: 0 }}>
Картинки для замены фона видео в конференции. Не больше {limit || 10} штук; перед
отправкой картинка автоматически уменьшается.
{!desktop && ' Сама замена фона работает только на компьютере — на телефоне она' +
' отключена, чтобы не нагружать устройство.'}
</p>
{items.length > 0 && (
<div className="bg-manage-grid">
{items.map((item, index) => (
<div key={item.id} className="bg-manage-tile">
<img src={item.url} alt={`Свой фон ${index + 1}`} loading="lazy" />
<button
type="button"
className="bg-tile-delete"
aria-label={`Удалить свой фон ${index + 1}`}
disabled={busy}
onClick={() => {
setError(null)
deleteMutation.mutate(item.id)
}}
>
<Trash2 className="lucide" aria-hidden="true" />
</button>
</div>
))}
</div>
)}
<div className="profile-avatar-actions">
<button
type="button"
className="btn btn-secondary"
disabled={busy || limitReached}
onClick={() => fileInputRef.current?.click()}
>
<Upload style={{ width: 16, height: 16 }} aria-hidden="true" />
{uploadMutation.isPending ? 'Загружаем…' : 'Добавить фон'}
</button>
<span className="field-hint">
{items.length} из {limit || 10}
</span>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_TYPES}
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0] ?? null
e.target.value = ''
setError(null)
if (file) uploadMutation.mutate(file)
}}
/>
</div>
{error && (
<p className="field-hint" style={{ color: 'var(--color-danger)', margin: 0 }}>
{error}
</p>
)}
</section>
)
}

View File

@@ -0,0 +1,77 @@
import { X } from 'lucide-react'
import type { MyBackground } from '@/api/users'
import { useModalDismiss } from '@/hooks/useModalDismiss'
import { BackgroundPicker } from '@/components/background/BackgroundPicker'
import type { BackgroundKey } from '@/lib/virtualBackground'
import type { VirtualBackgroundStatus } from '@/hooks/useVirtualBackground'
interface BackgroundDialogProps {
onClose: () => void
value: BackgroundKey
onChange: (key: BackgroundKey) => void
customBackgrounds: MyBackground[]
status: VirtualBackgroundStatus
/** Гость своих картинок иметь не может — ему показывается другая подсказка. */
canManageOwn: boolean
}
/**
* Модалка «Фон» в комнате — та же оболочка, что у `DeviceSettingsDialog`
* (`.room-modal-*`), внутри общая сетка выбора (`BackgroundPicker`).
*
* Мобильного варианта-шторки здесь НЕТ намеренно: фича десктопная (см.
* `lib/isDesktopDevice.ts`), и на телефоне ни кнопки, ни этой модалки не
* существует вовсе.
*
* Свои картинки отсюда не загружаются — они живут в профиле («личный кабинет»
* из задачи). В комнате их можно только выбрать: загрузка требует ухода со
* страницы, а бросать конференцию ради этого не нужно.
*/
export function BackgroundDialog({
onClose,
value,
onChange,
customBackgrounds,
status,
canManageOwn,
}: BackgroundDialogProps) {
useModalDismiss(onClose)
return (
<div
className="room-modal-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="background-dialog-title"
onClick={onClose}
>
<div className="room-modal-panel" onClick={(e) => e.stopPropagation()}>
<div className="room-modal-head">
<h2 id="background-dialog-title">Фон</h2>
<button type="button" className="room-modal-close" aria-label="Закрыть" onClick={onClose}>
<X className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
</div>
<BackgroundPicker
value={value}
onChange={onChange}
customBackgrounds={customBackgrounds}
busy={status === 'loading'}
/>
{status === 'error' ? (
<p className="bg-hint">
Не удалось применить фон попробуйте выбрать другой или отключить фон.
</p>
) : (
<p className="bg-hint">
{canManageOwn
? 'Свои картинки добавляются в профиле — их видно здесь сразу после загрузки.'
: 'Свои картинки доступны зарегистрированным пользователям.'}
</p>
)}
</div>
</div>
)
}

View File

@@ -6,12 +6,15 @@ import { useModalDismiss } from '@/hooks/useModalDismiss'
/** /**
* Небольшой собственный набор популярных эмодзи — вместо библиотеки-пикера на * Небольшой собственный набор популярных эмодзи — вместо библиотеки-пикера на
* сотни килобайт ради десятка кнопок в поповере. * сотни килобайт ради десятка кнопок в поповере. Ровно 30 штук (6×5 —
* `.chat-emoji-popover` в room.css рассчитан на эту сетку без остатка;
* добавляя/убирая эмодзи, держи кратность 5).
*/ */
const EMOJI_OPTIONS = [ const EMOJI_OPTIONS = [
'😀', '😂', '😊', '😉', '😍', '🤔', '😅', '😢', '😀', '😂', '😊', '😉', '😍', '🤔', '😅', '😢',
'😮', '😎', '🙌', '👍', '👎', '👏', '🙏', '❤️', '😮', '😎', '🙌', '👍', '👎', '👏', '🙏', '❤️',
'🔥', '🎉', '✅', '❌', '⚠️', '💡', '👀', '🤝', '🔥', '🎉', '✅', '❌', '⚠️', '💡', '👀', '🤝',
'🐎', '🦾', '🚀', '🦞', '💯', '🤷‍♂️',
] ]
interface ChatPanelProps { interface ChatPanelProps {

View File

@@ -11,7 +11,7 @@ interface DeviceSettingsDialogProps extends StageViewProps {
onClose: () => void onClose: () => void
} }
/** Свайп ручки шторки вниз дальше этого порога (px) закрывает панель, меньше — она возвращается на место. */ /** Свайп панели вниз дальше этого порога (px) закрывает шторку, меньше — она возвращается на место. */
const SHEET_DISMISS_THRESHOLD_PX = 80 const SHEET_DISMISS_THRESHOLD_PX = 80
/** Человекочитаемая подпись пункта списка устройств — `label` пуст, пока нет разрешения на медиа. */ /** Человекочитаемая подпись пункта списка устройств — `label` пуст, пока нет разрешения на медиа. */
@@ -30,7 +30,13 @@ function deviceLabel(device: MediaDeviceInfo, index: number, fallback: string):
* `deviceId` при следующем подключении. * `deviceId` при следующем подключении.
* *
* На мобильной ширине (`useIsCompactViewport`) рендерится шторкой снизу вместо * На мобильной ширине (`useIsCompactViewport`) рендерится шторкой снизу вместо
* модалки — по клику вне, Escape (`useModalDismiss`) и свайпу вниз за ручку. * модалки — по клику вне, Escape (`useModalDismiss`) и свайпу вниз по ЛЮБОМУ
* месту панели (не только за ручку-волосок `.room-sheet-handle` — та её не
* пережила бы: 40×4px, попасть пальцем почти нереально). Свайп срабатывает,
* только если содержимое панели проскроллено в самый верх (`scrollTop === 0`
* на touchstart, см. `dragEligibleRef`) — иначе палец должен листать список
* устройств, а не закрывать окно; решение фиксируется на весь жест, а не
* пересчитывается на каждый touchmove, как в любом стандартном bottom sheet.
* Десктоп не меняется. * Десктоп не меняется.
* *
* Там же, и только там, первой секцией идёт «Вид» (режим показа участников и * Там же, и только там, первой секцией идёт «Вид» (режим показа участников и
@@ -61,6 +67,12 @@ export function DeviceSettingsDialog({
useModalDismiss(onClose) useModalDismiss(onClose)
const dragStartYRef = useRef<number | null>(null) const dragStartYRef = useRef<number | null>(null)
// Жест начат, когда контент панели был проскроллен в самый верх — свайп по
// панели, у которой ещё есть что скроллить, должен листать содержимое, а не
// закрывать шторку. Решается ОДИН раз в touchstart и держится весь жест
// (даже если внутри него направление сменится) — так же, как в любом
// стандартном bottom sheet.
const dragEligibleRef = useRef(false)
const [dragOffset, setDragOffset] = useState(0) const [dragOffset, setDragOffset] = useState(0)
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
@@ -92,18 +104,21 @@ export function DeviceSettingsDialog({
} }
} }
function handleHandleTouchStart(e: React.TouchEvent<HTMLDivElement>) { function handlePanelTouchStart(e: React.TouchEvent<HTMLDivElement>) {
dragEligibleRef.current = e.currentTarget.scrollTop === 0
if (!dragEligibleRef.current) return
dragStartYRef.current = e.touches[0].clientY dragStartYRef.current = e.touches[0].clientY
setIsDragging(true) setIsDragging(true)
} }
function handleHandleTouchMove(e: React.TouchEvent<HTMLDivElement>) { function handlePanelTouchMove(e: React.TouchEvent<HTMLDivElement>) {
if (dragStartYRef.current === null) return if (!dragEligibleRef.current || dragStartYRef.current === null) return
const delta = e.touches[0].clientY - dragStartYRef.current const delta = e.touches[0].clientY - dragStartYRef.current
if (delta > 0) setDragOffset(delta) if (delta > 0) setDragOffset(delta)
} }
function handleHandleTouchEnd() { function handlePanelTouchEnd() {
if (!dragEligibleRef.current) return
if (dragOffset > SHEET_DISMISS_THRESHOLD_PX) { if (dragOffset > SHEET_DISMISS_THRESHOLD_PX) {
onClose() onClose()
return return
@@ -124,20 +139,16 @@ export function DeviceSettingsDialog({
<div <div
className={isCompact ? 'room-sheet-panel' : 'room-modal-panel'} className={isCompact ? 'room-sheet-panel' : 'room-modal-panel'}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onTouchStart={isCompact ? handlePanelTouchStart : undefined}
onTouchMove={isCompact ? handlePanelTouchMove : undefined}
onTouchEnd={isCompact ? handlePanelTouchEnd : undefined}
style={ style={
isCompact && dragOffset isCompact && dragOffset
? { transform: `translateY(${dragOffset}px)`, transition: isDragging ? 'none' : undefined } ? { transform: `translateY(${dragOffset}px)`, transition: isDragging ? 'none' : undefined }
: undefined : undefined
} }
> >
{isCompact && ( {isCompact && <div className="room-sheet-handle" />}
<div
className="room-sheet-handle"
onTouchStart={handleHandleTouchStart}
onTouchMove={handleHandleTouchMove}
onTouchEnd={handleHandleTouchEnd}
/>
)}
<div className="room-modal-head"> <div className="room-modal-head">
<h2 id="device-settings-title">{isCompact ? 'Настройки' : 'Настройки устройств'}</h2> <h2 id="device-settings-title">{isCompact ? 'Настройки' : 'Настройки устройств'}</h2>

View File

@@ -0,0 +1,40 @@
import { useEffect } from 'react'
import { useLocalParticipant } from '@livekit/components-react'
import { useToast } from '@/components/ui/ToastProvider'
import type { ForcedMuteEvent } from '@/hooks/useChat'
/**
* Уведомляет ЛОКАЛЬНОГО участника тостом, когда организатор принудительно
* выключил его микрофон/камеру (задача B2). Рендерится безусловно внутри
* `<LiveKitRoom>` — `useLocalParticipant` недоступен снаружи (`RoomPage`
* сам вне контекста LiveKit, см. докстринг `useIsOrganizer`).
*
* Само выключение трека организатор делает СЕРВЕРНЫМ вызовом LiveKit API
* (`services/room_control.py`) — тулбарные кнопки (useTrackToggle) сами
* отразят новое состояние по родному событию LiveKit `TrackMuted`, этот
* компонент только поясняет ПОЧЕМУ: без тоста человек не отличил бы
* действие организатора от случайного глюка. Участник может включить себя
* обратно сразу тем же тулбаром — сервер это не блокирует (см. докстринг B2
* в CHANGELOG/коммите).
*/
export function ForcedMuteWatcher({ event }: { event: ForcedMuteEvent | null }) {
const { localParticipant } = useLocalParticipant()
const toast = useToast()
useEffect(() => {
if (!event || event.identity !== localParticipant.identity) return
toast.show(
event.source === 'microphone'
? 'Организатор выключил ваш микрофон'
: 'Организатор выключил вашу камеру',
'info',
)
// `event` (включая `nonce`) — единственная зависимость, которая должна
// повторно показывать тост; `localParticipant`/`toast` стабильны в
// рамках подключения и намеренно не входят в список, чтобы их
// пересоздание (если когда-нибудь случится) не дублировало уведомление.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [event])
return null
}

Some files were not shown because too many files have changed in this diff Show More