Fixed Package conflicts

This commit is contained in:
Bobak Oftadeh 2018-06-25 10:01:34 -07:00
commit 5ee63a2aef
123 changed files with 5494 additions and 1531 deletions

View File

@ -303,7 +303,7 @@ public class VideoTranscoder extends UntypedActor implements ProcessMonitorObser
ffmpeg = new FFmpegCommand();
ffmpeg.setFFmpegPath(FFMPEG_PATH);
ffmpeg.setInput(input);
ffmpeg.setProtocolWhitelist("file,udp,rtp");
ffmpeg.setLoglevel("quiet");
ffmpeg.setOutput(outputLive);
ffmpeg.addRtmpOutputConnectionParameter(meetingId);
@ -570,7 +570,7 @@ public class VideoTranscoder extends UntypedActor implements ProcessMonitorObser
if(currentFFmpegRestartNumber == MAX_RESTARTINGS_NUMBER) {
long timeInterval = System.currentTimeMillis() - lastFFmpegRestartTime;
if(timeInterval <= MIN_RESTART_TIME) {
System.out.println(" > Max number of ffmpeg restartings reached in " + timeInterval + " miliseconds for " + transcoderId + "'s Video Transcoder." +
System.out.println(" > Max number of ffmpeg restartings reached in " + timeInterval + " miliseconds for " + transcoderId + "'s Video Transcoder." +
" Not restating it anymore.");
return true;
}

View File

@ -29,6 +29,8 @@ public class FFmpegCommand {
private int frameRate;
private String frameSize;
private String protocolWhitelist;
public FFmpegCommand() {
this.args = new HashMap();
this.x264Params = new HashMap();
@ -82,6 +84,11 @@ public class FFmpegCommand {
comm.add(probeSize);
}
if(protocolWhitelist != null && !protocolWhitelist.isEmpty()) {
comm.add("-protocol_whitelist");
comm.add(protocolWhitelist);
}
buildRtmpInput();
comm.add("-i");
@ -323,6 +330,14 @@ public class FFmpegCommand {
this.frameSize = value;
}
/**
* Sets protocol elements to be whitelisted
* @param whitelist
*/
public void setProtocolWhitelist(String whitelist) {
this.protocolWhitelist = whitelist;
}
/**
* Add parameters for rtmp connections.
* The order of parameters is the order they are added

View File

@ -62,6 +62,8 @@ import org.bigbluebutton.common.messages.SendStunTurnInfoReplyMessage;
import org.bigbluebutton.presentation.PresentationUrlDownloadService;
import org.bigbluebutton.api.messaging.messages.StunTurnInfoRequested;
import org.bigbluebutton.web.services.RegisteredUserCleanupTimerTask;
import org.bigbluebutton.web.services.callback.CallbackUrlService;
import org.bigbluebutton.web.services.callback.MeetingEndedEvent;
import org.bigbluebutton.web.services.turn.StunServer;
import org.bigbluebutton.web.services.turn.StunTurnService;
import org.bigbluebutton.web.services.turn.TurnEntry;
@ -89,6 +91,7 @@ public class MeetingService implements MessageListener {
private RegisteredUserCleanupTimerTask registeredUserCleaner;
private StunTurnService stunTurnService;
private RedisStorageService storeService;
private CallbackUrlService callbackUrlService;
private ParamsProcessorUtil paramsProcessorUtil;
private PresentationUrlDownloadService presDownloadService;
@ -561,7 +564,14 @@ public class MeetingService implements MessageListener {
Gson gson = new Gson();
String logStr = gson.toJson(logData);
log.info("Meeting destroyed: data={}", logStr);
log.info("Meeting ended: data={}", logStr);
String END_CALLBACK_URL = "endCallbackUrl".toLowerCase();
Map<String, String> metadata = m.getMetadata();
if (metadata.containsKey(END_CALLBACK_URL)) {
String callbackUrl = metadata.get(END_CALLBACK_URL);
callbackUrlService.handleMessage(new MeetingEndedEvent(callbackUrl));
}
processRemoveEndedMeeting(message);
@ -897,6 +907,10 @@ public class MeetingService implements MessageListener {
storeService = mess;
}
public void setCallbackUrlService(CallbackUrlService service) {
callbackUrlService = service;
}
public void setGw(IBbbWebApiGWApp gw) {
this.gw = gw;
}

View File

@ -477,11 +477,10 @@ public class RecordingService {
for (String recordID : recordIDs) {
for (Map.Entry<String, List<File>> entry : allDirectories.entrySet()) {
List<File> recs = getRecordingsForPath(recordID, entry.getValue());
// Lookup the target recording
Map<String,File> recsIndexed = indexRecordings(recs);
if ( recsIndexed.containsKey(recordID) ) {
File recFile = recsIndexed.get(recordID);
File metadataXml = recordingServiceHelper.getMetadataXmlLocation(recFile.getPath());
// Go through all recordings of all formats
for (File rec : recs) {
File metadataXml = recordingServiceHelper.getMetadataXmlLocation(rec.getPath());
updateRecordingMetadata(metadataXml, metaParams, metadataXml);
}
}

View File

@ -0,0 +1,153 @@
package org.bigbluebutton.web.services.callback;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.entity.ContentType;
import org.apache.http.impl.nio.client.CloseableHttpAsyncClient;
import org.apache.http.impl.nio.client.HttpAsyncClients;
import org.apache.http.nio.client.methods.HttpAsyncMethods;
import org.apache.http.nio.client.methods.ZeroCopyConsumer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.concurrent.*;
public class CallbackUrlService {
private static Logger log = LoggerFactory.getLogger(CallbackUrlService.class);
private BlockingQueue<ICallbackEvent> receivedMessages = new LinkedBlockingQueue<ICallbackEvent>();
private volatile boolean processMessage = false;
private final int maxRedirects = 5;
private final Executor msgProcessorExec = Executors.newSingleThreadExecutor();
private final Executor runExec = Executors.newSingleThreadExecutor();
public void stop() {
log.info("Stopping callback url service.");
processMessage = false;
}
public void start() {
log.info("Starting callback url service.");
try {
processMessage = true;
Runnable messageProcessor = new Runnable() {
public void run() {
while (processMessage) {
try {
ICallbackEvent msg = receivedMessages.take();
processMessage(msg);
} catch (InterruptedException e) {
log.warn("Error while taking received message from queue.");
}
}
}
};
msgProcessorExec.execute(messageProcessor);
} catch (Exception e) {
log.error("Error subscribing to channels: " + e.getMessage());
}
}
private void processMessage(final ICallbackEvent msg) {
Runnable task = new Runnable() {
public void run() {
fetchCallbackUrl(msg.getCallbackUrl());
}
};
runExec.execute(task);
}
public void handleMessage(ICallbackEvent message) {
receivedMessages.add(message);
}
private String followRedirect(String redirectUrl, int redirectCount, String origUrl) {
if (redirectCount > maxRedirects) {
log.error("Max redirect reached for callback url=[{}]", origUrl);
return null;
}
URL presUrl;
try {
presUrl = new URL(redirectUrl);
} catch (MalformedURLException e) {
log.error("Malformed callback url=[{}]", redirectUrl);
return null;
}
HttpURLConnection conn;
try {
conn = (HttpURLConnection) presUrl.openConnection();
conn.setReadTimeout(5000);
conn.addRequestProperty("Accept-Language", "en-US,en;q=0.8");
conn.addRequestProperty("User-Agent", "Mozilla");
// normally, 3xx is redirect
int status = conn.getResponseCode();
if (status != HttpURLConnection.HTTP_OK) {
if (status == HttpURLConnection.HTTP_MOVED_TEMP
|| status == HttpURLConnection.HTTP_MOVED_PERM
|| status == HttpURLConnection.HTTP_SEE_OTHER) {
String newUrl = conn.getHeaderField("Location");
return followRedirect(newUrl, redirectCount + 1, origUrl);
} else {
log.error("Invalid HTTP response=[{}] for callback url=[{}]", status, redirectUrl);
return null;
}
} else {
return redirectUrl;
}
} catch (IOException e) {
log.error("IOException for callback url=[{}]", redirectUrl);
return null;
}
}
private boolean fetchCallbackUrl(final String callbackUrl) {
log.info("Calling callback url {}", callbackUrl);
String finalUrl = followRedirect(callbackUrl, 0, callbackUrl);
if (finalUrl == null) return false;
boolean success = false;
CloseableHttpAsyncClient httpclient = HttpAsyncClients.createDefault();
try {
httpclient.start();
HttpGet request = new HttpGet(finalUrl);
Future<HttpResponse> future = httpclient.execute(request, null);
HttpResponse response = future.get();
success = response.getStatusLine().getStatusCode() == 200;
} catch (java.lang.InterruptedException ex) {
log.error("Interrupted exception while calling url {}", callbackUrl);
} catch (java.util.concurrent.ExecutionException ex) {
log.error("ExecutionException exception while calling url {}", callbackUrl);
} finally {
try {
httpclient.close();
} catch (java.io.IOException ex) {
log.error("IOException exception while closing http client for url {}", callbackUrl);
}
}
return success;
}
}

View File

@ -0,0 +1,5 @@
package org.bigbluebutton.web.services.callback;
public interface ICallbackEvent {
String getCallbackUrl();
}

View File

@ -0,0 +1,13 @@
package org.bigbluebutton.web.services.callback;
public class MeetingEndedEvent implements ICallbackEvent {
private final String callbackUrl;
public MeetingEndedEvent(String callbackUrl) {
this.callbackUrl = callbackUrl;
}
public String getCallbackUrl() {
return callbackUrl;
}
}

View File

@ -3,4 +3,5 @@
*.log
node_modules/
config_local.coffee
config_local.js
log/*

View File

@ -1 +1 @@
0.10.33
8.4.0

View File

@ -1,7 +1,5 @@
// This is a simple wrapper to run the app with 'node app.js'
require("coffee-script/register");
Application = require('./application.coffee');
Application = require('./application.js');
application = new Application();
application.start();

View File

@ -1,26 +0,0 @@
redis = require("redis")
config = require("./config")
Hook = require("./hook")
IDMapping = require("./id_mapping")
WebHooks = require("./web_hooks")
WebServer = require("./web_server")
# Class that defines the application. Listens for events on redis and starts the
# process to perform the callback calls.
# TODO: add port (-p) and log level (-l) to the command line args
module.exports = class Application
constructor: ->
# one for pub/sub, another to read/write data
config.redis.pubSubClient = redis.createClient()
config.redis.client = redis.createClient()
@webHooks = new WebHooks()
@webServer = new WebServer()
start: ->
Hook.initialize =>
IDMapping.initialize =>
@webServer.start(config.server.port)
@webHooks.start()

View File

@ -0,0 +1,38 @@
const config = require("./config.js");
const Hook = require("./hook.js");
const IDMapping = require("./id_mapping.js");
const WebHooks = require("./web_hooks.js");
const WebServer = require("./web_server.js");
const redis = require("redis");
const UserMapping = require("./userMapping.js");
const async = require("async");
// Class that defines the application. Listens for events on redis and starts the
// process to perform the callback calls.
// TODO: add port (-p) and log level (-l) to the command line args
module.exports = class Application {
constructor() {
config.redis.pubSubClient = redis.createClient();
config.redis.client = redis.createClient()
this.webHooks = new WebHooks();
this.webServer = new WebServer();
}
start(callback) {
Hook.initialize(() => {
UserMapping.initialize(() => {
IDMapping.initialize(()=> {
async.parallel([
(callback) => { this.webServer.start(config.server.port, callback) },
(callback) => { this.webServer.createPermanents(callback) },
(callback) => { this.webHooks.start(callback) }
], (err,results) => {
if(err != null) {}
typeof callback === 'function' ? callback() : undefined;
});
});
});
});
}
};

View File

@ -1,87 +0,0 @@
_ = require('lodash')
request = require("request")
url = require('url')
EventEmitter = require('events').EventEmitter
config = require("./config")
Logger = require("./logger")
Utils = require("./utils")
# Use to perform a callback. Will try several times until the callback is
# properly emitted and stop when successful (or after a given number of tries).
# Used to emit a single callback. Destroy it and create a new class for a new callback.
# Emits "success" on success, "failure" on error and "stopped" when gave up trying
# to perform the callback.
module.exports = class CallbackEmitter extends EventEmitter
constructor: (@callbackURL, @message) ->
@nextInterval = 0
@timestap = 0
start: ->
@timestamp = new Date().getTime()
@nextInterval = 0
@_scheduleNext 0
_scheduleNext: (timeout) ->
setTimeout( =>
@_emitMessage (error, result) =>
if not error? and result
@emit "success"
else
@emit "failure", error
# get the next interval we have to wait and schedule a new try
interval = config.hooks.retryIntervals[@nextInterval]
if interval?
Logger.warn "xx> Trying the callback again in #{interval/1000.0} secs"
@nextInterval++
@_scheduleNext(interval)
# no intervals anymore, time to give up
else
@nextInterval = 0
@emit "stopped"
, timeout)
_emitMessage: (callback) ->
# data to be sent
# note: keep keys in alphabetical order
data =
event: JSON.stringify(@message)
timestamp: @timestamp
# calculate the checksum
checksum = Utils.checksum("#{@callbackURL}#{JSON.stringify(data)}#{config.bbb.sharedSecret}")
# get the final callback URL, including the checksum
urlObj = url.parse(@callbackURL, true)
callbackURL = @callbackURL
callbackURL += if _.isEmpty(urlObj.search) then "?" else "&"
callbackURL += "checksum=#{checksum}"
requestOptions =
followRedirect: true
maxRedirects: 10
uri: callbackURL
method: "POST"
form: data
request requestOptions, (error, response, body) ->
if error? or not (response?.statusCode >= 200 and response?.statusCode < 300)
Logger.warn "xx> Error in the callback call to: [#{requestOptions.uri}] for #{simplifiedEvent(data.event)}"
Logger.warn "xx> Error:", error
Logger.warn "xx> Status:", response?.statusCode
callback error, false
else
Logger.info "==> Successful callback call to: [#{requestOptions.uri}] for #{simplifiedEvent(data.event)}"
callback null, true
# A simple string that identifies the event
simplifiedEvent = (event) ->
try
eventJs = JSON.parse(event)
"event: { name: #{eventJs.envelope?.name}, timestamp: #{eventJs.envelope?.timestamp} }"
catch e
"event: #{event}"

View File

@ -0,0 +1,138 @@
const _ = require('lodash');
const request = require("request");
const url = require('url');
const EventEmitter = require('events').EventEmitter;
const config = require("./config.js");
const Logger = require("./logger.js");
const Utils = require("./utils.js");
// Use to perform a callback. Will try several times until the callback is
// properly emitted and stop when successful (or after a given number of tries).
// Used to emit a single callback. Destroy it and create a new class for a new callback.
// Emits "success" on success, "failure" on error and "stopped" when gave up trying
// to perform the callback.
module.exports = class CallbackEmitter extends EventEmitter {
constructor(callbackURL, message, permanent) {
super();
this.callbackURL = callbackURL;
this.message = message;
this.nextInterval = 0;
this.timestap = 0;
this.permanent = false;
this.permanent = permanent;
}
start() {
this.timestamp = new Date().getTime();
this.nextInterval = 0;
this._scheduleNext(0);
}
_scheduleNext(timeout) {
setTimeout( () => {
this._emitMessage((error, result) => {
if ((error == null) && result) {
this.emit("success");
} else {
this.emit("failure", error);
// get the next interval we have to wait and schedule a new try
const interval = config.hooks.retryIntervals[this.nextInterval];
if (interval != null) {
Logger.warn(`[Emitter] trying the callback again in ${interval/1000.0} secs`);
this.nextInterval++;
this._scheduleNext(interval);
// no intervals anymore, time to give up
} else {
this.nextInterval = !this.permanent ? 0 : config.hooks.permanentIntervalReset; // Reset interval to permanent hooks
if(this.permanent){
this._scheduleNext(interval);
}
else {
return this.emit("stopped");
}
}
}
});
}
, timeout);
}
_emitMessage(callback) {
let data,requestOptions;
if (config.bbb.auth2_0) {
// Send data as a JSON
data = "[" + this.message + "]";
const callbackURL = this.callbackURL;
requestOptions = {
followRedirect: true,
maxRedirects: 10,
uri: callbackURL,
method: "POST",
form: data,
auth: {
bearer: config.bbb.sharedSecret
}
};
}
else {
// data to be sent
// note: keep keys in alphabetical order
data = {
event: "[" + this.message + "]",
timestamp: this.timestamp
};
// calculate the checksum
const checksum = Utils.checksum(`${this.callbackURL}${JSON.stringify(data)}${config.bbb.sharedSecret}`);
// get the final callback URL, including the checksum
const urlObj = url.parse(this.callbackURL, true);
let callbackURL = this.callbackURL;
callbackURL += _.isEmpty(urlObj.search) ? "?" : "&";
callbackURL += `checksum=${checksum}`;
requestOptions = {
followRedirect: true,
maxRedirects: 10,
uri: callbackURL,
method: "POST",
form: data
};
}
const responseFailed = (response) => {
var statusCode = (response != null ? response.statusCode : undefined)
return !((statusCode >= 200) && (statusCode < 300))
};
request(requestOptions, function(error, response, body) {
if ((error != null) || responseFailed(response)) {
Logger.warn(`[Emitter] error in the callback call to: [${requestOptions.uri}] for ${simplifiedEvent(data)}`, "error:", error, "status:", response != null ? response.statusCode : undefined);
callback(error, false);
} else {
Logger.info(`[Emitter] successful callback call to: [${requestOptions.uri}] for ${simplifiedEvent(data)}`);
callback(null, true);
}
});
}
};
// A simple string that identifies the event
var simplifiedEvent = function(event) {
if (event.event != null) {
event = event.event
}
try {
const eventJs = JSON.parse(event);
return `event: { name: ${(eventJs.data != null ? eventJs.data.id : undefined)}, timestamp: ${(eventJs.data.event != null ? eventJs.data.event.ts : undefined)} }`;
} catch (e) {
return `event: ${event}`;
}
};

View File

@ -1,115 +0,0 @@
# Global configuration file
# load the local configs
config = require("./config_local")
# BigBlueButton configs
config.bbb or= {}
config.bbb.sharedSecret or= "33e06642a13942004fd83b3ba6e4104a"
config.bbb.apiPath or= "/bigbluebutton/api"
# Web server configs
config.server or= {}
config.server.port or= 3005
# Web hooks configs
config.hooks or= {}
# Channels to subscribe to.
config.hooks.channels or= {
mainChannel: 'from-akka-apps-redis-channel',
rapChannel: 'bigbluebutton:from-rap'
}
# Filters to the events we want to generate callback calls for
config.hooks.events or= [
{ channel: config.hooks.channels.mainChannel, name: "MeetingCreatedEvtMsg" },
{ channel: config.hooks.channels.mainChannel, name: "MeetingEndedEvtMsg" },
{ channel: config.hooks.channels.mainChannel, name: "UserJoinedMeetingEvtMsg" },
{ channel: config.hooks.channels.mainChannel, name: "UserLeftMeetingEvtMsg" },
{ channel: config.hooks.channels.mainChannel, name: "UserJoinedVoiceConfToClientEvtMsg" },
{ channel: config.hooks.channels.mainChannel, name: "UserLeftVoiceConfToClientEvtMsg" },
{ channel: config.hooks.channels.mainChannel, name: "UserMutedVoiceEvtMsg" },
{ channel: config.hooks.channels.mainChannel, name: "UserBroadcastCamStartedEvtMsg" },
{ channel: config.hooks.channels.mainChannel, name: "UserBroadcastCamStoppedEvtMsg" },
{ channel: config.hooks.channels.mainChannel, name: "RecordingStatusChangedEvtMsg" },
{ channel: config.hooks.channels.rapChannel, name: "sanity_started" },
{ channel: config.hooks.channels.rapChannel, name: "sanity_ended" },
{ channel: config.hooks.channels.rapChannel, name: "archive_started" },
{ channel: config.hooks.channels.rapChannel, name: "archive_ended" },
{ channel: config.hooks.channels.rapChannel, name: "post_archive_started" },
{ channel: config.hooks.channels.rapChannel, name: "post_archive_ended" },
{ channel: config.hooks.channels.rapChannel, name: "process_started" },
{ channel: config.hooks.channels.rapChannel, name: "process_ended" },
{ channel: config.hooks.channels.rapChannel, name: "post_process_started" },
{ channel: config.hooks.channels.rapChannel, name: "post_process_ended" },
{ channel: config.hooks.channels.rapChannel, name: "publish_started" },
{ channel: config.hooks.channels.rapChannel, name: "publish_ended" },
{ channel: config.hooks.channels.rapChannel, name: "post_publish_started" },
{ channel: config.hooks.channels.rapChannel, name: "post_publish_ended" },
{ channel: config.hooks.channels.rapChannel, name: "unpublished" },
{ channel: config.hooks.channels.rapChannel, name: "published" },
{ channel: config.hooks.channels.rapChannel, name: "deleted" }
]
# Retry intervals for failed attempts for perform callback calls.
# In ms. Totals to around 5min.
config.hooks.retryIntervals = [
100, 500, 1000, 2000, 4000, 8000, 10000, 30000, 60000, 60000, 60000, 60000
]
# Mappings of internal to external meeting IDs
config.mappings = {}
config.mappings.cleanupInterval = 10000 # 10 secs, in ms
config.mappings.timeout = 1000*60*60*24 # 24 hours, in ms
# Redis
config.redis = {}
config.redis.keys = {}
config.redis.keys.hook = (id) -> "bigbluebutton:webhooks:hook:#{id}"
config.redis.keys.hooks = "bigbluebutton:webhooks:hooks"
config.redis.keys.mappings = "bigbluebutton:webhooks:mappings"
config.redis.keys.mapping = (id) -> "bigbluebutton:webhooks:mapping:#{id}"
config.api = {}
config.api.responses = {}
config.api.responses.failure = (key, msg) ->
"<response> \
<returncode>FAILED</returncode> \
<messageKey>#{key}</messageKey> \
<message>#{msg}</message> \
</response>"
config.api.responses.checksumError =
config.api.responses.failure("checksumError", "You did not pass the checksum security check.")
config.api.responses.createSuccess = (id) ->
"<response> \
<returncode>SUCCESS</returncode> \
<hookID>#{id}</hookID> \
</response>"
config.api.responses.createFailure =
config.api.responses.failure("createHookError", "An error happened while creating your hook. Check the logs.")
config.api.responses.createDuplicated = (id) ->
"<response> \
<returncode>SUCCESS</returncode> \
<hookID>#{id}</hookID> \
<messageKey>duplicateWarning</messageKey> \
<message>There is already a hook for this callback URL.</message> \
</response>"
config.api.responses.destroySuccess =
"<response> \
<returncode>SUCCESS</returncode> \
<removed>true</removed> \
</response>"
config.api.responses.destroyFailure =
config.api.responses.failure("destroyHookError", "An error happened while removing your hook. Check the logs.")
config.api.responses.destroyNoHook =
config.api.responses.failure("destroyMissingHook", "The hook informed was not found.")
config.api.responses.missingParamCallbackURL =
config.api.responses.failure("missingParamCallbackURL", "You must specify a callbackURL in the parameters.")
config.api.responses.missingParamHookID =
config.api.responses.failure("missingParamHookID", "You must specify a hookID in the parameters.")
module.exports = config

109
bbb-webhooks/config.js Normal file
View File

@ -0,0 +1,109 @@
// Global configuration file
// load the local configs
const config = require("./config_local.js");
// BigBlueButton configs
if (config.bbb == null) { config.bbb = {}; }
if (!config.bbb.sharedSecret) { config.bbb.sharedSecret = "sharedSecret"; }
if (!config.bbb.apiPath) { config.bbb.apiPath = "/bigbluebutton/api"; }
// Whether to use Auth2.0 or not, Auth2.0 sends the sharedSecret whithin an Authorization header as a bearer
// and data as JSON
if (config.bbb.auth2_0 == null) { config.bbb.auth2_0 = false; }
// Web server configs
if (!config.server) { config.server = {}; }
if (config.server.port == null) { config.server.port = 3005; }
// Web hooks configs
if (!config.hooks) { config.hooks = {}; }
if (!config.hooks.channels) {
config.hooks.channels = {
mainChannel: 'from-akka-apps-redis-channel',
rapChannel: 'bigbluebutton:from-rap',
chatChannel: 'from-akka-apps-chat-redis-channel'
}
}
// IP where permanent hook will post data (more than 1 URL means more than 1 permanent hook)
if (!config.hooks.permanentURLs) { config.hooks.permanentURLs = []; }
// How many messages will be enqueued to be processed at the same time
if (config.hooks.queueSize == null) { config.hooks.queueSize = 10000; }
// Allow permanent hooks to receive raw message, which is the message straight from BBB
if (config.hooks.getRaw == null) { config.hooks.getRaw = true; }
// If set to higher than 1, will send events on the format:
// "event=[{event1},{event2}],timestamp=000" or "[{event1},{event2}]" (based on using auth2_0 or not)
// when there are more than 1 event on the queue at the moment of processing the queue.
if (config.hooks.multiEvent == null) { config.hooks.multiEvent = 1; }
// Retry intervals for failed attempts for perform callback calls.
// In ms. Totals to around 5min.
config.hooks.retryIntervals = [
100, 500, 1000, 2000, 4000, 8000, 10000, 30000, 60000, 60000, 60000, 60000
];
// Reset permanent interval when exceeding maximum attemps
config.hooks.permanentURLsIntervalReset = 8;
// Mappings of internal to external meeting IDs
config.mappings = {};
config.mappings.cleanupInterval = 10000; // 10 secs, in ms
config.mappings.timeout = 1000*60*60*24; // 24 hours, in ms
// Redis
config.redis = {};
config.redis.keys = {};
config.redis.keys.hook = id => `bigbluebutton:webhooks:hook:${id}`;
config.redis.keys.hooks = "bigbluebutton:webhooks:hooks";
config.redis.keys.mappings = "bigbluebutton:webhooks:mappings";
config.redis.keys.mapping = id => `bigbluebutton:webhooks:mapping:${id}`;
config.redis.keys.events = id => `bigbluebutton:webhooks:events:${id}`;
config.redis.keys.userMaps = "bigbluebutton:webhooks:userMaps";
config.redis.keys.userMap = id => `bigbluebutton:webhooks:userMap:${id}`;
config.api = {};
config.api.responses = {};
config.api.responses.failure = (key, msg) =>
`<response> \
<returncode>FAILED</returncode> \
<messageKey>${key}</messageKey> \
<message>${msg}</message> \
</response>`
;
config.api.responses.checksumError =
config.api.responses.failure("checksumError", "You did not pass the checksum security check.");
config.api.responses.createSuccess = (id, permanent, getRaw) =>
`<response> \
<returncode>SUCCESS</returncode> \
<hookID>${id}</hookID> \
<permanentHook>${permanent}</permanentHook> \
<rawData>${getRaw}</rawData> \
</response>`
;
config.api.responses.createFailure =
config.api.responses.failure("createHookError", "An error happened while creating your hook. Check the logs.");
config.api.responses.createDuplicated = id =>
`<response> \
<returncode>SUCCESS</returncode> \
<hookID>${id}</hookID> \
<messageKey>duplicateWarning</messageKey> \
<message>There is already a hook for this callback URL.</message> \
</response>`
;
config.api.responses.destroySuccess =
`<response> \
<returncode>SUCCESS</returncode> \
<removed>true</removed> \
</response>`;
config.api.responses.destroyFailure =
config.api.responses.failure("destroyHookError", "An error happened while removing your hook. Check the logs.");
config.api.responses.destroyNoHook =
config.api.responses.failure("destroyMissingHook", "The hook informed was not found.");
config.api.responses.missingParamCallbackURL =
config.api.responses.failure("missingParamCallbackURL", "You must specify a callbackURL in the parameters.");
config.api.responses.missingParamHookID =
config.api.responses.failure("missingParamHookID", "You must specify a hookID in the parameters.");
module.exports = config;

View File

@ -1,33 +0,0 @@
# Local configuration file
config = {}
# Shared secret of your BigBlueButton server.
config.bbb = {}
config.bbb.sharedSecret = "33e06642a13942004fd83b3ba6e4104a"
# The port in which the API server will run.
config.server = {}
config.server.port = 3005
# Web hooks configs
config.hooks = {}
# Channels to subscribe to.
config.hooks.channels = {
mainChannel: 'from-akka-apps-redis-channel',
rapChannel: 'bigbluebutton:from-rap'
}
# Callbacks will be triggered for all the events in this list and only for these events.
# You only need to specify it if you want events that are not used by default or
# if you want to restrict the events used. See `config.coffee` for the default list.
#
# config.hooks.events = [
# { channel: config.hooks.channels.mainChannel, name: "MeetingCreatedEvtMsg" },
# { channel: config.hooks.channels.mainChannel, name: "MeetingEndedEvtMsg" },
# { channel: config.hooks.channels.mainChannel, name: "UserJoinedMeetingEvtMsg" },
# { channel: config.hooks.channels.mainChannel, name: "UserLeftMeetingEvtMsg" }
# ]
module.exports = config

View File

@ -0,0 +1,29 @@
// Local configuration file
const config = {};
// Shared secret of your BigBlueButton server.
config.bbb = {};
config.bbb.sharedSecret = "mysharedsecret";
// Whether to use Auth2.0 or not, Auth2.0 sends the sharedSecret whithin an Authorization header as a bearer
config.bbb.auth2_0 = false
// The port in which the API server will run.
config.server = {};
config.server.port = 3005;
// Callbacks will be triggered for all the events in this list and only for these events.
//config.hooks = {};
//config.hooks.channels = {
// mainChannel: 'from-akka-apps-redis-channel',
// rapChannel: 'bigbluebutton:from-rap',
// chatChannel: 'from-akka-apps-chat-redis-channel'
//}
// IP where permanent hook will post data (more than 1 URL means more than 1 permanent hook)
//config.hooks.permanentURLs = ["request.catcher.url", "another.request.catcher.url"]
// Allow global hook to receive all events with raw data
//config.hooks.getRaw = false;
module.exports = config;

View File

@ -2,8 +2,8 @@
// Uses the first meeting started after the application runs and will list all
// events, but only the first time they happen.
redis = require("redis");
const redis = require("redis");
const config = require('../config.js');
var target_meeting = null;
var events_printed = [];
var subscriber = redis.createClient();
@ -15,32 +15,25 @@ subscriber.on("psubscribe", function(channel, count) {
subscriber.on("pmessage", function(pattern, channel, message) {
try {
message = JSON.parse(message);
if (message !== null && message !== undefined && message.header !== undefined) {
if (message.hasOwnProperty('envelope')) {
var message_meeting_id = message.payload.meeting_id;
var message_name = message.header.name;
var message_name = message.envelope.name;
if (message_name === "meeting_created_message") {
if (target_meeting === null) {
target_meeting = message_meeting_id;
}
if (!containsOrAdd(events_printed, message_name)) {
console.log("\n###", message_name, "\n");
console.log(message);
console.log("\n");
}
if (target_meeting !== null && target_meeting === message_meeting_id) {
if (!containsOrAdd(events_printed, message_name)) {
console.log("\n###", message_name, "\n");
console.log(message);
console.log("\n");
}
}
}
} catch(e) {
console.log("error processing the message", message, ":", e);
}
});
subscriber.psubscribe("bigbluebutton:*");
for (let k in config.hooks.channels) {
const channel = config.hooks.channels[k];
subscriber.psubscribe(channel);
}
var containsOrAdd = function(list, value) {
for (i = 0; i <= list.length-1; i++) {

View File

@ -1,206 +0,0 @@
_ = require("lodash")
async = require("async")
redis = require("redis")
config = require("./config")
CallbackEmitter = require("./callback_emitter")
IDMapping = require("./id_mapping")
Logger = require("./logger")
# The database of hooks.
# Used always from memory, but saved to redis for persistence.
#
# Format:
# { id: Hook }
# Format on redis:
# * a SET "...:hooks" with all ids
# * a HASH "...:hook:<id>" for each hook with some of its attributes
db = {}
nextID = 1
# The representation of a hook and its properties. Stored in memory and persisted
# to redis.
# Hooks can be global, receiving callback calls for events from all meetings on the
# server, or for a specific meeting. If an `externalMeetingID` is set in the hook,
# it will only receive calls related to this meeting, otherwise it will be global.
# Events are kept in a queue to be sent in the order they are received.
# TODO: The queue should be cleared at some point. The hook is destroyed if too many
# callback attempts fail, after ~5min. So the queue is already protected in this case.
# But if the requests are going by but taking too long, the queue might be increasing
# faster than the callbacks are made.
module.exports = class Hook
constructor: ->
@id = null
@callbackURL = null
@externalMeetingID = null
@queue = []
@emitter = null
@redisClient = config.redis.client
save: (callback) ->
@redisClient.hmset config.redis.keys.hook(@id), @toRedis(), (error, reply) =>
Logger.error "Hook: error saving hook to redis!", error, reply if error?
@redisClient.sadd config.redis.keys.hooks, @id, (error, reply) =>
Logger.error "Hook: error saving hookID to the list of hooks!", error, reply if error?
db[@id] = this
callback?(error, db[@id])
destroy: (callback) ->
@redisClient.srem config.redis.keys.hooks, @id, (error, reply) =>
Logger.error "Hook: error removing hookID from the list of hooks!", error, reply if error?
@redisClient.del config.redis.keys.hook(@id), (error) =>
Logger.error "Hook: error removing hook from redis!", error if error?
if db[@id]
delete db[@id]
callback?(error, true)
else
callback?(error, false)
# Is this a global hook?
isGlobal: ->
not @externalMeetingID?
# The meeting from which this hook should receive events.
targetMeetingID: ->
@externalMeetingID
# Puts a new message in the queue. Will also trigger a processing in the queue so this
# message might be processed instantly.
enqueue: (message) ->
Logger.info "Hook: enqueueing message", JSON.stringify(message)
@queue.push message
@_processQueue()
toRedis: ->
r =
"hookID": @id,
"callbackURL": @callbackURL
r.externalMeetingID = @externalMeetingID if @externalMeetingID?
r
fromRedis: (redisData) ->
@id = parseInt(redisData.hookID)
@callbackURL = redisData.callbackURL
if redisData.externalMeetingID?
@externalMeetingID = redisData.externalMeetingID
else
@externalMeetingID = null
# Gets the first message in the queue and start an emitter to send it. Will only do it
# if there is no emitter running already and if there is a message in the queue.
_processQueue: ->
message = @queue[0]
return if not message? or @emitter?
@emitter = new CallbackEmitter(@callbackURL, message)
@emitter.start()
@emitter.on "success", =>
delete @emitter
@queue.shift() # pop the first message just sent
@_processQueue() # go to the next message
# gave up trying to perform the callback, remove the hook forever
@emitter.on "stopped", (error) =>
Logger.warn "Hook: too many failed attempts to perform a callback call, removing the hook for", @callbackURL
@destroy()
@addSubscription = (callbackURL, meetingID=null, callback) ->
hook = Hook.findByCallbackURLSync(callbackURL)
if hook?
callback?(new Error("There is already a subscription for this callback URL"), hook)
else
msg = "Hook: adding a hook with callback URL [#{callbackURL}]"
msg += " for the meeting [#{meetingID}]" if meetingID?
Logger.info msg
hook = new Hook()
hook.id = nextID++
hook.callbackURL = callbackURL
hook.externalMeetingID = meetingID
hook.save (error, hook) -> callback?(error, hook)
@removeSubscription = (hookID, callback) ->
hook = Hook.getSync(hookID)
if hook?
msg = "Hook: removing the hook with callback URL [#{hook.callbackURL}]"
msg += " for the meeting [#{hook.externalMeetingID}]" if hook.externalMeetingID?
Logger.info msg
hook.destroy (error, removed) -> callback?(error, removed)
else
callback?(null, false)
@countSync = ->
Object.keys(db).length
@getSync = (id) ->
db[id]
@firstSync = ->
keys = Object.keys(db)
if keys.length > 0
db[keys[0]]
else
null
@findByExternalMeetingIDSync = (externalMeetingID) ->
hooks = Hook.allSync()
_.filter(hooks, (hook) ->
(externalMeetingID? and externalMeetingID is hook.externalMeetingID)
)
@allGlobalSync = ->
hooks = Hook.allSync()
_.filter(hooks, (hook) -> hook.isGlobal())
@allSync = ->
arr = Object.keys(db).reduce((arr, id) ->
arr.push db[id]
arr
, [])
arr
@clearSync = ->
for id of db
delete db[id]
db = {}
@findByCallbackURLSync = (callbackURL) ->
for id of db
if db[id].callbackURL is callbackURL
return db[id]
@initialize = (callback) ->
Hook.resync(callback)
# Gets all hooks from redis to populate the local database.
# Calls `callback()` when done.
@resync = (callback) ->
client = config.redis.client
tasks = []
client.smembers config.redis.keys.hooks, (error, hooks) =>
Logger.error "Hook: error getting list of hooks from redis", error if error?
hooks.forEach (id) =>
tasks.push (done) =>
client.hgetall config.redis.keys.hook(id), (error, hookData) ->
Logger.error "Hook: error getting information for a hook from redis", error if error?
if hookData?
hook = new Hook()
hook.fromRedis(hookData)
hook.save (error, hook) ->
nextID = hook.id + 1 if hook.id >= nextID
done(null, hook)
else
done(null, null)
async.series tasks, (errors, result) ->
hooks = _.map(Hook.allSync(), (hook) -> "[#{hook.id}] #{hook.callbackURL}")
Logger.info "Hook: finished resync, hooks registered:", hooks
callback?()

322
bbb-webhooks/hook.js Normal file
View File

@ -0,0 +1,322 @@
const _ = require("lodash");
const async = require("async");
const redis = require("redis");
const config = require("./config.js");
const CallbackEmitter = require("./callback_emitter.js");
const IDMapping = require("./id_mapping.js");
const Logger = require("./logger.js");
// The database of hooks.
// Used always from memory, but saved to redis for persistence.
//
// Format:
// { id: Hook }
// Format on redis:
// * a SET "...:hooks" with all ids
// * a HASH "...:hook:<id>" for each hook with some of its attributes
let db = {};
let nextID = 1;
// The representation of a hook and its properties. Stored in memory and persisted
// to redis.
// Hooks can be global, receiving callback calls for events from all meetings on the
// server, or for a specific meeting. If an `externalMeetingID` is set in the hook,
// it will only receive calls related to this meeting, otherwise it will be global.
// Events are kept in a queue to be sent in the order they are received.
// But if the requests are going by but taking too long, the queue might be increasing
// faster than the callbacks are made. In this case the events will be concatenated
// and send up to 10 events in every post
module.exports = class Hook {
constructor() {
this.id = null;
this.callbackURL = null;
this.externalMeetingID = null;
this.queue = [];
this.emitter = null;
this.redisClient = config.redis.client;
this.permanent = false;
this.getRaw = false;
}
save(callback) {
this.redisClient.hmset(config.redis.keys.hook(this.id), this.toRedis(), (error, reply) => {
if (error != null) { Logger.error("[Hook] error saving hook to redis:", error, reply); }
this.redisClient.sadd(config.redis.keys.hooks, this.id, (error, reply) => {
if (error != null) { Logger.error("[Hook] error saving hookID to the list of hooks:", error, reply); }
db[this.id] = this;
(typeof callback === 'function' ? callback(error, db[this.id]) : undefined);
});
});
}
destroy(callback) {
this.redisClient.srem(config.redis.keys.hooks, this.id, (error, reply) => {
if (error != null) { Logger.error("[Hook] error removing hookID from the list of hooks:", error, reply); }
this.redisClient.del(config.redis.keys.hook(this.id), error => {
if (error != null) { Logger.error("[Hook] error removing hook from redis:", error); }
if (db[this.id]) {
delete db[this.id];
(typeof callback === 'function' ? callback(error, true) : undefined);
} else {
(typeof callback === 'function' ? callback(error, false) : undefined);
}
});
});
}
// Is this a global hook?
isGlobal() {
return (this.externalMeetingID == null);
}
// The meeting from which this hook should receive events.
targetMeetingID() {
return this.externalMeetingID;
}
// Puts a new message in the queue. Will also trigger a processing in the queue so this
// message might be processed instantly.
enqueue(message) {
this.redisClient.llen(config.redis.keys.events(this.id), (error, reply) => {
const length = reply;
if (length < config.hooks.queueSize && this.queue.length < config.hooks.queueSize) {
Logger.info(`[Hook] ${this.callbackURL} enqueueing message:`, JSON.stringify(message));
// Add message to redis queue
this.redisClient.rpush(config.redis.keys.events(this.id), JSON.stringify(message), (error,reply) => {
if (error != null) { Logger.error("[Hook] error pushing event to redis queue:", JSON.stringify(message), error); }
});
this.queue.push(JSON.stringify(message));
this._processQueue();
} else {
Logger.warn(`[Hook] ${this.callbackURL} queue size exceed, event:`, JSON.stringify(message));
}
});
}
toRedis() {
const r = {
"hookID": this.id,
"callbackURL": this.callbackURL,
"permanent": this.permanent,
"getRaw": this.getRaw
};
if (this.externalMeetingID != null) { r.externalMeetingID = this.externalMeetingID; }
return r;
}
fromRedis(redisData) {
this.id = parseInt(redisData.hookID);
this.callbackURL = redisData.callbackURL;
this.permanent = redisData.permanent.toLowerCase() == 'true';
this.getRaw = redisData.getRaw.toLowerCase() == 'true';
if (redisData.externalMeetingID != null) {
this.externalMeetingID = redisData.externalMeetingID;
} else {
this.externalMeetingID = null;
}
}
// Gets the first message in the queue and start an emitter to send it. Will only do it
// if there is no emitter running already and if there is a message in the queue.
_processQueue() {
// Will try to send up to a defined number of messages together if they're enqueued (defined on config.hooks.multiEvent)
const lengthIn = this.queue.length > config.hooks.multiEvent ? config.hooks.multiEvent : this.queue.length;
let num = lengthIn + 1;
// Concat messages
let message = this.queue.slice(0,lengthIn);
message = message.join(",");
if ((message == null) || (this.emitter != null) || (lengthIn <= 0)) { return; }
// Add params so emitter will 'know' when a hook is permanent and have backupURLs
this.emitter = new CallbackEmitter(this.callbackURL, message, this.permanent);
this.emitter.start();
this.emitter.on("success", () => {
delete this.emitter;
while ((num -= 1)) {
// Remove the sent message from redis
this.redisClient.lpop(config.redis.keys.events(this.id), (error, reply) => {
if (error != null) { return Logger.error("[Hook] error removing event from redis queue:", error); }
});
this.queue.shift();
} // pop the first message just sent
this._processQueue(); // go to the next message
});
// gave up trying to perform the callback, remove the hook forever if the hook's not permanent (emmiter will validate that)
return this.emitter.on("stopped", error => {
Logger.warn("[Hook] too many failed attempts to perform a callback call, removing the hook for:", this.callbackURL);
this.destroy();
});
}
static addSubscription(callbackURL, meetingID, getRaw, callback) {
let hook = Hook.findByCallbackURLSync(callbackURL);
if (hook != null) {
return (typeof callback === 'function' ? callback(new Error("There is already a subscription for this callback URL"), hook) : undefined);
} else {
let msg = `[Hook] adding a hook with callback URL: [${callbackURL}],`;
if (meetingID != null) { msg += ` for the meeting: [${meetingID}]`; }
Logger.info(msg);
hook = new Hook();
hook.callbackURL = callbackURL;
hook.externalMeetingID = meetingID;
hook.getRaw = getRaw;
hook.permanent = config.hooks.permanentURLs.some( url => {
return url === callbackURL
});
if (hook.permanent) {
hook.id = config.hooks.permanentURLs.indexOf(callbackURL) + 1;
nextID = config.hooks.permanentURLs.length + 1;
} else {
hook.id = nextID++;
}
// Sync permanent queue
if (hook.permanent) {
hook.redisClient.llen(config.redis.keys.events(hook.id), (error, len) => {
if (len > 0) {
const length = len;
hook.redisClient.lrange(config.redis.keys.events(hook.id), 0, len, (error, elements) => {
elements.forEach(element => {
hook.queue.push(element);
});
if (hook.queue.length > 0) { return hook._processQueue(); }
});
}
});
}
hook.save((error, hook) => { typeof callback === 'function' ? callback(error, hook) : undefined });
}
}
static removeSubscription(hookID, callback) {
let hook = Hook.getSync(hookID);
if (hook != null && !hook.permanent) {
let msg = `[Hook] removing the hook with callback URL: [${hook.callbackURL}],`;
if (hook.externalMeetingID != null) { msg += ` for the meeting: [${hook.externalMeetingID}]`; }
Logger.info(msg);
hook.destroy((error, removed) => { typeof callback === 'function' ? callback(error, removed) : undefined });
} else {
return (typeof callback === 'function' ? callback(null, false) : undefined);
}
}
static countSync() {
return Object.keys(db).length;
}
static getSync(id) {
return db[id];
}
static firstSync() {
const keys = Object.keys(db);
if (keys.length > 0) {
return db[keys[0]];
} else {
return null;
}
}
static findByExternalMeetingIDSync(externalMeetingID) {
const hooks = Hook.allSync();
return _.filter(hooks, hook => (externalMeetingID != null) && (externalMeetingID === hook.externalMeetingID));
}
static allGlobalSync() {
const hooks = Hook.allSync();
return _.filter(hooks, hook => hook.isGlobal());
}
static allSync() {
let arr = Object.keys(db).reduce(function(arr, id) {
arr.push(db[id]);
return arr;
}
, []);
return arr;
}
static clearSync() {
for (let id in db) {
delete db[id];
}
return db = {};
}
static findByCallbackURLSync(callbackURL) {
for (let id in db) {
if (db[id].callbackURL === callbackURL) {
return db[id];
}
}
}
static initialize(callback) {
Hook.resync(callback);
}
// Gets all hooks from redis to populate the local database.
// Calls `callback()` when done.
static resync(callback) {
let client = config.redis.client;
// Remove previous permanent hooks
for (let hk = 1; hk <= config.hooks.permanentURLs.length; hk++) {
client.srem(config.redis.keys.hooks, hk, (error, reply) => {
if (error != null) { Logger.error("[Hook] error removing previous permanent hook from list:", error); }
client.del(config.redis.keys.hook(hk), error => {
if (error != null) { Logger.error("[Hook] error removing previous permanent hook from redis:", error); }
});
});
}
let tasks = [];
client.smembers(config.redis.keys.hooks, (error, hooks) => {
if (error != null) { Logger.error("[Hook] error getting list of hooks from redis:", error); }
hooks.forEach(id => {
tasks.push(done => {
client.hgetall(config.redis.keys.hook(id), function(error, hookData) {
if (error != null) { Logger.error("[Hook] error getting information for a hook from redis:", error); }
if (hookData != null) {
let length;
let hook = new Hook();
hook.fromRedis(hookData);
// sync events queue
client.llen(config.redis.keys.events(hook.id), (error, len) => {
length = len;
client.lrange(config.redis.keys.events(hook.id), 0, len, (error, elements) => {
elements.forEach(element => {
hook.queue.push(element);
});
});
});
// Persist hook to redis
hook.save( (error, hook) => {
if (hook.id >= nextID) { nextID = hook.id + 1; }
if (hook.queue.length > 0) { hook._processQueue(); }
done(null, hook);
});
} else {
done(null, null);
}
});
});
});
async.series(tasks, function(errors, result) {
hooks = _.map(Hook.allSync(), hook => `[${hook.id}] ${hook.callbackURL}`);
Logger.info("[Hook] finished resync, hooks registered:", hooks);
(typeof callback === 'function' ? callback() : undefined);
});
});
}
};

View File

@ -1,163 +0,0 @@
_ = require("lodash")
async = require("async")
redis = require("redis")
config = require("./config")
Logger = require("./logger")
# The database of mappings. Uses the externalID as key because it changes less than
# the internal ID (e.g. the internalID can change for different meetings in the same
# room). Used always from memory, but saved to redis for persistence.
#
# Format:
# {
# externalMeetingID: {
# id: @id
# externalMeetingID: @exnternalMeetingID
# internalMeetingID: @internalMeetingID
# lastActivity: @lastActivity
# }
# }
# Format on redis:
# * a SET "...:mappings" with all ids (not meeting ids, the object id)
# * a HASH "...:mapping:<id>" for each mapping with all its attributes
db = {}
nextID = 1
# A simple model to store mappings for meeting IDs.
module.exports = class IDMapping
constructor: ->
@id = null
@externalMeetingID = null
@internalMeetingID = null
@lastActivity = null
@redisClient = config.redis.client
save: (callback) ->
@redisClient.hmset config.redis.keys.mapping(@id), @toRedis(), (error, reply) =>
Logger.error "Hook: error saving mapping to redis!", error, reply if error?
@redisClient.sadd config.redis.keys.mappings, @id, (error, reply) =>
Logger.error "Hook: error saving mapping ID to the list of mappings!", error, reply if error?
db[@externalMeetingID] = this
callback?(error, db[@externalMeetingID])
destroy: (callback) ->
@redisClient.srem config.redis.keys.mappings, @id, (error, reply) =>
Logger.error "Hook: error removing mapping ID from the list of mappings!", error, reply if error?
@redisClient.del config.redis.keys.mapping(@id), (error) =>
Logger.error "Hook: error removing mapping from redis!", error if error?
if db[@externalMeetingID]
delete db[@externalMeetingID]
callback?(error, true)
else
callback?(error, false)
toRedis: ->
r =
"id": @id,
"internalMeetingID": @internalMeetingID
"externalMeetingID": @externalMeetingID
"lastActivity": @lastActivity
r
fromRedis: (redisData) ->
@id = parseInt(redisData.id)
@externalMeetingID = redisData.externalMeetingID
@internalMeetingID = redisData.internalMeetingID
@lastActivity = redisData.lastActivity
print: ->
JSON.stringify(@toRedis())
@addOrUpdateMapping = (internalMeetingID, externalMeetingID, callback) ->
mapping = new IDMapping()
mapping.id = nextID++
mapping.internalMeetingID = internalMeetingID
mapping.externalMeetingID = externalMeetingID
mapping.lastActivity = new Date().getTime()
mapping.save (error, result) ->
Logger.info "IDMapping: added or changed meeting mapping to the list #{externalMeetingID}:", mapping.print()
callback?(error, result)
@removeMapping = (internalMeetingID, callback) ->
for external, mapping of db
if mapping.internalMeetingID is internalMeetingID
mapping.destroy (error, result) ->
Logger.info "IDMapping: removing meeting mapping from the list #{external}:", mapping.print()
callback?(error, result)
@getInternalMeetingID = (externalMeetingID) ->
db[externalMeetingID].internalMeetingID
@getExternalMeetingID = (internalMeetingID) ->
mapping = IDMapping.findByInternalMeetingID(internalMeetingID)
mapping?.externalMeetingID
@findByInternalMeetingID = (internalMeetingID) ->
if internalMeetingID?
for external, mapping of db
if mapping.internalMeetingID is internalMeetingID
return mapping
null
@allSync = ->
arr = Object.keys(db).reduce((arr, id) ->
arr.push db[id]
arr
, [])
arr
# Sets the last activity of the mapping for `internalMeetingID` to now.
@reportActivity = (internalMeetingID) ->
mapping = IDMapping.findByInternalMeetingID(internalMeetingID)
if mapping?
mapping.lastActivity = new Date().getTime()
mapping.save()
# Checks all current mappings for their last activity and removes the ones that
# are "expired", that had their last activity too long ago.
@cleanup = ->
now = new Date().getTime()
all = IDMapping.allSync()
toRemove = _.filter(all, (mapping) ->
mapping.lastActivity < now - config.mappings.timeout
)
unless _.isEmpty(toRemove)
Logger.info "IDMapping: expiring the mappings:", _.map(toRemove, (map) -> map.print())
toRemove.forEach (mapping) -> mapping.destroy()
# Initializes global methods for this model.
@initialize = (callback) ->
IDMapping.resync(callback)
IDMapping.cleanupInterval = setInterval(IDMapping.cleanup, config.mappings.cleanupInterval)
# Gets all mappings from redis to populate the local database.
# Calls `callback()` when done.
@resync = (callback) ->
client = config.redis.client
tasks = []
client.smembers config.redis.keys.mappings, (error, mappings) =>
Logger.error "Hook: error getting list of mappings from redis", error if error?
mappings.forEach (id) =>
tasks.push (done) =>
client.hgetall config.redis.keys.mapping(id), (error, mappingData) ->
Logger.error "Hook: error getting information for a mapping from redis", error if error?
if mappingData?
mapping = new IDMapping()
mapping.fromRedis(mappingData)
mapping.save (error, hook) ->
nextID = mapping.id + 1 if mapping.id >= nextID
done(null, mapping)
else
done(null, null)
async.series tasks, (errors, result) ->
mappings = _.map(IDMapping.allSync(), (m) -> m.print())
Logger.info "IDMapping: finished resync, mappings registered:", mappings
callback?()

215
bbb-webhooks/id_mapping.js Normal file
View File

@ -0,0 +1,215 @@
const _ = require("lodash");
const async = require("async");
const redis = require("redis");
const config = require("./config.js");
const Logger = require("./logger.js");
const UserMapping = require("./userMapping.js");
// The database of mappings. Uses the internal ID as key because it is unique
// unlike the external ID.
// Used always from memory, but saved to redis for persistence.
//
// Format:
// {
// internalMeetingID: {
// id: @id
// externalMeetingID: @externalMeetingID
// internalMeetingID: @internalMeetingID
// lastActivity: @lastActivity
// }
// }
// Format on redis:
// * a SET "...:mappings" with all ids (not meeting ids, the object id)
// * a HASH "...:mapping:<id>" for each mapping with all its attributes
const db = {};
let nextID = 1;
// A simple model to store mappings for meeting IDs.
module.exports = class IDMapping {
constructor() {
this.id = null;
this.externalMeetingID = null;
this.internalMeetingID = null;
this.lastActivity = null;
this.redisClient = config.redis.client;
}
save(callback) {
this.redisClient.hmset(config.redis.keys.mapping(this.id), this.toRedis(), (error, reply) => {
if (error != null) { Logger.error("[IDMapping] error saving mapping to redis:", error, reply); }
this.redisClient.sadd(config.redis.keys.mappings, this.id, (error, reply) => {
if (error != null) { Logger.error("[IDMapping] error saving mapping ID to the list of mappings:", error, reply); }
db[this.internalMeetingID] = this;
(typeof callback === 'function' ? callback(error, db[this.internalMeetingID]) : undefined);
});
});
}
destroy(callback) {
this.redisClient.srem(config.redis.keys.mappings, this.id, (error, reply) => {
if (error != null) { Logger.error("[IDMapping] error removing mapping ID from the list of mappings:", error, reply); }
this.redisClient.del(config.redis.keys.mapping(this.id), error => {
if (error != null) { Logger.error("[IDMapping] error removing mapping from redis:", error); }
if (db[this.internalMeetingID]) {
delete db[this.internalMeetingID];
(typeof callback === 'function' ? callback(error, true) : undefined);
} else {
(typeof callback === 'function' ? callback(error, false) : undefined);
}
});
});
}
toRedis() {
const r = {
"id": this.id,
"internalMeetingID": this.internalMeetingID,
"externalMeetingID": this.externalMeetingID,
"lastActivity": this.lastActivity
};
return r;
}
fromRedis(redisData) {
this.id = parseInt(redisData.id);
this.externalMeetingID = redisData.externalMeetingID;
this.internalMeetingID = redisData.internalMeetingID;
this.lastActivity = redisData.lastActivity;
}
print() {
return JSON.stringify(this.toRedis());
}
static addOrUpdateMapping(internalMeetingID, externalMeetingID, callback) {
let mapping = new IDMapping();
mapping.id = nextID++;
mapping.internalMeetingID = internalMeetingID;
mapping.externalMeetingID = externalMeetingID;
mapping.lastActivity = new Date().getTime();
mapping.save(function(error, result) {
Logger.info(`[IDMapping] added or changed meeting mapping to the list ${externalMeetingID}:`, mapping.print());
(typeof callback === 'function' ? callback(error, result) : undefined);
});
}
static removeMapping(internalMeetingID, callback) {
return (() => {
let result = [];
for (let internal in db) {
var mapping = db[internal];
if (mapping.internalMeetingID === internalMeetingID) {
result.push(mapping.destroy( (error, result) => {
Logger.info(`[IDMapping] removing meeting mapping from the list ${external}:`, mapping.print());
return (typeof callback === 'function' ? callback(error, result) : undefined);
}));
} else {
result.push(undefined);
}
}
return result;
})();
}
static getInternalMeetingID(externalMeetingID) {
const mapping = IDMapping.findByExternalMeetingID(externalMeetingID);
return (mapping != null ? mapping.internalMeetingID : undefined);
}
static getExternalMeetingID(internalMeetingID) {
if (db[internalMeetingID]){
return db[internalMeetingID].externalMeetingID;
}
}
static findByExternalMeetingID(externalMeetingID) {
if (externalMeetingID != null) {
for (let internal in db) {
const mapping = db[internal];
if (mapping.externalMeetingID === externalMeetingID) {
return mapping;
}
}
}
return null;
}
static allSync() {
let arr = Object.keys(db).reduce(function(arr, id) {
arr.push(db[id]);
return arr;
}
, []);
return arr;
}
// Sets the last activity of the mapping for `internalMeetingID` to now.
static reportActivity(internalMeetingID) {
let mapping = db[internalMeetingID];
if (mapping != null) {
mapping.lastActivity = new Date().getTime();
return mapping.save();
}
}
// Checks all current mappings for their last activity and removes the ones that
// are "expired", that had their last activity too long ago.
static cleanup() {
const now = new Date().getTime();
const all = IDMapping.allSync();
const toRemove = _.filter(all, mapping => mapping.lastActivity < (now - config.mappings.timeout));
if (!_.isEmpty(toRemove)) {
Logger.info("[IDMapping] expiring the mappings:", _.map(toRemove, map => map.print()));
toRemove.forEach(mapping => {
UserMapping.removeMappingMeetingId(mapping.internalMeetingID);
mapping.destroy()
});
}
}
// Initializes global methods for this model.
static initialize(callback) {
IDMapping.resync(callback);
IDMapping.cleanupInterval = setInterval(IDMapping.cleanup, config.mappings.cleanupInterval);
}
// Gets all mappings from redis to populate the local database.
// Calls `callback()` when done.
static resync(callback) {
let client = config.redis.client;
let tasks = [];
return client.smembers(config.redis.keys.mappings, (error, mappings) => {
if (error != null) { Logger.error("[IDMapping] error getting list of mappings from redis:", error); }
mappings.forEach(id => {
tasks.push(done => {
client.hgetall(config.redis.keys.mapping(id), function(error, mappingData) {
if (error != null) { Logger.error("[IDMapping] error getting information for a mapping from redis:", error); }
if (mappingData != null) {
let mapping = new IDMapping();
mapping.fromRedis(mappingData);
mapping.save(function(error, hook) {
if (mapping.id >= nextID) { nextID = mapping.id + 1; }
done(null, mapping);
});
} else {
done(null, null);
}
});
});
});
return async.series(tasks, function(errors, result) {
mappings = _.map(IDMapping.allSync(), m => m.print());
Logger.info("[IDMapping] finished resync, mappings registered:", mappings);
return (typeof callback === 'function' ? callback() : undefined);
});
});
}
};

View File

@ -1,10 +1,10 @@
winston = require("winston")
const winston = require("winston");
logger = new (winston.Logger)(
const logger = new (winston.Logger)({
transports: [
new (winston.transports.Console)({ timestamp: true, colorize: true }),
new (winston.transports.File)({ filename: "log/application.log", timestamp: true })
]
)
});
module.exports = logger
module.exports = logger;

View File

@ -0,0 +1,216 @@
const config = require("./config.js");
const Logger = require("./logger.js");
const IDMapping = require("./id_mapping.js");
const UserMapping = require("./userMapping.js");
module.exports = class MessageMapping {
constructor() {
this.mappedObject = {};
this.mappedMessage = {};
this.meetingEvents = ["MeetingCreatedEvtMsg","MeetingDestroyedEvtMsg"];
this.userEvents = ["UserJoinedMeetingEvtMsg","UserLeftMeetingEvtMsg","UserJoinedVoiceConfToClientEvtMsg","UserLeftVoiceConfToClientEvtMsg","PresenterAssignedEvtMsg", "PresenterUnassignedEvtMsg"];
this.chatEvents = ["SendPublicMessageEvtMsg","SendPrivateMessageEvtMsg"];
this.rapEvents = ["archive_started","archive_ended","sanity_started","sanity_ended","post_archive_started","post_archive_ended","process_started","process_ended","post_process_started","post_process_ended","publish_started","publish_ended","post_publish_started","post_publish_ended"];
}
// Map internal message based on it's type
mapMessage(messageObj) {
if (this.mappedEvent(messageObj,this.meetingEvents)) {
this.meetingTemplate(messageObj);
} else if (this.mappedEvent(messageObj,this.userEvents)) {
this.userTemplate(messageObj);
} else if (this.mappedEvent(messageObj,this.chatEvents)) {
this.chatTemplate(messageObj);
} else if (this.mappedEvent(messageObj,this.rapEvents)) {
this.rapTemplate(messageObj);
}
}
mappedEvent(messageObj,events) {
return events.some( event => {
if ((messageObj.header != null ? messageObj.header.name : undefined) === event) {
return true;
}
if ((messageObj.envelope != null ? messageObj.envelope.name : undefined) === event) {
return true;
}
return false;
});
}
// Map internal to external message for meeting information
meetingTemplate(messageObj) {
const props = messageObj.core.body.props;
this.mappedObject.data = {
"type": "event",
"id": this.mapInternalMessage(messageObj),
"attributes":{
"meeting":{
"internal-meeting-id": messageObj.core.body.meetingId,
"external-meeting-id": IDMapping.getExternalMeetingID(messageObj.core.body.meetingId)
}
},
"event":{
"ts": Date.now()
}
};
if (messageObj.envelope.name === "MeetingCreatedEvtMsg") {
this.mappedObject.data.attributes = {
"meeting":{
"internal-meeting-id": props.meetingProp.intId,
"external-meeting-id": props.meetingProp.extId,
"name": props.meetingProp.name,
"is-breakout": props.meetingProp.isBreakout,
"duration": props.durationProps.duration,
"create-time": props.durationProps.createdTime,
"create-date": props.durationProps.createdDate,
"moderator-pass": props.password.moderatorPass,
"viewer-pass": props.password.viewerPass,
"record": props.recordProp.record,
"voice-conf": props.voiceProp.voiceConf,
"dial-number": props.voiceProp.dialNumber,
"max-users": props.usersProp.maxUsers,
"metadata": props.metadataProp.metadata
}
};
}
this.mappedMessage = JSON.stringify(this.mappedObject);
Logger.info("[MessageMapping] Mapped message:", this.mappedMessage);
}
// Map internal to external message for user information
userTemplate(messageObj) {
const msgBody = messageObj.core.body;
const msgHeader = messageObj.core.header;
const extId = UserMapping.getExternalUserID(msgHeader.userId) ? UserMapping.getExternalUserID(msgHeader.userId) : msgBody.extId;
this.mappedObject.data = {
"type": "event",
"id": this.mapInternalMessage(messageObj),
"attributes":{
"meeting":{
"internal-meeting-id": messageObj.envelope.routing.meetingId,
"external-meeting-id": IDMapping.getExternalMeetingID(messageObj.envelope.routing.meetingId)
},
"user":{
"internal-user-id": msgHeader.userId,
"external-user-id": extId,
"sharing-mic": msgBody.muted,
"name": msgBody.name,
"role": msgBody.role,
"presenter": msgBody.presenter,
"stream": msgBody.stream,
"listening-only": msgBody.listenOnly
}
},
"event":{
"ts": Date.now()
}
};
this.mappedMessage = JSON.stringify(this.mappedObject);
Logger.info("[MessageMapping] Mapped message:", this.mappedMessage);
}
// Map internal to external message for chat information
chatTemplate(messageObj) {
const message = messageObj.core.body.message;
this.mappedObject.data = {
"type": "event",
"id": this.mapInternalMessage(messageObj),
"attributes":{
"meeting":{
"internal-meeting-id": messageObj.envelope.routing.meetingId,
"external-meeting-id": IDMapping.getExternalMeetingID(messageObj.envelope.routing.meetingId)
},
"chat-message":{
"message": message.message,
"sender":{
"internal-user-id": message.fromUserId,
"external-user-id": message.fromUsername,
"timezone-offset": message.fromTimezoneOffset,
"time": message.fromTime
}
}
},
"event":{
"ts": Date.now()
}
};
if (messageObj.envelope.name.indexOf("Private") !== -1) {
this.mappedObject.data.attributes["chat-message"].receiver = {
"internal-user-id": message.toUserId,
"external-user-id": message.toUsername
};
}
this.mappedMessage = JSON.stringify(this.mappedObject);
Logger.info("[MessageMapping] Mapped message:", this.mappedMessage);
}
rapTemplate(messageObj) {
data = messageObj.payload
this.mappedObject.data = {
"type": "event",
"id": this.mapInternalMessage(messageObj.header.name),
"attributes": {
"meeting": {
"internal-meeting-id": data.meeting_id,
"external-meeting-id": IDMapping.getExternalMeetingID(data.meeting_id)
},
"recording": {
"name": data.metadata.meetingName,
"isBreakout": data.metadata.isBreakout,
"startTime": data.startTime,
"endTime": data.endTime,
"size": data.playback.size,
"rawSize": data.rawSize,
"metadata": data.metadata,
"playback": data.playback,
"download": data.download
}
},
"event": {
"ts": messageObj.header.current_time
}
};
this.mappedMessage = JSON.stringify(this.mappedObject);
Logger.info("[MessageMapping] Mapped message:", this.mappedMessage);
}
mapInternalMessage(message) {
if (message.envelope) {
message = message.envelope.name
}
else if (message.header) {
message = message.header.name
}
const mappedMsg = (() => { switch (message) {
case "MeetingCreatedEvtMsg": return "meeting-created";
case "MeetingDestroyedEvtMsg": return "meeting-ended";
case "UserJoinedMeetingEvtMsg": return "user-joined";
case "UserLeftMeetingEvtMsg": return "user-left";
case "UserJoinedVoiceConfToClientEvtMsg": return "user-audio-voice-enabled";
case "UserLeftVoiceConfToClientEvtMsg": return "user-audio-voice-disabled";
case "UserBroadcastCamStartedEvtMsg": return "user-cam-broadcast-start";
case "UserBroadcastCamStoppedEvtMsg": return "user-cam-broadcast-end";
case "PresenterAssignedEvtMsg": return "user-presenter-assigned";
case "PresenterUnassignedEvtMsg": return "user-presenter-unassigned"
case "SendPublicMessageEvtMsg": return "chat-public-message-sent";
case "SendPrivateMessageEvtMsg": return "chat-private-message-sent";
case "archive_started": return "rap-archive-started";
case "archive_ended": return "rap-archive-ended";
case "sanity_started": return "rap-sanity-started";
case "sanity_ended": return "rap-sanity-ended";
case "post_archive_started": return "rap-post-archive-started";
case "post_archive_ended": return "rap-post-archive-ended";
case "process_started": return "rap-process-started";
case "process_ended": return "rap-process-ended";
case "post_process_started": return "rap-post-process-started";
case "post_process_ended": return "rap-post-process-ended";
case "publish_started": return "rap-publish-started";
case "publish_ended": return "rap-publish-ended";
case "post_publish_started": return "rap-post-publish-started";
case "post_publish_ended": return "rap-post-publish-ended";
} })();
return mappedMsg;
}
};

1256
bbb-webhooks/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -13,12 +13,23 @@
"coffee-script": "1.8.0",
"express": "4.10.2",
"lodash": "2.4.1",
"nock": "^9.0.14",
"redis": "0.12.1",
"request": "2.47.0",
"sha1": "1.1.0",
"winston": "0.8.3"
"sinon": "^3.2.1",
"winston": "0.8.3",
"xmldom": "^0.1.27",
"xpath": "0.0.24"
},
"engines": {
"node": "0.10.26"
"node": "8.4.0"
},
"devDependencies": {
"mocha": "^3.5.0",
"supertest": "^3.0.0"
},
"scripts": {
"test": "mocha"
}
}

View File

@ -0,0 +1,46 @@
const helpers = {};
helpers.url = 'http://10.0.3.179'; //serverUrl
helpers.port = ':3005'
helpers.callback = 'http://we2bh.requestcatcher.com'
helpers.callbackURL = '?callbackURL=' + helpers.callback
helpers.apiPath = '/bigbluebutton/api/hooks/'
helpers.createUrl = helpers.port + helpers.apiPath + 'create/' + helpers.callbackURL
helpers.destroyUrl = (id) => { return helpers.port + helpers.apiPath + 'destroy/' + '?hookID=' + id }
helpers.destroyPermanent = helpers.port + helpers.apiPath + 'destroy/' + '?hookID=1'
helpers.createRaw = '&getRaw=true'
helpers.listUrl = 'list/'
helpers.rawMessage = {
envelope: {
name: 'PresenterAssignedEvtMsg',
routing: {
msgType: 'BROADCAST_TO_MEETING',
meetingId: 'a674bb9c6ff92bfa6d5a0a1e530fabb56023932e-1509387833678',
userId: 'w_ysgy0erqgayc'
}
},
core: {
header: {
name: 'PresenterAssignedEvtMsg',
meetingId: 'a674bb9c6ff92bfa6d5a0a1e530fabb56023932e-1509387833678',
userId: 'w_ysgy0erqgayc'
},
body: {
presenterId: 'w_ysgy0erqgayc',
presenterName: 'User 4125097',
assignedBy: 'w_vlnwu1wkhena'
}
}
};
helpers.flushall = (rClient) => {
let client = rClient;
client.flushdb()
}
helpers.flushredis = (hook) => {
hook.redisClient.flushdb();
}
module.exports = helpers;

View File

@ -0,0 +1 @@
--timeout 5000

301
bbb-webhooks/test/test.js Normal file
View File

@ -0,0 +1,301 @@
const request = require('supertest');
const nock = require("nock");
const Application = require('../application.js');
const Logger = require('../logger.js');
const utils = require('../utils.js');
const config = require('../config.js');
const Hook = require('../hook.js');
const Helpers = require('./helpers.js')
const sinon = require('sinon');
const winston = require('winston');
// Block winston from logging
Logger.remove(winston.transports.Console);
describe('bbb-webhooks tests', () => {
before( (done) => {
config.hooks.queueSize = 10;
config.hooks.permanentURLs = ["http://wh.requestcatcher.com"];
application = new Application();
application.start( () => {
done();
});
});
beforeEach( (done) => {
hooks = Hook.allGlobalSync();
Helpers.flushall(config.redis.client);
hooks.forEach( hook => {
Helpers.flushredis(hook);
})
done();
})
after( () => {
hooks = Hook.allGlobalSync();
Helpers.flushall(config.redis.client);
hooks.forEach( hook => {
Helpers.flushredis(hook);
})
});
describe('GET /hooks/list permanent', () => {
it('should list permanent hook', (done) => {
let getUrl = utils.checksumAPI(Helpers.url + Helpers.listUrl, config.bbb.sharedSecret);
getUrl = Helpers.listUrl + '?checksum=' + getUrl
request(Helpers.url)
.get(getUrl)
.expect('Content-Type', /text\/xml/)
.expect(200, (res) => {
const hooks = Hook.allGlobalSync();
if (hooks && hooks.some( hook => { return hook.permanent }) ) {
done();
}
else {
done(new Error ("permanent hook was not created"));
}
})
})
});
describe('GET /hooks/create', () => {
after( (done) => {
const hooks = Hook.allGlobalSync();
Hook.removeSubscription(hooks[hooks.length-1].id, () => { done(); });
});
it('should create a hook', (done) => {
let getUrl = utils.checksumAPI(Helpers.url + Helpers.createUrl, config.bbb.sharedSecret);
getUrl = Helpers.createUrl + '&checksum=' + getUrl
request(Helpers.url)
.get(getUrl)
.expect('Content-Type', /text\/xml/)
.expect(200, (res) => {
const hooks = Hook.allGlobalSync();
if (hooks && hooks.some( hook => { return !hook.permanent }) ) {
done();
}
else {
done(new Error ("hook was not created"));
}
})
})
});
describe('GET /hooks/destroy', () => {
before( (done) => {
Hook.addSubscription(Helpers.callback,null,false,() => { done(); });
});
it('should destroy a hook', (done) => {
const hooks = Hook.allGlobalSync();
const hook = hooks[hooks.length-1].id;
let getUrl = utils.checksumAPI(Helpers.url + Helpers.destroyUrl(hook), config.bbb.sharedSecret);
getUrl = Helpers.destroyUrl(hook) + '&checksum=' + getUrl
request(Helpers.url)
.get(getUrl)
.expect('Content-Type', /text\/xml/)
.expect(200, (res) => {
const hooks = Hook.allGlobalSync();
if(hooks && hooks.every( hook => { return hook.callbackURL != Helpers.callback }))
done();
})
})
});
describe('GET /hooks/destroy permanent hook', () => {
it('should not destroy the permanent hook', (done) => {
let getUrl = utils.checksumAPI(Helpers.url + Helpers.destroyPermanent, config.bbb.sharedSecret);
getUrl = Helpers.destroyPermanent + '&checksum=' + getUrl
request(Helpers.url)
.get(getUrl)
.expect('Content-Type', /text\/xml/)
.expect(200, (res) => {
const hooks = Hook.allGlobalSync();
if (hooks && hooks[0].callbackURL == config.hooks.permanentURLs[0]) {
done();
}
else {
done(new Error("should not delete permanent"));
}
})
})
});
describe('GET /hooks/create getRaw hook', () => {
after( (done) => {
const hooks = Hook.allGlobalSync();
Hook.removeSubscription(hooks[hooks.length-1].id, () => { done(); });
});
it('should create a hook with getRaw=true', (done) => {
let getUrl = utils.checksumAPI(Helpers.url + Helpers.createUrl + Helpers.createRaw, config.bbb.sharedSecret);
getUrl = Helpers.createUrl + '&checksum=' + getUrl + Helpers.createRaw
request(Helpers.url)
.get(getUrl)
.expect('Content-Type', /text\/xml/)
.expect(200, (res) => {
const hooks = Hook.allGlobalSync();
if (hooks && hooks.some( (hook) => { return hook.getRaw })) {
done();
}
else {
done(new Error("getRaw hook was not created"))
}
})
})
});
describe('Hook queues', () => {
before( () => {
config.redis.pubSubClient.psubscribe("test-channel");
Hook.addSubscription(Helpers.callback,null,false, (err,reply) => {
const hooks = Hook.allGlobalSync();
const hook = hooks[0];
const hook2 = hooks[hooks.length -1];
sinon.stub(hook, '_processQueue');
sinon.stub(hook2, '_processQueue');
});
});
after( () => {
const hooks = Hook.allGlobalSync();
const hook = hooks[0];
const hook2 = hooks[hooks.length -1];
hook._processQueue.restore();
hook2._processQueue.restore();
Hook.removeSubscription(hooks[hooks.length-1].id);
config.redis.pubSubClient.unsubscribe("test-channel");
});
it('should have different queues for each hook', (done) => {
config.redis.client.publish("test-channel", JSON.stringify(Helpers.rawMessage));
const hooks = Hook.allGlobalSync();
if (hooks && hooks[0].queue != hooks[hooks.length-1].queue) {
done();
}
else {
done(new Error("hooks using same queue"))
}
})
});
// reduce queue size, fill queue with requests, try to add another one, if queue does not exceed, OK
describe('Hook queues', () => {
before( () => {
const hooks = Hook.allGlobalSync();
const hook = hooks[0];
sinon.stub(hook, '_processQueue');
});
after( () => {
const hooks = Hook.allGlobalSync();
const hook = hooks[0];
hook._processQueue.restore();
Helpers.flushredis(hook);
})
it('should limit queue size to defined in config', (done) => {
let hook = Hook.allGlobalSync();
hook = hook[0];
for(i=0;i<=9;i++) { hook.enqueue("message" + i); }
if (hook && hook.queue.length <= config.hooks.queueSize) {
done();
}
else {
done(new Error("hooks exceeded max queue size"))
}
})
});
describe('/POST mapped message', () => {
before( () => {
config.redis.pubSubClient.psubscribe("test-channel");
const hooks = Hook.allGlobalSync();
const hook = hooks[0];
hook.queue = [];
Helpers.flushredis(hook);
});
after( () => {
const hooks = Hook.allGlobalSync();
const hook = hooks[0];
Helpers.flushredis(hook);
config.redis.pubSubClient.unsubscribe("test-channel");
})
it('should post mapped message ', (done) => {
const hooks = Hook.allGlobalSync();
const hook = hooks[0];
const getpost = nock(config.hooks.permanentURLs[0])
.filteringRequestBody( (body) => {
let parsed = JSON.parse(body)
return parsed[0].data.id ? "mapped" : "not mapped";
})
.post("/", "mapped")
.reply(200, (res) => {
done();
});
config.redis.client.publish("test-channel", JSON.stringify(Helpers.rawMessage));
})
});
describe('/POST raw message', () => {
before( () => {
config.redis.pubSubClient.psubscribe("test-channel");
Hook.addSubscription(Helpers.callback,null,true, (err,hook) => {
Helpers.flushredis(hook);
})
});
after( () => {
const hooks = Hook.allGlobalSync();
Hook.removeSubscription(hooks[hooks.length-1].id);
Helpers.flushredis(hooks[hooks.length-1]);
config.redis.pubSubClient.unsubscribe("test-channel");
});
it('should post raw message ', (done) => {
const hooks = Hook.allGlobalSync();
const hook = hooks[0];
const getpost = nock(Helpers.callback)
.filteringRequestBody( (body) => {
if (body.indexOf("PresenterAssignedEvtMsg")) {
return "raw message";
}
else { return "not raw"; }
})
.post("/", "raw message")
.reply(200, () => {
done();
});
const permanent = nock(config.hooks.permanentURLs[0])
.post("/")
.reply(200)
config.redis.client.publish("test-channel", JSON.stringify(Helpers.rawMessage));
})
});
describe('/POST multi message', () => {
before( () =>{
const hooks = Hook.allGlobalSync();
const hook = hooks[0];
Helpers.flushredis(hook);
hook.queue = ["multiMessage1"];
});
it('should post multi message ', (done) => {
const hooks = Hook.allGlobalSync();
const hook = hooks[0];
hook.enqueue("multiMessage2")
const getpost = nock(config.hooks.permanentURLs[0])
.filteringPath( (path) => {
return path.split('?')[0];
})
.filteringRequestBody( (body) => {
if (body.indexOf("multiMessage1") != -1 && body.indexOf("multiMessage2") != -1) {
return "multiMess"
}
else {
return "not multi"
}
})
.post("/", "multiMess")
.reply(200, (res) => {
done();
});
})
});
});

189
bbb-webhooks/userMapping.js Normal file
View File

@ -0,0 +1,189 @@
const _ = require("lodash");
const async = require("async");
const redis = require("redis");
const config = require("./config.js");
const Logger = require("./logger.js");
// The database of mappings. Uses the internal ID as key because it is unique
// unlike the external ID.
// Used always from memory, but saved to redis for persistence.
//
// Format:
// {
// internalMeetingID: {
// id: @id
// externalMeetingID: @externalMeetingID
// internalMeetingID: @internalMeetingID
// lastActivity: @lastActivity
// }
// }
// Format on redis:
// * a SET "...:mappings" with all ids (not meeting ids, the object id)
// * a HASH "...:mapping:<id>" for each mapping with all its attributes
const db = {};
let nextID = 1;
// A simple model to store mappings for user extIDs.
module.exports = class UserMapping {
constructor() {
this.id = null;
this.externalUserID = null;
this.internalUserID = null;
this.meetingId = null;
this.redisClient = config.redis.client;
}
save(callback) {
this.redisClient.hmset(config.redis.keys.userMap(this.id), this.toRedis(), (error, reply) => {
if (error != null) { Logger.error("[UserMapping] error saving mapping to redis:", error, reply); }
this.redisClient.sadd(config.redis.keys.userMaps, this.id, (error, reply) => {
if (error != null) { Logger.error("[UserMapping] error saving mapping ID to the list of mappings:", error, reply); }
db[this.internalUserID] = this;
(typeof callback === 'function' ? callback(error, db[this.internalUserID]) : undefined);
});
});
}
destroy(callback) {
this.redisClient.srem(config.redis.keys.userMaps, this.id, (error, reply) => {
if (error != null) { Logger.error("[UserMapping] error removing mapping ID from the list of mappings:", error, reply); }
this.redisClient.del(config.redis.keys.userMap(this.id), error => {
if (error != null) { Logger.error("[UserMapping] error removing mapping from redis:", error); }
if (db[this.internalUserID]) {
delete db[this.internalUserID];
(typeof callback === 'function' ? callback(error, true) : undefined);
} else {
(typeof callback === 'function' ? callback(error, false) : undefined);
}
});
});
}
toRedis() {
const r = {
"id": this.id,
"internalUserID": this.internalUserID,
"externalUserID": this.externalUserID,
"meetingId": this.meetingId
};
return r;
}
fromRedis(redisData) {
this.id = parseInt(redisData.id);
this.externalUserID = redisData.externalUserID;
this.internalUserID = redisData.internalUserID;
this.meetingId = redisData.meetingId;
}
print() {
return JSON.stringify(this.toRedis());
}
static addMapping(internalUserID, externalUserID, meetingId, callback) {
let mapping = new UserMapping();
mapping.id = nextID++;
mapping.internalUserID = internalUserID;
mapping.externalUserID = externalUserID;
mapping.meetingId = meetingId;
mapping.save(function(error, result) {
Logger.info(`[UserMapping] added user mapping to the list ${internalUserID}:`, mapping.print());
(typeof callback === 'function' ? callback(error, result) : undefined);
});
}
static removeMapping(internalUserID, callback) {
return (() => {
let result = [];
for (let internal in db) {
var mapping = db[internal];
if (mapping.internalUserID === internalUserID) {
result.push(mapping.destroy( (error, result) => {
Logger.info(`[UserMapping] removing user mapping from the list ${internalUserID}:`, mapping.print());
return (typeof callback === 'function' ? callback(error, result) : undefined);
}));
} else {
result.push(undefined);
}
}
return result;
})();
}
static removeMappingMeetingId(meetingId, callback) {
return (() => {
let result = [];
for (let internal in db) {
var mapping = db[internal];
if (mapping.meetingId === meetingId) {
result.push(mapping.destroy( (error, result) => {
Logger.info(`[UserMapping] removing user mapping from the list ${mapping.internalUserID}:`, mapping.print());
}));
} else {
result.push(undefined);
}
}
return (typeof callback === 'function' ? callback() : undefined);
})();
}
static getExternalUserID(internalUserID) {
if (db[internalUserID]){
return db[internalUserID].externalUserID;
}
}
static allSync() {
let arr = Object.keys(db).reduce(function(arr, id) {
arr.push(db[id]);
return arr;
}
, []);
return arr;
}
// Initializes global methods for this model.
static initialize(callback) {
UserMapping.resync(callback);
}
// Gets all mappings from redis to populate the local database.
// Calls `callback()` when done.
static resync(callback) {
let client = config.redis.client;
let tasks = [];
return client.smembers(config.redis.keys.userMaps, (error, mappings) => {
if (error != null) { Logger.error("[UserMapping] error getting list of mappings from redis:", error); }
mappings.forEach(id => {
tasks.push(done => {
client.hgetall(config.redis.keys.userMap(id), function(error, mappingData) {
if (error != null) { Logger.error("[UserMapping] error getting information for a mapping from redis:", error); }
if (mappingData != null) {
let mapping = new UserMapping();
mapping.fromRedis(mappingData);
mapping.save(function(error, hook) {
if (mapping.id >= nextID) { nextID = mapping.id + 1; }
done(null, mapping);
});
} else {
done(null, null);
}
});
});
});
return async.series(tasks, function(errors, result) {
mappings = _.map(UserMapping.allSync(), m => m.print());
Logger.info("[UserMapping] finished resync, mappings registered:", mappings);
return (typeof callback === 'function' ? callback() : undefined);
});
});
}
};

View File

@ -1,62 +0,0 @@
sha1 = require("sha1")
url = require("url")
config = require("./config")
Utils = exports
# Calculates the checksum given a url `fullUrl` and a `salt`, as calculate by bbb-web.
Utils.checksumAPI = (fullUrl, salt) ->
query = Utils.queryFromUrl(fullUrl)
method = Utils.methodFromUrl(fullUrl)
Utils.checksum(method + query + salt)
# Calculates the checksum for a string.
# Just a wrapper for the method that actually does it.
Utils.checksum = (string) ->
sha1(string)
# Get the query of an API call from the url object (from url.parse())
# Example:
#
# * `fullUrl` = `http://bigbluebutton.org/bigbluebutton/api/create?name=Demo+Meeting&meetingID=Demo`
# * returns: `name=Demo+Meeting&meetingID=Demo`
Utils.queryFromUrl = (fullUrl) ->
# Returns the query without the checksum.
# We can't use url.parse() because it would change the encoding
# and the checksum wouldn't match. We need the url exactly as
# the client sent us.
query = fullUrl.replace(/&checksum=[^&]*/, '')
query = query.replace(/checksum=[^&]*&/, '')
query = query.replace(/checksum=[^&]*$/, '')
matched = query.match(/\?(.*)/)
if matched?
matched[1]
else
''
# Get the method name of an API call from the url object (from url.parse())
# Example:
#
# * `fullUrl` = `http://mconf.org/bigbluebutton/api/create?name=Demo+Meeting&meetingID=Demo`
# * returns: `create`
Utils.methodFromUrl = (fullUrl) ->
urlObj = url.parse(fullUrl, true)
urlObj.pathname.substr (config.bbb.apiPath + "/").length
# Returns the IP address of the client that made a request `req`.
# If can not determine the IP, returns `127.0.0.1`.
Utils.ipFromRequest = (req) ->
# the first ip in the list if the ip of the client
# the others are proxys between him and us
if req.headers?["x-forwarded-for"]?
ips = req.headers["x-forwarded-for"].split(",")
ipAddress = ips[0]?.trim()
# fallbacks
ipAddress ||= req.headers?["x-real-ip"] # when behind nginx
ipAddress ||= req.connection?.remoteAddress
ipAddress ||= "127.0.0.1"
ipAddress

68
bbb-webhooks/utils.js Normal file
View File

@ -0,0 +1,68 @@
const sha1 = require("sha1");
const url = require("url");
const config = require("./config");
const Utils = exports;
// Calculates the checksum given a url `fullUrl` and a `salt`, as calculate by bbb-web.
Utils.checksumAPI = function(fullUrl, salt) {
const query = Utils.queryFromUrl(fullUrl);
const method = Utils.methodFromUrl(fullUrl);
return Utils.checksum(method + query + salt);
};
// Calculates the checksum for a string.
// Just a wrapper for the method that actually does it.
Utils.checksum = string => sha1(string);
// Get the query of an API call from the url object (from url.parse())
// Example:
//
// * `fullUrl` = `http://bigbluebutton.org/bigbluebutton/api/create?name=Demo+Meeting&meetingID=Demo`
// * returns: `name=Demo+Meeting&meetingID=Demo`
Utils.queryFromUrl = function(fullUrl) {
// Returns the query without the checksum.
// We can't use url.parse() because it would change the encoding
// and the checksum wouldn't match. We need the url exactly as
// the client sent us.
let query = fullUrl.replace(/&checksum=[^&]*/, '');
query = query.replace(/checksum=[^&]*&/, '');
query = query.replace(/checksum=[^&]*$/, '');
const matched = query.match(/\?(.*)/);
if (matched != null) {
return matched[1];
} else {
return '';
}
};
// Get the method name of an API call from the url object (from url.parse())
// Example:
//
// * `fullUrl` = `http://mconf.org/bigbluebutton/api/create?name=Demo+Meeting&meetingID=Demo`
// * returns: `create`
Utils.methodFromUrl = function(fullUrl) {
const urlObj = url.parse(fullUrl, true);
return urlObj.pathname.substr((config.bbb.apiPath + "/").length);
};
// Returns the IP address of the client that made a request `req`.
// If can not determine the IP, returns `127.0.0.1`.
Utils.ipFromRequest = function(req) {
// the first ip in the list if the ip of the client
// the others are proxys between him and us
let ipAddress;
if ((req.headers != null ? req.headers["x-forwarded-for"] : undefined) != null) {
let ips = req.headers["x-forwarded-for"].split(",");
ipAddress = ips[0] != null ? ips[0].trim() : undefined;
}
// fallbacks
if (!ipAddress) { ipAddress = req.headers != null ? req.headers["x-real-ip"] : undefined; } // when behind nginx
if (!ipAddress) { ipAddress = req.connection != null ? req.connection.remoteAddress : undefined; }
if (!ipAddress) { ipAddress = "127.0.0.1"; }
return ipAddress;
};

View File

@ -1,117 +0,0 @@
_ = require("lodash")
async = require("async")
redis = require("redis")
request = require("request")
config = require("./config")
Hook = require("./hook")
IDMapping = require("./id_mapping")
Logger = require("./logger")
# Web hooks will listen for events on redis coming from BigBlueButton and
# perform HTTP calls with them to all registered hooks.
module.exports = class WebHooks
constructor: ->
@subscriberEvents = config.redis.pubSubClient
start: ->
@_subscribeToEvents()
# Subscribe to the events on pubsub that might need to be sent in callback calls.
_subscribeToEvents: ->
@subscriberEvents.on "psubscribe", (channel, count) ->
Logger.info "WebHooks: subscribed to " + channel
@subscriberEvents.on "pmessage", (pattern, channel, message) =>
processMessage = =>
if @_filterMessage(channel, message)
Logger.info "WebHooks: processing message on [#{channel}]:", JSON.stringify(message)
@_processEvent(message)
try
message = JSON.parse(message)
if message?
id = @_findMeetingId(message)
IDMapping.reportActivity(id)
# First treat meeting events to add/remove ID mappings
if message.envelope?.name is "MeetingCreatedEvtMsg"
Logger.info "WebHooks: got create message on meetings channel [#{channel}]", message
meetingProp = message.core?.body?.props?.meetingProp
if meetingProp
IDMapping.addOrUpdateMapping meetingProp.intId, meetingProp.extId, (error, result) ->
# has to be here, after the meeting was created, otherwise create calls won't generate
# callback calls for meeting hooks
processMessage()
# TODO: Temporarily commented because we still need the mapping for recording events,
# after the meeting ended.
# else if message.envelope?.name is "MeetingEndedEvtMessage"
# Logger.info "WebHooks: got destroy message on meetings channel [#{channel}]", message
# IDMapping.removeMapping @_findMeetingId(message), (error, result) ->
# processMessage()
else
processMessage()
catch e
Logger.error "WebHooks: error processing the message", message, ":", e
# Subscribe to the neccesary channels.
for k, channel of config.hooks.channels
@subscriberEvents.psubscribe channel
# Returns whether the message read from redis should generate a callback
# call or not.
_filterMessage: (channel, message) ->
messageName = @_messageNameFromChannel(channel, message)
for event in config.hooks.events
if channel? and messageName? and
event.channel.match(channel) and event.name.match(messageName)
return true
false
# BigBlueButton 2.0 changed where the message name is located, but it didn't
# change for the Record and Playback events. Thus, we need to handle both.
_messageNameFromChannel: (channel, message) ->
if channel == 'bigbluebutton:from-rap'
return message.header?.name
message.envelope?.name
# Find the meetingId in the message.
# This is neccesary because the new message in BigBlueButton 2.0 have
# their meetingId in different locations.
_findMeetingId: (message) ->
# Various 2.0 meetingId locations.
return message.envelope.routing.meetingId if message.envelope?.routing?.meetingId?
return message.header.body.meetingId if message.header?.body?.meetingId?
return message.core.body.meetingId if message.core?.body?.meetingId?
return message.core.body.props.meetingProp.intId if message.core?.body?.props?.meetingProp?.intId?
# Record and Playback 1.1 meeting_id location.
return message.payload.meeting_id if message.payload?.meeting_id?
return undefined
# Processes an event received from redis. Will get all hook URLs that
# should receive this event and start the process to perform the callback.
_processEvent: (message) ->
hooks = Hook.allGlobalSync()
# TODO: events that happen after the meeting ended will never trigger the hooks
# below, since the mapping is removed when the meeting ends
# filter the hooks that need to receive this event
# only global hooks or hooks for this specific meeting
# All the messages have the meetingId in different locations now.
# Depending on the event, it could appear within header, core or envelope.
# It always appears in atleast one, so we just need to search for it.
idFromMessage = @_findMeetingId(message)
if idFromMessage?
eMeetingID = IDMapping.getExternalMeetingID(idFromMessage)
hooks = hooks.concat(Hook.findByExternalMeetingIDSync(eMeetingID))
hooks.forEach (hook) ->
Logger.info "WebHooks: enqueueing a message in the hook:", hook.callbackURL
hook.enqueue message

151
bbb-webhooks/web_hooks.js Normal file
View File

@ -0,0 +1,151 @@
const _ = require("lodash");
const async = require("async");
const redis = require("redis");
const request = require("request");
const config = require("./config.js");
const Hook = require("./hook.js");
const IDMapping = require("./id_mapping.js");
const Logger = require("./logger.js");
const MessageMapping = require("./messageMapping.js");
const UserMapping = require("./userMapping.js");
// Web hooks will listen for events on redis coming from BigBlueButton and
// perform HTTP calls with them to all registered hooks.
module.exports = class WebHooks {
constructor() {
this.subscriberEvents = config.redis.pubSubClient;
}
start(callback) {
this._subscribeToEvents();
typeof callback === 'function' ? callback(null,"w") : undefined;
}
// Subscribe to the events on pubsub that might need to be sent in callback calls.
_subscribeToEvents() {
this.subscriberEvents.on("psubscribe", (channel, count) => Logger.info(`[WebHooks] subscribed to:${channel}`));
this.subscriberEvents.on("pmessage", (pattern, channel, message) => {
let raw;
const processMessage = () => {
Logger.info(`[WebHooks] processing message on [${channel}]:`, JSON.stringify(message));
this._processEvent(message, raw);
};
try {
raw = JSON.parse(message);
let messageMapped = new MessageMapping();
messageMapped.mapMessage(JSON.parse(message));
message = messageMapped.mappedObject;
if (!_.isEmpty(message) && !config.hooks.getRaw) {
const intId = message.data.attributes.meeting["internal-meeting-id"];
IDMapping.reportActivity(intId);
// First treat meeting events to add/remove ID mappings
switch (message.data.id) {
case "meeting-created":
Logger.info(`[WebHooks] got create message on meetings channel [${channel}]:`, message);
IDMapping.addOrUpdateMapping(intId, message.data.attributes.meeting["external-meeting-id"], (error, result) => {
// has to be here, after the meeting was created, otherwise create calls won't generate
// callback calls for meeting hooks
processMessage();
});
break;
case "user-joined":
UserMapping.addMapping(message.data.attributes.user["internal-user-id"],message.data.attributes.user["external-user-id"], intId, () => {
processMessage();
});
break;
case "user-left":
UserMapping.removeMapping(message.data.attributes.user["internal-user-id"], () => { processMessage(); });
break;
case "meeting-ended":
UserMapping.removeMappingMeetingId(intId, () => { processMessage(); });
break;
default:
processMessage();
}
} else {
this._processRaw(raw);
}
} catch (e) {
Logger.error("[WebHooks] error processing the message:", JSON.stringify(raw), ":", e);
}
});
for (let k in config.hooks.channels) {
const channel = config.hooks.channels[k];
this.subscriberEvents.psubscribe(channel);
}
}
// Send raw data to hooks that are not expecting mapped messages
_processRaw(message) {
let idFromMessage;
let hooks = Hook.allGlobalSync();
// Add hooks for the specific meeting that expect raw data
// Get meetingId for a raw message that was previously mapped by another webhook application or if it's straight from redis
idFromMessage = this._findMeetingID(message);
if (idFromMessage != null) {
const eMeetingID = IDMapping.getExternalMeetingID(idFromMessage);
hooks = hooks.concat(Hook.findByExternalMeetingIDSync(eMeetingID));
// Notify the hooks that expect raw data
async.forEach(hooks, (hook) => {
if (hook.getRaw) {
Logger.info("[WebHooks] enqueueing a raw message in the hook:", hook.callbackURL);
hook.enqueue(message);
}
});
} // Put foreach inside the if to avoid pingpong events
}
_findMeetingID(message) {
if (message.data) {
return message.data.attributes.meeting["internal-meeting-id"];
}
if (message.payload) {
return message.payload.meeting_id;
}
if (message.envelope && message.envelope.routing && message.envelope.routing.meetingId) {
return message.envelope.routing.meetingId;
}
if (message.header && message.header.body && message.header.body.meetingId) {
return message.header.body.meetingId;
}
if (message.core && message.core.body) {
return message.core.body.props ? message.core.body.props.meetingProp.intId : message.core.body.meetingId;
}
return undefined;
}
// Processes an event received from redis. Will get all hook URLs that
// should receive this event and start the process to perform the callback.
_processEvent(message, raw) {
// Get all global hooks
let hooks = Hook.allGlobalSync();
// filter the hooks that need to receive this event
// add hooks that are registered for this specific meeting
const idFromMessage = message.data != null ? message.data.attributes.meeting["internal-meeting-id"] : undefined;
if (idFromMessage != null) {
const eMeetingID = IDMapping.getExternalMeetingID(idFromMessage);
hooks = hooks.concat(Hook.findByExternalMeetingIDSync(eMeetingID));
}
// Notify every hook asynchronously, if hook N fails, it won't block hook N+k from receiving its message
async.forEach(hooks, (hook) => {
if (!hook.getRaw) {
Logger.info("[WebHooks] enqueueing a message in the hook:", hook.callbackURL);
hook.enqueue(message);
}
});
const sendRaw = hooks.some(hook => { return hook.getRaw });
if (sendRaw) {
this._processRaw(raw);
}
}
};

View File

@ -1,127 +0,0 @@
_ = require("lodash")
express = require("express")
url = require("url")
config = require("./config")
Hook = require("./hook")
Logger = require("./logger")
Utils = require("./utils")
# Web server that listens for API calls and process them.
module.exports = class WebServer
constructor: ->
@app = express()
@_registerRoutes()
start: (port) ->
@server = @app.listen(port)
unless @server.address()?
Logger.error "Could not bind to port", port
Logger.error "Aborting."
process.exit(1)
Logger.info "Server listening on port", port, "in", @app.settings.env.toUpperCase(), "mode"
_registerRoutes: ->
# Request logger
@app.all "*", (req, res, next) ->
unless fromMonit(req)
Logger.info "<==", req.method, "request to", req.url, "from:", clientDataSimple(req)
next()
@app.get "/bigbluebutton/api/hooks/create", @_validateChecksum, @_create
@app.get "/bigbluebutton/api/hooks/destroy", @_validateChecksum, @_destroy
@app.get "/bigbluebutton/api/hooks/list", @_validateChecksum, @_list
@app.get "/bigbluebutton/api/hooks/ping", (req, res) ->
res.write "bbb-webhooks up!"
res.end()
_create: (req, res, next) ->
urlObj = url.parse(req.url, true)
callbackURL = urlObj.query["callbackURL"]
meetingID = urlObj.query["meetingID"]
unless callbackURL?
respondWithXML(res, config.api.responses.missingParamCallbackURL)
else
Hook.addSubscription callbackURL, meetingID, (error, hook) ->
if error? # the only error for now is for duplicated callbackURL
msg = config.api.responses.createDuplicated(hook.id)
else if hook?
msg = config.api.responses.createSuccess(hook.id)
else
msg = config.api.responses.createFailure
respondWithXML(res, msg)
_destroy: (req, res, next) ->
urlObj = url.parse(req.url, true)
hookID = urlObj.query["hookID"]
unless hookID?
respondWithXML(res, config.api.responses.missingParamHookID)
else
Hook.removeSubscription hookID, (error, result) ->
if error?
msg = config.api.responses.destroyFailure
else if !result
msg = config.api.responses.destroyNoHook
else
msg = config.api.responses.destroySuccess
respondWithXML(res, msg)
_list: (req, res, next) ->
urlObj = url.parse(req.url, true)
meetingID = urlObj.query["meetingID"]
if meetingID?
# all the hooks that receive events from this meeting
hooks = Hook.allGlobalSync()
hooks = hooks.concat(Hook.findByExternalMeetingIDSync(meetingID))
hooks = _.sortBy(hooks, (hook) -> hook.id)
else
# no meetingID, return all hooks
hooks = Hook.allSync()
msg = "<response><returncode>SUCCESS</returncode><hooks>"
hooks.forEach (hook) ->
msg += "<hook>"
msg += "<hookID>#{hook.id}</hookID>"
msg += "<callbackURL><![CDATA[#{hook.callbackURL}]]></callbackURL>"
msg += "<meetingID><![CDATA[#{hook.externalMeetingID}]]></meetingID>" unless hook.isGlobal()
msg += "</hook>"
msg += "</hooks></response>"
respondWithXML(res, msg)
# Validates the checksum in the request `req`.
# If it doesn't match BigBlueButton's shared secret, will send an XML response
# with an error code just like BBB does.
_validateChecksum: (req, res, next) =>
urlObj = url.parse(req.url, true)
checksum = urlObj.query["checksum"]
if checksum is Utils.checksumAPI(req.url, config.bbb.sharedSecret)
next()
else
Logger.info "checksum check failed, sending a checksumError response"
res.setHeader("Content-Type", "text/xml")
res.send cleanupXML(config.api.responses.checksumError)
respondWithXML = (res, msg) ->
msg = cleanupXML(msg)
Logger.info "==> respond with:", msg
res.setHeader("Content-Type", "text/xml")
res.send msg
# Returns a simple string with a description of the client that made
# the request. It includes the IP address and the user agent.
clientDataSimple = (req) ->
"ip " + Utils.ipFromRequest(req) + ", using " + req.headers["user-agent"]
# Cleans up a string with an XML in it removing spaces and new lines from between the tags.
cleanupXML = (string) ->
string.trim().replace(/>\s*/g, '>')
# Was this request made by monit?
fromMonit = (req) ->
req.headers["user-agent"]? and req.headers["user-agent"].match(/^monit/)

172
bbb-webhooks/web_server.js Normal file
View File

@ -0,0 +1,172 @@
const _ = require("lodash");
const express = require("express");
const url = require("url");
const config = require("./config.js");
const Hook = require("./hook.js");
const Logger = require("./logger.js");
const Utils = require("./utils.js");
// Web server that listens for API calls and process them.
module.exports = class WebServer {
constructor() {
this._validateChecksum = this._validateChecksum.bind(this);
this.app = express();
this._registerRoutes();
}
start(port, callback) {
this.server = this.app.listen(port);
if (this.server.address() == null) {
Logger.error("[WebServer] aborting, could not bind to port", port,
process.exit(1));
}
Logger.info("[WebServer] listening on port", port, "in", this.app.settings.env.toUpperCase(), "mode");
typeof callback === 'function' ? callback(null,"k") : undefined;
}
_registerRoutes() {
// Request logger
this.app.all("*", function(req, res, next) {
if (!fromMonit(req)) {
Logger.info("[WebServer]", req.method, "request to", req.url, "from:", clientDataSimple(req));
}
next();
});
this.app.get("/bigbluebutton/api/hooks/create", this._validateChecksum, this._create);
this.app.get("/bigbluebutton/api/hooks/destroy", this._validateChecksum, this._destroy);
this.app.get("/bigbluebutton/api/hooks/list", this._validateChecksum, this._list);
this.app.get("/bigbluebutton/api/hooks/ping", function(req, res) {
res.write("bbb-webhooks up!");
res.end();
});
}
_create(req, res, next) {
const urlObj = url.parse(req.url, true);
const callbackURL = urlObj.query["callbackURL"];
const meetingID = urlObj.query["meetingID"];
let getRaw = urlObj.query["getRaw"];
if(getRaw){
getRaw = JSON.parse(getRaw.toLowerCase());
}
else getRaw = false
if (callbackURL == null) {
respondWithXML(res, config.api.responses.missingParamCallbackURL);
} else {
Hook.addSubscription(callbackURL, meetingID, getRaw, function(error, hook) {
let msg;
if (error != null) { // the only error for now is for duplicated callbackURL
msg = config.api.responses.createDuplicated(hook.id);
} else if (hook != null) {
msg = config.api.responses.createSuccess(hook.id, hook.permanent, hook.getRaw);
} else {
msg = config.api.responses.createFailure;
}
respondWithXML(res, msg);
});
}
}
// Create a permanent hook. Permanent hooks can't be deleted via API and will try to emit a message until it succeed
createPermanents(callback) {
for (let i = 0; i < config.hooks.permanentURLs.length; i++) {
Hook.addSubscription(config.hooks.permanentURLs[i], null, config.hooks.getRaw, function(error, hook) {
if (error != null) { // there probably won't be any errors here
Logger.info("[WebServer] duplicated permanent hook", error);
} else if (hook != null) {
Logger.info("[WebServer] permanent hook created successfully");
} else {
Logger.info("[WebServer] error creating permanent hook");
}
});
}
typeof callback === 'function' ? callback(null,"p") : undefined;
}
_destroy(req, res, next) {
const urlObj = url.parse(req.url, true);
const hookID = urlObj.query["hookID"];
if (hookID == null) {
respondWithXML(res, config.api.responses.missingParamHookID);
} else {
Hook.removeSubscription(hookID, function(error, result) {
let msg;
if (error != null) {
msg = config.api.responses.destroyFailure;
} else if (!result) {
msg = config.api.responses.destroyNoHook;
} else {
msg = config.api.responses.destroySuccess;
}
respondWithXML(res, msg);
});
}
}
_list(req, res, next) {
let hooks;
const urlObj = url.parse(req.url, true);
const meetingID = urlObj.query["meetingID"];
if (meetingID != null) {
// all the hooks that receive events from this meeting
hooks = Hook.allGlobalSync();
hooks = hooks.concat(Hook.findByExternalMeetingIDSync(meetingID));
hooks = _.sortBy(hooks, hook => hook.id);
} else {
// no meetingID, return all hooks
hooks = Hook.allSync();
}
let msg = "<response><returncode>SUCCESS</returncode><hooks>";
hooks.forEach(function(hook) {
msg += "<hook>";
msg += `<hookID>${hook.id}</hookID>`;
msg += `<callbackURL><![CDATA[${hook.callbackURL}]]></callbackURL>`;
if (!hook.isGlobal()) { msg += `<meetingID><![CDATA[${hook.externalMeetingID}]]></meetingID>`; }
msg += `<permanentHook>${hook.permanent}</permanentHook>`;
msg += `<rawData>${hook.getRaw}</rawData>`;
msg += "</hook>";
});
msg += "</hooks></response>";
respondWithXML(res, msg);
}
// Validates the checksum in the request `req`.
// If it doesn't match BigBlueButton's shared secret, will send an XML response
// with an error code just like BBB does.
_validateChecksum(req, res, next) {
const urlObj = url.parse(req.url, true);
const checksum = urlObj.query["checksum"];
if (checksum === Utils.checksumAPI(req.url, config.bbb.sharedSecret)) {
next();
} else {
Logger.info("[WebServer] checksum check failed, sending a checksumError response");
res.setHeader("Content-Type", "text/xml");
res.send(cleanupXML(config.api.responses.checksumError));
}
}
};
var respondWithXML = function(res, msg) {
msg = cleanupXML(msg);
Logger.info("[WebServer] respond with:", msg);
res.setHeader("Content-Type", "text/xml");
res.send(msg);
};
// Returns a simple string with a description of the client that made
// the request. It includes the IP address and the user agent.
var clientDataSimple = req => `ip ${Utils.ipFromRequest(req)}, using ${req.headers["user-agent"]}`;
// Cleans up a string with an XML in it removing spaces and new lines from between the tags.
var cleanupXML = string => string.trim().replace(/>\s*/g, '>');
// Was this request made by monit?
var fromMonit = req => (req.headers["user-agent"] != null) && req.headers["user-agent"].match(/^monit/);

View File

@ -6,7 +6,6 @@ bin/
bin-debug/
bin-release/
client/
locale/.tx/
bbbResources.properties.*
asdoc/
hs_err_pid*

View File

@ -1,3 +1,2 @@
.tx
ru/

View File

@ -0,0 +1,10 @@
[main]
host = https://www.transifex.com
[bigbluebutton.bbbresourcesproperties]
file_filter = <lang>/bbbResources.properties
minimum_perc = 0
source_file = en_US/bbbResources.properties
source_lang = en
type = UNICODEPROPERTIES

View File

@ -297,7 +297,7 @@ bbb.fileupload.progBarLbl = Progress:
bbb.fileupload.fileFormatHint = You can upload any Office or Portable Document Format (PDF) document. For the best result we recommend uploading a PDF.
bbb.fileupload.letUserDownload = Enable download of presentation
bbb.fileupload.letUserDownload.tooltip = Check here if you want the other users to download your presentation
bbb.fileupload.firefox60Warning.temp = This version of Firefox currently has an issue uploading documents (we are working with Mozilla to resolve). For the moment, please switch to a different browser (we recommend Chrome) to upload your documents.
bbb.fileupload.firefox60Warning.temp = This version of Firefox (FF60) currently has an issue uploading documents. The problem has been resolved in the next browser release (FF61). For the moment, please switch to a different browser (we recommend Chrome) or download the Firefox beta version to upload your documents.
bbb.filedownload.title = Download the Presentations
bbb.filedownload.close.tooltip = Close
bbb.filedownload.close.accessibilityName = Close file download window
@ -447,6 +447,7 @@ bbb.screensharePublish.WebRTCRetryExtensionInstallation.label = After you have i
bbb.screensharePublish.WebRTCExtensionFailFallback.label = Unable to detect screen sharing extension. Click here to try installing again.
bbb.screensharePublish.WebRTCPrivateBrowsingWarning.label = It seems you may be Incognito or using private browsing. Make sure under your extension settings you allow the extension the run in Incognito/private browsing.
bbb.screensharePublish.WebRTCExtensionInstallButton.label = Click here to install
bbb.screensharePublish.WebRTC.starting = Starting Screen Sharing
bbb.screensharePublish.sharingMessage= This is your screen being shared
bbb.screenshareView.title = Screen Sharing
bbb.screenshareView.fitToWindow = Fit to Window
@ -847,6 +848,8 @@ bbb.lockSettings.feature=Feature
bbb.lockSettings.locked=Locked
bbb.lockSettings.lockOnJoin=Lock On Join
bbb.users.meeting.closewarning.text = Meeting is closing in a minute.
bbb.users.breakout.breakoutRooms = Breakout Rooms
bbb.users.breakout.updateBreakoutRooms = Update Breakout Rooms
bbb.users.breakout.timerForRoom.toolTip = Time left for this breakout room

View File

@ -241,7 +241,7 @@ bbb.users.emojiStatus.speakFaster = Èske ou ta vle tanpri pale pi vit?
bbb.users.emojiStatus.speakSlower =
bbb.users.emojiStatus.beRightBack = Mwen pral dwa tounen
bbb.presentation.title = Prezantasyon
bbb.presentation.titleWithPres = Prezantasyon :(0)
bbb.presentation.titleWithPres = Prezantasyon: {0}
bbb.presentation.quickLink.label = Prezantasyon Window
bbb.presentation.fitToWidth.toolTip = Anfòm Prezantasyon pou Lajè
bbb.presentation.fitToPage.toolTip = Anfòm Prezantasyon pou Paj

View File

@ -21,11 +21,11 @@ package org.bigbluebutton.core {
import flash.events.TimerEvent;
import flash.utils.Dictionary;
import flash.utils.Timer;
import mx.controls.Alert;
import mx.controls.Label;
import mx.managers.PopUpManager;
import org.bigbluebutton.util.i18n.ResourceUtil;
public final class TimerUtil {
@ -43,7 +43,14 @@ package org.bigbluebutton.core {
var formattedTime:String = (Math.floor(remainingSeconds / 60)) + ":" + (remainingSeconds % 60 >= 10 ? "" : "0") + (remainingSeconds % 60);
label.text = formattedTime;
if (remainingSeconds < 60 && showMinuteWarning && !minuteWarningShown) {
minuteAlert = Alert.show(ResourceUtil.getInstance().getString('bbb.users.breakout.closewarning.text'));
// Check the label which timer is firing and display message accordingly.
var warnText: String = 'bbb.users.breakout.closewarning.text';
if (label.id == "breakoutTimeLabel") {
warnText = 'bbb.users.breakout.closewarning.text';
} else if (label.id == 'timeRemaining') {
warnText = 'bbb.users.meeting.closewarning.text';
}
minuteAlert = Alert.show(ResourceUtil.getInstance().getString(warnText));
minuteWarningShown = true;
}
});

View File

@ -205,6 +205,8 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
if (!timeRemaining.visible && e.timeLeftInSec <= 1800) {
timeRemaining.visible = true;
}
// The label.id is used to determine message to display. So make sure
// you change in the TimerUtil if you change the label.
TimerUtil.setCountDownTimer(timeRemaining, e.timeLeftInSec, true);
}

View File

@ -20,6 +20,7 @@ package org.bigbluebutton.modules.chat.model {
import org.as3commons.lang.StringUtils;
import org.bigbluebutton.common.Role;
import org.bigbluebutton.core.UsersUtil;
import org.bigbluebutton.core.model.users.User2x;
import org.bigbluebutton.util.i18n.ResourceUtil;
public class ChatMessage {
@ -43,16 +44,17 @@ package org.bigbluebutton.modules.chat.model {
[Bindable] public var senderTime:String;
*/
public function get differentLastSenderAndTime():Boolean {
return !(lastTime == time) || !sameLastSender;
public function sameLastTime():Boolean {
return lastTime == time;
}
public function get sameLastSender() : Boolean {
public function sameLastSender():Boolean {
return StringUtils.trimToEmpty(senderId) == StringUtils.trimToEmpty(lastSenderId);
}
public function get isModerator():Boolean {
return UsersUtil.getUser(senderId) && UsersUtil.getUser(senderId).role == Role.MODERATOR
public function isModerator():Boolean {
var user:User2x = UsersUtil.getUser(senderId);
return user && user.role == Role.MODERATOR
}
public function toString() : String {

View File

@ -19,9 +19,12 @@
package org.bigbluebutton.modules.chat.views
{
import flash.display.Sprite;
import flash.events.Event;
import mx.controls.List;
import mx.controls.listClasses.IListItemRenderer;
import mx.events.CollectionEvent;
import mx.events.CollectionEventKind;
import org.as3commons.logging.api.ILogger;
import org.as3commons.logging.api.getClassLogger;
@ -66,5 +69,19 @@ package org.bigbluebutton.modules.chat.views
public function get verticalScrollAtMax():Boolean {
return verticalScrollPosition == maxVerticalScrollPosition;
}
override protected function collectionChangeHandler(event:Event):void {
var previousVScroll:Number = verticalScrollPosition;
super.collectionChangeHandler(event);
if (event is CollectionEvent) {
var cEvent:CollectionEvent = CollectionEvent(event);
if (cEvent.kind == CollectionEventKind.REFRESH) {
verticalScrollPosition = previousVScroll;
}
}
}
}
}

View File

@ -52,11 +52,11 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
import flash.events.TextEvent;
import flashx.textLayout.formats.Direction;
import mx.binding.utils.BindingUtils;
import mx.events.ScrollEvent;
import flashx.textLayout.formats.Direction;
import org.as3commons.lang.StringUtils;
import org.as3commons.logging.api.ILogger;
import org.as3commons.logging.api.getClassLogger;
@ -80,6 +80,7 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
import org.bigbluebutton.modules.chat.events.SendPrivateChatMessageEvent;
import org.bigbluebutton.modules.chat.events.SendPublicChatMessageEvent;
import org.bigbluebutton.modules.chat.model.ChatConversation;
import org.bigbluebutton.modules.chat.model.ChatMessage;
import org.bigbluebutton.modules.chat.model.ChatOptions;
import org.bigbluebutton.modules.chat.vo.ChatMessageVO;
import org.bigbluebutton.modules.polling.events.StartCustomPollEvent;
@ -247,9 +248,8 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
private function handleUserLeftEvent(event:UserLeftEvent):void {
// Handle user leaving so that the user won't be talking to someone not there.
if (!publicChat && event.userID == chatWithUserID) {
displayUserHasLeftMessage();
addMessageAndScrollToEnd(createUserHasLeftMessage(), event.userID);
txtMsgArea.enabled = false;
scrollToEndOfMessage(event.userID);
}
}
@ -262,14 +262,13 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
private function handleUserJoinedEvent(event:UserJoinedEvent):void {
// Handle user joining so that the user can start to talk if the person rejoins
if (!publicChat && event.userID == chatWithUserID) {
displayUserHasJoinedMessage();
addMessageAndScrollToEnd(createUserHasJoinedMessage(), event.userID);
txtMsgArea.enabled = true;
scrollToEndOfMessage(event.userID);
}
}
private var SPACE:String = " ";
private function displayUserHasLeftMessage():void {
private function createUserHasLeftMessage():ChatMessageVO {
var msg:ChatMessageVO = new ChatMessageVO();
msg.fromUserId = SPACE;
msg.fromUsername = SPACE;
@ -280,10 +279,10 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
msg.toUsername = SPACE;
msg.message = "<b><i>"+ResourceUtil.getInstance().getString('bbb.chat.private.userLeft')+"</b></i>";
chatMessages.newChatMessage(msg);
return msg;
}
private function displayUserHasJoinedMessage():void {
private function createUserHasJoinedMessage():ChatMessageVO {
var msg:ChatMessageVO = new ChatMessageVO();
msg.fromUserId = SPACE;
msg.fromUsername = SPACE;
@ -294,7 +293,7 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
msg.toUsername = SPACE;
msg.message = "<b><i>"+ResourceUtil.getInstance().getString('bbb.chat.private.userJoined')+"</b></i>";
chatMessages.newChatMessage(msg);
return msg;
}
public function focusToTextMessageArea():void {
@ -304,8 +303,7 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
private function handlePublicChatMessageEvent(event:PublicChatMessageEvent):void {
if (publicChat) {
chatMessages.newChatMessage(event.message);
scrollToEndOfMessage(event.message.fromUserId);
addMessageAndScrollToEnd(event.message, event.message.fromUserId);
}
}
@ -327,8 +325,7 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
if (!publicChat &&
( (message.fromUserId == chatWithUserID && UsersUtil.isMe(message.toUserId)) ||
(message.toUserId == chatWithUserID && UsersUtil.isMe(message.fromUserId)) )) {
chatMessages.newChatMessage(event.message);
scrollToEndOfMessage(message.fromUserId);
addMessageAndScrollToEnd(event.message, event.message.fromUserId);
}
}
@ -353,14 +350,21 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
chatMessages.newChatMessage(msg);
}
public function scrollToEndOfMessage(userID:String):void {
public function addMessageAndScrollToEnd(message:ChatMessageVO, userId:String):void {
// Have to check if the vScroll is max before adding the new message
var vScrollMax:Boolean = chatMessagesList.verticalScrollAtMax;
chatMessages.newChatMessage(message);
scrollToEndOfMessage(userId, vScrollMax);
}
public function scrollToEndOfMessage(userID:String, precheckedVScroll:Boolean=false):void {
/**
* Trigger to force the scrollbar to show the last message.
*/
// @todo : scromm if
// 1 - I am the send of the last message
// 2 - If the scroll bar is at the bottom most
if (UsersUtil.isMe(userID) || (chatMessagesList.verticalScrollAtMax)) {
if (UsersUtil.isMe(userID) || precheckedVScroll || (chatMessagesList.verticalScrollAtMax)) {
if (scrollTimer != null) scrollTimer.start();
} else if (!scrollTimer.running) {
unreadMessagesBar.visible = unreadMessagesBar.includeInLayout = true;

View File

@ -53,18 +53,27 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
protected function dataChangeHandler(event:FlexEvent):void {
// If you remove this some of the chat messages will fail to render
validateNow();
var sameSender:Boolean = data.sameLastSender();
var sameTime:Boolean = data.sameLastTime();
var isMod:Boolean = data.isModerator();
hbHeader.visible = hbHeader.includeInLayout = !sameSender || !sameTime;
lblName.visible = !sameSender;
lblName.styleName = (isMod ? 'chatMessageHeaderModerator' : '');
moderatorIcon.visible = lblName.visible && isMod;
lblTime.visible = !sameSender || !sameTime;
}
]]>
</fx:Script>
<mx:Canvas width="100%" id="hbHeader" styleName="chatMessageHeader" verticalScrollPolicy="off" horizontalScrollPolicy="off"
visible="{lblName.visible || lblTime.visible}" includeInLayout="{lblName.visible || lblTime.visible}">
<mx:Label id="lblName" text="{data.name}" visible="{!data.sameLastSender}"
verticalCenter="0" textAlign="left" left="0" maxWidth="{this.width - lblTime.width - moderatorIcon.width - 22}"
styleName="{data.isModerator ? 'chatMessageHeaderModerator' : ''}"/>
<mx:Image id="moderatorIcon" visible="{lblName.visible &amp;&amp; data.isModerator}"
visible="false" includeInLayout="false">
<mx:Label id="lblName" text="{data.name}" visible="false"
verticalCenter="0" textAlign="left" left="0" maxWidth="{this.width - lblTime.width - moderatorIcon.width - 22}" />
<mx:Image id="moderatorIcon" visible="false"
source="{getStyle('moderatorIcon')}" x="{lblName.width + 4}" verticalCenter="0"/>
<mx:Text id="lblTime" visible="{data.differentLastSenderAndTime}" htmlText="{data.time}" textAlign="right"
<mx:Text id="lblTime" visible="true" htmlText="{data.time}" textAlign="right"
verticalCenter="0"
right="4" />
</mx:Canvas>

View File

@ -122,7 +122,7 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
// All of the code around the "firefox60" state is just a temporary stop gap to mitigate a regression
// in file uploading in Firefox 60+
if (presentOptions.disableFirefoxF60Upload && Capabilities.os.indexOf("Windows") != -1 &&
BrowserCheck.isFirefox() && BrowserCheck.browserMajorVersion >= "60" && BrowserCheck.isWin64()) {
BrowserCheck.isFirefox() && BrowserCheck.browserFullVersion == "60.0" && BrowserCheck.isWin64()) {
currentState = "firefox60";
}
}

View File

@ -0,0 +1,69 @@
package org.bigbluebutton.modules.screenshare.view.components {
import flash.events.TimerEvent;
import flash.text.TextLineMetrics;
import flash.utils.Timer;
import mx.controls.ProgressBar;
import mx.controls.ProgressBarLabelPlacement;
import mx.controls.ProgressBarMode;
import mx.core.UITextField;
import mx.core.mx_internal;
use namespace mx_internal;
public class AnimatedProgressBar extends ProgressBar {
private const TOTAL_LENGTH:uint = 15;
private const UPDATES_PER_SECOND:uint = 4;
private var timer:Timer;
private var totalProgress:Number;
private var currentProgress:Number;
public function AnimatedProgressBar() {
super();
timer = new Timer(1000/UPDATES_PER_SECOND, 0);
timer.addEventListener(TimerEvent.TIMER, onTimer);
totalProgress = TOTAL_LENGTH * UPDATES_PER_SECOND;
mode = ProgressBarMode.MANUAL;
minimum = 0;
maximum = totalProgress;
labelPlacement = ProgressBarLabelPlacement.TOP;
}
public function startAnimation():void {
timer.start();
visible = includeInLayout = true;
currentProgress = 0;
}
public function endAnimation():void {
timer.stop();
visible = includeInLayout = false;
}
private function onTimer(e:TimerEvent):void {
setProgress(++currentProgress, totalProgress);
}
override protected function updateDisplayList(unscaledWidth:Number, unscaledHeight:Number):void {
super.updateDisplayList(unscaledWidth, unscaledHeight);
// There's no TOP_CENTER label placement so we need to create one
var labelWidth:Number = getStyle("labelWidth");
var top:Number = getStyle("paddingTop");
var lineMetrics:TextLineMetrics = measureText(label);
var textWidth:Number = isNaN(labelWidth) ?
lineMetrics.width + UITextField.TEXT_WIDTH_PADDING :
labelWidth;
_labelField.move((unscaledWidth - textWidth) / 2, top);
}
}
}

View File

@ -397,7 +397,7 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
btnClosePublish.enabled = true;
connectingDots.startAnimation();
connectingProgress.startAnimation();
videoHolder.includeInLayout = videoHolder.visible = false;
}
@ -455,7 +455,7 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
if (info.hasOwnProperty("encoder")) {
// The encoder is sent to the client when the stream has actually started sending data. We can use
// it to know when the video is actually playing
connectingDots.endAnimation();
connectingProgress.endAnimation();
videoHolder.includeInLayout = videoHolder.visible = true;
if (info.hasOwnProperty("width")) {
@ -534,7 +534,9 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
<mx:VBox id="mainContainer" width="100%" height="100%" paddingBottom="2" paddingLeft="2" paddingRight="2" paddingTop="2">
<mx:HBox width="100%" height="90%" id="mainElement" verticalAlign="middle" horizontalAlign="center">
<ss:AnimatedDots id="connectingDots" styleName="animatedDotsStyle" />
<ss:AnimatedProgressBar id="connectingProgress" width="260" visible="false"
horizontalCenter="0" verticalCenter="0"
label="{ResourceUtil.getInstance().getString('bbb.screensharePublish.WebRTC.starting')}"/>
</mx:HBox>
<mx:HBox includeIn="dispFullRegionControlBar" width="100%" horizontalAlign="center">
<mx:Button id="btnClosePublish"

View File

@ -447,12 +447,16 @@ $Id: $
}
private function handleRemainingTimeUpdate(event:BreakoutRoomEvent):void {
// The label.id is used to determine message to display. So make sure
// you change in the TimerUtil if you change the label.
TimerUtil.setCountDownTimer(breakoutTimeLabel, event.durationInMinutes);
}
private function breakoutRoomsListChangeListener(event:CollectionEvent):void {
if (breakoutRoomsList.length == 0) {
breakoutTimeLabel.text = "...";
// The label.id is used to determine message to display. So make sure
// you change in the TimerUtil if you change the label.
TimerUtil.stopTimer(breakoutTimeLabel.id);
// All breakout rooms were close we don't need to display the join URL alert anymore
removeJoinWindow();

View File

@ -1 +1 @@
BIGBLUEBUTTON_RELEASE=2.0.0-beta
BIGBLUEBUTTON_RELEASE=2.0.0-RC1

View File

@ -32,7 +32,7 @@
# 2010-03-02 JRT Added trunk checkout options / fixed bbb-apps instructions
# 2010-04-02 FFD Updated for 0.64
# 2010-06-21 SEB Cleaned up some code / Updated for 0.70
# 2010-06-25 SEB Added ability to change the security salt
# 2010-06-25 SEB Added ability to change the security secret
# 2010-06-30 SEB Added some extra error checking
# 2010-07-06 SEB Added more error checking and report messages
# 2010-09-15 FFD Updates for 0.71-dev
@ -707,9 +707,12 @@ if [ $SECRET ]; then
need_root
change_var_salt ${SERVLET_DIR}/bigbluebutton/WEB-INF/classes/bigbluebutton.properties securitySalt $SECRET
if [ -f /usr/local/bigbluebutton/bbb-webhooks/config_local.coffee ]; then
sed -i "s|\(^[ \t]*config.bbb.sharedSecret[ =]*\).*|\1\"$SECRET\"|g" /usr/local/bigbluebutton/bbb-webhooks/config_local.coffee
fi
if [ -f /usr/local/bigbluebutton/bbb-webhooks/config_local.coffee ]; then
sed -i "s|\(^[ \t]*config.bbb.sharedSecret[ =]*\).*|\1\"$SECRET\"|g" /usr/local/bigbluebutton/bbb-webhooks/config_local.coffee
fi
if [ -f /usr/local/bigbluebutton/bbb-webhooks/config_local.js ]; then
sed -i "s|\(^[ \t]*config.bbb.sharedSecret[ =]*\).*|\1\"$SECRET\"|g" /usr/local/bigbluebutton/bbb-webhooks/config_local.js
fi
if [ -f /usr/local/bigbluebutton/bbb-webhooks/extra/post_catcher.js ]; then
sed -i "s|\(^[ \t]*var shared_secret[ =]*\)[^;]*|\1\"$SECRET\"|g" /usr/local/bigbluebutton/bbb-webhooks/extra/post_catcher.js
@ -866,7 +869,7 @@ check_configuration() {
if [ -f ${SERVLET_DIR}/demo/bbb_api_conf.jsp ]; then
#
# Make sure the salt for the API matches the server
# Make sure the shared secret for the API matches the server
#
SECRET_PROPERTIES=$(cat ${SERVLET_DIR}/bigbluebutton/WEB-INF/classes/bigbluebutton.properties | grep -v '#' | tr -d '\r' | sed -n '/securitySalt/{s/.*=//;p}')
SECRET_DEMO=$(cat ${SERVLET_DIR}/demo/bbb_api_conf.jsp | grep -v '^//' | tr -d '\r' | sed -n '/salt[ ]*=/{s/.*=[ ]*"//;s/".*//g;p}')
@ -891,33 +894,41 @@ check_configuration() {
BBB_SECRET=$(cat ${SERVLET_DIR}/bigbluebutton/WEB-INF/classes/bigbluebutton.properties | grep -v '#' | tr -d '\r' | sed -n '/securitySalt/{s/.*=//;p}')
NGINX_IP=$(cat /etc/nginx/sites-available/bigbluebutton | grep -v '#' | sed -n '/server_name/{s/.*server_name[ ]*//;s/;//;p}' | cut -d' ' -f1)
if [ -f /usr/lib/systemd/system/bbb-webhooks.service ]; then
WEBHOOKS_SECRET=$(cat /usr/local/bigbluebutton/bbb-webhooks/config_local.coffee | grep '^[ \t]*config.bbb.sharedSecret[ =]*' | cut -d '"' -f2)
if [ "$BBB_SECRET" != "$WEBHOOKS_SECRET" ]; then
echo "# Warning: Webhooks API Shared Secret mismatch: "
echo "# ${SERVLET_DIR}/bigbluebutton/WEB-INF/classes/bigbluebutton.properties = $BBB_SECRET"
echo "# /usr/local/bigbluebutton/bbb-webhooks/config_local.coffee = $WEBHOOKS_SECRET"
echo
fi
WEBHOOKS_PROXY_PORT=$(cat /etc/bigbluebutton/nginx/webhooks.nginx | grep -v '#' | grep '^[ \t]*proxy_pass[ \t]*' | sed 's|.*http[s]\?://[^:]*:\([^;]*\);.*|\1|g')
WEBHOOKS_APP_PORT=$(cat /usr/local/bigbluebutton/bbb-webhooks/config_local.coffee | grep '^[ \t]*config.server.port[ =]*' | cut -d '=' -f2 | xargs)
if [ -f /usr/lib/systemd/system/bbb-webhooks.service ]; then
if [ -f /usr/local/bigbluebutton/bbb-webhooks/config_local.js ]; then
WEBHOOKS_SECRET=$(cat /usr/local/bigbluebutton/bbb-webhooks/config_local.js | grep '^[ \t]*config.bbb.sharedSecret[ =]*' | cut -d '"' -f2)
WEBHOOKS_CONF=/usr/local/bigbluebutton/bbb-webhooks/config_local.js
fi
if [ -f /usr/local/bigbluebutton/bbb-webhooks/config_local.coffee ]; then
WEBHOOKS_SECRET=$(cat /usr/local/bigbluebutton/bbb-webhooks/config_local.coffee | grep '^[ \t]*config.bbb.sharedSecret[ =]*' | cut -d '"' -f2)
WEBHOOKS_CONF=/usr/local/bigbluebutton/bbb-webhooks/config_local.coffee
fi
if [ "$BBB_SECRET" != "$WEBHOOKS_SECRET" ]; then
echo "# Warning: Webhooks API Shared Secret mismatch: "
echo "# ${SERVLET_DIR}/bigbluebutton/WEB-INF/classes/bigbluebutton.properties = $BBB_SECRET"
echo "# $WEBHOOKS_CONF = $WEBHOOKS_SECRET"
echo
fi
WEBHOOKS_PROXY_PORT=$(cat /etc/bigbluebutton/nginx/webhooks.nginx | grep -v '#' | grep '^[ \t]*proxy_pass[ \t]*' | sed 's|.*http[s]\?://[^:]*:\([^;]*\);.*|\1|g')
WEBHOOKS_APP_PORT=$(cat $WEBHOOKS_CONF | grep config.server.port | sed "s/.*config.server.port[ =\"]*//g" | sed 's/[;\"]*//g')
if [ "$WEBHOOKS_PROXY_PORT" != "$WEBHOOKS_APP_PORT" ]; then
echo "# Warning: Webhooks port mismatch: "
echo "# /etc/bigbluebutton/nginx/webhooks.nginx = $WEBHOOKS_PROXY_PORT"
echo "# $WEBHOOKS_CONF = $WEBHOOKS_APP_PORT"
echo
fi
fi
if [ "$WEBHOOKS_PROXY_PORT" != "$WEBHOOKS_APP_PORT" ]; then
echo "# Warning: Webhooks port mismatch: "
echo "# /etc/bigbluebutton/nginx/webhooks.nginx = $WEBHOOKS_PROXY_PORT"
echo "# /usr/local/bigbluebutton/bbb-webhooks/config_local.coffee = $WEBHOOKS_APP_PORT"
echo
fi
fi
if [ -f ${SERVLET_DIR}/lti/WEB-INF/classes/lti-config.properties ]; then
LTI_SECRET=$(cat ${SERVLET_DIR}/lti/WEB-INF/classes/lti-config.properties | grep -v '#' | tr -d '\r' | sed -n '/^bigbluebuttonSalt/{s/.*=//;p}')
BBB_SECRET=$(cat ${SERVLET_DIR}/bigbluebutton/WEB-INF/classes/bigbluebutton.properties | grep -v '#' | tr -d '\r' | sed -n '/securitySalt/{s/.*=//;p}')
if [ "$LTI_SECRET" != "$BBB_SECRET" ]; then
echo "# Warning: LTI shared secret (salt) mismatch:"
echo "# Warning: LTI shared secret mismatch:"
echo "# ${SERVLET_DIR}/lti/WEB-INF/classes/lti-config.properties = $LTI_SECRET"
echo "# ${SERVLET_DIR}/bigbluebutton/WEB-INF/classes/bigbluebutton.properties = $BBB_SECRET"
echo
@ -1185,22 +1196,27 @@ check_state() {
#
# Check if ffmpeg is installed, and whether it is a supported version
#
FFMPEG_VERSION=$(ffmpeg -version 2>/dev/null | grep ffmpeg | cut -d ' ' -f3)
FFMPEG_VERSION=$(ffmpeg -version 2>/dev/null | grep ffmpeg | cut -d ' ' -f3 | sed 's/--.*//g' | tr -d '\n')
case "$FFMPEG_VERSION" in
2.8.*)
# This is the current supported version; OK.
;;
'')
echo "# Warning: No ffmpeg version was found on the system"
echo "# Recording processing will not function"
echo
;;
*)
echo "# Warning: The installed ffmpeg version '${FFMPEG_VERSION}' is not supported"
echo "# Recording processing may not function correctly"
echo
;;
esac
4.0.*)
# This is the current supported version; OK.
;;
'')
echo "# Warning: No ffmpeg version was found on the system"
echo "# Recording processing will not function"
echo
;;
*)
echo "# Warning: The installed ffmpeg version '${FFMPEG_VERSION}' is not recommended."
echo "# Recommend you update to the 4.0.x version of ffmpeg. To upgrade, do the following"
echo "#"
echo "# sudo add-apt-repository ppa:jonathonf/ffmpeg-4"
echo "# sudo apt-get update"
echo "# sudo apt-get dist-upgrade"
echo "#"
echo
;;
esac
if [ -f /usr/share/red5/log/sip.log ]; then
@ -1565,12 +1581,12 @@ if [ $CHECK ]; then
echo " url: $BBB_WEB_URL"
fi
if [ -f ${SERVLET_DIR}/lti/WEB-INF/classes/lti-config.properties ]; then
LTI_URL=$(cat ${SERVLET_DIR}/lti/WEB-INF/classes/lti-config.properties | grep -v '#' | sed -n '/^bigbluebuttonURL/{s/.*http[s]:\/\///;s/\/.*//;p}' | tr -d '\015')
echo
echo "${SERVLET_DIR}/lti/WEB-INF/classes/lti-config.properties (LTI integration)"
echo " api url: $LTI_URL"
fi
# if [ -f ${SERVLET_DIR}/lti/WEB-INF/classes/lti-config.properties ]; then
# LTI_URL=$(cat ${SERVLET_DIR}/lti/WEB-INF/classes/lti-config.properties | grep -v '#' | sed -n '/^bigbluebuttonURL/{s/.*http[s]:\/\///;s/\/.*//;p}' | tr -d '\015')
# echo
# echo "${SERVLET_DIR}/lti/WEB-INF/classes/lti-config.properties (LTI integration)"
# echo " api url: $LTI_URL"
# fi
if [ -f /var/www/bigbluebutton/check/conf/config.xml ]; then
CHECK_URL=$(cat /var/www/bigbluebutton/check/conf/config.xml | grep "<uri>rtmp" | head -1 | sed 's/.*rtmp[s]*:\/\///g' | sed 's/\/.*//g' | tr -d '\015')
@ -1590,6 +1606,7 @@ if [ $CHECK ]; then
echo
echo "/usr/local/bigbluebutton/core/scripts/bigbluebutton.yml (record and playback)"
echo " playback host: $PLAYBACK_IP"
echo " ffmpeg: $(ffmpeg -version 2>/dev/null | grep ffmpeg | cut -d ' ' -f3 | sed 's/--.*//g' | tr -d '\n')"
fi
if [ -f /usr/local/bigbluebutton/bbb-webrtc-sfu/config/default.yml ]; then
@ -1605,6 +1622,8 @@ if [ $CHECK ]; then
echo " localIpAddress: $KURENTO_LOCAL_IP"
echo " recordScreenSharing: $KURENTO_RECORD_SCREEN_SHARING"
echo " recordWebcams: $KURENTO_RECORD_WEBCAMS"
echo " Node: $(node -v)"
echo " mongoDB: $(/usr/bin/mongod --version | grep "db version" | sed 's/db version //g')"
fi
check_state
@ -1964,6 +1983,10 @@ if [ $CLEAN ]; then
rm -f /var/log/bbb-transcode-akka/*
fi
if [ -d /var/log/bbb-webrtc-sfu ]; then
rm -f /var/log/bbb-webrtc-sfu/*
fi
start_bigbluebutton
check_state
fi

View File

@ -262,7 +262,7 @@
<div class="row">
<div class="span twelve center">
<p>Copyright &copy; 2018 BigBlueButton Inc.<br>
<small>Version <a href="http://docs.bigbluebutton.org/">2.0-beta</a></small>
<small>Version <a href="http://docs.bigbluebutton.org/">2.0-RC1</a></small>
</p>
</div>
</div>

View File

@ -288,7 +288,7 @@
<div class="row">
<div class="span twelve center">
<p>Copyright &copy; 2018 BigBlueButton Inc.<br>
<small>Version <a href="http://docs.bigbluebutton.org/">2.0-beta</a></small>
<small>Version <a href="http://docs.bigbluebutton.org/">2.0-RC1</a></small>
</p>
</div>
</div>

View File

@ -1,10 +1,29 @@
import Logger from '/imports/startup/server/logger';
import Users from '/imports/api/users';
const logClient = function (type, log, ...args) {
const SERVER_CONN_ID = this.connection.id;
const User = Users.findOne({ connectionId: SERVER_CONN_ID });
const logContents = { ...args };
if (User) {
const {
meetingId, name, intId, extId, authToken,
} = User;
const serverInfo = {
meetingId,
userName: name,
userIntId: intId,
userExtId: extId,
authToken,
};
logContents.serverInfo = serverInfo;
}
const logClient = (type, log, ...args) => {
if (typeof log === 'string' || log instanceof String) {
Logger.log(type, `CLIENT LOG: ${log}\n`, ...args);
Logger.log(type, `CLIENT LOG: ${log}\n`, logContents);
} else {
Logger.log(type, `CLIENT LOG: ${JSON.stringify(log)}\n`, ...args);
Logger.log(type, `CLIENT LOG: ${JSON.stringify(log)}\n`, logContents);
}
};

View File

@ -44,6 +44,7 @@ export default function handleValidateAuthToken({ body }, meetingId) {
const selector = {
meetingId,
userId,
clientType: 'HTML5',
};
const User = Users.findOne(selector);

View File

@ -23,7 +23,7 @@ export default function userLeaving(credentials, userId, connectionId) {
const User = Users.findOne(selector);
if (!User) {
throw new Meteor.Error('user-not-found', `Could not find ${userId} in ${meetingId}: cannot complete userLeaving`);
Logger.info(`Skipping userLeaving. Could not find ${userId} in ${meetingId}`);
}
// If the current user connection is not the same that triggered the leave we skip

View File

@ -1,19 +1,20 @@
import sharedWebcam from '../modifiers/sharedWebcam';
import { check } from 'meteor/check';
import sharedWebcam from '../modifiers/sharedWebcam';
export default function handleUserSharedHtml5Webcam({ header, body }, meetingId ) {
export default function handleUserSharedHtml5Webcam({ header, body }, meetingId) {
const { userId, stream } = body;
const isValidStream = Match.Where((stream) => {
check(stream, String);
const isValidStream = (testString) => {
// Checking if the stream name is a flash one
const regexp = /^([A-z0-9]+)-([A-z0-9]+)-([A-z0-9]+)(-recorded)?$/;
return !regexp.test(stream);
});
return !regexp.test(testString);
};
check(header, Object);
check(meetingId, String);
check(userId, String);
check(stream, isValidStream);
check(stream, String);
if (!isValidStream(stream)) return false;
return sharedWebcam(meetingId, userId);
}

View File

@ -1,19 +1,20 @@
import unsharedWebcam from '../modifiers/unsharedWebcam';
import { check } from 'meteor/check';
import unsharedWebcam from '../modifiers/unsharedWebcam';
export default function handleUserUnsharedHtml5Webcam({ header, body }, meetingId) {
const { userId, stream } = body;
const isValidStream = Match.Where((stream) => {
check(stream, String);
const isValidStream = (testString) => {
// Checking if the stream name is a flash one
const regexp = /^([A-z0-9]+)-([A-z0-9]+)-([A-z0-9]+)(-recorded)?$/;
return !regexp.test(stream);
});
return !regexp.test(testString);
};
check(header, Object);
check(meetingId, String);
check(userId, String);
check(stream, isValidStream);
check(stream, String);
if (!isValidStream(stream)) return false;
return unsharedWebcam(meetingId, userId);
}

View File

@ -35,6 +35,14 @@ export function joinRouteHandler(nextState, replace, callback) {
const metakeys = metadata.length
? metadata.reduce((acc, meta) => {
const key = Object.keys(meta).shift();
const handledHTML5Parameters = [
'html5autoswaplayout', 'html5autosharewebcam', 'html5hidepresentation',
];
if (handledHTML5Parameters.indexOf(key) === -1) {
return acc;
}
/* this reducer transforms array of objects in a single object and
forces the metadata a be boolean value */
let value = meta[key];

View File

@ -62,7 +62,7 @@ class MettingMessageQueue {
this.debug(`${eventName} completed ${isAsync ? 'async' : 'sync'}`);
called = true;
const queueLength = this.queue.length();
if (queueLength > 0) {
if (queueLength > 100) {
Logger.error(`prev queue size=${queueLength} `);
}
next();

View File

@ -5,7 +5,7 @@ import { defineMessages, injectIntl, intlShape } from 'react-intl';
import Modal from 'react-modal';
import cx from 'classnames';
import Resizable from 're-resizable';
import browser from 'browser-detect';
import ToastContainer from '../toast/container';
import ModalContainer from '../modal/container';
import NotificationsBarContainer from '../notifications-bar/container';
@ -79,6 +79,11 @@ class App extends Component {
document.getElementsByTagName('html')[0].lang = locale;
document.getElementsByTagName('html')[0].style.fontSize = this.props.fontSize;
const BROWSER_RESULTS = browser();
const body = document.getElementsByTagName('body')[0];
body.classList.add(`browser-${BROWSER_RESULTS.name}`);
body.classList.add(`os-${BROWSER_RESULTS.os.split(' ').shift().toLowerCase()}`);
this.handleWindowResize();
window.addEventListener('resize', this.handleWindowResize, false);
}

View File

@ -2,7 +2,6 @@ import React, { Component } from 'react';
import PropTypes from 'prop-types';
import ModalBase from '/imports/ui/components/modal/base/component';
import Button from '/imports/ui/components/button/component';
import deviceInfo from '/imports/utils/deviceInfo';
import { defineMessages, injectIntl, intlShape } from 'react-intl';
import { styles } from './styles';
import PermissionsOverlay from '../permissions-overlay/component';
@ -10,7 +9,6 @@ import AudioSettings from '../audio-settings/component';
import EchoTest from '../echo-test/component';
import Help from '../help/component';
const propTypes = {
intl: intlShape.isRequired,
closeModal: PropTypes.func.isRequired,
@ -307,11 +305,12 @@ class AudioModal extends Component {
const {
isEchoTest,
intl,
isIOSChrome,
} = this.props;
const { content } = this.state;
if (deviceInfo.osType().isIOSChrome) {
if (isIOSChrome) {
return (
<div>
<div className={styles.warning}>!</div>
@ -381,6 +380,7 @@ class AudioModal extends Component {
const {
intl,
showPermissionsOvelay,
isIOSChrome,
} = this.props;
const { content } = this.state;
@ -399,16 +399,13 @@ class AudioModal extends Component {
data-test="audioModalHeader"
className={styles.header}
>{
(!deviceInfo.osType().isIOSChrome ?
isIOSChrome ? null :
<h3 className={styles.title}>
{content ?
this.contents[content].title :
intl.formatMessage(intlMessages.audioChoiceLabel)}
</h3> : <h3 className={styles.title} />
)
</h3>
}
<Button
data-test="modalBaseCloseButton"
className={styles.closeBtn}

View File

@ -1,6 +1,7 @@
import React from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import { withModalMounter } from '/imports/ui/components/modal/service';
import browser from 'browser-detect';
import AudioModal from './component';
import Service from '../service';
@ -24,7 +25,7 @@ export default withModalMounter(withTracker(({ mountModal }) =>
}
reject(() => {
Service.exitAudio();
})
});
});
return call.then(() => {
@ -55,4 +56,5 @@ export default withModalMounter(withTracker(({ mountModal }) =>
joinFullAudioImmediately: !listenOnlyMode && skipCheck,
joinFullAudioEchoTest: !listenOnlyMode && !skipCheck,
forceListenOnlyAttendee: listenOnlyMode && forceListenOnly && !Service.isUserModerator(),
isIOSChrome: browser().name === 'crios',
}))(AudioModalContainer));

View File

@ -29,7 +29,6 @@ class AudioStreamVolume extends Component {
this.handleError = this.handleError.bind(this);
this.state = {
instant: 0,
slow: 0,
};
}
@ -42,7 +41,6 @@ class AudioStreamVolume extends Component {
if (prevProps.deviceId !== this.props.deviceId) {
this.closeAudioContext().then(() => {
this.setState({
instant: 0,
slow: 0,
});
this.createAudioContext();
@ -104,12 +102,13 @@ class AudioStreamVolume extends Component {
}
handleError(error) {
log('error', error);
log('error', JSON.stringify(error));
}
render() {
const { low, optimum, high, deviceId, ...props } = this.props;
const { instant, slow } = this.state;
const {
low, optimum, high, ...props
} = this.props;
return (
<meter

View File

@ -1,4 +1,4 @@
import React, { Component } from 'react';
import React from 'react';
import { injectIntl, intlShape, defineMessages } from 'react-intl';
import { styles } from './styles';
@ -17,52 +17,16 @@ const intlMessages = defineMessages({
},
});
class PermissionsOverlay extends Component {
constructor(props) {
super(props);
const broswerStyles = {
Chrome: {
top: '145px',
left: '380px',
},
Firefox: {
top: '210px',
left: '605px',
},
Safari: {
top: '100px',
left: '100px',
},
};
const browser = window.bowser.name;
this.state = {
styles: {
top: broswerStyles[browser].top,
left: broswerStyles[browser].left,
},
};
}
render() {
const {
intl,
} = this.props;
return (
<div className={styles.overlay}>
<div style={this.state.styles} className={styles.hint}>
{ intl.formatMessage(intlMessages.title) }
<small>
{ intl.formatMessage(intlMessages.hint) }
</small>
</div>
</div>
);
}
}
const PermissionsOverlay = ({ intl }) => (
<div className={styles.overlay}>
<div className={styles.hint}>
{ intl.formatMessage(intlMessages.title) }
<small>
{ intl.formatMessage(intlMessages.hint) }
</small>
</div>
</div>
);
PermissionsOverlay.propTypes = propTypes;

View File

@ -1,3 +1,44 @@
@mixin arrowIconStyle() {
&:after {
top: -50px;
left: -20px;
font-size: 20px;
-webkit-animation: bounce 2s infinite;
animation: bounce 2s infinite;
display: block;
font-family: 'bbb-icons';
content: "\E906";
position: relative;
}
:global(.browser-edge) &:after {
top: -50px;
left: -15.5em;
font-size: 20px;
-webkit-animation: bounceRotate 2s infinite;
animation: bounceRotate 2s infinite;
}
}
@mixin positionHint() {
:global(.browser-edge) & {
left: 50%;
bottom: 10%;
}
:global(.browser-firefox) & {
top: 210px;
left: 605px;
}
:global(.browser-chrome) & {
top: 145px;
left: 380px;
}
:global(.browser-safari) & {
top: 100px;
left: 100px;
}
}
.overlay {
position: fixed;
z-index: 1002;
@ -10,6 +51,8 @@
}
.hint {
@include positionHint();
position: absolute;
color: #fff;
font-size: 16px;
@ -25,17 +68,7 @@
opacity: .6;
}
&:after {
display: block;
font-family: 'bbb-icons';
content: "\E906";
position: relative;
top: -50px;
left: -20px;
font-size: 20px;
-webkit-animation: bounce 2s infinite;
animation: bounce 2s infinite;
}
@include arrowIconStyle();
}
@-webkit-keyframes bounce {
@ -80,6 +113,21 @@
}
}
@keyframes bounceRotate {
0%, 20%, 50%, 80%, 100% {
-ms-transform: translateY(0) rotate(180deg);
transform: translateY(0) rotate(180deg);
}
40% {
-ms-transform: translateY(10px) rotate(180deg);
transform: translateY(10px) rotate(180deg);
}
60% {
-ms-transform: translateY(5px) rotate(180deg);
transform: translateY(5px) rotate(180deg);
}
}
@keyframes fade-in {
0% {
opacity: 0;

View File

@ -30,12 +30,18 @@ class MessageList extends Component {
this.ticking = false;
this.handleScrollChange = _.debounce(this.handleScrollChange.bind(this), 150);
this.handleScrollUpdate = _.debounce(this.handleScrollUpdate.bind(this), 150);
this.state = {};
}
componentDidMount() {
const { scrollArea } = this;
this.setState({
scrollArea: this.scrollArea
});
this.scrollTo(this.props.scrollPosition);
scrollArea.addEventListener('scroll', this.handleScrollChange, false);
}
@ -47,13 +53,15 @@ class MessageList extends Component {
}
}
shouldComponentUpdate(nextProps) {
shouldComponentUpdate(nextProps, nextState) {
const {
chatId,
hasUnreadMessages,
partnerIsLoggedOut,
} = this.props;
if(!this.state.scrollArea && nextState.scrollArea) return true;
const switchingCorrespondent = chatId !== nextProps.chatId;
const hasNewUnreadMessages = hasUnreadMessages !== nextProps.hasUnreadMessages;
@ -162,7 +170,7 @@ class MessageList extends Component {
<div className={styles.messageListWrapper}>
<div
role="log"
ref={(ref) => { this.scrollArea = ref; }}
ref={(ref) => { if (ref != null) { this.scrollArea = ref; } }}
id={this.props.id}
className={styles.messageList}
aria-live="polite"
@ -180,7 +188,7 @@ class MessageList extends Component {
time={message.time}
chatAreaId={this.props.id}
lastReadMessageTime={this.props.lastReadMessageTime}
scrollArea={this.scrollArea}
scrollArea={this.state.scrollArea}
/>
))}
</div>

View File

@ -91,7 +91,8 @@ export default class MessageListItem extends Component {
}
shouldComponentUpdate(nextProps, nextState) {
return !nextState.preventRender && nextState.pendingChanges;
if(!this.props.scrollArea && nextProps.scrollArea) return true;
else return !nextState.preventRender && nextState.pendingChanges;
}
renderSystemMessage() {
@ -123,7 +124,7 @@ export default class MessageListItem extends Component {
const {
user,
messages,
time,
time
} = this.props;
const dateTime = new Date(time);

View File

@ -45,9 +45,11 @@ export default class MessageListItem extends Component {
if (isElementInViewport(node)) {
this.props.handleReadMessage(this.props.time);
eventsToBeBound.forEach(
e => scrollArea.removeEventListener(e, this.handleMessageInViewport),
);
if(scrollArea) {
eventsToBeBound.forEach(
e => scrollArea.removeEventListener(e, this.handleMessageInViewport),
);
}
}
this.ticking = false;
@ -57,7 +59,9 @@ export default class MessageListItem extends Component {
this.ticking = true;
}
componentDidMount() {
// depending on whether the message is in viewport or not,
// either read it or attach a listener
listenToUnreadMessages() {
if (!this.props.lastReadMessageTime > this.props.time) {
return;
}
@ -65,13 +69,17 @@ export default class MessageListItem extends Component {
const node = this.text;
const { scrollArea } = this.props;
if (isElementInViewport(node)) {
if (isElementInViewport(node)) { // no need to listen, the message is already in viewport
this.props.handleReadMessage(this.props.time);
} else if (scrollArea) {
eventsToBeBound.forEach(
(e) => { scrollArea.addEventListener(e, this.handleMessageInViewport, false); },
);
(e) => { scrollArea.addEventListener(e, this.handleMessageInViewport, false); },
);
}
}
componentDidMount() {
this.listenToUnreadMessages();
}
componentWillUnmount() {
@ -88,6 +96,10 @@ export default class MessageListItem extends Component {
}
}
componentDidUpdate(prevProps) {
this.listenToUnreadMessages();
}
render() {
const {
text,

View File

@ -51,6 +51,7 @@
flex: 1;
display: flex;
flex-flow: column;
overflow-x: hidden;
width: calc(100% - 1.7rem);
}

View File

@ -1,7 +1,12 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import _ from 'lodash';
import UnreadMessages from '/imports/ui/services/unread-messages';
import ChatAudioNotification from './audio-notification/component';
import ChatPushNotification from './push-notification/component';
import Service from '../service';
import { styles } from '../styles';
const propTypes = {
disableNotify: PropTypes.bool.isRequired,
@ -9,10 +14,35 @@ const propTypes = {
disableAudio: PropTypes.bool.isRequired,
};
const intlMessages = defineMessages({
appToastChatPublic: {
id: 'app.toast.chat.public',
description: 'when entry various message',
},
appToastChatPrivate: {
id: 'app.toast.chat.private',
description: 'when entry various message',
},
appToastChatSystem: {
id: 'app.toast.chat.system',
description: 'system for use',
},
publicChatClear: {
id: 'app.chat.clearPublicChatMessage',
description: 'message of when clear the public chat',
},
});
const PUBLIC_KEY = 'public';
const PRIVATE_KEY = 'private';
class ChatNotification extends Component {
constructor(props) {
super(props);
this.state = { notified: {} };
this.state = {
notified: Service.getNotified(PRIVATE_KEY),
publicNotified: Service.getNotified(PUBLIC_KEY),
};
}
componentWillReceiveProps(nextProps) {
@ -43,51 +73,174 @@ class ChatNotification extends Component {
...notified,
...notifiedToClear,
},
}));
}), () => {
Service.setNotified(PRIVATE_KEY, this.state.notified);
});
}
render() {
mapContentText(message) {
const {
intl,
} = this.props;
const contentMessage = message
.map((content) => {
if (content.text === 'PUBLIC_CHAT_CLEAR') return intl.formatMessage(intlMessages.publicChatClear);
/* this code is to remove html tags that come in the server's messangens */
const tempDiv = document.createElement('div');
tempDiv.innerHTML = content.text;
const textWithoutTag = tempDiv.innerText;
return textWithoutTag;
});
return contentMessage;
}
createMessage(name, message) {
return (
<div className={styles.pushMessageContent}>
<h3 className={styles.userNameMessage}>{name}</h3>
<div className={styles.contentMessage}>
{
this.mapContentText(message)
.reduce((acc, text) => [...acc, (<br />), text], []).splice(1)
}
</div>
</div>
);
}
notifyPrivateChat() {
const {
disableNotify,
disableAudio,
openChats,
intl,
} = this.props;
const unreadMessagesCount = openChats
.map(chat => chat.unreadCounter)
.reduce((a, b) => a + b, 0);
const shouldPlayAudio = !disableAudio && unreadMessagesCount > 0;
const chatsNotify = openChats
.filter(({ id, unreadCounter }) =>
unreadCounter > 0 &&
unreadCounter !== this.state.notified[id] &&
!disableNotify);
!disableNotify && id !== PUBLIC_KEY)
.map(({
id,
name,
unreadCounter,
...rest
}) => ({
...rest,
name,
unreadCounter,
id,
message: intl.formatMessage(intlMessages.appToastChatPrivate),
}));
return (
<span>
<ChatAudioNotification play={shouldPlayAudio} count={unreadMessagesCount} />
{
chatsNotify.map(({ id, name, unreadCounter }) =>
chatsNotify.map(({ id, message, name }) => {
const getChatmessages = UnreadMessages.getUnreadMessages(id)
.filter(({ fromTime, fromUserId }) => fromTime > (this.state.notified[fromUserId] || 0));
const reduceMessages = Service
.reduceAndMapMessages(getChatmessages);
if (!reduceMessages.length) return null;
const flatMessages = _.flatten(reduceMessages
.map(msg => this.createMessage(name, msg.content)));
const limitingMessages = flatMessages;
return (<ChatPushNotification
key={id}
chatId={id}
content={limitingMessages}
message={<span >{message}</span>}
onOpen={() => {
this.setState(({ notified }) => ({
notified: {
...notified,
[id]: new Date().getTime(),
},
}), () => {
Service.setNotified(PRIVATE_KEY, this.state.notified);
});
}}
/>);
})
}
</span>
);
}
notifyPublicChat() {
const {
publicUserId,
intl,
disableNotify,
} = this.props;
const publicUnread = UnreadMessages.getUnreadMessages(publicUserId);
const publicUnreadReduced = Service.reduceAndMapMessages(publicUnread);
const chatsNotify = publicUnreadReduced
.map(msg => ({
...msg,
sender: {
name: msg.sender ? msg.sender.name : intl.formatMessage(intlMessages.appToastChatSystem),
...msg.sender,
},
}))
.filter(({ sender, time }) =>
(time > (this.state.publicNotified[sender.id] || 0))
&& !disableNotify && Service.hasUnreadMessages(publicUserId));
return (
<span>
{
chatsNotify.map(({ sender, time, content }) =>
(<ChatPushNotification
key={id}
name={name}
count={unreadCounter}
key={time}
chatId={PUBLIC_KEY}
name={sender.name}
message={
<span >
{ intl.formatMessage(intlMessages.appToastChatPublic) }
</span>
}
content={this.createMessage(sender.name, content)}
onOpen={() => {
this.setState(({ notified }) => ({
notified: {
...notified,
[id]: unreadCounter,
this.setState(({ notified, publicNotified }) => ({
...notified,
publicNotified: {
...publicNotified,
[sender.id]: time,
},
}));
}), () => {
Service.setNotified(PUBLIC_KEY, this.state.publicNotified);
});
}}
/>))
}
</span>
);
}
render() {
const {
openChats,
disableAudio,
} = this.props;
const unreadMessagesCount = openChats
.map(chat => chat.unreadCounter)
.reduce((a, b) => a + b, 0);
const shouldPlayAudio = !disableAudio && unreadMessagesCount > 0;
return (
<span>
<ChatAudioNotification play={shouldPlayAudio} count={unreadMessagesCount} />
{ this.notifyPublicChat() }
{ this.notifyPrivateChat() }
</span>
);
}
}
ChatNotification.propTypes = propTypes;
export default ChatNotification;
export default injectIntl(ChatNotification);

View File

@ -16,5 +16,6 @@ export default withTracker(() => {
disableAudio: !AppSettings.chatAudioNotifications,
disableNotify: !AppSettings.chatPushNotifications,
openChats,
publicUserId: Meteor.settings.public.chat.public_userid,
};
})(ChatNotificationContainer);

View File

@ -2,31 +2,29 @@ import React from 'react';
import PropTypes from 'prop-types';
import _ from 'lodash';
import injectNotify from '/imports/ui/components/toast/inject-notify/component';
import { defineMessages, injectIntl, intlShape } from 'react-intl';
import { Link } from 'react-router';
import { styles } from '../../styles.scss';
const NOTIFICATION_INTERVAL = 2000; // 2 seconds
const NOTIFICATION_LIFETIME = 4000; // 4 seconds
const propTypes = {
intl: intlShape.isRequired,
count: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
notify: PropTypes.func.isRequired,
onOpen: PropTypes.func.isRequired,
};
const intlMessages = defineMessages({
appToastChatSigular: {
id: 'app.toast.chat.singular',
description: 'when entry a message',
},
appToastChatPlural: {
id: 'app.toast.chat.plural',
description: 'when entry various message',
},
});
class ChatPushNotification extends React.Component {
static link(message, chatId) {
return (
<Link className={styles.link} to={`/users/chat/${chatId}`}>
{message}
</Link>
);
}
constructor(props) {
super(props);
this.showNotify = _.debounce(this.showNotify.bind(this), 1000);
this.showNotify = _.debounce(this.showNotify.bind(this), NOTIFICATION_INTERVAL);
this.componentDidMount = this.showNotify;
this.componentDidUpdate = this.showNotify;
@ -34,21 +32,21 @@ class ChatPushNotification extends React.Component {
showNotify() {
const {
intl,
count,
name,
notify,
onOpen,
chatId,
message,
content,
} = this.props;
const message = intl.formatMessage(count > 1 ?
intlMessages.appToastChatPlural :
intlMessages.appToastChatSigular, {
0: count,
1: name,
});
return notify(message, 'info', 'chat', { onOpen });
return notify(
ChatPushNotification.link(message, chatId),
'info',
'chat',
{ onOpen, autoClose: NOTIFICATION_LIFETIME },
ChatPushNotification.link(content, chatId),
true,
);
}
render() {
@ -57,4 +55,4 @@ class ChatPushNotification extends React.Component {
}
ChatPushNotification.propTypes = propTypes;
export default injectIntl(injectNotify(ChatPushNotification));
export default injectNotify(ChatPushNotification);

View File

@ -65,8 +65,9 @@ const reduceMessages = (previous, current) => {
// Check if the last message is from the same user and time discrepancy
// between the two messages exceeds window and then group current message
// with the last one
const timeOfLastMessage = lastMessage.content[lastMessage.content.length - 1].time;
if (lastMessage.fromUserId === currentMessage.fromUserId
&& (currentMessage.fromTime - lastMessage.fromTime) <= GROUPING_MESSAGES_WINDOW) {
&& (currentMessage.fromTime - timeOfLastMessage) <= GROUPING_MESSAGES_WINDOW) {
lastMessage.content.push(currentMessage.content.pop());
return previous;
}
@ -97,7 +98,6 @@ const getPrivateMessages = (userID) => {
}, {
sort: ['fromTime'],
}).fetch();
return reduceAndMapMessages(messages);
};
@ -122,7 +122,6 @@ const isChatLocked = (receiverID) => {
const hasUnreadMessages = (receiverID) => {
const isPublic = receiverID === PUBLIC_CHAT_ID;
const chatType = isPublic ? PUBLIC_CHAT_USERID : receiverID;
return UnreadMessages.count(chatType) > 0;
};
@ -223,6 +222,29 @@ const exportChat = messageList => (
}).join('\n')
);
const setNotified = (chatType, item) => {
const notified = Storage.getItem('notified');
const key = 'notified';
const userChat = { [chatType]: item };
if (notified) {
Storage.setItem(key, {
...notified,
...userChat,
});
return;
}
Storage.setItem(key, {
...userChat,
});
};
const getNotified = (chat) => {
const key = 'notified';
const notified = Storage.getItem(key);
if (notified) return notified[chat] || {};
return {};
};
export default {
reduceAndMapMessages,
getPublicMessages,
@ -239,4 +261,6 @@ export default {
removeFromClosedChatsSession,
exportChat,
clearPublicChatHistory,
setNotified,
getNotified,
};

View File

@ -2,6 +2,39 @@
@import "/imports/ui/stylesheets/variables/_all";
$icon-offset: -.4em;
$background-active: darken($color-white, 5%);
@mixin lineClamp($lineHeight: 1em, $lineCount: 1, $bgColor: $color-white) {
display: block;
box-orient: vertical;
position: relative;
word-break: break-word;
overflow: hidden;
line-height: $lineHeight;
max-height: $lineHeight * $lineCount;
&:before {
content: '...';
width: 10%;
height: $lineHeight;
text-align: right;
position: absolute;
right: 0;
bottom: 0;
background: linear-gradient(to right, rgba(255, 255, 255, 0), $bgColor 75%);
}
&:after {
content: '';
position: absolute;
right: 0;
width: 1em;
height: $lineHeight;
margin-top: 0.2em;
background-color: $bgColor;
}
}
.chat {
background-color: #fff;
@ -57,3 +90,28 @@ $icon-offset: -.4em;
}
}
}
.link {
text-decoration: none;
}
.pushMessageContent {
margin-left: 2rem;
margin-right: 2rem;
margin-top: 1.4rem;
margin-bottom: .4rem;
}
.userNameMessage {
margin: 0;
font-size: 80%;
color: $color-gray-dark;
font-weight: bold;
@include lineClamp($lineHeight: 1em, $lineCount: 1, $bgColor: $color-white);
}
.contentMessage {
margin-top: .2rem;
font-size: 80%;
@include lineClamp($lineHeight: $font-size-small, $lineCount: 10, $bgColor: $color-white);
}

View File

@ -7,7 +7,7 @@ const RecordingIndicator = ({
if (!record) return null;
return (
<div>
<div className={styles.recordState}>
<div className={recording ? styles.recordIndicator : styles.notRecording} />
<span className={recording ? styles.recordingLabel : styles.notRecordingLabel}>{title}</span>
</div>

View File

@ -55,3 +55,7 @@
@extend %baseIndicatorLabel;
color: $color-gray;
}
.recordState {
display: flex;
}

View File

@ -5,7 +5,7 @@ const getCurrentCursor = (cursorId) => {
const cursor = Cursor.findOne({ _id: cursorId });
if (cursor) {
const { userId } = cursor;
const user = Users.findOne({ userId });
const user = Users.findOne({ userId, connectionStatus: 'online' });
if (user) {
cursor.userName = user.name;
return cursor;

View File

@ -36,7 +36,6 @@ export default class PresentationOverlay extends Component {
this.getTransformedSvgPoint = this.getTransformedSvgPoint.bind(this);
this.svgCoordinateToPercentages = this.svgCoordinateToPercentages.bind(this);
}
// transforms the coordinate from window coordinate system
// to the main svg coordinate system
getTransformedSvgPoint(clientX, clientY) {

View File

@ -348,7 +348,7 @@ class PresentationUploader extends Component {
if (!item.upload.done && !item.upload.error) {
return intl.formatMessage(intlMessages.uploadProcess, {
progress: Math.floor(item.upload.progress).toString(),
0: Math.floor(item.upload.progress).toString(),
});
}
@ -365,8 +365,8 @@ class PresentationUploader extends Component {
if (!item.conversion.done && !item.conversion.error) {
if (item.conversion.pagesCompleted < item.conversion.numPages) {
return intl.formatMessage(intlMessages.conversionProcessingSlides, {
current: item.conversion.pagesCompleted,
total: item.conversion.numPages,
0: item.conversion.pagesCompleted,
1: item.conversion.numPages,
});
}

View File

@ -1,7 +1,7 @@
import React, { Component } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import browser from 'browser-detect';
import Modal from '/imports/ui/components/modal/simple/component';
import deviceInfo from '/imports/utils/deviceInfo';
import _ from 'lodash';
import { styles } from './styles';
@ -77,23 +77,24 @@ const SHORTCUTS_CONFIG = Meteor.settings.public.app.shortcuts;
class ShortcutHelpComponent extends Component {
render() {
const { intl } = this.props;
const { isWindows, isLinux, isMac } = deviceInfo.osType();
const { isFirefox, isChrome, isIE } = deviceInfo.browserType();
const shortcuts = Object.values(SHORTCUTS_CONFIG);
const { name } = browser();
let accessMod = null;
if (isMac) {
accessMod = 'Control + Alt';
}
if (isWindows) {
accessMod = isIE ? 'Alt' : accessMod;
}
if (isWindows || isLinux) {
accessMod = isFirefox ? 'Alt + Shift' : accessMod;
accessMod = isChrome ? 'Alt' : accessMod;
switch (name) {
case 'chrome':
case 'edge':
accessMod = 'Alt';
break;
case 'firefox':
accessMod = 'Alt + Shift';
break;
case 'safari':
case 'crios':
case 'fxios':
accessMod = 'Control + Alt';
break;
}
return (

View File

@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { toast } from 'react-toastify';
import cx from 'classnames';
import Icon from '../icon/component';
import { styles } from './styles';
@ -23,12 +23,36 @@ const defaultIcons = {
[toast.TYPE.DEFAULT]: 'about',
};
const Toast = ({ icon, type, message }) => (
<div className={styles[type]} role="alert">
<div className={styles.icon}><Icon iconName={icon || defaultIcons[type]} /></div>
<div className={styles.message}>
<span>{message}</span>
const Toast = ({
icon,
type,
message,
content,
small,
}) => (
<div
className={cx(styles.toastContainer, small ? styles.smallToastContainer : null)}
role="alert"
>
<div className={styles[type]}>
<div className={cx(styles.icon, small ? styles.smallIcon : null)}>
<Icon iconName={icon || defaultIcons[type]} />
</div>
<div className={cx(styles.message, small ? styles.smallMessage : null)}>
<span>{message}</span>
</div>
</div>
{
content ? (
<div>
<div className={styles.separator} />
<div>
{content}
</div>
</div>
)
: null
}
</div>
);

View File

@ -27,6 +27,15 @@ $background-active: darken($color-white, 5%);
}
}
.toastContainer {
display: flex;
flex-direction: column;
}
.smallToastContainer {
margin: -.35rem;
}
.icon {
align-self: flex-start;
margin-bottom: auto;
@ -46,6 +55,28 @@ $background-active: darken($color-white, 5%);
}
}
.smallIcon {
width: 1.2rem;
height: 1.2rem;
> i {
font-size: 70%;
}
}
.separator {
height: 1px;
width: 99%;
position: absolute;
margin-left: -.5em;
background-color: $color-gray-lighter;
margin-top: $line-height-computed * .5;
margin-bottom: $line-height-computed * .5;
@include mq($small-only) {
position: relative;
margin-left: auto;
}
}
.message {
margin-top: auto;
margin-bottom: auto;
@ -54,6 +85,10 @@ $background-active: darken($color-white, 5%);
overflow: auto;
}
.smallMessage {
font-size: 80%;
}
.default {
@include notification-variant($toast-default-color, $toast-default-bg);
}
@ -122,18 +157,17 @@ $background-active: darken($color-white, 5%);
outline: none;
border: none;
cursor: pointer;
opacity: .3;
opacity: .5;
transition: .3s ease;
font-size: .35rem;
color: $color-gray-dark;
border: 1px solid;
border-radius: 50%;
padding: .4rem;
line-height: 0;
position: absolute;
top: $md-padding-y;
right: $md-padding-y;
font-size: 70%;
top: $lg-padding-y;
&:before {
margin-left: -.2rem;
}
@ -175,4 +209,5 @@ $background-active: darken($color-white, 5%);
z-index: 9999;
animation: track-progress linear 1;
background-color: $color-gray-lighter;
border-radius: $border-radius;
}

View File

@ -1,12 +1,12 @@
@import "/imports/ui/stylesheets/variables/palette";
@import "/imports/ui/stylesheets/variables/general";
@import "/imports/ui/stylesheets/mixins/_indicators";
/* Variables
* ==========
*/
$user-avatar-border: $color-gray-light;
$user-avatar-text: $color-white;
$user-indicators-offset: -5px;
$user-indicator-presenter-bg: $color-primary;
$user-indicator-voice-bg: $color-success;
$user-indicator-muted-bg: $color-danger;
$user-list-bg: $color-off-white;
@ -72,24 +72,14 @@ $user-color: currentColor; //picks the current color reference in the class
.presenter {
&:before {
content: "\00a0\e90b\00a0";
opacity: 1;
top: $user-indicators-offset;
left: $user-indicators-offset;
bottom: auto;
right: auto;
border-radius: 5px;
background-color: $user-indicator-presenter-bg;
padding: .425rem;
}
@include presenterIndicator();
}
.voice {
&:after {
content: "\00a0\e931\00a0";
background-color: $user-indicator-voice-bg;
opacity: 1;
width: 1.375rem;
height: 1.375rem;
top: 1.375rem;
left: 1.375rem;
}
@ -99,18 +89,19 @@ $user-color: currentColor; //picks the current color reference in the class
&:after {
content: "\00a0\e932\00a0";
background-color: $user-indicator-muted-bg;
opacity: 1;
}
}
.listenOnly {
&:after {
content: "\00a0\e90c\00a0";
opacity: 1;
}
}
.listenOnly, .muted, .voice {
@include indicatorStyles();
}
.content {
color: $user-avatar-text;
top: 50%;

View File

@ -1,12 +1,8 @@
import React, { Component } from 'react';
import { styles } from './styles';
import { defineMessages, injectIntl } from 'react-intl';
import { log } from '/imports/ui/services/api';
import { notify } from '/imports/ui/services/notification';
import { toast } from 'react-toastify';
import VisibilityEvent from '/imports/utils/visibilityEvent';
import Toast from '/imports/ui/components/toast/component';
import _ from 'lodash';
import VideoService from './service';
import VideoList from './video-list/component';
@ -96,7 +92,7 @@ class VideoProvider extends Component {
this.customGetStats = this.customGetStats.bind(this);
}
_sendPauseStream (id, role, state) {
_sendPauseStream(id, role, state) {
this.sendMessage({
cameraId: id,
id: 'pause',
@ -106,9 +102,8 @@ class VideoProvider extends Component {
});
}
pauseViewers () {
log("debug", "Calling pause in viewer streams");
pauseViewers() {
log('debug', 'Calling pause in viewer streams');
Object.keys(this.webRtcPeers).forEach((id) => {
if (this.props.userId !== id && this.webRtcPeers[id].started) {
@ -117,8 +112,8 @@ class VideoProvider extends Component {
});
}
unpauseViewers () {
log("debug", "Calling un-pause in viewer streams");
unpauseViewers() {
log('debug', 'Calling un-pause in viewer streams');
Object.keys(this.webRtcPeers).forEach((id) => {
if (id !== this.props.userId && this.webRtcPeers[id].started) {
@ -154,8 +149,8 @@ class VideoProvider extends Component {
usersToConnect.forEach(id => this.createWebRTCPeer(id, userId === id));
usersToDisconnect.forEach(id => this.stopWebRTCPeer(id));
console.warn('[usersToConnect]', usersToConnect);
console.warn('[usersToDisconnect]', usersToDisconnect);
// console.warn('[usersToConnect]', usersToConnect);
// console.warn('[usersToDisconnect]', usersToDisconnect);
}
componentWillUnmount() {
@ -182,7 +177,7 @@ class VideoProvider extends Component {
});
// Close websocket connection to prevent multiple reconnects from happening
// Don't disonnect socket on unmount to prevent multiple reconnects
// Don't disconnect socket on unmount to prevent multiple reconnects
this.ws.close();
}
@ -210,13 +205,12 @@ class VideoProvider extends Component {
ping() {
const message = {
id: 'ping'
id: 'ping',
};
this.sendMessage(message);
}
onWsMessage(msg) {
const { intl } = this.props;
const parsedMessage = JSON.parse(msg.data);
console.log('Received message new ws message: ');
@ -240,7 +234,7 @@ class VideoProvider extends Component {
break;
case 'pong':
console.debug("Received pong from server");
console.debug('Received pong from server');
break;
case 'error':
@ -251,7 +245,7 @@ class VideoProvider extends Component {
}
sendMessage(message) {
const ws = this.ws;
const { ws } = this;
if (this.connectedToMediaServer()) {
const jsonMessage = JSON.stringify(message);
@ -263,7 +257,7 @@ class VideoProvider extends Component {
});
} else {
// No need to queue video stop messages
if (message.id != 'stop') {
if (message.id !== 'stop') {
this.wsQueue.push(message);
}
}
@ -281,13 +275,12 @@ class VideoProvider extends Component {
peer.processAnswer(message.sdpAnswer, (error) => {
if (error) {
return log('error', error);
return log('error', JSON.stringify(error));
}
});
}
handleIceCandidate(message) {
const { intl } = this.props;
const webRtcPeer = this.webRtcPeers[message.cameraId];
if (webRtcPeer) {
@ -307,7 +300,7 @@ class VideoProvider extends Component {
stopWebRTCPeer(id) {
log('info', 'Stopping webcam', id);
const userId = this.props.userId;
const { userId } = this.props;
const shareWebcam = id === userId;
if (shareWebcam) {
@ -340,7 +333,7 @@ class VideoProvider extends Component {
}
createWebRTCPeer(id, shareWebcam) {
const { intl, meetingId } = this.props;
const { meetingId } = this.props;
const videoConstraints = {
width: {
@ -373,7 +366,7 @@ class VideoProvider extends Component {
}
this.webRtcPeers[id] = new WebRtcPeerObj(options, (error) => {
let peer = this.webRtcPeers[id];
const peer = this.webRtcPeers[id];
peer.started = false;
peer.attached = false;
@ -384,10 +377,9 @@ class VideoProvider extends Component {
return this._webRTCOnError(error, id, shareWebcam);
}
peer.generateOffer((error, offerSdp) => {
if (error) {
return this._webRTCOnError(error, id, shareWebcam);
peer.generateOffer((errorGenOffer, offerSdp) => {
if (errorGenOffer) {
return this._webRTCOnError(errorGenOffer, id, shareWebcam);
}
log('info', `Invoking SDP offer callback function ${location.host}`);
@ -409,7 +401,7 @@ class VideoProvider extends Component {
});
}
_getWebRTCStartTimeout (id, shareWebcam, peer) {
_getWebRTCStartTimeout(id, shareWebcam, peer) {
const { intl } = this.props;
return () => {
@ -429,12 +421,12 @@ class VideoProvider extends Component {
this.attachVideoStream(id, tag);
// Increment reconnect interval
this.restartTimer[id] = Math.min(2*this.restartTimer[id], MAX_CAMERA_SHARE_FAILED_WAIT_TIME);
this.restartTimer[id] = Math.min(2 * this.restartTimer[id], MAX_CAMERA_SHARE_FAILED_WAIT_TIME);
}
};
}
_processIceQueue (peer) {
_processIceQueue(peer) {
const { intl } = this.props;
while (peer.iceQueue.length) {
@ -448,11 +440,11 @@ class VideoProvider extends Component {
}
}
_webRTCOnError (error, id, shareWebcam) {
_webRTCOnError(error, id, shareWebcam) {
const { intl } = this.props;
log('error', ' WebRTC peerObj create error');
log('error', error);
log('error', JSON.stringify(error));
const errorMessage = intlMediaErrorsMessages[error.name]
|| intlMediaErrorsMessages.permissionError;
this.notifyError(intl.formatMessage(errorMessage));
@ -463,7 +455,7 @@ class VideoProvider extends Component {
this.stopWebRTCPeer(id);
return log('error', error);
return log('error', JSON.stringify(errorMessage));
}
_getOnIceCandidateCallback(id, shareWebcam) {
@ -527,14 +519,14 @@ class VideoProvider extends Component {
}
promise.then((results) => {
let videoInOrOutbound = {};
results.forEach(function(res) {
if (res.type == 'ssrc' || res.type == 'inbound-rtp' || res.type == 'outbound-rtp') {
results.forEach((res) => {
if (res.type === 'ssrc' || res.type === 'inbound-rtp' || res.type === 'outbound-rtp') {
res.packetsSent = parseInt(res.packetsSent);
res.packetsLost = parseInt(res.packetsLost) || 0;
res.packetsReceived = parseInt(res.packetsReceived);
if ((isNaN(res.packetsSent) && res.packetsReceived == 0)
|| (res.type == 'outbound-rtp' && res.isRemote)) {
if ((isNaN(res.packetsSent) && res.packetsReceived === 0)
|| (res.type === 'outbound-rtp' && res.isRemote)) {
return; // Discard local video receiving
}
@ -573,9 +565,9 @@ class VideoProvider extends Component {
currentDelay: videoInOrOutbound.currentDelay,
};
let videoStatsArray = statsState;
const videoStatsArray = statsState;
videoStatsArray.push(videoStats);
while (videoStatsArray.length > 5) {// maximum interval to consider
while (videoStatsArray.length > 5) { // maximum interval to consider
videoStatsArray.shift();
}
this.setState({ stats: videoStatsArray });
@ -592,19 +584,21 @@ class VideoProvider extends Component {
const videoReceivedInterval = lastVideoStats.timestamp - firstVideoStats.timestamp;
const videoSentInterval = lastVideoStats.timestamp - firstVideoStats.timestamp;
const videoKbitsReceivedPerSecond = videoIntervalBytesReceived * 8 / videoReceivedInterval;
const videoKbitsSentPerSecond = videoIntervalBytesSent * 8 / videoSentInterval;
const videoPacketDuration = videoIntervalPacketsSent / videoSentInterval * 1000;
const videoKbitsReceivedPerSecond = (videoIntervalBytesReceived * 8) / videoReceivedInterval;
const videoKbitsSentPerSecond = (videoIntervalBytesSent * 8) / videoSentInterval;
const videoPacketDuration = (videoIntervalPacketsSent / videoSentInterval) * 1000;
let videoLostPercentage, videoLostRecentPercentage, videoBitrate;
let videoLostPercentage,
videoLostRecentPercentage,
videoBitrate;
if (videoStats.packetsReceived > 0) { // Remote video
videoLostPercentage = ((videoStats.packetsLost / (videoStats.packetsLost + videoStats.packetsReceived) * 100) || 0).toFixed(1);
videoLostPercentage = ((videoStats.packetsLost / ((videoStats.packetsLost + videoStats.packetsReceived) * 100)) || 0).toFixed(1);
videoBitrate = Math.floor(videoKbitsReceivedPerSecond || 0);
videoLostRecentPercentage = ((videoIntervalPacketsLost / (videoIntervalPacketsLost + videoIntervalPacketsReceived) * 100) || 0).toFixed(1);
videoLostRecentPercentage = ((videoIntervalPacketsLost / ((videoIntervalPacketsLost + videoIntervalPacketsReceived) * 100)) || 0).toFixed(1);
} else {
videoLostPercentage = ((videoStats.packetsLost / (videoStats.packetsLost + videoStats.packetsSent) * 100) || 0).toFixed(1);
videoLostPercentage = (((videoStats.packetsLost / (videoStats.packetsLost + videoStats.packetsSent)) * 100) || 0).toFixed(1);
videoBitrate = Math.floor(videoKbitsSentPerSecond || 0);
videoLostRecentPercentage = ((videoIntervalPacketsLost / (videoIntervalPacketsLost + videoIntervalPacketsSent) * 100) || 0).toFixed(1);
videoLostRecentPercentage = ((videoIntervalPacketsLost / ((videoIntervalPacketsLost + videoIntervalPacketsSent) * 100)) || 0).toFixed(1);
}
const result = {
@ -624,34 +618,34 @@ class VideoProvider extends Component {
encodeUsagePercent: videoStats.encodeUsagePercent,
rtt: videoStats.rtt,
currentDelay: videoStats.currentDelay,
}
},
};
callback(result);
}, function(exception) {
console.error("customGetStats() Promise rejected:", exception.message);
}, (exception) => {
log('error', `customGetStats() Promise rejected: ${exception.message}`);
callback(null);
});
}
monitorTrackStart(peer, track, local, callback){
monitorTrackStart(peer, track, local, callback) {
const that = this;
console.log("Starting stats monitoring on", track.id);
console.log('Starting stats monitoring on', track.id);
const getStatsInterval = 2000;
const callGetStats = () => {
that.customGetStats(
peer,
track,
function(results) {
if (results == null || peer.signalingState == "closed") {
(results) => {
if (results == null || peer.signalingState === 'closed') {
that.monitorTrackStop(track.id);
} else {
callback(results);
}
},
getStatsInterval
)
getStatsInterval,
);
};
if (!this.monitoredTracks[track.id]) {
@ -661,22 +655,21 @@ class VideoProvider extends Component {
getStatsInterval,
);
} else {
console.log("Already monitoring this track");
console.log('Already monitoring this track');
}
}
monitorTrackStop(trackId){
monitorTrackStop(trackId) {
if (this.monitoredTracks[trackId]) {
clearInterval(this.monitoredTracks[trackId]);
delete this.monitoredTracks[trackId];
console.log("Track " + trackId + " removed");
console.log(`Track ${trackId} removed`);
} else {
console.log("Track " + trackId + " is not monitored");
console.log(`Track ${trackId} is not monitored`);
}
}
getStats(id, video, callback) {
const isCurrent = id === this.props.userId;
const peer = this.webRtcPeers[id];
const hasLocalStream = peer && peer.started === true && peer.peerConnection.getLocalStreams().length > 0;
@ -687,12 +680,9 @@ class VideoProvider extends Component {
} else if (hasRemoteStream) {
this.monitorTrackStart(peer.peerConnection, peer.peerConnection.getRemoteStreams()[0].getVideoTracks()[0], false, callback);
}
return;
}
stopGettingStats(id) {
const isCurrent = id === this.props.userId;
const peer = this.webRtcPeers[id];
const hasLocalStream = peer && peer.started === true && peer.peerConnection.getLocalStreams().length > 0;
@ -703,8 +693,6 @@ class VideoProvider extends Component {
} else if (hasRemoteStream) {
this.monitorTrackStop(peer.peerConnection.getRemoteStreams()[0].getVideoTracks()[0].id);
}
return;
}
handlePlayStop(message) {
@ -740,7 +728,7 @@ class VideoProvider extends Component {
handleError(message) {
const { intl } = this.props;
const { userId } = this.props;
if (message.cameraId == userId) {
if (message.cameraId === userId) {
this.unshareWebcam();
this.notifyError(intl.formatMessage(intlMediaErrorsMessages[message.message]
|| intlMessages.sharingError));

View File

@ -1,5 +1,6 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import RenderInBrowser from 'react-render-in-browser';
import AnnotationHelpers from '../helpers';
const DRAW_END = Meteor.settings.public.whiteboard.annotations.status.end;
@ -138,20 +139,19 @@ export default class TextDrawComponent extends Component {
renderViewerTextShape(results) {
const styles = TextDrawComponent.getViewerStyles(results);
const { isChrome, isEdge } = this.props.browserType;
return (
<g>
{ isChrome || isEdge ? null :
<clipPath id={this.props.annotation.id}>
<rect
x={results.x}
y={results.y}
width={results.width}
height={results.height}
/>
</clipPath>
}
<RenderInBrowser only firefox>
<clipPath id={this.props.annotation.id}>
<rect
x={results.x}
y={results.y}
width={results.width}
height={results.height}
/>
</clipPath>
</RenderInBrowser>
<foreignObject
clipPath={`url(#${this.props.annotation.id})`}
x={results.x}

View File

@ -1,6 +1,5 @@
import React from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import deviceInfo from '/imports/utils/deviceInfo';
import TextShapeService from './service';
import TextDrawComponent from './component';
@ -21,6 +20,5 @@ export default withTracker((params) => {
isActive,
setTextShapeValue: TextShapeService.setTextShapeValue,
resetTextShapeActiveId: TextShapeService.resetTextShapeActiveId,
browserType: deviceInfo.browserType(),
};
})(TextDrawContainer);

View File

@ -3,6 +3,9 @@ import PropTypes from 'prop-types';
import cx from 'classnames';
import { HEXToINTColor, INTToHEXColor } from '/imports/utils/hexInt';
import { defineMessages, injectIntl, intlShape } from 'react-intl';
import RenderInBrowser from 'react-render-in-browser';
import browser from 'browser-detect';
import { noop } from 'lodash';
import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrapper/component';
import { styles } from './styles.scss';
import ToolbarMenuItem from './toolbar-menu-item/component';
@ -58,6 +61,8 @@ const intlMessages = defineMessages({
},
});
const runExceptInEdge = fn => (browser().name === 'edge' ? noop : fn);
class WhiteboardToolbar extends Component {
constructor() {
super();
@ -97,6 +102,8 @@ class WhiteboardToolbar extends Component {
this.handleColorChange = this.handleColorChange.bind(this);
this.handleMouseEnter = this.handleMouseEnter.bind(this);
this.handleMouseLeave = this.handleMouseLeave.bind(this);
this.componentDidMount = runExceptInEdge(this.componentDidMount);
this.componentDidUpdate = runExceptInEdge(this.componentDidUpdate);
}
componentWillMount() {
@ -174,7 +181,6 @@ class WhiteboardToolbar extends Component {
* 3. Switch from the Text tool to any other - trigger color and radius for thickness
* 4. Trigger initial animation for the icons
*/
// 1st case
if (this.state.colorSelected.value !== prevState.colorSelected.value) {
// 1st case b)
@ -186,13 +192,12 @@ class WhiteboardToolbar extends Component {
// 2nd case
} else if (this.state.thicknessSelected.value !== prevState.thicknessSelected.value) {
this.thicknessListIconRadius.beginElement();
// 3rd case
// 3rd case
} else if (this.state.annotationSelected.value !== 'text' &&
prevState.annotationSelected.value === 'text') {
prevState.annotationSelected.value === 'text') {
this.thicknessListIconRadius.beginElement();
this.thicknessListIconColor.beginElement();
}
// 4th case, initial animation is triggered in componentDidMount
}
@ -406,36 +411,41 @@ class WhiteboardToolbar extends Component {
renderThicknessItemIcon() {
return (
<svg className={styles.customSvgIcon} shapeRendering="geometricPrecision">
<circle
shapeRendering="geometricPrecision"
cx="50%"
cy="50%"
stroke="black"
strokeWidth="1"
>
<animate
ref={(ref) => { this.thicknessListIconColor = ref; }}
attributeName="fill"
attributeType="XML"
from={this.state.prevColorSelected.value}
to={this.state.colorSelected.value}
begin="indefinite"
dur={TRANSITION_DURATION}
repeatCount="0"
fill="freeze"
/>
<animate
ref={(ref) => { this.thicknessListIconRadius = ref; }}
attributeName="r"
attributeType="XML"
from={this.state.prevThicknessSelected.value}
to={this.state.thicknessSelected.value}
begin="indefinite"
dur={TRANSITION_DURATION}
repeatCount="0"
fill="freeze"
/>
</circle>
<RenderInBrowser only edge>
<circle cx="50%" cy="50%" r={this.state.thicknessSelected.value} stroke="black" strokeWidth="1" fill={this.state.colorSelected.value} />
</RenderInBrowser>
<RenderInBrowser except edge>
<circle
shapeRendering="geometricPrecision"
cx="50%"
cy="50%"
stroke="black"
strokeWidth="1"
>
<animate
ref={(ref) => { this.thicknessListIconColor = ref; }}
attributeName="fill"
attributeType="XML"
from={this.state.prevColorSelected.value}
to={this.state.colorSelected.value}
begin="indefinite"
dur={TRANSITION_DURATION}
repeatCount="0"
fill="freeze"
/>
<animate
ref={(ref) => { this.thicknessListIconRadius = ref; }}
attributeName="r"
attributeType="XML"
from={this.state.prevThicknessSelected.value}
to={this.state.thicknessSelected.value}
begin="indefinite"
dur={TRANSITION_DURATION}
repeatCount="0"
fill="freeze"
/>
</circle>
</RenderInBrowser>
</svg>
);
}
@ -474,19 +484,24 @@ class WhiteboardToolbar extends Component {
renderColorItemIcon() {
return (
<svg className={styles.customSvgIcon}>
<rect x="25%" y="25%" width="50%" height="50%" stroke="black" strokeWidth="1">
<animate
ref={(ref) => { this.colorListIconColor = ref; }}
attributeName="fill"
attributeType="XML"
from={this.state.prevColorSelected.value}
to={this.state.colorSelected.value}
begin="indefinite"
dur={TRANSITION_DURATION}
repeatCount="0"
fill="freeze"
/>
</rect>
<RenderInBrowser only edge>
<rect x="25%" y="25%" width="50%" height="50%" stroke="black" strokeWidth="1" fill={this.state.colorSelected.value} />
</RenderInBrowser>
<RenderInBrowser except edge>
<rect x="25%" y="25%" width="50%" height="50%" stroke="black" strokeWidth="1">
<animate
ref={(ref) => { this.colorListIconColor = ref; }}
attributeName="fill"
attributeType="XML"
from={this.state.prevColorSelected.value}
to={this.state.colorSelected.value}
begin="indefinite"
dur={TRANSITION_DURATION}
repeatCount="0"
fill="freeze"
/>
</rect>
</RenderInBrowser>
</svg>
);
}

View File

@ -12,14 +12,20 @@ let lastToast = {
icon: null,
};
export function notify(message, type = 'default', icon, options) {
export function notify(message, type = 'default', icon, options, content, small) {
const settings = {
type,
...options,
};
const { id: lastToastId, ...lastToastProps } = lastToast;
const toastProps = { message, type, icon };
const toastProps = {
message,
type,
icon,
content,
small,
};
if (!toast.isActive(lastToast.id) || !_.isEqual(lastToastProps, toastProps)) {
const id = toast(<Toast {...toastProps} />, settings);

View File

@ -11,7 +11,8 @@ const PUBLIC_CHAT_USERID = CHAT_CONFIG.public_userid;
class UnreadMessagesTracker {
constructor() {
this._tracker = new Tracker.Dependency();
this._unreadChats = Storage.getItem('UNREAD_CHATS') || {};
this._unreadChats = Storage.getItem('UNREAD_CHATS') || { [PUBLIC_CHAT_USERID]: (new Date()).getTime() };
this.get = this.get.bind(this);
}
get(chatID) {
@ -30,14 +31,13 @@ class UnreadMessagesTracker {
return this._unreadChats[chatID];
}
count(chatID) {
getUnreadMessages(chatID) {
const filter = {
fromTime: {
$gt: this.get(chatID),
},
fromUserId: { $ne: Auth.userID },
};
// Minimongo does not support $eq. See https://github.com/meteor/meteor/issues/4142
if (chatID === PUBLIC_CHAT_USERID) {
filter.toUserId = { $not: { $ne: chatID } };
@ -45,8 +45,13 @@ class UnreadMessagesTracker {
filter.toUserId = { $not: { $ne: Auth.userID } };
filter.fromUserId.$not = { $ne: chatID };
}
const messages = Chats.find(filter).fetch();
return messages;
}
return Chats.find(filter).count();
count(chatID) {
const messages = this.getUnreadMessages(chatID);
return messages.length;
}
}

View File

@ -0,0 +1,41 @@
@import "/imports/ui/stylesheets/variables/palette";
@import "/imports/ui/stylesheets/variables/general";
@mixin presenterIndicator() {
&:before {
opacity: 1;
top: $user-indicators-offset;
left: $user-indicators-offset;
bottom: auto;
right: auto;
border-radius: 5px;
background-color: $color-primary;
}
:global(.browser-chrome) &:before,
:global(.browser-firefox) &:before {
padding: .45rem;
}
:global(.browser-edge) &:before {
padding-top: $indicator-padding-top;
padding-left: $indicator-padding-left;
padding-right: $indicator-padding-right;
padding-bottom: $indicator-padding-bottom;
}
}
@mixin indicatorStyles() {
&:after {
opacity: 1;
width: 1.2rem;
height: 1.2rem;
}
:global(.browser-edge) &:after {
padding-top: $indicator-padding-top;
padding-left: $indicator-padding-left;
padding-right: $indicator-padding-right;
padding-bottom: $indicator-padding-bottom;
}
}

View File

@ -14,3 +14,14 @@ $lg-padding-y: 0.6rem;
$jumbo-padding-x: 3.025rem;
$jumbo-padding-y: 1.5rem;
//used to center presenter indicator icon in Chrome / Firefox
$indicator-padding: .425rem;
//used to center indicator icons in Edge
$indicator-padding-right: 1.2em;
$indicator-padding-left: 0.175em;
$indicator-padding-top: 0.7em;
$indicator-padding-bottom: 0.7em;
$user-indicators-offset: -5px;

View File

@ -12,25 +12,6 @@ const deviceInfo = {
isPhone: smallSide <= MAX_PHONE_SHORT_SIDE,
};
},
browserType() {
return {
// Uses features to determine browser
isChrome: !!window.chrome && !!window.chrome.webstore,
isFirefox: typeof InstallTrigger !== 'undefined',
isIE: 'ActiveXObject' in window,
isEdge: !document.documentMode && window.StyleMedia,
};
},
osType() {
return {
// Uses userAgent to determine operating system
isWindows: window.navigator.userAgent.indexOf('Windows') !== -1,
isMac: window.navigator.userAgent.indexOf('Mac') !== -1,
isLinux: window.navigator.userAgent.indexOf('Linux') !== -1,
isIOSChrome: navigator.userAgent.match('CriOS'),
};
},
};

View File

@ -4675,6 +4675,16 @@
"prop-types": "15.6.0"
}
},
"react-render-in-browser": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/react-render-in-browser/-/react-render-in-browser-1.0.0.tgz",
"integrity": "sha512-DnOYcGVfjcu13Em8Z/sNbgYSrL26NjCQhZNzOEMV3BJiZ5WfvWFqvI9P/MW2K8guAkuf+hBouQyZysJdqrVhKA==",
"requires": {
"prop-types": "15.6.0",
"react": "16.0.0",
"react-dom": "16.0.0"
}
},
"react-router": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-3.0.5.tgz",

View File

@ -53,6 +53,7 @@
"react-dropzone": "~4.2.1",
"react-intl": "~2.4.0",
"react-modal": "~3.0.4",
"react-render-in-browser": "^1.0.0",
"react-router": "~3.0.2",
"react-tabs": "~2.1.0",
"react-toastify": "~2.1.2",

View File

@ -20,7 +20,7 @@
"defaultSettings": {
"application": {
"chatAudioNotifications": false,
"chatPushNotifications": false,
"chatPushNotifications": true,
"fontSize": "16px",
"fallbackLocale": "en"
},
@ -139,7 +139,7 @@
"chat": {
"min_message_length": 1,
"max_message_length": 5000,
"grouping_messages_window": 60000,
"grouping_messages_window": 10000,
"type_system": "SYSTEM_MESSAGE",
"type_public": "PUBLIC_CHAT",
"type_private": "PRIVATE_CHAT",

View File

@ -20,7 +20,7 @@
"defaultSettings": {
"application": {
"chatAudioNotifications": false,
"chatPushNotifications": false,
"chatPushNotifications": true,
"fontSize": "16px",
"fallbackLocale": "en"
},
@ -139,7 +139,7 @@
"chat": {
"min_message_length": 1,
"max_message_length": 5000,
"grouping_messages_window": 60000,
"grouping_messages_window": 10000,
"type_system": "SYSTEM_MESSAGE",
"type_public": "PUBLIC_CHAT",
"type_private": "PRIVATE_CHAT",

Some files were not shown because too many files have changed in this diff Show More