Merge remote-tracking branch 'upstream/develop' into connection-manager

This commit is contained in:
Joao Siebel 2020-10-21 13:57:17 -03:00
commit be8421db3c
59 changed files with 22225 additions and 13428 deletions

View File

@ -37,6 +37,7 @@ trait SystemConfiguration {
lazy val fromAkkaAppsJsonChannel = Try(config.getString("eventBus.fromAkkaAppsChannel")).getOrElse("from-akka-apps-json-channel")
lazy val applyPermissionCheck = Try(config.getBoolean("apps.checkPermissions")).getOrElse(false)
lazy val ejectOnViolation = Try(config.getBoolean("apps.ejectOnViolation")).getOrElse(false)
lazy val voiceConfRecordPath = Try(config.getString("voiceConf.recordPath")).getOrElse("/var/freeswitch/meetings")
lazy val voiceConfRecordCodec = Try(config.getString("voiceConf.recordCodec")).getOrElse("wav")

View File

@ -37,7 +37,7 @@ trait RightsManagementTrait extends SystemConfiguration {
}
}
object PermissionCheck {
object PermissionCheck extends SystemConfiguration {
val MOD_LEVEL = 100
val AUTHED_LEVEL = 50
@ -83,12 +83,17 @@ object PermissionCheck {
def ejectUserForFailedPermission(meetingId: String, userId: String, reason: String,
outGW: OutMsgRouter, liveMeeting: LiveMeeting): Unit = {
val ejectedBy = SystemUser.ID
if (ejectOnViolation) {
val ejectedBy = SystemUser.ID
UsersApp.ejectUserFromMeeting(outGW, liveMeeting, userId, ejectedBy, reason, EjectReasonCode.PERMISSION_FAILED, ban = false)
UsersApp.ejectUserFromMeeting(outGW, liveMeeting, userId, ejectedBy, reason, EjectReasonCode.PERMISSION_FAILED, ban = false)
// send a system message to force disconnection
Sender.sendDisconnectClientSysMsg(meetingId, userId, ejectedBy, reason, outGW)
// send a system message to force disconnection
Sender.sendDisconnectClientSysMsg(meetingId, userId, ejectedBy, reason, outGW)
} else {
// TODO: get this object a context so it can use the akka logging system
println(s"Skipping violation ejection of ${userId} trying to ${reason} in ${meetingId}")
}
}
def addOldPresenter(users: Users2x, userId: String): OldPresenter = {

View File

@ -71,6 +71,7 @@ services {
apps {
checkPermissions = true
ejectOnViolation = false
endMeetingWhenNoMoreAuthedUsers = false
endMeetingWhenNoMoreAuthedUsersAfterMinutes = 2
}

View File

@ -82,8 +82,9 @@ import org.bigbluebutton.api2.IBbbWebApiGWApp;
import org.bigbluebutton.api2.domain.UploadedTrack;
import org.bigbluebutton.common2.redis.RedisStorageService;
import org.bigbluebutton.presentation.PresentationUrlDownloadService;
import org.bigbluebutton.web.services.RegisteredUserCleanupTimerTask;
import org.bigbluebutton.web.services.WaitingGuestCleanupTimerTask;
import org.bigbluebutton.web.services.UserCleanupTimerTask;
import org.bigbluebutton.web.services.EnteredUserCleanupTimerTask;
import org.bigbluebutton.web.services.callback.CallbackUrlService;
import org.bigbluebutton.web.services.callback.MeetingEndedEvent;
import org.bigbluebutton.web.services.turn.StunTurnService;
@ -108,13 +109,17 @@ public class MeetingService implements MessageListener {
private final ConcurrentMap<String, UserSession> sessions;
private RecordingService recordingService;
private RegisteredUserCleanupTimerTask registeredUserCleaner;
private WaitingGuestCleanupTimerTask waitingGuestCleaner;
private UserCleanupTimerTask userCleaner;
private EnteredUserCleanupTimerTask enteredUserCleaner;
private StunTurnService stunTurnService;
private RedisStorageService storeService;
private CallbackUrlService callbackUrlService;
private boolean keepEvents;
private long usersTimeout;
private long enteredUsersTimeout;
private ParamsProcessorUtil paramsProcessorUtil;
private PresentationUrlDownloadService presDownloadService;
@ -181,22 +186,63 @@ public class MeetingService implements MessageListener {
}
/**
* Remove registered users who did not successfully joined the meeting.
* Remove users who did not successfully reconnected to the meeting.
*/
public void purgeRegisteredUsers() {
public void purgeUsers() {
for (AbstractMap.Entry<String, Meeting> entry : this.meetings.entrySet()) {
Long now = System.currentTimeMillis();
Meeting meeting = entry.getValue();
ConcurrentMap<String, User> users = meeting.getUsersMap();
for (AbstractMap.Entry<String, User> userEntry : meeting.getUsersMap().entrySet()) {
String userId = userEntry.getKey();
User user = userEntry.getValue();
for (AbstractMap.Entry<String, RegisteredUser> registeredUser : meeting.getRegisteredUsers().entrySet()) {
String registeredUserID = registeredUser.getKey();
RegisteredUser registeredUserDate = registeredUser.getValue();
if (!user.hasLeft()) continue;
long elapsedTime = now - registeredUserDate.registeredOn;
if (elapsedTime >= 60000 && !users.containsKey(registeredUserID)) {
meeting.userUnregistered(registeredUserID);
long elapsedTime = now - user.getLeftOn();
if (elapsedTime >= usersTimeout) {
meeting.removeUser(userId);
Map<String, Object> logData = new HashMap<>();
logData.put("meetingId", meeting.getInternalId());
logData.put("userId", userId);
logData.put("logCode", "removed_user");
logData.put("description", "User left and was removed from the meeting.");
Gson gson = new Gson();
String logStr = gson.toJson(logData);
log.info(" --analytics-- data={}", logStr);
}
}
}
}
/**
* Remove entered users who did not join.
*/
public void purgeEnteredUsers() {
for (AbstractMap.Entry<String, Meeting> entry : this.meetings.entrySet()) {
Long now = System.currentTimeMillis();
Meeting meeting = entry.getValue();
for (AbstractMap.Entry<String, Long> enteredUser : meeting.getEnteredUsers().entrySet()) {
String userId = enteredUser.getKey();
long elapsedTime = now - enteredUser.getValue();
if (elapsedTime >= enteredUsersTimeout) {
meeting.removeEnteredUser(userId);
Map<String, Object> logData = new HashMap<>();
logData.put("meetingId", meeting.getInternalId());
logData.put("userId", userId);
logData.put("logCode", "purged_entered_user");
logData.put("description", "Purged user that called ENTER from the API but never joined");
Gson gson = new Gson();
String logStr = gson.toJson(logData);
log.info(" --analytics-- data={}", logStr);
}
}
}
@ -879,13 +925,6 @@ public class MeetingService implements MessageListener {
// the meeting ended.
m.setEndTime(System.currentTimeMillis());
}
RegisteredUser userRegistered = m.userUnregistered(message.userId);
if (userRegistered != null) {
log.info("User unregistered from meeting");
} else {
log.info("User was not unregistered from meeting because it was not found");
}
}
}
}
@ -1098,8 +1137,9 @@ public class MeetingService implements MessageListener {
public void stop() {
processMessage = false;
registeredUserCleaner.stop();
waitingGuestCleaner.stop();
userCleaner.stop();
enteredUserCleaner.stop();
}
public void setRecordingService(RecordingService s) {
@ -1124,11 +1164,16 @@ public class MeetingService implements MessageListener {
waitingGuestCleaner.start();
}
public void setRegisteredUserCleanupTimerTask(
RegisteredUserCleanupTimerTask c) {
registeredUserCleaner = c;
registeredUserCleaner.setMeetingService(this);
registeredUserCleaner.start();
public void setEnteredUserCleanupTimerTask(EnteredUserCleanupTimerTask c) {
enteredUserCleaner = c;
enteredUserCleaner.setMeetingService(this);
enteredUserCleaner.start();
}
public void setUserCleanupTimerTask(UserCleanupTimerTask c) {
userCleaner = c;
userCleaner.setMeetingService(this);
userCleaner.start();
}
public void setStunTurnService(StunTurnService s) {
@ -1138,4 +1183,12 @@ public class MeetingService implements MessageListener {
public void setKeepEvents(boolean value) {
keepEvents = value;
}
public void setUsersTimeout(long value) {
usersTimeout = value;
}
public void setEnteredUsersTimeout(long value) {
enteredUsersTimeout = value;
}
}

View File

@ -73,6 +73,7 @@ public class Meeting {
private Map<String, Object> userCustomData;
private final ConcurrentMap<String, User> users;
private final ConcurrentMap<String, RegisteredUser> registeredUsers;
private final ConcurrentMap<String, Long> enteredUsers;
private final ConcurrentMap<String, Config> configs;
private final Boolean isBreakout;
private final List<String> breakoutRooms = new ArrayList<>();
@ -132,6 +133,7 @@ public class Meeting {
users = new ConcurrentHashMap<>();
registeredUsers = new ConcurrentHashMap<>();
enteredUsers = new ConcurrentHashMap<>();;
configs = new ConcurrentHashMap<>();
}
@ -454,12 +456,28 @@ public class Meeting {
}
public void userJoined(User user) {
userHasJoined = true;
this.users.put(user.getInternalUserId(), user);
User u = getUserById(user.getInternalUserId());
if (u != null) {
u.joined();
} else {
if (!userHasJoined) userHasJoined = true;
this.users.put(user.getInternalUserId(), user);
// Clean this user up from the entered user's list
removeEnteredUser(user.getInternalUserId());
}
}
public User userLeft(String userid){
return users.remove(userid);
public User userLeft(String userId) {
User user = getUserById(userId);
if (user != null) {
user.left();
}
return user;
}
public User removeUser(String userId) {
return this.users.remove(userId);
}
public User getUserById(String id){
@ -593,6 +611,29 @@ public class Meeting {
return registeredUsers;
}
public ConcurrentMap<String, Long> getEnteredUsers() {
return this.enteredUsers;
}
public void userEntered(String userId) {
// Skip if user already joined
User u = getUserById(userId);
if (u != null) return;
if (!enteredUsers.containsKey(userId)) {
Long time = System.currentTimeMillis();
this.enteredUsers.put(userId, time);
}
}
public Long removeEnteredUser(String userId) {
return this.enteredUsers.remove(userId);
}
public Long getEnteredUserById(String userId) {
return this.enteredUsers.get(userId);
}
/***
* Meeting Builder
*

View File

@ -38,6 +38,7 @@ public class User {
private Boolean voiceJoined = false;
private String clientType;
private List<String> streams;
private Long leftOn = null;
public User(String internalUserId,
String externalUserId,
@ -90,6 +91,22 @@ public class User {
return this.guestStatus;
}
public Boolean hasLeft() {
return leftOn != null;
}
public void joined() {
this.leftOn = null;
}
public void left() {
this.leftOn = System.currentTimeMillis();
}
public Long getLeftOn() {
return this.leftOn;
}
public String getFullname() {
return fullname;
}

View File

@ -1,231 +1,224 @@
/**
* BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
*
* Copyright (c) 2012 BigBlueButton Inc. and by respective authors (see below).
*
* This program is free software; you can redistribute it and/or modify it under the
* terms of the GNU Lesser General Public License as published by the Free Software
* Foundation; either version 3.0 of the License, or (at your option) any later
* version.
*
* BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License along
* with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
*
*/
package org.bigbluebutton.presentation.imp;
import java.io.File;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import org.bigbluebutton.presentation.ConversionMessageConstants;
import org.bigbluebutton.presentation.SupportedFileTypes;
import org.bigbluebutton.presentation.UploadedPresentation;
import org.jodconverter.core.office.OfficeException;
import org.jodconverter.core.office.OfficeUtils;
import org.jodconverter.local.LocalConverter;
import org.jodconverter.local.office.ExternalOfficeManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
public class OfficeToPdfConversionService {
private static Logger log = LoggerFactory.getLogger(OfficeToPdfConversionService.class);
private OfficeDocumentValidator2 officeDocumentValidator;
private final ArrayList<ExternalOfficeManager> officeManagers;
private ExternalOfficeManager currentManager = null;
private boolean skipOfficePrecheck = false;
private int sofficeBasePort = 0;
private int sofficeManagers = 0;
private String sofficeWorkingDirBase = null;
public OfficeToPdfConversionService() throws OfficeException {
officeManagers = new ArrayList<>();
}
/*
* Convert the Office document to PDF. If successful, update
* UploadPresentation.uploadedFile with the new PDF out and
* UploadPresentation.lastStepSuccessful to TRUE.
*/
public UploadedPresentation convertOfficeToPdf(UploadedPresentation pres) {
initialize(pres);
if (SupportedFileTypes.isOfficeFile(pres.getFileType())) {
// Check if we need to precheck office document
if (!skipOfficePrecheck && officeDocumentValidator.isValid(pres)) {
Map<String, Object> logData = new HashMap<>();
logData.put("meetingId", pres.getMeetingId());
logData.put("presId", pres.getId());
logData.put("filename", pres.getName());
logData.put("logCode", "problems_office_to_pdf_validation");
logData.put("message", "Problems detected prior to converting the file to PDF.");
Gson gson = new Gson();
String logStr = gson.toJson(logData);
log.warn(" --analytics-- data={}", logStr);
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_INVALID_KEY);
return pres;
}
File pdfOutput = setupOutputPdfFile(pres);
if (convertOfficeDocToPdf(pres, pdfOutput)) {
Map<String, Object> logData = new HashMap<>();
logData.put("meetingId", pres.getMeetingId());
logData.put("presId", pres.getId());
logData.put("filename", pres.getName());
logData.put("logCode", "office_to_pdf_success");
logData.put("message", "Successfully converted office file to pdf.");
Gson gson = new Gson();
String logStr = gson.toJson(logData);
log.info(" --analytics-- data={}", logStr);
makePdfTheUploadedFileAndSetStepAsSuccess(pres, pdfOutput);
} else {
Map<String, Object> logData = new HashMap<>();
logData.put("meetingId", pres.getMeetingId());
logData.put("presId", pres.getId());
logData.put("filename", pres.getName());
logData.put("logCode", "office_to_pdf_failed");
logData.put("message", "Failed to convert " + pres.getUploadedFile().getAbsolutePath() + " to Pdf.");
Gson gson = new Gson();
String logStr = gson.toJson(logData);
log.warn(" --analytics-- data={}", logStr);
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_FAILED_KEY);
return pres;
}
}
return pres;
}
public void initialize(UploadedPresentation pres) {
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_FAILED_KEY);
}
private File setupOutputPdfFile(UploadedPresentation pres) {
File presentationFile = pres.getUploadedFile();
String filenameWithoutExt = presentationFile.getAbsolutePath().substring(0,
presentationFile.getAbsolutePath().lastIndexOf('.'));
return new File(filenameWithoutExt + ".pdf");
}
private boolean convertOfficeDocToPdf(UploadedPresentation pres,
File pdfOutput) {
boolean success = false;
int attempts = 0;
while(!success) {
LocalConverter documentConverter = LocalConverter
.builder()
.officeManager(currentManager)
.filterChain(new OfficeDocumentConversionFilter())
.build();
success = Office2PdfPageConverter.convert(pres.getUploadedFile(), pdfOutput, 0, pres, documentConverter);
if(!success) {
// In case of failure, try with other open Office Manager
if(++attempts != officeManagers.size()) {
// Go to next Office Manager ( if the last retry with the first one )
int currentManagerIndex = officeManagers.indexOf(currentManager);
boolean isLastManager = ( currentManagerIndex == officeManagers.size()-1 );
if(isLastManager) {
currentManager = officeManagers.get(0);
} else {
currentManager = officeManagers.get(currentManagerIndex+1);
}
} else {
// We tried to use all our office managers and it's still failing
break;
}
}
}
return success;
}
private void makePdfTheUploadedFileAndSetStepAsSuccess(UploadedPresentation pres, File pdf) {
pres.setUploadedFile(pdf);
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_SUCCESS_KEY);
}
public void setOfficeDocumentValidator(OfficeDocumentValidator2 v) {
officeDocumentValidator = v;
}
public void setSkipOfficePrecheck(boolean skipOfficePrecheck) {
this.skipOfficePrecheck = skipOfficePrecheck;
}
public void setSofficeBasePort(int sofficeBasePort) {
this.sofficeBasePort = sofficeBasePort;
}
public void setSofficeManagers(int sofficeServiceManagers) {
this.sofficeManagers = sofficeServiceManagers;
}
public void setSofficeWorkingDirBase(String sofficeWorkingDirBase) {
this.sofficeWorkingDirBase = sofficeWorkingDirBase;
}
public void start() {
log.info("Starting LibreOffice pool with " + sofficeManagers + " managers, starting from port " + sofficeBasePort);
for(int managerIndex = 0; managerIndex < sofficeManagers; managerIndex ++) {
Integer instanceNumber = managerIndex + 1; // starts at 1
try {
final File workingDir = new File(sofficeWorkingDirBase + String.format("%02d", instanceNumber));
if(!workingDir.exists()) {
workingDir.mkdir();
}
ExternalOfficeManager officeManager = ExternalOfficeManager
.builder()
.connectTimeout(2000L)
.retryInterval(500L)
.portNumber(sofficeBasePort + managerIndex)
.connectOnStart(false) // If it's true and soffice is not available, exception is thrown here ( we don't want exception here - we want the manager alive trying to reconnect )
.workingDir(workingDir)
.build();
// Workaround for jodconverter not calling makeTempDir when connectOnStart=false (issue 211)
Method method = officeManager.getClass().getSuperclass().getDeclaredMethod("makeTempDir");
method.setAccessible(true);
method.invoke(officeManager);
// End of workaround for jodconverter not calling makeTempDir
officeManager.start();
officeManagers.add(officeManager);
} catch (Exception e) {
log.error("Could not start Office Manager " + instanceNumber + ". Details: " + e.getMessage());
}
}
if (officeManagers.size() == 0) {
log.error("No office managers could be started");
return;
}
currentManager = officeManagers.get(0);
}
public void stop() {
try {
officeManagers.forEach(officeManager -> officeManager.stop() );
} catch (Exception e) {
log.error("Could not stop Office Manager", e);
}
}
}
/**
* BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
*
* Copyright (c) 2012 BigBlueButton Inc. and by respective authors (see below).
*
* This program is free software; you can redistribute it and/or modify it under the
* terms of the GNU Lesser General Public License as published by the Free Software
* Foundation; either version 3.0 of the License, or (at your option) any later
* version.
*
* BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License along
* with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
*
*/
package org.bigbluebutton.presentation.imp;
import java.io.File;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import org.bigbluebutton.presentation.ConversionMessageConstants;
import org.bigbluebutton.presentation.SupportedFileTypes;
import org.bigbluebutton.presentation.UploadedPresentation;
import org.jodconverter.core.office.OfficeException;
import org.jodconverter.core.office.OfficeUtils;
import org.jodconverter.local.LocalConverter;
import org.jodconverter.local.office.ExternalOfficeManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.sun.star.document.UpdateDocMode;
import com.google.gson.Gson;
public class OfficeToPdfConversionService {
private static Logger log = LoggerFactory.getLogger(OfficeToPdfConversionService.class);
private OfficeDocumentValidator2 officeDocumentValidator;
private final ArrayList<ExternalOfficeManager> officeManagers;
private ExternalOfficeManager currentManager = null;
private boolean skipOfficePrecheck = false;
private int sofficeBasePort = 0;
private int sofficeManagers = 0;
private String sofficeWorkingDirBase = null;
public OfficeToPdfConversionService() throws OfficeException {
officeManagers = new ArrayList<>();
}
/*
* Convert the Office document to PDF. If successful, update
* UploadPresentation.uploadedFile with the new PDF out and
* UploadPresentation.lastStepSuccessful to TRUE.
*/
public UploadedPresentation convertOfficeToPdf(UploadedPresentation pres) {
initialize(pres);
if (SupportedFileTypes.isOfficeFile(pres.getFileType())) {
// Check if we need to precheck office document
if (!skipOfficePrecheck && officeDocumentValidator.isValid(pres)) {
Map<String, Object> logData = new HashMap<>();
logData.put("meetingId", pres.getMeetingId());
logData.put("presId", pres.getId());
logData.put("filename", pres.getName());
logData.put("logCode", "problems_office_to_pdf_validation");
logData.put("message", "Problems detected prior to converting the file to PDF.");
Gson gson = new Gson();
String logStr = gson.toJson(logData);
log.warn(" --analytics-- data={}", logStr);
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_INVALID_KEY);
return pres;
}
File pdfOutput = setupOutputPdfFile(pres);
if (convertOfficeDocToPdf(pres, pdfOutput)) {
Map<String, Object> logData = new HashMap<>();
logData.put("meetingId", pres.getMeetingId());
logData.put("presId", pres.getId());
logData.put("filename", pres.getName());
logData.put("logCode", "office_to_pdf_success");
logData.put("message", "Successfully converted office file to pdf.");
Gson gson = new Gson();
String logStr = gson.toJson(logData);
log.info(" --analytics-- data={}", logStr);
makePdfTheUploadedFileAndSetStepAsSuccess(pres, pdfOutput);
} else {
Map<String, Object> logData = new HashMap<>();
logData.put("meetingId", pres.getMeetingId());
logData.put("presId", pres.getId());
logData.put("filename", pres.getName());
logData.put("logCode", "office_to_pdf_failed");
logData.put("message", "Failed to convert " + pres.getUploadedFile().getAbsolutePath() + " to Pdf.");
Gson gson = new Gson();
String logStr = gson.toJson(logData);
log.warn(" --analytics-- data={}", logStr);
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_FAILED_KEY);
return pres;
}
}
return pres;
}
public void initialize(UploadedPresentation pres) {
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_FAILED_KEY);
}
private File setupOutputPdfFile(UploadedPresentation pres) {
File presentationFile = pres.getUploadedFile();
String filenameWithoutExt = presentationFile.getAbsolutePath().substring(0,
presentationFile.getAbsolutePath().lastIndexOf('.'));
return new File(filenameWithoutExt + ".pdf");
}
private boolean convertOfficeDocToPdf(UploadedPresentation pres,
File pdfOutput) {
boolean success = false;
int attempts = 0;
while(!success) {
final Map<String, Object> loadProperties = new HashMap<>();
loadProperties.put("Hidden", true);
loadProperties.put("ReadOnly", true);
loadProperties.put("UpdateDocMode", UpdateDocMode.NO_UPDATE);
LocalConverter documentConverter = LocalConverter
.builder()
.officeManager(currentManager)
.loadProperties(loadProperties)
.filterChain(new OfficeDocumentConversionFilter())
.build();
success = Office2PdfPageConverter.convert(pres.getUploadedFile(), pdfOutput, 0, pres, documentConverter);
if(!success) {
// In case of failure, try with other open Office Manager
if(++attempts != officeManagers.size()) {
// Go to next Office Manager ( if the last retry with the first one )
int currentManagerIndex = officeManagers.indexOf(currentManager);
boolean isLastManager = ( currentManagerIndex == officeManagers.size()-1 );
if(isLastManager) {
currentManager = officeManagers.get(0);
} else {
currentManager = officeManagers.get(currentManagerIndex+1);
}
} else {
// We tried to use all our office managers and it's still failing
break;
}
}
}
return success;
}
private void makePdfTheUploadedFileAndSetStepAsSuccess(UploadedPresentation pres, File pdf) {
pres.setUploadedFile(pdf);
pres.setConversionStatus(ConversionMessageConstants.OFFICE_DOC_CONVERSION_SUCCESS_KEY);
}
public void setOfficeDocumentValidator(OfficeDocumentValidator2 v) {
officeDocumentValidator = v;
}
public void setSkipOfficePrecheck(boolean skipOfficePrecheck) {
this.skipOfficePrecheck = skipOfficePrecheck;
}
public void setSofficeBasePort(int sofficeBasePort) {
this.sofficeBasePort = sofficeBasePort;
}
public void setSofficeManagers(int sofficeServiceManagers) {
this.sofficeManagers = sofficeServiceManagers;
}
public void setSofficeWorkingDirBase(String sofficeWorkingDirBase) {
this.sofficeWorkingDirBase = sofficeWorkingDirBase;
}
public void start() {
log.info("Starting LibreOffice pool with " + sofficeManagers + " managers, starting from port " + sofficeBasePort);
for(int managerIndex = 0; managerIndex < sofficeManagers; managerIndex ++) {
Integer instanceNumber = managerIndex + 1; // starts at 1
try {
final File workingDir = new File(sofficeWorkingDirBase + String.format("%02d", instanceNumber));
if(!workingDir.exists()) {
workingDir.mkdir();
}
ExternalOfficeManager officeManager = ExternalOfficeManager
.builder()
.connectTimeout(2000L)
.retryInterval(500L)
.portNumber(sofficeBasePort + managerIndex)
.connectOnStart(false) // If it's true and soffice is not available, exception is thrown here ( we don't want exception here - we want the manager alive trying to reconnect )
.workingDir(workingDir)
.build();
// Workaround for jodconverter not calling makeTempDir when connectOnStart=false (issue 211)
Method method = officeManager.getClass().getSuperclass().getDeclaredMethod("makeTempDir");
method.setAccessible(true);
method.invoke(officeManager);
// End of workaround for jodconverter not calling makeTempDir
officeManager.start();
officeManagers.add(officeManager);
} catch (Exception e) {
log.error("Could not start Office Manager " + instanceNumber + ". Details: " + e.getMessage());
}
}
if (officeManagers.size() == 0) {
log.error("No office managers could be started");
return;
}
currentManager = officeManagers.get(0);
}
public void stop() {
try {
officeManagers.forEach(officeManager -> officeManager.stop() );
} catch (Exception e) {
log.error("Could not stop Office Manager", e);
}
}
}

View File

@ -1,7 +1,7 @@
/**
* BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
*
* Copyright (c) 2012 BigBlueButton Inc. and by respective authors (see below).
* Copyright (c) 2020 BigBlueButton Inc. and by respective authors (see below).
*
* This program is free software; you can redistribute it and/or modify it under the
* terms of the GNU Lesser General Public License as published by the Free Software
@ -25,11 +25,11 @@ import java.util.concurrent.TimeUnit;
import org.bigbluebutton.api.MeetingService;
public class RegisteredUserCleanupTimerTask {
public class EnteredUserCleanupTimerTask {
private MeetingService service;
private ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(1);
private long runEvery = 60000;
private long runEvery = 30000;
public void setMeetingService(MeetingService svc) {
this.service = svc;
@ -50,7 +50,7 @@ public class RegisteredUserCleanupTimerTask {
private class CleanupTask implements Runnable {
@Override
public void run() {
service.purgeRegisteredUsers();
service.purgeEnteredUsers();
}
}
}
}

View File

@ -0,0 +1,56 @@
/**
* BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
*
* Copyright (c) 2020 BigBlueButton Inc. and by respective authors (see below).
*
* This program is free software; you can redistribute it and/or modify it under the
* terms of the GNU Lesser General Public License as published by the Free Software
* Foundation; either version 3.0 of the License, or (at your option) any later
* version.
*
* BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License along
* with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
*
*/
package org.bigbluebutton.web.services;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.bigbluebutton.api.MeetingService;
public class UserCleanupTimerTask {
private MeetingService service;
private ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(1);
private long runEvery = 15000;
public void setMeetingService(MeetingService svc) {
this.service = svc;
}
public void start() {
scheduledThreadPool.scheduleWithFixedDelay(new CleanupTask(), 60000, runEvery, TimeUnit.MILLISECONDS);
}
public void stop() {
scheduledThreadPool.shutdownNow();
}
public void setRunEvery(long v) {
runEvery = v;
}
private class CleanupTask implements Runnable {
@Override
public void run() {
service.purgeUsers();
}
}
}

View File

@ -81,7 +81,6 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
});
</script>
<script src="compatibility/adapter.js?v=VERSION" language="javascript"></script>
<script src="compatibility/bowser.js?v=VERSION" language="javascript"></script>
<script src="compatibility/sip.js?v=VERSION" language="javascript"></script>
<script src="compatibility/kurento-extension.js?v=VERSION" language="javascript"></script>
<script src="compatibility/kurento-utils.js?v=VERSION" language="javascript"></script>
@ -89,7 +88,6 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
<body style="background-color: #06172A">
<div id="app" role="document"></div>
<span id="destination"></span>
<audio id="remote-media" autoPlay="autoplay">
<track kind="captions" /> {/* These captions are brought to you by eslint */}
<audio id="remote-media" autoplay>
</audio>
</body>

View File

@ -2,8 +2,6 @@ import { check } from 'meteor/check';
const ANNOTATION_TYPE_TEXT = 'text';
const ANNOTATION_TYPE_PENCIL = 'pencil';
const DEFAULT_TEXT_WIDTH = 30;
const DEFAULT_TEXT_HEIGHT = 20;
// line, triangle, ellipse, rectangle
function handleCommonAnnotation(meetingId, whiteboardId, userId, annotation) {
@ -41,23 +39,6 @@ function handleTextUpdate(meetingId, whiteboardId, userId, annotation) {
id, status, annotationType, annotationInfo, wbId, position,
} = annotation;
const { textBoxWidth, textBoxHeight, calcedFontSize } = annotationInfo;
const useDefaultSize = (textBoxWidth === 0 && textBoxHeight === 0)
|| textBoxWidth < calcedFontSize
|| textBoxHeight < calcedFontSize;
if (useDefaultSize) {
annotationInfo.textBoxWidth = DEFAULT_TEXT_WIDTH;
annotationInfo.textBoxHeight = DEFAULT_TEXT_HEIGHT;
if (100 - annotationInfo.x < DEFAULT_TEXT_WIDTH) {
annotationInfo.textBoxWidth = 100 - annotationInfo.x;
}
if (100 - annotationInfo.y < DEFAULT_TEXT_HEIGHT) {
annotationInfo.textBoxHeight = 100 - annotationInfo.y;
}
}
const selector = {
meetingId,
id,

View File

@ -60,14 +60,22 @@ export default class KurentoAudioBridge extends BaseAudioBridge {
let iceServers = [];
try {
logger.info({
logCode: 'sfuaudiobridge_stunturn_fetch_start',
extraInfo: { iceServers },
}, 'SFU audio bridge starting STUN/TURN fetch');
iceServers = await fetchWebRTCMappedStunTurnServers(this.user.sessionToken);
} catch (error) {
logger.error({ logCode: 'sfuaudiobridge_stunturn_fetch_failed' },
'SFU audio bridge failed to fetch STUN/TURN info, using default servers');
iceServers = getMappedFallbackStun();
} finally {
logger.debug({ logCode: 'sfuaudiobridge_stunturn_fetch_sucess', extraInfo: { iceServers } },
'SFU audio bridge got STUN/TURN servers');
logger.info({
logCode: 'sfuaudiobridge_stunturn_fetch_sucess',
extraInfo: { iceServers },
}, 'SFU audio bridge got STUN/TURN servers');
const options = {
wsUrl: Auth.authenticateURL(SFU_URL),
userName: this.user.name,
@ -131,12 +139,25 @@ export default class KurentoAudioBridge extends BaseAudioBridge {
this.hasSuccessfullyStarted = true;
if (webRtcPeer) {
logger.info({
logCode: 'sfuaudiobridge_audio_negotiation_success',
}, 'SFU audio bridge negotiated audio with success');
const stream = webRtcPeer.getRemoteStream();
audioTag.pause();
audioTag.srcObject = stream;
audioTag.muted = false;
logger.info({
logCode: 'sfuaudiobridge_audio_ready_to_play',
}, 'SFU audio bridge is ready to play');
playElement();
} else {
logger.info({
logCode: 'sfuaudiobridge_audio_negotiation_failed',
}, 'SFU audio bridge failed to negotiate audio');
this.callback({
status: this.baseCallStates.failed,
error: this.baseErrorCodes.CONNECTION_ERROR,
@ -218,6 +239,9 @@ export default class KurentoAudioBridge extends BaseAudioBridge {
return reject(new Error('Invalid bridge option'));
}
logger.info({
logCode: 'sfuaudiobridge_ready_to_join_audio',
}, 'SFU audio bridge is ready to join audio');
window.kurentoJoinAudio(
MEDIA_TAG,
this.voiceBridge,

View File

@ -1,7 +1,10 @@
import browser from 'browser-detect';
import BaseAudioBridge from './base';
import logger from '/imports/startup/client/logger';
import { fetchStunTurnServers, getFallbackStun } from '/imports/utils/fetchStunTurnServers';
import {
fetchWebRTCMappedStunTurnServers,
getMappedFallbackStun,
} from '/imports/utils/fetchStunTurnServers';
import {
isUnifiedPlan,
toUnifiedPlan,
@ -20,13 +23,13 @@ const MEDIA_TAG = MEDIA.mediaTag;
const CALL_TRANSFER_TIMEOUT = MEDIA.callTransferTimeout;
const CALL_HANGUP_TIMEOUT = MEDIA.callHangupTimeout;
const CALL_HANGUP_MAX_RETRIES = MEDIA.callHangupMaximumRetries;
const RELAY_ONLY_ON_RECONNECT = MEDIA.relayOnlyOnReconnect;
const IPV4_FALLBACK_DOMAIN = Meteor.settings.public.app.ipv4FallbackDomain;
const ICE_NEGOTIATION_FAILED = ['iceConnectionFailed'];
const CALL_CONNECT_TIMEOUT = 20000;
const ICE_NEGOTIATION_TIMEOUT = 20000;
const AUDIO_SESSION_NUM_KEY = 'AudioSessionNumber';
const USER_AGENT_RECONNECTION_ATTEMPTS = 3;
const USER_AGENT_RECONNECTION_DELAY_MS = 5000;
const USER_AGENT_CONNECTION_TIMEOUT_MS = 5000;
const getAudioSessionNumber = () => {
let currItem = parseInt(sessionStorage.getItem(AUDIO_SESSION_NUM_KEY), 10);
@ -39,6 +42,24 @@ const getAudioSessionNumber = () => {
return currItem;
};
/**
* Get error code from SIP.js websocket messages.
*/
const getErrorCode = (error) => {
try {
if (!error) return error;
const match = error.message.match(/code: \d+/g);
const _codeArray = match[0].split(':');
return parseInt(_codeArray[1].trim(), 10);
} catch (e) {
return 0;
}
};
class SIPSession {
constructor(user, userData, protocol, hostname,
baseCallStates, baseErrorCodes, reconnectAttempt) {
@ -49,9 +70,15 @@ class SIPSession {
this.baseCallStates = baseCallStates;
this.baseErrorCodes = baseErrorCodes;
this.reconnectAttempt = reconnectAttempt;
this.currentSession = null;
this.remoteStream = null;
this.inputDeviceId = null;
this._hangupFlag = false;
this._reconnecting = false;
this._currentSessionState = null;
}
joinAudio({ isListenOnly, extension, inputStream }, managerCallback) {
joinAudio({ isListenOnly, extension, inputDeviceId }, managerCallback) {
return new Promise((resolve, reject) => {
const callExtension = extension ? `${extension}${this.userData.voiceBridge}` : this.userData.voiceBridge;
@ -78,7 +105,7 @@ class SIPSession {
// If there's an extension passed it means that we're joining the echo test first
this.inEchoTest = !!extension;
return this.doCall({ callExtension, isListenOnly, inputStream })
return this.doCall({ callExtension, isListenOnly, inputDeviceId })
.catch((reason) => {
reject(reason);
});
@ -87,7 +114,7 @@ class SIPSession {
async getIceServers(sessionToken) {
try {
const iceServers = await fetchStunTurnServers(sessionToken);
const iceServers = await fetchWebRTCMappedStunTurnServers(sessionToken);
return iceServers;
} catch (error) {
logger.error({
@ -98,15 +125,18 @@ class SIPSession {
callerIdName: this.user.callerIdName,
},
}, 'Full audio bridge failed to fetch STUN/TURN info');
return getFallbackStun();
return getMappedFallbackStun();
}
}
doCall(options) {
const {
isListenOnly,
inputDeviceId,
} = options;
this.inputDeviceId = inputDeviceId;
const {
userId,
name,
@ -124,8 +154,7 @@ class SIPSession {
return this.getIceServers(sessionToken)
.then(this.createUserAgent.bind(this))
.then(this.inviteUserAgent.bind(this))
.then(this.setupEventHandlers.bind(this));
.then(this.inviteUserAgent.bind(this));
}
transferCall(onTransferSuccess) {
@ -149,7 +178,18 @@ class SIPSession {
}, CALL_TRANSFER_TIMEOUT);
// This is is the call transfer code ask @chadpilkey
this.currentSession.dtmf(1);
if (this.sessionSupportRTPPayloadDtmf(this.currentSession)) {
this.currentSession.sessionDescriptionHandler.sendDtmf(1);
} else {
// RFC4733 not supported , sending DTMF through INFO
logger.debug({
logCode: 'sip_js_rtp_payload_dtmf_not_supported',
extraInfo: {
callerIdName: this.user.callerIdName,
},
}, 'Browser do not support payload dtmf, using INFO instead');
this.sendDtmf(1);
}
Tracker.autorun((c) => {
trackerControl = c;
@ -171,28 +211,82 @@ class SIPSession {
});
}
/**
*
* sessionSupportRTPPayloadDtmf
* tells if browser support RFC4733 DTMF.
* Safari 13 doens't support it yet
*/
sessionSupportRTPPayloadDtmf(session) {
try {
const sessionDescriptionHandler = session
? session.sessionDescriptionHandler
: this.currentSession.sessionDescriptionHandler;
const senders = sessionDescriptionHandler.peerConnection.getSenders();
return !!(senders[0].dtmf);
} catch (error) {
return false;
}
}
/**
* sendDtmf - send DTMF Tones using INFO message
*
* same as SimpleUser's dtmf
*/
sendDtmf(tone) {
const dtmf = tone;
const duration = 2000;
const body = {
contentDisposition: 'render',
contentType: 'application/dtmf-relay',
content: `Signal=${dtmf}\r\nDuration=${duration}`,
};
const requestOptions = { body };
return this.currentSession.info({ requestOptions });
}
exitAudio() {
return new Promise((resolve, reject) => {
let hangupRetries = 0;
let hangup = false;
this._hangupFlag = false;
this.userRequestedHangup = true;
if (this.currentSession) {
const { mediaHandler } = this.currentSession;
// Removing termination events to avoid triggering an error
ICE_NEGOTIATION_FAILED.forEach(e => mediaHandler.off(e));
}
const tryHangup = () => {
if ((this.currentSession && this.currentSession.endTime)
|| (this.userAgent && this.userAgent.status === SIP.UA.C.STATUS_USER_CLOSED)) {
hangup = true;
if (this._hangupFlag) {
resolve();
}
if ((this.currentSession
&& (this.currentSession.state === SIP.SessionState.Terminated))
|| (this.userAgent && (!this.userAgent.isConnected()))) {
this._hangupFlag = true;
return resolve();
}
if (this.currentSession) this.currentSession.bye();
if (this.userAgent) this.userAgent.stop();
if (this.currentSession
&& ((this.currentSession.state === SIP.SessionState.Establishing))) {
this.currentSession.cancel().then(() => {
this._hangupFlag = true;
return resolve();
});
}
if (this.currentSession
&& ((this.currentSession.state === SIP.SessionState.Established))) {
this.currentSession.bye().then(() => {
this._hangupFlag = true;
return resolve();
});
}
if (this.userAgent && this.userAgent.isConnected()) {
this.userAgent.stop();
window.removeEventListener('beforeunload', this.onBeforeUnload);
}
hangupRetries += 1;
@ -206,23 +300,24 @@ class SIPSession {
return reject(this.baseErrorCodes.REQUEST_TIMEOUT);
}
if (!hangup) return tryHangup();
if (!this._hangupFlag) return tryHangup();
return resolve();
}, CALL_HANGUP_TIMEOUT);
};
if (this.currentSession) {
this.currentSession.on('bye', () => {
hangup = true;
resolve();
});
}
return tryHangup();
});
}
createUserAgent({ stun, turn }) {
onBeforeUnload() {
if (this.userAgent) {
return this.userAgent.stop();
}
return Promise.resolve();
}
createUserAgent(iceServers) {
return new Promise((resolve, reject) => {
if (this.userRequestedHangup === true) reject();
@ -236,17 +331,6 @@ class SIPSession {
sessionToken,
} = this.user;
// WebView safari needs a transceiver to be added. Made it a SIP.js hack.
// Don't like the UA picking though, we should straighten everything to user
// transceivers - prlanzarin 2019/05/21
const browserUA = window.navigator.userAgent.toLocaleLowerCase();
const isSafariWebview = ((browserUA.indexOf('iphone') > -1
|| browserUA.indexOf('ipad') > -1) && browserUA.indexOf('safari') === -1);
// Second UA check to get all Safari browsers to enable Unified Plan <-> PlanB
// translation
const isSafari = browser().name === 'safari';
logger.debug({ logCode: 'sip_js_creating_user_agent', extraInfo: { callerIdName } }, 'Creating the user agent');
if (this.userAgent && this.userAgent.isConnected()) {
@ -275,112 +359,261 @@ class SIPSession {
let userAgentConnected = false;
const token = `sessionToken=${sessionToken}`;
this.userAgent = new window.SIP.UA({
uri: `sip:${encodeURIComponent(callerIdName)}@${hostname}`,
wsServers: `${(protocol === 'https:' ? 'wss://' : 'ws://')}${hostname}/ws?${token}`,
this.userAgent = new SIP.UserAgent({
uri: SIP.UserAgent.makeURI(`sip:${encodeURIComponent(callerIdName)}@${hostname}`),
transportOptions: {
server: `${(protocol === 'https:' ? 'wss://' : 'ws://')}${hostname}/ws?${token}`,
connectionTimeout: USER_AGENT_CONNECTION_TIMEOUT_MS,
},
sessionDescriptionHandlerFactoryOptions: {
peerConnectionConfiguration: {
iceServers,
},
},
displayName: callerIdName,
register: false,
traceSip: true,
autostart: false,
userAgentString: 'BigBlueButton',
stunServers: stun,
turnServers: turn,
hackPlanBUnifiedPlanTranslation: isSafari,
hackAddAudioTransceiver: isSafariWebview,
relayOnlyOnReconnect: this.reconnectAttempt && RELAY_ONLY_ON_RECONNECT,
localSdpCallback,
remoteSdpCallback,
});
const handleUserAgentConnection = () => {
userAgentConnected = true;
resolve(this.userAgent);
if (!userAgentConnected) {
userAgentConnected = true;
resolve(this.userAgent);
}
};
const handleUserAgentDisconnection = () => {
if (this.userAgent) {
this.userAgent.removeAllListeners();
this.userAgent.stop();
if (this.userRequestedHangup) {
userAgentConnected = false;
return;
}
let error;
let bridgeError;
if (!this._reconnecting) {
logger.info({
logCode: 'sip_js_session_ua_disconnected',
extraInfo: {
callerIdName: this.user.callerIdName,
},
}, 'User agent disconnected: trying to reconnect...'
+ ` (userHangup = ${!!this.userRequestedHangup})`);
logger.info({
logCode: 'sip_js_session_ua_reconnecting',
extraInfo: {
callerIdName: this.user.callerIdName,
},
}, 'User agent disconnected, reconnecting');
this.reconnect().then(() => {
logger.info({
logCode: 'sip_js_session_ua_reconnected',
extraInfo: {
callerIdName: this.user.callerIdName,
},
}, 'User agent succesfully reconnected');
}).catch(() => {
if (userAgentConnected) {
error = 1001;
bridgeError = 'Websocket disconnected';
} else {
error = 1002;
bridgeError = 'Websocket failed to connect';
}
this.callback({
status: this.baseCallStates.failed,
error,
bridgeError,
});
reject(this.baseErrorCodes.CONNECTION_ERROR);
});
}
}
let error;
let bridgeError;
if (this.userRequestedHangup) return;
if (userAgentConnected) {
error = 1001;
bridgeError = 'Websocket disconnected';
} else {
error = 1002;
bridgeError = 'Websocket failed to connect';
}
this.callback({
status: this.baseCallStates.failed,
error,
bridgeError,
});
reject(this.baseErrorCodes.CONNECTION_ERROR);
};
this.userAgent.on('connected', handleUserAgentConnection);
this.userAgent.on('disconnected', handleUserAgentDisconnection);
this.userAgent.transport.onConnect = handleUserAgentConnection;
this.userAgent.transport.onDisconnect = handleUserAgentDisconnection;
this.userAgent.start();
const preturn = this.userAgent.start().then(() => {
logger.info({
logCode: 'sip_js_session_ua_connected',
extraInfo: {
callerIdName: this.user.callerIdName,
},
}, 'User agent succesfully connected');
window.addEventListener('beforeunload', this.onBeforeUnload.bind(this));
resolve();
}).catch((error) => {
logger.info({
logCode: 'sip_js_session_ua_reconnecting',
extraInfo: {
callerIdName: this.user.callerIdName,
},
}, 'User agent failed to connect, reconnecting');
const code = getErrorCode(error);
if (code === 1006) {
this.callback({
status: this.baseCallStates.failed,
error: 1006,
bridgeError: 'Websocket failed to connect',
});
return reject({
type: this.baseErrorCodes.CONNECTION_ERROR,
});
}
this.reconnect().then(() => {
logger.info({
logCode: 'sip_js_session_ua_reconnected',
extraInfo: {
callerIdName: this.user.callerIdName,
},
}, 'User agent succesfully reconnected');
resolve();
}).catch(() => {
logger.info({
logCode: 'sip_js_session_ua_disconnected',
extraInfo: {
callerIdName: this.user.callerIdName,
},
}, 'User agent failed to reconnect after'
+ ` ${USER_AGENT_RECONNECTION_ATTEMPTS} attemps`);
this.callback({
status: this.baseCallStates.failed,
error: 1002,
bridgeError: 'Websocket failed to connect',
});
reject({
type: this.baseErrorCodes.CONNECTION_ERROR,
});
});
});
return preturn;
});
}
reconnect(attempts = 1) {
return new Promise((resolve, reject) => {
if (this._reconnecting) {
return resolve();
}
if (attempts > USER_AGENT_RECONNECTION_ATTEMPTS) {
return reject({
type: this.baseErrorCodes.CONNECTION_ERROR,
});
}
this._reconnecting = true;
setTimeout(() => {
this.userAgent.reconnect().then(() => {
this._reconnecting = false;
resolve();
}).catch(() => {
this._reconnecting = false;
this.reconnect(++attempts).then(() => {
resolve();
}).catch((error) => {
reject(error);
});
});
}, USER_AGENT_RECONNECTION_DELAY_MS);
});
}
inviteUserAgent(userAgent) {
if (this.userRequestedHangup === true) Promise.reject();
return new Promise((resolve, reject) => {
if (this.userRequestedHangup === true) reject();
const {
hostname,
} = this;
const {
hostname,
} = this;
const {
callExtension,
isListenOnly,
} = this.callOptions;
const {
inputStream,
callExtension,
} = this.callOptions;
const options = {
media: {
stream: inputStream,
constraints: {
audio: true,
video: false,
const target = SIP.UserAgent.makeURI(`sip:${callExtension}@${hostname}`);
const audioDeviceConstraint = this.inputDeviceId
? { deviceId: { exact: this.inputDeviceId } }
: true;
const inviterOptions = {
sessionDescriptionHandlerOptions: {
constraints: {
audio: isListenOnly
? false
: audioDeviceConstraint,
video: false,
},
},
render: {
remote: document.querySelector(MEDIA_TAG),
},
},
RTCConstraints: {
offerToReceiveAudio: true,
offerToReceiveVideo: false,
},
};
sessionDescriptionHandlerModifiersPostICEGathering:
[stripMDnsCandidates],
};
return userAgent.invite(`sip:${callExtension}@${hostname}`, options);
if (isListenOnly) {
inviterOptions.sessionDescriptionHandlerOptions.offerOptions = {
offerToReceiveAudio: true,
};
}
const inviter = new SIP.Inviter(userAgent, target, inviterOptions);
this.currentSession = inviter;
this.setupEventHandlers(inviter).then(() => {
inviter.invite().then(() => {
resolve();
}).catch(e => reject(e));
});
});
}
setupEventHandlers(currentSession) {
return new Promise((resolve, reject) => {
if (this.userRequestedHangup === true) reject();
const { mediaHandler } = currentSession;
let iceCompleted = false;
let fsReady = false;
this.currentSession = currentSession;
const setupRemoteMedia = () => {
const mediaElement = document.querySelector(MEDIA_TAG);
let connectionCompletedEvents = ['iceConnectionCompleted', 'iceConnectionConnected'];
// Edge sends a connected first and then a completed, but the call isn't ready until
// the completed comes in. Due to the way that we have the listeners set up, the only
// way to ignore one status is to not listen for it.
if (browser().name === 'edge') {
connectionCompletedEvents = ['iceConnectionCompleted'];
}
this.remoteStream = new MediaStream();
this.currentSession.sessionDescriptionHandler
.peerConnection.getReceivers().forEach((receiver) => {
if (receiver.track) {
this.remoteStream.addTrack(receiver.track);
}
});
logger.info({
logCode: 'sip_js_session_playing_remote_media',
extraInfo: {
callerIdName: this.user.callerIdName,
},
}, 'Audio call - playing remote media');
mediaElement.srcObject = this.remoteStream;
mediaElement.play();
};
const checkIfCallReady = () => {
if (this.userRequestedHangup === true) {
@ -388,8 +621,28 @@ class SIPSession {
resolve();
}
logger.info({
logCode: 'sip_js_session_check_if_call_ready',
extraInfo: {
iceCompleted,
fsReady,
},
}, 'Audio call - check if ICE is finished and FreeSWITCH is ready');
if (iceCompleted && fsReady) {
this.webrtcConnected = true;
setupRemoteMedia();
const { sdp } = this.currentSession.sessionDescriptionHandler
.peerConnection.remoteDescription;
logger.info({
logCode: 'sip_js_session_setup_remote_media',
extraInfo: {
callerIdName: this.user.callerIdName,
sdp,
},
}, 'Audio call - setup remote media');
this.callback({ status: this.baseCallStates.started });
resolve();
}
@ -412,7 +665,6 @@ class SIPSession {
const handleSessionAccepted = () => {
logger.info({ logCode: 'sip_js_session_accepted', extraInfo: { callerIdName: this.user.callerIdName } }, 'Audio call session accepted');
clearTimeout(callTimeout);
currentSession.off('accepted', handleSessionAccepted);
// If ICE isn't connected yet then start timeout waiting for ICE to finish
if (!iceCompleted) {
@ -420,45 +672,114 @@ class SIPSession {
this.callback({
status: this.baseCallStates.failed,
error: 1010,
bridgeError: `ICE negotiation timeout after ${ICE_NEGOTIATION_TIMEOUT / 1000}s`,
bridgeError: 'ICE negotiation timeout after '
+ `${ICE_NEGOTIATION_TIMEOUT / 1000}s`,
});
this.exitAudio();
reject({
type: this.baseErrorCodes.CONNECTION_ERROR,
});
}, ICE_NEGOTIATION_TIMEOUT);
}
};
currentSession.on('accepted', handleSessionAccepted);
const handleSessionProgress = (update) => {
logger.info({ logCode: 'sip_js_session_progress', extraInfo: { callerIdName: this.user.callerIdName } }, 'Audio call session progress update');
clearTimeout(callTimeout);
currentSession.off('progress', handleSessionProgress);
};
currentSession.on('progress', handleSessionProgress);
const handleConnectionCompleted = (peer) => {
logger.info({
logCode: 'sip_js_ice_connection_success',
extraInfo: {
currentState: peer.iceConnectionState,
callerIdName: this.user.callerIdName,
},
}, `ICE connection success. Current state - ${peer.iceConnectionState}`);
clearTimeout(callTimeout);
clearTimeout(iceNegotiationTimeout);
connectionCompletedEvents.forEach(e => mediaHandler.off(e, handleConnectionCompleted));
iceCompleted = true;
logSelectedCandidate(peer, this.protocolIsIpv6);
checkIfCallReady();
};
connectionCompletedEvents.forEach(e => mediaHandler.on(e, handleConnectionCompleted));
const handleIceNegotiationFailed = (peer) => {
if (iceCompleted) {
logger.error({
logCode: 'sipjs_ice_failed_after',
extraInfo: {
callerIdName: this.user.callerIdName,
},
}, 'ICE connection failed after success');
} else {
logger.error({
logCode: 'sipjs_ice_failed_before',
extraInfo: {
callerIdName: this.user.callerIdName,
},
}, 'ICE connection failed before success');
}
clearTimeout(callTimeout);
clearTimeout(iceNegotiationTimeout);
this.callback({
status: this.baseCallStates.failed,
error: 1007,
bridgeError: 'ICE negotiation failed. Current state '
+ `- ${peer.iceConnectionState}`,
});
};
const handleIceConnectionTerminated = (peer) => {
if (!this.userRequestedHangup) {
logger.error({
logCode: 'sipjs_ice_closed',
extraInfo: {
callerIdName: this.user.callerIdName,
},
}, 'ICE connection closed');
}
this.callback({
status: this.baseCallStates.failed,
error: 1012,
bridgeError: 'ICE connection closed. Current state -'
+ `${peer.iceConnectionState}`,
});
};
const handleSessionProgress = (update) => {
logger.info({
logCode: 'sip_js_session_progress',
extraInfo: {
callerIdName: this.user.callerIdName,
update,
},
}, 'Audio call session progress update');
this.currentSession.sessionDescriptionHandler.peerConnectionDelegate = {
onconnectionstatechange: (event) => {
const peer = event.target;
switch (peer.connectionState) {
case 'connected':
logger.info({
logCode: 'sip_js_ice_connection_success',
extraInfo: {
currentState: peer.connectionState,
callerIdName: this.user.callerIdName,
},
}, 'ICE connection success. Current state - '
+ `${peer.iceConnectionState}`);
clearTimeout(callTimeout);
clearTimeout(iceNegotiationTimeout);
iceCompleted = true;
logSelectedCandidate(peer, this.protocolIsIpv6);
checkIfCallReady();
break;
case 'failed':
handleIceNegotiationFailed(peer);
break;
case 'closed':
handleIceConnectionTerminated(peer);
break;
default:
break;
}
},
};
};
const handleSessionTerminated = (message, cause) => {
clearTimeout(callTimeout);
clearTimeout(iceNegotiationTimeout);
currentSession.off('terminated', handleSessionTerminated);
if (!message && !cause && !!this.userRequestedHangup) {
return this.callback({
@ -466,6 +787,10 @@ class SIPSession {
});
}
// if session hasn't even started, we let audio-modal to handle
// any possile errors
if (!this._currentSessionState) return false;
logger.error({
logCode: 'sip_js_call_terminated',
extraInfo: { cause, callerIdName: this.user.callerIdName },
@ -484,39 +809,33 @@ class SIPSession {
bridgeError: cause,
});
};
currentSession.on('terminated', handleSessionTerminated);
const handleIceNegotiationFailed = (peer) => {
if (iceCompleted) {
logger.error({ logCode: 'sipjs_ice_failed_after', extraInfo: { callerIdName: this.user.callerIdName } }, 'ICE connection failed after success');
} else {
logger.error({ logCode: 'sipjs_ice_failed_before', extraInfo: { callerIdName: this.user.callerIdName } }, 'ICE connection failed before success');
currentSession.stateChange.addListener((state) => {
switch (state) {
case SIP.SessionState.Initial:
break;
case SIP.SessionState.Establishing:
handleSessionProgress();
break;
case SIP.SessionState.Established:
handleSessionAccepted();
break;
case SIP.SessionState.Terminating:
break;
case SIP.SessionState.Terminated:
handleSessionTerminated();
break;
default:
logger.error({
logCode: 'sipjs_ice_session_unknown_state',
extraInfo: {
callerIdName: this.user.callerIdName,
},
}, 'SIP.js unknown session state');
break;
}
clearTimeout(callTimeout);
clearTimeout(iceNegotiationTimeout);
ICE_NEGOTIATION_FAILED.forEach(e => mediaHandler.off(e, handleIceNegotiationFailed));
this.callback({
status: this.baseCallStates.failed,
error: 1007,
bridgeError: `ICE negotiation failed. Current state - ${peer.iceConnectionState}`,
});
};
ICE_NEGOTIATION_FAILED.forEach(e => mediaHandler.on(e, handleIceNegotiationFailed));
const handleIceConnectionTerminated = (peer) => {
['iceConnectionClosed'].forEach(e => mediaHandler.off(e, handleIceConnectionTerminated));
if (!this.userRequestedHangup) {
logger.error({ logCode: 'sipjs_ice_closed', extraInfo: { callerIdName: this.user.callerIdName } }, 'ICE connection closed');
}
/*
this.callback({
status: this.baseCallStates.failed,
error: 1012,
bridgeError: "ICE connection closed. Current state - " + peer.iceConnectionState,
});
*/
};
['iceConnectionClosed'].forEach(e => mediaHandler.on(e, handleIceConnectionTerminated));
this._currentSessionState = state;
});
Tracker.autorun((c) => {
const selector = { meetingId: Auth.meetingID, userId: Auth.userID };
@ -534,6 +853,8 @@ class SIPSession {
},
});
});
resolve();
});
}
}
@ -571,7 +892,11 @@ export default class SIPBridge extends BaseAudioBridge {
window.clientLogger = logger;
}
joinAudio({ isListenOnly, extension, inputStream }, managerCallback) {
get inputDeviceId () {
return this.media.inputDevice ? this.media.inputDevice.inputDeviceId : null;
}
joinAudio({ isListenOnly, extension }, managerCallback) {
const hasFallbackDomain = typeof IPV4_FALLBACK_DOMAIN === 'string' && IPV4_FALLBACK_DOMAIN !== '';
return new Promise((resolve, reject) => {
@ -603,7 +928,12 @@ export default class SIPBridge extends BaseAudioBridge {
const fallbackExtension = this.activeSession.inEchoTest ? extension : undefined;
this.activeSession = new SIPSession(this.user, this.userData, this.protocol,
hostname, this.baseCallStates, this.baseErrorCodes, true);
this.activeSession.joinAudio({ isListenOnly, extension: fallbackExtension, inputStream }, callback)
const { inputDeviceId } = this.media.inputDevice;
this.activeSession.joinAudio({
isListenOnly,
extension: fallbackExtension,
inputDeviceId,
}, callback)
.then((value) => {
resolve(value);
}).catch((reason) => {
@ -615,7 +945,12 @@ export default class SIPBridge extends BaseAudioBridge {
return managerCallback(message);
};
this.activeSession.joinAudio({ isListenOnly, extension, inputStream }, callback)
const { inputDeviceId } = this.media.inputDevice;
this.activeSession.joinAudio({
isListenOnly,
extension,
inputDeviceId,
}, callback)
.then((value) => {
resolve(value);
}).catch((reason) => {
@ -630,8 +965,8 @@ export default class SIPBridge extends BaseAudioBridge {
getPeerConnection() {
const { currentSession } = this.activeSession;
if (currentSession && currentSession.mediaHandler) {
return currentSession.mediaHandler.peerConnection;
if (currentSession && currentSession.sessionDescriptionHandler) {
return currentSession.sessionDescriptionHandler.peerConnection;
}
return null;
}
@ -641,62 +976,16 @@ export default class SIPBridge extends BaseAudioBridge {
}
setDefaultInputDevice() {
const handleMediaSuccess = (mediaStream) => {
const deviceLabel = mediaStream.getAudioTracks()[0].label;
window.defaultInputStream = mediaStream.getTracks();
return navigator.mediaDevices.enumerateDevices().then((mediaDevices) => {
const device = mediaDevices.find(d => d.label === deviceLabel);
return this.changeInputDevice(device.deviceId, deviceLabel);
});
};
return navigator.mediaDevices.getUserMedia({ audio: true }).then(handleMediaSuccess);
this.media.inputDevice.inputDeviceId = DEFAULT_INPUT_DEVICE_ID;
}
changeInputDevice(deviceId, deviceLabel) {
const {
media,
} = this;
if (media.inputDevice.audioContext) {
const handleAudioContextCloseSuccess = () => {
media.inputDevice.audioContext = null;
media.inputDevice.scriptProcessor = null;
media.inputDevice.source = null;
return this.changeInputDevice(deviceId);
};
return media.inputDevice.audioContext.close().then(handleAudioContextCloseSuccess);
async changeInputDeviceId(inputDeviceId) {
if (!inputDeviceId) {
throw new Error();
}
if ('AudioContext' in window) {
media.inputDevice.audioContext = new window.AudioContext();
} else {
media.inputDevice.audioContext = new window.webkitAudioContext();
}
media.inputDevice.id = deviceId;
media.inputDevice.label = deviceLabel;
media.inputDevice.scriptProcessor = media.inputDevice.audioContext
.createScriptProcessor(2048, 1, 1);
media.inputDevice.source = null;
const constraints = {
audio: {
deviceId,
},
};
const handleMediaSuccess = (mediaStream) => {
media.inputDevice.stream = mediaStream;
media.inputDevice.source = media.inputDevice.audioContext
.createMediaStreamSource(mediaStream);
media.inputDevice.source.connect(media.inputDevice.scriptProcessor);
media.inputDevice.scriptProcessor.connect(media.inputDevice.audioContext.destination);
return this.media.inputDevice;
};
return navigator.mediaDevices.getUserMedia(constraints).then(handleMediaSuccess);
this.media.inputDevice.inputDeviceId = inputDeviceId;
return inputDeviceId;
}
async changeOutputDevice(value) {

View File

@ -12,7 +12,7 @@ export default function endMeeting() {
const payload = {
userId: requesterUserId,
};
Logger.verbose(`Meeting '${meetingId}' is destroyed by '${requesterUserId}'`);
Logger.warn(`Meeting '${meetingId}' is destroyed by '${requesterUserId}'`);
return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
}

View File

@ -4,34 +4,15 @@ import Polls from '/imports/api/polls';
import Logger from '/imports/startup/server/logger';
import { extractCredentials } from '/imports/api/common/server/helpers';
export default function publishVote(id, pollAnswerId) { // TODO discuss location
export default function publishVote(pollId, pollAnswerId) {
const REDIS_CONFIG = Meteor.settings.private.redis;
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
const EVENT_NAME = 'RespondToPollReqMsg';
const { meetingId, requesterUserId } = extractCredentials(this.userId);
/*
We keep an array of people who were in the meeting at the time the poll
was started. The poll is published to them only.
Once they vote - their ID is removed and they cannot see the poll anymore
*/
const currentPoll = Polls.findOne({
users: requesterUserId,
meetingId,
'answers.id': pollAnswerId,
id,
});
check(pollAnswerId, Number);
check(currentPoll, Object);
check(currentPoll.meetingId, String);
const payload = {
requesterId: requesterUserId,
pollId: currentPoll.id,
questionId: 0,
answerId: pollAnswerId,
};
check(pollId, String);
const selector = {
users: requesterUserId,
@ -39,6 +20,18 @@ export default function publishVote(id, pollAnswerId) { // TODO discuss location
'answers.id': pollAnswerId,
};
const payload = {
requesterId: requesterUserId,
pollId,
questionId: 0,
answerId: pollAnswerId,
};
/*
We keep an array of people who were in the meeting at the time the poll
was started. The poll is published to them only.
Once they vote - their ID is removed and they cannot see the poll anymore
*/
const modifier = {
$pull: {
users: requesterUserId,
@ -47,11 +40,11 @@ export default function publishVote(id, pollAnswerId) { // TODO discuss location
const cb = (err) => {
if (err) {
return Logger.error(`Updating Polls collection: ${err}`);
return Logger.error(`Removing responded user from Polls collection: ${err}`);
}
return Logger.info(`Updating Polls collection (meetingId: ${meetingId}, `
+ `pollId: ${currentPoll.id}!)`);
return Logger.info(`Removed responded user=${requesterUserId} from poll (meetingId: ${meetingId}, `
+ `pollId: ${pollId}!)`);
};
Polls.update(selector, modifier, cb);

View File

@ -63,6 +63,7 @@ export default function handleValidateAuthToken({ body }, meetingId) {
/* Logic migrated from validateAuthToken method ( postponed to only run in case of success response ) - Begin */
const sessionId = `${meetingId}--${userId}`;
methodInvocationObject.setUserId(sessionId);
const User = Users.findOne({

View File

@ -30,6 +30,7 @@ export default function userLeaving(meetingId, userId, connectionId) {
// If the current user connection is not the same that triggered the leave we skip
if (auth?.connectionId !== connectionId) {
Logger.info(`Skipping userLeaving. User connectionId=${User.connectionId} is different from requester connectionId=${connectionId}`);
return false;
}

View File

@ -6,6 +6,7 @@ import upsertValidationState from '/imports/api/auth-token-validation/server/mod
import { ValidationStates } from '/imports/api/auth-token-validation';
import pendingAuthenticationsStore from '../store/pendingAuthentications';
import BannedUsers from '../store/bannedUsers';
import Users from '/imports/api/users';
export default function validateAuthToken(meetingId, requesterUserId, requesterToken, externalId) {
const REDIS_CONFIG = Meteor.settings.private.redis;
@ -16,10 +17,27 @@ export default function validateAuthToken(meetingId, requesterUserId, requesterT
if (externalId) {
if (BannedUsers.has(meetingId, externalId)) {
Logger.warn(`A banned user with extId ${externalId} tried to enter in meeting ${meetingId}`);
return;
return { invalid: true, reason: 'User has been banned', error_type: 'user_banned' };
}
}
// Prevent users who have left or been ejected to use the same sessionToken again.
const isUserInvalid = Users.findOne({
meetingId,
userId: requesterUserId,
authToken: requesterToken,
$or: [{ ejected: true }, { loggedOut: true }],
});
if (isUserInvalid) {
Logger.warn(`An invalid sessionToken tried to validateAuthToken meetingId=${meetingId} authToken=${requesterToken}`);
return {
invalid: true,
reason: `User has an invalid sessionToken due to ${isUserInvalid.ejected ? 'ejection' : 'log out'}`,
error_type: `invalid_session_token_due_to_${isUserInvalid.ejected ? 'eject' : 'log_out'}`,
};
}
ClientConnections.add(`${meetingId}--${requesterUserId}`, this.connection);
// Store reference of methodInvocationObject ( to postpone the connection userId definition )

View File

@ -359,8 +359,9 @@ const BaseContainer = withTracker(() => {
changed: (newDocument) => {
if (newDocument.validated && newDocument.name && newDocument.userId !== localUserId) {
if (userJoinAudioAlerts) {
const audio = new Audio(`${Meteor.settings.public.app.cdn + Meteor.settings.public.app.basename}/resources/sounds/userJoin.mp3`);
audio.play();
AudioService.playAlertSound(`${Meteor.settings.public.app.cdn
+ Meteor.settings.public.app.basename}`
+ '/resources/sounds/userJoin.mp3');
}
if (userJoinPushAlerts) {

View File

@ -71,7 +71,8 @@ class MeteorStream {
'logClient',
nameFromLevel[this.rec.level],
this.rec.msg,
{ clientURL },
this.rec.logCode,
{ ...rec.extraInfo, clientURL },
);
}
}

View File

@ -118,7 +118,7 @@ class ActionsBar extends PureComponent {
}}
/>
<Button
className={cx(styles.button, autoArrangeLayout || styles.btn)}
className={cx(styles.btn, autoArrangeLayout || styles.btn)}
icon={autoArrangeLayout ? 'lock' : 'unlock'}
color={autoArrangeLayout ? 'primary' : 'default'}
ghost={!autoArrangeLayout}

View File

@ -13,6 +13,7 @@ import getFromUserSettings from '/imports/ui/services/users-settings';
import deviceInfo from '/imports/utils/deviceInfo';
import UserInfos from '/imports/api/users-infos';
import { startBandwidthMonitoring, updateNavigatorConnection } from '/imports/ui/services/network-information/index';
import logger from '/imports/startup/client/logger';
import {
getFontSize,
@ -73,9 +74,9 @@ const currentUserEmoji = currentUser => (currentUser ? {
status: currentUser.emoji,
changedAt: currentUser.emojiTime,
} : {
status: 'none',
changedAt: null,
});
status: 'none',
changedAt: null,
});
export default injectIntl(withModalMounter(withTracker(({ intl, baseControls }) => {
const authTokenValidation = AuthTokenValidation.findOne({}, { sort: { updatedAt: -1 } });

View File

@ -245,8 +245,6 @@ class AudioModal extends Component {
}
const {
inputDeviceId,
outputDeviceId,
joinEchoTest,
} = this.props;
@ -268,12 +266,27 @@ class AudioModal extends Component {
disableActions: false,
});
}).catch((err) => {
if (err.type === 'MEDIA_ERROR') {
this.setState({
content: 'help',
errCode: err.code,
disableActions: false,
});
const { type } = err;
switch (type) {
case 'MEDIA_ERROR':
this.setState({
content: 'help',
errCode: 0,
disableActions: false,
});
break;
case 'CONNECTION_ERROR':
this.setState({
errCode: 0,
disableActions: false,
});
break;
default:
this.setState({
errCode: 0,
disableActions: false,
});
break;
}
});
}

View File

@ -88,4 +88,5 @@ export default {
isVoiceUser,
autoplayBlocked: () => AudioManager.autoplayBlocked,
handleAllowAutoplay: () => AudioManager.handleAllowAutoplay(),
playAlertSound: url => AudioManager.playAlertSound(url),
};

View File

@ -224,13 +224,12 @@ class BreakoutRoom extends PureComponent {
(
<Button
label={
moderatorJoinedAudio
&& stateBreakoutId === breakoutId
&& joinedAudioOnly
stateBreakoutId === breakoutId && joinedAudioOnly
? intl.formatMessage(intlMessages.breakoutReturnAudio)
: intl.formatMessage(intlMessages.breakoutJoinAudio)
}
className={styles.button}
disabled={stateBreakoutId !== breakoutId && joinedAudioOnly}
key={`join-audio-${breakoutId}`}
onClick={audioAction}
/>

View File

@ -1,5 +1,6 @@
import { Component } from 'react';
import PropTypes from 'prop-types';
import AudioService from '/imports/ui/components/audio/service';
const propTypes = {
play: PropTypes.bool.isRequired,
@ -8,17 +9,16 @@ const propTypes = {
class ChatAudioAlert extends Component {
constructor(props) {
super(props);
this.audio = new Audio(`${Meteor.settings.public.app.cdn + Meteor.settings.public.app.basename}/resources/sounds/notify.mp3`);
this.handleAudioLoaded = this.handleAudioLoaded.bind(this);
this.playAudio = this.playAudio.bind(this);
}
componentDidMount() {
this.audio.addEventListener('loadedmetadata', this.handleAudioLoaded);
this.handleAudioLoaded();
}
componentWillUnmount() {
this.audio.removeEventListener('loadedmetadata', this.handleAudioLoaded);
this.handleAudioLoaded();
}
handleAudioLoaded() {
@ -28,7 +28,9 @@ class ChatAudioAlert extends Component {
playAudio() {
const { play } = this.props;
if (!play) return;
this.audio.play();
AudioService.playAlertSound(`${Meteor.settings.public.app.cdn
+ Meteor.settings.public.app.basename}`
+ '/resources/sounds/notify.mp3');
}
render() {

View File

@ -35,10 +35,7 @@ export function publishCursorUpdate(payload) {
}
export function initCursorStreamListener() {
logger.info({
logCode: 'init_cursor_stream_listener',
extraInfo: { meetingId: Auth.meetingID, userId: Auth.userID },
}, 'initCursorStreamListener called');
logger.info({ logCode: 'init_cursor_stream_listener' }, 'initCursorStreamListener called');
/**
* We create a promise to add the handlers after a ddp subscription stop.
@ -60,9 +57,7 @@ export function initCursorStreamListener() {
});
startStreamHandlersPromise.then(() => {
logger.debug({
logCode: 'init_cursor_stream_listener',
}, 'initCursorStreamListener called');
logger.debug({ logCode: 'cursor_stream_handler_attach' }, 'Attaching handlers for cursor stream');
cursorStreamListener.on('message', ({ cursors }) => {
Object.keys(cursors).forEach((userId) => {

View File

@ -3,6 +3,7 @@ import { withTracker } from 'meteor/react-meteor-data';
import { withModalMounter } from '/imports/ui/components/modal/service';
import { makeCall } from '/imports/ui/services/api';
import EndMeetingComponent from './component';
import logger from '/imports/startup/client/logger';
const EndMeetingContainer = props => <EndMeetingComponent {...props} />;
@ -12,6 +13,10 @@ export default withModalMounter(withTracker(({ mountModal }) => ({
},
endMeeting: () => {
logger.warn({
logCode: 'moderator_forcing_end_meeting',
extraInfo: { logType: 'user_action' },
}, 'this user clicked on EndMeeting and confirmed, removing everybody from the meeting');
makeCall('endMeeting');
mountModal(null);
},

View File

@ -15,6 +15,9 @@ const intlMessages = defineMessages({
410: {
id: 'app.error.410',
},
408: {
id: 'app.error.408',
},
404: {
id: 'app.error.404',
defaultMessage: 'Not found',

View File

@ -1,7 +1,7 @@
import loadScript from 'load-script';
import React, { Component } from 'react'
const MATCH_URL = new RegExp("https?:\/\/(\\w+)[.](instructuremedia.com)(\/embed)?\/([-abcdef0-9]+)");
const MATCH_URL = new RegExp("https?:\/\/(.*)(instructuremedia.com)(\/embed)?\/([-abcdef0-9]+)");
const SDK_URL = 'https://files.instructuremedia.com/instructure-media-script/instructure-media-1.1.0.js';

View File

@ -181,6 +181,7 @@ class JoinHandler extends Component {
const { response } = parseToJson;
setLogoutURL(response);
logUserInfo();
if (response.returncode !== 'FAILED') {
await setAuth(response);
@ -188,7 +189,6 @@ class JoinHandler extends Component {
setBannerProps(response);
setLogoURL(response);
setModOnlyMessage(response);
logUserInfo();
Tracker.autorun(async (cd) => {
const user = Users.findOne({ userId: Auth.userID, approved: true }, { fields: { _id: 1 } });

View File

@ -68,7 +68,7 @@ import './styles.css';
const FETCHING = 'fetching';
const FALLBACK = 'fallback';
const READY = 'ready';
const supportedBrowsers = ['chrome', 'firefox', 'safari', 'opera', 'edge'];
const supportedBrowsers = ['chrome', 'firefox', 'safari', 'opera', 'edge', 'yandex'];
export default class Legacy extends Component {
constructor(props) {

View File

@ -1,6 +1,7 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import cx from 'classnames';
import Settings from '/imports/ui/services/settings';
import { isMobile, isIPad13 } from 'react-device-detect';
import WebcamDraggable from './webcam-draggable-overlay/component';
import { styles } from './styles';
@ -65,6 +66,9 @@ export default class Media extends Component {
[styles.containerV]: webcamsPlacement === 'top' || webcamsPlacement === 'bottom' || webcamsPlacement === 'floating',
[styles.containerH]: webcamsPlacement === 'left' || webcamsPlacement === 'right',
});
const { viewParticipantsWebcams } = Settings.dataSaving;
const showVideo = usersVideo.length > 0 && viewParticipantsWebcams;
const fullHeight = !showVideo || (webcamsPlacement === 'floating');
return (
<div
@ -103,22 +107,18 @@ export default class Media extends Component {
>
{children}
</div>
{
usersVideo.length > 0
? (
<WebcamDraggable
refMediaContainer={this.refContainer}
swapLayout={swapLayout}
singleWebcam={singleWebcam}
usersVideoLenght={usersVideo.length}
hideOverlay={hideOverlay}
disableVideo={disableVideo}
audioModalIsOpen={audioModalIsOpen}
usersVideo={usersVideo}
/>
)
: null
}
{showVideo ? (
<WebcamDraggable
refMediaContainer={this.refContainer}
swapLayout={swapLayout}
singleWebcam={singleWebcam}
usersVideoLenght={usersVideo.length}
hideOverlay={hideOverlay}
disableVideo={disableVideo}
audioModalIsOpen={audioModalIsOpen}
usersVideo={usersVideo}
/>
) : null}
</div>
);
}

View File

@ -4,6 +4,7 @@ import { defineMessages, injectIntl } from 'react-intl';
import { Meteor } from 'meteor/meteor';
import Auth from '/imports/ui/services/auth';
import Button from '/imports/ui/components/button/component';
import allowRedirectToLogoutURL from './service';
import getFromUserSettings from '/imports/ui/services/users-settings';
import logoutRouteHandler from '/imports/utils/logoutRouteHandler';
import Rating from './rating/component';
@ -103,6 +104,7 @@ class MeetingEnded extends PureComponent {
super(props);
this.state = {
selected: 0,
dispatched: false,
};
const user = Users.findOne({ userId: Auth.userID });
@ -111,8 +113,9 @@ class MeetingEnded extends PureComponent {
}
this.setSelectedStar = this.setSelectedStar.bind(this);
this.confirmRedirect = this.confirmRedirect.bind(this);
this.sendFeedback = this.sendFeedback.bind(this);
this.shouldShowFeedback = getFromUserSettings('bbb_ask_for_feedback_on_logout', Meteor.settings.public.app.askForFeedbackOnLogout);
this.shouldShowFeedback = this.shouldShowFeedback.bind(this);
AudioManager.exitAudio();
Meteor.disconnect();
@ -124,16 +127,26 @@ class MeetingEnded extends PureComponent {
});
}
sendFeedback() {
shouldShowFeedback() {
const { dispatched } = this.state;
return getFromUserSettings('bbb_ask_for_feedback_on_logout', Meteor.settings.public.app.askForFeedbackOnLogout) && !dispatched;
}
confirmRedirect() {
const {
selected,
} = this.state;
if (selected <= 0) {
if (meetingIsBreakout()) window.close();
logoutRouteHandler();
return;
if (allowRedirectToLogoutURL()) logoutRouteHandler();
}
}
sendFeedback() {
const {
selected,
} = this.state;
const { fullname } = Auth.credentials;
@ -158,25 +171,70 @@ class MeetingEnded extends PureComponent {
// client logger
logger.info({ logCode: 'feedback_functionality', extraInfo: { feedback: message } }, 'Feedback component');
const FEEDBACK_WAIT_TIME = 500;
setTimeout(() => {
fetch(url, options)
.then(() => {
logoutRouteHandler();
})
.catch(() => {
logoutRouteHandler();
});
}, FEEDBACK_WAIT_TIME);
this.setState({
dispatched: true,
});
if (allowRedirectToLogoutURL()) {
const FEEDBACK_WAIT_TIME = 500;
setTimeout(() => {
fetch(url, options)
.then(() => {
logoutRouteHandler();
})
.catch(() => {
logoutRouteHandler();
});
}, FEEDBACK_WAIT_TIME);
}
}
render() {
const { code, intl, reason } = this.props;
const { selected } = this.state;
renderNoFeedback() {
const { intl, code, reason } = this.props;
logger.info({ logCode: 'meeting_ended_code', extraInfo: { endedCode: code, reason } }, 'Meeting ended component, no feedback configured');
return (
<div className={styles.parent}>
<div className={styles.modal}>
<div className={styles.content}>
<h1 className={styles.title} data-test="meetingEndedModalTitle">
{
intl.formatMessage(intlMessage[code] || intlMessage[430])
}
</h1>
{!allowRedirectToLogoutURL() ? null : (
<div>
<div className={styles.text}>
{intl.formatMessage(intlMessage.messageEnded)}
</div>
<Button
color="primary"
onClick={this.confirmRedirect}
className={styles.button}
label={intl.formatMessage(intlMessage.buttonOkay)}
description={intl.formatMessage(intlMessage.confirmDesc)}
/>
</div>
)}
</div>
</div>
</div>
);
}
renderFeedback() {
const { intl, code, reason } = this.props;
const {
selected,
dispatched,
} = this.state;
const noRating = selected <= 0;
logger.info({ logCode: 'meeting_ended_code', extraInfo: { endedCode: code, reason } }, 'Meeting ended component');
logger.info({ logCode: 'meeting_ended_code', extraInfo: { endedCode: code, reason } }, 'Meeting ended component, feedback allowed');
return (
<div className={styles.parent}>
@ -188,11 +246,12 @@ class MeetingEnded extends PureComponent {
}
</h1>
<div className={styles.text}>
{this.shouldShowFeedback
{this.shouldShowFeedback()
? intl.formatMessage(intlMessage.subtitle)
: intl.formatMessage(intlMessage.messageEnded)}
</div>
{this.shouldShowFeedback ? (
{this.shouldShowFeedback() ? (
<div data-test="rating">
<Rating
total="5"
@ -209,22 +268,35 @@ class MeetingEnded extends PureComponent {
) : null}
</div>
) : null }
<Button
color="primary"
onClick={this.sendFeedback}
className={styles.button}
label={noRating
? intl.formatMessage(intlMessage.buttonOkay)
: intl.formatMessage(intlMessage.sendLabel)}
description={noRating
? intl.formatMessage(intlMessage.confirmDesc)
: intl.formatMessage(intlMessage.sendDesc)}
/>
{noRating && allowRedirectToLogoutURL() ? (
<Button
color="primary"
onClick={this.confirmRedirect}
className={styles.button}
label={intl.formatMessage(intlMessage.buttonOkay)}
description={intl.formatMessage(intlMessage.confirmDesc)}
/>
) : null}
{!noRating && !dispatched ? (
<Button
color="primary"
onClick={this.sendFeedback}
className={styles.button}
label={intl.formatMessage(intlMessage.sendLabel)}
description={intl.formatMessage(intlMessage.sendDesc)}
/>
) : null}
</div>
</div>
</div>
);
}
render() {
if (this.shouldShowFeedback()) return this.renderFeedback();
return this.renderNoFeedback();
}
}
MeetingEnded.propTypes = propTypes;

View File

@ -0,0 +1,22 @@
import { Meteor } from 'meteor/meteor';
import Auth from '/imports/ui/services/auth';
export default function allowRedirectToLogoutURL() {
const ALLOW_DEFAULT_LOGOUT_URL = Meteor.settings.public.app.allowDefaultLogoutUrl;
const protocolPattern = /^((http|https):\/\/)/;
if (Auth.logoutURL) {
// default logoutURL
// compare only the host to ignore protocols
const urlWithoutProtocolForAuthLogout = Auth.logoutURL.replace(protocolPattern, '');
const urlWithoutProtocolForLocationOrigin = window.location.origin.replace(protocolPattern, '');
if (urlWithoutProtocolForAuthLogout === urlWithoutProtocolForLocationOrigin) {
return ALLOW_DEFAULT_LOGOUT_URL;
}
// custom logoutURL
return true;
}
// no logout url
return false;
}

View File

@ -5,6 +5,7 @@ import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrap
import { defineMessages, injectIntl } from 'react-intl';
import cx from 'classnames';
import { styles } from './styles.scss';
import AudioService from '/imports/ui/components/audio/service';
const intlMessages = defineMessages({
pollingTitleLabel: {
@ -30,8 +31,9 @@ class Polling extends Component {
}
play() {
this.alert = new Audio(`${Meteor.settings.public.app.cdn + Meteor.settings.public.app.basename}/resources/sounds/Poll.mp3`);
this.alert.play();
AudioService.playAlertSound(`${Meteor.settings.public.app.cdn
+ Meteor.settings.public.app.basename}`
+ '/resources/sounds/Poll.mp3');
}
render() {
@ -42,6 +44,9 @@ class Polling extends Component {
handleVote,
pollAnswerIds,
} = this.props;
if (!poll) return null;
const { stackOptions, answers } = poll;
const pollAnswerStyles = {
[styles.pollingAnswers]: true,

View File

@ -8,6 +8,7 @@ import { stopWatching } from '/imports/ui/components/external-video-player/servi
import Meetings from '/imports/api/meetings';
import Auth from '/imports/ui/services/auth';
import UserListService from '/imports/ui/components/user-list/service';
import AudioService from '/imports/ui/components/audio/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
@ -70,7 +71,10 @@ const shareScreen = (onFail) => {
}).catch(onFail);
};
const screenShareEndAlert = () => new Audio(`${Meteor.settings.public.app.cdn + Meteor.settings.public.app.basename}/resources/sounds/ScreenshareOff.mp3`).play();
const screenShareEndAlert = () => AudioService
.playAlertSound(`${Meteor.settings.public.app.cdn
+ Meteor.settings.public.app.basename}`
+ '/resources/sounds/ScreenshareOff.mp3');
const unshareScreen = () => {
KurentoBridge.kurentoExitScreenShare();

View File

@ -187,7 +187,7 @@ const userFindSorting = {
emojiTime: 1,
role: 1,
phoneUser: 1,
sortName: 1,
name: 1,
userId: 1,
};
@ -534,6 +534,7 @@ const sortUsersByFirstName = (a, b) => {
};
const sortUsersByLastName = (a, b) => {
if (!a.lastName && !b.lastName) return 0;
if (a.lastName && !b.lastName) return -1;
if (!a.lastName && b.lastName) return 1;
@ -555,7 +556,7 @@ export const getUserNamesLink = (docTitle, fnSortedLabel, lnSortedLabel) => {
const mimeType = 'text/plain';
const userNamesObj = getUsers()
.map((u) => {
const name = u.sortName.split(' ');
const name = u.name.split(' ');
return ({
firstName: name[0],
middleNames: name.length > 2 ? name.slice(1, name.length - 1) : null,

View File

@ -86,6 +86,8 @@ class UserNotes extends Component {
return (
<div
aria-label={intl.formatMessage(intlMessages.sharedNotes)}
aria-describedby="lockedNote"
role="button"
tabIndex={0}
className={styles.listItem}
@ -100,7 +102,7 @@ class UserNotes extends Component {
? (
<div className={styles.noteLock}>
<Icon iconName="lock" />
<span>{`${intl.formatMessage(intlMessages.locked)} ${intl.formatMessage(intlMessages.byModerator)}`}</span>
<span id="lockedNote">{`${intl.formatMessage(intlMessages.locked)} ${intl.formatMessage(intlMessages.byModerator)}`}</span>
</div>
) : null
}

View File

@ -269,8 +269,8 @@ class VideoPreview extends Component {
if (device.kind === 'videoinput' && !found) {
webcams.push(device);
if (!initialDeviceId
|| (webcamDeviceId && webcamDeviceId === device.deviceId)
|| device.deviceId === firstAllowedDeviceId) {
|| (webcamDeviceId && webcamDeviceId === device.deviceId)
|| device.deviceId === firstAllowedDeviceId) {
initialDeviceId = device.deviceId;
}
}
@ -310,18 +310,18 @@ class VideoPreview extends Component {
});
});
}).catch((error) => {
logger.warn({
logCode: 'video_preview_initial_device_error',
extraInfo: {
errorName: error.name,
errorMessage: error.message,
},
}, 'Error getting initial device');
this.setState({
viewState: VIEW_STATES.error,
deviceError: VideoPreview.handleGUMError(error),
});
logger.warn({
logCode: 'video_preview_initial_device_error',
extraInfo: {
errorName: error.name,
errorMessage: error.message,
},
}, 'Error getting initial device');
this.setState({
viewState: VIEW_STATES.error,
deviceError: VideoPreview.handleGUMError(error),
});
});
} catch (error) {
logger.warn({
logCode: 'video_preview_grabbing_error',
@ -539,14 +539,14 @@ class VideoPreview extends Component {
{ shared
? (
<span className={styles.label}>
{intl.formatMessage(intlMessages.sharedCameraLabel)}
</span>
{intl.formatMessage(intlMessages.sharedCameraLabel)}
</span>
)
: (
<span>
<label className={styles.label} htmlFor="setQuality">
{intl.formatMessage(intlMessages.qualityLabel)}
</label>
<label className={styles.label} htmlFor="setQuality">
{intl.formatMessage(intlMessages.qualityLabel)}
</label>
{ availableProfiles && availableProfiles.length > 0
? (
<select
@ -557,23 +557,23 @@ class VideoPreview extends Component {
disabled={skipVideoPreview}
>
{availableProfiles.map(profile => {
const label = intlMessages[`${profile.id}`]
? intl.formatMessage(intlMessages[`${profile.id}`])
: profile.name;
const label = intlMessages[`${profile.id}`]
? intl.formatMessage(intlMessages[`${profile.id}`])
: profile.name;
return (
<option key={profile.id} value={profile.id}>
{`${label} ${profile.id === 'hd' ? '' : intl.formatMessage(intlMessages.qualityLabel).toLowerCase()}`}
</option>
)})}
return (
<option key={profile.id} value={profile.id}>
{`${label}`}
</option>
)})}
</select>
)
: (
<span>
{intl.formatMessage(intlMessages.profileNotFoundLabel)}
</span>
{intl.formatMessage(intlMessages.profileNotFoundLabel)}
</span>
)
}
}
</span>
)
}
@ -616,25 +616,25 @@ class VideoPreview extends Component {
<div className={styles.content}>
<div className={styles.videoCol}>
{
previewError
? (
<div>{previewError}</div>
)
: (
<video
id="preview"
data-test={this.mirrorOwnWebcam ? 'mirroredVideoPreview' : 'videoPreview'}
className={cx({
[styles.preview]: true,
[styles.mirroredVideo]: this.mirrorOwnWebcam,
})}
ref={(ref) => { this.video = ref; }}
autoPlay
playsInline
muted
/>
)
}
previewError
? (
<div>{previewError}</div>
)
: (
<video
id="preview"
data-test={this.mirrorOwnWebcam ? 'mirroredVideoPreview' : 'videoPreview'}
className={cx({
[styles.preview]: true,
[styles.mirroredVideo]: this.mirrorOwnWebcam,
})}
ref={(ref) => { this.video = ref; }}
autoPlay
playsInline
muted
/>
)
}
</div>
{this.renderDeviceSelectors()}
</div>

View File

@ -16,10 +16,6 @@ const intlMessages = defineMessages({
id: 'app.video.leaveVideo',
description: 'Leave video button label',
},
videoButtonDesc: {
id: 'app.video.videoButtonDesc',
description: 'video button description',
},
videoLocked: {
id: 'app.video.videoLocked',
description: 'video disabled label',
@ -68,18 +64,19 @@ const JoinVideoButton = ({
}
};
const label = exitVideo()
let label = exitVideo()
? intl.formatMessage(intlMessages.leaveVideo)
: intl.formatMessage(intlMessages.joinVideo);
if (disableReason) label = intl.formatMessage(intlMessages[disableReason]);
return (
<Button
label={label}
data-test="joinVideo"
label={disableReason ? intl.formatMessage(intlMessages[disableReason]) : label}
className={cx(styles.button, hasVideoStream || styles.btn)}
onClick={handleOnClick}
hideLabel
aria-label={intl.formatMessage(intlMessages.videoButtonDesc)}
color={hasVideoStream ? 'primary' : 'default'}
icon={hasVideoStream ? 'video' : 'video_off'}
ghost={!hasVideoStream}

View File

@ -3,6 +3,7 @@ import Auth from '/imports/ui/services/auth';
import WhiteboardMultiUser from '/imports/api/whiteboard-multi-user/';
import addAnnotationQuery from '/imports/api/annotations/addAnnotation';
import { makeCall } from '/imports/ui/services/api';
import logger from '/imports/startup/client/logger';
const Annotations = new Mongo.Collection(null);
const UnsentAnnotations = new Mongo.Collection(null);
@ -53,6 +54,7 @@ function handleRemovedAnnotation({
}
export function initAnnotationsStreamListener() {
logger.info({ logCode: 'init_annotations_stream_listener' }, 'initAnnotationsStreamListener called');
/**
* We create a promise to add the handlers after a ddp subscription stop.
* The problem was caused because we add handlers to stream before the onStop event happens,
@ -73,6 +75,8 @@ export function initAnnotationsStreamListener() {
});
startStreamHandlersPromise.then(() => {
logger.debug({ logCode: 'annotations_stream_handler_attach' }, 'Attaching handlers for annotations stream');
annotationsStreamListener.on('removed', handleRemovedAnnotation);
annotationsStreamListener.on('added', ({ annotations }) => {

View File

@ -8,10 +8,19 @@ import CursorListener from './cursor-listener/component';
export default class WhiteboardOverlay extends Component {
// a function to transform a screen point to svg point
// accepts and returns a point of type SvgPoint and an svg object
// if unable to get the screen CTM, returns an out
// of bounds (-1, -1) svg point
static coordinateTransform(screenPoint, someSvgObject) {
const CTM = someSvgObject.getScreenCTM();
if (!CTM) return {};
return screenPoint.matrixTransform(CTM.inverse());
if (CTM !== null) {
return screenPoint.matrixTransform(CTM.inverse());
}
const outOfBounds = someSvgObject.createSVGPoint();
outOfBounds.x = -1;
outOfBounds.y = -1;
return outOfBounds;
}
// Removes selection from all selected elements

View File

@ -5,6 +5,8 @@ const ANNOTATION_CONFIG = Meteor.settings.public.whiteboard.annotations;
const DRAW_START = ANNOTATION_CONFIG.status.start;
const DRAW_UPDATE = ANNOTATION_CONFIG.status.update;
const DRAW_END = ANNOTATION_CONFIG.status.end;
const DEFAULT_TEXT_WIDTH = 30;
const DEFAULT_TEXT_HEIGHT = 20;
// maximum value of z-index to prevent other things from overlapping
const MAX_Z_INDEX = (2 ** 31) - 1;
@ -362,6 +364,7 @@ export default class TextDrawListener extends Component {
actions,
slideWidth,
slideHeight,
drawSettings,
} = this.props;
const {
@ -382,14 +385,34 @@ export default class TextDrawListener extends Component {
generateNewShapeId,
getCurrentShapeId,
setTextShapeActiveId,
normalizeFont,
} = actions;
const {
textFontSize,
} = drawSettings;
const calcedFontSize = normalizeFont(textFontSize);
let calcedTextBoxWidth = (textBoxWidth / slideWidth) * 100;
let calcedTextBoxHeight = (textBoxHeight / slideHeight) * 100;
const useDefaultSize = (textBoxWidth === 0 && textBoxHeight === 0)
|| calcedTextBoxWidth < calcedFontSize
|| calcedTextBoxHeight < calcedFontSize;
// coordinates and width/height of the textarea in percentages of the current slide
// saving them in the class since they will be used during all updates
this.currentX = (textBoxX / slideWidth) * 100;
this.currentY = (textBoxY / slideHeight) * 100;
this.currentWidth = (textBoxWidth / slideWidth) * 100;
this.currentHeight = (textBoxHeight / slideHeight) * 100;
if (useDefaultSize) {
calcedTextBoxWidth = DEFAULT_TEXT_WIDTH;
calcedTextBoxHeight = DEFAULT_TEXT_HEIGHT;
if (100 - this.currentX < calcedTextBoxWidth) calcedTextBoxWidth = 100 - this.currentX;
if (100 - this.currentY < calcedTextBoxHeight) calcedTextBoxHeight = 100 - this.currentY;
}
this.currentWidth = calcedTextBoxWidth;
this.currentHeight = calcedTextBoxHeight;
this.currentStatus = DRAW_START;
this.handleDrawText(
{ x: this.currentX, y: this.currentY },

View File

@ -16,7 +16,9 @@ const MEDIA = Meteor.settings.public.media;
const MEDIA_TAG = MEDIA.mediaTag;
const ECHO_TEST_NUMBER = MEDIA.echoTestNumber;
const MAX_LISTEN_ONLY_RETRIES = 1;
const LISTEN_ONLY_CALL_TIMEOUT_MS = MEDIA.listenOnlyCallTimeout || 15000;
const LISTEN_ONLY_CALL_TIMEOUT_MS = MEDIA.listenOnlyCallTimeout || 25000;
const DEFAULT_INPUT_DEVICE_ID = 'default';
const DEFAULT_OUTPUT_DEVICE_ID = 'default';
const CALL_STATES = {
STARTED: 'started',
@ -29,7 +31,7 @@ const CALL_STATES = {
class AudioManager {
constructor() {
this._inputDevice = {
value: 'default',
value: DEFAULT_INPUT_DEVICE_ID,
tracker: new Tracker.Dependency(),
};
@ -89,48 +91,18 @@ class AudioManager {
});
}
askDevicesPermissions() {
// Check to see if the stream has already been retrieved becasue then we don't need to
// request. This is a fix for an issue with the input device selector.
if (this.inputStream) {
return Promise.resolve();
}
// Only change the isWaitingPermissions for the case where the user didnt allowed it yet
const permTimeout = setTimeout(() => {
if (!this.devicesInitialized) { this.isWaitingPermissions = true; }
}, 100);
this.isWaitingPermissions = false;
this.devicesInitialized = false;
return Promise.all([
this.setDefaultInputDevice(),
this.setDefaultOutputDevice(),
]).then(() => {
this.devicesInitialized = true;
this.isWaitingPermissions = false;
}).catch((err) => {
clearTimeout(permTimeout);
this.isConnecting = false;
this.isWaitingPermissions = false;
throw err;
});
}
joinMicrophone() {
this.isListenOnly = false;
this.isEchoTest = false;
return this.askDevicesPermissions()
.then(this.onAudioJoining.bind(this))
return this.onAudioJoining.bind(this)()
.then(() => {
const callOptions = {
isListenOnly: false,
extension: null,
inputStream: this.inputStream,
};
return this.bridge.joinAudio(callOptions, this.callStateCallback.bind(this));
return this.joinAudio(callOptions, this.callStateCallback.bind(this));
});
}
@ -138,8 +110,7 @@ class AudioManager {
this.isListenOnly = false;
this.isEchoTest = true;
return this.askDevicesPermissions()
.then(this.onAudioJoining.bind(this))
return this.onAudioJoining.bind(this)()
.then(() => {
const callOptions = {
isListenOnly: false,
@ -147,10 +118,52 @@ class AudioManager {
inputStream: this.inputStream,
};
logger.info({ logCode: 'audiomanager_join_echotest', extraInfo: { logType: 'user_action' } }, 'User requested to join audio conference with mic');
return this.bridge.joinAudio(callOptions, this.callStateCallback.bind(this));
return this.joinAudio(callOptions, this.callStateCallback.bind(this));
});
}
joinAudio(callOptions, callStateCallback) {
return this.bridge.joinAudio(callOptions,
callStateCallback.bind(this)).catch((error) => {
const { name } = error;
if (!name) {
throw error;
}
switch (name) {
case 'NotAllowedError':
logger.error({
logCode: 'audiomanager_error_getting_device',
extraInfo: {
errorName: error.name,
errorMessage: error.message,
},
}, `Error getting microphone - {${error.name}: ${error.message}}`);
break;
case 'NotFoundError':
logger.error({
logCode: 'audiomanager_error_device_not_found',
extraInfo: {
errorName: error.name,
errorMessage: error.message,
},
}, `Error getting microphone - {${error.name}: ${error.message}}`);
break;
default:
break;
}
this.isConnecting = false;
this.isWaitingPermissions = false;
throw {
type: 'MEDIA_ERROR',
};
});
}
async joinListenOnly(r = 0) {
let retries = r;
this.isListenOnly = true;
@ -407,31 +420,25 @@ class AudioManager {
}
createListenOnlyStream() {
if (this.listenOnlyAudioContext) {
this.listenOnlyAudioContext.close();
}
const { AudioContext, webkitAudioContext } = window;
this.listenOnlyAudioContext = AudioContext
? new AudioContext()
: new webkitAudioContext();
const dest = this.listenOnlyAudioContext.createMediaStreamDestination();
const audio = document.querySelector(MEDIA_TAG);
// Play bogus silent audio to try to circumvent autoplay policy on Safari
audio.src = 'resources/sounds/silence.mp3';
if (!audio.src) {
audio.src = 'resources/sounds/silence.mp3';
}
audio.play().catch((e) => {
if (e.name === 'AbortError') {
return;
}
logger.warn({
logCode: 'audiomanager_error_test_audio',
extraInfo: { error: e },
}, 'Error on playing test audio');
});
return dest.stream;
return {};
}
isUsingAudio() {
@ -448,9 +455,13 @@ class AudioManager {
}
changeInputDevice(deviceId) {
const handleChangeInputDeviceSuccess = (inputDevice) => {
this.inputDevice = inputDevice;
return Promise.resolve(inputDevice);
if (!deviceId) {
return Promise.resolve();
}
const handleChangeInputDeviceSuccess = (inputDeviceId) => {
this.inputDevice.id = inputDeviceId;
return Promise.resolve(inputDeviceId);
};
const handleChangeInputDeviceError = (error) => {
@ -476,19 +487,15 @@ class AudioManager {
});
};
if (!deviceId) {
return this.bridge.setDefaultInputDevice()
.then(handleChangeInputDeviceSuccess)
.catch(handleChangeInputDeviceError);
}
return this.bridge.changeInputDevice(deviceId)
return this.bridge.changeInputDeviceId(deviceId)
.then(handleChangeInputDeviceSuccess)
.catch(handleChangeInputDeviceError);
}
async changeOutputDevice(deviceId) {
this.outputDeviceId = await this.bridge.changeOutputDevice(deviceId);
this.outputDeviceId = await this
.bridge
.changeOutputDevice(deviceId || DEFAULT_OUTPUT_DEVICE_ID);
}
set inputDevice(value) {
@ -501,9 +508,13 @@ class AudioManager {
return this._inputDevice.value.stream;
}
get inputDevice() {
return this._inputDevice;
}
get inputDeviceId() {
this._inputDevice.tracker.depend();
return this._inputDevice.value.id;
return (this.bridge && this.bridge.inputDeviceId)
? this.bridge.inputDeviceId : DEFAULT_INPUT_DEVICE_ID;
}
set userData(value) {
@ -515,8 +526,9 @@ class AudioManager {
}
playHangUpSound() {
this.alert = new Audio(`${Meteor.settings.public.app.cdn + Meteor.settings.public.app.basename}/resources/sounds/LeftCall.mp3`);
this.alert.play();
this.playAlertSound(`${Meteor.settings.public.app.cdn
+ Meteor.settings.public.app.basename}`
+ '/resources/sounds/LeftCall.mp3');
}
notify(message, error = false, icon = 'unmute') {
@ -582,7 +594,12 @@ class AudioManager {
// Bridge -> SIP.js bridge, the only full audio capable one right now
const peer = this.bridge.getPeerConnection();
peer.getSenders().forEach((sender) => {
if (!peer) {
return;
}
peer.getSenders().forEach(sender => {
const { track } = sender;
if (track && track.kind === 'audio') {
track.enabled = shouldEnable;
@ -597,6 +614,22 @@ class AudioManager {
unmute() {
this.setSenderTrackEnabled(true);
}
playAlertSound (url) {
if (!url) {
return Promise.resolve();
}
const audioAlert = new Audio(url);
if (this.outputDeviceId && (typeof audioAlert.setSinkId === 'function')) {
return audioAlert
.setSinkId(this.outputDeviceId)
.then(() => audioAlert.play());
}
return audioAlert.play();
}
}
const audioManager = new AudioManager();

View File

@ -7,6 +7,7 @@ import Users from '/imports/api/users';
import logger from '/imports/startup/client/logger';
import { makeCall } from '/imports/ui/services/api';
import { initAnnotationsStreamListener } from '/imports/ui/components/whiteboard/service';
import allowRedirectToLogoutURL from '/imports/ui/components/meeting-ended/service';
import { initCursorStreamListener } from '/imports/ui/components/cursor/service';
import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation';
@ -183,7 +184,12 @@ class Auth {
return new Promise((resolve) => {
resolve(this._logoutURL);
if (allowRedirectToLogoutURL()) {
resolve(this._logoutURL);
}
// do not redirect
resolve();
});
}
@ -195,7 +201,7 @@ class Auth {
if (!(this.meetingID && this.userID && this.token)) {
return Promise.reject({
error: 401,
description: 'Authentication failed due to missing credentials.',
description: Session.get('errorMessageDescription') ? Session.get('errorMessageDescription') : 'Authentication failed due to missing credentials',
});
}
@ -214,8 +220,8 @@ class Auth {
const validationTimeout = setTimeout(() => {
computation.stop();
reject({
error: 401,
description: 'Authentication timeout.',
error: 408,
description: 'Authentication timeout',
});
}, CONNECTION_TIMEOUT);
@ -223,18 +229,20 @@ class Auth {
const result = await makeCall('validateAuthToken', this.meetingID, this.userID, this.token, this.externUserID);
if (!result) {
if (result && result.invalid) {
clearTimeout(validationTimeout);
reject({
error: 401,
description: 'User has been banned.',
error: 403,
description: result.reason,
type: result.error_type,
});
return;
}
Meteor.subscribe('current-user');
Tracker.autorun((c) => {
computation = c;
Meteor.subscribe('current-user');
const selector = { meetingId: this.meetingID, userId: this.userID };
const fields = {
@ -252,7 +260,7 @@ class Auth {
if (User.ejected) {
computation.stop();
reject({
error: 401,
error: 403,
description: 'User has been ejected.',
});
return;

View File

@ -2,13 +2,11 @@ import Auth from '/imports/ui/services/auth';
const logoutRouteHandler = () => {
Auth.logout()
.then((logoutURL = window.location.origin) => {
const protocolPattern = /^((http|https):\/\/)/;
window.location.href =
protocolPattern.test(logoutURL) ?
logoutURL :
`http://${logoutURL}`;
.then((logoutURL) => {
if (logoutURL) {
const protocolPattern = /^((http|https):\/\/)/;
window.location.href = protocolPattern.test(logoutURL) ? logoutURL : `http://${logoutURL}`;
}
});
};

View File

@ -46,7 +46,7 @@ const toUnifiedPlan = (planBSDP) => {
};
const stripMDnsCandidates = (sdp) => {
const parsedSDP = transform.parse(sdp);
const parsedSDP = transform.parse(sdp.sdp);
let strippedCandidates = 0;
parsedSDP.media.forEach((media) => {
if (media.candidates) {
@ -62,7 +62,7 @@ const stripMDnsCandidates = (sdp) => {
if (strippedCandidates > 0) {
logger.info({ logCode: 'sdp_utils_mdns_candidate_strip' }, `Stripped ${strippedCandidates} mDNS candidates`);
}
return transform.write(parsedSDP);
return { sdp: transform.write(parsedSDP), type: sdp.type };
};
const isPublicIpv4 = (ip) => {

View File

@ -17,6 +17,9 @@ public:
cdn: ""
basename: "/html5client"
askForFeedbackOnLogout: false
# the default logoutUrl matches window.location.origin i.e. bigbluebutton.org for demo.bigbluebutton.org
# in some cases we want only custom logoutUrl to be used when provided on meeting create. Default value: true
allowDefaultLogoutUrl: true
allowUserLookup: false
enableNetworkInformation: false
enableLimitOfViewersInWebcam: false
@ -306,7 +309,7 @@ public:
callHangupMaximumRetries: 10
echoTestNumber: 'echo'
relayOnlyOnReconnect: false
listenOnlyCallTimeout: 15000
listenOnlyCallTimeout: 25000
stats:
enabled: true
interval: 2000

View File

@ -1,601 +0,0 @@
/*!
* Bowser - a browser detector
* https://github.com/ded/bowser
* MIT License | (c) Dustin Diaz 2015
*/
!function (root, name, definition) {
if (typeof module != 'undefined' && module.exports) module.exports = definition()
else if (typeof define == 'function' && define.amd) define(name, definition)
else root[name] = definition()
}(this, 'bowser', function () {
/**
* See useragents.js for examples of navigator.userAgent
*/
var t = true
function detect(ua) {
function getFirstMatch(regex) {
var match = ua.match(regex);
return (match && match.length > 1 && match[1]) || '';
}
function getSecondMatch(regex) {
var match = ua.match(regex);
return (match && match.length > 1 && match[2]) || '';
}
var iosdevice = getFirstMatch(/(ipod|iphone|ipad)/i).toLowerCase()
, likeAndroid = /like android/i.test(ua)
, android = !likeAndroid && /android/i.test(ua)
, nexusMobile = /nexus\s*[0-6]\s*/i.test(ua)
, nexusTablet = !nexusMobile && /nexus\s*[0-9]+/i.test(ua)
, chromeos = /CrOS/.test(ua)
, silk = /silk/i.test(ua)
, sailfish = /sailfish/i.test(ua)
, tizen = /tizen/i.test(ua)
, webos = /(web|hpw)os/i.test(ua)
, windowsphone = /windows phone/i.test(ua)
, samsungBrowser = /SamsungBrowser/i.test(ua)
, windows = !windowsphone && /windows/i.test(ua)
, mac = !iosdevice && !silk && /macintosh/i.test(ua)
, linux = !android && !sailfish && !tizen && !webos && /linux/i.test(ua)
, edgeVersion = getFirstMatch(/edge\/(\d+(\.\d+)?)/i)
, versionIdentifier = getFirstMatch(/version\/(\d+(\.\d+)?)/i)
, tablet = /tablet/i.test(ua) && !/tablet pc/i.test(ua)
, mobile = !tablet && /[^-]mobi/i.test(ua)
, xbox = /xbox/i.test(ua)
, result
if (/opera/i.test(ua)) {
// an old Opera
result = {
name: 'Opera'
, opera: t
, version: versionIdentifier || getFirstMatch(/(?:opera|opr|opios)[\s\/](\d+(\.\d+)?)/i)
}
} else if (/opr\/|opios/i.test(ua)) {
// a new Opera
result = {
name: 'Opera'
, opera: t
, version: getFirstMatch(/(?:opr|opios)[\s\/](\d+(\.\d+)?)/i) || versionIdentifier
}
}
else if (/SamsungBrowser/i.test(ua)) {
result = {
name: 'Samsung Internet for Android'
, samsungBrowser: t
, version: versionIdentifier || getFirstMatch(/(?:SamsungBrowser)[\s\/](\d+(\.\d+)?)/i)
}
}
else if (/coast/i.test(ua)) {
result = {
name: 'Opera Coast'
, coast: t
, version: versionIdentifier || getFirstMatch(/(?:coast)[\s\/](\d+(\.\d+)?)/i)
}
}
else if (/yabrowser/i.test(ua)) {
result = {
name: 'Yandex Browser'
, yandexbrowser: t
, version: versionIdentifier || getFirstMatch(/(?:yabrowser)[\s\/](\d+(\.\d+)?)/i)
}
}
else if (/ucbrowser/i.test(ua)) {
result = {
name: 'UC Browser'
, ucbrowser: t
, version: getFirstMatch(/(?:ucbrowser)[\s\/](\d+(?:\.\d+)+)/i)
}
}
else if (/mxios/i.test(ua)) {
result = {
name: 'Maxthon'
, maxthon: t
, version: getFirstMatch(/(?:mxios)[\s\/](\d+(?:\.\d+)+)/i)
}
}
else if (/epiphany/i.test(ua)) {
result = {
name: 'Epiphany'
, epiphany: t
, version: getFirstMatch(/(?:epiphany)[\s\/](\d+(?:\.\d+)+)/i)
}
}
else if (/puffin/i.test(ua)) {
result = {
name: 'Puffin'
, puffin: t
, version: getFirstMatch(/(?:puffin)[\s\/](\d+(?:\.\d+)?)/i)
}
}
else if (/sleipnir/i.test(ua)) {
result = {
name: 'Sleipnir'
, sleipnir: t
, version: getFirstMatch(/(?:sleipnir)[\s\/](\d+(?:\.\d+)+)/i)
}
}
else if (/k-meleon/i.test(ua)) {
result = {
name: 'K-Meleon'
, kMeleon: t
, version: getFirstMatch(/(?:k-meleon)[\s\/](\d+(?:\.\d+)+)/i)
}
}
else if (windowsphone) {
result = {
name: 'Windows Phone'
, windowsphone: t
}
if (edgeVersion) {
result.msedge = t
result.version = edgeVersion
}
else {
result.msie = t
result.version = getFirstMatch(/iemobile\/(\d+(\.\d+)?)/i)
}
}
else if (/msie|trident/i.test(ua)) {
result = {
name: 'Internet Explorer'
, msie: t
, version: getFirstMatch(/(?:msie |rv:)(\d+(\.\d+)?)/i)
}
} else if (chromeos) {
result = {
name: 'Chrome'
, chromeos: t
, chromeBook: t
, chrome: t
, version: getFirstMatch(/(?:chrome|crios|crmo)\/(\d+(\.\d+)?)/i)
}
} else if (/chrome.+? edge/i.test(ua)) {
result = {
name: 'Microsoft Edge'
, msedge: t
, version: edgeVersion
}
}
else if (/vivaldi/i.test(ua)) {
result = {
name: 'Vivaldi'
, vivaldi: t
, version: getFirstMatch(/vivaldi\/(\d+(\.\d+)?)/i) || versionIdentifier
}
}
else if (sailfish) {
result = {
name: 'Sailfish'
, sailfish: t
, version: getFirstMatch(/sailfish\s?browser\/(\d+(\.\d+)?)/i)
}
}
else if (/seamonkey\//i.test(ua)) {
result = {
name: 'SeaMonkey'
, seamonkey: t
, version: getFirstMatch(/seamonkey\/(\d+(\.\d+)?)/i)
}
}
else if (/firefox|iceweasel|fxios/i.test(ua)) {
result = {
name: 'Firefox'
, firefox: t
, version: getFirstMatch(/(?:firefox|iceweasel|fxios)[ \/](\d+(\.\d+)?)/i)
}
if (/\((mobile|tablet);[^\)]*rv:[\d\.]+\)/i.test(ua)) {
result.firefoxos = t
}
}
else if (silk) {
result = {
name: 'Amazon Silk'
, silk: t
, version : getFirstMatch(/silk\/(\d+(\.\d+)?)/i)
}
}
else if (/phantom/i.test(ua)) {
result = {
name: 'PhantomJS'
, phantom: t
, version: getFirstMatch(/phantomjs\/(\d+(\.\d+)?)/i)
}
}
else if (/slimerjs/i.test(ua)) {
result = {
name: 'SlimerJS'
, slimer: t
, version: getFirstMatch(/slimerjs\/(\d+(\.\d+)?)/i)
}
}
else if (/blackberry|\bbb\d+/i.test(ua) || /rim\stablet/i.test(ua)) {
result = {
name: 'BlackBerry'
, blackberry: t
, version: versionIdentifier || getFirstMatch(/blackberry[\d]+\/(\d+(\.\d+)?)/i)
}
}
else if (webos) {
result = {
name: 'WebOS'
, webos: t
, version: versionIdentifier || getFirstMatch(/w(?:eb)?osbrowser\/(\d+(\.\d+)?)/i)
};
/touchpad\//i.test(ua) && (result.touchpad = t)
}
else if (/bada/i.test(ua)) {
result = {
name: 'Bada'
, bada: t
, version: getFirstMatch(/dolfin\/(\d+(\.\d+)?)/i)
};
}
else if (tizen) {
result = {
name: 'Tizen'
, tizen: t
, version: getFirstMatch(/(?:tizen\s?)?browser\/(\d+(\.\d+)?)/i) || versionIdentifier
};
}
else if (/qupzilla/i.test(ua)) {
result = {
name: 'QupZilla'
, qupzilla: t
, version: getFirstMatch(/(?:qupzilla)[\s\/](\d+(?:\.\d+)+)/i) || versionIdentifier
}
}
else if (/chromium/i.test(ua)) {
result = {
name: 'Chromium'
, chromium: t
, version: getFirstMatch(/(?:chromium)[\s\/](\d+(?:\.\d+)?)/i) || versionIdentifier
}
}
else if (/chrome|crios|crmo/i.test(ua)) {
result = {
name: 'Chrome'
, chrome: t
, version: getFirstMatch(/(?:chrome|crios|crmo)\/(\d+(\.\d+)?)/i)
}
}
else if (android) {
result = {
name: 'Android'
, version: versionIdentifier
}
}
else if (/safari|applewebkit/i.test(ua)) {
result = {
name: 'Safari'
, safari: t
}
if (versionIdentifier) {
result.version = versionIdentifier
}
}
else if (iosdevice) {
result = {
name : iosdevice == 'iphone' ? 'iPhone' : iosdevice == 'ipad' ? 'iPad' : 'iPod'
}
// WTF: version is not part of user agent in web apps
if (versionIdentifier) {
result.version = versionIdentifier
}
}
else if(/googlebot/i.test(ua)) {
result = {
name: 'Googlebot'
, googlebot: t
, version: getFirstMatch(/googlebot\/(\d+(\.\d+))/i) || versionIdentifier
}
}
else {
result = {
name: getFirstMatch(/^(.*)\/(.*) /),
version: getSecondMatch(/^(.*)\/(.*) /)
};
}
// set webkit or gecko flag for browsers based on these engines
if (!result.msedge && /(apple)?webkit/i.test(ua)) {
if (/(apple)?webkit\/537\.36/i.test(ua)) {
result.name = result.name || "Blink"
result.blink = t
} else {
result.name = result.name || "Webkit"
result.webkit = t
}
if (!result.version && versionIdentifier) {
result.version = versionIdentifier
}
} else if (!result.opera && /gecko\//i.test(ua)) {
result.name = result.name || "Gecko"
result.gecko = t
result.version = result.version || getFirstMatch(/gecko\/(\d+(\.\d+)?)/i)
}
// set OS flags for platforms that have multiple browsers
if (!result.windowsphone && !result.msedge && (android || result.silk)) {
result.android = t
} else if (!result.windowsphone && !result.msedge && iosdevice) {
result[iosdevice] = t
result.ios = t
} else if (mac) {
result.mac = t
} else if (xbox) {
result.xbox = t
} else if (windows) {
result.windows = t
} else if (linux) {
result.linux = t
}
function getWindowsVersion (s) {
switch (s) {
case 'NT': return 'NT'
case 'XP': return 'XP'
case 'NT 5.0': return '2000'
case 'NT 5.1': return 'XP'
case 'NT 5.2': return '2003'
case 'NT 6.0': return 'Vista'
case 'NT 6.1': return '7'
case 'NT 6.2': return '8'
case 'NT 6.3': return '8.1'
case 'NT 10.0': return '10'
default: return undefined
}
}
// OS version extraction
var osVersion = '';
if (result.windows) {
osVersion = getWindowsVersion(getFirstMatch(/Windows ((NT|XP)( \d\d?.\d)?)/i))
} else if (result.windowsphone) {
osVersion = getFirstMatch(/windows phone (?:os)?\s?(\d+(\.\d+)*)/i);
} else if (result.mac) {
osVersion = getFirstMatch(/Mac OS X (\d+([_\.\s]\d+)*)/i);
osVersion = osVersion.replace(/[_\s]/g, '.');
} else if (iosdevice) {
osVersion = getFirstMatch(/os (\d+([_\s]\d+)*) like mac os x/i);
osVersion = osVersion.replace(/[_\s]/g, '.');
} else if (android) {
osVersion = getFirstMatch(/android[ \/-](\d+(\.\d+)*)/i);
} else if (result.webos) {
osVersion = getFirstMatch(/(?:web|hpw)os\/(\d+(\.\d+)*)/i);
} else if (result.blackberry) {
osVersion = getFirstMatch(/rim\stablet\sos\s(\d+(\.\d+)*)/i);
} else if (result.bada) {
osVersion = getFirstMatch(/bada\/(\d+(\.\d+)*)/i);
} else if (result.tizen) {
osVersion = getFirstMatch(/tizen[\/\s](\d+(\.\d+)*)/i);
}
if (osVersion) {
result.osversion = osVersion;
}
// device type extraction
var osMajorVersion = !result.windows && osVersion.split('.')[0];
if (
tablet
|| nexusTablet
|| iosdevice == 'ipad'
|| (android && (osMajorVersion == 3 || (osMajorVersion >= 4 && !mobile)))
|| result.silk
) {
result.tablet = t
} else if (
mobile
|| iosdevice == 'iphone'
|| iosdevice == 'ipod'
|| android
|| nexusMobile
|| result.blackberry
|| result.webos
|| result.bada
) {
result.mobile = t
}
// Graded Browser Support
// http://developer.yahoo.com/yui/articles/gbs
if (result.msedge ||
(result.msie && result.version >= 10) ||
(result.yandexbrowser && result.version >= 15) ||
(result.vivaldi && result.version >= 1.0) ||
(result.chrome && result.version >= 20) ||
(result.samsungBrowser && result.version >= 4) ||
(result.firefox && result.version >= 20.0) ||
(result.safari && result.version >= 6) ||
(result.opera && result.version >= 10.0) ||
(result.ios && result.osversion && result.osversion.split(".")[0] >= 6) ||
(result.blackberry && result.version >= 10.1)
|| (result.chromium && result.version >= 20)
) {
result.a = t;
}
else if ((result.msie && result.version < 10) ||
(result.chrome && result.version < 20) ||
(result.firefox && result.version < 20.0) ||
(result.safari && result.version < 6) ||
(result.opera && result.version < 10.0) ||
(result.ios && result.osversion && result.osversion.split(".")[0] < 6)
|| (result.chromium && result.version < 20)
) {
result.c = t
} else result.x = t
return result
}
var bowser = detect(typeof navigator !== 'undefined' ? navigator.userAgent || '' : '')
bowser.test = function (browserList) {
for (var i = 0; i < browserList.length; ++i) {
var browserItem = browserList[i];
if (typeof browserItem=== 'string') {
if (browserItem in bowser) {
return true;
}
}
}
return false;
}
/**
* Get version precisions count
*
* @example
* getVersionPrecision("1.10.3") // 3
*
* @param {string} version
* @return {number}
*/
function getVersionPrecision(version) {
return version.split(".").length;
}
/**
* Array::map polyfill
*
* @param {Array} arr
* @param {Function} iterator
* @return {Array}
*/
function map(arr, iterator) {
var result = [], i;
if (Array.prototype.map) {
return Array.prototype.map.call(arr, iterator);
}
for (i = 0; i < arr.length; i++) {
result.push(iterator(arr[i]));
}
return result;
}
/**
* Calculate browser version weight
*
* @example
* compareVersions(['1.10.2.1', '1.8.2.1.90']) // 1
* compareVersions(['1.010.2.1', '1.09.2.1.90']); // 1
* compareVersions(['1.10.2.1', '1.10.2.1']); // 0
* compareVersions(['1.10.2.1', '1.0800.2']); // -1
*
* @param {Array<String>} versions versions to compare
* @return {Number} comparison result
*/
function compareVersions(versions) {
// 1) get common precision for both versions, for example for "10.0" and "9" it should be 2
var precision = Math.max(getVersionPrecision(versions[0]), getVersionPrecision(versions[1]));
var chunks = map(versions, function (version) {
var delta = precision - getVersionPrecision(version);
// 2) "9" -> "9.0" (for precision = 2)
version = version + new Array(delta + 1).join(".0");
// 3) "9.0" -> ["000000000"", "000000009"]
return map(version.split("."), function (chunk) {
return new Array(20 - chunk.length).join("0") + chunk;
}).reverse();
});
// iterate in reverse order by reversed chunks array
while (--precision >= 0) {
// 4) compare: "000000009" > "000000010" = false (but "9" > "10" = true)
if (chunks[0][precision] > chunks[1][precision]) {
return 1;
}
else if (chunks[0][precision] === chunks[1][precision]) {
if (precision === 0) {
// all version chunks are same
return 0;
}
}
else {
return -1;
}
}
}
/**
* Check if browser is unsupported
*
* @example
* bowser.isUnsupportedBrowser({
* msie: "10",
* firefox: "23",
* chrome: "29",
* safari: "5.1",
* opera: "16",
* phantom: "534"
* });
*
* @param {Object} minVersions map of minimal version to browser
* @param {Boolean} [strictMode = false] flag to return false if browser wasn't found in map
* @param {String} [ua] user agent string
* @return {Boolean}
*/
function isUnsupportedBrowser(minVersions, strictMode, ua) {
var _bowser = bowser;
// make strictMode param optional with ua param usage
if (typeof strictMode === 'string') {
ua = strictMode;
strictMode = void(0);
}
if (strictMode === void(0)) {
strictMode = false;
}
if (ua) {
_bowser = detect(ua);
}
var version = "" + _bowser.version;
for (var browser in minVersions) {
if (minVersions.hasOwnProperty(browser)) {
if (_bowser[browser]) {
if (typeof minVersions[browser] !== 'string') {
throw new Error('Browser version in the minVersion map should be a string: ' + browser + ': ' + String(minVersions));
}
// browser version and min supported version.
return compareVersions([version, minVersions[browser]]) < 0;
}
}
}
return strictMode; // not found
}
/**
* Check if browser is supported
*
* @param {Object} minVersions map of minimal version to browser
* @param {Boolean} [strictMode = false] flag to return false if browser wasn't found in map
* @param {String} [ua] user agent string
* @return {Boolean}
*/
function check(minVersions, strictMode, ua) {
return !isUnsupportedBrowser(minVersions, strictMode, ua);
}
bowser.isUnsupportedBrowser = isUnsupportedBrowser;
bowser.compareVersions = compareVersions;
bowser.check = check;
/*
* Set our detect method to the main bowser object so we can
* reuse it to test other user agents.
* This is needed to implement future tests.
*/
bowser._detect = detect;
return bowser
});

View File

@ -357,6 +357,11 @@ Kurento.prototype.startResponse = function (message) {
return this.onFail(error);
}
this.logger.info({
logCode: 'kurentoextension_process_answer',
}, `Answer processed with success`);
// Mark the peer as negotiated and flush the ICE queue
this.webRtcPeer.negotiated = true;
this.processIceQueue();

32326
bigbluebutton-html5/public/compatibility/sip.js Executable file → Normal file

File diff suppressed because it is too large Load Diff

View File

@ -26,6 +26,11 @@ endpoints:
jmx:
enabled: true
server:
session:
cookie:
secure: true
---
grails:
mime:

View File

@ -221,6 +221,16 @@ allowModsToUnmuteUsers=false
# Saves meeting events even if the meeting is not recorded
keepEvents=true
# Timeout (millis) to remove a joined user after her/his left event without a rejoin
# e.g. regular user left event
# Default 60s
usersTimeout=60000
# Timeout (millis) to remove users that called the enter API but did not join
# e.g. user's client hanged between the enter call and join event
# Default 45s
enteredUsersTimeout=45000
#----------------------------------------------------
# This URL is where the BBB client is accessible. When a user sucessfully
# enters a name and password, she is redirected here to load the client.

View File

@ -33,8 +33,9 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
</property>
</bean>
<bean id="registeredUserCleanupTimerTask" class="org.bigbluebutton.web.services.RegisteredUserCleanupTimerTask"/>
<bean id="waitingGuestCleanupTimerTask" class="org.bigbluebutton.web.services.WaitingGuestCleanupTimerTask"/>
<bean id="userCleanupTimerTask" class="org.bigbluebutton.web.services.UserCleanupTimerTask"/>
<bean id="enteredUserCleanupTimerTask" class="org.bigbluebutton.web.services.EnteredUserCleanupTimerTask"/>
<bean id="keepAliveService" class="org.bigbluebutton.web.services.KeepAliveService"
init-method="start" destroy-method="stop">
@ -48,11 +49,14 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
<property name="presDownloadService" ref="presDownloadService"/>
<property name="paramsProcessorUtil" ref="paramsProcessorUtil"/>
<property name="stunTurnService" ref="stunTurnService"/>
<property name="registeredUserCleanupTimerTask" ref="registeredUserCleanupTimerTask"/>
<property name="waitingGuestCleanupTimerTask" ref="waitingGuestCleanupTimerTask"/>
<property name="userCleanupTimerTask" ref="userCleanupTimerTask"/>
<property name="enteredUserCleanupTimerTask" ref="enteredUserCleanupTimerTask"/>
<property name="gw" ref="bbbWebApiGWApp"/>
<property name="callbackUrlService" ref="callbackUrlService"/>
<property name="keepEvents" value="${keepEvents}"/>
<property name="usersTimeout" value="${usersTimeout}"/>
<property name="enteredUsersTimeout" value="${enteredUsersTimeout}"/>
</bean>
<bean id="oldMessageReceivedGW" class="org.bigbluebutton.api2.bus.OldMessageReceivedGW">

View File

@ -453,10 +453,9 @@ class ApiController {
us.avatarURL = meeting.defaultAvatarURL
}
// Validate if the maxParticipants limit has been reached based on registeredUsers. If so, complain.
// when maxUsers is set to 0, the validation is ignored
int maxUsers = meeting.getMaxUsers();
if (maxUsers > 0 && meeting.getRegisteredUsers().size() >= maxUsers) {
String meetingId = meeting.getInternalId()
if (hasReachedMaxParticipants(meeting, us)) {
// BEGIN - backward compatibility
invalid("maxParticipantsReached", "The number of participants allowed for this meeting has been reached.", REDIRECT_RESPONSE);
return
@ -468,8 +467,18 @@ class ApiController {
}
// Register user into the meeting.
meetingService.registerUser(us.meetingID, us.internalUserId, us.fullname, us.role, us.externUserID,
us.authToken, us.avatarURL, us.guest, us.authed, guestStatusVal)
meetingService.registerUser(
us.meetingID,
us.internalUserId,
us.fullname,
us.role,
us.externUserID,
us.authToken,
us.avatarURL,
us.guest,
us.authed,
guestStatusVal
)
//Identify which of these to logs should be used. sessionToken or user-token
log.info("Session sessionToken for " + us.fullname + " [" + session[sessionToken] + "]")
@ -1192,24 +1201,8 @@ class ApiController {
String logoutUrl = paramsProcessorUtil.getDefaultLogoutUrl()
boolean reject = false
String sessionToken = null
UserSession us = null
if (StringUtils.isEmpty(params.sessionToken)) {
log.info("No session for user in conference.")
reject = true
} else {
sessionToken = StringUtils.strip(params.sessionToken)
log.info("Getting ConfigXml for SessionToken = " + sessionToken)
if (!session[sessionToken]) {
reject = true
} else {
us = meetingService.getUserSessionWithAuthToken(sessionToken);
if (us == null) reject = true
}
}
if (reject) {
String sessionToken = sanitizeSessionToken(params.sessionToken)
if (!hasValidSession(sessionToken)) {
response.addHeader("Cache-Control", "no-cache")
withFormat {
xml {
@ -1217,6 +1210,7 @@ class ApiController {
}
}
} else {
UserSession us = getUserSession(sessionToken)
if (StringUtils.isEmpty(us.configXML)) {
// BEGIN - backward compatibility
invalid("noConfigFound", "We could not find a config for this request.", REDIRECT_RESPONSE);
@ -1254,44 +1248,26 @@ class ApiController {
log.debug CONTROLLER_NAME + "#${API_CALL}"
ApiErrors errors = new ApiErrors()
boolean reject = false;
String sessionToken = sanitizeSessionToken(params.sessionToken)
if (StringUtils.isEmpty(params.sessionToken)) {
log.debug("SessionToken is missing.")
}
String sessionToken = StringUtils.strip(params.sessionToken)
UserSession us = null;
UserSession us = getUserSession(sessionToken);
Meeting meeting = null;
UserSession userSession = null;
if (sessionToken == null || meetingService.getUserSessionWithAuthToken(sessionToken) == null) {
if (us == null) {
log.debug("No user with session token.")
reject = true;
} else {
us = meetingService.getUserSessionWithAuthToken(sessionToken);
meeting = meetingService.getMeeting(us.meetingID);
if (meeting == null || meeting.isForciblyEnded()) {
log.debug("Meeting not found.")
reject = true
}
userSession = meetingService.getUserSessionWithAuthToken(sessionToken)
if (userSession == null) {
log.debug("Session with user not found.")
reject = true
}
}
// Determine the logout url so we can send the user there.
String logoutUrl = paramsProcessorUtil.getDefaultLogoutUrl()
if (us != null) {
logoutUrl = us.logoutUrl
}
String logoutUrl = us != null ? us.logoutUrl : paramsProcessorUtil.getDefaultLogoutUrl()
if (reject) {
log.info("No session for user in conference.")
response.addHeader("Cache-Control", "no-cache")
withFormat {
json {
@ -1327,7 +1303,7 @@ class ApiController {
clientURL = params.clientURL;
}
String guestWaitStatus = userSession.guestStatus
String guestWaitStatus = us.guestStatus
log.debug("GuestWaitStatus = " + guestWaitStatus)
@ -1416,48 +1392,34 @@ class ApiController {
def enter = {
boolean reject = false;
if (StringUtils.isEmpty(params.sessionToken)) {
println("SessionToken is missing.")
}
String sessionToken = StringUtils.strip(params.sessionToken)
UserSession us = null;
String sessionToken = sanitizeSessionToken(params.sessionToken)
UserSession us = getUserSession(sessionToken);
Meeting meeting = null;
UserSession userSession = null;
Boolean allowEnterWithoutSession = false;
// Depending on configuration, allow ENTER requests to proceed without session
if (paramsProcessorUtil.getAllowRequestsWithoutSession()) {
allowEnterWithoutSession = paramsProcessorUtil.getAllowRequestsWithoutSession();
}
String respMessage = "Session " + sessionToken + " not found."
if (!sessionToken || meetingService.getUserSessionWithAuthToken(sessionToken) == null || (!allowEnterWithoutSession && !session[sessionToken])) {
if (!hasValidSession(sessionToken)) {
reject = true;
respMessage = "Session " + sessionToken + " not found."
} else {
us = meetingService.getUserSessionWithAuthToken(sessionToken);
if (us == null) {
respMessage = "Session " + sessionToken + " not found."
meeting = meetingService.getMeeting(us.meetingID);
if (meeting == null || meeting.isForciblyEnded()) {
reject = true
respMessage = "Meeting not found or ended for session " + sessionToken + "."
} else {
meeting = meetingService.getMeeting(us.meetingID);
if (meeting == null || meeting.isForciblyEnded()) {
reject = true
respMessage = "Meeting not found or ended for session " + sessionToken + "."
}
if (us.guestStatus.equals(GuestPolicy.DENY)) {
respMessage = "User denied for user with session " + sessionToken + "."
reject = true
if (hasReachedMaxParticipants(meeting, us)) {
reject = true;
respMessage = "The number of participants allowed for this meeting has been reached.";
} else {
meeting.userEntered(us.internalUserId);
}
}
if (us.guestStatus.equals(GuestPolicy.DENY)) {
respMessage = "User denied for user with session " + sessionToken + "."
reject = true
}
}
if (reject) {
log.info("No session for user in conference.")
// Determine the logout url so we can send the user there.
String logoutUrl = paramsProcessorUtil.getDefaultLogoutUrl()
@ -1572,25 +1534,13 @@ class ApiController {
def stuns = {
boolean reject = false;
UserSession us = null;
String sessionToken = sanitizeSessionToken(params.sessionToken)
UserSession us = getUserSession(sessionToken);
Meeting meeting = null;
String sessionToken = null
if (!StringUtils.isEmpty(params.sessionToken)) {
sessionToken = StringUtils.strip(params.sessionToken)
println("Session token = [" + sessionToken + "]")
}
Boolean allowStunsWithoutSession = false;
// Depending on configuration, allow STUNS requests to proceed without session
if (paramsProcessorUtil.getAllowRequestsWithoutSession()) {
allowStunsWithoutSession = paramsProcessorUtil.getAllowRequestsWithoutSession();
}
if (sessionToken == null || meetingService.getUserSessionWithAuthToken(sessionToken) == null || (!allowStunsWithoutSession && !session[sessionToken])) {
if (!hasValidSession(sessionToken)) {
reject = true;
} else {
us = meetingService.getUserSessionWithAuthToken(sessionToken);
meeting = meetingService.getMeeting(us.meetingID);
if (meeting == null || meeting.isForciblyEnded()) {
reject = true
@ -1598,8 +1548,6 @@ class ApiController {
}
if (reject) {
log.info("No session for user in conference.")
String logoutUrl = paramsProcessorUtil.getDefaultLogoutUrl()
response.addHeader("Cache-Control", "no-cache")
@ -1656,12 +1604,7 @@ class ApiController {
*************************************************/
def signOut = {
String sessionToken = null
if (!StringUtils.isEmpty(params.sessionToken)) {
sessionToken = StringUtils.strip(params.sessionToken)
println("SessionToken = " + sessionToken)
}
String sessionToken = sanitizeSessionToken(params.sessionToken)
Meeting meeting = null;
@ -2182,6 +2125,76 @@ class ApiController {
}
}
def getUserSession(token) {
if (token == null) {
return null
}
UserSession us = meetingService.getUserSessionWithAuthToken(token)
if (us == null) {
log.info("Cannot find UserSession for token ${token}")
}
return us
}
def sanitizeSessionToken(param) {
if (param == null) {
log.info("sanitizeSessionToken: token is null")
return null
}
if (StringUtils.isEmpty(param)) {
log.info("sanitizeSessionToken: token is empty")
return null
}
return StringUtils.strip(param)
}
private Boolean hasValidSession(token) {
UserSession us = getUserSession(token)
if (us == null) {
return false
}
if (!session[token]) {
log.info("Session for token ${token} not found")
Boolean allowRequestsWithoutSession = paramsProcessorUtil.getAllowRequestsWithoutSession()
if (!allowRequestsWithoutSession) {
log.info("Meeting related to ${token} doesn't allow requests without session")
return false
}
}
log.info("Token ${token} is valid")
return true
}
// Validate maxParticipants constraint
private Boolean hasReachedMaxParticipants(meeting, us) {
// Meeting object calls it maxUsers to build up the drama
int maxParticipants = meeting.getMaxUsers();
// When is set to 0, the validation is ignored
Boolean enabled = maxParticipants > 0;
// Users refreshing page or reconnecting must be identified
Boolean rejoin = meeting.getUserById(us.internalUserId) != null;
// Users that passed enter once, still not joined but somehow re-entered
Boolean reenter = meeting.getEnteredUserById(us.internalUserId) != null;
// Users that already joined the meeting
int joinedUsers = meeting.getUsers().size()
// Users that are entering the meeting
int enteredUsers = meeting.getEnteredUsers().size()
Boolean reachedMax = (joinedUsers + enteredUsers) >= maxParticipants;
if (enabled && !rejoin && !reenter && reachedMax) {
return true;
}
return false;
}
private void respondWithErrors(errorList, redirectResponse = false) {
log.debug CONTROLLER_NAME + "#invalid"
if (redirectResponse) {

View File

@ -36,6 +36,8 @@ require 'rubygems'
require 'net/http'
require 'journald/logger'
require 'fnv'
require 'shellwords'
require 'English'
module BigBlueButton
class MissingDirectoryException < RuntimeError
@ -97,40 +99,28 @@ module BigBlueButton
end
def self.execute(command, fail_on_error = true)
status = ExecutionStatus.new
status.detailedStatus = Open4::popen4(command) do |pid, stdin, stdout, stderr|
BigBlueButton.logger.info("Executing: #{command}")
status.output = stdout.readlines
BigBlueButton.logger.info("Output: #{Array(status.output).join} ") unless status.output.empty?
status.errors = stderr.readlines
unless status.errors.empty?
BigBlueButton.logger.error("Error: stderr: #{Array(status.errors).join}")
BigBlueButton.logger.info("Executing: #{command.respond_to?(:to_ary) ? Shellwords.join(command) : command}")
IO.popen(command, err: %i[child out]) do |io|
io.each_line do |line|
BigBlueButton.logger.info(line.chomp)
end
end
status = $CHILD_STATUS
BigBlueButton.logger.info("Success?: #{status.success?}")
BigBlueButton.logger.info("Process exited? #{status.exited?}")
BigBlueButton.logger.info("Exit status: #{status.exitstatus}")
if status.success? == false and fail_on_error
raise "Execution failed"
end
raise 'Execution failed' if status.success? == false && fail_on_error
status
end
def self.exec_ret(*command)
BigBlueButton.logger.info "Executing: #{command.join(' ')}"
IO.popen([*command, :err => [:child, :out]]) do |io|
io.each_line do |line|
BigBlueButton.logger.info line.chomp
end
end
BigBlueButton.logger.info "Exit status: #{$?.exitstatus}"
return $?.exitstatus
execute(command, false).exitstatus
end
def self.exec_redirect_ret(outio, *command)
BigBlueButton.logger.info "Executing: #{command.join(' ')}"
BigBlueButton.logger.info "Executing: #{Shellwords.join(command)}"
BigBlueButton.logger.info "Sending output to #{outio}"
IO.pipe do |r, w|
pid = spawn(*command, :out => outio, :err => w)

View File

@ -22,7 +22,7 @@ from lxml import etree
from collections import deque
from fractions import Fraction
import io
from icu import Locale, BreakIterator
from icu import Locale, BreakIterator, UnicodeString
import unicodedata
import html
import logging
@ -35,12 +35,14 @@ logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
def webvtt_timestamp(ms):
frac_s = int(ms % 1000)
s = int(ms / 1000 % 60)
m = int(ms / 1000 / 60 % 60)
h = int(ms / 1000 / 60 / 60)
return '{:02}:{:02}:{:02}.{:03}'.format(h, m, s, frac_s)
return "{:02}:{:02}:{:02}.{:03}".format(h, m, s, frac_s)
class CaptionLine:
def __init__(self):
@ -48,10 +50,11 @@ class CaptionLine:
self.start_time = 0
self.end_time = 0
class Caption:
def __init__(self, locale):
self.locale = locale
self.text = list()
self.text = UnicodeString()
self.timestamps = list()
self._del_timestamps = list()
@ -63,24 +66,30 @@ class Caption:
else:
del_timestamp = self.timestamps[i]
self._del_timestamps[i] = del_timestamp
logger.debug("Removing text %s at %d:%d, del_ts: %d",
repr(''.join(self.text[i:j])), i, j, del_timestamp)
logger.debug(
"Removing text %s at %d:%d, del_ts: %d",
repr(str(self.text[i:j])),
i,
j,
del_timestamp,
)
if len(text) > 0:
logger.debug("Inserting text %s at %d:%d, ts: %d",
repr(''.join(text)), i, j, timestamp)
logger.debug(
"Inserting text %s at %d:%d, ts: %d", repr(str(text)), i, j, timestamp
)
if i < len(self.timestamps) and timestamp > self.timestamps[i]:
timestamp = self._del_timestamps[i]
if timestamp is None:
if i > 0:
timestamp = self.timestamps[i-1]
timestamp = self.timestamps[i - 1]
else:
timestamp = self.timestamps[i]
logger.debug("Out of order timestamps, using ts: %d", timestamp)
self._del_timestamps[i:j] = [del_timestamp] * len(text)
if (i < len(self._del_timestamps)):
if i < len(self._del_timestamps):
self._del_timestamps[i] = del_timestamp
self.text[i:j] = text
@ -94,9 +103,9 @@ class Caption:
stop_pos = 0
start_pos = None
for event in events:
if event['name'] == 'record_status':
status = event['status']
timestamp = event['timestamp']
if event["name"] == "record_status":
status = event["status"]
timestamp = event["timestamp"]
if status and not record:
record = True
@ -106,13 +115,14 @@ class Caption:
# Find the position of the first character after recording
# started
start_pos = stop_pos
while start_pos < len(self.timestamps) and \
self.timestamps[start_pos] < start_ts:
while (
start_pos < len(self.timestamps)
and self.timestamps[start_pos] < start_ts
):
start_pos += 1
logger.debug("Replacing characters %d:%d",
stop_pos, start_pos)
self.text[stop_pos:start_pos] = ["\n"]
logger.debug("Replacing characters %d:%d", stop_pos, start_pos)
self.text[stop_pos:start_pos] = "\n"
self.timestamps[stop_pos:start_pos] = [stop_ts - ts_offset]
start_pos = stop_pos + 1
@ -130,8 +140,10 @@ class Caption:
# Find the position of the first character after recording
# stopped, and apply ts offsets
stop_pos = start_pos
while stop_pos < len(self.timestamps) and \
self.timestamps[stop_pos] < stop_ts:
while (
stop_pos < len(self.timestamps)
and self.timestamps[stop_pos] < stop_ts
):
self.timestamps[stop_pos] -= ts_offset
stop_pos += 1
@ -149,17 +161,16 @@ class Caption:
# Apply all of the caption events to generate the full text
# with per-character timestamps
for event in events:
if event['name'] == 'edit_caption_history':
locale = event['locale']
i = event['start_index']
j = event['end_index']
timestamp = event['timestamp']
text = event['text']
if event["name"] == "edit_caption_history":
locale = event["locale"]
i = event["start_index"]
j = event["end_index"]
timestamp = event["timestamp"]
text = UnicodeString(event["text"])
caption = captions.get(locale)
if caption is None:
logger.info("Started caption stream for locale '%s'",
locale)
logger.info("Started caption stream for locale '%s'", locale)
captions[locale] = caption = cls(locale)
caption.apply_edit(i, j, timestamp, text)
@ -175,15 +186,12 @@ class Caption:
def split_lines(self, max_length=32):
lines = list()
str_text = "".join(self.text)
locale = Locale(self.locale)
logger.debug("Using locale %s for word-wrapping",
locale.getDisplayName(locale))
logger.debug("Using locale %s for word-wrapping", locale.getDisplayName(locale))
break_iter = BreakIterator.createLineInstance(locale)
break_iter.setText(str_text)
break_iter.setText(self.text)
line = CaptionLine()
line_start = 0
prev_break = 0
@ -194,39 +202,45 @@ class Caption:
status = break_iter.getRuleStatus()
line_end = next_break
while line_end > line_start and ( \
self.text[line_end-1].isspace() or \
unicodedata.category(self.text[line_end-1]) in ['Cc', 'Mn']
):
logger.debug("text len: %d, line end: %d", len(self.text), line_end)
while line_end > line_start and (
self.text[line_end - 1].isspace()
or unicodedata.category(self.text[line_end - 1]) in ["Cc", "Mn"]
):
line_end -= 1
do_break = False
text_section = unicodedata.normalize(
'NFC', "".join(self.text[line_start:line_end]))
"NFC", str(self.text[line_start:line_end])
)
timestamps_section = self.timestamps[line_start:next_break]
start_time = min(timestamps_section)
end_time = max(timestamps_section)
if len(text_section) > max_length:
if prev_break == line_start:
# Over-long string. Just chop it into bits
line_end = next_break = prev_break + max_length
next_break = prev_break + max_length
continue
else:
next_break = prev_break
do_break = True
else:
# Status [100,200) indicates a required (hard) line break
if next_break >= len(self.text) or \
(status >= 100 and status < 200):
if next_break >= len(self.text) or (status >= 100 and status < 200):
line.text = text_section
line.start_time = start_time
line.end_time = end_time
do_break = True
if do_break:
logger.debug("text section %d -> %d (%d): %s",
line.start_time, line.end_time,
len(line.text), repr(line.text))
logger.debug(
"text section %d -> %d (%d): %s",
line.start_time,
line.end_time,
len(line.text),
repr(line.text),
)
lines.append(line)
line = CaptionLine()
line_start = next_break
@ -242,7 +256,7 @@ class Caption:
def write_webvtt(self, f):
# Write magic
f.write("WEBVTT\n\n".encode('utf-8'))
f.write("WEBVTT\n\n".encode("utf-8"))
lines = self.split_lines()
@ -297,49 +311,48 @@ class Caption:
if next_start_time - end_time < 500:
end_time = next_start_time
f.write("{} --> {}\n".format(
webvtt_timestamp(start_time),
webvtt_timestamp(end_time)
).encode('utf-8'))
f.write(html.escape(text, quote=False).encode('utf-8'))
f.write("\n\n".encode('utf-8'))
f.write(
"{} --> {}\n".format(
webvtt_timestamp(start_time), webvtt_timestamp(end_time)
).encode("utf-8")
)
f.write(html.escape(text, quote=False).encode("utf-8"))
f.write("\n\n".encode("utf-8"))
def caption_desc(self):
locale = Locale(self.locale)
return {
"locale": self.locale,
"localeName": locale.getDisplayName(locale)
}
return {"locale": self.locale, "localeName": locale.getDisplayName(locale)}
def parse_record_status(event, element):
userId = element.find('userId')
status = element.find('status')
userId = element.find("userId")
status = element.find("status")
event["name"] = "record_status"
event["user_id"] = userId.text
event["status"] = status.text == "true"
event['name'] = 'record_status'
event['user_id'] = userId.text
event['status'] = (status.text == 'true')
def parse_caption_edit(event, element):
locale = element.find('locale')
text = element.find('text')
startIndex = element.find('startIndex')
endIndex = element.find('endIndex')
localeCode = element.find('localeCode')
locale = element.find("locale")
text = element.find("text")
startIndex = element.find("startIndex")
endIndex = element.find("endIndex")
localeCode = element.find("localeCode")
event['name'] = 'edit_caption_history'
event['locale_name'] = locale.text
event["name"] = "edit_caption_history"
event["locale_name"] = locale.text
if localeCode is not None:
event['locale'] = localeCode.text
event["locale"] = localeCode.text
else:
# Fallback for missing 'localeCode'
event['locale'] = "en"
event["locale"] = "en"
if text.text is None:
event['text'] = list()
event["text"] = ""
else:
event['text'] = list(text.text)
event['start_index'] = int(startIndex.text)
event['end_index'] = int(endIndex.text)
event["text"] = text.text
event["start_index"] = int(startIndex.text)
event["end_index"] = int(endIndex.text)
def parse_events(directory="."):
@ -353,22 +366,22 @@ def parse_events(directory="."):
event = {}
# Convert timestamps to be in seconds from recording start
timestamp = int(element.attrib['timestamp'])
timestamp = int(element.attrib["timestamp"])
if not start_time:
start_time = timestamp
timestamp = timestamp - start_time
# Only need events from these modules
if not element.attrib['module'] in ['CAPTION','PARTICIPANT']:
if not element.attrib["module"] in ["CAPTION", "PARTICIPANT"]:
continue
event['name'] = name = element.attrib['eventname']
event['timestamp'] = timestamp
event["name"] = name = element.attrib["eventname"]
event["timestamp"] = timestamp
if name == 'RecordStatusEvent':
if name == "RecordStatusEvent":
parse_record_status(event, element)
have_record_events = True
elif name == 'EditCaptionHistoryEvent':
elif name == "EditCaptionHistoryEvent":
parse_caption_edit(event, element)
else:
logger.debug("Unhandled event: %s", name)
@ -381,25 +394,31 @@ def parse_events(directory="."):
if not have_record_events:
# Add a fake record start event to the events list
event = {
'name': 'record_status',
'user_id': None,
'timestamp': 0,
'status': True
}
"name": "record_status",
"user_id": None,
"timestamp": 0,
"status": True,
}
events.appendleft(event)
return events
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Generate WebVTT files from BigBlueButton captions",
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("-i", "--input", metavar="PATH",
help="input directory with events.xml file",
default=os.curdir)
parser.add_argument("-o", "--output", metavar="PATH",
help="output directory",
default=os.curdir)
description="Generate WebVTT files from BigBlueButton captions",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"-i",
"--input",
metavar="PATH",
help="input directory with events.xml file",
default=os.curdir,
)
parser.add_argument(
"-o", "--output", metavar="PATH", help="output directory", default=os.curdir
)
args = parser.parse_args()
rawdir = args.input
@ -419,6 +438,6 @@ if __name__ == "__main__":
filename = os.path.join(outputdir, "captions.json")
logger.info("Writing captions index file to %s", filename)
caption_descs = [ caption.caption_desc() for caption in captions.values() ]
caption_descs = [caption.caption_desc() for caption in captions.values()]
with open(filename, "w") as f:
json.dump(caption_descs, f)

View File

@ -597,11 +597,15 @@ def events_parse_shape(shapes, event, current_presentation, current_slide, times
shape[:type] == 'ellipse' or shape[:type] == 'triangle' or
shape[:type] == 'line'
shape[:color] = color_to_hex(event.at_xpath('color').text)
thickness = event.at_xpath('thickness').text
thickness = event.at_xpath('thickness')
unless thickness
BigBlueButton.logger.warn("Draw #{shape[:shape_id]} Shape #{shape[:shape_unique_id]} ID #{shape[:id]} is missing thickness")
return
end
if $version_atleast_2_0_0
shape[:thickness_percent] = thickness.to_f
shape[:thickness_percent] = thickness.text.to_f
else
shape[:thickness] = thickness.to_i
shape[:thickness] = thickness.text.to_i
end
end
if shape[:type] == 'rectangle'