Kamera i ekran dla konkretnego uczestnika z panelu prowadzenia
Deploy PROD / deploy (push) Successful in 26s
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:
@@ -75,8 +75,12 @@ Trzy role, egzekwowane uprawnieniami w tokenie dostepu, nie regulaminem:
|
|||||||
| prelegent | publikuje obraz i dzwiek |
|
| prelegent | publikuje obraz i dzwiek |
|
||||||
| uczestnik | tylko odbiera, ma czat i pytania |
|
| uczestnik | tylko odbiera, ma czat i pytania |
|
||||||
| uczestnik z glosem | publikuje wylacznie dzwiek, po zgodzie moderatora |
|
| 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
|
## 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.
|
czekajacy piec minut widzi, jak mowi ktos zgloszony przed chwila.
|
||||||
- Pilnuje tego `SpeakRequestTest`, w tym dziesiec rownoczesnych zgloszen.
|
- 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
|
### Moderacja wyrazow
|
||||||
|
|
||||||
Tabela `moderation_words` (V31), ekran `/moderacja-wyrazow`, endpointy
|
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
|
- **Uprawnienia wynikaja z tokenu u dostawcy, nie z interfejsu.** Ukrycie przycisku
|
||||||
kamery niczego nie zalatwia: uczestnik moze podmienic kod strony albo wejsc wlasnym
|
kamery niczego nie zalatwia: uczestnik moze podmienic kod strony albo wejsc wlasnym
|
||||||
klientem. `RoomRole` przeklada sie na `permissions.canSend` w tokenie.
|
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`:
|
- `RegistrationRoomRole` (VIEWER, SPEAKER) jest celowo wezszy niz `RoomRole`:
|
||||||
uczestnik z zapisu **nigdy** nie dostaje uprawnien moderatora. Ma je wylacznie
|
uczestnik z zapisu **nigdy** nie dostaje uprawnien moderatora. Ma je wylacznie
|
||||||
prelegent z `webinar_speakers`, wchodzacy wlasnym linkiem. Pilnuje tego
|
prelegent z `webinar_speakers`, wchodzacy wlasnym linkiem. Pilnuje tego
|
||||||
|
|||||||
@@ -79,6 +79,20 @@ public class Registration {
|
|||||||
@Column(name = "room_banned", nullable = false)
|
@Column(name = "room_banned", nullable = false)
|
||||||
private boolean roomBanned;
|
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.
|
* Rola w pokoju wideo.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -173,6 +173,22 @@ public class InteractionBroadcaster {
|
|||||||
new ModerationEvent(kind, registrationId));
|
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`. */
|
/** Koperta zdarzenia. `kind` mowi odbiorcy, co zrobic z `payload`. */
|
||||||
/**
|
/**
|
||||||
* Zdarzenie na temacie PUBLICZNYM: czat i pytania widoczne dla wszystkich
|
* 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);
|
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.
|
* Jak dlugo wazny jest token wejscia.
|
||||||
*
|
*
|
||||||
@@ -129,84 +113,87 @@ public class LiveKitVideoProvider implements VideoProvider {
|
|||||||
token.setTtl(TOKEN_TTL_MILLIS);
|
token.setTtl(TOKEN_TTL_MILLIS);
|
||||||
|
|
||||||
token.addGrants(new RoomJoin(true), new RoomName(roomId));
|
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());
|
return new JoinTicket(url, token.toJwt(), true, request.role());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uprawnienia odpowiadajace roli.
|
* Uprawnienia w tokenie.
|
||||||
*
|
*
|
||||||
* Kazda rola dostaje **jawna liste**, a nie modyfikacje poprzedniej:
|
* Zrodla publikacji pochodza z praw uczestnika (`PublishRights`), a nie
|
||||||
* przy dziedziczeniu latwo przeoczyc, ze SPEAKER odziedziczyl prawo do
|
* z samej roli: prowadzacy moze dac konkretnej osobie kamere albo ekran
|
||||||
* kamery po PRESENTER.
|
* bez glosu. Rola decyduje wylacznie o uprawnieniach moderatora, ktore ma
|
||||||
|
* tylko prelegent.
|
||||||
*/
|
*/
|
||||||
private List<io.livekit.server.VideoGrant> grantsFor(RoomRole role) {
|
private List<io.livekit.server.VideoGrant> grantsFor(JoinRequest request) {
|
||||||
return switch (role) {
|
PublishRights rights = request.rights();
|
||||||
case VIEWER -> List.of(
|
List<io.livekit.server.VideoGrant> grants = new java.util.ArrayList<>(List.of(
|
||||||
new CanSubscribe(true),
|
new CanSubscribe(true),
|
||||||
new CanPublish(false),
|
new CanPublish(rights.any()),
|
||||||
// dane pozwalaja bibliotece klienta na sygnalizacje
|
// dane pozwalaja bibliotece klienta na sygnalizacje,
|
||||||
// (np. podniesienie reki), bez prawa do audio i wideo
|
// bez prawa do audio i wideo
|
||||||
new CanPublishData(true));
|
new CanPublishData(true)));
|
||||||
|
if (rights.any()) {
|
||||||
case SPEAKER -> List.of(
|
// sedno: jawna lista zrodel. Bez niej `canPublish` znaczyloby
|
||||||
new CanSubscribe(true),
|
// "wszystko", czyli takze kamere i ekran
|
||||||
new CanPublish(true),
|
grants.add(new CanPublishSources(sourcesFor(rights)));
|
||||||
// sedno: publikacja ograniczona do mikrofonu.
|
}
|
||||||
// Bez tej linii SPEAKER moglby wlaczyc kamere.
|
if (request.role() == RoomRole.PRESENTER) {
|
||||||
new CanPublishSources(AUDIO_ONLY),
|
grants.add(new RoomAdmin(true));
|
||||||
new CanPublishData(true));
|
grants.add(new RoomRecord(true));
|
||||||
|
}
|
||||||
case ACTIVE_PARTICIPANT -> List.of(
|
return grants;
|
||||||
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));
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Te same uprawnienia co w tokenie, w postaci do zmiany na zywo.
|
* Te same uprawnienia co w tokenie, w postaci do zmiany na zywo.
|
||||||
*
|
*
|
||||||
* Sluzy `RoomPermissionSync`: przyznanie i odebranie glosu zmienia
|
* Sluzy `RoomPermissionSync`: przyznanie i odebranie glosu, kamery albo
|
||||||
* uprawnienia uczestnika juz polaczonego, bez ponownego wejscia na sale.
|
* ekranu zmienia uprawnienia uczestnika juz polaczonego, bez ponownego
|
||||||
* Zrodla publikacji pochodza z tych samych stalych co `grantsFor`, wiec
|
* wejscia na sale. Zrodla pochodza z tej samej `sourcesFor` co token, wiec
|
||||||
* token i zmiana na zywo nie moga sie rozjechac.
|
* token i zmiana na zywo nie moga sie rozjechac.
|
||||||
*
|
*
|
||||||
* UWAGA: `canPublish=true` z PUSTA lista zrodel oznacza u LiveKit "wszystkie
|
* UWAGA: `canPublish=true` z PUSTA lista zrodel oznacza u LiveKit "wszystkie
|
||||||
* zrodla", czyli takze kamere i ekran. Dlatego lista jest zawsze jawna,
|
* 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()
|
var builder = LivekitModels.ParticipantPermission.newBuilder()
|
||||||
.setCanSubscribe(true)
|
.setCanSubscribe(true)
|
||||||
.setCanPublishData(true)
|
.setCanPublishData(true)
|
||||||
.setCanPublish(role != RoomRole.VIEWER);
|
.setCanPublish(rights.any());
|
||||||
for (String source : sourcesFor(role)) {
|
for (String source : sourcesFor(rights)) {
|
||||||
builder.addCanPublishSources(trackSource(source));
|
builder.addCanPublishSources(trackSource(source));
|
||||||
}
|
}
|
||||||
return builder.build();
|
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) {
|
static List<String> sourcesFor(RoomRole role) {
|
||||||
return switch (role) {
|
return sourcesFor(PublishRights.of(role));
|
||||||
case VIEWER -> List.of();
|
|
||||||
case SPEAKER -> AUDIO_ONLY;
|
|
||||||
case ACTIVE_PARTICIPANT -> CAMERA_AND_MIC;
|
|
||||||
case PRESENTER -> PRESENTER_SOURCES;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static LivekitModels.TrackSource trackSource(String name) {
|
static LivekitModels.TrackSource trackSource(String name) {
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ public class PublicJoinController {
|
|||||||
*/
|
*/
|
||||||
boolean canPublishVideo,
|
boolean canPublishVideo,
|
||||||
boolean canPublishAudio,
|
boolean canPublishAudio,
|
||||||
|
/** Czy wolno udostepnic ekran; u uczestnika tylko za zgoda prowadzacego. */
|
||||||
|
boolean canShareScreen,
|
||||||
/**
|
/**
|
||||||
* Publiczny klucz klienta do osadzenia (Zoom SDK Key).
|
* Publiczny klucz klienta do osadzenia (Zoom SDK Key).
|
||||||
* Pusty dla dostawcow, ktorzy go nie potrzebuja.
|
* Pusty dla dostawcow, ktorzy go nie potrzebuja.
|
||||||
@@ -111,8 +113,9 @@ public class PublicJoinController {
|
|||||||
result.timezone(),
|
result.timezone(),
|
||||||
result.provider(),
|
result.provider(),
|
||||||
result.roomId(),
|
result.roomId(),
|
||||||
result.ticket().role().canPublishVideo(),
|
result.rights().camera(),
|
||||||
result.ticket().role().canPublishAudio(),
|
result.rights().audio(),
|
||||||
|
result.rights().screen(),
|
||||||
result.ticket().clientKey());
|
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
|
// nie ma go na sali: uprawnienia wezmie z tokenu przy wejsciu
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
RoomRole role = videoService.participantRoles(webinarId, List.of(registrationId))
|
PublishRights rights = videoService.participantRights(webinarId, List.of(registrationId))
|
||||||
.getOrDefault(registrationId, RoomRole.VIEWER);
|
.getOrDefault(registrationId, PublishRights.NONE);
|
||||||
apply(client, room, response.body(), role);
|
apply(client, room, response.body(), rights);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Uprawnienia: synchronizacja reg-{} w webinarze {} nieudana: {}",
|
log.warn("Uprawnienia: synchronizacja reg-{} w webinarze {} nieudana: {}",
|
||||||
registrationId, webinarId, e.getMessage());
|
registrationId, webinarId, e.getMessage());
|
||||||
@@ -121,7 +121,7 @@ public class RoomPermissionSync {
|
|||||||
.map(p -> RoomPresenceService.idOf(p.getIdentity(), "reg-"))
|
.map(p -> RoomPresenceService.idOf(p.getIdentity(), "reg-"))
|
||||||
.filter(Objects::nonNull)
|
.filter(Objects::nonNull)
|
||||||
.toList();
|
.toList();
|
||||||
Map<Long, RoomRole> roles = videoService.participantRoles(webinarId, ids);
|
Map<Long, PublishRights> rights = videoService.participantRights(webinarId, ids);
|
||||||
|
|
||||||
int changed = 0;
|
int changed = 0;
|
||||||
for (LivekitModels.ParticipantInfo info : participants) {
|
for (LivekitModels.ParticipantInfo info : participants) {
|
||||||
@@ -129,7 +129,7 @@ public class RoomPermissionSync {
|
|||||||
// prelegent i polaczenia spoza zapisu zachowuja swoje uprawnienia
|
// prelegent i polaczenia spoza zapisu zachowuja swoje uprawnienia
|
||||||
if (id == null) continue;
|
if (id == null) continue;
|
||||||
try {
|
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) {
|
} catch (Exception e) {
|
||||||
log.warn("Uprawnienia: {} w webinarze {} nieudane: {}",
|
log.warn("Uprawnienia: {} w webinarze {} nieudane: {}",
|
||||||
info.getIdentity(), webinarId, e.getMessage());
|
info.getIdentity(), webinarId, e.getMessage());
|
||||||
@@ -147,8 +147,8 @@ public class RoomPermissionSync {
|
|||||||
* @return czy cokolwiek zostalo zmienione
|
* @return czy cokolwiek zostalo zmienione
|
||||||
*/
|
*/
|
||||||
private boolean apply(RoomServiceClient client, String room,
|
private boolean apply(RoomServiceClient client, String room,
|
||||||
LivekitModels.ParticipantInfo info, RoomRole role) throws java.io.IOException {
|
LivekitModels.ParticipantInfo info, PublishRights rights) throws java.io.IOException {
|
||||||
LivekitModels.ParticipantPermission target = liveKit.permissionFor(role);
|
LivekitModels.ParticipantPermission target = liveKit.permissionFor(rights);
|
||||||
if (samePermission(info.getPermission(), target)) {
|
if (samePermission(info.getPermission(), target)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,13 +30,19 @@ public class RoomPresenceController {
|
|||||||
private final RoomPresenceService presence;
|
private final RoomPresenceService presence;
|
||||||
private final InteractionService interactions;
|
private final InteractionService interactions;
|
||||||
private final InteractionBroadcaster broadcaster;
|
private final InteractionBroadcaster broadcaster;
|
||||||
|
private final VideoService videoService;
|
||||||
|
private final RoomPermissionSync permissionSync;
|
||||||
|
|
||||||
public RoomPresenceController(RoomPresenceService presence,
|
public RoomPresenceController(RoomPresenceService presence,
|
||||||
InteractionService interactions,
|
InteractionService interactions,
|
||||||
InteractionBroadcaster broadcaster) {
|
InteractionBroadcaster broadcaster,
|
||||||
|
VideoService videoService,
|
||||||
|
RoomPermissionSync permissionSync) {
|
||||||
this.presence = presence;
|
this.presence = presence;
|
||||||
this.interactions = interactions;
|
this.interactions = interactions;
|
||||||
this.broadcaster = broadcaster;
|
this.broadcaster = broadcaster;
|
||||||
|
this.videoService = videoService;
|
||||||
|
this.permissionSync = permissionSync;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -89,6 +95,32 @@ public class RoomPresenceController {
|
|||||||
public record RemoveRequest(@NotBlank String identity) {
|
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
|
* @param removedFromRoom serwer pokoju potwierdzil usuniecie; false przy
|
||||||
* dostawcy bez tej operacji albo gdy osoba juz wyszla
|
* dostawcy bez tej operacji albo gdy osoba juz wyszla
|
||||||
|
|||||||
@@ -272,15 +272,18 @@ public class RoomPresenceService {
|
|||||||
if (registration != null) {
|
if (registration != null) {
|
||||||
result.add(new ParticipantView(identity, fullName(registration), KIND_PARTICIPANT,
|
result.add(new ParticipantView(identity, fullName(registration), KIND_PARTICIPANT,
|
||||||
registration.getId(), registration.getEmail(), joinedAt, audio, video, screen,
|
registration.getId(), registration.getEmail(), joinedAt, audio, video, screen,
|
||||||
registration.isChatMuted(), registration.isRoomBanned()));
|
registration.isChatMuted(), registration.isRoomBanned(),
|
||||||
|
registration.isCameraAllowed(), registration.isScreenAllowed()));
|
||||||
} else if (speaker != null) {
|
} else if (speaker != null) {
|
||||||
result.add(new ParticipantView(identity, speaker.getFullName(), KIND_SPEAKER,
|
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 {
|
} else {
|
||||||
// polaczenie spoza naszej sciezki wejscia: nie ma go w bazie
|
// polaczenie spoza naszej sciezki wejscia: nie ma go w bazie
|
||||||
String name = info.getName().isBlank() ? identity : info.getName();
|
String name = info.getName().isBlank() ? identity : info.getName();
|
||||||
result.add(new ParticipantView(identity, name, KIND_OTHER,
|
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;
|
return result;
|
||||||
@@ -299,10 +302,11 @@ public class RoomPresenceService {
|
|||||||
if (r != null) {
|
if (r != null) {
|
||||||
view = new ParticipantView("reg-" + r.getId(), fullName(r), KIND_PARTICIPANT,
|
view = new ParticipantView("reg-" + r.getId(), fullName(r), KIND_PARTICIPANT,
|
||||||
r.getId(), r.getEmail(), session.getJoinedAt(), false, false, false,
|
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) {
|
} else if (s != null) {
|
||||||
view = new ParticipantView("spk-" + s.getId(), s.getFullName(), KIND_SPEAKER,
|
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 {
|
} else {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -374,7 +378,11 @@ public class RoomPresenceService {
|
|||||||
public record ParticipantView(String identity, String name, String kind, Long registrationId,
|
public record ParticipantView(String identity, String name, String kind, Long registrationId,
|
||||||
String email, OffsetDateTime joinedAt,
|
String email, OffsetDateTime joinedAt,
|
||||||
boolean audio, boolean video, boolean screen,
|
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) {
|
public record BannedView(Long registrationId, String name, String email) {
|
||||||
|
|||||||
@@ -121,7 +121,12 @@ public interface VideoProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Kto wchodzi do pokoju i z jakimi uprawnieniami. */
|
/** 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
|
* bo dostawca odrzuci publikacje bez uprawnienia. Zmiane w trakcie
|
||||||
* spotkania przenosi na polaczonych `RoomPermissionSync`.
|
* spotkania przenosi na polaczonych `RoomPermissionSync`.
|
||||||
*/
|
*/
|
||||||
RoomRole role = participantRole(webinar, registration);
|
PublishRights rights = participantRights(webinar, registration);
|
||||||
|
|
||||||
VideoProvider.JoinTicket ticket = provider.createJoinTicket(webinar,
|
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,
|
return new JoinResult(ticket, webinar.getTitle(), displayName, window,
|
||||||
webinar.getStartsAt(), webinar.getTimezone(),
|
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
|
* Identyfikatory pochodza z pokoju u dostawcy, wiec zapis z innego
|
||||||
* webinaru jest pomijany, a nie podnoszony.
|
* webinaru jest pomijany, a nie podnoszony.
|
||||||
*/
|
*/
|
||||||
@Transactional(readOnly = true)
|
@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)
|
Webinar webinar = webinarRepository.findById(webinarId)
|
||||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono webinaru"));
|
.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)) {
|
for (Registration registration : registrationRepository.findAllById(registrationIds)) {
|
||||||
if (registration.getWebinar().getId().equals(webinarId)
|
if (registration.getWebinar().getId().equals(webinarId)
|
||||||
&& registration.getStatus() == RegistrationStatus.CONFIRMED
|
&& registration.getStatus() == RegistrationStatus.CONFIRMED
|
||||||
&& !registration.isRoomBanned()) {
|
&& !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),
|
return new JoinResult(ticket, webinar.getTitle(), speaker.getFullName(), window(webinar),
|
||||||
webinar.getStartsAt(), webinar.getTimezone(),
|
webinar.getStartsAt(), webinar.getTimezone(),
|
||||||
provider.name(), webinar.getRoomId());
|
provider.name(), webinar.getRoomId(), PublishRights.of(RoomRole.PRESENTER));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Okno, w ktorym pokoj jest otwarty dla uczestnikow. */
|
/** Okno, w ktorym pokoj jest otwarty dla uczestnikow. */
|
||||||
@@ -382,7 +422,9 @@ public class VideoService {
|
|||||||
/** Nazwa dostawcy: front wie, ktory komponent osadzic. */
|
/** Nazwa dostawcy: front wie, ktory komponent osadzic. */
|
||||||
String provider,
|
String provider,
|
||||||
/** Identyfikator pokoju u dostawcy. */
|
/** 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,10 +81,16 @@ type History = {
|
|||||||
questions: InteractionView[]
|
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 = {
|
export type ModerationEvent = {
|
||||||
kind: 'MUTED' | 'UNMUTED' | 'BANNED' | 'UNBANNED'
|
kind: 'MUTED' | 'UNMUTED' | 'BANNED' | 'UNBANNED' | 'PUBLISH_CHANGED'
|
||||||
registrationId: number
|
registrationId: number
|
||||||
|
/** Tylko przy PUBLISH_CHANGED. */
|
||||||
|
camera?: boolean
|
||||||
|
screen?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type Event = { kind: string; payload: InteractionView }
|
type Event = { kind: string; payload: InteractionView }
|
||||||
@@ -245,7 +251,8 @@ export function useInteractions(
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
event.kind === 'MUTED' || event.kind === 'UNMUTED' ||
|
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
|
const mod = event as unknown as ModerationEvent
|
||||||
// reagujemy TYLKO na zdarzenie dotyczace tego uczestnika: temat jest
|
// reagujemy TYLKO na zdarzenie dotyczace tego uczestnika: temat jest
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ type JoinView = {
|
|||||||
*/
|
*/
|
||||||
canPublishVideo: boolean
|
canPublishVideo: boolean
|
||||||
canPublishAudio: boolean
|
canPublishAudio: boolean
|
||||||
|
/** Udostepnianie ekranu: u uczestnika wylacznie za zgoda prowadzacego. */
|
||||||
|
canShareScreen: boolean
|
||||||
/** Publiczny klucz klienta do osadzenia (Zoom SDK Key). Pusty dla innych. */
|
/** Publiczny klucz klienta do osadzenia (Zoom SDK Key). Pusty dla innych. */
|
||||||
clientKey: string | null
|
clientKey: string | null
|
||||||
}
|
}
|
||||||
@@ -369,6 +371,24 @@ function MeetingRoom({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
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 && (
|
||||||
|
<p className="border-b border-info-500/20 bg-info-50 px-3 py-2 text-xs text-info-600">
|
||||||
|
Prowadzący pozwolił Ci włączyć kamerę. Włączysz ją przyciskiem pod obrazem.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!isSpeaker && join.embedded && join.provider === 'LIVEKIT' && join.canShareScreen && (
|
||||||
|
<p className="border-b border-warn-500/20 bg-warn-50 px-3 py-2 text-xs text-warn-600">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<InteractionPanel
|
<InteractionPanel
|
||||||
token={token}
|
token={token}
|
||||||
onSpeakEvent={speak.apply}
|
onSpeakEvent={speak.apply}
|
||||||
@@ -377,6 +397,9 @@ function MeetingRoom({
|
|||||||
onModeration={(event) => {
|
onModeration={(event) => {
|
||||||
if (event.kind === 'BANNED') setBanned(true)
|
if (event.kind === 'BANNED') setBanned(true)
|
||||||
if (event.kind === 'UNBANNED') setBanned(false)
|
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) => {
|
onHistoryLoaded={(history) => {
|
||||||
setOpenMic(history.openMic)
|
setOpenMic(history.openMic)
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ type Participant = {
|
|||||||
screen: boolean
|
screen: boolean
|
||||||
chatMuted: boolean
|
chatMuted: boolean
|
||||||
roomBanned: boolean
|
roomBanned: boolean
|
||||||
|
/** Indywidualna zgoda prowadzacego na kamere. */
|
||||||
|
cameraAllowed: boolean
|
||||||
|
/** Indywidualna zgoda prowadzacego na udostepnienie ekranu. */
|
||||||
|
screenAllowed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type Presence = {
|
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) {
|
async function unban(registrationId: number, name: string) {
|
||||||
setBusy(`ban-${registrationId}`)
|
setBusy(`ban-${registrationId}`)
|
||||||
try {
|
try {
|
||||||
@@ -263,6 +299,53 @@ export function RoomParticipants({ webinarId, timezone }: { webinarId: string; t
|
|||||||
|
|
||||||
{participant.kind !== 'SPEAKER' && (
|
{participant.kind !== 'SPEAKER' && (
|
||||||
<div className="flex shrink-0 items-center gap-0.5">
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
|
{/*
|
||||||
|
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' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === participant.identity}
|
||||||
|
onClick={() => void togglePublish(participant, 'camera')}
|
||||||
|
aria-pressed={participant.cameraAllowed}
|
||||||
|
aria-label={
|
||||||
|
participant.cameraAllowed
|
||||||
|
? `Odbierz kamerę: ${participant.name}`
|
||||||
|
: `Pozwól włączyć kamerę: ${participant.name}`
|
||||||
|
}
|
||||||
|
title={participant.cameraAllowed ? 'Odbierz kamerę' : 'Pozwól włączyć kamerę'}
|
||||||
|
className={`flex h-9 w-9 items-center justify-center rounded-md disabled:opacity-40 ${
|
||||||
|
participant.cameraAllowed
|
||||||
|
? 'bg-ok-50 text-ok-600 hover:bg-ok-50'
|
||||||
|
: 'text-slate-400 hover:bg-slate-100 hover:text-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon name={participant.cameraAllowed ? 'video' : 'video-off'} size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy === participant.identity}
|
||||||
|
onClick={() => void togglePublish(participant, 'screen')}
|
||||||
|
aria-pressed={participant.screenAllowed}
|
||||||
|
aria-label={
|
||||||
|
participant.screenAllowed
|
||||||
|
? `Odbierz udostępnianie ekranu: ${participant.name}`
|
||||||
|
: `Pozwól udostępnić ekran: ${participant.name}`
|
||||||
|
}
|
||||||
|
title={participant.screenAllowed ? 'Odbierz udostępnianie ekranu' : 'Pozwól udostępnić ekran'}
|
||||||
|
className={`flex h-9 w-9 items-center justify-center rounded-md disabled:opacity-40 ${
|
||||||
|
participant.screenAllowed
|
||||||
|
? 'bg-ok-50 text-ok-600 hover:bg-ok-50'
|
||||||
|
: 'text-slate-400 hover:bg-slate-100 hover:text-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon name="presentation" size={16} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{participant.kind === 'PARTICIPANT' && (
|
{participant.kind === 'PARTICIPANT' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
Reference in New Issue
Block a user