diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/ApiParams.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/ApiParams.java index 5c2d04245b86f4babe996777ffe26b9ecad65937..5bfa44ec760bc61f1005e1a664caa95b32bf4b39 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api/ApiParams.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/ApiParams.java @@ -70,6 +70,16 @@ public class ApiParams { public static final String LOCK_SETTINGS_LOCK_ON_JOIN = "lockSettingsLockOnJoin"; public static final String LOCK_SETTINGS_LOCK_ON_JOIN_CONFIGURABLE = "lockSettingsLockOnJoinConfigurable"; + // New param passed on create call to callback when meeting ends. + // This is a duplicate of the endCallbackUrl meta param as we want this + // param to stay on the server and not propagated to client and recordings. + public static final String MEETING_ENDED_CALLBACK_URL = "meetingEndedURL"; + + // Param to end the meeting when there are no moderators after a certain period of time. + // Needed for classes where teacher gets disconnected and can't get back in. Prevents + // students from running amok. + public static final String END_WHEN_NO_MODERATOR = "endWhenNoModerator"; + private ApiParams() { throw new IllegalStateException("ApiParams is a utility class. Instanciation is forbidden."); } diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java index 27aed4a1e16423e457e3ef1ddda337c4b1c2efbf..62cb84d40c9b917610b4eaeb957b6b20c71510f7 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java @@ -40,6 +40,7 @@ import java.util.concurrent.Executor; import java.util.concurrent.Executors; import java.util.concurrent.LinkedBlockingQueue; +import org.apache.commons.lang3.StringUtils; import org.apache.http.client.utils.URIBuilder; import org.bigbluebutton.api.domain.GuestPolicy; import org.bigbluebutton.api.domain.Meeting; @@ -778,27 +779,38 @@ public class MeetingService implements MessageListener { String endCallbackUrl = "endCallbackUrl".toLowerCase(); Map<String, String> metadata = m.getMetadata(); - if (!m.isBreakout() && metadata.containsKey(endCallbackUrl)) { - String callbackUrl = metadata.get(endCallbackUrl); - try { + if (!m.isBreakout()) { + if (metadata.containsKey(endCallbackUrl)) { + String callbackUrl = metadata.get(endCallbackUrl); + try { callbackUrl = new URIBuilder(new URI(callbackUrl)) - .addParameter("recordingmarks", m.haveRecordingMarks() ? "true" : "false") - .addParameter("meetingID", m.getExternalId()).build().toURL().toString(); - callbackUrlService.handleMessage(new MeetingEndedEvent(m.getInternalId(), m.getExternalId(), m.getName(), callbackUrl)); - } catch (MalformedURLException e) { - log.error("Malformed URL in callback url=[{}]", callbackUrl, e); - } catch (URISyntaxException e) { - log.error("URI Syntax error in callback url=[{}]", callbackUrl, e); - } catch (Exception e) { - log.error("Error in callback url=[{}]", callbackUrl, e); + .addParameter("recordingmarks", m.haveRecordingMarks() ? "true" : "false") + .addParameter("meetingID", m.getExternalId()).build().toURL().toString(); + MeetingEndedEvent event = new MeetingEndedEvent(m.getInternalId(), m.getExternalId(), m.getName(), callbackUrl); + processMeetingEndedCallback(event); + } catch (Exception e) { + log.error("Error in callback url=[{}]", callbackUrl, e); + } } + if (! StringUtils.isEmpty(m.getMeetingEndedCallbackURL())) { + String meetingEndedCallbackURL = m.getMeetingEndedCallbackURL(); + callbackUrlService.handleMessage(new MeetingEndedEvent(m.getInternalId(), m.getExternalId(), m.getName(), meetingEndedCallbackURL)); + } } processRemoveEndedMeeting(message); } } + private void processMeetingEndedCallback(MeetingEndedEvent event) { + try { + callbackUrlService.handleMessage(event); + } catch (Exception e) { + log.error("Error in callback url=[{}]", event.getCallbackUrl(), e); + } + } + private void userJoined(UserJoined message) { Meeting m = getMeeting(message.meetingId); if (m != null) { diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/ParamsProcessorUtil.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/ParamsProcessorUtil.java index 7b8d26d6a2daf9e4abef7128c6dfa537921aedba..5b1e008f3618b5d1558f37e16e4fab5b4a2c4c36 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api/ParamsProcessorUtil.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/ParamsProcessorUtil.java @@ -114,6 +114,7 @@ public class ParamsProcessorUtil { private Integer userInactivityThresholdInMinutes = 30; private Integer userActivitySignResponseDelayInMinutes = 5; private Boolean defaultAllowDuplicateExtUserid = true; + private Boolean defaultEndWhenNoModerator = false; private String formatConfNum(String s) { if (s.length() > 5) { @@ -420,6 +421,15 @@ public class ParamsProcessorUtil { } } + boolean endWhenNoModerator = defaultEndWhenNoModerator; + if (!StringUtils.isEmpty(params.get(ApiParams.END_WHEN_NO_MODERATOR))) { + try { + endWhenNoModerator = Boolean.parseBoolean(params.get(ApiParams.END_WHEN_NO_MODERATOR)); + } catch (Exception ex) { + log.warn("Invalid param [endWhenNoModerator] for meeting=[{}]", internalMeetingId); + } + } + String guestPolicy = defaultGuestPolicy; if (!StringUtils.isEmpty(params.get(ApiParams.GUEST_POLICY))) { guestPolicy = params.get(ApiParams.GUEST_POLICY); @@ -487,6 +497,13 @@ public class ParamsProcessorUtil { meeting.setModeratorOnlyMessage(moderatorOnlyMessage); } + if (!StringUtils.isEmpty(params.get(ApiParams.MEETING_ENDED_CALLBACK_URL))) { + String meetingEndedCallbackURL = params.get(ApiParams.MEETING_ENDED_CALLBACK_URL); + meeting.setMeetingEndedCallbackURL(meetingEndedCallbackURL); + } + + meeting.setMaxInactivityTimeoutMinutes(maxInactivityTimeoutMinutes); + meeting.setWarnMinutesBeforeMax(warnMinutesBeforeMax); meeting.setMeetingExpireIfNoUserJoinedInMinutes(meetingExpireIfNoUserJoinedInMinutes); meeting.setMeetingExpireWhenLastUserLeftInMinutes(meetingExpireWhenLastUserLeftInMinutes); meeting.setUserInactivityInspectTimerInMinutes(userInactivityInspectTimerInMinutes); @@ -1115,4 +1132,10 @@ public class ParamsProcessorUtil { public void setAllowDuplicateExtUserid(Boolean allow) { this.defaultAllowDuplicateExtUserid = allow; } + + public void setEndWhenNoModerator(Boolean val) { + this.defaultEndWhenNoModerator = val; + } + + } diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Meeting.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Meeting.java index 0ca24f42ddb7cccdb0fa5e9b1a6c083b262fe1f9..f4867b2ffeee575db70a6e3a2e33d211f437dbe4 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Meeting.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Meeting.java @@ -92,6 +92,11 @@ public class Meeting { public final Boolean allowDuplicateExtUserid; + private String meetingEndedCallbackURL = ""; + + public final Boolean endWhenNoModerator; + + public Meeting(Meeting.Builder builder) { name = builder.name; extMeetingId = builder.externalId; @@ -120,7 +125,8 @@ public class Meeting { guestPolicy = builder.guestPolicy; breakoutRoomsParams = builder.breakoutRoomsParams; lockSettingsParams = builder.lockSettingsParams; - allowDuplicateExtUserid = builder.allowDuplicateExtUserid; + allowDuplicateExtUserid = builder.allowDuplicateExtUserid; + endWhenNoModerator = builder.endWhenNoModerator; userCustomData = new HashMap<>(); @@ -563,6 +569,14 @@ public class Meeting { this.userActivitySignResponseDelayInMinutes = userActivitySignResponseDelayInMinutes; } + public String getMeetingEndedCallbackURL() { + return meetingEndedCallbackURL; + } + + public void setMeetingEndedCallbackURL(String meetingEndedCallbackURL) { + this.meetingEndedCallbackURL = meetingEndedCallbackURL; + } + public Map<String, Object> getUserCustomData(String userID){ return (Map<String, Object>) userCustomData.get(userID); } @@ -612,6 +626,7 @@ public class Meeting { private BreakoutRoomsParams breakoutRoomsParams; private LockSettingsParams lockSettingsParams; private Boolean allowDuplicateExtUserid; + private Boolean endWhenNoModerator; public Builder(String externalId, String internalId, long createTime) { this.externalId = externalId; @@ -743,6 +758,11 @@ public class Meeting { this.allowDuplicateExtUserid = allowDuplicateExtUserid; return this; } + + public Builder withEndWhenNoModerator(Boolean endWhenNoModerator) { + this.endWhenNoModerator = endWhenNoModerator; + return this; + } public Meeting build() { return new Meeting(this); diff --git a/bigbluebutton-config/bin/apply-lib.sh b/bigbluebutton-config/bin/apply-lib.sh index a1a7173f5789c8318dbd39a2af1618238c103812..7d884bb89549aa5d20b712474806c0ab16023bd7 100644 --- a/bigbluebutton-config/bin/apply-lib.sh +++ b/bigbluebutton-config/bin/apply-lib.sh @@ -5,8 +5,8 @@ # which (if exists) will be run by `bbb-conf --setip` and `bbb-conf --restart` before restarting # BigBlueButton. # -# The purpose of apply-config.sh is to make it easy for you apply defaults to BigBlueButton server that get applied after -# each package update (since the last step in doing an upate is to run `bbb-conf --setip`. +# The purpose of apply-config.sh is to make it easy to apply your configuration changes to a BigBlueButton server +# before BigBlueButton starts # @@ -74,7 +74,19 @@ HERE } -# Enable firewall rules to lock down access to server +enableHTML5CameraQualityThresholds() { + echo " - Enable HTML5 cameraQualityThresholds" + yq w -i $HTML5_CONFIG public.kurento.cameraQualityThresholds.enabled true +} + +enableHTML5WebcamPagination() { + echo " - Enable HTML5 webcam pagination" + yq w -i $HTML5_CONFIG public.kurento.pagination.enabled true +} + + +# +# Enable firewall rules to open only # enableUFWRules() { echo " - Enable Firewall and opening 22/tcp, 80/tcp, 443/tcp and 16384:32768/udp" @@ -90,6 +102,123 @@ enableUFWRules() { } +enableMultipleKurentos() { + echo " - Configuring three Kurento Media Servers: one for listen only, webcam, and screeshare" + + # Step 1. Setup shared certificate between FreeSWITCH and Kurento + + HOSTNAME=$(cat /etc/nginx/sites-available/bigbluebutton | grep -v '#' | sed -n '/server_name/{s/.*server_name[ ]*//;s/;//;p}' | cut -d' ' -f1 | head -n 1) + openssl req -x509 -new -nodes -newkey rsa:2048 -sha256 -days 3650 -subj "/C=BR/ST=Ottawa/O=BigBlueButton Inc./OU=Live/CN=$HOSTNAME" -keyout /tmp/dtls-srtp-key.pem -out /tmp/dtls-srtp-cert.pem + cat /tmp/dtls-srtp-key.pem /tmp/dtls-srtp-cert.pem > /etc/kurento/dtls-srtp.pem + cat /tmp/dtls-srtp-key.pem /tmp/dtls-srtp-cert.pem > /opt/freeswitch/etc/freeswitch/tls/dtls-srtp.pem + + sed -i 's/;pemCertificateRSA=.*/pemCertificateRSA=\/etc\/kurento\/dtls-srtp.pem/g' /etc/kurento/modules/kurento/WebRtcEndpoint.conf.ini + + # Step 2. Setup systemd unit files to launch three separate instances of Kurento + + for i in `seq 8888 8890`; do + + cat > /usr/lib/systemd/system/kurento-media-server-${i}.service << HERE + # /usr/lib/systemd/system/kurento-media-server-#{i}.service + [Unit] + Description=Kurento Media Server daemon (${i}) + After=network.target + PartOf=kurento-media-server.service + After=kurento-media-server.service + + [Service] + UMask=0002 + Environment=KURENTO_LOGS_PATH=/var/log/kurento-media-server + Environment=KURENTO_CONF_FILE=/etc/kurento/kurento-${i}.conf.json + User=kurento + Group=kurento + LimitNOFILE=1000000 + ExecStartPre=-/bin/rm -f /var/kurento/.cache/gstreamer-1.5/registry.x86_64.bin + ExecStart=/usr/bin/kurento-media-server --gst-debug-level=3 --gst-debug="3,Kurento*:4,kms*:4,KurentoWebSocketTransport:5" + Type=simple + PIDFile=/var/run/kurento-media-server-${i}.pid + Restart=always + + [Install] + WantedBy=kurento-media-server.service + +HERE + + # Make a new configuration file each instance of Kurento that binds to a different port + cp /etc/kurento/kurento.conf.json /etc/kurento/kurento-${i}.conf.json + sed -i "s/8888/${i}/g" /etc/kurento/kurento-${i}.conf.json + + done + + # Step 3. Override the main kurento-media-server unit to start/stop the three Kurento instances + + cat > /etc/systemd/system/kurento-media-server.service << HERE + [Unit] + Description=Kurento Media Server + + [Service] + Type=oneshot + ExecStart=/bin/true + RemainAfterExit=yes + + [Install] + WantedBy=multi-user.target +HERE + + systemctl daemon-reload + + for i in `seq 8888 8890`; do + systemctl enable kurento-media-server-${i}.service + done + + + # Step 4. Modify bbb-webrtc-sfu config to use the three Kurento servers + + KURENTO_CONFIG=/usr/local/bigbluebutton/bbb-webrtc-sfu/config/default.yml + + MEDIA_TYPE=(main audio content) + IP=$(yq r /usr/local/bigbluebutton/bbb-webrtc-sfu/config/default.yml kurento[0].ip) + + for i in `seq 0 2`; do + yq w -i $KURENTO_CONFIG "kurento[$i].ip" $IP + yq w -i $KURENTO_CONFIG "kurento[$i].url" "ws://127.0.0.1:$(($i + 8888))/kurento" + yq w -i $KURENTO_CONFIG "kurento[$i].mediaType" "${MEDIA_TYPE[$i]}" + yq w -i $KURENTO_CONFIG "kurento[$i].ipClassMappings.local" "" + yq w -i $KURENTO_CONFIG "kurento[$i].ipClassMappings.private" "" + yq w -i $KURENTO_CONFIG "kurento[$i].ipClassMappings.public" "" + yq w -i $KURENTO_CONFIG "kurento[$i].options.failAfter" 5 + yq w -i $KURENTO_CONFIG "kurento[$i].options.request_timeout" 30000 + yq w -i $KURENTO_CONFIG "kurento[$i].options.response_timeout" 30000 + done + + yq w -i $KURENTO_CONFIG balancing-strategy MEDIA_TYPE +} + +disableMultipleKurentos() { + echo " - Configuring a single Kurento Media Server for listen only, webcam, and screeshare" + systemctl stop kurento-media-server.service + + for i in `seq 8888 8890`; do + systemctl disable kurento-media-server-${i}.service + done + + # Remove the overrride (restoring the original kurento-media-server.service unit file) + rm -f /etc/systemd/system/kurento-media-server.service + systemctl daemon-reload + + # Restore bbb-webrtc-sfu configuration to use a single instance of Kurento + KURENTO_CONFIG=/usr/local/bigbluebutton/bbb-webrtc-sfu/config/default.yml + yq d -i $KURENTO_CONFIG kurento[1] + yq d -i $KURENTO_CONFIG kurento[1] + + yq w -i $KURENTO_CONFIG "kurento[0].url" "ws://127.0.0.1:8888/kurento" + yq w -i $KURENTO_CONFIG "kurento[0].mediaType" "" + + yq w -i $KURENTO_CONFIG balancing-strategy ROUND_ROBIN +} + + + notCalled() { # # This function is not called. @@ -112,6 +241,9 @@ source /etc/bigbluebutton/bbb-conf/apply-lib.sh #enableHTML5ClientLog #enableUFWRules +#enableHTML5CameraQualityThresholds +#enableHTML5WebcamPagination + HERE chmod +x /etc/bigbluebutton/bbb-conf/apply-config.sh ## Stop Copying HERE diff --git a/bigbluebutton-config/bin/bbb-conf b/bigbluebutton-config/bin/bbb-conf index 09a8c49ad8ef803bb1e543221907fa245993a8f7..f8b3bb3d92ea545c33922ad75f6de223a19c8ff6 100755 --- a/bigbluebutton-config/bin/bbb-conf +++ b/bigbluebutton-config/bin/bbb-conf @@ -1996,16 +1996,17 @@ if [ -n "$HOST" ]; then #fi fi - ESL_PASSWORD=$(xmlstarlet sel -t -m 'configuration/settings/param[@name="password"]' -v @value /opt/freeswitch/etc/freeswitch/autoload_configs/event_socket.conf.xml) + # + # Update ESL passwords in three configuration files + # + ESL_PASSWORD=$(cat /usr/share/bbb-fsesl-akka/conf/application.conf | grep password | head -n 1 | sed 's/.*="//g' | sed 's/"//g') if [ "$ESL_PASSWORD" == "ClueCon" ]; then ESL_PASSWORD=$(openssl rand -hex 8) - echo "Changing default password for FreeSWITCH Event Socket Layer (see /opt/freeswitch/etc/freeswitch/autoload_configs/event_socket.conf.xml)" + sudo sed -i "s/ClueCon/$ESL_PASSWORD/g" /usr/share/bbb-fsesl-akka/conf/application.conf fi - # Update all references to ESL password - sudo sed -i "s/ClueCon/$ESL_PASSWORD/g" /opt/freeswitch/etc/freeswitch/autoload_configs/event_socket.conf.xml - sudo sed -i "s/ClueCon/$ESL_PASSWORD/g" /usr/share/bbb-fsesl-akka/conf/application.conf sudo yq w -i /usr/local/bigbluebutton/bbb-webrtc-sfu/config/default.yml freeswitch.esl_password "$ESL_PASSWORD" + sudo xmlstarlet edit --inplace --update 'configuration/settings//param[@name="password"]/@value' --value $ESL_PASSWORD /opt/freeswitch/etc/freeswitch/autoload_configs/event_socket.conf.xml echo "Restarting the BigBlueButton $BIGBLUEBUTTON_RELEASE ..." diff --git a/bigbluebutton-html5/imports/api/breakouts/server/methods/createBreakout.js b/bigbluebutton-html5/imports/api/breakouts/server/methods/createBreakout.js index 2b069675ef3fdfc0b0a3e3340998071f89152f45..315ce25e254efa16253cc0a873eedb37d9efe99a 100644 --- a/bigbluebutton-html5/imports/api/breakouts/server/methods/createBreakout.js +++ b/bigbluebutton-html5/imports/api/breakouts/server/methods/createBreakout.js @@ -6,11 +6,14 @@ import { extractCredentials } from '/imports/api/common/server/helpers'; export default function createBreakoutRoom(rooms, durationInMinutes, record = false) { const REDIS_CONFIG = Meteor.settings.private.redis; const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; + const BREAKOUT_LIM = Meteor.settings.public.app.breakoutRoomLimit; + const MIN_BREAKOUT_ROOMS = 2; + const MAX_BREAKOUT_ROOMS = BREAKOUT_LIM > MIN_BREAKOUT_ROOMS ? BREAKOUT_LIM : MIN_BREAKOUT_ROOMS; const { meetingId, requesterUserId } = extractCredentials(this.userId); const eventName = 'CreateBreakoutRoomsCmdMsg'; - if (rooms.length > 8) return Logger.info(`Attempt to create breakout rooms with invalid number of rooms in meeting id=${meetingId}`); + if (rooms.length > MAX_BREAKOUT_ROOMS) return Logger.info(`Attempt to create breakout rooms with invalid number of rooms in meeting id=${meetingId}`); const payload = { record, durationInMinutes, diff --git a/bigbluebutton-html5/imports/api/external-videos/server/methods/startWatchingExternalVideo.js b/bigbluebutton-html5/imports/api/external-videos/server/methods/startWatchingExternalVideo.js index e157b7a0b3e0ede194b9d16fa76152a59633bc1b..df2b88abe3fc2d0ee78002adc9cc560aa6d15388 100644 --- a/bigbluebutton-html5/imports/api/external-videos/server/methods/startWatchingExternalVideo.js +++ b/bigbluebutton-html5/imports/api/external-videos/server/methods/startWatchingExternalVideo.js @@ -2,6 +2,7 @@ import { Meteor } from 'meteor/meteor'; import { check } from 'meteor/check'; import Logger from '/imports/startup/server/logger'; import Meetings from '/imports/api/meetings'; +import Users from '/imports/api/users'; import RedisPubSub from '/imports/startup/server/redis'; import { extractCredentials } from '/imports/api/common/server/helpers'; @@ -10,16 +11,29 @@ export default function startWatchingExternalVideo(options) { const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; const EVENT_NAME = 'StartExternalVideoMsg'; - const { meetingId, requesterUserId } = extractCredentials(this.userId); + const { meetingId, requesterUserId: userId } = extractCredentials(this.userId); const { externalVideoUrl } = options; - check(externalVideoUrl, String); + try { + check(meetingId, String); + check(userId, String); + check(externalVideoUrl, String); - Meetings.update({ meetingId }, { $set: { externalVideoUrl } }); + const user = Users.findOne({ meetingId, userId, presenter: true }, { presenter: 1 }); - const payload = { externalVideoUrl }; + if (!user) { + Logger.error(`Only presenters are allowed to start external video for a meeting. meeting=${meetingId} userId=${userId}`); + return; + } - Logger.info(`User id=${requesterUserId} sharing an external video: ${externalVideoUrl} for meeting ${meetingId}`); + Meetings.update({ meetingId }, { $set: { externalVideoUrl } }); - return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + const payload = { externalVideoUrl }; + + Logger.info(`User id=${userId} sharing an external video: ${externalVideoUrl} for meeting ${meetingId}`); + + return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, userId, payload); + } catch (error) { + Logger.error(`Error on sharing an external video: ${externalVideoUrl} ${error}`); + } } diff --git a/bigbluebutton-html5/imports/api/external-videos/server/methods/stopWatchingExternalVideo.js b/bigbluebutton-html5/imports/api/external-videos/server/methods/stopWatchingExternalVideo.js index 60384b82f772230d68d5c5399008f7de9e780610..c1fe37b6c7378c69f1bd747e6a28c4a8326e4d59 100644 --- a/bigbluebutton-html5/imports/api/external-videos/server/methods/stopWatchingExternalVideo.js +++ b/bigbluebutton-html5/imports/api/external-videos/server/methods/stopWatchingExternalVideo.js @@ -1,6 +1,7 @@ import { Meteor } from 'meteor/meteor'; import Logger from '/imports/startup/server/logger'; import Meetings from '/imports/api/meetings'; +import Users from '/imports/api/users'; import RedisPubSub from '/imports/startup/server/redis'; import { extractCredentials } from '/imports/api/common/server/helpers'; @@ -9,19 +10,33 @@ export default function stopWatchingExternalVideo(options) { const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; const EVENT_NAME = 'StopExternalVideoMsg'; - if (this.userId) { - options = extractCredentials(this.userId); - } + const { meetingId, requesterUserId } = this.userId ? extractCredentials(this.userId) : options; + + try { + check(meetingId, String); + check(requesterUserId, String); - const { meetingId, requesterUserId } = options; + const user = Users.findOne({ + meetingId, + userId: requesterUserId, + presenter: true, + }, { presenter: 1 }); - const meeting = Meetings.findOne({ meetingId }); - if (!meeting || meeting.externalVideoUrl === null) return; + if (this.userId && !user) { + Logger.error(`Only presenters are allowed to stop external video for a meeting. meeting=${meetingId} userId=${requesterUserId}`); + return; + } - Meetings.update({ meetingId }, { $set: { externalVideoUrl: null } }); - const payload = {}; + const meeting = Meetings.findOne({ meetingId }); + if (!meeting || meeting.externalVideoUrl === null) return; - Logger.info(`User id=${requesterUserId} stopped sharing an external video for meeting=${meetingId}`); + Meetings.update({ meetingId }, { $set: { externalVideoUrl: null } }); + const payload = {}; - RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + Logger.info(`User id=${requesterUserId} stopped sharing an external video for meeting=${meetingId}`); + + RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload); + } catch (error) { + Logger.error(`Error on stop sharing an external video for meeting=${meetingId} ${error}`); + } } diff --git a/bigbluebutton-html5/imports/api/meetings/server/modifiers/meetingHasEnded.js b/bigbluebutton-html5/imports/api/meetings/server/modifiers/meetingHasEnded.js index 3b9efcbb1698899b0e0347f2f196d546067e882a..acc6f5b47255a5509170be64a5b215af98b187ca 100755 --- a/bigbluebutton-html5/imports/api/meetings/server/modifiers/meetingHasEnded.js +++ b/bigbluebutton-html5/imports/api/meetings/server/modifiers/meetingHasEnded.js @@ -22,6 +22,7 @@ import clearLocalSettings from '/imports/api/local-settings/server/modifiers/cle import clearRecordMeeting from './clearRecordMeeting'; import clearVoiceCallStates from '/imports/api/voice-call-states/server/modifiers/clearVoiceCallStates'; import clearVideoStreams from '/imports/api/video-streams/server/modifiers/clearVideoStreams'; +import BannedUsers from '/imports/api/users/server/store/bannedUsers'; export default function meetingHasEnded(meetingId) { removeAnnotationsStreamer(meetingId); @@ -46,6 +47,7 @@ export default function meetingHasEnded(meetingId) { clearRecordMeeting(meetingId); clearVoiceCallStates(meetingId); clearVideoStreams(meetingId); + BannedUsers.delete(meetingId); return Logger.info(`Cleared Meetings with id ${meetingId}`); }); diff --git a/bigbluebutton-html5/imports/api/screenshare/client/bridge/kurento.js b/bigbluebutton-html5/imports/api/screenshare/client/bridge/kurento.js index 761e383ede9eafae27c72990146aaadfc41a26e2..d3ffdff3b8e00d297ec3c56c6a65360f9b843482 100755 --- a/bigbluebutton-html5/imports/api/screenshare/client/bridge/kurento.js +++ b/bigbluebutton-html5/imports/api/screenshare/client/bridge/kurento.js @@ -63,15 +63,80 @@ export default class KurentoScreenshareBridge { return normalizedError; } - async kurentoWatchVideo() { + static playElement(screenshareMediaElement) { + const mediaTagPlayed = () => { + logger.info({ + logCode: 'screenshare_media_play_success', + }, 'Screenshare media played successfully'); + }; + + if (screenshareMediaElement.paused) { + // Tag isn't playing yet. Play it. + screenshareMediaElement.play() + .then(mediaTagPlayed) + .catch((error) => { + // NotAllowedError equals autoplay issues, fire autoplay handling event. + // This will be handled in the screenshare react component. + if (error.name === 'NotAllowedError') { + logger.error({ + logCode: 'screenshare_error_autoplay', + extraInfo: { errorName: error.name }, + }, 'Screenshare play failed due to autoplay error'); + const tagFailedEvent = new CustomEvent('screensharePlayFailed', + { detail: { mediaElement: screenshareMediaElement } }); + window.dispatchEvent(tagFailedEvent); + } else { + // Tag failed for reasons other than autoplay. Log the error and + // try playing again a few times until it works or fails for good + const played = playAndRetry(screenshareMediaElement); + if (!played) { + logger.error({ + logCode: 'screenshare_error_media_play_failed', + extraInfo: { errorName: error.name }, + }, `Screenshare media play failed due to ${error.name}`); + } else { + mediaTagPlayed(); + } + } + }); + } else { + // Media tag is already playing, so log a success. This is really a + // logging fallback for a case that shouldn't happen. But if it does + // (ie someone re-enables the autoPlay prop in the element), then it + // means the stream is playing properly and it'll be logged. + mediaTagPlayed(); + } + } + + static screenshareElementLoadAndPlay(stream, element, muted) { + element.muted = muted; + element.pause(); + element.srcObject = stream; + KurentoScreenshareBridge.playElement(element); + } + + kurentoViewLocalPreview() { + const screenshareMediaElement = document.getElementById(SCREENSHARE_VIDEO_TAG); + const { webRtcPeer } = window.kurentoManager.kurentoScreenshare; + + if (webRtcPeer) { + const stream = webRtcPeer.getLocalStream(); + KurentoScreenshareBridge.screenshareElementLoadAndPlay(stream, screenshareMediaElement, true); + } + } + + async kurentoViewScreen() { + const screenshareMediaElement = document.getElementById(SCREENSHARE_VIDEO_TAG); let iceServers = []; let started = false; try { iceServers = await fetchWebRTCMappedStunTurnServers(getSessionToken()); } catch (error) { - logger.error({ logCode: 'screenshare_viwer_fetchstunturninfo_error', extraInfo: { error } }, - 'Screenshare bridge failed to fetch STUN/TURN info, using default'); + logger.error({ + logCode: 'screenshare_viewer_fetchstunturninfo_error', + extraInfo: { error }, + }, 'Screenshare bridge failed to fetch STUN/TURN info, using default'); iceServers = getMappedFallbackStun(); } finally { const options = { @@ -81,52 +146,6 @@ export default class KurentoScreenshareBridge { userName: getUsername(), }; - const screenshareTag = document.getElementById(SCREENSHARE_VIDEO_TAG); - - const playElement = () => { - const mediaTagPlayed = () => { - logger.info({ - logCode: 'screenshare_viewer_media_play_success', - }, 'Screenshare viewer media played successfully'); - }; - if (screenshareTag.paused) { - // Tag isn't playing yet. Play it. - screenshareTag.play() - .then(mediaTagPlayed) - .catch((error) => { - // NotAllowedError equals autoplay issues, fire autoplay handling event. - // This will be handled in the screenshare react component. - if (error.name === 'NotAllowedError') { - logger.error({ - logCode: 'screenshare_viewer_error_autoplay', - extraInfo: { errorName: error.name }, - }, 'Screenshare viewer play failed due to autoplay error'); - const tagFailedEvent = new CustomEvent('screensharePlayFailed', - { detail: { mediaElement: screenshareTag } }); - window.dispatchEvent(tagFailedEvent); - } else { - // Tag failed for reasons other than autoplay. Log the error and - // try playing again a few times until it works or fails for good - const played = playAndRetry(screenshareTag); - if (!played) { - logger.error({ - logCode: 'screenshare_viewer_error_media_play_failed', - extraInfo: { errorName: error.name }, - }, `Screenshare viewer media play failed due to ${error.name}`); - } else { - mediaTagPlayed(); - } - } - }); - } else { - // Media tag is already playing, so log a success. This is really a - // logging fallback for a case that shouldn't happen. But if it does - // (ie someone re-enables the autoPlay prop in the element), then it - // means the stream is playing properly and it'll be logged. - mediaTagPlayed(); - } - }; - const onFail = (error) => { KurentoScreenshareBridge.handleViewerFailure(error, started); }; @@ -139,10 +158,11 @@ export default class KurentoScreenshareBridge { const { webRtcPeer } = window.kurentoManager.kurentoVideo; if (webRtcPeer) { const stream = webRtcPeer.getRemoteStream(); - screenshareTag.muted = true; - screenshareTag.pause(); - screenshareTag.srcObject = stream; - playElement(); + KurentoScreenshareBridge.screenshareElementLoadAndPlay( + stream, + screenshareMediaElement, + true, + ); } }; diff --git a/bigbluebutton-html5/imports/api/users-settings/server/methods/addUserSettings.js b/bigbluebutton-html5/imports/api/users-settings/server/methods/addUserSettings.js index 420ff21474fc1b64264e2bea7d04ca71b0e31ef7..93719cad394604c4aedde1d35dbff120faba0abe 100644 --- a/bigbluebutton-html5/imports/api/users-settings/server/methods/addUserSettings.js +++ b/bigbluebutton-html5/imports/api/users-settings/server/methods/addUserSettings.js @@ -70,7 +70,7 @@ const currentParameters = [ function valueParser(val) { try { - const parsedValue = JSON.parse(val.toLowerCase()); + const parsedValue = JSON.parse(val.toLowerCase().trim()); return parsedValue; } catch (error) { logger.warn(`addUserSettings:Parameter ${val} could not be parsed (was not json)`); @@ -87,21 +87,22 @@ export default function addUserSettings(settings) { settings.forEach((el) => { const settingKey = Object.keys(el).shift(); + const normalizedKey = settingKey.trim(); - if (currentParameters.includes(settingKey)) { - if (!Object.keys(parameters).includes(settingKey)) { + if (currentParameters.includes(normalizedKey)) { + if (!Object.keys(parameters).includes(normalizedKey)) { parameters = { - [settingKey]: valueParser(el[settingKey]), + [normalizedKey]: valueParser(el[settingKey]), ...parameters, }; } else { - parameters[settingKey] = el[settingKey]; + parameters[normalizedKey] = el[settingKey]; } return; } - if (oldParametersKeys.includes(settingKey)) { - const matchingNewKey = oldParameters[settingKey]; + if (oldParametersKeys.includes(normalizedKey)) { + const matchingNewKey = oldParameters[normalizedKey]; if (!Object.keys(parameters).includes(matchingNewKey)) { parameters = { [matchingNewKey]: valueParser(el[settingKey]), @@ -111,7 +112,7 @@ export default function addUserSettings(settings) { return; } - logger.warn(`Parameter ${settingKey} not handled`); + logger.warn(`Parameter ${normalizedKey} not handled`); }); const settingsAdded = []; diff --git a/bigbluebutton-html5/imports/api/users/server/store/bannedUsers.js b/bigbluebutton-html5/imports/api/users/server/store/bannedUsers.js index 5355e3d1dcec3bf441259010f1d0ac484201cef3..f731bedf2104bdaeb17a9bd148516f15ce2b455e 100644 --- a/bigbluebutton-html5/imports/api/users/server/store/bannedUsers.js +++ b/bigbluebutton-html5/imports/api/users/server/store/bannedUsers.js @@ -7,7 +7,7 @@ class BannedUsers { } init(meetingId) { - Logger.debug('BannedUsers :: init', meetingId); + Logger.debug('BannedUsers :: init', { meetingId }); if (!this.store[meetingId]) this.store[meetingId] = new Set(); } @@ -20,7 +20,7 @@ class BannedUsers { } delete(meetingId) { - Logger.debug('BannedUsers :: delete', meetingId); + Logger.debug('BannedUsers :: delete', { meetingId }); delete this.store[meetingId]; } diff --git a/bigbluebutton-html5/imports/ui/components/actions-bar/create-breakout-room/component.jsx b/bigbluebutton-html5/imports/ui/components/actions-bar/create-breakout-room/component.jsx index dd71d872086bb61c561665f75052e4482a6d0cf6..6ff8858d482e68aedad306bf7ae8e834a097309c 100755 --- a/bigbluebutton-html5/imports/ui/components/actions-bar/create-breakout-room/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/actions-bar/create-breakout-room/component.jsx @@ -109,8 +109,9 @@ const intlMessages = defineMessages({ }, }); +const BREAKOUT_LIM = Meteor.settings.public.app.breakoutRoomLimit; const MIN_BREAKOUT_ROOMS = 2; -const MAX_BREAKOUT_ROOMS = 8; +const MAX_BREAKOUT_ROOMS = BREAKOUT_LIM > MIN_BREAKOUT_ROOMS ? BREAKOUT_LIM : MIN_BREAKOUT_ROOMS; const propTypes = { intl: PropTypes.object.isRequired, diff --git a/bigbluebutton-html5/imports/ui/components/actions-bar/create-breakout-room/styles.scss b/bigbluebutton-html5/imports/ui/components/actions-bar/create-breakout-room/styles.scss index 8ae52fe8ec08e9afdd61ef30338fb0742c82a8b0..65d2f3cdc3346cff79c7d4115c5abfc9cc9fd793 100644 --- a/bigbluebutton-html5/imports/ui/components/actions-bar/create-breakout-room/styles.scss +++ b/bigbluebutton-html5/imports/ui/components/actions-bar/create-breakout-room/styles.scss @@ -117,11 +117,12 @@ input[type="number"]::-webkit-outer-spin-button, input[type="number"]::-webkit-i } .boxContainer { - height: 50vh; display: grid; - grid-template-columns: 1fr 1fr 1fr; - grid-template-rows: 33% 33% 33%; + grid-template-columns: repeat(3, minmax(4rem, 16rem)); + grid-template-rows: repeat(auto-fill, minmax(4rem, 8rem)); grid-gap: 1.5rem 1rem; + box-sizing: border-box; + padding-bottom: 1rem; } .changeToWarn { diff --git a/bigbluebutton-html5/imports/ui/components/breakout-room/component.jsx b/bigbluebutton-html5/imports/ui/components/breakout-room/component.jsx index 7ecdbff97de981830629d342e1c4a9ba3b4c5c69..27c7de6fac00e5d7faec23da35b9f3d4e77fc99b 100644 --- a/bigbluebutton-html5/imports/ui/components/breakout-room/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/breakout-room/component.jsx @@ -261,7 +261,7 @@ class BreakoutRoom extends PureComponent { > <div className={styles.content} key={`breakoutRoomList-${breakout.breakoutId}`}> <span aria-hidden> - {intl.formatMessage(intlMessages.breakoutRoom, breakout.sequence.toString())} + {intl.formatMessage(intlMessages.breakoutRoom, { 0: breakout.sequence })} <span className={styles.usersAssignedNumberLabel}> ( {breakout.joinedUsers.length} diff --git a/bigbluebutton-html5/imports/ui/components/media/container.jsx b/bigbluebutton-html5/imports/ui/components/media/container.jsx index aae458049aad19529dfcee7b99032e53f0131f37..bd70be550bebdf79413ad4fb741c00a2974efd87 100755 --- a/bigbluebutton-html5/imports/ui/components/media/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/media/container.jsx @@ -110,7 +110,7 @@ export default withLayoutConsumer(withModalMounter(withTracker(() => { const { dataSaving } = Settings; const { viewParticipantsWebcams, viewScreenshare } = dataSaving; const hidePresentation = getFromUserSettings('bbb_hide_presentation', LAYOUT_CONFIG.hidePresentation); - const autoSwapLayout = getFromUserSettings('userdata-bbb_auto_swap_layout', LAYOUT_CONFIG.autoSwapLayout); + const autoSwapLayout = getFromUserSettings('bbb_auto_swap_layout', LAYOUT_CONFIG.autoSwapLayout); const { current_presentation: hasPresentation } = MediaService.getPresentationInfo(); const data = { children: <DefaultContent {...{ autoSwapLayout, hidePresentation }} />, diff --git a/bigbluebutton-html5/imports/ui/components/meeting-ended/component.jsx b/bigbluebutton-html5/imports/ui/components/meeting-ended/component.jsx index 4f31a9557e9e891520eac50bca28621cc86c721a..d314d0101e5a0d7eb4436f7c9f1a95e9d3e24717 100755 --- a/bigbluebutton-html5/imports/ui/components/meeting-ended/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/meeting-ended/component.jsx @@ -11,6 +11,7 @@ import { styles } from './styles'; import logger from '/imports/startup/client/logger'; import Users from '/imports/api/users'; import AudioManager from '/imports/ui/services/audio-manager'; +import { meetingIsBreakout } from '/imports/ui/components/app/service'; const intlMessage = defineMessages({ 410: { @@ -129,6 +130,7 @@ class MeetingEnded extends PureComponent { } = this.state; if (selected <= 0) { + if (meetingIsBreakout()) window.close(); logoutRouteHandler(); return; } diff --git a/bigbluebutton-html5/imports/ui/components/screenshare/service.js b/bigbluebutton-html5/imports/ui/components/screenshare/service.js index f2d71423fad098ddfe40dbf4d343f9b20aec0e67..a246eb71b762ee0dccdc5f96b6ecb3a03eef3a76 100644 --- a/bigbluebutton-html5/imports/ui/components/screenshare/service.js +++ b/bigbluebutton-html5/imports/ui/components/screenshare/service.js @@ -7,18 +7,20 @@ import { tryGenerateIceCandidates } from '/imports/utils/safari-webrtc'; import { stopWatching } from '/imports/ui/components/external-video-player/service'; import Meetings from '/imports/api/meetings'; import Auth from '/imports/ui/services/auth'; +import UserListService from '/imports/ui/components/user-list/service'; // when the meeting information has been updated check to see if it was // screensharing. If it has changed either trigger a call to receive video // and display it, or end the call and hide the video const isVideoBroadcasting = () => { - const ds = Screenshare.findOne({}); + const screenshareEntry = Screenshare.findOne({ meetingId: Auth.meetingID }, + { fields: { 'screenshare.stream': 1 } }); - if (!ds) { + if (!screenshareEntry) { return false; } - return !!ds.screenshare.stream; + return !!screenshareEntry.screenshare.stream; }; // if remote screenshare has been ended disconnect and hide the video stream @@ -28,15 +30,21 @@ const presenterScreenshareHasEnded = () => { KurentoBridge.kurentoExitVideo(); }; +const viewScreenshare = () => { + const amIPresenter = UserListService.isUserPresenter(Auth.userID); + if (!amIPresenter) { + KurentoBridge.kurentoViewScreen(); + } else { + KurentoBridge.kurentoViewLocalPreview(); + } +}; + // if remote screenshare has been started connect and display the video stream const presenterScreenshareHasStarted = () => { - // KurentoBridge.kurentoWatchVideo: references a function in the global - // namespace inside kurento-extension.js that we load dynamically - // WebRTC restrictions may need a capture device permission to release // useful ICE candidates on recvonly/no-gUM peers tryGenerateIceCandidates().then(() => { - KurentoBridge.kurentoWatchVideo(); + viewScreenshare(); }).catch((error) => { logger.error({ logCode: 'screenshare_no_valid_candidate_gum_failure', @@ -46,7 +54,7 @@ const presenterScreenshareHasStarted = () => { }, }, `Forced gUM to release additional ICE candidates failed due to ${error.name}.`); // The fallback gUM failed. Try it anyways and hope for the best. - KurentoBridge.kurentoWatchVideo(); + viewScreenshare(); }); }; diff --git a/bigbluebutton-html5/imports/ui/components/user-list/service.js b/bigbluebutton-html5/imports/ui/components/user-list/service.js index 4ca35b290a7a7f2b92b5de0fd71765570cf23ee2..40c53e540a9b6604bb6f67471eabb668929474f9 100755 --- a/bigbluebutton-html5/imports/ui/components/user-list/service.js +++ b/bigbluebutton-html5/imports/ui/components/user-list/service.js @@ -524,12 +524,58 @@ const requestUserInformation = (userId) => { makeCall('requestUserInformation', userId); }; -export const getUserNamesLink = () => { +const sortUsersByFirstName = (a, b) => { + const aName = a.firstName.toLowerCase(); + const bName = b.firstName.toLowerCase(); + if (aName < bName) return -1; + if (aName > bName) return 1; + return 0; +}; + +const sortUsersByLastName = (a, b) => { + if (a.lastName && !b.lastName) return -1; + if (!a.lastName && b.lastName) return 1; + + const aName = a.lastName.toLowerCase(); + const bName = b.lastName.toLowerCase(); + + if (aName < bName) return -1; + if (aName > bName) return 1; + return 0; +}; + +const isUserPresenter = (userId) => { + const user = Users.findOne({ userId }, + { fields: { presenter: 1 } }); + return user ? user.presenter : false; +}; + +export const getUserNamesLink = (docTitle, fnSortedLabel, lnSortedLabel) => { const mimeType = 'text/plain'; - const userNamesObj = getUsers(); - const userNameListString = userNamesObj - .map(u => u.name) - .join('\r\n'); + const userNamesObj = getUsers() + .map((u) => { + const name = u.sortName.split(' '); + return ({ + firstName: name[0], + middleNames: name.length > 2 ? name.slice(1, name.length - 1) : null, + lastName: name.length > 1 ? name[name.length - 1] : null, + }); + }); + + const getUsernameString = (user) => { + const { firstName, middleNames, lastName } = user; + return `${firstName || ''} ${middleNames && middleNames.length > 0 ? middleNames.join(' ') : ''} ${lastName || ''}`; + }; + + const namesByFirstName = userNamesObj.sort(sortUsersByFirstName) + .map(u => getUsernameString(u)).join('\r\n'); + + const namesByLastName = userNamesObj.sort(sortUsersByLastName) + .map(u => getUsernameString(u)).join('\r\n'); + + const namesListsString = `${docTitle}\r\n\r\n${fnSortedLabel}\r\n${namesByFirstName} + \r\n\r\n${lnSortedLabel}\r\n${namesByLastName}`.replace(/ {2}/g, ' '); + const link = document.createElement('a'); const meeting = Meetings.findOne({ meetingId: Auth.meetingID }, { fields: { 'meetingProp.name': 1 } }); @@ -539,7 +585,7 @@ export const getUserNamesLink = () => { link.setAttribute('download', `bbb-${meeting.meetingProp.name}[users-list]_${dateString}.txt`); link.setAttribute( 'href', - `data: ${mimeType} ;charset=utf-16,${encodeURIComponent(userNameListString)}`, + `data: ${mimeType} ;charset=utf-16,${encodeURIComponent(namesListsString)}`, ); return link; }; @@ -571,4 +617,5 @@ export default { toggleUserLock, requestUserInformation, focusFirstDropDownItem, + isUserPresenter, }; diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-options/component.jsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-options/component.jsx index e0d90bcbc903ef0b2428ab0f876d0f9a582dcd57..0cf23728298e01d8780c27784e30353024bf6fc7 100755 --- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-options/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-options/component.jsx @@ -112,6 +112,18 @@ const intlMessages = defineMessages({ id: 'app.actionsBar.actionsDropdown.captionsDesc', description: 'Captions menu toggle description', }, + savedNamesListTitle: { + id: 'app.userList.userOptions.savedNames.title', + description: '', + }, + sortedFirstNameHeading: { + id: 'app.userList.userOptions.sortedFirstName.heading', + description: '', + }, + sortedLastNameHeading: { + id: 'app.userList.userOptions.sortedLastName.heading', + description: '', + }, }); class UserOptions extends PureComponent { @@ -142,7 +154,21 @@ class UserOptions extends PureComponent { } onSaveUserNames() { - getUserNamesLink().dispatchEvent(new MouseEvent('click', + const { intl, meetingName } = this.props; + const date = new Date(); + getUserNamesLink( + intl.formatMessage(intlMessages.savedNamesListTitle, + { + 0: meetingName, + 1: `${date.toLocaleDateString( + document.documentElement.lang, + )}:${date.toLocaleTimeString( + document.documentElement.lang, + )}`, + }), + intl.formatMessage(intlMessages.sortedFirstNameHeading), + intl.formatMessage(intlMessages.sortedLastNameHeading), + ).dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); } diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-options/container.jsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-options/container.jsx index 0faa12243713390f0ae34bc2578e6b4996b02f1b..f24b76e177f05099e697b713e0f6a94bad863d2c 100755 --- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-options/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-options/container.jsx @@ -48,6 +48,13 @@ const UserOptionsContainer = withTracker((props) => { return muteOnStart; }; + const getMeetingName = () => { + const { meetingProp } = Meetings.findOne({ meetingId: Auth.meetingID }, + { fields: { 'meetingProp.name': 1 } }); + const { name } = meetingProp; + return name; + }; + return { toggleMuteAllUsers: () => { UserListService.muteAllUsers(Auth.userID); @@ -78,6 +85,7 @@ const UserOptionsContainer = withTracker((props) => { isBreakoutRecordable: ActionsBarService.isBreakoutRecordable(), users: ActionsBarService.users(), isMeteorConnected: Meteor.status().connected, + meetingName: getMeetingName(), }; })(UserOptions); diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx b/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx index e0b13ad120088b3359b2aa28370f678e89b14732..02c4533a6f155303ce432775aa94b0a2d95f7ea4 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-provider/component.jsx @@ -1,16 +1,16 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import ReconnectingWebSocket from 'reconnecting-websocket'; +import { defineMessages, injectIntl } from 'react-intl'; +import _ from 'lodash'; import VideoService from './service'; import VideoListContainer from './video-list/container'; -import { defineMessages, injectIntl } from 'react-intl'; import { fetchWebRTCMappedStunTurnServers, getMappedFallbackStun, } from '/imports/utils/fetchStunTurnServers'; import { tryGenerateIceCandidates } from '/imports/utils/safari-webrtc'; import logger from '/imports/startup/client/logger'; -import _ from 'lodash'; // Default values and default empty object to be backwards compat with 2.2. // FIXME Remove hardcoded defaults 2.3. @@ -83,6 +83,7 @@ const propTypes = { isUserLocked: PropTypes.bool.isRequired, swapLayout: PropTypes.bool.isRequired, currentVideoPageIndex: PropTypes.number.isRequired, + totalNumberOfStreams: PropTypes.number.isRequired, }; class VideoProvider extends Component { @@ -122,7 +123,7 @@ class VideoProvider extends Component { this.debouncedConnectStreams = _.debounce( this.connectStreams, VideoService.getPageChangeDebounceTime(), - { leading: false, trailing: true, } + { leading: false, trailing: true }, ); } @@ -229,15 +230,15 @@ class VideoProvider extends Component { this.setState({ socketOpen: true }); } - updateThreshold (numberOfPublishers) { + updateThreshold(numberOfPublishers) { const { threshold, profile } = VideoService.getThreshold(numberOfPublishers); if (profile) { const publishers = Object.values(this.webRtcPeers) .filter(peer => peer.isPublisher) - .forEach(peer => { + .forEach((peer) => { // 0 means no threshold in place. Reapply original one if needed - let profileToApply = (threshold === 0) ? peer.originalProfileId : profile; - VideoService.applyCameraProfile(peer, profileToApply) + const profileToApply = (threshold === 0) ? peer.originalProfileId : profile; + VideoService.applyCameraProfile(peer, profileToApply); }); } } @@ -271,7 +272,7 @@ class VideoProvider extends Component { updateStreams(streams, shouldDebounce = false) { const [streamsToConnect, streamsToDisconnect] = this.getStreamsToConnectAndDisconnect(streams); - if(shouldDebounce) { + if (shouldDebounce) { this.debouncedConnectStreams(streamsToConnect); } else { this.connectStreams(streamsToConnect); @@ -679,7 +680,7 @@ class VideoProvider extends Component { this.restartTimeout[cameraId] = setTimeout( this._getWebRTCStartTimeout(cameraId, isLocal), - this.restartTimer[cameraId] + this.restartTimer[cameraId], ); } } @@ -879,13 +880,8 @@ class VideoProvider extends Component { } render() { - const { swapLayout, currentVideoPageIndex } = this.props; - const { socketOpen } = this.state; - if (!socketOpen) return null; + const { swapLayout, currentVideoPageIndex, streams } = this.props; - const { - streams, - } = this.props; return ( <VideoListContainer streams={streams} diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/service.js b/bigbluebutton-html5/imports/ui/components/video-provider/service.js index 0890a389683699b08f3e9ffccebc3ebcc1252de7..adc0a46804f1bea06327f632ac523c52f6404d79 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/service.js +++ b/bigbluebutton-html5/imports/ui/components/video-provider/service.js @@ -34,17 +34,11 @@ const { const TOKEN = '_'; class VideoService { - static isUserPresenter(userId) { - const user = Users.findOne({ userId }, - { fields: { presenter: 1 } }); - return user ? user.presenter : false; - } - // Paginated streams: sort with following priority: local -> presenter -> alphabetic static sortPaginatedStreams(s1, s2) { - if (VideoService.isUserPresenter(s1.userId) && !VideoService.isUserPresenter(s2.userId)) { + if (UserListService.isUserPresenter(s1.userId) && !UserListService.isUserPresenter(s2.userId)) { return -1; - } else if (VideoService.isUserPresenter(s2.userId) && !VideoService.isUserPresenter(s1.userId)) { + } else if (UserListService.isUserPresenter(s2.userId) && !UserListService.isUserPresenter(s1.userId)) { return 1; } else { return UserListService.sortUsersByName(s1, s2); @@ -53,8 +47,10 @@ class VideoService { // Full mesh: sort with the following priority: local -> alphabetic static sortMeshStreams(s1, s2) { - if (s1.userId === Auth.userID) { + if (s1.userId === Auth.userID && s2.userId !== Auth.userID) { return -1; + } else if (s2.userId === Auth.userID && s1.userId !== Auth.userID) { + return 1; } else { return UserListService.sortUsersByName(s1, s2); } @@ -546,10 +542,17 @@ class VideoService { this.exitVideo(); } - isDisabled() { + disableReason() { const { viewParticipantsWebcams } = Settings.dataSaving; - - return this.isUserLocked() || this.isConnecting || !viewParticipantsWebcams; + const locks = { + videoLocked: this.isUserLocked(), + videoConnecting: this.isConnecting, + dataSaving: !viewParticipantsWebcams, + meteorDisconnected: !Meteor.status().connected + }; + const locksKeys = Object.keys(locks); + const disableReason = locksKeys.filter( i => locks[i]).shift(); + return disableReason ? disableReason : false; } getRole(isLocal) { @@ -739,7 +742,7 @@ export default { getAuthenticatedURL: () => videoService.getAuthenticatedURL(), isLocalStream: cameraId => videoService.isLocalStream(cameraId), hasVideoStream: () => videoService.hasVideoStream(), - isDisabled: () => videoService.isDisabled(), + disableReason: () => videoService.disableReason(), playStart: cameraId => videoService.playStart(cameraId), getCameraProfile: () => videoService.getCameraProfile(), addCandidateToPeer: (peer, candidate, cameraId) => videoService.addCandidateToPeer(peer, candidate, cameraId), diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/video-button/component.jsx b/bigbluebutton-html5/imports/ui/components/video-provider/video-button/component.jsx index 1c140702af586e18eb9a055342c09a69fb5bad76..b5ea26fd9c144765d16023e2941c55146fdc8626 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/video-button/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-provider/video-button/component.jsx @@ -24,6 +24,18 @@ const intlMessages = defineMessages({ id: 'app.video.videoLocked', description: 'video disabled label', }, + videoConnecting: { + id: 'app.video.connecting', + description: 'video connecting label', + }, + dataSaving: { + id: 'app.video.dataSaving', + description: 'video data saving label', + }, + meteorDisconnected: { + id: 'app.video.clientDisconnected', + description: 'Meteor disconnected label', + }, iOSWarning: { id: 'app.iOSWarning.label', description: 'message indicating to upgrade ios version', @@ -33,14 +45,13 @@ const intlMessages = defineMessages({ const propTypes = { intl: PropTypes.object.isRequired, hasVideoStream: PropTypes.bool.isRequired, - isDisabled: PropTypes.bool.isRequired, mountVideoPreview: PropTypes.func.isRequired, }; const JoinVideoButton = ({ intl, hasVideoStream, - isDisabled, + disableReason, mountVideoPreview, }) => { const exitVideo = () => hasVideoStream && !VideoService.isMultipleCamerasEnabled(); @@ -57,14 +68,14 @@ const JoinVideoButton = ({ } }; - const label = exitVideo() ? - intl.formatMessage(intlMessages.leaveVideo) : - intl.formatMessage(intlMessages.joinVideo); + const label = exitVideo() + ? intl.formatMessage(intlMessages.leaveVideo) + : intl.formatMessage(intlMessages.joinVideo); return ( <Button data-test="joinVideo" - label={isDisabled ? intl.formatMessage(intlMessages.videoLocked) : label} + label={disableReason ? intl.formatMessage(intlMessages[disableReason]) : label} className={cx(styles.button, hasVideoStream || styles.btn)} onClick={handleOnClick} hideLabel @@ -74,7 +85,7 @@ const JoinVideoButton = ({ ghost={!hasVideoStream} size="lg" circle - disabled={isDisabled} + disabled={!!disableReason} /> ); }; diff --git a/bigbluebutton-html5/imports/ui/components/video-provider/video-button/container.jsx b/bigbluebutton-html5/imports/ui/components/video-provider/video-button/container.jsx index 28b44f9707f7e54382090a0c3faa5b7c3a196997..b730020c953f177402147a26df8b9cb46ff8ff33 100755 --- a/bigbluebutton-html5/imports/ui/components/video-provider/video-button/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/video-provider/video-button/container.jsx @@ -9,7 +9,7 @@ import VideoService from '../service'; const JoinVideoOptionsContainer = (props) => { const { hasVideoStream, - isDisabled, + disableReason, intl, mountModal, ...restProps @@ -19,7 +19,7 @@ const JoinVideoOptionsContainer = (props) => { return ( <JoinVideoButton {...{ - mountVideoPreview, hasVideoStream, isDisabled, ...restProps, + mountVideoPreview, hasVideoStream, disableReason, ...restProps, }} /> ); @@ -27,5 +27,5 @@ const JoinVideoOptionsContainer = (props) => { export default withModalMounter(injectIntl(withTracker(() => ({ hasVideoStream: VideoService.hasVideoStream(), - isDisabled: VideoService.isDisabled() || !Meteor.status().connected, + disableReason: VideoService.disableReason(), }))(JoinVideoOptionsContainer))); diff --git a/bigbluebutton-html5/imports/ui/services/audio-manager/index.js b/bigbluebutton-html5/imports/ui/services/audio-manager/index.js index 7fe46940005f07299f01041fdaa56efa4fd822e6..9a06d6f12a592575fc5fe38b283056dabc6e056f 100755 --- a/bigbluebutton-html5/imports/ui/services/audio-manager/index.js +++ b/bigbluebutton-html5/imports/ui/services/audio-manager/index.js @@ -280,6 +280,31 @@ class AudioManager { return this.bridge.transferCall(this.onAudioJoin.bind(this)); } + onVoiceUserChanges(fields) { + if (fields.muted !== undefined && fields.muted !== this.isMuted) { + let muteState; + this.isMuted = fields.muted; + + if (this.isMuted) { + muteState = 'selfMuted'; + this.mute(); + } else { + muteState = 'selfUnmuted'; + this.unmute(); + } + + window.parent.postMessage({ response: muteState }, '*'); + } + + if (fields.talking !== undefined && fields.talking !== this.isTalking) { + this.isTalking = fields.talking; + } + + if (this.isMuted) { + this.isTalking = false; + } + } + onAudioJoin() { this.isConnecting = false; this.isConnected = true; @@ -288,21 +313,8 @@ class AudioManager { if (!this.muteHandle) { const query = VoiceUsers.find({ intId: Auth.userID }, { fields: { muted: 1, talking: 1 } }); this.muteHandle = query.observeChanges({ - changed: (id, fields) => { - if (fields.muted !== undefined && fields.muted !== this.isMuted) { - this.isMuted = fields.muted; - const muteState = this.isMuted ? 'selfMuted' : 'selfUnmuted'; - window.parent.postMessage({ response: muteState }, '*'); - } - - if (fields.talking !== undefined && fields.talking !== this.isTalking) { - this.isTalking = fields.talking; - } - - if (this.isMuted) { - this.isTalking = false; - } - }, + added: (id, fields) => this.onVoiceUserChanges(fields), + changed: (id, fields) => this.onVoiceUserChanges(fields), }); } @@ -562,6 +574,29 @@ class AudioManager { this.autoplayBlocked = true; } } + + setSenderTrackEnabled(shouldEnable) { + // If the bridge is set to listen only mode, nothing to do here. This method + // is solely for muting outbound tracks. + if (this.isListenOnly) return; + + // Bridge -> SIP.js bridge, the only full audio capable one right now + const peer = this.bridge.getPeerConnection(); + peer.getSenders().forEach((sender) => { + const { track } = sender; + if (track && track.kind === 'audio') { + track.enabled = shouldEnable; + } + }); + } + + mute() { + this.setSenderTrackEnabled(false); + } + + unmute() { + this.setSenderTrackEnabled(true); + } } const audioManager = new AudioManager(); diff --git a/bigbluebutton-html5/private/config/settings.yml b/bigbluebutton-html5/private/config/settings.yml index 78672e2c02c6cd73c24df763a9223cd0b5b245d2..825bee01405ef78243523ae33878c848589b4658 100755 --- a/bigbluebutton-html5/private/config/settings.yml +++ b/bigbluebutton-html5/private/config/settings.yml @@ -35,6 +35,10 @@ public: duration: 4000 remainingTimeThreshold: 30 remainingTimeAlertThreshold: 1 + # Warning: increasing the limit of breakout rooms per meeting + # can generate excessive overhead to the server. We recommend + # this value to be kept under 12. + breakoutRoomLimit: 8 defaultSettings: application: animations: true diff --git a/bigbluebutton-html5/private/locales/en.json b/bigbluebutton-html5/private/locales/en.json index 783ad8410b59ae3ba1b6a75d084c7e033969403a..ce97c08a811a213688c88633b6f9469a53065088 100755 --- a/bigbluebutton-html5/private/locales/en.json +++ b/bigbluebutton-html5/private/locales/en.json @@ -113,6 +113,9 @@ "app.userList.userOptions.enableNote": "Shared notes are now enabled", "app.userList.userOptions.showUserList": "User list is now shown to viewers", "app.userList.userOptions.enableOnlyModeratorWebcam": "You can enable your webcam now, everyone will see you", + "app.userList.userOptions.savedNames.title": "List of users in meeting {0} at {1}", + "app.userList.userOptions.sortedFirstName.heading": "Sorted by first name:", + "app.userList.userOptions.sortedLastName.heading": "Sorted by last name:", "app.media.label": "Media", "app.media.autoplayAlertDesc": "Allow Access", "app.media.screenshare.start": "Screenshare has started", @@ -587,6 +590,8 @@ "app.videoPreview.webcamNotFoundLabel": "Webcam not found", "app.videoPreview.profileNotFoundLabel": "No supported camera profile", "app.video.joinVideo": "Share webcam", + "app.video.connecting": "Webcam sharing is starting ...", + "app.video.dataSaving": "Webcam sharing is disabled in Data Saving", "app.video.leaveVideo": "Stop sharing webcam", "app.video.iceCandidateError": "Error on adding ICE candidate", "app.video.iceConnectionStateError": "Connection failure (ICE error 1107)", @@ -612,6 +617,7 @@ "app.video.chromeExtensionErrorLink": "this Chrome extension", "app.video.pagination.prevPage": "See previous videos", "app.video.pagination.nextPage": "See next videos", + "app.video.clientDisconnected": "Webcam cannot be shared due to connection issues", "app.fullscreenButton.label": "Make {0} fullscreen", "app.deskshare.iceConnectionStateError": "Connection failed when sharing screen (ICE error 1108)", "app.sfu.mediaServerConnectionError2000": "Unable to connect to media server (error 2000)", diff --git a/bigbluebutton-web/grails-app/conf/bigbluebutton.properties b/bigbluebutton-web/grails-app/conf/bigbluebutton.properties index 376d32b5a83bb18365645ec4fae61cf152ab5f5c..af5ab18a9f76808abeab7fd2b14c42edc7fb5ad2 100755 --- a/bigbluebutton-web/grails-app/conf/bigbluebutton.properties +++ b/bigbluebutton-web/grails-app/conf/bigbluebutton.properties @@ -241,10 +241,10 @@ defaultClientUrl=${bigbluebutton.web.serverURL}/client/BigBlueButton.html allowRequestsWithoutSession=false # Force all attendees to join the meeting using the HTML5 client -attendeesJoinViaHTML5Client=false +attendeesJoinViaHTML5Client=true # Force all moderators to join the meeting using the HTML5 client -moderatorsJoinViaHTML5Client=false +moderatorsJoinViaHTML5Client=true # The url of the BigBlueButton HTML5 client. Users will be redirected here when # successfully joining the meeting. @@ -354,3 +354,8 @@ lockSettingsLockOnJoinConfigurable=false allowDuplicateExtUserid=true defaultTextTrackUrl=${bigbluebutton.web.serverURL}/bigbluebutton + +# Param to end the meeting when there are no moderators after a certain period of time. +# Needed for classes where teacher gets disconnected and can't get back in. Prevents +# students from running amok. +endWhenNoModerator=false diff --git a/bigbluebutton-web/grails-app/conf/spring/resources.xml b/bigbluebutton-web/grails-app/conf/spring/resources.xml index 4fe99c78bc8d1314f25e566f5ce8e1dcff221332..45711ac9d79fec3670e7d5bb6b83c095eb8b3bd4 100755 --- a/bigbluebutton-web/grails-app/conf/spring/resources.xml +++ b/bigbluebutton-web/grails-app/conf/spring/resources.xml @@ -158,6 +158,7 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>. <property name="lockSettingsLockOnJoin" value="${lockSettingsLockOnJoin}"/> <property name="lockSettingsLockOnJoinConfigurable" value="${lockSettingsLockOnJoinConfigurable}"/> <property name="allowDuplicateExtUserid" value="${allowDuplicateExtUserid}"/> + <property name="endWhenNoModerator" value="${endWhenNoModerator}"/> </bean> <import resource="doc-conversion.xml"/>