Compare commits
15 Commits
e5c596f2bf
...
v0.0.22
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f5f336cd6 | |||
| 0441f4f72b | |||
| 9bc8d6174d | |||
| 45c997380f | |||
| 173d384f06 | |||
| 7b2427535a | |||
| 8e45038251 | |||
| 286c01d93b | |||
| e8af2fce10 | |||
| d7ae462ed5 | |||
| 826b7639b1 | |||
| daaa480f03 | |||
| c60047c594 | |||
| f39c21e7e1 | |||
| 84b7f807f7 |
12
.env.example
12
.env.example
@@ -59,6 +59,16 @@ TURN_STATIC_AUTH_SECRET=change-me-turn-secret
|
||||
# скриптом deploy/render-templates.sh (вызывается install.sh).
|
||||
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 (через пробел, все — в server_name).
|
||||
NGINX_SERVER_NAMES=example.com www.example.com
|
||||
@@ -112,7 +122,7 @@ SMTP_TIMEOUT_S=30
|
||||
# --- Версия инстанса (релиз v0.0.1) ---
|
||||
# install.sh копирует значение из корневого файла VERSION при каждой
|
||||
# установке/обновлении — руками менять не нужно.
|
||||
VIDCONF_VERSION=0.0.16
|
||||
VIDCONF_VERSION=0.0.22
|
||||
|
||||
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
||||
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
|
||||
|
||||
115
CHANGELOG.md
115
CHANGELOG.md
@@ -3,6 +3,121 @@
|
||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
|
||||
проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
|
||||
|
||||
## [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 кнопок вместо
|
||||
восьми) — теперь плавно уменьшаются на диапазоне 1200–600px вместо
|
||||
жёсткого скачка на мобильный вид.
|
||||
- Подпись «Мини-окно» на промежуточных ширинах переносилась на 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
|
||||
|
||||
Роль организатора в комнате: поднятие руки с очередью и принудительный мьют.
|
||||
|
||||
@@ -217,7 +217,7 @@ async def create_user(
|
||||
user = await repo.create(
|
||||
email=data.email,
|
||||
name_user=data.name_user,
|
||||
password_hash=hash_password(data.password),
|
||||
password_hash=await hash_password(data.password),
|
||||
team_id=data.team_id,
|
||||
)
|
||||
user.email_verified = True
|
||||
@@ -467,6 +467,8 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -
|
||||
registration_email_domains=cfg.registration_email_domains,
|
||||
contact_email_enabled=cfg.contact_email_enabled,
|
||||
contact_email=cfg.contact_email,
|
||||
publish_quality_cap=cfg.media_limits.publish_quality_cap,
|
||||
stage_max_tiles=cfg.media_limits.stage_max_tiles,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.deps import get_current_user
|
||||
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 schemas.conferences import (
|
||||
ConferenceCreateIn,
|
||||
@@ -103,10 +108,18 @@ async def resolve_conference(
|
||||
п.4, уточнение резолва): вход в неё невозможен в любом случае (410 у
|
||||
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)
|
||||
conference = await service.resolve(q)
|
||||
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")
|
||||
if conference.status == "ended":
|
||||
return ResolveOut(id=conference.id, title=conference.title, status=conference.status)
|
||||
@@ -154,11 +167,15 @@ async def guest_join_conference(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> JoinOut:
|
||||
"""Войти гостем: представиться (имя обязательно, 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)
|
||||
try:
|
||||
return await service.join_as_guest(conference_id, data=data)
|
||||
except ConferenceNotFoundError as exc:
|
||||
await _count_guest_join_miss(ip)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found"
|
||||
) from exc
|
||||
@@ -169,6 +186,9 @@ async def guest_join_conference(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="password_required"
|
||||
) from exc
|
||||
except InvalidPasswordError as exc:
|
||||
# Подбор пароля закрытой конференции — тот же класс атаки, что и
|
||||
# перебор номера, поэтому считается жёстким счётчиком.
|
||||
await _count_guest_join_miss(ip)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="invalid_password"
|
||||
) from exc
|
||||
@@ -284,6 +304,11 @@ def _require_utc(value: datetime) -> datetime:
|
||||
return value.astimezone(UTC)
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
"""IP-адрес клиента для rate limit (без auth — ключ по IP, а не по пользователю)."""
|
||||
return request.client.host if request.client else "unknown"
|
||||
async def _count_guest_join_miss(ip: str) -> None:
|
||||
"""Учесть неудачную попытку гостевого входа в жёстком счётчике.
|
||||
|
||||
Вынесено отдельно, потому что вызывается из двух веток обработки ошибок
|
||||
(несуществующая конференция и неверный пароль) и обязано бросать 429
|
||||
ровно так же, как обычный `enforce_rate_limit`.
|
||||
"""
|
||||
await enforce_rate_limit(f"guest_join_miss:{ip}", max_requests=RATE_LIMIT_MISS_MAX_REQUESTS)
|
||||
|
||||
@@ -101,11 +101,11 @@ async def change_current_user_password(
|
||||
вместе со сбросом пароля по email (v0.1.0, см. ADR-005
|
||||
`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(
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -62,6 +62,28 @@ SummaryRecipientsMode = Literal["all", "owner"]
|
||||
"""Режим рассылки саммари по умолчанию: всем участникам либо только
|
||||
владельцу конференции (переопределяется на уровне `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):
|
||||
"""Эффективная конфигурация инстанса (значения `instance_settings` поверх дефолтов
|
||||
@@ -87,3 +109,9 @@ class InstanceConfig(BaseModel):
|
||||
# адреса) — см. `services/instance_settings.py`, `services/email.py`.
|
||||
contact_email_enabled: bool = False
|
||||
contact_email: str | None = None
|
||||
# Потолок качества публикации + максимум плиток сцены — см.
|
||||
# `services/instance_settings.py`. Отдаётся участнику ДО входа в
|
||||
# LiveKit-комнату (в ответе join, `schemas/conferences.py::JoinOut`), а
|
||||
# не только в админке — настройка должна быть на руках у клиента до
|
||||
# публикации трека.
|
||||
media_limits: MediaLimitsConfig = Field(default_factory=MediaLimitsConfig)
|
||||
|
||||
@@ -3,15 +3,59 @@
|
||||
Используется резолвом конференций и гостевым входом (`api/conferences.py`) —
|
||||
эндпоинтами без аутентификации, уязвимыми к перебору номера/ссылки конференции
|
||||
(см. 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
|
||||
|
||||
RATE_LIMIT_MAX_REQUESTS = 10
|
||||
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(
|
||||
key: str,
|
||||
|
||||
@@ -1,33 +1,85 @@
|
||||
"""Хэширование паролей (argon2) и выпуск/проверка JWT (access + refresh)."""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
from argon2.exceptions import InvalidHashError, VerifyMismatchError
|
||||
|
||||
from core.config import get_settings
|
||||
|
||||
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:
|
||||
"""Захэшировать пароль алгоритмом argon2 для хранения в БД."""
|
||||
def _hash_password_sync(password: str) -> str:
|
||||
return _hasher.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
"""Сверить пароль с сохранённым argon2-хэшем; пароль/хэш никогда не логируются."""
|
||||
def _verify_password_sync(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return _hasher.verify(password_hash, password)
|
||||
except VerifyMismatchError:
|
||||
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: синхронный вызов останавливал весь процесс на 95–155 мс, и при
|
||||
массовом входе (около 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:
|
||||
"""Выпустить access-токен: `sub`=user_id, `role`=роль, TTL из настроек."""
|
||||
settings = get_settings()
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Literal
|
||||
|
||||
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 services.ai_levels import AiLevelStatus
|
||||
|
||||
@@ -158,6 +158,10 @@ class SettingsOut(BaseModel):
|
||||
registration_email_domains: list[str] = Field(default_factory=list)
|
||||
contact_email_enabled: bool
|
||||
contact_email: str | None = None
|
||||
# Рычаги нагрузки медиа (`InstanceConfig.media_limits`) — потолок
|
||||
# качества публикации и максимум плиток сцены, см. `core/plugins/config.py`.
|
||||
publish_quality_cap: PublishQualityCap
|
||||
stage_max_tiles: StageMaxTiles
|
||||
|
||||
|
||||
class TestEmailIn(BaseModel):
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import UTC, datetime, timedelta
|
||||
|
||||
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
|
||||
|
||||
@@ -130,6 +130,11 @@ class JoinOut(BaseModel):
|
||||
# Тоггл инстанса `chat.enabled` на момент входа — клиент решает,
|
||||
# показывать ли UI чата, не дожидаясь ошибки WS-подключения.
|
||||
chat_enabled: bool
|
||||
# Рычаги нагрузки медиа (`instance_settings.media_limits`) — отдаются
|
||||
# прямо в join-ответе, а не только в админке: участнику нужно иметь их
|
||||
# на руках ДО публикации своего трека (см. `services/conference_access.py`).
|
||||
publish_quality_cap: PublishQualityCap
|
||||
stage_max_tiles: StageMaxTiles
|
||||
|
||||
|
||||
class ConferenceOut(BaseModel):
|
||||
|
||||
@@ -24,6 +24,7 @@ from core.security import (
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
hash_password,
|
||||
needs_rehash,
|
||||
verify_password,
|
||||
)
|
||||
from models.email_verification import EmailVerificationToken
|
||||
@@ -131,7 +132,7 @@ class AuthService:
|
||||
user = await self._users.create(
|
||||
email=email,
|
||||
name_user=name_user,
|
||||
password_hash=hash_password(password),
|
||||
password_hash=await hash_password(password),
|
||||
team_id=team_id,
|
||||
)
|
||||
reply_to = cfg.contact_email if cfg.contact_email_enabled else None
|
||||
@@ -161,10 +162,21 @@ class AuthService:
|
||||
async def login(self, *, email: str, password: str) -> TokenPair:
|
||||
"""Проверить учётные данные и выдать пару access/refresh токенов."""
|
||||
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
|
||||
if not user.email_verified:
|
||||
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)
|
||||
|
||||
async def refresh(self, refresh_token: str) -> TokenPair:
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import json
|
||||
|
||||
from core.config import get_settings
|
||||
from core.plugins.config import PublishQualityCap, StageMaxTiles
|
||||
from core.security import verify_password
|
||||
from models.conference import Conference
|
||||
from schemas.conferences import JoinOut
|
||||
@@ -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`
|
||||
@@ -42,7 +43,7 @@ def ensure_joinable(conference: Conference, *, password: str | None) -> None:
|
||||
return
|
||||
if conference.password_hash is None or password is None:
|
||||
raise PasswordRequiredError
|
||||
if not verify_password(password, conference.password_hash):
|
||||
if not await verify_password(password, conference.password_hash):
|
||||
raise InvalidPasswordError
|
||||
|
||||
|
||||
@@ -52,15 +53,18 @@ def build_join(
|
||||
identity: str,
|
||||
name: str,
|
||||
chat_enabled: bool,
|
||||
publish_quality_cap: PublishQualityCap,
|
||||
stage_max_tiles: StageMaxTiles,
|
||||
avatar_url: str | None = None,
|
||||
is_organizer: bool = False,
|
||||
) -> JoinOut:
|
||||
"""Построить ответ join: LiveKit access-токен для входа в комнату конференции.
|
||||
|
||||
Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4).
|
||||
`chat_enabled` — снятый вызывающей стороной тоггл `instance_settings`:
|
||||
читается здесь параметром, а не заново из БД, чтобы не плодить
|
||||
отдельный запрос настроек на каждый join. `avatar_url`/`is_organizer`
|
||||
`chat_enabled`/`publish_quality_cap`/`stage_max_tiles` — снятые вызывающей
|
||||
стороной значения `instance_settings`: читаются здесь параметрами, а не
|
||||
заново из БД, чтобы не плодить отдельный запрос настроек на каждый join.
|
||||
`avatar_url`/`is_organizer`
|
||||
прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}`
|
||||
— поля добавляются, только если заданы (гость без аватара и не-организатор
|
||||
получают токен вовсе без метаданных, как и раньше).
|
||||
@@ -87,4 +91,6 @@ def build_join(
|
||||
room_name=conference.slug,
|
||||
conference_id=conference.id,
|
||||
chat_enabled=chat_enabled,
|
||||
publish_quality_cap=publish_quality_cap,
|
||||
stage_max_tiles=stage_max_tiles,
|
||||
)
|
||||
|
||||
@@ -101,7 +101,7 @@ class ConferenceService:
|
||||
повторением без явного `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
|
||||
conference_status = "active" if is_instant else "scheduled"
|
||||
recurrence_json = data.recurrence.model_dump(mode="json") if data.recurrence else None
|
||||
@@ -149,12 +149,14 @@ class ConferenceService:
|
||||
|
||||
join = None
|
||||
if is_instant:
|
||||
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
|
||||
cfg = await InstanceSettingsService(self._session).get()
|
||||
join = build_join(
|
||||
conference,
|
||||
identity=str(owner_id),
|
||||
name=owner_name,
|
||||
chat_enabled=chat_enabled,
|
||||
chat_enabled=cfg.chat.enabled,
|
||||
publish_quality_cap=cfg.media_limits.publish_quality_cap,
|
||||
stage_max_tiles=cfg.media_limits.stage_max_tiles,
|
||||
avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path),
|
||||
is_organizer=True,
|
||||
)
|
||||
@@ -242,13 +244,15 @@ class ConferenceService:
|
||||
) -> JoinOut:
|
||||
"""Войти в конференцию зарегистрированным пользователем."""
|
||||
conference = await self._get_or_raise(conference_id)
|
||||
ensure_joinable(conference, password=password)
|
||||
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
|
||||
await ensure_joinable(conference, password=password)
|
||||
cfg = await InstanceSettingsService(self._session).get()
|
||||
return build_join(
|
||||
conference,
|
||||
identity=str(user.id),
|
||||
name=user.name_user,
|
||||
chat_enabled=chat_enabled,
|
||||
chat_enabled=cfg.chat.enabled,
|
||||
publish_quality_cap=cfg.media_limits.publish_quality_cap,
|
||||
stage_max_tiles=cfg.media_limits.stage_max_tiles,
|
||||
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,
|
||||
)
|
||||
@@ -256,7 +260,7 @@ class ConferenceService:
|
||||
async def join_as_guest(self, conference_id: uuid.UUID, *, data: GuestJoinIn) -> JoinOut:
|
||||
"""Войти в конференцию гостем: создать `GuestAccess` и выдать токен."""
|
||||
conference = await self._get_or_raise(conference_id)
|
||||
ensure_joinable(conference, password=data.password)
|
||||
await ensure_joinable(conference, password=data.password)
|
||||
|
||||
guest = GuestAccess(
|
||||
conference_id=conference.id, display_name=data.display_name, email=data.email
|
||||
@@ -265,12 +269,14 @@ class ConferenceService:
|
||||
await self._session.flush()
|
||||
await self._session.commit()
|
||||
|
||||
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
|
||||
cfg = await InstanceSettingsService(self._session).get()
|
||||
return build_join(
|
||||
conference,
|
||||
identity=f"guest:{guest.id}",
|
||||
name=data.display_name,
|
||||
chat_enabled=chat_enabled,
|
||||
chat_enabled=cfg.chat.enabled,
|
||||
publish_quality_cap=cfg.media_limits.publish_quality_cap,
|
||||
stage_max_tiles=cfg.media_limits.stage_max_tiles,
|
||||
)
|
||||
|
||||
async def mute_participant(
|
||||
@@ -330,7 +336,7 @@ class ConferenceService:
|
||||
if data.is_closed is not None:
|
||||
conference.is_closed = data.is_closed
|
||||
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:
|
||||
# Явная передача (в т.ч. `null`) — сбросить/установить
|
||||
# переопределение; отсутствие поля в запросе значение не трогает.
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
|
||||
`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`)
|
||||
импортирует дефолты `config/plugins.yaml` через `INSERT ... ON CONFLICT DO
|
||||
NOTHING` в lifespan backend — однократно и идемпотентно: повторный вызов
|
||||
@@ -28,7 +29,10 @@ from core.plugins.config import (
|
||||
AiLevel,
|
||||
ChatConfig,
|
||||
InstanceConfig,
|
||||
MediaLimitsConfig,
|
||||
PluginsConfig,
|
||||
PublishQualityCap,
|
||||
StageMaxTiles,
|
||||
SummarizerConfig,
|
||||
SummaryRecipientsMode,
|
||||
TranscriberConfig,
|
||||
@@ -47,6 +51,7 @@ _KEY_DISPLAY_TIMEZONE = "display_timezone"
|
||||
_KEY_REGISTRATION_TEAM_CHOICE = "registration_team_choice"
|
||||
_KEY_REGISTRATION_EMAIL_DOMAIN = "registration_email_domain"
|
||||
_KEY_CONTACT_EMAIL = "contact_email"
|
||||
_KEY_MEDIA_LIMITS = "media_limits"
|
||||
|
||||
BOOTSTRAP_MANAGED_KEYS: tuple[str, ...] = (
|
||||
_KEY_CHAT,
|
||||
@@ -70,6 +75,7 @@ _DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE: dict[str, Any] = {"enabled": False, "d
|
||||
для обратной совместимости с уже развёрнутыми инстансами; при первом же
|
||||
`update()` значение переписывается в новую форму (см. `update`)."""
|
||||
_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}
|
||||
|
||||
# Простой паттерн доменного имени: минимум один символ, минимум одна точка,
|
||||
# метки из латинских букв/цифр/дефисов (без ведущего/конечного дефиса),
|
||||
@@ -103,6 +109,8 @@ class SettingsUpdateIn(BaseModel):
|
||||
registration_email_domains: list[str] | None = None
|
||||
contact_email_enabled: bool | None = None
|
||||
contact_email: str | None = None
|
||||
publish_quality_cap: PublishQualityCap | None = None
|
||||
stage_max_tiles: StageMaxTiles | None = None
|
||||
|
||||
|
||||
class BootstrapOverrides(BaseModel):
|
||||
@@ -152,6 +160,7 @@ def build_bootstrap_defaults(
|
||||
_KEY_REGISTRATION_TEAM_CHOICE: dict(_DEFAULT_REGISTRATION_TEAM_CHOICE_VALUE),
|
||||
_KEY_REGISTRATION_EMAIL_DOMAIN: dict(_DEFAULT_REGISTRATION_EMAIL_DOMAIN_VALUE),
|
||||
_KEY_CONTACT_EMAIL: dict(_DEFAULT_CONTACT_EMAIL_VALUE),
|
||||
_KEY_MEDIA_LIMITS: dict(_DEFAULT_MEDIA_LIMITS_VALUE),
|
||||
}
|
||||
if overrides is None:
|
||||
return defaults
|
||||
@@ -340,6 +349,20 @@ class InstanceSettingsService:
|
||||
await self._set(_KEY_TRANSCRIBER, cfg.transcriber.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"))
|
||||
|
||||
await self._session.commit()
|
||||
return cfg
|
||||
|
||||
@@ -489,4 +512,7 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
|
||||
"enabled", False
|
||||
),
|
||||
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)
|
||||
),
|
||||
)
|
||||
|
||||
@@ -33,7 +33,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
|
||||
user = User(
|
||||
email=f"{uuid.uuid4()}@example.com",
|
||||
name_user="Admin API Tester",
|
||||
password_hash=hash_password("password123"),
|
||||
password_hash=await hash_password("password123"),
|
||||
email_verified=True,
|
||||
role=role,
|
||||
)
|
||||
@@ -706,6 +706,68 @@ async def test_put_settings_contact_email_invalid_returns_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
|
||||
|
||||
|
||||
# --- Тестовое письмо ----------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
|
||||
user = User(
|
||||
email=f"{uuid.uuid4()}@example.com",
|
||||
name_user="Team API Tester",
|
||||
password_hash=hash_password("password123"),
|
||||
password_hash=await hash_password("password123"),
|
||||
email_verified=True,
|
||||
role=role,
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Annotated
|
||||
|
||||
import httpx
|
||||
import pytest_asyncio
|
||||
from argon2 import PasswordHasher
|
||||
from fastapi import Depends, FastAPI
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -16,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from api.auth import get_auth_service
|
||||
from core.db import get_session
|
||||
from core.redis import redis_client
|
||||
from core.security import needs_rehash
|
||||
from models.team import Team
|
||||
from models.user import User
|
||||
from services.auth import AuthService
|
||||
@@ -491,3 +493,35 @@ async def test_register_no_reply_to_when_contact_email_disabled(
|
||||
|
||||
assert response.status_code == 201, response.text
|
||||
assert email_backend.reply_to[-1] 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
|
||||
|
||||
@@ -44,7 +44,7 @@ async def _make_user(session: AsyncSession, *, name: str = "Chat Tester") -> Use
|
||||
user = User(
|
||||
email=f"{uuid.uuid4()}@example.com",
|
||||
name_user=name,
|
||||
password_hash=hash_password("password123"),
|
||||
password_hash=await hash_password("password123"),
|
||||
email_verified=True,
|
||||
)
|
||||
session.add(user)
|
||||
|
||||
@@ -19,7 +19,7 @@ async def _make_user(session: AsyncSession) -> User:
|
||||
user = User(
|
||||
email=f"{uuid.uuid4()}@example.com",
|
||||
name_user="Invitee Tester",
|
||||
password_hash=hash_password("password123"),
|
||||
password_hash=await hash_password("password123"),
|
||||
email_verified=True,
|
||||
)
|
||||
session.add(user)
|
||||
|
||||
@@ -25,7 +25,7 @@ async def _make_user(session: AsyncSession, *, name: str = "Service Tester") ->
|
||||
user = User(
|
||||
email=f"{uuid.uuid4()}@example.com",
|
||||
name_user=name,
|
||||
password_hash=hash_password("password123"),
|
||||
password_hash=await hash_password("password123"),
|
||||
email_verified=True,
|
||||
)
|
||||
session.add(user)
|
||||
|
||||
@@ -33,7 +33,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
|
||||
user = User(
|
||||
email=f"{uuid.uuid4()}@example.com",
|
||||
name_user="Conference Tester",
|
||||
password_hash=hash_password("password123"),
|
||||
password_hash=await hash_password("password123"),
|
||||
email_verified=True,
|
||||
role=role,
|
||||
)
|
||||
@@ -67,7 +67,7 @@ async def _make_conference(
|
||||
status=status,
|
||||
is_pinned=is_pinned,
|
||||
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,
|
||||
scheduled_at=scheduled_at,
|
||||
duration_minutes=duration_minutes,
|
||||
@@ -113,6 +113,10 @@ async def test_create_instant_conference_returns_active_with_join(
|
||||
assert body["join"]["conference_id"] == body["id"]
|
||||
assert body["join"]["room_name"] == body["slug"]
|
||||
assert body["join"]["token"]
|
||||
# Рычаги нагрузки медиа — дефолты без ограничения (существующие
|
||||
# инсталляции не должны получить внезапно ухудшенное качество).
|
||||
assert body["join"]["publish_quality_cap"] == "off"
|
||||
assert body["join"]["stage_max_tiles"] == 25
|
||||
|
||||
|
||||
async def test_create_instant_conference_join_metadata_contains_owner_avatar_url(
|
||||
@@ -573,17 +577,75 @@ async def test_resolve_unknown_returns_uniform_404(client: httpx.AsyncClient) ->
|
||||
assert response.json()["detail"] == "not_found"
|
||||
|
||||
|
||||
async def test_resolve_is_rate_limited_after_10_requests_per_minute(
|
||||
client: httpx.AsyncClient,
|
||||
) -> None:
|
||||
def _ip_headers() -> dict[str, str]:
|
||||
"""Уникальный `X-Real-IP` на каждый тест.
|
||||
|
||||
Счётчики 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):
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
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, "лимит одного клиента не должен задевать другого"
|
||||
|
||||
|
||||
# --- Вход зарегистрированным пользователем ---------------------------------------
|
||||
|
||||
|
||||
@@ -783,6 +845,33 @@ async def test_guest_join_creates_guest_access_and_returns_join(
|
||||
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(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
@@ -835,21 +924,69 @@ async def test_guest_join_ended_conference_returns_410(
|
||||
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
|
||||
) -> None:
|
||||
"""Успешные гостевые входы не упираются в лимит промахов.
|
||||
|
||||
На нагрузочном тесте 31.07.2026 конференцию из семи десятков человек не
|
||||
пускало внутрь именно это ограничение — счётчик не различал легитимный
|
||||
массовый вход и перебор.
|
||||
"""
|
||||
conference = await _make_conference(db_session)
|
||||
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):
|
||||
response = await client.post(
|
||||
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(
|
||||
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
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ async def _make_user(session: AsyncSession, *, name: str = "Hand Tester") -> Use
|
||||
user = User(
|
||||
email=f"{uuid.uuid4()}@example.com",
|
||||
name_user=name,
|
||||
password_hash=hash_password("password123"),
|
||||
password_hash=await hash_password("password123"),
|
||||
email_verified=True,
|
||||
)
|
||||
session.add(user)
|
||||
|
||||
@@ -71,6 +71,7 @@ _MANAGED_KEYS = (
|
||||
"registration_team_choice",
|
||||
"registration_email_domain",
|
||||
"contact_email",
|
||||
"media_limits",
|
||||
)
|
||||
|
||||
|
||||
@@ -126,6 +127,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
|
||||
"registration_team_choice",
|
||||
"registration_email_domain",
|
||||
"contact_email",
|
||||
"media_limits",
|
||||
}
|
||||
cfg = await service.get()
|
||||
assert cfg.transcriber.provider == "faster_whisper_cpu"
|
||||
@@ -137,6 +139,11 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
|
||||
assert cfg.registration_email_domains == []
|
||||
assert cfg.contact_email_enabled is False
|
||||
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
|
||||
|
||||
|
||||
async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits(
|
||||
@@ -538,6 +545,28 @@ async def test_transcription_enabled_flag_toggles_both_transcriber_and_summarize
|
||||
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(
|
||||
db_session: AsyncSession, clean_instance_settings: None
|
||||
) -> None:
|
||||
|
||||
@@ -131,7 +131,7 @@ async def _make_user(session: AsyncSession, email: str) -> User:
|
||||
user = User(
|
||||
email=email,
|
||||
name_user="Participant",
|
||||
password_hash=hash_password("password123"),
|
||||
password_hash=await hash_password("password123"),
|
||||
email_verified=True,
|
||||
)
|
||||
session.add(user)
|
||||
|
||||
@@ -53,7 +53,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
|
||||
user = User(
|
||||
email=f"{uuid.uuid4()}@example.com",
|
||||
name_user="Mute Tester",
|
||||
password_hash=hash_password("password123"),
|
||||
password_hash=await hash_password("password123"),
|
||||
email_verified=True,
|
||||
role=role,
|
||||
)
|
||||
|
||||
128
backend/tests/test_password_hashing.py
Normal file
128
backend/tests/test_password_hashing.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Проверка пароля не должна блокировать event loop (регресс после теста 31.07.2026).
|
||||
|
||||
Синхронный `verify_password` останавливал весь процесс backend на 95–155 мс.
|
||||
При массовом входе (около 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 обязан прокрутить заметное число тиков. Порог намеренно щадящий:
|
||||
# при блокировке тиков будет 0–1, а не «мало».
|
||||
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
|
||||
@@ -20,7 +20,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
|
||||
user = User(
|
||||
email=f"{uuid.uuid4()}@example.com",
|
||||
name_user="Test User",
|
||||
password_hash=hash_password("password123"),
|
||||
password_hash=await hash_password("password123"),
|
||||
role=role,
|
||||
email_verified=True,
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ async def _make_user(session: AsyncSession) -> User:
|
||||
user = User(
|
||||
email=f"{uuid.uuid4()}@example.com",
|
||||
name_user="Teams API Tester",
|
||||
password_hash=hash_password("password123"),
|
||||
password_hash=await hash_password("password123"),
|
||||
email_verified=True,
|
||||
)
|
||||
session.add(user)
|
||||
|
||||
@@ -24,7 +24,7 @@ async def _make_user(session: AsyncSession) -> User:
|
||||
user = User(
|
||||
email=f"{uuid.uuid4()}@example.com",
|
||||
name_user="List Tester",
|
||||
password_hash=hash_password("password123"),
|
||||
password_hash=await hash_password("password123"),
|
||||
email_verified=True,
|
||||
)
|
||||
session.add(user)
|
||||
@@ -98,7 +98,7 @@ async def test_get_me_with_reserved_tld_email_does_not_500(
|
||||
user = User(
|
||||
email=legacy_email,
|
||||
name_user="Legacy Admin",
|
||||
password_hash=hash_password("password123"),
|
||||
password_hash=await hash_password("password123"),
|
||||
email_verified=True,
|
||||
role="admin",
|
||||
)
|
||||
@@ -380,7 +380,7 @@ async def test_list_users_search_by_q_filters_by_name_or_email(
|
||||
match = User(
|
||||
email=f"{unique_marker}@example.com",
|
||||
name_user=f"Findable {unique_marker}",
|
||||
password_hash=hash_password("password123"),
|
||||
password_hash=await hash_password("password123"),
|
||||
email_verified=True,
|
||||
)
|
||||
db_session.add(match)
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
# затрутся при следующем рендере.
|
||||
|
||||
listening-port=3478
|
||||
# Установить 5349 в проде для TURN over TLS, если будут смонтированы
|
||||
# реальные TLS-сертификаты (см. закомментированные cert/pkey ниже).
|
||||
# TLS-порт объявлен всегда; реально слушать TLS coturn начинает только когда
|
||||
# заданы cert/pkey ниже (блок TLS-CERT) — без них строка ничего не включает.
|
||||
tls-listening-port=5349
|
||||
|
||||
# Диапазон relay-портов для TURN-аллокаций.
|
||||
@@ -31,10 +31,15 @@ fingerprint
|
||||
# Без CLI/telnet admin-интерфейса в этой поставке.
|
||||
no-cli
|
||||
|
||||
# Раскомментировать и смонтировать реальные сертификаты, чтобы включить
|
||||
# TURN over TLS на 443:
|
||||
# cert=/etc/coturn/certs/cert.pem
|
||||
# pkey=/etc/coturn/certs/key.pem
|
||||
# Блок ниже рендерится ТОЛЬКО когда в .env задан TURN_TLS_HOST (см.
|
||||
# render-templates.sh) — тогда coturn-certs-init (docker-compose.yml) уже
|
||||
# скопировал fullchain/privkey из /etc/letsencrypt в volume coturn-certs.
|
||||
# Без 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
|
||||
simple-log
|
||||
|
||||
@@ -89,7 +89,7 @@ services:
|
||||
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
|
||||
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
|
||||
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
|
||||
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.16}
|
||||
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.22}
|
||||
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
|
||||
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
|
||||
# проверьте бюджет соединений с БД: каждый воркер держит свой пул
|
||||
@@ -407,16 +407,19 @@ services:
|
||||
# 7880 (signaling) — ТОЛЬКО loopback: nginx проксирует /livekit/ по имени
|
||||
# `livekit:7880` внутри docker-сети (см. nginx.conf.template), браузеры
|
||||
# снаружи ходят через nginx/443 (wss://), прямой доступ к 7880 им не
|
||||
# нужен. 7881/tcp и UDP-диапазон ниже — реальные медиа-порты, остаются
|
||||
# нужен. 7881/tcp и UDP-порт ниже — реальные медиа-порты, остаются
|
||||
# публичными.
|
||||
ports:
|
||||
- "127.0.0.1:7880:7880" # HTTP/WebSocket signaling
|
||||
- "7881:7881" # RTC TCP fallback
|
||||
# Узкий диапазон для dev на macOS: широкий (50000-60000) почти всегда
|
||||
# конфликтует с занятыми UDP-портами хоста и тормозит Docker Desktop.
|
||||
# 54000+ выбран после конфликтов: нижние диапазоны (50000+, 52000+)
|
||||
# занимают Steam/системные процессы macOS и эфемерные QUIC-соединения.
|
||||
- "54000-54100:54000-54100/udp" # WebRTC media (ICE)
|
||||
# Один порт вместо диапазона (был 54000-54100/udp) — LiveKit
|
||||
# мультиплексирует все ICE-сессии через него (rtc.udp_port в
|
||||
# livekit.yaml.template), а не открывает по порту на участника.
|
||||
# На диапазон Docker поднимал по docker-proxy на КАЖДЫЙ порт —
|
||||
# 101 порт держали 101 лишний userland-процесс на медиапути.
|
||||
# 54000 выбран, как раньше: нижние диапазоны (50000+, 52000+) на
|
||||
# macOS заняты Steam/системными процессами и эфемерными QUIC.
|
||||
- "54000:54000/udp" # WebRTC media (ICE, мультиплекс)
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
@@ -429,6 +432,49 @@ services:
|
||||
profiles: ["media"]
|
||||
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:
|
||||
image: coturn/coturn:latest
|
||||
restart: unless-stopped
|
||||
@@ -441,6 +487,10 @@ services:
|
||||
# (deploy/render-templates.sh, вызывается install.sh).
|
||||
volumes:
|
||||
- ./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
|
||||
# Образ coturn/coturn — минимальный (debian-slim), в нём нет pgrep/ps/nc/
|
||||
# curl/wget, поэтому проверка процесса по имени не работает
|
||||
@@ -884,6 +934,11 @@ volumes:
|
||||
# Загруженные пользователями файлы (аватары) — общий том между
|
||||
# backend (запись при загрузке) и nginx (раздача статики, `location /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_data:
|
||||
# Дашборды/настройки Grafana (профиль `monitoring`) — переживают пересоздание.
|
||||
|
||||
@@ -15,16 +15,20 @@ port: 7880
|
||||
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
# Диапазон сужен для dev (см. комментарий в docker-compose.yml); в проде
|
||||
# расширить и синхронизировать с пробросом портов.
|
||||
port_range_start: 54000
|
||||
port_range_end: 54100
|
||||
# Один UDP-порт с мультиплексированием ICE вместо диапазона портов.
|
||||
# Раньше здесь был port_range_start/port_range_end (54000-54100) — под
|
||||
# каждый порт диапазона Docker поднимал отдельный процесс docker-proxy
|
||||
# (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 — режим для локальной
|
||||
# разработки (Docker Desktop): use_external_ip=true определяет публичный
|
||||
# IP через STUN, что в контейнере на macOS даёт недостижимый изнутри хоста
|
||||
# внутренний IP (172.18.x.x) — DTLS-хендшейк по data-каналам не проходит
|
||||
# ("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/домен
|
||||
# сервера> в .env) клиенты снаружи хоста подключаются по этому адресу —
|
||||
@@ -50,10 +54,24 @@ rtc:
|
||||
# оба рендерятся из одного TURN_STATIC_AUTH_SECRET (deploy/render-templates.sh).
|
||||
# Логин/пароль LiveKit генерирует сам по механизму TURN REST API.
|
||||
#
|
||||
# UDP и TCP на 3478 — оба порта уже открыты в ufw. TLS (5349) намеренно не
|
||||
# объявляем: в turnserver.conf сертификаты не смонтированы, и анонс
|
||||
# неработающего `turns:` заставил бы клиента впустую ждать таймаута,
|
||||
# прежде чем перейти к рабочему кандидату.
|
||||
# UDP и TCP на 3478 — оба порта уже открыты в ufw.
|
||||
#
|
||||
# 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:
|
||||
- host: ${TURN_EXTERNAL_IP}
|
||||
port: 3478
|
||||
@@ -65,6 +83,13 @@ rtc:
|
||||
protocol: tcp
|
||||
secret: ${TURN_STATIC_AUTH_SECRET}
|
||||
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/) — он использует
|
||||
# его как pub/sub и key-value хранилище состояния запущенных записей;
|
||||
|
||||
@@ -23,13 +23,27 @@ fi
|
||||
# значения вроде `SMTP_FROM=VidConf <no-reply@vidconf.example>`, где `<` —
|
||||
# валидный литерал для docker-compose/pydantic, но невалидный bash-синтаксис
|
||||
# (интерпретируется как редирект) при попытке `source` файла целиком.
|
||||
#
|
||||
# `|| true` в конце обязателен: под `set -e -o pipefail` (см. выше) сборка
|
||||
# `"$(env_var VAR)"` для ключа, которого в файле нет ВООБЩЕ (не просто
|
||||
# пустое значение, а отсутствующая строка) иначе завершает весь скрипт
|
||||
# ошибкой grep ДО того, как сработает дружелюбная проверка `:?` ниже —
|
||||
# найдено на TURN_TLS_HOST (новый необязательный ключ, есть не во всех
|
||||
# существующих .env).
|
||||
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_REALM="$(env_var TURN_REALM)"
|
||||
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_NODE_IP="$(env_var LIVEKIT_NODE_IP)"
|
||||
LIVEKIT_USE_EXTERNAL_IP="$(env_var LIVEKIT_USE_EXTERNAL_IP)"
|
||||
@@ -43,17 +57,31 @@ REDIS_PASSWORD="$(env_var REDIS_PASSWORD)"
|
||||
: "${LIVEKIT_USE_EXTERNAL_IP:?LIVEKIT_USE_EXTERNAL_IP не задан в .env (true/false)}"
|
||||
: "${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}' \
|
||||
< "$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
|
||||
# анонсирует клиентам внешний coturn (секция `rtc.turn_servers`), и секрет
|
||||
# обязан совпадать с `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"
|
||||
strip_tls_block_if_disabled "$SCRIPT_DIR/livekit/livekit.yaml" "# BEGIN-TLS-TURN" "# END-TLS-TURN"
|
||||
echo "[render] deploy/livekit/livekit.yaml готов"
|
||||
|
||||
envsubst '${REDIS_PASSWORD}' \
|
||||
|
||||
@@ -393,7 +393,9 @@ Email сразу считается подтверждённым (письмо
|
||||
"registration_team_choice": false,
|
||||
"registration_email_domain_enabled": false,
|
||||
"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` — эталонный домен email (нормализован: без ведущего `@`, в нижнем регистре); `null`, пока верификация не настроена
|
||||
- `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, то есть без ограничения); участники сверх лимита уходят на следующую страницу пагинации
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ ufw allow 22/tcp # SSH — сузьте до вашей сети, если
|
||||
ufw allow 80/tcp # HTTP (редирект на HTTPS + ACME-challenge)
|
||||
ufw allow 443/tcp # HTTPS
|
||||
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. Нужны ОБА пункта:
|
||||
# сигнальные порты И диапазон relay-аллокаций (min-port/max-port из
|
||||
# deploy/coturn/turnserver.conf). Без второго TURN отвечает на запросы, но
|
||||
@@ -237,7 +237,22 @@ docker compose -f deploy/docker-compose.yml --env-file .env restart nginx
|
||||
```bash
|
||||
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh << 'EOF'
|
||||
#!/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
|
||||
|
||||
# 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
|
||||
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||||
|
||||
@@ -276,8 +291,9 @@ docker compose -f deploy/docker-compose.yml --env-file .env --profile monitoring
|
||||
# 1. Все сервисы healthy
|
||||
docker compose -f deploy/docker-compose.yml --env-file .env ps
|
||||
|
||||
# 2. Наружу открыто только ожидаемое (80/443/7881 + udp 54000-54100,
|
||||
# плюс 3478 tcp+udp, если включили TURN — раздел 8)
|
||||
# 2. Наружу открыто только ожидаемое (80/443/7881 + udp 54000,
|
||||
# плюс 3478 tcp+udp и 49160:49200/udp, если включили TURN, плюс 5349/tcp,
|
||||
# если включили TURN over TLS — раздел 8)
|
||||
ss -ltnp
|
||||
|
||||
# 3. Redis требует пароль (НЕ должен пускать без него)
|
||||
@@ -318,7 +334,7 @@ Protocols`. Проверьте **гостевой вход** (`/j/<slug>` в п
|
||||
**Статус: НЕ обязателен.** Реальное кросс-сетевое тестирование (участники в
|
||||
разных сетях/на разных устройствах) прошло успешно **без** раздачи TURN
|
||||
клиентам — комбинации `LIVEKIT_USE_EXTERNAL_IP=false` + реальный
|
||||
`LIVEKIT_NODE_IP` + проброшенный UDP-диапазон `54000-54100` (шаг 1,
|
||||
`LIVEKIT_NODE_IP` + проброшенный UDP-порт `54000` (шаг 1,
|
||||
firewall) хватает для подавляющего большинства сетей. Включайте этот
|
||||
раздел только если у вас есть конкретные пользователи за CGNAT или
|
||||
жёстким корпоративным firewall, которые не могут установить медиа-соединение
|
||||
@@ -359,14 +375,89 @@ state: connected`, но собеседник не видит видео/не с
|
||||
Ноль при живом звонке из-за NAT означает, что до coturn не дошли —
|
||||
смотрите ufw и `TURN_EXTERNAL_IP`.
|
||||
|
||||
**TURN over TLS (порт 5349 или 443) — не настроен.** Это самый надёжный
|
||||
фолбэк (проходит там, где режут UDP и нестандартные порты), но требует
|
||||
смонтировать в coturn TLS-сертификат: раскомментировать `cert`/`pkey` в
|
||||
`deploy/coturn/turnserver.conf.template`, добавить volume с
|
||||
`/etc/letsencrypt` (nginx его уже монтирует, coturn — нет), открыть порт и
|
||||
не забыть про перезапуск coturn при обновлении сертификата. Пока этого нет,
|
||||
`turns:` намеренно не анонсируется: анонс неработающего адреса заставил бы
|
||||
клиента ждать таймаута перед переходом к рабочему кандидату.
|
||||
### TURN over TLS (порт 5349)
|
||||
|
||||
Самый надёжный фолбэк: в жёстких корпоративных сетях наружу часто разрешён
|
||||
только `443/tcp`, и TLS-соединение на нестандартный порт (5349) выглядит для
|
||||
firewall как обычный HTTPS. UDP/TCP на 3478 такие сети режут целиком.
|
||||
|
||||
**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 продолжает работать для остальных клиентов.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -179,12 +179,15 @@ dev-стека.
|
||||
профилей битрейта заводить не требуется; при необходимости ограничить
|
||||
верхнюю границу — `videoEncoding`/`simulcastLayers` на фронтенде
|
||||
(клиентский SDK, вне скоупа devops-части).
|
||||
4. **UDP-диапазон 54000-54100 (101 порт)** не был узким местом ни на одной
|
||||
ступени (максимум 60 участников в тесте) — при планировании прод-узла с
|
||||
ожидаемым бОльшим числом одновременных участников across все комнаты
|
||||
узла держать `port_range_end - port_range_start` заметно больше пикового
|
||||
числа участников на узле (LiveKit резервирует пару портов на участника
|
||||
на медиа-транспорт).
|
||||
4. **UDP-диапазон 54000-54100 (101 порт)**, на котором проводился этот тест,
|
||||
не был узким местом ни на одной ступени (максимум 60 участников). Тогда
|
||||
же с ним была цена: под каждый порт диапазона Docker держал отдельный
|
||||
процесс `docker-proxy` — 101 порт-101 процесс на медиапути, весь трафик
|
||||
шёл лишним userland-хопом. С переходом на `rtc.udp_port` (один порт,
|
||||
мультиплексирование по ICE ufrag внутри LiveKit) рекомендация «держать
|
||||
диапазон шире пикового числа участников» больше не актуальна — портов
|
||||
для планирования ёмкости не остаётся вовсе, LiveKit разводит участников
|
||||
поверх одного сокета сам.
|
||||
5. **STUN/TURN-находка (см. «Методика») —** рекомендуется отдельной задачей
|
||||
зарегистрировать `deploy/coturn/` в `rtc.turn_servers` LiveKit и на
|
||||
проде, а не только для теста — иначе клиенты в вырожденном случае
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* конверте пагинации `items`/`total`.
|
||||
*/
|
||||
import { apiRequest } from '@/api/client'
|
||||
import type { ConferenceRecurrence, ConferenceStatus, SummaryRecipientsMode } from '@/api/conferences'
|
||||
import type { ConferenceRecurrence, ConferenceStatus, PublishQualityCap, SummaryRecipientsMode } from '@/api/conferences'
|
||||
|
||||
/** Уровень качества AI-обработки (транскрибация + суммаризация). */
|
||||
export type AiLevel = 'min' | 'medium' | 'max'
|
||||
@@ -39,6 +39,10 @@ export interface SettingsOut {
|
||||
contact_email_enabled: boolean
|
||||
/** Контактный адрес — `null`, если не задан/выключен. */
|
||||
contact_email: string | null
|
||||
/** Потолок качества исходящего видео публикующего — см. `PublishQualityCap`. */
|
||||
publish_quality_cap: PublishQualityCap
|
||||
/** Максимум одновременно видимых плиток сцены (`StageGrid`). */
|
||||
stage_max_tiles: number
|
||||
}
|
||||
|
||||
/** Тело частичного обновления настроек инстанса — все поля опциональны. */
|
||||
@@ -56,6 +60,8 @@ export interface SettingsUpdateIn {
|
||||
/** Включение без email или невалидный email — backend отвечает 400. */
|
||||
contact_email_enabled?: boolean
|
||||
contact_email?: string | null
|
||||
publish_quality_cap?: PublishQualityCap
|
||||
stage_max_tiles?: number
|
||||
}
|
||||
|
||||
/** Тело запроса тестовой отправки письма (`POST /admin/settings/test-email`). */
|
||||
|
||||
@@ -19,6 +19,15 @@ export type RecurrenceType = 'weekly' | 'biweekly' | 'monthly' | 'every_n_days'
|
||||
*/
|
||||
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-моделью
|
||||
* `backend/services/recurrence.py::RecurrenceRule` (истина о форме — там).
|
||||
@@ -52,6 +61,10 @@ export interface ConferenceJoinData {
|
||||
conference_id: string
|
||||
/** Включён ли чат для этой конференции — при `false` панель/кнопка чата не рендерятся. */
|
||||
chat_enabled: boolean
|
||||
/** Потолок качества публикации видео на момент входа — см. `PublishQualityCap`. */
|
||||
publish_quality_cap: PublishQualityCap
|
||||
/** Максимум одновременно видимых плиток сцены (`StageGrid`) на момент входа. */
|
||||
stage_max_tiles: number
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type SettingsUpdateIn,
|
||||
type TestEmailOut,
|
||||
} from '@/api/admin'
|
||||
import type { SummaryRecipientsMode } from '@/api/conferences'
|
||||
import type { PublishQualityCap, SummaryRecipientsMode } from '@/api/conferences'
|
||||
import { ApiError, errorDetail } from '@/api/client'
|
||||
import { useAuth } from '@/auth/useAuth'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
@@ -21,6 +21,20 @@ const SUMMARY_RECIPIENTS_OPTIONS = [
|
||||
{ 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> = {
|
||||
min: 'Минимальный (CPU, faster-whisper small + Qwen2.5-3B)',
|
||||
medium: 'Средний',
|
||||
@@ -64,6 +78,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
||||
const [newDomainInput, setNewDomainInput] = useState('')
|
||||
const [contactEmailEnabled, setContactEmailEnabled] = useState(data.contact_email_enabled)
|
||||
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 [testEmailTo, setTestEmailTo] = useState('')
|
||||
const [testEmailResult, setTestEmailResult] = useState<TestEmailOut | null>(null)
|
||||
|
||||
@@ -136,6 +152,8 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
||||
if (trimmedContactEmail !== (data.contact_email ?? null)) {
|
||||
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
|
||||
mutation.mutate(payload)
|
||||
}
|
||||
|
||||
@@ -361,6 +379,51 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
|
||||
</div>
|
||||
</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">
|
||||
<h2>Контактный адрес</h2>
|
||||
<p className="desc">
|
||||
|
||||
@@ -6,13 +6,15 @@ import { useModalDismiss } from '@/hooks/useModalDismiss'
|
||||
|
||||
/**
|
||||
* Небольшой собственный набор популярных эмодзи — вместо библиотеки-пикера на
|
||||
* сотни килобайт ради десятка кнопок в поповере.
|
||||
* сотни килобайт ради десятка кнопок в поповере. Ровно 30 штук (6×5 —
|
||||
* `.chat-emoji-popover` в room.css рассчитан на эту сетку без остатка;
|
||||
* добавляя/убирая эмодзи, держи кратность 5).
|
||||
*/
|
||||
const EMOJI_OPTIONS = [
|
||||
'😀', '😂', '😊', '😉', '😍', '🤔', '😅', '😢',
|
||||
'😮', '😎', '🙌', '👍', '👎', '👏', '🙏', '❤️',
|
||||
'🔥', '🎉', '✅', '❌', '⚠️', '💡', '👀', '🤝',
|
||||
'🐎',
|
||||
'🐎', '🦾', '🚀', '🦞', '💯', '🤷♂️',
|
||||
]
|
||||
|
||||
interface ChatPanelProps {
|
||||
|
||||
99
frontend/src/components/room/HandQueueMenu.tsx
Normal file
99
frontend/src/components/room/HandQueueMenu.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Hand, ListOrdered } from 'lucide-react'
|
||||
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
|
||||
import type { HandQueueEntry } from '@/hooks/useChat'
|
||||
|
||||
interface HandQueueMenuProps {
|
||||
queue: HandQueueEntry[]
|
||||
onLower: (identity: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Кнопка «Очередь» в тулбаре с поповером над ней — видна только организатору
|
||||
* (задача B1). Тот же самодостаточный паттерн, что и `StageViewMenu` («Вид»):
|
||||
* собственное состояние открытия, закрытие по клику вне/Escape, поповер
|
||||
* `.tb-menu` над кнопкой — а не боковая панель на весь экран (как чат):
|
||||
* очередь рук — короткий список, а не история переписки, разворачивать её
|
||||
* во весь экран незачем и на мобильном.
|
||||
*
|
||||
* Размер поповера подстраивается под число записей — `.hand-queue-list`
|
||||
* растёт вместе со списком и не даёт пустого места при 1–2 поднятых руках,
|
||||
* но не бесконечно: после ~10 строк список упирается в `max-height` и дальше
|
||||
* скроллится (см. room.css) — иначе организатор на энергичной встрече
|
||||
* получил бы поповер выше экрана.
|
||||
*/
|
||||
export function HandQueueMenu({ queue, onLower }: HandQueueMenuProps) {
|
||||
const isOrganizer = useIsOrganizer()
|
||||
const [open, setOpen] = useState(false)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') setOpen(false)
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handlePointerDown)
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (!isOrganizer) return null
|
||||
|
||||
return (
|
||||
<div className="tb-menu-wrap" ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`tb-btn${open ? ' is-panel-open' : ''}`}
|
||||
aria-pressed={open}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
aria-label={open ? 'Свернуть очередь поднятых рук' : 'Открыть очередь поднятых рук'}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<span className="icon-shell">
|
||||
<ListOrdered className="lucide" aria-hidden="true" />
|
||||
{queue.length > 0 && (
|
||||
<span className="badge-count">{queue.length > 9 ? '9+' : queue.length}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="label">Очередь</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="tb-menu hand-queue-menu" role="dialog" aria-label="Очередь поднятых рук">
|
||||
{queue.length === 0 ? (
|
||||
<p className="chat-empty">Пока никто не поднял руку</p>
|
||||
) : (
|
||||
<ol className="hand-queue-list">
|
||||
{queue.map((entry, index) => (
|
||||
<li className="hand-queue-item" key={entry.identity}>
|
||||
<span className="hand-queue-position">{index + 1}</span>
|
||||
<span className="hand-queue-name">
|
||||
<Hand className="lucide" aria-hidden="true" />
|
||||
{entry.name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="hand-queue-lower"
|
||||
onClick={() => onLower(entry.identity)}
|
||||
>
|
||||
Опустить
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Hand, X } from 'lucide-react'
|
||||
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
|
||||
import type { HandQueueEntry } from '@/hooks/useChat'
|
||||
|
||||
interface HandQueuePanelProps {
|
||||
queue: HandQueueEntry[]
|
||||
onLower: (identity: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Панель очереди поднятых рук — видна только организатору (задача B1).
|
||||
* Визуально — тот же боковой контейнер, что и `ChatPanel` (`.chat-panel`,
|
||||
* включая мобильное поведение «во весь экран» на ≤900px), содержимое своё:
|
||||
* упорядоченный список с позицией и кнопкой «Опустить» на каждой строке —
|
||||
* организатору разрешено опускать чужую руку (решение оператора, задача B1).
|
||||
*
|
||||
* `RoomPage` гейтит рендер по `handQueueOpen` (как и `ChatPanel` по
|
||||
* `chatOpen`) — свой `useIsOrganizer()` здесь ДОПОЛНИТЕЛЬНАЯ, а не
|
||||
* единственная защита: `RoomToolbar` уже не показывает кнопку открытия
|
||||
* не-организатору, это подстраховка на случай прямого рендера.
|
||||
*/
|
||||
export function HandQueuePanel({ queue, onLower, onClose }: HandQueuePanelProps) {
|
||||
const isOrganizer = useIsOrganizer()
|
||||
if (!isOrganizer) return null
|
||||
|
||||
return (
|
||||
<aside className="chat-panel hand-queue-panel">
|
||||
<div className="chat-head">
|
||||
<h2>Поднятые руки</h2>
|
||||
<button type="button" aria-label="Закрыть очередь" onClick={onClose}>
|
||||
<X className="lucide" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{queue.length === 0 ? (
|
||||
<p className="chat-empty">Пока никто не поднял руку</p>
|
||||
) : (
|
||||
<ol className="hand-queue-list">
|
||||
{queue.map((entry, index) => (
|
||||
<li className="hand-queue-item" key={entry.identity}>
|
||||
<span className="hand-queue-position">{index + 1}</span>
|
||||
<span className="hand-queue-name">
|
||||
<Hand className="lucide" aria-hidden="true" />
|
||||
{entry.name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="hand-queue-lower"
|
||||
onClick={() => onLower(entry.identity)}
|
||||
>
|
||||
Опустить
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -206,6 +206,7 @@ export function RoomStage({
|
||||
onPinFocus,
|
||||
raisedHandIdentities,
|
||||
conferenceId,
|
||||
stageMaxTiles,
|
||||
}: {
|
||||
variant?: 'full' | 'pip'
|
||||
/** Выбранный пользователем режим показа; игнорируется при `variant="pip"`. */
|
||||
@@ -232,6 +233,8 @@ export function RoomStage({
|
||||
raisedHandIdentities?: Set<string>
|
||||
/** Id конференции (задача B2) — кнопки принудительного мьюта на чужих плитках; игнорируется при `variant="pip"`. */
|
||||
conferenceId?: string
|
||||
/** Потолок админки на число плиток `StageGrid` (`instance_settings.media_limits`); игнорируется при `variant="pip"`. */
|
||||
stageMaxTiles?: number
|
||||
}) {
|
||||
const room = useRoomContext()
|
||||
const isCompact = useIsCompactViewport()
|
||||
@@ -429,14 +432,14 @@ export function RoomStage({
|
||||
function renderMain(): ReactNode {
|
||||
if (effectiveMode === 'tiles') {
|
||||
return (
|
||||
<StageGrid tracks={tracks}>
|
||||
<StageGrid tracks={tracks} maxTiles={stageMaxTiles}>
|
||||
<RoomParticipantTile {...tileProps} />
|
||||
</StageGrid>
|
||||
)
|
||||
}
|
||||
if (effectiveMode === 'live-tiles') {
|
||||
return (
|
||||
<StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks}>
|
||||
<StageGrid tracks={liveCameraTracks.length > 0 ? liveCameraTracks : cameraTracks} maxTiles={stageMaxTiles}>
|
||||
<RoomParticipantTile {...tileProps} />
|
||||
</StageGrid>
|
||||
)
|
||||
@@ -445,7 +448,7 @@ export function RoomStage({
|
||||
// прежнее поведение: равномерная сетка на всю сцену, а не фокус-плитка.
|
||||
if (sideTracks.length === 0 && !hideOthers) {
|
||||
return (
|
||||
<StageGrid tracks={tracks}>
|
||||
<StageGrid tracks={tracks} maxTiles={stageMaxTiles}>
|
||||
<RoomParticipantTile {...tileProps} />
|
||||
</StageGrid>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
Hand,
|
||||
ListOrdered,
|
||||
LogOut,
|
||||
Maximize,
|
||||
MessageSquare,
|
||||
@@ -18,9 +17,9 @@ import { Track, type ScreenShareCaptureOptions } from 'livekit-client'
|
||||
import { DisconnectButton, useLocalParticipant, useTrackToggle } from '@livekit/components-react'
|
||||
import { useToast } from '@/components/ui/ToastProvider'
|
||||
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
|
||||
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
|
||||
import type { HandQueueEntry } from '@/hooks/useChat'
|
||||
import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions'
|
||||
import { HandQueueMenu } from '@/components/room/HandQueueMenu'
|
||||
|
||||
/**
|
||||
* Опции захвата демонстрации экрана: `audio: true` — звук
|
||||
@@ -67,8 +66,8 @@ interface RoomToolbarProps extends StageViewProps {
|
||||
handQueue: HandQueueEntry[]
|
||||
onRaiseHand: () => void
|
||||
onLowerHand: () => void
|
||||
handQueueOpen: boolean
|
||||
onToggleHandQueue: () => void
|
||||
/** Опустить ЧУЖУЮ руку по identity — только организатору (панель очереди, `HandQueueMenu`). */
|
||||
onLowerHandById: (identity: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,8 +103,7 @@ export function RoomToolbar({
|
||||
handQueue,
|
||||
onRaiseHand,
|
||||
onLowerHand,
|
||||
handQueueOpen,
|
||||
onToggleHandQueue,
|
||||
onLowerHandById,
|
||||
layoutMode,
|
||||
onLayoutModeChange,
|
||||
hideOthers,
|
||||
@@ -113,7 +111,6 @@ export function RoomToolbar({
|
||||
}: RoomToolbarProps) {
|
||||
const toast = useToast()
|
||||
const isCompact = useIsCompactViewport()
|
||||
const isOrganizer = useIsOrganizer()
|
||||
const { localParticipant } = useLocalParticipant()
|
||||
const handRaised = handQueue.some((entry) => entry.identity === localParticipant.identity)
|
||||
const mic = useTrackToggle({ source: Track.Source.Microphone })
|
||||
@@ -197,23 +194,7 @@ export function RoomToolbar({
|
||||
<span className="label">Рука</span>
|
||||
</button>
|
||||
|
||||
{isOrganizer && (
|
||||
<button
|
||||
type="button"
|
||||
className={`tb-btn${handQueueOpen ? ' is-panel-open' : ''}`}
|
||||
aria-pressed={handQueueOpen}
|
||||
aria-label={handQueueOpen ? 'Свернуть очередь поднятых рук' : 'Открыть очередь поднятых рук'}
|
||||
onClick={onToggleHandQueue}
|
||||
>
|
||||
<span className="icon-shell">
|
||||
<ListOrdered className="lucide" aria-hidden="true" />
|
||||
{handQueue.length > 0 && (
|
||||
<span className="badge-count">{handQueue.length > 9 ? '9+' : handQueue.length}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="label">Очередь</span>
|
||||
</button>
|
||||
)}
|
||||
<HandQueueMenu queue={handQueue} onLower={onLowerHandById} />
|
||||
|
||||
{!isCompact && (
|
||||
<StageViewMenu
|
||||
@@ -266,7 +247,14 @@ export function RoomToolbar({
|
||||
<span className="icon-shell">
|
||||
<PictureInPicture2 className="lucide" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="label">Мини-окно</span>
|
||||
{/* На узком экране («Мини-окно» иначе переносится на 2 строки и
|
||||
кнопка становится выше соседних, см. .label-full/.label-short
|
||||
в room.css) — только «Мини». Текст, не структура: aria-label
|
||||
выше уже несёт полный смысл независимо от видимой подписи. */}
|
||||
<span className="label">
|
||||
<span className="label-full">Мини-окно</span>
|
||||
<span className="label-short">Мини</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, type ReactNode, type RefObject } from 'react'
|
||||
import { useMemo, useRef, type ReactNode, type RefObject } from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import {
|
||||
TrackLoop,
|
||||
@@ -47,6 +47,17 @@ interface StageGridProps {
|
||||
tracks: TrackReferenceOrPlaceholder[]
|
||||
/** Шаблон плитки — рендерится для каждого трека страницы (как у `GridLayout`, через `TrackLoop`). */
|
||||
children: ReactNode
|
||||
/**
|
||||
* Потолок админки на число одновременно видимых плиток (`instance_settings.media_limits.stage_max_tiles`,
|
||||
* см. `RoomPage`). Не задан — все раскладки `STAGE_GRID_LAYOUTS` доступны как
|
||||
* раньше (текущий максимум сетки — 25, 5×5). Реализовано отсечением раскладок
|
||||
* КРУПНЕЕ потолка из набора, который видит `useGridLayout`: она сама выбирает
|
||||
* бОльшую свободную раскладку, укладывающую всех участников без пагинации,
|
||||
* поэтому урезанный набор просто не даёт ей раздуть сетку сверх лимита —
|
||||
* лишние участники уходят на следующую страницу пагинации (`usePagination`),
|
||||
* то есть перестают быть подписанными треками, а не просто визуально мельче.
|
||||
*/
|
||||
maxTiles?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,7 +75,7 @@ interface StageGridProps {
|
||||
* `.stage-grid-pages` (кнопки со стрелками + счётчик, доступен и мышью, и с
|
||||
* клавиатуры; на тач-экране страницы листаются ещё и свайпом).
|
||||
*/
|
||||
export function StageGrid({ tracks, children }: StageGridProps) {
|
||||
export function StageGrid({ tracks, children, maxTiles }: StageGridProps) {
|
||||
const gridEl = useRef<HTMLDivElement | null>(null)
|
||||
// Хуки библиотеки объявлены с `RefObject<HTMLDivElement>` (типы React 18, где
|
||||
// `current` был readonly и тип вёл себя ковариантно). В типах React 19
|
||||
@@ -72,7 +83,11 @@ export function StageGrid({ tracks, children }: StageGridProps) {
|
||||
// параметр уже не присваивается — приведение безопасно: оба хука только
|
||||
// читают `.current` (ResizeObserver и слушатели touch-событий).
|
||||
const gridRef = gridEl as RefObject<HTMLDivElement>
|
||||
const { layout } = useGridLayout(gridRef, tracks.length, { gridLayouts: STAGE_GRID_LAYOUTS })
|
||||
const gridLayouts = useMemo(
|
||||
() => (maxTiles == null ? STAGE_GRID_LAYOUTS : STAGE_GRID_LAYOUTS.filter((l) => l.columns * l.rows <= maxTiles)),
|
||||
[maxTiles],
|
||||
)
|
||||
const { layout } = useGridLayout(gridRef, tracks.length, { gridLayouts })
|
||||
const pagination = usePagination(layout.maxTiles, tracks)
|
||||
|
||||
useSwipe(gridRef, {
|
||||
|
||||
36
frontend/src/lib/publishQualityCap.ts
Normal file
36
frontend/src/lib/publishQualityCap.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { VideoPresets, type TrackPublishDefaults } from 'livekit-client'
|
||||
import type { PublishQualityCap } from '@/api/conferences'
|
||||
|
||||
/**
|
||||
* Потолок качества публикации → `TrackPublishDefaults` для `RoomOptions.publishDefaults`
|
||||
* (см. `RoomPage.tsx`, `roomOptions`).
|
||||
*
|
||||
* Ограничивается только `videoEncoding` (битрейт/framerate верхнего слоя
|
||||
* симулкаста) и набор дополнительных слоёв `videoSimulcastLayers` — НЕ
|
||||
* фактическое разрешение захвата камеры (`videoCaptureDefaults`, трогать
|
||||
* его не входит в задачу). WebRTC сам подстраивает реальное разрешение
|
||||
* кодирования под урезанный битрейт (`degradationPreference`), поэтому
|
||||
* проверять эффект нужно по фактическому битрейту исходящего видео, а не по
|
||||
* заявленному разрешению потока.
|
||||
*
|
||||
* Слои каждого потолка — все пресеты LiveKit НИЖЕ и РАВНО потолку (без
|
||||
* дефолтного «h180, h360», который иначе подставился бы сам при пустом
|
||||
* `videoSimulcastLayers` и мог бы превысить потолок 180p).
|
||||
*/
|
||||
const PUBLISH_DEFAULTS_BY_CAP: Record<Exclude<PublishQualityCap, 'off'>, TrackPublishDefaults> = {
|
||||
'180p': { videoEncoding: VideoPresets.h180.encoding, videoSimulcastLayers: [] },
|
||||
'360p': { videoEncoding: VideoPresets.h360.encoding, videoSimulcastLayers: [VideoPresets.h180] },
|
||||
'720p': {
|
||||
videoEncoding: VideoPresets.h720.encoding,
|
||||
videoSimulcastLayers: [VideoPresets.h180, VideoPresets.h360],
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* `off` — `undefined`: `publishDefaults` не задаётся вовсе, поведение
|
||||
* библиотеки не отличается от состояния до появления настройки (см.
|
||||
* критерий готовности «дефолты сохраняют текущее поведение»).
|
||||
*/
|
||||
export function buildPublishDefaults(cap: PublishQualityCap): TrackPublishDefaults | undefined {
|
||||
return cap === 'off' ? undefined : PUBLISH_DEFAULTS_BY_CAP[cap]
|
||||
}
|
||||
@@ -146,6 +146,8 @@ export function JoinPage() {
|
||||
title: resolved.title,
|
||||
conferenceId: data.conference_id,
|
||||
chatEnabled: data.chat_enabled,
|
||||
publishQualityCap: data.publish_quality_cap,
|
||||
stageMaxTiles: data.stage_max_tiles,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
|
||||
@@ -43,6 +43,8 @@ export function LobbyPage() {
|
||||
title: conference.title,
|
||||
conferenceId: conference.join.conference_id,
|
||||
chatEnabled: conference.join.chat_enabled,
|
||||
publishQualityCap: conference.join.publish_quality_cap,
|
||||
stageMaxTiles: conference.join.stage_max_tiles,
|
||||
number: conference.number,
|
||||
slug: conference.slug,
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import { LiveKitRoom, usePersistentUserChoices } from '@livekit/components-react
|
||||
import type { RoomOptions } from 'livekit-client'
|
||||
import '@livekit/components-styles'
|
||||
import '@/styles/room.css'
|
||||
import { joinConference, resolveConference } from '@/api/conferences'
|
||||
import { joinConference, resolveConference, type PublishQualityCap } from '@/api/conferences'
|
||||
import { ApiError, errorDetail } from '@/api/client'
|
||||
import { useAuth } from '@/auth/useAuth'
|
||||
import { useChat } from '@/hooks/useChat'
|
||||
@@ -16,10 +16,10 @@ import { RoomTopbar } from '@/components/room/RoomTopbar'
|
||||
import { RoomStage } from '@/components/room/RoomStage'
|
||||
import { RoomToolbar } from '@/components/room/RoomToolbar'
|
||||
import { ChatPanel } from '@/components/room/ChatPanel'
|
||||
import { HandQueuePanel } from '@/components/room/HandQueuePanel'
|
||||
import { ForcedMuteWatcher } from '@/components/room/ForcedMuteWatcher'
|
||||
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
|
||||
import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
|
||||
import { buildPublishDefaults } from '@/lib/publishQualityCap'
|
||||
import { loadStageLayoutMode, saveStageLayoutMode, type StageLayoutMode } from '@/lib/stageLayoutMode'
|
||||
|
||||
interface RoomJoinState {
|
||||
@@ -32,6 +32,18 @@ interface RoomJoinState {
|
||||
number?: string
|
||||
/** `JoinOut.chat_enabled` — при `false` кнопка чата и панель не рендерятся. */
|
||||
chatEnabled?: boolean
|
||||
/**
|
||||
* Рычаги нагрузки медиа (`JoinOut.publish_quality_cap`/`stage_max_tiles`,
|
||||
* `instance_settings.media_limits`) — приезжают вместе с токеном, ДО
|
||||
* первого рендера `LiveKitRoom` (см. `roomOptions` ниже и докстринг про
|
||||
* стабильность его ссылки): `joinState` целиком появляется одним актом
|
||||
* (`setJoinState`), а до этого момента `LiveKitRoom` не рендерится вовсе
|
||||
* (ранний `return` на «Подключаемся…» ниже) — значит, оба значения уже
|
||||
* на руках к моменту публикации трека, без отдельного асинхронного
|
||||
* похода за настройками после подключения и без риска переподключения.
|
||||
*/
|
||||
publishQualityCap?: PublishQualityCap
|
||||
stageMaxTiles?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,6 +110,8 @@ export function RoomPage() {
|
||||
title: info.title,
|
||||
conferenceId: result.conference_id,
|
||||
chatEnabled: result.chat_enabled,
|
||||
publishQualityCap: result.publish_quality_cap,
|
||||
stageMaxTiles: result.stage_max_tiles,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -147,11 +161,6 @@ export function RoomPage() {
|
||||
// изначально JoinOut.chat_enabled был true (рассинхрон с админкой в моменте).
|
||||
const chatVisible = Boolean(joinState?.chatEnabled) && !chat.unavailable
|
||||
|
||||
// Панель очереди поднятых рук — открыта/закрыта организатором (задача B1).
|
||||
// Саму видимость кнопки/панели решает `useIsOrganizer()` внутри
|
||||
// `RoomToolbar`/`HandQueuePanel` (эти компоненты — дети `LiveKitRoom`, а
|
||||
// `RoomPage` — нет, `useLocalParticipant` здесь не вызвать).
|
||||
const [handQueueOpen, setHandQueueOpen] = useState(false)
|
||||
// Identity участников с поднятой рукой — множеством, для дешёвого `.has()`
|
||||
// на каждой плитке сцены (см. `RoomParticipantTile`).
|
||||
const raisedHandIdentities = useMemo(
|
||||
@@ -208,9 +217,16 @@ export function RoomPage() {
|
||||
// потому что `userChoices` ЭТОГО вызова хука меняется, только если МЫ САМИ
|
||||
// вызовем saveAudioInputDeviceId/saveVideoInputDeviceId НА НЁМ — а мы этого
|
||||
// не делаем (сохранение — только в DeviceSettingsDialog).
|
||||
//
|
||||
// `joinState?.publishQualityCap` в зависимостях безопасен по той же
|
||||
// причине: `joinState` выставляется РОВНО ОДИН раз (см. докстринг
|
||||
// `RoomJoinState.publishQualityCap`) до первого рендера `LiveKitRoom`, а
|
||||
// не меняется постфактум — значит, `roomOptions` не пересоздастся у уже
|
||||
// подключённого участника.
|
||||
const { userChoices } = usePersistentUserChoices()
|
||||
const roomOptions = useMemo<RoomOptions>(
|
||||
() => ({
|
||||
publishDefaults: buildPublishDefaults(joinState?.publishQualityCap ?? 'off'),
|
||||
// Оба флага в LiveKit по умолчанию выключены, и без них каждый клиент
|
||||
// подписан на полное качество всех чужих треков независимо от размера
|
||||
// плитки, а каждый паблишер шлёт все слои симулкаста, даже если их никто
|
||||
@@ -236,7 +252,7 @@ export function RoomPage() {
|
||||
// (setActiveMediaDevice), а не пересозданием roomOptions.
|
||||
audioOutput: { deviceId: loadAudioOutputDeviceId() || undefined },
|
||||
}),
|
||||
[userChoices],
|
||||
[userChoices, joinState?.publishQualityCap],
|
||||
)
|
||||
|
||||
if (error) {
|
||||
@@ -293,6 +309,7 @@ export function RoomPage() {
|
||||
onPinFocus={handlePinFocus}
|
||||
raisedHandIdentities={raisedHandIdentities}
|
||||
conferenceId={joinState.conferenceId}
|
||||
stageMaxTiles={joinState.stageMaxTiles}
|
||||
/>
|
||||
)}
|
||||
{chatVisible && chatOpen && (
|
||||
@@ -304,13 +321,6 @@ export function RoomPage() {
|
||||
onClose={() => setChatOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{handQueueOpen && (
|
||||
<HandQueuePanel
|
||||
queue={chat.handQueue}
|
||||
onLower={(identity) => chat.lowerHand(identity)}
|
||||
onClose={() => setHandQueueOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<RoomToolbar
|
||||
chatVisible={chatVisible}
|
||||
@@ -327,8 +337,7 @@ export function RoomPage() {
|
||||
handQueue={chat.handQueue}
|
||||
onRaiseHand={chat.raiseHand}
|
||||
onLowerHand={() => chat.lowerHand()}
|
||||
handQueueOpen={handQueueOpen}
|
||||
onToggleHandQueue={() => setHandQueueOpen((open) => !open)}
|
||||
onLowerHandById={(identity) => chat.lowerHand(identity)}
|
||||
layoutMode={layoutMode}
|
||||
onLayoutModeChange={handleLayoutModeChange}
|
||||
hideOthers={hideOthers}
|
||||
|
||||
@@ -277,32 +277,54 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
||||
.stage-show-others svg { width: 18px; height: 18px; flex-shrink: 0; }
|
||||
|
||||
/* Нижний тулбар: свои кнопки на хуках LiveKit (TrackToggle/DisconnectButton) */
|
||||
/*
|
||||
* Кнопки тулбара плавно уменьшаются (иконка/отступы/шрифт/зазор) на всём
|
||||
* диапазоне 1200px → 600px — до этого тулбар стал шире, чем при исходном
|
||||
* проектировании (задачи B1/B2 добавили «Рука»/«Очередь», раньше помещались
|
||||
* без сжатия 8 кнопок, теперь до 11 — без этого блока получался
|
||||
* горизонтальный оверфлоу вплоть до самого мобильного брейкпоинта, кнопки
|
||||
* вылезали за края тулбара).
|
||||
*
|
||||
* Обычный `clamp(min, Nvw, max)` тут не подходит: подобранный `N`
|
||||
* дотягивается до `max` уже на довольно узких экранах (например,
|
||||
* `3vw` = 24px ровно на 800px viewport) и дальше просто стоит на потолке —
|
||||
* получается не плавное сжатие в нужном диапазоне, а резкий скачок сильно
|
||||
* раньше нужной ширины (поймали именно так на первой версии этого блока).
|
||||
* Вместо этого — явная линейная интерполяция между двумя точками
|
||||
* (600px→минимум, 1200px→максимум): `calc(MIN + (MAX-MIN) * (100vw - 600px)
|
||||
* / 600)`, снаружи в `clamp()` только чтобы намертво остановиться на
|
||||
* границах диапазона. Нижние границы — те же значения, что жёстко
|
||||
* выставляет мобильный медиа-запрос ниже (`max-width: 600px`), поэтому
|
||||
* переход в него на 600px визуально бесшовный.
|
||||
*/
|
||||
.room-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
gap: clamp(2px, calc(2px + (100vw - 600px) * 6 / 600), var(--space-2));
|
||||
background: var(--color-room-surface);
|
||||
border-top: 1px solid var(--color-room-tile-border);
|
||||
padding: var(--space-3) var(--space-6);
|
||||
padding: var(--space-3) clamp(8px, calc(8px + (100vw - 600px) * 16 / 600), var(--space-6));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tb-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: clamp(2px, calc(2px + (100vw - 600px) * 2 / 600), 4px);
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 8px 18px;
|
||||
padding:
|
||||
clamp(6px, calc(6px + (100vw - 600px) * 2 / 600), 8px)
|
||||
clamp(6px, calc(6px + (100vw - 600px) * 12 / 600), 18px);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-room-text-primary);
|
||||
min-width: 76px;
|
||||
min-width: clamp(0px, calc((100vw - 600px) * 76 / 600), 76px);
|
||||
cursor: pointer;
|
||||
}
|
||||
.tb-btn .icon-shell {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
width: clamp(40px, calc(40px + (100vw - 600px) * 8 / 600), 48px);
|
||||
height: clamp(40px, calc(40px + (100vw - 600px) * 8 / 600), 48px);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -311,9 +333,27 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
||||
background: var(--color-room-tile);
|
||||
color: var(--color-room-text-primary);
|
||||
}
|
||||
.tb-btn span.label { font: var(--text-caption); text-transform: none; letter-spacing: normal; color: var(--color-room-text-secondary); font-weight: 500; }
|
||||
.tb-btn span.label {
|
||||
font: var(--text-caption);
|
||||
font-size: clamp(11px, calc(11px + (100vw - 600px) * 1 / 600), 12px);
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
color: var(--color-room-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.tb-btn:hover .icon-shell { background: var(--color-room-tile-hover); }
|
||||
|
||||
/* Короткая подпись мини-окна (см. RoomToolbar.tsx) — «Мини-окно» на узком
|
||||
экране переносится на 2 строки и делает эту кнопку выше соседних; ниже
|
||||
порога, где начинается перенос, прячем длинный вариант и показываем
|
||||
короткий «Мини» — кнопка остаётся однострочной и той же высоты, что и
|
||||
остальные. */
|
||||
.tb-btn .label-short { display: none; }
|
||||
@media (max-width: 1200px) {
|
||||
.tb-btn .label-full { display: none; }
|
||||
.tb-btn .label-short { display: inline; }
|
||||
}
|
||||
|
||||
.tb-btn.is-off .icon-shell { background: var(--color-room-mic-off); border-color: var(--color-room-mic-off); color: #3a0f16; }
|
||||
.tb-btn.is-off span.label { color: var(--color-room-mic-off); font-weight: 700; }
|
||||
|
||||
@@ -526,8 +566,16 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
||||
}
|
||||
.chat-input-row button:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* Триггер и опции эмодзи-поповера лежат внутри `.chat-input-row` (форма
|
||||
отправки) — тот же контейнер, что у круглой зелёной кнопки «Отправить»
|
||||
(`.chat-input-row button`, специфичность 0,1,1). Голого класса
|
||||
(`.chat-emoji-trigger`/`.chat-emoji-option`, 0,1,0) для победы над ней не
|
||||
хватает — ЛЮБАЯ кнопка внутри формы (включая кнопки в самом поповере,
|
||||
он тоже в этом поддереве) красилась в зелёный независимо от порядка
|
||||
правил в файле. Каждый селектор ниже уточнён родительским классом ровно
|
||||
затем, чтобы обойти именно эту гонку специфичности. */
|
||||
.chat-emoji-wrap { position: relative; display: flex; flex-shrink: 0; }
|
||||
.chat-emoji-trigger {
|
||||
.chat-emoji-wrap .chat-emoji-trigger {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
@@ -539,35 +587,40 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chat-emoji-trigger:hover { color: var(--color-room-text-primary); }
|
||||
.chat-emoji-trigger.is-open { color: var(--color-room-mic-on); border-color: var(--color-room-speaker-ring); }
|
||||
.chat-emoji-trigger:disabled { opacity: 0.5; cursor: default; }
|
||||
.chat-emoji-wrap .chat-emoji-trigger:hover { color: var(--color-room-text-primary); }
|
||||
.chat-emoji-wrap .chat-emoji-trigger.is-open { color: var(--color-room-mic-on); border-color: var(--color-room-speaker-ring); }
|
||||
.chat-emoji-wrap .chat-emoji-trigger:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* 5 колонок × 6 строк — ровно 30 эмодзи в EMOJI_OPTIONS (ChatPanel.tsx), без
|
||||
неполной последней строки. */
|
||||
.chat-emoji-popover {
|
||||
position: absolute;
|
||||
bottom: calc(100% + var(--space-2));
|
||||
left: 0;
|
||||
z-index: 50;
|
||||
width: 224px;
|
||||
width: 220px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 2px;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 4px;
|
||||
padding: var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-room-tile-border);
|
||||
background: var(--color-room-surface-raised);
|
||||
box-shadow: var(--shadow-room-panel);
|
||||
}
|
||||
.chat-emoji-option {
|
||||
.chat-emoji-popover .chat-emoji-option {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
.chat-emoji-option:hover { background: var(--color-room-tile); }
|
||||
.chat-emoji-popover .chat-emoji-option:hover { background: var(--color-room-tile); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.chat-panel {
|
||||
@@ -580,16 +633,22 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Панель очереди поднятых рук (`HandQueuePanel`, задача B1) ----------
|
||||
* Контейнер — `.chat-panel` (та же геометрия и мобильное поведение), список
|
||||
* — свой. */
|
||||
/* ---------- Поповер очереди поднятых рук (`HandQueueMenu`, задача B1) ----------
|
||||
* Контейнер — `.tb-menu` (тот же поповер над кнопкой, что у «Вида»), не
|
||||
* `.chat-panel`: очередь — короткий список, а не история переписки,
|
||||
* разворачивать её на весь экран/боковой панелью незачем даже на мобильном. */
|
||||
.hand-queue-menu { width: 300px; padding: var(--space-3); }
|
||||
.hand-queue-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: var(--space-3);
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
/* Высота растёт вместе со списком (при 1–2 записях поповер компактный), но
|
||||
не безгранично — после ~10 строк упирается в потолок и скроллится
|
||||
дальше, иначе на энергичной встрече поповер вылез бы выше экрана. */
|
||||
max-height: 460px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.hand-queue-item {
|
||||
|
||||
@@ -412,6 +412,10 @@ ensure_default NGINX_CERT_NAME "localhost"
|
||||
ensure_default LIVEKIT_USE_EXTERNAL_IP "false"
|
||||
ensure_default LIVEKIT_NODE_IP "127.0.0.1"
|
||||
ensure_default TURN_EXTERNAL_IP "127.0.0.1"
|
||||
# Пусто = TURN over TLS выключен (см. docs/deploy/DEPLOYMENT.md §8) — не
|
||||
# генерируем и не требуем здесь, только гарантируем, что ключ явно есть в
|
||||
# .env (для discoverability), а не отсутствует молча.
|
||||
ensure_default TURN_TLS_HOST ""
|
||||
|
||||
# Профили compose и модели по пресету. GPU-профили — ТОЛЬКО для пресета 5
|
||||
# (max): в текущей матрице уровней (backend/services/ai_tiers.py, ADR-004)
|
||||
|
||||
Reference in New Issue
Block a user