Files
mirotalk/app/src/server.js
T
2022-06-08 08:21:27 +02:00

841 lines
28 KiB
JavaScript
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
http://patorjk.com/software/taag/#p=display&f=ANSI%20Regular&t=Server
███████ ███████ ██████  ██  ██ ███████ ██████ 
██      ██      ██   ██ ██  ██ ██      ██   ██ 
███████ █████  ██████  ██  ██ █████  ██████  
     ██ ██     ██   ██  ██  ██  ██     ██   ██ 
███████ ███████ ██  ██   ████   ███████ ██  ██                 
dependencies: {
compression : https://www.npmjs.com/package/compression
cors : https://www.npmjs.com/package/cors
dotenv : https://www.npmjs.com/package/dotenv
express : https://www.npmjs.com/package/express
ngrok : https://www.npmjs.com/package/ngrok
@sentry/node : https://www.npmjs.com/package/@sentry/node
@sentry/integrations : https://www.npmjs.com/package/@sentry/integrations
socket.io : https://www.npmjs.com/package/socket.io
swagger : https://www.npmjs.com/package/swagger-ui-express
uuid : https://www.npmjs.com/package/uuid
yamljs : https://www.npmjs.com/package/yamljs
}
*/
/**
* MiroTalk P2P - Server component
*
* @link GitHub: https://github.com/miroslavpejic85/mirotalk
* @link Live demo: https://mirotalk.up.railway.app or https://mirotalk.herokuapp.com
* @license For open source use: AGPLv3
* @license For commercial or closed source, contact us at info.mirotalk@gmail.com
* @author Miroslav Pejic - miroslav.pejic.85@gmail.com
* @version 1.0.0
*
*/
'use strict'; // https://www.w3schools.com/js/js_strict.asp
require('dotenv').config();
const { Server } = require('socket.io');
const http = require('http');
const https = require('https');
const compression = require('compression');
const express = require('express');
const cors = require('cors');
const path = require('path');
const app = express();
const Logger = require('./Logger');
const log = new Logger('server');
const isHttps = false; // must be the same on client.js
const port = process.env.PORT || 3000; // must be the same to client.js signalingServerPort
let io, server, host;
if (isHttps) {
const fs = require('fs');
const options = {
key: fs.readFileSync(path.join(__dirname, '../ssl/key.pem'), 'utf-8'),
cert: fs.readFileSync(path.join(__dirname, '../ssl/cert.pem'), 'utf-8'),
};
server = https.createServer(options, app);
host = 'https://' + 'localhost' + ':' + port;
} else {
server = http.createServer(app);
host = 'http://' + 'localhost' + ':' + port;
}
/*
Set maxHttpBufferSize from 1e6 (1MB) to 1e7 (10MB)
*/
io = new Server({
maxHttpBufferSize: 1e7,
}).listen(server);
// console.log(io);
// Swagger config
const yamlJS = require('yamljs');
const swaggerUi = require('swagger-ui-express');
const swaggerDocument = yamlJS.load(path.join(__dirname + '/../api/swagger.yaml'));
// Api config
const { v4: uuidV4 } = require('uuid');
const apiBasePath = '/api/v1'; // api endpoint path
const api_docs = host + apiBasePath + '/docs'; // api docs
const api_key_secret = process.env.API_KEY_SECRET || 'mirotalk_default_secret';
// Ngrok config
const ngrok = require('ngrok');
const ngrokEnabled = process.env.NGROK_ENABLED || false;
const ngrokAuthToken = process.env.NGROK_AUTH_TOKEN;
// Turn config
const turnEnabled = process.env.TURN_ENABLED || false;
const turnUrls = process.env.TURN_URLS;
const turnUsername = process.env.TURN_USERNAME;
const turnCredential = process.env.TURN_PASSWORD;
// Sentry config
const Sentry = require('@sentry/node');
const { CaptureConsole } = require('@sentry/integrations');
const sentryEnabled = process.env.SENTRY_ENABLED || false;
const sentryDSN = process.env.SENTRY_DSN;
const sentryTracesSampleRate = process.env.SENTRY_TRACES_SAMPLE_RATE;
// Setup sentry client
if (sentryEnabled == 'true') {
Sentry.init({
dsn: sentryDSN,
integrations: [
new CaptureConsole({
// array of methods that should be captured
// defaults to ['log', 'info', 'warn', 'error', 'debug', 'assert']
levels: ['warn', 'error'],
}),
],
// Set tracesSampleRate to 1.0 to capture 100%
// of transactions for performance monitoring.
// We recommend adjusting this value in production
tracesSampleRate: sentryTracesSampleRate,
});
}
// directory
const dir = {
public: path.join(__dirname, '../../', 'public'),
};
// html views
const view = {
about: path.join(__dirname, '../../', 'public/view/about.html'),
client: path.join(__dirname, '../../', 'public/view/client.html'),
landing: path.join(__dirname, '../../', 'public/view/landing.html'),
newCall: path.join(__dirname, '../../', 'public/view/newcall.html'),
notFound: path.join(__dirname, '../../', 'public/view/404.html'),
permission: path.join(__dirname, '../../', 'public/view/permission.html'),
privacy: path.join(__dirname, '../../', 'public/view/privacy.html'),
};
let channels = {}; // collect channels
let sockets = {}; // collect sockets
let peers = {}; // collect peers info grp by channels
app.use(cors()); // Enable All CORS Requests for all origins
app.use(compression()); // Compress all HTTP responses using GZip
app.use(express.json()); // Api parse body data as json
app.use(express.static(dir.public)); // Use all static files from the public folder
// Remove trailing slashes in url handle bad requests
app.use((err, req, res, next) => {
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
log.debug('Request Error', {
header: req.headers,
body: req.body,
error: err.message,
});
return res.status(400).send({ status: 404, message: err.message }); // Bad request
}
if (req.path.substr(-1) === '/' && req.path.length > 1) {
let query = req.url.slice(req.path.length);
res.redirect(301, req.path.slice(0, -1) + query);
} else {
next();
}
});
// all start from here
app.get(['/'], (req, res) => {
res.sendFile(view.landing);
});
// mirotalk about
app.get(['/about'], (req, res) => {
res.sendFile(view.about);
});
// set new room name and join
app.get(['/newcall'], (req, res) => {
res.sendFile(view.newCall);
});
// if not allow video/audio
app.get(['/permission'], (req, res) => {
res.sendFile(view.permission);
});
// privacy policy
app.get(['/privacy'], (req, res) => {
res.sendFile(view.privacy);
});
// no room name specified to join
app.get('/join/', (req, res) => {
if (Object.keys(req.query).length > 0) {
log.debug('Request Query', req.query);
/*
http://localhost:3000/join?room=test&name=mirotalk&audio=1&video=1&screen=1&notify=1
https://mirotalk.up.railway.app/join?room=test&name=mirotalk&audio=1&video=1&screen=1&notify=1
https://mirotalk.herokuapp.com/join?room=test&name=mirotalk&audio=1&video=1&screen=1&notify=1
*/
const { room, name, audio, video, screen, notify } = req.query;
// all the params are mandatory for the direct room join
if (room && name && audio && video && screen && notify) {
return res.sendFile(view.client);
}
}
res.redirect('/');
});
// Join Room *
app.get('/join/*', (req, res) => {
res.sendFile(view.client);
});
/**
MiroTalk API v1
For api docs we use: https://swagger.io/
*/
// api docs
app.use(apiBasePath + '/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
// request meeting room endpoint
app.post([apiBasePath + '/meeting'], (req, res) => {
// check if user was authorized for the api call
let authorization = req.headers.authorization;
if (authorization != api_key_secret) {
log.debug('MiroTalk get meeting - Unauthorized', {
header: req.headers,
body: req.body,
});
return res.status(403).json({ error: 'Unauthorized!' });
}
// setup meeting URL
let host = req.headers.host;
let meetingURL = getMeetingURL(host);
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ meeting: meetingURL }));
// log.debug the output if all done
log.debug('MiroTalk get meeting - Authorized', {
header: req.headers,
body: req.body,
meeting: meetingURL,
});
});
/**
* Request meeting room endpoint
* @returns entrypoint / Room URL for your meeting.
*/
function getMeetingURL(host) {
return 'http' + (host.includes('localhost') ? '' : 's') + '://' + host + '/join/' + uuidV4();
}
// end of MiroTalk API v1
// not match any of page before, so 404 not found
app.get('*', function (req, res) {
res.sendFile(view.notFound);
});
/**
* You should probably use a different stun-turn server
* doing commercial stuff, also see:
*
* https://github.com/coturn/coturn
* https://gist.github.com/zziuni/3741933
* https://www.twilio.com/docs/stun-turn
*
* Check the functionality of STUN/TURN servers:
* https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/
*/
const iceServers = [];
if (turnEnabled == 'true') {
iceServers.push(
{
urls: 'stun:stun.l.google.com:19302',
},
{
urls: turnUrls,
username: turnUsername,
credential: turnCredential,
},
);
} else {
// My own As backup if not configured, please configure your in the .env file
iceServers.push(
{
urls: 'stun:stun.l.google.com:19302',
},
{
urls: 'turn:numb.viagenie.ca',
username: 'miroslav.pejic.85@gmail.com',
credential: 'mirotalkp2p',
},
);
}
/**
* Expose server to external with https tunnel using ngrok
* https://ngrok.com
*/
async function ngrokStart() {
try {
await ngrok.authtoken(ngrokAuthToken);
await ngrok.connect(port);
let api = ngrok.getApi();
let data = await api.listTunnels();
let pu0 = data.tunnels[0].public_url;
let pu1 = data.tunnels[1].public_url;
let tunnelHttps = pu0.startsWith('https') ? pu0 : pu1;
// server settings
log.debug('settings', {
iceServers: iceServers,
ngrok: {
ngrok_enabled: ngrokEnabled,
ngrok_token: ngrokAuthToken,
},
server: host,
server_tunnel: tunnelHttps,
api_docs: api_docs,
api_key_secret: api_key_secret,
sentry_enabled: sentryEnabled,
node_version: process.versions.node,
});
} catch (err) {
log.warn('[Error] ngrokStart', err.body);
process.exit(1);
}
}
/**
* Start Local Server with ngrok https tunnel (optional)
*/
server.listen(port, null, () => {
log.debug(
`%c
███████╗██╗ ██████╗ ███╗ ██╗ ███████╗███████╗██████╗ ██╗ ██╗███████╗██████╗
██╔════╝██║██╔════╝ ████╗ ██║ ██╔════╝██╔════╝██╔══██╗██║ ██║██╔════╝██╔══██╗
███████╗██║██║ ███╗██╔██╗ ██║█████╗███████╗█████╗ ██████╔╝██║ ██║█████╗ ██████╔╝
╚════██║██║██║ ██║██║╚██╗██║╚════╝╚════██║██╔══╝ ██╔══██╗╚██╗ ██╔╝██╔══╝ ██╔══██╗
███████║██║╚██████╔╝██║ ╚████║ ███████║███████╗██║ ██║ ╚████╔╝ ███████╗██║ ██║
╚══════╝╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═══╝ ╚══════╝╚═╝ ╚═╝ started...
`,
'font-family:monospace',
);
// https tunnel
if (ngrokEnabled == 'true' && isHttps === false) {
ngrokStart();
} else {
// server settings
log.debug('settings', {
iceServers: iceServers,
server: host,
api_docs: api_docs,
api_key_secret: api_key_secret,
sentry_enabled: sentryEnabled,
node_version: process.versions.node,
});
}
});
/**
* On peer connected
* Users will connect to the signaling server, after which they'll issue a "join"
* to join a particular channel. The signaling server keeps track of all sockets
* who are in a channel, and on join will send out 'addPeer' events to each pair
* of users in a channel. When clients receive the 'addPeer' even they'll begin
* setting up an RTCPeerConnection with one another. During this process they'll
* need to relay ICECandidate information to one another, as well as SessionDescription
* information. After all of that happens, they'll finally be able to complete
* the peer connection and will be in streaming audio/video between eachother.
*/
io.sockets.on('connect', (socket) => {
log.debug('[' + socket.id + '] connection accepted');
socket.channels = {};
sockets[socket.id] = socket;
const transport = socket.conn.transport.name; // in most cases, "polling"
log.debug('[' + socket.id + '] Connection transport', transport);
/**
* Check upgrade transport
*/
socket.conn.on('upgrade', () => {
const upgradedTransport = socket.conn.transport.name; // in most cases, "websocket"
log.debug('[' + socket.id + '] Connection upgraded transport', upgradedTransport);
});
/**
* On peer diconnected
*/
socket.on('disconnect', (reason) => {
for (let channel in socket.channels) {
removePeerFrom(channel);
}
log.debug('[' + socket.id + '] disconnected', { reason: reason });
delete sockets[socket.id];
});
/**
* On peer join
*/
socket.on('join', (config) => {
log.debug('[' + socket.id + '] join ', config);
let channel = config.channel;
let channel_password = config.channel_password;
let peer_name = config.peer_name;
let peer_video = config.peer_video;
let peer_audio = config.peer_audio;
let peer_hand = config.peer_hand;
let peer_rec = config.peer_rec;
if (channel in socket.channels) {
log.debug('[' + socket.id + '] [Warning] already joined', channel);
return;
}
// no channel aka room in channels init
if (!(channel in channels)) channels[channel] = {};
// no channel aka room in peers init
if (!(channel in peers)) peers[channel] = {};
// room locked by the participants can't join
if (peers[channel]['lock'] === true && peers[channel]['password'] != channel_password) {
log.debug('[' + socket.id + '] [Warning] Room Is Locked', channel);
socket.emit('roomIsLocked');
return;
}
// collect peers info grp by channels
peers[channel][socket.id] = {
peer_name: peer_name,
peer_video: peer_video,
peer_audio: peer_audio,
peer_hand: peer_hand,
peer_rec: peer_rec,
};
log.debug('connected peers grp by roomId', peers);
addPeerTo(channel);
channels[channel][socket.id] = socket;
socket.channels[channel] = channel;
// Send some server info to joined peer
sendToPeer(socket.id, sockets, 'serverInfo', { peers_count: Object.keys(peers[channel]).length });
});
/**
* Add peers to channel
* @param {string} channel room id
*/
async function addPeerTo(channel) {
for (let id in channels[channel]) {
// offer false
await channels[channel][id].emit('addPeer', {
peer_id: socket.id,
peers: peers[channel],
should_create_offer: false,
iceServers: iceServers,
});
// offer true
socket.emit('addPeer', {
peer_id: id,
peers: peers[channel],
should_create_offer: true,
iceServers: iceServers,
});
log.debug('[' + socket.id + '] emit addPeer [' + id + ']');
}
}
/**
* Remove peers from channel
* @param {string} channel room id
*/
async function removePeerFrom(channel) {
if (!(channel in socket.channels)) {
log.debug('[' + socket.id + '] [Warning] not in ', channel);
return;
}
try {
delete socket.channels[channel];
delete channels[channel][socket.id];
delete peers[channel][socket.id]; // delete peer data from the room
switch (Object.keys(peers[channel]).length) {
case 0: // last peer disconnected from the room without room lock & password set
case 2: // last peer disconnected from the room having room lock & password set
delete peers[channel]; // clean lock and password value from the room
break;
}
} catch (err) {
log.error('Remove Peer', toJson(err));
}
log.debug('connected peers grp by roomId', peers);
for (let id in channels[channel]) {
await channels[channel][id].emit('removePeer', { peer_id: socket.id });
socket.emit('removePeer', { peer_id: id });
log.debug('[' + socket.id + '] emit removePeer [' + id + ']');
}
}
/**
* Relay ICE to peers
*/
socket.on('relayICE', (config) => {
let peer_id = config.peer_id;
let ice_candidate = config.ice_candidate;
// log.debug('[' + socket.id + '] relay ICE-candidate to [' + peer_id + '] ', {
// address: config.ice_candidate,
// });
sendToPeer(peer_id, sockets, 'iceCandidate', {
peer_id: socket.id,
ice_candidate: ice_candidate,
});
});
/**
* Relay SDP to peers
*/
socket.on('relaySDP', (config) => {
let peer_id = config.peer_id;
let session_description = config.session_description;
log.debug('[' + socket.id + '] relay SessionDescription to [' + peer_id + '] ', {
type: session_description.type,
});
sendToPeer(peer_id, sockets, 'sessionDescription', {
peer_id: socket.id,
session_description: session_description,
});
});
/**
* Handle Room action
*/
socket.on('roomAction', (config) => {
//log.debug('[' + socket.id + '] Room action:', config);
let room_is_locked = false;
let room_id = config.room_id;
let peer_name = config.peer_name;
let password = config.password;
let action = config.action;
//
switch (action) {
case 'lock':
peers[room_id]['lock'] = true;
peers[room_id]['password'] = password;
sendToRoom(room_id, socket.id, 'roomAction', {
peer_name: peer_name,
action: action,
});
room_is_locked = true;
break;
case 'unlock':
peers[room_id]['lock'] = false;
peers[room_id]['password'] = password;
sendToRoom(room_id, socket.id, 'roomAction', {
peer_name: peer_name,
action: action,
});
break;
case 'checkPassword':
let config = {
peer_name: peer_name,
action: action,
password: password === peers[room_id]['password'] ? 'OK' : 'KO',
};
sendToPeer(socket.id, sockets, 'roomAction', config);
break;
}
log.debug('[' + socket.id + '] Room ' + room_id, { locked: room_is_locked, password: password });
});
/**
* Relay NAME to peers
*/
socket.on('peerName', (config) => {
let room_id = config.room_id;
let peer_name_old = config.peer_name_old;
let peer_name_new = config.peer_name_new;
let peer_id_to_update = null;
for (let peer_id in peers[room_id]) {
if (peers[room_id][peer_id]['peer_name'] == peer_name_old) {
peers[room_id][peer_id]['peer_name'] = peer_name_new;
peer_id_to_update = peer_id;
}
}
if (peer_id_to_update) {
log.debug('[' + socket.id + '] emit peerName to [room_id: ' + room_id + ']', {
peer_id: peer_id_to_update,
peer_name: peer_name_new,
});
sendToRoom(room_id, socket.id, 'peerName', {
peer_id: peer_id_to_update,
peer_name: peer_name_new,
});
}
});
/**
* Relay Audio Video Hand ... Status to peers
*/
socket.on('peerStatus', (config) => {
let room_id = config.room_id;
let peer_name = config.peer_name;
let element = config.element;
let status = config.status;
for (let peer_id in peers[room_id]) {
if (peers[room_id][peer_id]['peer_name'] === peer_name) {
switch (element) {
case 'video':
peers[room_id][peer_id]['peer_video'] = status;
break;
case 'audio':
peers[room_id][peer_id]['peer_audio'] = status;
break;
case 'hand':
peers[room_id][peer_id]['peer_hand'] = status;
break;
case 'rec':
peers[room_id][peer_id]['peer_rec'] = status;
break;
}
}
}
log.debug('[' + socket.id + '] emit peerStatus to [room_id: ' + room_id + ']', {
peer_id: socket.id,
element: element,
status: status,
});
sendToRoom(room_id, socket.id, 'peerStatus', {
peer_id: socket.id,
peer_name: peer_name,
element: element,
status: status,
});
});
/**
* Relay actions to peers or specific peer in the same room
*/
socket.on('peerAction', (config) => {
let room_id = config.room_id;
let peer_name = config.peer_name;
let peer_action = config.peer_action;
let peer_id = config.peer_id;
if (peer_id) {
log.debug('[' + socket.id + '] emit peerAction to [' + peer_id + '] from room_id [' + room_id + ']');
sendToPeer(peer_id, sockets, 'peerAction', {
peer_name: peer_name,
peer_action: peer_action,
});
} else {
log.debug('[' + socket.id + '] emit peerAction to [room_id: ' + room_id + ']', {
peer_id: socket.id,
peer_name: peer_name,
peer_action: peer_action,
});
sendToRoom(room_id, socket.id, 'peerAction', {
peer_name: peer_name,
peer_action: peer_action,
});
}
});
/**
* Relay Kick out peer from room
*/
socket.on('kickOut', (config) => {
let room_id = config.room_id;
let peer_id = config.peer_id;
let peer_name = config.peer_name;
log.debug('[' + socket.id + '] kick out peer [' + peer_id + '] from room_id [' + room_id + ']');
sendToPeer(peer_id, sockets, 'kickOut', {
peer_name: peer_name,
});
});
/**
* Relay File info
*/
socket.on('fileInfo', (config) => {
let room_id = config.room_id;
let peer_name = config.peer_name;
let peer_id = config.peer_id;
let broadcast = config.broadcast;
let file = config.file;
function bytesToSize(bytes) {
let sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
if (bytes == 0) return '0 Byte';
let i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)));
return Math.round(bytes / Math.pow(1024, i), 2) + ' ' + sizes[i];
}
file['peerName'] = peer_name;
log.debug('[' + socket.id + '] Peer [' + peer_name + '] send file to room_id [' + room_id + ']', {
peerName: file.peerName,
fileName: file.fileName,
fileSize: bytesToSize(file.fileSize),
fileType: file.fileType,
broadcast: broadcast,
});
if (broadcast) {
sendToRoom(room_id, socket.id, 'fileInfo', file);
} else {
sendToPeer(peer_id, sockets, 'fileInfo', file);
}
});
/**
* Abort file sharing
*/
socket.on('fileAbort', (config) => {
let room_id = config.room_id;
let peer_name = config.peer_name;
log.debug('[' + socket.id + '] Peer [' + peer_name + '] send fileAbort to room_id [' + room_id + ']');
sendToRoom(room_id, socket.id, 'fileAbort');
});
/**
* Relay video player action
*/
socket.on('videoPlayer', (config) => {
let room_id = config.room_id;
let peer_name = config.peer_name;
let video_action = config.video_action;
let video_src = config.video_src;
let peer_id = config.peer_id;
let sendConfig = {
peer_name: peer_name,
video_action: video_action,
video_src: video_src,
};
let logMe = {
peer_id: socket.id,
peer_name: peer_name,
video_action: video_action,
video_src: video_src,
};
if (peer_id) {
log.debug(
'[' + socket.id + '] emit videoPlayer to [' + peer_id + '] from room_id [' + room_id + ']',
logMe,
);
sendToPeer(peer_id, sockets, 'videoPlayer', sendConfig);
} else {
log.debug('[' + socket.id + '] emit videoPlayer to [room_id: ' + room_id + ']', logMe);
sendToRoom(room_id, socket.id, 'videoPlayer', sendConfig);
}
});
/**
* Whiteboard actions for all user in the same room
*/
socket.on('wbCanvasToJson', (config) => {
let room_id = config.room_id;
// log.debug('Whiteboard send canvas', config);
sendToRoom(room_id, socket.id, 'wbCanvasToJson', config);
});
socket.on('whiteboardAction', (config) => {
log.debug('Whiteboard', config);
let room_id = config.room_id;
sendToRoom(room_id, socket.id, 'whiteboardAction', config);
});
}); // end [sockets.on-connect]
/**
* Object to Json
* @param {object} data object
* @returns {json} indent 4 spaces
*/
function toJson(data) {
return JSON.stringify(data, null, 4); // "\t"
}
/**
* Send async data to all peers in the same room except yourself
* @param {string} room_id id of the room to send data
* @param {string} socket_id socket id of peer that send data
* @param {string} msg message to send to the peers in the same room
* @param {object} config data to send to the peers in the same room
*/
async function sendToRoom(room_id, socket_id, msg, config = {}) {
for (let peer_id in channels[room_id]) {
// not send data to myself
if (peer_id != socket_id) {
await channels[room_id][peer_id].emit(msg, config);
//console.log('Send to room', { msg: msg, config: config });
}
}
}
/**
* Send async data to specified peer
* @param {string} peer_id id of the peer to send data
* @param {object} sockets all peers connections
* @param {string} msg message to send to the peer in the same room
* @param {object} config data to send to the peer in the same room
*/
async function sendToPeer(peer_id, sockets, msg, config = {}) {
if (peer_id in sockets) {
await sockets[peer_id].emit(msg, config);
//console.log('Send to peer', { msg: msg, config: config });
}
}