diff --git a/CLAUDE.md b/CLAUDE.md index 39e81e6..0bde828 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,8 +75,12 @@ Trzy role, egzekwowane uprawnieniami w tokenie dostepu, nie regulaminem: | prelegent | publikuje obraz i dzwiek | | uczestnik | tylko odbiera, ma czat i pytania | | uczestnik z glosem | publikuje wylacznie dzwiek, po zgodzie moderatora | +| uczestnik z kamera albo ekranem | publikuje obraz lub ekran, wylacznie po indywidualnej zgodzie prowadzacego | -Wideo uczestnikow jest zawsze wylaczone na poziomie uprawnien. +Wideo i ekran uczestnikow sa **domyslnie** wylaczone na poziomie uprawnien. +Wlacza je wylacznie prowadzacy: calej sali ("wideo dla wszystkich", bez ekranu) +albo konkretnej osobie (kamera i ekran osobno, patrz "Indywidualna zgoda na +kamere i ekran"). Uczestnik sam niczego sobie nie wlaczy. ## Zasady pracy @@ -1683,6 +1687,33 @@ Tabela `speak_requests` (V17), kolejka w panelu prowadzenia, temat WebSocket czekajacy piec minut widzi, jak mowi ktos zgloszony przed chwila. - Pilnuje tego `SpeakRequestTest`, w tym dziesiec rownoczesnych zgloszen. +### Indywidualna zgoda na kamere i ekran + +Kolumny `registrations.camera_allowed` i `screen_allowed` (V32), przelaczniki +w karcie "Na sali", endpoint `POST /admin/webinars/{id}/room/participants/{rid}/publish`. + +- **Trzy niezalezne prawa, nie jedna rola** (`PublishRights`: mikrofon, kamera, + ekran). Kombinacji jest osiem, a enum rol na kazda oznaczalby, ze latwo + przeoczyc, ktora odziedziczyla za duzo. `RoomRole` zostaje dla dostawcow + znajacych tylko role (`closestRole`, nigdy PRESENTER). +- **Zgoda na jedno zrodlo nie otwiera innego.** Sam ekran to nie kamera, sama + kamera to nie mikrofon. Pilnuja tego `PublishRightsTest` (uprawnienia na zywo) + i `PublishRightsTokenTest` (lista zrodel w podpisanym tokenie), oba sprawdzone + w obie strony. +- **Prawa licza sie w jednym miejscu** (`VideoService.participantRights`) dla + tokenu i dla zmiany na zywo. Zgoda indywidualna jest niezalezna od trybow sali: + wylaczenie "wideo dla wszystkich" nie odbiera kamery osobie, ktorej dano ja osobno. +- **Tylko przy LiveKit.** Wylacznie tam serwer egzekwuje osobno kazde zrodlo; + przy innych dostawcach panel nie pokazuje tych przelacznikow. +- **Kolejnosc: zapis, serwer pokoju, zdarzenie.** Zdarzenie `PUBLISH_CHANGED` + sluzy przegladarce do pokazania komunikatu i pobrania aktualnego biletu na + wypadek ponownego wejscia; samo uprawnienie zmienil juz serwer. +- **Uczestnik z prawem do ekranu dostaje ostrzezenie**, ze sala zobaczy wszystko, + co pokaze, lacznie z powiadomieniami. Przycisku udostepniania nie da sie + przechwycic (to pasek LiveKit), wiec ostrzezenie wisi, dopoki zgoda trwa. +- Pasek sterowania LiveKit pokazuje przyciski wedlug `canPublishSources`, wiec + uczestnik z samym ekranem widzi tylko przycisk ekranu. + ### Moderacja wyrazow Tabela `moderation_words` (V31), ekran `/moderacja-wyrazow`, endpointy @@ -2098,7 +2129,8 @@ ustawienie `VIDEO_PROVIDER`. Na start dwie: `NONE` i `DAILY`. - **Uprawnienia wynikaja z tokenu u dostawcy, nie z interfejsu.** Ukrycie przycisku kamery niczego nie zalatwia: uczestnik moze podmienic kod strony albo wejsc wlasnym klientem. `RoomRole` przeklada sie na `permissions.canSend` w tokenie. - **Wideo uczestnika jest wylaczone zawsze**, takze po podniesieniu do SPEAKER. + **Wideo uczestnika jest domyslnie wylaczone**, takze po podniesieniu do SPEAKER. + Kamere i ekran daje wylacznie prowadzacy (`PublishRights`). - `RegistrationRoomRole` (VIEWER, SPEAKER) jest celowo wezszy niz `RoomRole`: uczestnik z zapisu **nigdy** nie dostaje uprawnien moderatora. Ma je wylacznie prelegent z `webinar_speakers`, wchodzacy wlasnym linkiem. Pilnuje tego diff --git a/backend/src/main/java/com/softspm/webinar/domain/Registration.java b/backend/src/main/java/com/softspm/webinar/domain/Registration.java index 8112e04..22c8a27 100644 --- a/backend/src/main/java/com/softspm/webinar/domain/Registration.java +++ b/backend/src/main/java/com/softspm/webinar/domain/Registration.java @@ -79,6 +79,20 @@ public class Registration { @Column(name = "room_banned", nullable = false) private boolean roomBanned; + /** + * Prowadzacy pozwolil tej osobie wlaczyc kamere, niezaleznie od glosu + * i od trybu "wideo dla wszystkich". Domyslnie false. + */ + @Column(name = "camera_allowed", nullable = false) + private boolean cameraAllowed; + + /** + * Prowadzacy pozwolil tej osobie udostepnic ekran. Domyslnie false: + * udostepniony ekran widzi cala sala, takze to, co pokazano przypadkiem. + */ + @Column(name = "screen_allowed", nullable = false) + private boolean screenAllowed; + /** * Rola w pokoju wideo. * diff --git a/backend/src/main/java/com/softspm/webinar/interaction/InteractionBroadcaster.java b/backend/src/main/java/com/softspm/webinar/interaction/InteractionBroadcaster.java index 13e8aee..700662d 100644 --- a/backend/src/main/java/com/softspm/webinar/interaction/InteractionBroadcaster.java +++ b/backend/src/main/java/com/softspm/webinar/interaction/InteractionBroadcaster.java @@ -173,6 +173,22 @@ public class InteractionBroadcaster { new ModerationEvent(kind, registrationId)); } + /** + * Zmiana indywidualnej zgody na kamere albo ekran. + * + * Uprawnienie zmienia juz serwer pokoju; zdarzenie sluzy temu, zeby + * przegladarka uczestnika pokazala, co dostal, i pobrala aktualny token na + * wypadek ponownego wejscia. Temat jest wspolny, wiec odbiorca filtruje po + * `registrationId`. + */ + public void publishRightsChanged(Long webinarId, Long registrationId, boolean camera, boolean screen) { + messaging.convertAndSend(speakTopic(webinarId), + new PublishRightsEvent("PUBLISH_CHANGED", registrationId, camera, screen)); + } + + public record PublishRightsEvent(String kind, Long registrationId, boolean camera, boolean screen) { + } + /** Koperta zdarzenia. `kind` mowi odbiorcy, co zrobic z `payload`. */ /** * Zdarzenie na temacie PUBLICZNYM: czat i pytania widoczne dla wszystkich diff --git a/backend/src/main/java/com/softspm/webinar/video/LiveKitVideoProvider.java b/backend/src/main/java/com/softspm/webinar/video/LiveKitVideoProvider.java index b8f436e..5c0288e 100644 --- a/backend/src/main/java/com/softspm/webinar/video/LiveKitVideoProvider.java +++ b/backend/src/main/java/com/softspm/webinar/video/LiveKitVideoProvider.java @@ -56,22 +56,6 @@ public class LiveKitVideoProvider implements VideoProvider { private static final Logger log = LoggerFactory.getLogger(LiveKitVideoProvider.class); - /** - * Zrodla, ktore wolno publikowac uczestnikowi z glosem. - * - * Nazwy pochodza z protokolu LiveKit. Brak "camera" na tej liscie jest - * **jedynym** miejscem, w ktorym zabraniamy uczestnikowi obrazu, i dziala - * po stronie serwera. - */ - private static final List AUDIO_ONLY = List.of("microphone"); - - /** Uczestnik w trybie wideo dla wszystkich: kamera i mikrofon, bez ekranu. */ - private static final List CAMERA_AND_MIC = List.of("camera", "microphone"); - - /** Prelegent: obraz, dzwiek i udostepnianie ekranu. */ - private static final List PRESENTER_SOURCES = - List.of("camera", "microphone", "screen_share", "screen_share_audio"); - /** * Jak dlugo wazny jest token wejscia. * @@ -129,84 +113,87 @@ public class LiveKitVideoProvider implements VideoProvider { token.setTtl(TOKEN_TTL_MILLIS); token.addGrants(new RoomJoin(true), new RoomName(roomId)); - token.addGrants(grantsFor(request.role())); + token.addGrants(grantsFor(request)); return new JoinTicket(url, token.toJwt(), true, request.role()); } /** - * Uprawnienia odpowiadajace roli. + * Uprawnienia w tokenie. * - * Kazda rola dostaje **jawna liste**, a nie modyfikacje poprzedniej: - * przy dziedziczeniu latwo przeoczyc, ze SPEAKER odziedziczyl prawo do - * kamery po PRESENTER. + * Zrodla publikacji pochodza z praw uczestnika (`PublishRights`), a nie + * z samej roli: prowadzacy moze dac konkretnej osobie kamere albo ekran + * bez glosu. Rola decyduje wylacznie o uprawnieniach moderatora, ktore ma + * tylko prelegent. */ - private List grantsFor(RoomRole role) { - return switch (role) { - case VIEWER -> List.of( - new CanSubscribe(true), - new CanPublish(false), - // dane pozwalaja bibliotece klienta na sygnalizacje - // (np. podniesienie reki), bez prawa do audio i wideo - new CanPublishData(true)); - - case SPEAKER -> List.of( - new CanSubscribe(true), - new CanPublish(true), - // sedno: publikacja ograniczona do mikrofonu. - // Bez tej linii SPEAKER moglby wlaczyc kamere. - new CanPublishSources(AUDIO_ONLY), - new CanPublishData(true)); - - case ACTIVE_PARTICIPANT -> List.of( - new CanSubscribe(true), - new CanPublish(true), - // tryb "wideo dla wszystkich": kamera i mikrofon, ale bez - // udostepniania ekranu - to zostaje przy prelegencie - new CanPublishSources(CAMERA_AND_MIC), - new CanPublishData(true)); - - case PRESENTER -> List.of( - new CanSubscribe(true), - new CanPublish(true), - new CanPublishSources(PRESENTER_SOURCES), - new CanPublishData(true), - new RoomAdmin(true), - new RoomRecord(true)); - }; + private List grantsFor(JoinRequest request) { + PublishRights rights = request.rights(); + List grants = new java.util.ArrayList<>(List.of( + new CanSubscribe(true), + new CanPublish(rights.any()), + // dane pozwalaja bibliotece klienta na sygnalizacje, + // bez prawa do audio i wideo + new CanPublishData(true))); + if (rights.any()) { + // sedno: jawna lista zrodel. Bez niej `canPublish` znaczyloby + // "wszystko", czyli takze kamere i ekran + grants.add(new CanPublishSources(sourcesFor(rights))); + } + if (request.role() == RoomRole.PRESENTER) { + grants.add(new RoomAdmin(true)); + grants.add(new RoomRecord(true)); + } + return grants; } /** * Te same uprawnienia co w tokenie, w postaci do zmiany na zywo. * - * Sluzy `RoomPermissionSync`: przyznanie i odebranie glosu zmienia - * uprawnienia uczestnika juz polaczonego, bez ponownego wejscia na sale. - * Zrodla publikacji pochodza z tych samych stalych co `grantsFor`, wiec + * Sluzy `RoomPermissionSync`: przyznanie i odebranie glosu, kamery albo + * ekranu zmienia uprawnienia uczestnika juz polaczonego, bez ponownego + * wejscia na sale. Zrodla pochodza z tej samej `sourcesFor` co token, wiec * token i zmiana na zywo nie moga sie rozjechac. * * UWAGA: `canPublish=true` z PUSTA lista zrodel oznacza u LiveKit "wszystkie * zrodla", czyli takze kamere i ekran. Dlatego lista jest zawsze jawna, - * a VIEWER ma `canPublish=false`. + * a brak praw daje `canPublish=false`. */ - LivekitModels.ParticipantPermission permissionFor(RoomRole role) { + LivekitModels.ParticipantPermission permissionFor(PublishRights rights) { var builder = LivekitModels.ParticipantPermission.newBuilder() .setCanSubscribe(true) .setCanPublishData(true) - .setCanPublish(role != RoomRole.VIEWER); - for (String source : sourcesFor(role)) { + .setCanPublish(rights.any()); + for (String source : sourcesFor(rights)) { builder.addCanPublishSources(trackSource(source)); } return builder.build(); } - /** Zrodla, ktore rola moze publikowac. Pusta lista przy VIEWER. */ + LivekitModels.ParticipantPermission permissionFor(RoomRole role) { + return permissionFor(PublishRights.of(role)); + } + + /** + * Zrodla publikacji. Nazwy pochodza z protokolu LiveKit. + * + * Brak "camera" i "screen_share" na tej liscie jest JEDYNYM miejscem, + * w ktorym zabraniamy uczestnikowi obrazu i ekranu, i dziala po stronie + * serwera. Pusta lista przy braku praw. + */ + static List sourcesFor(PublishRights rights) { + List sources = new java.util.ArrayList<>(); + if (rights.audio()) sources.add("microphone"); + if (rights.camera()) sources.add("camera"); + if (rights.screen()) { + sources.add("screen_share"); + // dzwiek udostepnianej karty, np. film pokazywany na ekranie + sources.add("screen_share_audio"); + } + return List.copyOf(sources); + } + static List sourcesFor(RoomRole role) { - return switch (role) { - case VIEWER -> List.of(); - case SPEAKER -> AUDIO_ONLY; - case ACTIVE_PARTICIPANT -> CAMERA_AND_MIC; - case PRESENTER -> PRESENTER_SOURCES; - }; + return sourcesFor(PublishRights.of(role)); } static LivekitModels.TrackSource trackSource(String name) { diff --git a/backend/src/main/java/com/softspm/webinar/video/PublicJoinController.java b/backend/src/main/java/com/softspm/webinar/video/PublicJoinController.java index d936130..fb15716 100644 --- a/backend/src/main/java/com/softspm/webinar/video/PublicJoinController.java +++ b/backend/src/main/java/com/softspm/webinar/video/PublicJoinController.java @@ -91,6 +91,8 @@ public class PublicJoinController { */ boolean canPublishVideo, boolean canPublishAudio, + /** Czy wolno udostepnic ekran; u uczestnika tylko za zgoda prowadzacego. */ + boolean canShareScreen, /** * Publiczny klucz klienta do osadzenia (Zoom SDK Key). * Pusty dla dostawcow, ktorzy go nie potrzebuja. @@ -111,8 +113,9 @@ public class PublicJoinController { result.timezone(), result.provider(), result.roomId(), - result.ticket().role().canPublishVideo(), - result.ticket().role().canPublishAudio(), + result.rights().camera(), + result.rights().audio(), + result.rights().screen(), result.ticket().clientKey()); } } diff --git a/backend/src/main/java/com/softspm/webinar/video/PublishRights.java b/backend/src/main/java/com/softspm/webinar/video/PublishRights.java new file mode 100644 index 0000000..6b38024 --- /dev/null +++ b/backend/src/main/java/com/softspm/webinar/video/PublishRights.java @@ -0,0 +1,42 @@ +package com.softspm.webinar.video; + +/** + * Co uczestnik moze nadawac: mikrofon, kamera, udostepnienie ekranu. + * + * TRZY NIEZALEZNE PRAWA, NIE JEDNA ROLA + * --------------------------------------------------------------------------- + * `RoomRole` opisuje stale zestawy (widz, z glosem, wideo dla wszystkich, + * prelegent). Prowadzacy moze jednak dac konkretnej osobie sama kamere albo + * sam ekran, niezaleznie od tego, czy ma glos. Takich kombinacji jest osiem, + * a dopisywanie roli na kazda oznaczaloby enum, w ktorym latwo przeoczyc, + * ze ktoras odziedziczyla za duzo. + * + * Rola zostaje dla dostawcow, ktorzy znaja tylko role (Daily, Jitsi, Zoom): + * dostaja najblizsza (`closestRole`). Dokladne prawa egzekwuje LiveKit, + * lista zrodel w tokenie i w zmianie na zywo. + */ +public record PublishRights(boolean audio, boolean camera, boolean screen) { + + public static final PublishRights NONE = new PublishRights(false, false, false); + + /** Prawa wynikajace z samej roli. Ekran wylacznie u prelegenta. */ + public static PublishRights of(RoomRole role) { + return new PublishRights(role.canPublishAudio(), role.canPublishVideo(), + role == RoomRole.PRESENTER); + } + + /** Czy wolno nadawac cokolwiek. */ + public boolean any() { + return audio || camera || screen; + } + + /** + * Rola najblizsza tym prawom, dla dostawcow bez rozroznienia zrodel. + * Nigdy PRESENTER: uczestnik z zapisu nie dostaje uprawnien moderatora. + */ + public RoomRole closestRole() { + if (camera) return RoomRole.ACTIVE_PARTICIPANT; + if (audio) return RoomRole.SPEAKER; + return RoomRole.VIEWER; + } +} diff --git a/backend/src/main/java/com/softspm/webinar/video/RoomPermissionSync.java b/backend/src/main/java/com/softspm/webinar/video/RoomPermissionSync.java index 6a25ade..f42678a 100644 --- a/backend/src/main/java/com/softspm/webinar/video/RoomPermissionSync.java +++ b/backend/src/main/java/com/softspm/webinar/video/RoomPermissionSync.java @@ -87,9 +87,9 @@ public class RoomPermissionSync { // nie ma go na sali: uprawnienia wezmie z tokenu przy wejsciu return; } - RoomRole role = videoService.participantRoles(webinarId, List.of(registrationId)) - .getOrDefault(registrationId, RoomRole.VIEWER); - apply(client, room, response.body(), role); + PublishRights rights = videoService.participantRights(webinarId, List.of(registrationId)) + .getOrDefault(registrationId, PublishRights.NONE); + apply(client, room, response.body(), rights); } catch (Exception e) { log.warn("Uprawnienia: synchronizacja reg-{} w webinarze {} nieudana: {}", registrationId, webinarId, e.getMessage()); @@ -121,7 +121,7 @@ public class RoomPermissionSync { .map(p -> RoomPresenceService.idOf(p.getIdentity(), "reg-")) .filter(Objects::nonNull) .toList(); - Map roles = videoService.participantRoles(webinarId, ids); + Map rights = videoService.participantRights(webinarId, ids); int changed = 0; for (LivekitModels.ParticipantInfo info : participants) { @@ -129,7 +129,7 @@ public class RoomPermissionSync { // prelegent i polaczenia spoza zapisu zachowuja swoje uprawnienia if (id == null) continue; try { - if (apply(client, room, info, roles.getOrDefault(id, RoomRole.VIEWER))) changed++; + if (apply(client, room, info, rights.getOrDefault(id, PublishRights.NONE))) changed++; } catch (Exception e) { log.warn("Uprawnienia: {} w webinarze {} nieudane: {}", info.getIdentity(), webinarId, e.getMessage()); @@ -147,8 +147,8 @@ public class RoomPermissionSync { * @return czy cokolwiek zostalo zmienione */ private boolean apply(RoomServiceClient client, String room, - LivekitModels.ParticipantInfo info, RoomRole role) throws java.io.IOException { - LivekitModels.ParticipantPermission target = liveKit.permissionFor(role); + LivekitModels.ParticipantInfo info, PublishRights rights) throws java.io.IOException { + LivekitModels.ParticipantPermission target = liveKit.permissionFor(rights); if (samePermission(info.getPermission(), target)) { return false; } diff --git a/backend/src/main/java/com/softspm/webinar/video/RoomPresenceController.java b/backend/src/main/java/com/softspm/webinar/video/RoomPresenceController.java index d854e1b..6f62282 100644 --- a/backend/src/main/java/com/softspm/webinar/video/RoomPresenceController.java +++ b/backend/src/main/java/com/softspm/webinar/video/RoomPresenceController.java @@ -30,13 +30,19 @@ public class RoomPresenceController { private final RoomPresenceService presence; private final InteractionService interactions; private final InteractionBroadcaster broadcaster; + private final VideoService videoService; + private final RoomPermissionSync permissionSync; public RoomPresenceController(RoomPresenceService presence, InteractionService interactions, - InteractionBroadcaster broadcaster) { + InteractionBroadcaster broadcaster, + VideoService videoService, + RoomPermissionSync permissionSync) { this.presence = presence; this.interactions = interactions; this.broadcaster = broadcaster; + this.videoService = videoService; + this.permissionSync = permissionSync; } @GetMapping @@ -89,6 +95,32 @@ public class RoomPresenceController { public record RemoveRequest(@NotBlank String identity) { } + /** + * Indywidualna zgoda na kamere i udostepnienie ekranu. + * + * Kolejnosc ma znaczenie: najpierw zapis w bazie (transakcja zatwierdzona), + * potem zmiana na serwerze pokoju, na koncu zdarzenie dla przegladarki. + * Odwrotnie przegladarka pobralaby token z jeszcze starymi prawami. + * + * Dziala wylacznie przy LiveKit: tylko tam serwer egzekwuje osobno kamere + * i ekran. Przy innych dostawcach panel nie pokazuje tych przelacznikow. + */ + @PostMapping("/{registrationId}/publish") + @Operation(summary = "Zgoda uczestnika na kamere i udostepnienie ekranu") + public VideoService.IndividualRights setPublishRights(@PathVariable Long id, + @PathVariable Long registrationId, + @RequestBody PublishRequest request) { + VideoService.IndividualRights rights = + videoService.setIndividualRights(id, registrationId, request.camera(), request.screen()); + permissionSync.syncParticipant(id, registrationId); + broadcaster.publishRightsChanged(id, registrationId, rights.camera(), rights.screen()); + return rights; + } + + /** Null w polu oznacza "bez zmiany". */ + public record PublishRequest(Boolean camera, Boolean screen) { + } + /** * @param removedFromRoom serwer pokoju potwierdzil usuniecie; false przy * dostawcy bez tej operacji albo gdy osoba juz wyszla diff --git a/backend/src/main/java/com/softspm/webinar/video/RoomPresenceService.java b/backend/src/main/java/com/softspm/webinar/video/RoomPresenceService.java index 6eb124f..eaa5068 100644 --- a/backend/src/main/java/com/softspm/webinar/video/RoomPresenceService.java +++ b/backend/src/main/java/com/softspm/webinar/video/RoomPresenceService.java @@ -272,15 +272,18 @@ public class RoomPresenceService { if (registration != null) { result.add(new ParticipantView(identity, fullName(registration), KIND_PARTICIPANT, registration.getId(), registration.getEmail(), joinedAt, audio, video, screen, - registration.isChatMuted(), registration.isRoomBanned())); + registration.isChatMuted(), registration.isRoomBanned(), + registration.isCameraAllowed(), registration.isScreenAllowed())); } else if (speaker != null) { result.add(new ParticipantView(identity, speaker.getFullName(), KIND_SPEAKER, - null, speaker.getEmail(), joinedAt, audio, video, screen, false, false)); + null, speaker.getEmail(), joinedAt, audio, video, screen, false, false, + true, true)); } else { // polaczenie spoza naszej sciezki wejscia: nie ma go w bazie String name = info.getName().isBlank() ? identity : info.getName(); result.add(new ParticipantView(identity, name, KIND_OTHER, - null, null, joinedAt, audio, video, screen, false, false)); + null, null, joinedAt, audio, video, screen, false, false, + false, false)); } } return result; @@ -299,10 +302,11 @@ public class RoomPresenceService { if (r != null) { view = new ParticipantView("reg-" + r.getId(), fullName(r), KIND_PARTICIPANT, r.getId(), r.getEmail(), session.getJoinedAt(), false, false, false, - r.isChatMuted(), r.isRoomBanned()); + r.isChatMuted(), r.isRoomBanned(), r.isCameraAllowed(), r.isScreenAllowed()); } else if (s != null) { view = new ParticipantView("spk-" + s.getId(), s.getFullName(), KIND_SPEAKER, - null, s.getEmail(), session.getJoinedAt(), false, false, false, false, false); + null, s.getEmail(), session.getJoinedAt(), false, false, false, false, false, + true, true); } else { continue; } @@ -374,7 +378,11 @@ public class RoomPresenceService { public record ParticipantView(String identity, String name, String kind, Long registrationId, String email, OffsetDateTime joinedAt, boolean audio, boolean video, boolean screen, - boolean chatMuted, boolean roomBanned) { + boolean chatMuted, boolean roomBanned, + /** Indywidualna zgoda prowadzacego na kamere (prelegent zawsze true). */ + boolean cameraAllowed, + /** Indywidualna zgoda prowadzacego na ekran (prelegent zawsze true). */ + boolean screenAllowed) { } public record BannedView(Long registrationId, String name, String email) { diff --git a/backend/src/main/java/com/softspm/webinar/video/VideoProvider.java b/backend/src/main/java/com/softspm/webinar/video/VideoProvider.java index 6e2a5c5..c1ba7a3 100644 --- a/backend/src/main/java/com/softspm/webinar/video/VideoProvider.java +++ b/backend/src/main/java/com/softspm/webinar/video/VideoProvider.java @@ -121,7 +121,12 @@ public interface VideoProvider { } /** Kto wchodzi do pokoju i z jakimi uprawnieniami. */ - record JoinRequest(String displayName, String userId, RoomRole role) { + record JoinRequest(String displayName, String userId, RoomRole role, PublishRights rights) { + + /** Prawa wynikajace z samej roli, bez indywidualnych zgod prowadzacego. */ + JoinRequest(String displayName, String userId, RoomRole role) { + this(displayName, userId, role, PublishRights.of(role)); + } } /** diff --git a/backend/src/main/java/com/softspm/webinar/video/VideoService.java b/backend/src/main/java/com/softspm/webinar/video/VideoService.java index 9f2884b..100ac70 100644 --- a/backend/src/main/java/com/softspm/webinar/video/VideoService.java +++ b/backend/src/main/java/com/softspm/webinar/video/VideoService.java @@ -264,14 +264,15 @@ public class VideoService { * bo dostawca odrzuci publikacje bez uprawnienia. Zmiane w trakcie * spotkania przenosi na polaczonych `RoomPermissionSync`. */ - RoomRole role = participantRole(webinar, registration); + PublishRights rights = participantRights(webinar, registration); VideoProvider.JoinTicket ticket = provider.createJoinTicket(webinar, - new VideoProvider.JoinRequest(displayName, "reg-" + registration.getId(), role)); + new VideoProvider.JoinRequest(displayName, "reg-" + registration.getId(), + rights.closestRole(), rights)); return new JoinResult(ticket, webinar.getTitle(), displayName, window, webinar.getStartsAt(), webinar.getTimezone(), - provider.name(), webinar.getRoomId()); + provider.name(), webinar.getRoomId(), rights); } /** @@ -299,24 +300,63 @@ public class VideoService { } /** - * Role wskazanych uczestnikow, wylacznie tych nalezacych do webinaru. + * Co uczestnik moze nadawac: rola sali (glos, oba tryby) plus indywidualne + * zgody prowadzacego na kamere i ekran. + * + * Zgody indywidualne sa NIEZALEZNE od trybow: wylaczenie "wideo dla + * wszystkich" nie odbiera kamery osobie, ktorej prowadzacy dal ja osobno, + * tak samo jak wylaczenie otwartego mikrofonu nie odbiera przyznanego glosu. + */ + public static PublishRights participantRights(Webinar webinar, Registration registration) { + RoomRole base = participantRole(webinar, registration); + return new PublishRights( + base.canPublishAudio(), + base.canPublishVideo() || registration.isCameraAllowed(), + registration.isScreenAllowed()); + } + + /** + * Prawa wskazanych uczestnikow, wylacznie tych nalezacych do webinaru. * * Identyfikatory pochodza z pokoju u dostawcy, wiec zapis z innego * webinaru jest pomijany, a nie podnoszony. */ @Transactional(readOnly = true) - public Map participantRoles(Long webinarId, java.util.Collection registrationIds) { + public Map participantRights(Long webinarId, java.util.Collection registrationIds) { Webinar webinar = webinarRepository.findById(webinarId) .orElseThrow(() -> new NotFoundException("Nie znaleziono webinaru")); - Map roles = new java.util.HashMap<>(); + Map rights = new java.util.HashMap<>(); for (Registration registration : registrationRepository.findAllById(registrationIds)) { if (registration.getWebinar().getId().equals(webinarId) && registration.getStatus() == RegistrationStatus.CONFIRMED && !registration.isRoomBanned()) { - roles.put(registration.getId(), participantRole(webinar, registration)); + rights.put(registration.getId(), participantRights(webinar, registration)); } } - return roles; + return rights; + } + + /** + * Indywidualna zgoda na kamere i ekran, z panelu prowadzenia. + * + * Null oznacza "bez zmiany", zeby przelacznik kamery nie ruszal ekranu. + * Zapis musi nalezec do webinaru z adresu: identyfikator przychodzi + * z zewnatrz. + */ + @Transactional + public IndividualRights setIndividualRights(Long webinarId, Long registrationId, + Boolean camera, Boolean screen) { + Registration registration = registrationRepository.findById(registrationId) + .filter(r -> r.getWebinar().getId().equals(webinarId)) + .orElseThrow(() -> new NotFoundException("Nie znaleziono uczestnika tego webinaru")); + if (camera != null) registration.setCameraAllowed(camera); + if (screen != null) registration.setScreenAllowed(screen); + registrationRepository.save(registration); + return new IndividualRights(registration.getId(), + registration.isCameraAllowed(), registration.isScreenAllowed()); + } + + public record IndividualRights(Long registrationId, boolean camera, boolean screen) { } /** @@ -342,7 +382,7 @@ public class VideoService { return new JoinResult(ticket, webinar.getTitle(), speaker.getFullName(), window(webinar), webinar.getStartsAt(), webinar.getTimezone(), - provider.name(), webinar.getRoomId()); + provider.name(), webinar.getRoomId(), PublishRights.of(RoomRole.PRESENTER)); } /** Okno, w ktorym pokoj jest otwarty dla uczestnikow. */ @@ -382,7 +422,9 @@ public class VideoService { /** Nazwa dostawcy: front wie, ktory komponent osadzic. */ String provider, /** Identyfikator pokoju u dostawcy. */ - String roomId) { + String roomId, + /** Co wolno nadawac: mikrofon, kamera, ekran. */ + PublishRights rights) { } /** diff --git a/backend/src/main/resources/db/migration/V32__participant_camera_screen.sql b/backend/src/main/resources/db/migration/V32__participant_camera_screen.sql new file mode 100644 index 0000000..75559e3 --- /dev/null +++ b/backend/src/main/resources/db/migration/V32__participant_camera_screen.sql @@ -0,0 +1,16 @@ +-- Indywidualne prawo uczestnika do kamery i do udostepnienia ekranu. +-- +-- Prowadzacy wlacza je konkretnej osobie z panelu prowadzenia, niezaleznie +-- od glosu (`room_role`) i od trybow calej sali (`open_mic`, `open_video`). +-- Osobne kolumny, a nie kolejne wartosci `room_role`: prawa sa niezalezne, +-- wiec wartosci na kazda kombinacje byloby osiem. +-- +-- Domyslnie false: wideo i ekran uczestnika sa wylaczone, dopoki prowadzacy +-- swiadomie ich nie przyzna. Udostepniony ekran widzi cala sala, lacznie +-- z tym, co uczestnik pokaze przez przypadek. + +ALTER TABLE registrations + ADD COLUMN camera_allowed boolean NOT NULL DEFAULT false; + +ALTER TABLE registrations + ADD COLUMN screen_allowed boolean NOT NULL DEFAULT false; diff --git a/backend/src/test/java/com/softspm/webinar/video/PublishRightsTest.java b/backend/src/test/java/com/softspm/webinar/video/PublishRightsTest.java new file mode 100644 index 0000000..1424a34 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/video/PublishRightsTest.java @@ -0,0 +1,107 @@ +package com.softspm.webinar.video; + +import com.softspm.webinar.domain.Registration; +import com.softspm.webinar.domain.RegistrationRoomRole; +import com.softspm.webinar.domain.Webinar; +import livekit.LivekitModels.TrackSource; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Indywidualna zgoda prowadzacego na kamere i ekran. + * + * Najwazniejsze sa dwie granice: zgoda na jedno zrodlo nie moze otwierac + * innego (sam ekran to nie kamera, sama kamera to nie mikrofon), a uczestnik + * z zapisu nigdy nie dostaje uprawnien prelegenta. + */ +class PublishRightsTest { + + private final LiveKitVideoProvider provider = new LiveKitVideoProvider(null); + + private static Webinar webinar(boolean openMic, boolean openVideo) { + Webinar webinar = new Webinar(); + webinar.setOpenMic(openMic); + webinar.setOpenVideo(openVideo); + return webinar; + } + + private static Registration registration(RegistrationRoomRole role, boolean camera, boolean screen) { + Registration registration = new Registration(); + registration.setRoomRole(role); + registration.setCameraAllowed(camera); + registration.setScreenAllowed(screen); + return registration; + } + + private Set sources(PublishRights rights) { + return Set.copyOf(provider.permissionFor(rights).getCanPublishSourcesList()); + } + + @Test + void viewerWithoutGrantsPublishesNothing() { + PublishRights rights = VideoService.participantRights( + webinar(false, false), registration(RegistrationRoomRole.VIEWER, false, false)); + assertThat(rights).isEqualTo(PublishRights.NONE); + assertThat(provider.permissionFor(rights).getCanPublish()).isFalse(); + } + + @Test + void cameraGrantDoesNotOpenMicrophone() { + PublishRights rights = VideoService.participantRights( + webinar(false, false), registration(RegistrationRoomRole.VIEWER, true, false)); + assertThat(sources(rights)).containsExactly(TrackSource.CAMERA); + } + + @Test + void screenGrantDoesNotOpenCamera() { + PublishRights rights = VideoService.participantRights( + webinar(false, false), registration(RegistrationRoomRole.VIEWER, false, true)); + assertThat(sources(rights)) + .containsExactlyInAnyOrder(TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO); + } + + @Test + void grantsAddUpWithVoice() { + PublishRights rights = VideoService.participantRights( + webinar(false, false), registration(RegistrationRoomRole.SPEAKER, true, true)); + assertThat(sources(rights)).containsExactlyInAnyOrder(TrackSource.MICROPHONE, TrackSource.CAMERA, + TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO); + } + + @Test + void individualCameraSurvivesTurningOffVideoForAll() { + // tryb "wideo dla wszystkich" wylaczony, ale prowadzacy dal kamere tej osobie + PublishRights rights = VideoService.participantRights( + webinar(false, false), registration(RegistrationRoomRole.VIEWER, true, false)); + assertThat(rights.camera()).isTrue(); + } + + @Test + void videoForAllStillGivesNoScreen() { + PublishRights rights = VideoService.participantRights( + webinar(false, true), registration(RegistrationRoomRole.VIEWER, false, false)); + assertThat(sources(rights)).containsExactlyInAnyOrder(TrackSource.CAMERA, TrackSource.MICROPHONE); + } + + @Test + void participantNeverGetsPresenterRole() { + for (boolean audio : new boolean[] {false, true}) { + for (boolean camera : new boolean[] {false, true}) { + for (boolean screen : new boolean[] {false, true}) { + assertThat(new PublishRights(audio, camera, screen).closestRole()) + .isNotEqualTo(RoomRole.PRESENTER); + } + } + } + } + + @Test + void presenterKeepsEverything() { + assertThat(sources(PublishRights.of(RoomRole.PRESENTER))).containsExactlyInAnyOrder( + TrackSource.MICROPHONE, TrackSource.CAMERA, + TrackSource.SCREEN_SHARE, TrackSource.SCREEN_SHARE_AUDIO); + } +} diff --git a/backend/src/test/java/com/softspm/webinar/video/PublishRightsTokenTest.java b/backend/src/test/java/com/softspm/webinar/video/PublishRightsTokenTest.java new file mode 100644 index 0000000..7947fc9 --- /dev/null +++ b/backend/src/test/java/com/softspm/webinar/video/PublishRightsTokenTest.java @@ -0,0 +1,86 @@ +package com.softspm.webinar.video; + +import com.softspm.webinar.domain.Webinar; +import com.softspm.webinar.settings.SettingKey; +import com.softspm.webinar.settings.SettingsService; +import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.Test; + +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Token LiveKit wydany dla indywidualnych zgod na kamere i ekran. + * + * Bez kontekstu Springa i bez bazy: ustawienia sa podstawione, a token + * rozkladany tak samo jak w `ProviderTokenTest`. Sprawdza to, co faktycznie + * trafia do serwera LiveKit, czyli liste zrodel w podpisanym tokenie, a nie + * tylko obiekt uprawnien po naszej stronie. + */ +class PublishRightsTokenTest { + + private static final String SECRET = "sekret-testowy-livekit-co-najmniej-32-znaki"; + + private final LiveKitVideoProvider provider = new LiveKitVideoProvider(settings()); + + private static SettingsService settings() { + SettingsService settings = mock(SettingsService.class); + when(settings.get(SettingKey.LIVEKIT_URL)).thenReturn("wss://livekit.example.test"); + when(settings.get(SettingKey.LIVEKIT_API_KEY)).thenReturn("klucz-testowy"); + when(settings.get(SettingKey.LIVEKIT_API_SECRET)).thenReturn(SECRET); + return settings; + } + + @SuppressWarnings("unchecked") + private Map grants(PublishRights rights) { + Webinar webinar = new Webinar(); + webinar.setId(4242L); + String jwt = provider.createJoinTicket(webinar, new VideoProvider.JoinRequest( + "Jan Testowy", "reg-1", rights.closestRole(), rights)).token(); + var claims = Jwts.parser() + .verifyWith(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")) + .build() + .parseSignedClaims(jwt) + .getPayload(); + return (Map) claims.get("video"); + } + + @SuppressWarnings("unchecked") + private static List sources(Map video) { + Object raw = video.get("canPublishSources"); + return raw == null ? List.of() : (List) raw; + } + + @Test + void screenOnlyTokenHasNoCameraAndNoMicrophone() { + var video = grants(new PublishRights(false, false, true)); + assertThat(video.get("canPublish")).isEqualTo(true); + assertThat(sources(video)).containsExactlyInAnyOrder("screen_share", "screen_share_audio"); + } + + @Test + void cameraOnlyTokenHasNoMicrophone() { + var video = grants(new PublishRights(false, true, false)); + assertThat(sources(video)).containsExactly("camera"); + } + + @Test + void noRightsMeansNoPublishing() { + var video = grants(PublishRights.NONE); + assertThat(video.get("canPublish")).isEqualTo(false); + assertThat(sources(video)).isEmpty(); + } + + @Test + void participantTokenNeverGetsRoomAdmin() { + var video = grants(new PublishRights(true, true, true)); + assertThat(video.get("roomAdmin")).isNotEqualTo(true); + assertThat(video.get("roomRecord")).isNotEqualTo(true); + } +} diff --git a/frontend/src/lib/useInteractions.ts b/frontend/src/lib/useInteractions.ts index d8f54da..c969a40 100644 --- a/frontend/src/lib/useInteractions.ts +++ b/frontend/src/lib/useInteractions.ts @@ -81,10 +81,16 @@ type History = { questions: InteractionView[] } -/** Zdarzenie moderacji uczestnika: wyciszenie/blokada. */ +/** + * Zdarzenie dotyczace jednego uczestnika: wyciszenie, blokada albo zmiana + * indywidualnej zgody na kamere i ekran (`PUBLISH_CHANGED`). + */ export type ModerationEvent = { - kind: 'MUTED' | 'UNMUTED' | 'BANNED' | 'UNBANNED' + kind: 'MUTED' | 'UNMUTED' | 'BANNED' | 'UNBANNED' | 'PUBLISH_CHANGED' registrationId: number + /** Tylko przy PUBLISH_CHANGED. */ + camera?: boolean + screen?: boolean } type Event = { kind: string; payload: InteractionView } @@ -245,7 +251,8 @@ export function useInteractions( } if ( event.kind === 'MUTED' || event.kind === 'UNMUTED' || - event.kind === 'BANNED' || event.kind === 'UNBANNED' + event.kind === 'BANNED' || event.kind === 'UNBANNED' || + event.kind === 'PUBLISH_CHANGED' ) { const mod = event as unknown as ModerationEvent // reagujemy TYLKO na zdarzenie dotyczace tego uczestnika: temat jest diff --git a/frontend/src/pages/public/MeetingPage.tsx b/frontend/src/pages/public/MeetingPage.tsx index 78a3914..3b97df7 100644 --- a/frontend/src/pages/public/MeetingPage.tsx +++ b/frontend/src/pages/public/MeetingPage.tsx @@ -75,6 +75,8 @@ type JoinView = { */ canPublishVideo: boolean canPublishAudio: boolean + /** Udostepnianie ekranu: u uczestnika wylacznie za zgoda prowadzacego. */ + canShareScreen: boolean /** Publiczny klucz klienta do osadzenia (Zoom SDK Key). Pusty dla innych. */ clientKey: string | null } @@ -369,6 +371,24 @@ function MeetingRoom({

)} + {/* + Indywidualna zgoda prowadzacego. Przyciski kamery i ekranu pojawiaja + sie pod obrazem same, bo uprawnienie zmienia serwer; ta informacja + mowi uczestnikowi, ze to nie przypadek, i ostrzega przed ekranem. + */} + {!isSpeaker && join.embedded && join.provider === 'LIVEKIT' && join.canPublishVideo && !openVideo && ( +

+ Prowadzący pozwolił Ci włączyć kamerę. Włączysz ją przyciskiem pod obrazem. +

+ )} + {!isSpeaker && join.embedded && join.provider === 'LIVEKIT' && join.canShareScreen && ( +

+ Prowadzący pozwolił Ci udostępnić ekran. Wszyscy uczestnicy zobaczą to, co pokażesz, + także powiadomienia i pocztę. Przed udostępnieniem zamknij to, czego nie chcesz pokazywać, + albo udostępnij tylko jedno okno. +

+ )} + { if (event.kind === 'BANNED') setBanned(true) if (event.kind === 'UNBANNED') setBanned(false) + // uprawnienie zmienil juz serwer; nowy bilet niesie je do komunikatu + // i na wypadek ponownego wejscia po zerwaniu polaczenia + if (event.kind === 'PUBLISH_CHANGED' && join.embedded) void onRefreshTicket() }} onHistoryLoaded={(history) => { setOpenMic(history.openMic) diff --git a/frontend/src/pages/webinars/RoomParticipants.tsx b/frontend/src/pages/webinars/RoomParticipants.tsx index 4cfdbb9..e9aeaea 100644 --- a/frontend/src/pages/webinars/RoomParticipants.tsx +++ b/frontend/src/pages/webinars/RoomParticipants.tsx @@ -21,6 +21,10 @@ type Participant = { screen: boolean chatMuted: boolean roomBanned: boolean + /** Indywidualna zgoda prowadzacego na kamere. */ + cameraAllowed: boolean + /** Indywidualna zgoda prowadzacego na udostepnienie ekranu. */ + screenAllowed: boolean } type Presence = { @@ -143,6 +147,38 @@ export function RoomParticipants({ webinarId, timezone }: { webinarId: string; t } } + /** + * Zgoda na kamere albo ekran dla jednej osoby. + * + * Bez okna potwierdzenia: to nie kasuje ani nie blokuje, a cofniecie jest + * jednym kliknieciem i dziala od razu, bo uprawnienie zmienia serwer. + */ + async function togglePublish(participant: Participant, what: 'camera' | 'screen') { + if (participant.registrationId === null) return + const next = what === 'camera' ? !participant.cameraAllowed : !participant.screenAllowed + setBusy(participant.identity) + try { + await client.post( + `/admin/webinars/${webinarId}/room/participants/${participant.registrationId}/publish`, + { [what]: next }, + ) + toasts.success( + what === 'camera' + ? next + ? `${participant.name} może włączyć kamerę` + : `Wyłączono kamerę: ${participant.name}` + : next + ? `${participant.name} może udostępnić ekran` + : `Wyłączono udostępnianie ekranu: ${participant.name}`, + ) + await refresh() + } catch (error) { + toasts.error(errorMessage(error, 'Nie udało się zmienić uprawnień')) + } finally { + setBusy(null) + } + } + async function unban(registrationId: number, name: string) { setBusy(`ban-${registrationId}`) try { @@ -263,6 +299,53 @@ export function RoomParticipants({ webinarId, timezone }: { webinarId: string; t {participant.kind !== 'SPEAKER' && (
+ {/* + Kamera i ekran tylko przy LiveKit: wylacznie tam serwer + egzekwuje osobno kazde zrodlo. Przy innych dostawcach + przycisk obiecywalby cos, czego nikt nie pilnuje. + */} + {participant.kind === 'PARTICIPANT' && data?.source === 'LIVEKIT' && ( + <> + + + + )} {participant.kind === 'PARTICIPANT' && (