feat(room): роль организатора в метаданных LiveKit-токена
Предварительная работа для очереди рук и принудительного мьюта (B1/B2): build_join кладёт is_organizer:true в метаданные токена организатора (создатель мгновенной конференции и владелец при обычном входе). Метаданные токена — только подсказка для UI (см. предупреждение в докстринге build_join), любое серверное действие организатора обязано перепроверяться по conference.owner_id в БД — так и сделано в mute_participant (B2). На фронте — общий парсер метаданных участника (lib/participantMetadata.ts, переиспользован в RoomParticipantTile вместо локальной копии) и хук useIsOrganizer (читает подсказку для локального участника через useLocalParticipant — вызывается только внутри LiveKitRoom).
This commit is contained in:
@@ -53,19 +53,31 @@ def build_join(
|
|||||||
name: str,
|
name: str,
|
||||||
chat_enabled: bool,
|
chat_enabled: bool,
|
||||||
avatar_url: str | None = None,
|
avatar_url: str | None = None,
|
||||||
|
is_organizer: bool = False,
|
||||||
) -> JoinOut:
|
) -> JoinOut:
|
||||||
"""Построить ответ join: LiveKit access-токен для входа в комнату конференции.
|
"""Построить ответ join: LiveKit access-токен для входа в комнату конференции.
|
||||||
|
|
||||||
Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4).
|
Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4).
|
||||||
`chat_enabled` — снятый вызывающей стороной тоггл `instance_settings`:
|
`chat_enabled` — снятый вызывающей стороной тоггл `instance_settings`:
|
||||||
читается здесь параметром, а не заново из БД, чтобы не плодить
|
читается здесь параметром, а не заново из БД, чтобы не плодить
|
||||||
отдельный запрос настроек на каждый join. `avatar_url` прокидывается
|
отдельный запрос настроек на каждый join. `avatar_url`/`is_organizer`
|
||||||
в метаданные токена как JSON
|
прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}`
|
||||||
`{"avatar_url": ...}`; `None` (гость либо пользователь без аватара) —
|
— поля добавляются, только если заданы (гость без аватара и не-организатор
|
||||||
метаданные не выставляются вовсе.
|
получают токен вовсе без метаданных, как и раньше).
|
||||||
|
|
||||||
|
⚠️ `is_organizer` в метаданных — только подсказка для UI клиента (показать/
|
||||||
|
скрыть кнопки организатора). Метаданным токена доверять для АВТОРИЗАЦИИ
|
||||||
|
нельзя — участник технически может их подделать на своей стороне. Любое
|
||||||
|
серверное действие организатора (например, принудительный мьют) обязано
|
||||||
|
заново проверяться по `conference.owner_id` в БД, а не по этому полю.
|
||||||
"""
|
"""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
metadata = json.dumps({"avatar_url": avatar_url}) if avatar_url else None
|
metadata_payload: dict[str, object] = {}
|
||||||
|
if avatar_url:
|
||||||
|
metadata_payload["avatar_url"] = avatar_url
|
||||||
|
if is_organizer:
|
||||||
|
metadata_payload["is_organizer"] = True
|
||||||
|
metadata = json.dumps(metadata_payload) if metadata_payload else None
|
||||||
token = create_room_access_token(
|
token = create_room_access_token(
|
||||||
room_name=conference.slug, identity=identity, name=name, metadata=metadata
|
room_name=conference.slug, identity=identity, name=name, metadata=metadata
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ class ConferenceService:
|
|||||||
name=owner_name,
|
name=owner_name,
|
||||||
chat_enabled=chat_enabled,
|
chat_enabled=chat_enabled,
|
||||||
avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path),
|
avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path),
|
||||||
|
is_organizer=True,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Плановая (разовая) либо закреплённая с повторением/датой — есть
|
# Плановая (разовая) либо закреплённая с повторением/датой — есть
|
||||||
@@ -247,6 +248,7 @@ class ConferenceService:
|
|||||||
name=user.name_user,
|
name=user.name_user,
|
||||||
chat_enabled=chat_enabled,
|
chat_enabled=chat_enabled,
|
||||||
avatar_url=resolve_avatar_url(self._media_root, user.avatar_path),
|
avatar_url=resolve_avatar_url(self._media_root, user.avatar_path),
|
||||||
|
is_organizer=conference.owner_id is not None and conference.owner_id == user.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def join_as_guest(self, conference_id: uuid.UUID, *, data: GuestJoinIn) -> JoinOut:
|
async def join_as_guest(self, conference_id: uuid.UUID, *, data: GuestJoinIn) -> JoinOut:
|
||||||
|
|||||||
@@ -708,6 +708,38 @@ async def test_join_metadata_absent_for_user_without_avatar(
|
|||||||
assert "metadata" not in payload
|
assert "metadata" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
async def test_join_metadata_contains_is_organizer_for_owner(
|
||||||
|
client: httpx.AsyncClient, db_session: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
owner = await _make_user(db_session)
|
||||||
|
conference = await _make_conference(db_session, owner_id=owner.id)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(owner)
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
payload = _decode_livekit_token(response.json()["token"])
|
||||||
|
metadata = json.loads(str(payload["metadata"]))
|
||||||
|
assert metadata["is_organizer"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_join_metadata_absent_is_organizer_for_non_owner(
|
||||||
|
client: httpx.AsyncClient, db_session: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
owner = await _make_user(db_session)
|
||||||
|
other = await _make_user(db_session)
|
||||||
|
conference = await _make_conference(db_session, owner_id=owner.id)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/v1/conferences/{conference.id}/join", headers=_auth_headers(other)
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
payload = _decode_livekit_token(response.json()["token"])
|
||||||
|
assert "metadata" not in payload
|
||||||
|
|
||||||
|
|
||||||
async def test_guest_join_metadata_is_absent(
|
async def test_guest_join_metadata_is_absent(
|
||||||
client: httpx.AsyncClient, db_session: AsyncSession
|
client: httpx.AsyncClient, db_session: AsyncSession
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
13
frontend/src/hooks/useIsOrganizer.ts
Normal file
13
frontend/src/hooks/useIsOrganizer.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useLocalParticipant } from '@livekit/components-react'
|
||||||
|
import { parseParticipantMetadata } from '@/lib/participantMetadata'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Организатор ли ТЕКУЩИЙ (локальный) участник комнаты — читает подсказку
|
||||||
|
* `is_organizer` из метаданных собственного LiveKit-токена (см.
|
||||||
|
* `lib/participantMetadata.ts`). Только для UI (показать/скрыть кнопки
|
||||||
|
* организатора) — серверные действия перепроверяют права по БД сами.
|
||||||
|
*/
|
||||||
|
export function useIsOrganizer(): boolean {
|
||||||
|
const { localParticipant } = useLocalParticipant()
|
||||||
|
return Boolean(parseParticipantMetadata(localParticipant.metadata).is_organizer)
|
||||||
|
}
|
||||||
26
frontend/src/lib/participantMetadata.ts
Normal file
26
frontend/src/lib/participantMetadata.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Метаданные участника из LiveKit access-токена (см. `AccessToken.with_metadata`
|
||||||
|
* на backend, `services/conference_access.py::build_join`) — JSON
|
||||||
|
* `{"avatar_url"?: string, "is_organizer"?: true}`. У гостей и участников без
|
||||||
|
* аватара/прав организатора соответствующие поля отсутствуют.
|
||||||
|
*/
|
||||||
|
export interface ParticipantMetadata {
|
||||||
|
avatar_url?: string | null
|
||||||
|
/**
|
||||||
|
* Подсказка для UI — организатор ли участник. НЕ источник авторизации:
|
||||||
|
* метаданные читает и потенциально может подделать сам клиент. Любое
|
||||||
|
* серверное действие организатора (например, принудительный мьют)
|
||||||
|
* перепроверяется backend'ом по владельцу конференции в БД.
|
||||||
|
*/
|
||||||
|
is_organizer?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Разобрать `participant.metadata` — пустой объект, если поля нет, метаданные пусты или невалидны. */
|
||||||
|
export function parseParticipantMetadata(metadata: string | undefined): ParticipantMetadata {
|
||||||
|
if (!metadata) return {}
|
||||||
|
try {
|
||||||
|
return JSON.parse(metadata) as ParticipantMetadata
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user