Fixed Package conflicts
This commit is contained in:
commit
5ee63a2aef
@ -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;
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
package org.bigbluebutton.web.services.callback;
|
||||
|
||||
public interface ICallbackEvent {
|
||||
String getCallbackUrl();
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
1
bbb-webhooks/.gitignore
vendored
1
bbb-webhooks/.gitignore
vendored
@ -3,4 +3,5 @@
|
||||
*.log
|
||||
node_modules/
|
||||
config_local.coffee
|
||||
config_local.js
|
||||
log/*
|
||||
|
@ -1 +1 @@
|
||||
0.10.33
|
||||
8.4.0
|
||||
|
@ -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();
|
||||
|
@ -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()
|
38
bbb-webhooks/application.js
Normal file
38
bbb-webhooks/application.js
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
@ -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}"
|
138
bbb-webhooks/callback_emitter.js
Normal file
138
bbb-webhooks/callback_emitter.js
Normal 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}`;
|
||||
}
|
||||
};
|
@ -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
109
bbb-webhooks/config.js
Normal 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;
|
@ -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
|
29
bbb-webhooks/config_local.js.example
Normal file
29
bbb-webhooks/config_local.js.example
Normal 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;
|
@ -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++) {
|
||||
|
@ -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
322
bbb-webhooks/hook.js
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
@ -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
215
bbb-webhooks/id_mapping.js
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
@ -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;
|
216
bbb-webhooks/messageMapping.js
Normal file
216
bbb-webhooks/messageMapping.js
Normal 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
1256
bbb-webhooks/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
|
46
bbb-webhooks/test/helpers.js
Normal file
46
bbb-webhooks/test/helpers.js
Normal 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;
|
1
bbb-webhooks/test/mocha.opts
Normal file
1
bbb-webhooks/test/mocha.opts
Normal file
@ -0,0 +1 @@
|
||||
--timeout 5000
|
301
bbb-webhooks/test/test.js
Normal file
301
bbb-webhooks/test/test.js
Normal 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
189
bbb-webhooks/userMapping.js
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
@ -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
68
bbb-webhooks/utils.js
Normal 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;
|
||||
};
|
@ -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
151
bbb-webhooks/web_hooks.js
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
@ -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
172
bbb-webhooks/web_server.js
Normal 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/);
|
1
bigbluebutton-client/.gitignore
vendored
1
bigbluebutton-client/.gitignore
vendored
@ -6,7 +6,6 @@ bin/
|
||||
bin-debug/
|
||||
bin-release/
|
||||
client/
|
||||
locale/.tx/
|
||||
bbbResources.properties.*
|
||||
asdoc/
|
||||
hs_err_pid*
|
||||
|
1
bigbluebutton-client/locale/.gitignore
vendored
1
bigbluebutton-client/locale/.gitignore
vendored
@ -1,3 +1,2 @@
|
||||
.tx
|
||||
ru/
|
||||
|
||||
|
10
bigbluebutton-client/locale/.tx/config
Normal file
10
bigbluebutton-client/locale/.tx/config
Normal 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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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 && 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>
|
||||
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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"
|
||||
|
@ -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();
|
||||
|
@ -1 +1 @@
|
||||
BIGBLUEBUTTON_RELEASE=2.0.0-beta
|
||||
BIGBLUEBUTTON_RELEASE=2.0.0-RC1
|
||||
|
@ -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
|
||||
|
@ -262,7 +262,7 @@
|
||||
<div class="row">
|
||||
<div class="span twelve center">
|
||||
<p>Copyright © 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>
|
||||
|
@ -288,7 +288,7 @@
|
||||
<div class="row">
|
||||
<div class="span twelve center">
|
||||
<p>Copyright © 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>
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -44,6 +44,7 @@ export default function handleValidateAuthToken({ body }, meetingId) {
|
||||
const selector = {
|
||||
meetingId,
|
||||
userId,
|
||||
clientType: 'HTML5',
|
||||
};
|
||||
|
||||
const User = Users.findOne(selector);
|
||||
|
2
bigbluebutton-html5/imports/api/users/server/methods/userLeaving.js
Normal file → Executable file
2
bigbluebutton-html5/imports/api/users/server/methods/userLeaving.js
Normal file → Executable 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
|
||||
|
15
bigbluebutton-html5/imports/api/video/server/handlers/userSharedHtml5Webcam.js
Normal file → Executable file
15
bigbluebutton-html5/imports/api/video/server/handlers/userSharedHtml5Webcam.js
Normal file → Executable 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);
|
||||
}
|
||||
|
13
bigbluebutton-html5/imports/api/video/server/handlers/userUnsharedHtml5Webcam.js
Normal file → Executable file
13
bigbluebutton-html5/imports/api/video/server/handlers/userUnsharedHtml5Webcam.js
Normal file → Executable 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);
|
||||
}
|
||||
|
@ -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];
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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));
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
|
@ -51,6 +51,7 @@
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
overflow-x: hidden;
|
||||
width: calc(100% - 1.7rem);
|
||||
}
|
||||
|
||||
|
197
bigbluebutton-html5/imports/ui/components/chat/notification/component.jsx
Normal file → Executable file
197
bigbluebutton-html5/imports/ui/components/chat/notification/component.jsx
Normal file → Executable 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);
|
||||
|
1
bigbluebutton-html5/imports/ui/components/chat/notification/container.jsx
Normal file → Executable file
1
bigbluebutton-html5/imports/ui/components/chat/notification/container.jsx
Normal file → Executable file
@ -16,5 +16,6 @@ export default withTracker(() => {
|
||||
disableAudio: !AppSettings.chatAudioNotifications,
|
||||
disableNotify: !AppSettings.chatPushNotifications,
|
||||
openChats,
|
||||
publicUserId: Meteor.settings.public.chat.public_userid,
|
||||
};
|
||||
})(ChatNotificationContainer);
|
||||
|
54
bigbluebutton-html5/imports/ui/components/chat/notification/push-notification/component.jsx
Normal file → Executable file
54
bigbluebutton-html5/imports/ui/components/chat/notification/push-notification/component.jsx
Normal file → Executable 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);
|
||||
|
30
bigbluebutton-html5/imports/ui/components/chat/service.js
Normal file → Executable file
30
bigbluebutton-html5/imports/ui/components/chat/service.js
Normal file → Executable 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,
|
||||
};
|
||||
|
58
bigbluebutton-html5/imports/ui/components/chat/styles.scss
Normal file → Executable file
58
bigbluebutton-html5/imports/ui/components/chat/styles.scss
Normal file → Executable 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);
|
||||
}
|
||||
|
2
bigbluebutton-html5/imports/ui/components/nav-bar/recording-indicator/component.jsx
Normal file → Executable file
2
bigbluebutton-html5/imports/ui/components/nav-bar/recording-indicator/component.jsx
Normal file → Executable 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>
|
||||
|
4
bigbluebutton-html5/imports/ui/components/nav-bar/recording-indicator/styles.scss
Normal file → Executable file
4
bigbluebutton-html5/imports/ui/components/nav-bar/recording-indicator/styles.scss
Normal file → Executable file
@ -55,3 +55,7 @@
|
||||
@extend %baseIndicatorLabel;
|
||||
color: $color-gray;
|
||||
}
|
||||
|
||||
.recordState {
|
||||
display: flex;
|
||||
}
|
@ -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;
|
||||
|
@ -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) {
|
||||
|
6
bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/component.jsx
Normal file → Executable file
6
bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/component.jsx
Normal file → Executable 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,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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 (
|
||||
|
36
bigbluebutton-html5/imports/ui/components/toast/component.jsx
Normal file → Executable file
36
bigbluebutton-html5/imports/ui/components/toast/component.jsx
Normal file → Executable 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>
|
||||
);
|
||||
|
||||
|
43
bigbluebutton-html5/imports/ui/components/toast/styles.scss
Normal file → Executable file
43
bigbluebutton-html5/imports/ui/components/toast/styles.scss
Normal file → Executable 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;
|
||||
}
|
||||
|
@ -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%;
|
||||
|
@ -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));
|
||||
|
@ -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}
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
10
bigbluebutton-html5/imports/ui/services/notification/index.js
Normal file → Executable file
10
bigbluebutton-html5/imports/ui/services/notification/index.js
Normal file → Executable 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);
|
||||
|
13
bigbluebutton-html5/imports/ui/services/unread-messages/index.js
Normal file → Executable file
13
bigbluebutton-html5/imports/ui/services/unread-messages/index.js
Normal file → Executable 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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'),
|
||||
};
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
10
bigbluebutton-html5/package-lock.json
generated
10
bigbluebutton-html5/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user