diff --git a/backend/services/conference_access.py b/backend/services/conference_access.py index 9a1729e..c8981d7 100644 --- a/backend/services/conference_access.py +++ b/backend/services/conference_access.py @@ -53,19 +53,31 @@ def build_join( name: str, chat_enabled: bool, 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` прокидывается - в метаданные токена как JSON - `{"avatar_url": ...}`; `None` (гость либо пользователь без аватара) — - метаданные не выставляются вовсе. + отдельный запрос настроек на каждый join. `avatar_url`/`is_organizer` + прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}` + — поля добавляются, только если заданы (гость без аватара и не-организатор + получают токен вовсе без метаданных, как и раньше). + + ⚠️ `is_organizer` в метаданных — только подсказка для UI клиента (показать/ + скрыть кнопки организатора). Метаданным токена доверять для АВТОРИЗАЦИИ + нельзя — участник технически может их подделать на своей стороне. Любое + серверное действие организатора (например, принудительный мьют) обязано + заново проверяться по `conference.owner_id` в БД, а не по этому полю. """ 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( room_name=conference.slug, identity=identity, name=name, metadata=metadata ) diff --git a/backend/services/conferences.py b/backend/services/conferences.py index 57c727e..d2cfa26 100644 --- a/backend/services/conferences.py +++ b/backend/services/conferences.py @@ -154,6 +154,7 @@ class ConferenceService: name=owner_name, chat_enabled=chat_enabled, avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path), + is_organizer=True, ) else: # Плановая (разовая) либо закреплённая с повторением/датой — есть @@ -247,6 +248,7 @@ class ConferenceService: name=user.name_user, chat_enabled=chat_enabled, 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: diff --git a/backend/tests/test_conferences_api.py b/backend/tests/test_conferences_api.py index 8df421a..1fbf8d8 100644 --- a/backend/tests/test_conferences_api.py +++ b/backend/tests/test_conferences_api.py @@ -708,6 +708,38 @@ async def test_join_metadata_absent_for_user_without_avatar( 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( client: httpx.AsyncClient, db_session: AsyncSession ) -> None: diff --git a/frontend/src/hooks/useIsOrganizer.ts b/frontend/src/hooks/useIsOrganizer.ts new file mode 100644 index 0000000..0944221 --- /dev/null +++ b/frontend/src/hooks/useIsOrganizer.ts @@ -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) +} diff --git a/frontend/src/lib/participantMetadata.ts b/frontend/src/lib/participantMetadata.ts new file mode 100644 index 0000000..35cdf71 --- /dev/null +++ b/frontend/src/lib/participantMetadata.ts @@ -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 {} + } +}