Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 286c01d93b | |||
| e8af2fce10 | |||
| d7ae462ed5 | |||
| 826b7639b1 | |||
| daaa480f03 | |||
| c60047c594 | |||
| f39c21e7e1 | |||
| 84b7f807f7 |
@@ -112,7 +112,7 @@ SMTP_TIMEOUT_S=30
|
|||||||
# --- Версия инстанса (релиз v0.0.1) ---
|
# --- Версия инстанса (релиз v0.0.1) ---
|
||||||
# install.sh копирует значение из корневого файла VERSION при каждой
|
# install.sh копирует значение из корневого файла VERSION при каждой
|
||||||
# установке/обновлении — руками менять не нужно.
|
# установке/обновлении — руками менять не нужно.
|
||||||
VIDCONF_VERSION=0.0.16
|
VIDCONF_VERSION=0.0.19
|
||||||
|
|
||||||
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
# --- Профили compose. Дефолт ниже (`media,monitoring`) — только для ручного
|
||||||
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
|
# `docker compose up` БЕЗ install.sh: медиа (LiveKit+coturn) + мониторинг,
|
||||||
|
|||||||
57
CHANGELOG.md
57
CHANGELOG.md
@@ -3,6 +3,63 @@
|
|||||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
|
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/),
|
||||||
проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
|
проект придерживается [семантического версионирования](https://semver.org/lang/ru/).
|
||||||
|
|
||||||
|
## [0.0.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
|
## [0.0.16] — 2026-08-01
|
||||||
|
|
||||||
Роль организатора в комнате: поднятие руки с очередью и принудительный мьют.
|
Роль организатора в комнате: поднятие руки с очередью и принудительный мьют.
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ async def create_user(
|
|||||||
user = await repo.create(
|
user = await repo.create(
|
||||||
email=data.email,
|
email=data.email,
|
||||||
name_user=data.name_user,
|
name_user=data.name_user,
|
||||||
password_hash=hash_password(data.password),
|
password_hash=await hash_password(data.password),
|
||||||
team_id=data.team_id,
|
team_id=data.team_id,
|
||||||
)
|
)
|
||||||
user.email_verified = True
|
user.email_verified = True
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from api.deps import get_current_user
|
from api.deps import get_current_user
|
||||||
from core.db import get_session
|
from core.db import get_session
|
||||||
from core.rate_limit import enforce_rate_limit
|
from core.rate_limit import (
|
||||||
|
RATE_LIMIT_MISS_MAX_REQUESTS,
|
||||||
|
RATE_LIMIT_SOFT_MAX_REQUESTS,
|
||||||
|
client_ip,
|
||||||
|
enforce_rate_limit,
|
||||||
|
)
|
||||||
from models.user import User
|
from models.user import User
|
||||||
from schemas.conferences import (
|
from schemas.conferences import (
|
||||||
ConferenceCreateIn,
|
ConferenceCreateIn,
|
||||||
@@ -103,10 +108,18 @@ async def resolve_conference(
|
|||||||
п.4, уточнение резолва): вход в неё невозможен в любом случае (410 у
|
п.4, уточнение резолва): вход в неё невозможен в любом случае (410 у
|
||||||
join/guest-join), а признак закрытости неактуален для мёртвой конференции.
|
join/guest-join), а признак закрытости неактуален для мёртвой конференции.
|
||||||
"""
|
"""
|
||||||
await enforce_rate_limit(f"resolve:{_client_ip(request)}")
|
ip = client_ip(request)
|
||||||
|
# Мягкий потолок против флуда: успешные резолвы легитимны и массовы —
|
||||||
|
# вся конференция открывает ссылку в одну минуту.
|
||||||
|
await enforce_rate_limit(f"resolve:{ip}", max_requests=RATE_LIMIT_SOFT_MAX_REQUESTS)
|
||||||
service = ConferenceService(session)
|
service = ConferenceService(session)
|
||||||
conference = await service.resolve(q)
|
conference = await service.resolve(q)
|
||||||
if conference is None:
|
if conference is None:
|
||||||
|
# Жёсткий счётчик — только на промахи: перебор номера конференции
|
||||||
|
# выглядит именно так (см. core/rate_limit.py и ADR-001, п.4).
|
||||||
|
await enforce_rate_limit(
|
||||||
|
f"resolve_miss:{ip}", max_requests=RATE_LIMIT_MISS_MAX_REQUESTS
|
||||||
|
)
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not_found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not_found")
|
||||||
if conference.status == "ended":
|
if conference.status == "ended":
|
||||||
return ResolveOut(id=conference.id, title=conference.title, status=conference.status)
|
return ResolveOut(id=conference.id, title=conference.title, status=conference.status)
|
||||||
@@ -154,11 +167,15 @@ async def guest_join_conference(
|
|||||||
session: Annotated[AsyncSession, Depends(get_session)],
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
) -> JoinOut:
|
) -> JoinOut:
|
||||||
"""Войти гостем: представиться (имя обязательно, email факультативен) — без auth, rate limit."""
|
"""Войти гостем: представиться (имя обязательно, email факультативен) — без auth, rate limit."""
|
||||||
await enforce_rate_limit(f"guest_join:{_client_ip(request)}")
|
ip = client_ip(request)
|
||||||
|
# Мягкий потолок: успешный гостевой вход — обычное дело для всей
|
||||||
|
# конференции сразу, ограничивать его числом «10 в минуту» нельзя.
|
||||||
|
await enforce_rate_limit(f"guest_join:{ip}", max_requests=RATE_LIMIT_SOFT_MAX_REQUESTS)
|
||||||
service = ConferenceService(session)
|
service = ConferenceService(session)
|
||||||
try:
|
try:
|
||||||
return await service.join_as_guest(conference_id, data=data)
|
return await service.join_as_guest(conference_id, data=data)
|
||||||
except ConferenceNotFoundError as exc:
|
except ConferenceNotFoundError as exc:
|
||||||
|
await _count_guest_join_miss(ip)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="conference_not_found"
|
||||||
) from exc
|
) from exc
|
||||||
@@ -169,6 +186,9 @@ async def guest_join_conference(
|
|||||||
status_code=status.HTTP_403_FORBIDDEN, detail="password_required"
|
status_code=status.HTTP_403_FORBIDDEN, detail="password_required"
|
||||||
) from exc
|
) from exc
|
||||||
except InvalidPasswordError as exc:
|
except InvalidPasswordError as exc:
|
||||||
|
# Подбор пароля закрытой конференции — тот же класс атаки, что и
|
||||||
|
# перебор номера, поэтому считается жёстким счётчиком.
|
||||||
|
await _count_guest_join_miss(ip)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN, detail="invalid_password"
|
status_code=status.HTTP_403_FORBIDDEN, detail="invalid_password"
|
||||||
) from exc
|
) from exc
|
||||||
@@ -284,6 +304,11 @@ def _require_utc(value: datetime) -> datetime:
|
|||||||
return value.astimezone(UTC)
|
return value.astimezone(UTC)
|
||||||
|
|
||||||
|
|
||||||
def _client_ip(request: Request) -> str:
|
async def _count_guest_join_miss(ip: str) -> None:
|
||||||
"""IP-адрес клиента для rate limit (без auth — ключ по IP, а не по пользователю)."""
|
"""Учесть неудачную попытку гостевого входа в жёстком счётчике.
|
||||||
return request.client.host if request.client else "unknown"
|
|
||||||
|
Вынесено отдельно, потому что вызывается из двух веток обработки ошибок
|
||||||
|
(несуществующая конференция и неверный пароль) и обязано бросать 429
|
||||||
|
ровно так же, как обычный `enforce_rate_limit`.
|
||||||
|
"""
|
||||||
|
await enforce_rate_limit(f"guest_join_miss:{ip}", max_requests=RATE_LIMIT_MISS_MAX_REQUESTS)
|
||||||
|
|||||||
@@ -101,11 +101,11 @@ async def change_current_user_password(
|
|||||||
вместе со сбросом пароля по email (v0.1.0, см. ADR-005
|
вместе со сбросом пароля по email (v0.1.0, см. ADR-005
|
||||||
`docs/architecture/adr/005-password-reset-deferred.md`).
|
`docs/architecture/adr/005-password-reset-deferred.md`).
|
||||||
"""
|
"""
|
||||||
if not verify_password(data.current_password, user.password_hash):
|
if not await verify_password(data.current_password, user.password_hash):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_current_password"
|
status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_current_password"
|
||||||
)
|
)
|
||||||
user.password_hash = hash_password(data.new_password)
|
user.password_hash = await hash_password(data.new_password)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,59 @@
|
|||||||
Используется резолвом конференций и гостевым входом (`api/conferences.py`) —
|
Используется резолвом конференций и гостевым входом (`api/conferences.py`) —
|
||||||
эндпоинтами без аутентификации, уязвимыми к перебору номера/ссылки конференции
|
эндпоинтами без аутентификации, уязвимыми к перебору номера/ссылки конференции
|
||||||
(см. ADR-001, п.4 — оценка энтропии и рекомендуемый лимит 10 запросов/мин на IP).
|
(см. ADR-001, п.4 — оценка энтропии и рекомендуемый лимит 10 запросов/мин на IP).
|
||||||
|
|
||||||
|
## Два счётчика вместо одного (0.0.18)
|
||||||
|
|
||||||
|
Прежняя схема считала ВСЕ запросы подряд с лимитом 10/мин. На нагрузочном
|
||||||
|
тесте 31.07.2026 это остановило вход целой конференции: люди открывали ссылку
|
||||||
|
одновременно, одиннадцатый получал 429, а фронтенд показывал «Не удалось найти
|
||||||
|
конференцию» — при том что конференция существовала и была активна.
|
||||||
|
|
||||||
|
Смысл лимита по ADR-001 — защита от ПЕРЕБОРА номера конференции. Перебор — это
|
||||||
|
поток промахов; легитимный участник открывает существующую ссылку и получает
|
||||||
|
успех. Поэтому:
|
||||||
|
|
||||||
|
- `RATE_LIMIT_MISS_MAX_REQUESTS` — жёсткий счётчик промахов (конференция не
|
||||||
|
найдена, неверный пароль). Именно он защищает от перебора, и он остался
|
||||||
|
прежним — 10/мин;
|
||||||
|
- `RATE_LIMIT_SOFT_MAX_REQUESTS` — мягкий потолок на общее число обращений с
|
||||||
|
одного адреса. Нужен только против тупого флуда; рассчитан так, чтобы сотня
|
||||||
|
человек из офиса за общим NAT спокойно зашла в одну конференцию.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, Request, status
|
||||||
|
|
||||||
from core.redis import redis_client
|
from core.redis import redis_client
|
||||||
|
|
||||||
RATE_LIMIT_MAX_REQUESTS = 10
|
RATE_LIMIT_MAX_REQUESTS = 10
|
||||||
RATE_LIMIT_WINDOW_SECONDS = 60
|
RATE_LIMIT_WINDOW_SECONDS = 60
|
||||||
|
|
||||||
|
# Промахи: перебор номера/ссылки или подбор пароля конференции.
|
||||||
|
RATE_LIMIT_MISS_MAX_REQUESTS = 10
|
||||||
|
# Общий поток с одного IP. Офис за общим NAT — это ОДИН адрес, поэтому потолок
|
||||||
|
# заведомо выше правдоподобного числа участников одной конференции.
|
||||||
|
RATE_LIMIT_SOFT_MAX_REQUESTS = 300
|
||||||
|
|
||||||
|
|
||||||
|
def client_ip(request: Request) -> str:
|
||||||
|
"""IP клиента для rate limit — с учётом того, что backend стоит за nginx.
|
||||||
|
|
||||||
|
`request.client.host` — это TCP-peer, то есть контейнер nginx, один и тот же
|
||||||
|
для всех пользователей. С ним лимит превращался в общий на весь инстанс:
|
||||||
|
на проде в Redis лежал единственный ключ `rate_limit:resolve:172.18.0.13`,
|
||||||
|
и десяти запросов в минуту хватало, чтобы заблокировать вход всем сразу.
|
||||||
|
|
||||||
|
Берём `X-Real-IP`, а НЕ первый элемент `X-Forwarded-For`: nginx заполняет
|
||||||
|
его через `$proxy_add_x_forwarded_for`, то есть ДОПИСЫВАЕТ к присланному
|
||||||
|
клиентом. Первый элемент там подделывается одним заголовком, и лимит
|
||||||
|
обходился бы тривиально. `X-Real-IP` nginx всегда перезаписывает своим
|
||||||
|
`$remote_addr` (см. deploy/nginx/nginx.conf.template).
|
||||||
|
"""
|
||||||
|
real_ip = request.headers.get("x-real-ip")
|
||||||
|
if real_ip:
|
||||||
|
return real_ip.strip()
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
|
||||||
async def enforce_rate_limit(
|
async def enforce_rate_limit(
|
||||||
key: str,
|
key: str,
|
||||||
|
|||||||
@@ -1,33 +1,85 @@
|
|||||||
"""Хэширование паролей (argon2) и выпуск/проверка JWT (access + refresh)."""
|
"""Хэширование паролей (argon2) и выпуск/проверка JWT (access + refresh)."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from argon2 import PasswordHasher
|
from argon2 import PasswordHasher
|
||||||
from argon2.exceptions import VerifyMismatchError
|
from argon2.exceptions import InvalidHashError, VerifyMismatchError
|
||||||
|
|
||||||
from core.config import get_settings
|
from core.config import get_settings
|
||||||
|
|
||||||
JWT_ALGORITHM = "HS256"
|
JWT_ALGORITHM = "HS256"
|
||||||
|
|
||||||
_hasher = PasswordHasher()
|
# Параметры argon2id по рекомендации OWASP (Password Storage Cheat Sheet):
|
||||||
|
# t=2, m=19 МБ, p=1. Раньше использовались дефолты argon2-cffi
|
||||||
|
# (t=3, m=64 МБ, p=4) — это был не выбор, а «что было в коробке».
|
||||||
|
#
|
||||||
|
# Замер на боевом сервере (4 ядра): 95 мс против 42 мс на одну проверку.
|
||||||
|
# Отдельно важен `parallelism`: при p=4 ОДНА проверка пароля занимала все
|
||||||
|
# четыре ядра, конкурируя с LiveKit за то же железо ровно в момент, когда
|
||||||
|
# люди массово заходят в конференцию.
|
||||||
|
#
|
||||||
|
# Существующие хэши не ломаются: параметры хранятся внутри самой строки хэша
|
||||||
|
# и читаются при verify. Старые хэши перевыпускаются постепенно — см.
|
||||||
|
# `needs_rehash` и его использование при успешном входе.
|
||||||
|
_hasher = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1)
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def _hash_password_sync(password: str) -> str:
|
||||||
"""Захэшировать пароль алгоритмом argon2 для хранения в БД."""
|
|
||||||
return _hasher.hash(password)
|
return _hasher.hash(password)
|
||||||
|
|
||||||
|
|
||||||
def verify_password(password: str, password_hash: str) -> bool:
|
def _verify_password_sync(password: str, password_hash: str) -> bool:
|
||||||
"""Сверить пароль с сохранённым argon2-хэшем; пароль/хэш никогда не логируются."""
|
|
||||||
try:
|
try:
|
||||||
return _hasher.verify(password_hash, password)
|
return _hasher.verify(password_hash, password)
|
||||||
except VerifyMismatchError:
|
except VerifyMismatchError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def hash_password(password: str) -> str:
|
||||||
|
"""Захэшировать пароль алгоритмом argon2 для хранения в БД.
|
||||||
|
|
||||||
|
Считается в отдельном потоке — argon2 это CPU-bound работа на десятки
|
||||||
|
миллисекунд, и в event loop ей не место (см. `verify_password`).
|
||||||
|
"""
|
||||||
|
return await asyncio.to_thread(_hash_password_sync, password)
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_password(password: str, password_hash: str) -> bool:
|
||||||
|
"""Сверить пароль с сохранённым argon2-хэшем; пароль/хэш никогда не логируются.
|
||||||
|
|
||||||
|
Выполняется в пуле потоков, а не в event loop. Причина — нагрузочный тест
|
||||||
|
31.07.2026: синхронный вызов останавливал весь процесс на 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:
|
def create_access_token(user_id: uuid.UUID, role: str) -> str:
|
||||||
"""Выпустить access-токен: `sub`=user_id, `role`=роль, TTL из настроек."""
|
"""Выпустить access-токен: `sub`=user_id, `role`=роль, TTL из настроек."""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from core.security import (
|
|||||||
create_refresh_token,
|
create_refresh_token,
|
||||||
decode_token,
|
decode_token,
|
||||||
hash_password,
|
hash_password,
|
||||||
|
needs_rehash,
|
||||||
verify_password,
|
verify_password,
|
||||||
)
|
)
|
||||||
from models.email_verification import EmailVerificationToken
|
from models.email_verification import EmailVerificationToken
|
||||||
@@ -131,7 +132,7 @@ class AuthService:
|
|||||||
user = await self._users.create(
|
user = await self._users.create(
|
||||||
email=email,
|
email=email,
|
||||||
name_user=name_user,
|
name_user=name_user,
|
||||||
password_hash=hash_password(password),
|
password_hash=await hash_password(password),
|
||||||
team_id=team_id,
|
team_id=team_id,
|
||||||
)
|
)
|
||||||
reply_to = cfg.contact_email if cfg.contact_email_enabled else None
|
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:
|
async def login(self, *, email: str, password: str) -> TokenPair:
|
||||||
"""Проверить учётные данные и выдать пару access/refresh токенов."""
|
"""Проверить учётные данные и выдать пару access/refresh токенов."""
|
||||||
user = await self._users.get_by_email(email)
|
user = await self._users.get_by_email(email)
|
||||||
if user is None or not verify_password(password, user.password_hash):
|
if user is None or not await verify_password(password, user.password_hash):
|
||||||
raise InvalidCredentialsError
|
raise InvalidCredentialsError
|
||||||
if not user.email_verified:
|
if not user.email_verified:
|
||||||
raise EmailNotVerifiedError
|
raise EmailNotVerifiedError
|
||||||
|
|
||||||
|
# Постепенная миграция на актуальные параметры argon2 (см. core/security.py):
|
||||||
|
# параметры зашиты в саму строку хэша, поэтому старые записи так и
|
||||||
|
# проверялись бы вдвое дольше. Открытый пароль есть только здесь и
|
||||||
|
# только сейчас — другого места для перевыпуска не будет.
|
||||||
|
if needs_rehash(user.password_hash):
|
||||||
|
user.password_hash = await hash_password(password)
|
||||||
|
# Явный commit: выдача токенов идёт через Redis и БД не трогает,
|
||||||
|
# поэтому без него перевыпущенный хэш откатился бы вместе с сессией.
|
||||||
|
await self._session.commit()
|
||||||
|
|
||||||
return await self._issue_token_pair(user.id, user.role)
|
return await self._issue_token_pair(user.id, user.role)
|
||||||
|
|
||||||
async def refresh(self, refresh_token: str) -> TokenPair:
|
async def refresh(self, refresh_token: str) -> TokenPair:
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class InvalidPasswordError(Exception):
|
|||||||
"""Указанный пароль не совпадает с паролем закрытой конференции."""
|
"""Указанный пароль не совпадает с паролем закрытой конференции."""
|
||||||
|
|
||||||
|
|
||||||
def ensure_joinable(conference: Conference, *, password: str | None) -> None:
|
async def ensure_joinable(conference: Conference, *, password: str | None) -> None:
|
||||||
"""Проверить, что в конференцию можно войти прямо сейчас.
|
"""Проверить, что в конференцию можно войти прямо сейчас.
|
||||||
|
|
||||||
Бросает `ConferenceEndedError` для терминального статуса `ended`
|
Бросает `ConferenceEndedError` для терминального статуса `ended`
|
||||||
@@ -42,7 +42,7 @@ def ensure_joinable(conference: Conference, *, password: str | None) -> None:
|
|||||||
return
|
return
|
||||||
if conference.password_hash is None or password is None:
|
if conference.password_hash is None or password is None:
|
||||||
raise PasswordRequiredError
|
raise PasswordRequiredError
|
||||||
if not verify_password(password, conference.password_hash):
|
if not await verify_password(password, conference.password_hash):
|
||||||
raise InvalidPasswordError
|
raise InvalidPasswordError
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ class ConferenceService:
|
|||||||
повторением без явного `scheduled_at` — плановая конференция,
|
повторением без явного `scheduled_at` — плановая конференция,
|
||||||
ожидающая своего первого вхождения, а не мгновенный вход.
|
ожидающая своего первого вхождения, а не мгновенный вход.
|
||||||
"""
|
"""
|
||||||
password_hash = hash_password(data.password) if data.password else None
|
password_hash = await hash_password(data.password) if data.password else None
|
||||||
is_instant = data.scheduled_at is None and data.recurrence is None
|
is_instant = data.scheduled_at is None and data.recurrence is None
|
||||||
conference_status = "active" if is_instant else "scheduled"
|
conference_status = "active" if is_instant else "scheduled"
|
||||||
recurrence_json = data.recurrence.model_dump(mode="json") if data.recurrence else None
|
recurrence_json = data.recurrence.model_dump(mode="json") if data.recurrence else None
|
||||||
@@ -242,7 +242,7 @@ class ConferenceService:
|
|||||||
) -> JoinOut:
|
) -> JoinOut:
|
||||||
"""Войти в конференцию зарегистрированным пользователем."""
|
"""Войти в конференцию зарегистрированным пользователем."""
|
||||||
conference = await self._get_or_raise(conference_id)
|
conference = await self._get_or_raise(conference_id)
|
||||||
ensure_joinable(conference, password=password)
|
await ensure_joinable(conference, password=password)
|
||||||
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
|
chat_enabled = (await InstanceSettingsService(self._session).get()).chat.enabled
|
||||||
return build_join(
|
return build_join(
|
||||||
conference,
|
conference,
|
||||||
@@ -256,7 +256,7 @@ class ConferenceService:
|
|||||||
async def join_as_guest(self, conference_id: uuid.UUID, *, data: GuestJoinIn) -> JoinOut:
|
async def join_as_guest(self, conference_id: uuid.UUID, *, data: GuestJoinIn) -> JoinOut:
|
||||||
"""Войти в конференцию гостем: создать `GuestAccess` и выдать токен."""
|
"""Войти в конференцию гостем: создать `GuestAccess` и выдать токен."""
|
||||||
conference = await self._get_or_raise(conference_id)
|
conference = await self._get_or_raise(conference_id)
|
||||||
ensure_joinable(conference, password=data.password)
|
await ensure_joinable(conference, password=data.password)
|
||||||
|
|
||||||
guest = GuestAccess(
|
guest = GuestAccess(
|
||||||
conference_id=conference.id, display_name=data.display_name, email=data.email
|
conference_id=conference.id, display_name=data.display_name, email=data.email
|
||||||
@@ -330,7 +330,7 @@ class ConferenceService:
|
|||||||
if data.is_closed is not None:
|
if data.is_closed is not None:
|
||||||
conference.is_closed = data.is_closed
|
conference.is_closed = data.is_closed
|
||||||
if data.password is not None:
|
if data.password is not None:
|
||||||
conference.password_hash = hash_password(data.password)
|
conference.password_hash = await hash_password(data.password)
|
||||||
if "summary_recipients" in data.model_fields_set:
|
if "summary_recipients" in data.model_fields_set:
|
||||||
# Явная передача (в т.ч. `null`) — сбросить/установить
|
# Явная передача (в т.ч. `null`) — сбросить/установить
|
||||||
# переопределение; отсутствие поля в запросе значение не трогает.
|
# переопределение; отсутствие поля в запросе значение не трогает.
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
|
|||||||
user = User(
|
user = User(
|
||||||
email=f"{uuid.uuid4()}@example.com",
|
email=f"{uuid.uuid4()}@example.com",
|
||||||
name_user="Admin API Tester",
|
name_user="Admin API Tester",
|
||||||
password_hash=hash_password("password123"),
|
password_hash=await hash_password("password123"),
|
||||||
email_verified=True,
|
email_verified=True,
|
||||||
role=role,
|
role=role,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
|
|||||||
user = User(
|
user = User(
|
||||||
email=f"{uuid.uuid4()}@example.com",
|
email=f"{uuid.uuid4()}@example.com",
|
||||||
name_user="Team API Tester",
|
name_user="Team API Tester",
|
||||||
password_hash=hash_password("password123"),
|
password_hash=await hash_password("password123"),
|
||||||
email_verified=True,
|
email_verified=True,
|
||||||
role=role,
|
role=role,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import Annotated
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
from argon2 import PasswordHasher
|
||||||
from fastapi import Depends, FastAPI
|
from fastapi import Depends, FastAPI
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -16,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from api.auth import get_auth_service
|
from api.auth import get_auth_service
|
||||||
from core.db import get_session
|
from core.db import get_session
|
||||||
from core.redis import redis_client
|
from core.redis import redis_client
|
||||||
|
from core.security import needs_rehash
|
||||||
from models.team import Team
|
from models.team import Team
|
||||||
from models.user import User
|
from models.user import User
|
||||||
from services.auth import AuthService
|
from services.auth import AuthService
|
||||||
@@ -491,3 +493,35 @@ async def test_register_no_reply_to_when_contact_email_disabled(
|
|||||||
|
|
||||||
assert response.status_code == 201, response.text
|
assert response.status_code == 201, response.text
|
||||||
assert email_backend.reply_to[-1] is None
|
assert email_backend.reply_to[-1] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_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(
|
user = User(
|
||||||
email=f"{uuid.uuid4()}@example.com",
|
email=f"{uuid.uuid4()}@example.com",
|
||||||
name_user=name,
|
name_user=name,
|
||||||
password_hash=hash_password("password123"),
|
password_hash=await hash_password("password123"),
|
||||||
email_verified=True,
|
email_verified=True,
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ async def _make_user(session: AsyncSession) -> User:
|
|||||||
user = User(
|
user = User(
|
||||||
email=f"{uuid.uuid4()}@example.com",
|
email=f"{uuid.uuid4()}@example.com",
|
||||||
name_user="Invitee Tester",
|
name_user="Invitee Tester",
|
||||||
password_hash=hash_password("password123"),
|
password_hash=await hash_password("password123"),
|
||||||
email_verified=True,
|
email_verified=True,
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ async def _make_user(session: AsyncSession, *, name: str = "Service Tester") ->
|
|||||||
user = User(
|
user = User(
|
||||||
email=f"{uuid.uuid4()}@example.com",
|
email=f"{uuid.uuid4()}@example.com",
|
||||||
name_user=name,
|
name_user=name,
|
||||||
password_hash=hash_password("password123"),
|
password_hash=await hash_password("password123"),
|
||||||
email_verified=True,
|
email_verified=True,
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
|
|||||||
user = User(
|
user = User(
|
||||||
email=f"{uuid.uuid4()}@example.com",
|
email=f"{uuid.uuid4()}@example.com",
|
||||||
name_user="Conference Tester",
|
name_user="Conference Tester",
|
||||||
password_hash=hash_password("password123"),
|
password_hash=await hash_password("password123"),
|
||||||
email_verified=True,
|
email_verified=True,
|
||||||
role=role,
|
role=role,
|
||||||
)
|
)
|
||||||
@@ -67,7 +67,7 @@ async def _make_conference(
|
|||||||
status=status,
|
status=status,
|
||||||
is_pinned=is_pinned,
|
is_pinned=is_pinned,
|
||||||
is_closed=is_closed,
|
is_closed=is_closed,
|
||||||
password_hash=hash_password(password) if password else None,
|
password_hash=await hash_password(password) if password else None,
|
||||||
ended_at=ended_at,
|
ended_at=ended_at,
|
||||||
scheduled_at=scheduled_at,
|
scheduled_at=scheduled_at,
|
||||||
duration_minutes=duration_minutes,
|
duration_minutes=duration_minutes,
|
||||||
@@ -573,17 +573,75 @@ async def test_resolve_unknown_returns_uniform_404(client: httpx.AsyncClient) ->
|
|||||||
assert response.json()["detail"] == "not_found"
|
assert response.json()["detail"] == "not_found"
|
||||||
|
|
||||||
|
|
||||||
async def test_resolve_is_rate_limited_after_10_requests_per_minute(
|
def _ip_headers() -> dict[str, str]:
|
||||||
client: httpx.AsyncClient,
|
"""Уникальный `X-Real-IP` на каждый тест.
|
||||||
) -> None:
|
|
||||||
|
Счётчики rate limit живут в Redis 60 секунд и общие для всего инстанса,
|
||||||
|
поэтому без изоляции тесты влияли бы друг на друга через остаточные ключи.
|
||||||
|
Заодно это проверяет, что заголовок вообще читается: раньше ключ строился
|
||||||
|
по `request.client.host`, то есть по адресу nginx, одинаковому для всех.
|
||||||
|
"""
|
||||||
|
return {"X-Real-IP": f"198.51.100.{uuid.uuid4().int % 250 + 1}-{uuid.uuid4().hex[:8]}"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_resolve_misses_are_rate_limited(client: httpx.AsyncClient) -> None:
|
||||||
|
"""Перебор номера конференции упирается в жёсткий лимит промахов (ADR-001, п.4)."""
|
||||||
|
headers = _ip_headers()
|
||||||
for _ in range(10):
|
for _ in range(10):
|
||||||
response = await client.get("/api/v1/conferences/resolve", params={"q": "irrelevant-query"})
|
response = await client.get(
|
||||||
|
"/api/v1/conferences/resolve", params={"q": "irrelevant-query"}, headers=headers
|
||||||
|
)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
limited = await client.get("/api/v1/conferences/resolve", params={"q": "irrelevant-query"})
|
limited = await client.get(
|
||||||
|
"/api/v1/conferences/resolve", params={"q": "irrelevant-query"}, headers=headers
|
||||||
|
)
|
||||||
assert limited.status_code == 429
|
assert limited.status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
async def test_successful_resolves_are_not_limited_by_miss_counter(
|
||||||
|
client: httpx.AsyncClient, db_session: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
"""Вся конференция может открыть ссылку одновременно (регресс теста 31.07.2026).
|
||||||
|
|
||||||
|
Прежняя схема считала любые запросы с лимитом 10/мин, и одиннадцатый
|
||||||
|
участник получал 429 — фронтенд показывал «Не удалось найти конференцию»
|
||||||
|
для существующей и активной конференции.
|
||||||
|
"""
|
||||||
|
conference = await _make_conference(db_session)
|
||||||
|
await db_session.commit()
|
||||||
|
headers = _ip_headers()
|
||||||
|
|
||||||
|
for _ in range(50):
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/conferences/resolve", params={"q": conference.slug}, headers=headers
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rate_limit_is_per_client_ip(client: httpx.AsyncClient) -> None:
|
||||||
|
"""Счётчик привязан к адресу клиента, а не к адресу nginx.
|
||||||
|
|
||||||
|
Исчерпав лимит промахов с одного адреса, с другого по-прежнему можно
|
||||||
|
работать. До исправления ключ был общим на весь инстанс.
|
||||||
|
"""
|
||||||
|
first, second = _ip_headers(), _ip_headers()
|
||||||
|
for _ in range(11):
|
||||||
|
await client.get(
|
||||||
|
"/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=first
|
||||||
|
)
|
||||||
|
|
||||||
|
exhausted = await client.get(
|
||||||
|
"/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=first
|
||||||
|
)
|
||||||
|
assert exhausted.status_code == 429
|
||||||
|
|
||||||
|
other = await client.get(
|
||||||
|
"/api/v1/conferences/resolve", params={"q": "no-such-conference"}, headers=second
|
||||||
|
)
|
||||||
|
assert other.status_code == 404, "лимит одного клиента не должен задевать другого"
|
||||||
|
|
||||||
|
|
||||||
# --- Вход зарегистрированным пользователем ---------------------------------------
|
# --- Вход зарегистрированным пользователем ---------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -835,21 +893,69 @@ async def test_guest_join_ended_conference_returns_410(
|
|||||||
assert response.json()["detail"] == "conference_ended"
|
assert response.json()["detail"] == "conference_ended"
|
||||||
|
|
||||||
|
|
||||||
async def test_guest_join_is_rate_limited_after_10_requests_per_minute(
|
async def test_guest_join_allows_a_whole_conference_to_enter(
|
||||||
client: httpx.AsyncClient, db_session: AsyncSession
|
client: httpx.AsyncClient, db_session: AsyncSession
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Успешные гостевые входы не упираются в лимит промахов.
|
||||||
|
|
||||||
|
На нагрузочном тесте 31.07.2026 конференцию из семи десятков человек не
|
||||||
|
пускало внутрь именно это ограничение — счётчик не различал легитимный
|
||||||
|
массовый вход и перебор.
|
||||||
|
"""
|
||||||
conference = await _make_conference(db_session)
|
conference = await _make_conference(db_session)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
headers = _ip_headers()
|
||||||
|
|
||||||
|
for i in range(30):
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/v1/conferences/{conference.id}/guest-join",
|
||||||
|
json={"display_name": f"Guest {i}"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_guest_join_misses_are_rate_limited(client: httpx.AsyncClient) -> None:
|
||||||
|
"""Перебор идентификатора конференции по-прежнему упирается в лимит."""
|
||||||
|
headers = _ip_headers()
|
||||||
|
missing_id = uuid.uuid4()
|
||||||
|
|
||||||
|
for _ in range(10):
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/v1/conferences/{missing_id}/guest-join",
|
||||||
|
json={"display_name": "Bruteforce"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
limited = await client.post(
|
||||||
|
f"/api/v1/conferences/{missing_id}/guest-join",
|
||||||
|
json={"display_name": "Bruteforce"},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert limited.status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
async def test_guest_join_wrong_password_is_rate_limited(
|
||||||
|
client: httpx.AsyncClient, db_session: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
"""Подбор пароля закрытой конференции считается тем же жёстким счётчиком."""
|
||||||
|
conference = await _make_conference(db_session, is_closed=True, password="right-password")
|
||||||
|
await db_session.commit()
|
||||||
|
headers = _ip_headers()
|
||||||
|
|
||||||
for _ in range(10):
|
for _ in range(10):
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"/api/v1/conferences/{conference.id}/guest-join",
|
f"/api/v1/conferences/{conference.id}/guest-join",
|
||||||
json={"display_name": "Repeat Guest"},
|
json={"display_name": "Guesser", "password": "wrong"},
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 403
|
||||||
|
|
||||||
limited = await client.post(
|
limited = await client.post(
|
||||||
f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Repeat Guest"}
|
f"/api/v1/conferences/{conference.id}/guest-join",
|
||||||
|
json={"display_name": "Guesser", "password": "wrong"},
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
assert limited.status_code == 429
|
assert limited.status_code == 429
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ async def _make_user(session: AsyncSession, *, name: str = "Hand Tester") -> Use
|
|||||||
user = User(
|
user = User(
|
||||||
email=f"{uuid.uuid4()}@example.com",
|
email=f"{uuid.uuid4()}@example.com",
|
||||||
name_user=name,
|
name_user=name,
|
||||||
password_hash=hash_password("password123"),
|
password_hash=await hash_password("password123"),
|
||||||
email_verified=True,
|
email_verified=True,
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ async def _make_user(session: AsyncSession, email: str) -> User:
|
|||||||
user = User(
|
user = User(
|
||||||
email=email,
|
email=email,
|
||||||
name_user="Participant",
|
name_user="Participant",
|
||||||
password_hash=hash_password("password123"),
|
password_hash=await hash_password("password123"),
|
||||||
email_verified=True,
|
email_verified=True,
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ async def _make_user(session: AsyncSession, *, role: str = "user") -> User:
|
|||||||
user = User(
|
user = User(
|
||||||
email=f"{uuid.uuid4()}@example.com",
|
email=f"{uuid.uuid4()}@example.com",
|
||||||
name_user="Mute Tester",
|
name_user="Mute Tester",
|
||||||
password_hash=hash_password("password123"),
|
password_hash=await hash_password("password123"),
|
||||||
email_verified=True,
|
email_verified=True,
|
||||||
role=role,
|
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(
|
user = User(
|
||||||
email=f"{uuid.uuid4()}@example.com",
|
email=f"{uuid.uuid4()}@example.com",
|
||||||
name_user="Test User",
|
name_user="Test User",
|
||||||
password_hash=hash_password("password123"),
|
password_hash=await hash_password("password123"),
|
||||||
role=role,
|
role=role,
|
||||||
email_verified=True,
|
email_verified=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ async def _make_user(session: AsyncSession) -> User:
|
|||||||
user = User(
|
user = User(
|
||||||
email=f"{uuid.uuid4()}@example.com",
|
email=f"{uuid.uuid4()}@example.com",
|
||||||
name_user="Teams API Tester",
|
name_user="Teams API Tester",
|
||||||
password_hash=hash_password("password123"),
|
password_hash=await hash_password("password123"),
|
||||||
email_verified=True,
|
email_verified=True,
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ async def _make_user(session: AsyncSession) -> User:
|
|||||||
user = User(
|
user = User(
|
||||||
email=f"{uuid.uuid4()}@example.com",
|
email=f"{uuid.uuid4()}@example.com",
|
||||||
name_user="List Tester",
|
name_user="List Tester",
|
||||||
password_hash=hash_password("password123"),
|
password_hash=await hash_password("password123"),
|
||||||
email_verified=True,
|
email_verified=True,
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
@@ -98,7 +98,7 @@ async def test_get_me_with_reserved_tld_email_does_not_500(
|
|||||||
user = User(
|
user = User(
|
||||||
email=legacy_email,
|
email=legacy_email,
|
||||||
name_user="Legacy Admin",
|
name_user="Legacy Admin",
|
||||||
password_hash=hash_password("password123"),
|
password_hash=await hash_password("password123"),
|
||||||
email_verified=True,
|
email_verified=True,
|
||||||
role="admin",
|
role="admin",
|
||||||
)
|
)
|
||||||
@@ -380,7 +380,7 @@ async def test_list_users_search_by_q_filters_by_name_or_email(
|
|||||||
match = User(
|
match = User(
|
||||||
email=f"{unique_marker}@example.com",
|
email=f"{unique_marker}@example.com",
|
||||||
name_user=f"Findable {unique_marker}",
|
name_user=f"Findable {unique_marker}",
|
||||||
password_hash=hash_password("password123"),
|
password_hash=await hash_password("password123"),
|
||||||
email_verified=True,
|
email_verified=True,
|
||||||
)
|
)
|
||||||
db_session.add(match)
|
db_session.add(match)
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ services:
|
|||||||
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
|
MEDIA_ROOT: ${MEDIA_ROOT:-/app/media}
|
||||||
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
|
# Версия инстанса (релиз v0.0.1) — install.sh копирует значение
|
||||||
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
|
# из файла VERSION (корень репозитория) в .env; отдаётся в GET /api/health.
|
||||||
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.16}
|
VIDCONF_VERSION: ${VIDCONF_VERSION:-0.0.19}
|
||||||
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
|
# Число процессов uvicorn (см. backend/Dockerfile). Дефолт 2 рассчитан
|
||||||
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
|
# на 4-ядерный сервер, где ядра делятся с LiveKit. Поднимая значение,
|
||||||
# проверьте бюджет соединений с БД: каждый воркер держит свой пул
|
# проверьте бюджет соединений с БД: каждый воркер держит свой пул
|
||||||
|
|||||||
@@ -6,13 +6,15 @@ import { useModalDismiss } from '@/hooks/useModalDismiss'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Небольшой собственный набор популярных эмодзи — вместо библиотеки-пикера на
|
* Небольшой собственный набор популярных эмодзи — вместо библиотеки-пикера на
|
||||||
* сотни килобайт ради десятка кнопок в поповере.
|
* сотни килобайт ради десятка кнопок в поповере. Ровно 30 штук (6×5 —
|
||||||
|
* `.chat-emoji-popover` в room.css рассчитан на эту сетку без остатка;
|
||||||
|
* добавляя/убирая эмодзи, держи кратность 5).
|
||||||
*/
|
*/
|
||||||
const EMOJI_OPTIONS = [
|
const EMOJI_OPTIONS = [
|
||||||
'😀', '😂', '😊', '😉', '😍', '🤔', '😅', '😢',
|
'😀', '😂', '😊', '😉', '😍', '🤔', '😅', '😢',
|
||||||
'😮', '😎', '🙌', '👍', '👎', '👏', '🙏', '❤️',
|
'😮', '😎', '🙌', '👍', '👎', '👏', '🙏', '❤️',
|
||||||
'🔥', '🎉', '✅', '❌', '⚠️', '💡', '👀', '🤝',
|
'🔥', '🎉', '✅', '❌', '⚠️', '💡', '👀', '🤝',
|
||||||
'🐎',
|
'🐎', '🦾', '🚀', '🦞', '💯', '🤷♂️',
|
||||||
]
|
]
|
||||||
|
|
||||||
interface ChatPanelProps {
|
interface ChatPanelProps {
|
||||||
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
Hand,
|
Hand,
|
||||||
ListOrdered,
|
|
||||||
LogOut,
|
LogOut,
|
||||||
Maximize,
|
Maximize,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
@@ -18,9 +17,9 @@ import { Track, type ScreenShareCaptureOptions } from 'livekit-client'
|
|||||||
import { DisconnectButton, useLocalParticipant, useTrackToggle } from '@livekit/components-react'
|
import { DisconnectButton, useLocalParticipant, useTrackToggle } from '@livekit/components-react'
|
||||||
import { useToast } from '@/components/ui/ToastProvider'
|
import { useToast } from '@/components/ui/ToastProvider'
|
||||||
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
|
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
|
||||||
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
|
|
||||||
import type { HandQueueEntry } from '@/hooks/useChat'
|
import type { HandQueueEntry } from '@/hooks/useChat'
|
||||||
import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions'
|
import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions'
|
||||||
|
import { HandQueueMenu } from '@/components/room/HandQueueMenu'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Опции захвата демонстрации экрана: `audio: true` — звук
|
* Опции захвата демонстрации экрана: `audio: true` — звук
|
||||||
@@ -67,8 +66,8 @@ interface RoomToolbarProps extends StageViewProps {
|
|||||||
handQueue: HandQueueEntry[]
|
handQueue: HandQueueEntry[]
|
||||||
onRaiseHand: () => void
|
onRaiseHand: () => void
|
||||||
onLowerHand: () => void
|
onLowerHand: () => void
|
||||||
handQueueOpen: boolean
|
/** Опустить ЧУЖУЮ руку по identity — только организатору (панель очереди, `HandQueueMenu`). */
|
||||||
onToggleHandQueue: () => void
|
onLowerHandById: (identity: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,8 +103,7 @@ export function RoomToolbar({
|
|||||||
handQueue,
|
handQueue,
|
||||||
onRaiseHand,
|
onRaiseHand,
|
||||||
onLowerHand,
|
onLowerHand,
|
||||||
handQueueOpen,
|
onLowerHandById,
|
||||||
onToggleHandQueue,
|
|
||||||
layoutMode,
|
layoutMode,
|
||||||
onLayoutModeChange,
|
onLayoutModeChange,
|
||||||
hideOthers,
|
hideOthers,
|
||||||
@@ -113,7 +111,6 @@ export function RoomToolbar({
|
|||||||
}: RoomToolbarProps) {
|
}: RoomToolbarProps) {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const isCompact = useIsCompactViewport()
|
const isCompact = useIsCompactViewport()
|
||||||
const isOrganizer = useIsOrganizer()
|
|
||||||
const { localParticipant } = useLocalParticipant()
|
const { localParticipant } = useLocalParticipant()
|
||||||
const handRaised = handQueue.some((entry) => entry.identity === localParticipant.identity)
|
const handRaised = handQueue.some((entry) => entry.identity === localParticipant.identity)
|
||||||
const mic = useTrackToggle({ source: Track.Source.Microphone })
|
const mic = useTrackToggle({ source: Track.Source.Microphone })
|
||||||
@@ -197,23 +194,7 @@ export function RoomToolbar({
|
|||||||
<span className="label">Рука</span>
|
<span className="label">Рука</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOrganizer && (
|
<HandQueueMenu queue={handQueue} onLower={onLowerHandById} />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isCompact && (
|
{!isCompact && (
|
||||||
<StageViewMenu
|
<StageViewMenu
|
||||||
@@ -266,7 +247,14 @@ export function RoomToolbar({
|
|||||||
<span className="icon-shell">
|
<span className="icon-shell">
|
||||||
<PictureInPicture2 className="lucide" aria-hidden="true" />
|
<PictureInPicture2 className="lucide" aria-hidden="true" />
|
||||||
</span>
|
</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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { RoomTopbar } from '@/components/room/RoomTopbar'
|
|||||||
import { RoomStage } from '@/components/room/RoomStage'
|
import { RoomStage } from '@/components/room/RoomStage'
|
||||||
import { RoomToolbar } from '@/components/room/RoomToolbar'
|
import { RoomToolbar } from '@/components/room/RoomToolbar'
|
||||||
import { ChatPanel } from '@/components/room/ChatPanel'
|
import { ChatPanel } from '@/components/room/ChatPanel'
|
||||||
import { HandQueuePanel } from '@/components/room/HandQueuePanel'
|
|
||||||
import { ForcedMuteWatcher } from '@/components/room/ForcedMuteWatcher'
|
import { ForcedMuteWatcher } from '@/components/room/ForcedMuteWatcher'
|
||||||
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
|
import { DeviceSettingsDialog } from '@/components/room/DeviceSettingsDialog'
|
||||||
import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
|
import { loadAudioOutputDeviceId } from '@/lib/audioOutputDevice'
|
||||||
@@ -147,11 +146,6 @@ export function RoomPage() {
|
|||||||
// изначально JoinOut.chat_enabled был true (рассинхрон с админкой в моменте).
|
// изначально JoinOut.chat_enabled был true (рассинхрон с админкой в моменте).
|
||||||
const chatVisible = Boolean(joinState?.chatEnabled) && !chat.unavailable
|
const chatVisible = Boolean(joinState?.chatEnabled) && !chat.unavailable
|
||||||
|
|
||||||
// Панель очереди поднятых рук — открыта/закрыта организатором (задача B1).
|
|
||||||
// Саму видимость кнопки/панели решает `useIsOrganizer()` внутри
|
|
||||||
// `RoomToolbar`/`HandQueuePanel` (эти компоненты — дети `LiveKitRoom`, а
|
|
||||||
// `RoomPage` — нет, `useLocalParticipant` здесь не вызвать).
|
|
||||||
const [handQueueOpen, setHandQueueOpen] = useState(false)
|
|
||||||
// Identity участников с поднятой рукой — множеством, для дешёвого `.has()`
|
// Identity участников с поднятой рукой — множеством, для дешёвого `.has()`
|
||||||
// на каждой плитке сцены (см. `RoomParticipantTile`).
|
// на каждой плитке сцены (см. `RoomParticipantTile`).
|
||||||
const raisedHandIdentities = useMemo(
|
const raisedHandIdentities = useMemo(
|
||||||
@@ -304,13 +298,6 @@ export function RoomPage() {
|
|||||||
onClose={() => setChatOpen(false)}
|
onClose={() => setChatOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{handQueueOpen && (
|
|
||||||
<HandQueuePanel
|
|
||||||
queue={chat.handQueue}
|
|
||||||
onLower={(identity) => chat.lowerHand(identity)}
|
|
||||||
onClose={() => setHandQueueOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<RoomToolbar
|
<RoomToolbar
|
||||||
chatVisible={chatVisible}
|
chatVisible={chatVisible}
|
||||||
@@ -327,8 +314,7 @@ export function RoomPage() {
|
|||||||
handQueue={chat.handQueue}
|
handQueue={chat.handQueue}
|
||||||
onRaiseHand={chat.raiseHand}
|
onRaiseHand={chat.raiseHand}
|
||||||
onLowerHand={() => chat.lowerHand()}
|
onLowerHand={() => chat.lowerHand()}
|
||||||
handQueueOpen={handQueueOpen}
|
onLowerHandById={(identity) => chat.lowerHand(identity)}
|
||||||
onToggleHandQueue={() => setHandQueueOpen((open) => !open)}
|
|
||||||
layoutMode={layoutMode}
|
layoutMode={layoutMode}
|
||||||
onLayoutModeChange={handleLayoutModeChange}
|
onLayoutModeChange={handleLayoutModeChange}
|
||||||
hideOthers={hideOthers}
|
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; }
|
.stage-show-others svg { width: 18px; height: 18px; flex-shrink: 0; }
|
||||||
|
|
||||||
/* Нижний тулбар: свои кнопки на хуках LiveKit (TrackToggle/DisconnectButton) */
|
/* Нижний тулбар: свои кнопки на хуках 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 {
|
.room-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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);
|
background: var(--color-room-surface);
|
||||||
border-top: 1px solid var(--color-room-tile-border);
|
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;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.tb-btn {
|
.tb-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: clamp(2px, calc(2px + (100vw - 600px) * 2 / 600), 4px);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
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);
|
border-radius: var(--radius-md);
|
||||||
color: var(--color-room-text-primary);
|
color: var(--color-room-text-primary);
|
||||||
min-width: 76px;
|
min-width: clamp(0px, calc((100vw - 600px) * 76 / 600), 76px);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.tb-btn .icon-shell {
|
.tb-btn .icon-shell {
|
||||||
width: 48px;
|
width: clamp(40px, calc(40px + (100vw - 600px) * 8 / 600), 48px);
|
||||||
height: 48px;
|
height: clamp(40px, calc(40px + (100vw - 600px) * 8 / 600), 48px);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -311,9 +333,27 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
|||||||
background: var(--color-room-tile);
|
background: var(--color-room-tile);
|
||||||
color: var(--color-room-text-primary);
|
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); }
|
.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 .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; }
|
.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 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-wrap { position: relative; display: flex; flex-shrink: 0; }
|
||||||
.chat-emoji-trigger {
|
.chat-emoji-wrap .chat-emoji-trigger {
|
||||||
width: 42px;
|
width: 42px;
|
||||||
height: 42px;
|
height: 42px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -539,35 +587,40 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.chat-emoji-trigger:hover { color: var(--color-room-text-primary); }
|
.chat-emoji-wrap .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-wrap .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:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
/* 5 колонок × 6 строк — ровно 30 эмодзи в EMOJI_OPTIONS (ChatPanel.tsx), без
|
||||||
|
неполной последней строки. */
|
||||||
.chat-emoji-popover {
|
.chat-emoji-popover {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: calc(100% + var(--space-2));
|
bottom: calc(100% + var(--space-2));
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
width: 224px;
|
width: 220px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, 1fr);
|
grid-template-columns: repeat(5, 1fr);
|
||||||
gap: 2px;
|
gap: 4px;
|
||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
border: 1px solid var(--color-room-tile-border);
|
border: 1px solid var(--color-room-tile-border);
|
||||||
background: var(--color-room-surface-raised);
|
background: var(--color-room-surface-raised);
|
||||||
box-shadow: var(--shadow-room-panel);
|
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;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding: 6px;
|
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
cursor: pointer;
|
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) {
|
@media (max-width: 900px) {
|
||||||
.chat-panel {
|
.chat-panel {
|
||||||
@@ -580,16 +633,22 @@ video[data-lk-source='screen_share'] { object-fit: contain; background: #000; }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Панель очереди поднятых рук (`HandQueuePanel`, задача B1) ----------
|
/* ---------- Поповер очереди поднятых рук (`HandQueueMenu`, задача B1) ----------
|
||||||
* Контейнер — `.chat-panel` (та же геометрия и мобильное поведение), список
|
* Контейнер — `.tb-menu` (тот же поповер над кнопкой, что у «Вида»), не
|
||||||
* — свой. */
|
* `.chat-panel`: очередь — короткий список, а не история переписки,
|
||||||
|
* разворачивать её на весь экран/боковой панелью незачем даже на мобильном. */
|
||||||
|
.hand-queue-menu { width: 300px; padding: var(--space-3); }
|
||||||
.hand-queue-list {
|
.hand-queue-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: var(--space-3);
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
|
/* Высота растёт вместе со списком (при 1–2 записях поповер компактный), но
|
||||||
|
не безгранично — после ~10 строк упирается в потолок и скроллится
|
||||||
|
дальше, иначе на энергичной встрече поповер вылез бы выше экрана. */
|
||||||
|
max-height: 460px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.hand-queue-item {
|
.hand-queue-item {
|
||||||
|
|||||||
Reference in New Issue
Block a user