Kamera i ekran dla konkretnego uczestnika z panelu prowadzenia
Deploy PROD / deploy (push) Successful in 26s

Karta "Na sali" dostala przelaczniki kamery i udostepniania ekranu przy
kazdym uczestniku (wylacznie LiveKit). Uprawnienia licza sie z trzech
niezaleznych praw (PublishRights: mikrofon, kamera, ekran) zamiast jednej
roli, w jednym miejscu dla tokenu i zmiany na zywo; serwer pokoju nadaje
i odbiera je bez ponownego wejscia na sale.

Kolumny camera_allowed i screen_allowed (V32), domyslnie false. Zgoda
indywidualna przezywa wylaczenie "wideo dla wszystkich". Uczestnik
dostaje komunikat, a przy ekranie ostrzezenie, ze sala zobaczy wszystko.

PublishRightsTest i PublishRightsTokenTest pilnuja, ze zgoda na jedno
zrodlo nie otwiera innego; oba sprawdzone w obie strony.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 12:59:00 +02:00
parent dda54f00a1
commit 589c317841
17 changed files with 603 additions and 100 deletions
@@ -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.
*
@@ -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
@@ -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<String> AUDIO_ONLY = List.of("microphone");
/** Uczestnik w trybie wideo dla wszystkich: kamera i mikrofon, bez ekranu. */
private static final List<String> CAMERA_AND_MIC = List.of("camera", "microphone");
/** Prelegent: obraz, dzwiek i udostepnianie ekranu. */
private static final List<String> 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<io.livekit.server.VideoGrant> 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<io.livekit.server.VideoGrant> grantsFor(JoinRequest request) {
PublishRights rights = request.rights();
List<io.livekit.server.VideoGrant> 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<String> sourcesFor(PublishRights rights) {
List<String> 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<String> 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) {
@@ -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());
}
}
@@ -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;
}
}
@@ -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<Long, RoomRole> roles = videoService.participantRoles(webinarId, ids);
Map<Long, PublishRights> 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;
}
@@ -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
@@ -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) {
@@ -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));
}
}
/**
@@ -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<Long, RoomRole> participantRoles(Long webinarId, java.util.Collection<Long> registrationIds) {
public Map<Long, PublishRights> participantRights(Long webinarId, java.util.Collection<Long> registrationIds) {
Webinar webinar = webinarRepository.findById(webinarId)
.orElseThrow(() -> new NotFoundException("Nie znaleziono webinaru"));
Map<Long, RoomRole> roles = new java.util.HashMap<>();
Map<Long, PublishRights> 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) {
}
/**
@@ -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;
@@ -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<TrackSource> 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);
}
}
@@ -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<String, Object> 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<String, Object>) claims.get("video");
}
@SuppressWarnings("unchecked")
private static List<String> sources(Map<String, Object> video) {
Object raw = video.get("canPublishSources");
return raw == null ? List.of() : (List<String>) 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);
}
}