ajout app

This commit is contained in:
2024-04-17 20:22:30 +02:00
parent cc017cfc5e
commit f9d05a2fd3
8025 changed files with 729805 additions and 0 deletions

View File

@ -0,0 +1,62 @@
![restAPI](restAPI.png)
## Create a meeting
Create a meeting with a `HTTP request` containing the `API_KEY` sent to MiroTalks server. The response contains a `meeting` URL that can be `embedded` in your client within an `iframe`.
The `API_KEY` is defined in the `app/src/config.js`, change it with your own.
```js
api: {
// app/api
keySecret: 'mirotalksfu_default_secret',
}
```
Some examples demonstrating how to call the API:
```bash
# js
node meetings.js
node meeting.js
node join.js
# php
php meetings.php
php meeting.php
php join.php
# python
python3 meetings.py
python3 meeting.py
python3 join.py
# bash
./meetings.sh
./meeting.sh
./join.sh
```
## Embed a meeting
Embedding a meeting into a `service` or `app` requires using an `iframe` with the `src` attribute specified as the `meeting` from `HTTP response`.
```html
<iframe
allow="camera; microphone; display-capture; fullscreen; clipboard-read; clipboard-write; autoplay"
src="https://sfu.mirotalk.com/join/your_room_name"
style="height: 100vh; width: 100vw; border: 0px;"
></iframe>
```
## Fast Integration
Develop your `website` or `application`, and bring `video meetings` in with a simple `iframe`.
```html
<iframe
allow="camera; microphone; display-capture; fullscreen; clipboard-read; clipboard-write; autoplay"
src="https://sfu.mirotalk.com/newroom"
style="height: 100vh; width: 100vw; border: 0px;"
></iframe>
```

View File

@ -0,0 +1,46 @@
'use strict';
async function getJoin() {
try {
// Use dynamic import with await
const { default: fetch } = await import('node-fetch');
const API_KEY_SECRET = 'mirotalksfu_default_secret';
const MIROTALK_URL = 'https://sfu.mirotalk.com/api/v1/join';
//const MIROTALK_URL = 'http://localhost:3010/api/v1/join';
const response = await fetch(MIROTALK_URL, {
method: 'POST',
headers: {
authorization: API_KEY_SECRET,
'Content-Type': 'application/json',
},
body: JSON.stringify({
room: 'test',
roomPassword: false,
name: 'mirotalksfu',
audio: true,
video: true,
screen: true,
hide: false,
notify: true,
token: {
username: 'username',
password: 'password',
presenter: true,
expire: '1h',
},
}),
});
const data = await response.json();
if (data.error) {
console.log('Error:', data.error);
} else {
console.log('join:', data.join);
}
} catch (error) {
console.error('Error fetching data:', error);
}
}
getJoin();

View File

@ -0,0 +1,45 @@
<?php
$API_KEY_SECRET = "mirotalksfu_default_secret";
$MIROTALK_URL = "https://sfu.mirotalk.com/api/v1/join";
// $MIROTALK_URL = "http://localhost:3010/api/v1/join";
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $MIROTALK_URL);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_POST, 1);
$headers = [
'authorization:' . $API_KEY_SECRET,
'Content-Type: application/json'
];
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
$data = array(
"room" => "test",
"roomPassword" => false,
"name" => "mirotalksfu",
"audio" => true,
"video" => true,
"screen" => true,
"hide" => false,
"notify" => true,
"token" => array(
"username" => "username",
"password" => "password",
"presenter" => true,
"expire" => "1h",
),
);
$data_string = json_encode($data);
curl_setopt($ch, CURLOPT_POSTFIELDS, $data_string);
$response = curl_exec($ch);
$httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
echo "Status code: $httpcode \n";
$data = json_decode($response);
echo "join: ", $data->{'join'}, "\n";

View File

@ -0,0 +1,39 @@
# pip3 install requests
import requests
import json
API_KEY_SECRET = "mirotalksfu_default_secret"
MIROTALK_URL = "https://sfu.mirotalk.com/api/v1/join"
# MIROTALK_URL = "http://localhost:3010/api/v1/join"
headers = {
"authorization": API_KEY_SECRET,
"Content-Type": "application/json",
}
data = {
"room": "test",
"roomPassword": "false",
"name": "mirotalksfu",
"audio": "true",
"video": "true",
"screen": "true",
"hide": "false",
"notify": "true",
"token": {
"username": "username",
"password": "password",
"presenter": "true",
"expire": "1h",
}
}
response = requests.post(
MIROTALK_URL,
headers=headers,
json=data,
)
print("Status code:", response.status_code)
data = json.loads(response.text)
print("join:", data["join"])

View File

@ -0,0 +1,11 @@
#!/bin/bash
API_KEY_SECRET="mirotalksfu_default_secret"
MIROTALK_URL="https://sfu.mirotalk.com/api/v1/join"
# MIROTALK_URL="http://localhost:3010/api/v1/join"
curl $MIROTALK_URL \
--header "authorization: $API_KEY_SECRET" \
--header "Content-Type: application/json" \
--data '{"room":"test","roomPassword":"false","name":"mirotalksfu","audio":"true","video":"true","screen":"false","hide":"false","notify":"true","token":{"username":"username","password":"password","presenter":"true", "expire":"1h"}}' \
--request POST

View File

@ -0,0 +1,30 @@
'use strict';
async function getMeeting() {
try {
// Use dynamic import with await
const { default: fetch } = await import('node-fetch');
const API_KEY_SECRET = 'mirotalksfu_default_secret';
const MIROTALK_URL = 'https://sfu.mirotalk.com/api/v1/meeting';
// const MIROTALK_URL = 'http://localhost:3010/api/v1/meeting';
const response = await fetch(MIROTALK_URL, {
method: 'POST',
headers: {
authorization: API_KEY_SECRET,
'Content-Type': 'application/json',
},
});
const data = await response.json();
if (data.error) {
console.log('Error:', data.error);
} else {
console.log('meeting:', data.meeting);
}
} catch (error) {
console.error('Error fetching data:', error);
}
}
getMeeting();

View File

@ -0,0 +1,25 @@
<?php
$API_KEY_SECRET = "mirotalksfu_default_secret";
$MIROTALK_URL = "https://sfu.mirotalk.com/api/v1/meeting";
// $MIROTALK_URL = "http://localhost:3010/api/v1/meeting";
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $MIROTALK_URL);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_POST, 1);
$headers = [
'authorization:' . $API_KEY_SECRET,
'Content-Type: application/json'
];
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
$response = curl_exec($ch);
$httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
echo "Status code: $httpcode \n";
$data = json_decode($response);
echo "meeting: ", $data->{'meeting'}, "\n";

View File

@ -0,0 +1,21 @@
# pip3 install requests
import requests
import json
API_KEY_SECRET = "mirotalksfu_default_secret"
MIROTALK_URL = "https://sfu.mirotalk.com/api/v1/meeting"
# MIROTALK_URL = "http://localhost:3010/api/v1/meeting"
headers = {
"authorization": API_KEY_SECRET,
"Content-Type": "application/json",
}
response = requests.post(
MIROTALK_URL,
headers=headers
)
print("Status code:", response.status_code)
data = json.loads(response.text)
print("meeting:", data["meeting"])

View File

@ -0,0 +1,10 @@
#!/bin/bash
API_KEY_SECRET="mirotalksfu_default_secret"
MIROTALK_URL="https://sfu.mirotalk.com/api/v1/meeting"
# MIROTALK_URL="http://localhost:3010/api/v1/meeting"
curl $MIROTALK_URL \
--header "authorization: $API_KEY_SECRET" \
--header "Content-Type: application/json" \
--request POST

View File

@ -0,0 +1,34 @@
'use strict';
async function getMeetings() {
try {
// Use dynamic import with await
const { default: fetch } = await import('node-fetch');
const API_KEY_SECRET = 'mirotalksfu_default_secret';
const MIROTALK_URL = 'https://sfu.mirotalk.com/api/v1/meetings';
//const MIROTALK_URL = 'http://localhost:3010/api/v1/meetings';
const response = await fetch(MIROTALK_URL, {
method: 'GET',
headers: {
authorization: API_KEY_SECRET,
'Content-Type': 'application/json',
},
});
const data = await response.json();
if (data.error) {
console.log('Error:', data.error);
} else {
if (data && data.meetings) {
const meetings = data.meetings;
const formattedData = JSON.stringify({ meetings }, null, 2);
console.log(formattedData);
}
}
} catch (error) {
console.error('Error fetching data:', error);
}
}
getMeetings();

View File

@ -0,0 +1,29 @@
<?php
$API_KEY_SECRET = "mirotalksfu_default_secret";
$MIROTALK_URL = "https://sfu.mirotalk.com/api/v1/meetings";
//$MIROTALK_URL = "http://localhost:3010/api/v1/meetings";
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $MIROTALK_URL);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HTTPGET, true);
$headers = [
'authorization:' . $API_KEY_SECRET,
'Content-Type: application/json'
];
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
$response = curl_exec($ch);
$httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
echo "Status code: $httpcode \n";
if ($response) {
echo json_encode(json_decode($response), JSON_PRETTY_PRINT);
} else {
echo "Failed to retrieve data.\n";
}

View File

@ -0,0 +1,26 @@
# pip3 install requests
import requests
import json
API_KEY_SECRET = "mirotalksfu_default_secret"
MIROTALK_URL = "https://sfu.mirotalk.com/api/v1/meetings"
#MIROTALK_URL = "http://localhost:3010/api/v1/meetings"
headers = {
"authorization": API_KEY_SECRET,
"Content-Type": "application/json",
}
response = requests.get(
MIROTALK_URL,
headers=headers
)
print("Status code:", response.status_code)
if response.status_code == 200:
data = response.json()
pretty_printed_data = json.dumps(data, indent=4)
print(data)
else:
print("Failed to retrieve data. Error:", response.text)

View File

@ -0,0 +1,10 @@
#!/bin/bash
API_KEY_SECRET="mirotalksfu_default_secret"
MIROTALK_URL="https://sfu.mirotalk.com/api/v1/meetings"
#MIROTALK_URL="http://localhost:3010/api/v1/meetings"
curl $MIROTALK_URL \
--header "authorization: $API_KEY_SECRET" \
--header "Content-Type: application/json" \
--request GET

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1,204 @@
swagger: '2.0'
info:
title: MiroTalk SFU API
description: API description for external applications that integrate with MiroTalk SFU.
version: 1.0.1
basePath: /api/v1
schemes:
- https
- http
paths:
/meetings:
get:
tags:
- 'meetings'
summary: 'Get meetings'
description: 'Get meetings'
produces:
- 'application/json'
security:
- secretApiKey: []
responses:
'200':
description: 'Get Meetings done'
schema:
$ref: '#/definitions/MeetingsResponse'
'403':
description: 'Unauthorized!'
/meeting:
post:
tags:
- 'meeting'
summary: 'Create meeting'
description: 'Create meeting'
consumes:
- 'application/json'
produces:
- 'application/json'
security:
- secretApiKey: []
responses:
'200':
description: 'Meeting created'
schema:
$ref: '#/definitions/MeetingResponse'
'403':
description: 'Unauthorized!'
/join:
post:
tags:
- 'join'
summary: 'Create direct join'
description: 'Create join'
parameters:
- in: body
name: Join
description: Custom Join URL.
schema:
$ref: '#/definitions/JoinRequest'
consumes:
- 'application/json'
produces:
- 'application/json'
security:
- secretApiKey: []
responses:
'200':
description: 'Direct join created'
schema:
$ref: '#/definitions/JoinResponse'
'403':
description: 'Unauthorized!'
/token:
post:
tags:
- 'token'
summary: 'Get token'
description: 'Get token'
parameters:
- in: body
name: token
description: Custom Token.
schema:
$ref: '#/definitions/TokenRequest'
consumes:
- 'application/json'
produces:
- 'application/json'
security:
- secretApiKey: []
responses:
'200':
description: 'Get token done'
schema:
$ref: '#/definitions/TokenResponse'
'403':
description: 'Unauthorized!'
securityDefinitions:
secretApiKey:
type: 'apiKey'
name: 'authorization'
in: 'header'
description: 'Format like this: authorization: {API_KEY_SECRET}'
definitions:
MeetingsResponse:
type: object
properties:
meetings:
type: array
items:
$ref: '#/definitions/Meeting'
MeetingResponse:
type: 'object'
properties:
meeting:
type: string
JoinRequest:
type: object
properties:
room:
type: string
default: 'test'
roomPassword:
type: ['boolean', 'string'] # Allow boolean or string type
default: false
name:
type: string
default: 'mirotalksfu'
audio:
type: boolean
default: false
video:
type: boolean
default: false
screen:
type: boolean
default: false
hide:
type: boolean
default: false
notify:
type: boolean
default: false
token:
$ref: '#/definitions/TokenRequest'
TokenRequest:
type: object
properties:
username:
type: string
default: 'username'
password:
type: string
default: 'password'
presenter:
type: boolean
default: true
expire:
type: string
default: '1h'
JoinResponse:
type: 'object'
properties:
join:
type: string
TokenResponse:
type: 'object'
properties:
token:
type: string
Peer:
type: object
properties:
name:
type: string
presenter:
type: boolean
video:
type: boolean
audio:
type: boolean
screen:
type: boolean
hand:
type: boolean
os:
type: string
browser:
type: string
Meeting:
type: object
properties:
roomId:
type: string
peers:
type: array
items:
$ref: '#/definitions/Peer'

View File

@ -0,0 +1,36 @@
'use strict';
async function getToken() {
try {
// Use dynamic import with await
const { default: fetch } = await import('node-fetch');
const API_KEY_SECRET = 'mirotalksfu_default_secret';
const MIROTALK_URL = 'https://sfu.mirotalk.com/api/v1/token';
//const MIROTALK_URL = 'http://localhost:3010/api/v1/token';
const response = await fetch(MIROTALK_URL, {
method: 'POST',
headers: {
authorization: API_KEY_SECRET,
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: 'username',
password: 'password',
presenter: true,
expire: '1h',
}),
});
const data = await response.json();
if (data.error) {
console.log('Error:', data.error);
} else {
console.log('token:', data.token);
}
} catch (error) {
console.error('Error fetching data:', error);
}
}
getToken();

View File

@ -0,0 +1,37 @@
<?php
$API_KEY_SECRET = "mirotalksfu_default_secret";
$MIROTALK_URL = "https://sfu.mirotalk.com/api/v1/token";
#$MIROTALK_URL = "http://localhost:3010/api/v1/token";
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $MIROTALK_URL);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_POST, 1);
$headers = [
'authorization:' . $API_KEY_SECRET,
'Content-Type: application/json'
];
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
$data = array(
"username" => "username",
"password" => "password",
"presenter" => true,
"expire" => "1h",
);
$data_string = json_encode($data);
curl_setopt($ch, CURLOPT_POSTFIELDS, $data_string);
$response = curl_exec($ch);
$httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
echo "Status code: $httpcode \n";
$data = json_decode($response);
echo "token: ", $data->{'token'}, "\n";

View File

@ -0,0 +1,29 @@
# pip3 install requests
import requests
import json
API_KEY_SECRET = "mirotalksfu_default_secret"
MIROTALK_URL = "https://sfu.mirotalk.com/api/v1/token"
#MIROTALK_URL = "http://localhost:3010/api/v1/token"
headers = {
"authorization": API_KEY_SECRET,
"Content-Type": "application/json",
}
data = {
"username": "username",
"password": "password",
"presenter": "true",
"expire": "1h"
}
response = requests.post(
MIROTALK_URL,
headers=headers,
json=data
)
print("Status code:", response.status_code)
data = json.loads(response.text)
print("token:", data["token"])

View File

@ -0,0 +1,11 @@
#!/bin/bash
API_KEY_SECRET="mirotalksfu_default_secret"
MIROTALK_URL="https://sfu.mirotalk.com/api/v1/token"
#MIROTALK_URL="http://localhost:3010/api/v1/token"
curl $MIROTALK_URL \
--header "authorization: $API_KEY_SECRET" \
--header "Content-Type: application/json" \
--data '{"username":"username","password":"password","presenter":"true", "expire":"1h"}' \
--request POST

View File

@ -0,0 +1,42 @@
'use strict';
module.exports = class Host {
constructor() {
this.authorizedIPs = new Map();
}
/**
* Get authorized IPs
* @returns object
*/
getAuthorizedIPs() {
return Object.fromEntries(this.authorizedIPs);
}
/**
* Set authorized IP
* @param {string} ip
* @param {boolean} authorized
*/
setAuthorizedIP(ip, authorized) {
this.authorizedIPs.set(ip, authorized);
}
/**
* Check if IP is authorized
* @param {string} ip
* @returns boolean
*/
isAuthorizedIP(ip) {
return this.authorizedIPs.has(ip);
}
/**
* Delete ip from authorized IPs
* @param {string} ip
* @returns boolean
*/
deleteIP(ip) {
return this.authorizedIPs.delete(ip);
}
};

View File

@ -0,0 +1,90 @@
'use strict';
const util = require('util');
const colors = require('colors');
const config = require('./config');
config.console.colors ? colors.enable() : colors.disable();
const options = {
depth: null,
colors: true,
};
module.exports = class Logger {
constructor(appName = 'miroTalkSfu') {
this.appName = colors.yellow(appName);
this.debugOn = config.console.debug;
this.timeStart = Date.now();
this.timeEnd = null;
this.timeElapsedMs = null;
this.tzOptions = {
timeZone: process.env.TZ || config.console.timeZone || 'UTC',
hour12: false,
};
}
debug(msg, op = '') {
if (this.debugOn) {
this.timeEnd = Date.now();
this.timeElapsedMs = this.getFormatTime(Math.floor(this.timeEnd - this.timeStart));
console.debug(
'[' + this.getDateTime() + '] [' + this.appName + '] ' + msg,
util.inspect(op, options),
this.timeElapsedMs,
);
this.timeStart = Date.now();
}
}
log(msg, op = '') {
console.log('[' + this.getDateTime() + '] [' + this.appName + '] ' + msg, util.inspect(op, options));
}
info(msg, op = '') {
console.info(
'[' + this.getDateTime() + '] [' + this.appName + '] ' + colors.green(msg),
util.inspect(op, options),
);
}
warn(msg, op = '') {
console.warn(
'[' + this.getDateTime() + '] [' + this.appName + '] ' + colors.yellow(msg),
util.inspect(op, options),
);
}
error(msg, op = '') {
console.error(
'[' + this.getDateTime() + '] [' + this.appName + '] ' + colors.red(msg),
util.inspect(op, options),
);
}
getDateTime() {
const currentTime = new Date().toLocaleString('en-US', this.tzOptions);
const milliseconds = String(new Date().getMilliseconds()).padStart(3, '0');
return colors.cyan(`${currentTime}:${milliseconds}`);
}
getFormatTime(ms) {
let time = Math.floor(ms);
let type = 'ms';
if (ms >= 1000) {
time = Math.floor((ms / 1000) % 60);
type = 's';
}
if (ms >= 60000) {
time = Math.floor((ms / 1000 / 60) % 60);
type = 'm';
}
if (ms >= (3, 6e6)) {
time = Math.floor((ms / 1000 / 60 / 60) % 24);
type = 'h';
}
return colors.magenta('+' + time + type);
}
};

View File

@ -0,0 +1,316 @@
'use strict';
const Logger = require('./Logger');
const log = new Logger('Peer');
module.exports = class Peer {
constructor(socket_id, data) {
const { peer_info } = data;
const { peer_name, peer_presenter, peer_audio, peer_video, peer_video_privacy, peer_recording, peer_hand } =
peer_info;
this.id = socket_id;
this.peer_info = peer_info;
this.peer_name = peer_name;
this.peer_presenter = peer_presenter;
this.peer_audio = peer_audio;
this.peer_video = peer_video;
this.peer_video_privacy = peer_video_privacy;
this.peer_recording = peer_recording;
this.peer_hand = peer_hand;
this.transports = new Map();
this.consumers = new Map();
this.producers = new Map();
}
// ####################################################
// UPDATE PEER INFO
// ####################################################
updatePeerInfo(data) {
log.debug('Update peer info', data);
switch (data.type) {
case 'audio':
case 'audioType':
this.peer_info.peer_audio = data.status;
this.peer_audio = data.status;
break;
case 'video':
case 'videoType':
this.peer_info.peer_video = data.status;
this.peer_video = data.status;
if (data.status == false) {
this.peer_info.peer_video_privacy = data.status;
this.peer_video_privacy = data.status;
}
break;
case 'screen':
case 'screenType':
this.peer_info.peer_screen = data.status;
break;
case 'hand':
this.peer_info.peer_hand = data.status;
this.peer_hand = data.status;
break;
case 'privacy':
this.peer_info.peer_video_privacy = data.status;
this.peer_video_privacy = data.status;
break;
case 'presenter':
this.peer_info.peer_presenter = data.status;
this.peer_presenter = data.status;
break;
case 'recording':
this.peer_info.peer_recording = data.status;
this.peer_recording = data.status;
break;
default:
break;
}
}
// ####################################################
// TRANSPORT
// ####################################################
getTransports() {
return JSON.parse(JSON.stringify([...this.transports]));
}
getTransport(transport_id) {
return this.transports.get(transport_id);
}
delTransport(transport_id) {
this.transports.delete(transport_id);
}
addTransport(transport) {
this.transports.set(transport.id, transport);
}
async connectTransport(transport_id, dtlsParameters) {
if (!this.transports.has(transport_id)) {
return false;
}
await this.transports.get(transport_id).connect({
dtlsParameters: dtlsParameters,
});
return true;
}
close() {
this.transports.forEach((transport, transport_id) => {
log.debug('Close and delete peer transports', {
transport_id: transport_id,
//transportInternal: transport.internal,
});
transport.close();
this.delTransport(transport_id);
});
}
// ####################################################
// PRODUCER
// ####################################################
getProducers() {
return JSON.parse(JSON.stringify([...this.producers]));
}
getProducer(producer_id) {
return this.producers.get(producer_id);
}
delProducer(producer_id) {
this.producers.delete(producer_id);
}
async createProducer(producerTransportId, producer_rtpParameters, producer_kind, producer_type) {
try {
if (!producerTransportId) {
return 'Invalid producer transport ID';
}
const producerTransport = this.transports.get(producerTransportId);
if (!producerTransport) {
return `Producer transport with ID ${producerTransportId} not found`;
}
const producer = await producerTransport.produce({
kind: producer_kind,
rtpParameters: producer_rtpParameters,
});
if (!producer) {
return `Producer type: ${producer_type} kind: ${producer_kind} not found`;
}
const { id, appData, type, kind, rtpParameters } = producer;
appData.mediaType = producer_type;
this.producers.set(id, producer);
if (['simulcast', 'svc'].includes(type)) {
const { scalabilityMode } = rtpParameters.encodings[0];
const spatialLayer = parseInt(scalabilityMode.substring(1, 2)); // 1/2/3
const temporalLayer = parseInt(scalabilityMode.substring(3, 4)); // 1/2/3
log.debug(`Producer [${type}-${kind}] ----->`, {
scalabilityMode,
spatialLayer,
temporalLayer,
});
} else {
log.debug('Producer ----->', { type: type, kind: kind });
}
producer.on('transportclose', () => {
log.debug('Producer "transportclose" event');
this.closeProducer(id);
});
return producer;
} catch (error) {
log.error('Error creating producer', error.message);
return error.message;
}
}
closeProducer(producer_id) {
if (!this.producers.has(producer_id)) return;
const producer = this.getProducer(producer_id);
const { id, kind, type, appData } = producer;
try {
producer.close();
} catch (error) {
log.warn('Close Producer', error.message);
}
this.delProducer(producer_id);
log.debug('Producer closed and deleted', {
peer_name: this.peer_name,
kind: kind,
type: type,
appData: appData,
producer_id: id,
});
}
// ####################################################
// CONSUMER
// ####################################################
getConsumers() {
return JSON.parse(JSON.stringify([...this.consumers]));
}
getConsumer(consumer_id) {
return this.consumers.get(consumer_id);
}
delConsumer(consumer_id) {
this.consumers.delete(consumer_id);
}
async createConsumer(consumer_transport_id, producer_id, rtpCapabilities) {
try {
if (!consumer_transport_id) {
return 'Invalid consumer transport ID';
}
const consumerTransport = this.transports.get(consumer_transport_id);
if (!consumerTransport) {
return `Consumer transport with id ${consumer_transport_id} not found`;
}
const consumer = await consumerTransport.consume({
producerId: producer_id,
rtpCapabilities,
enableRtx: true, // Enable NACK for OPUS.
paused: false,
});
if (!consumer) {
return `Consumer for producer ID ${producer_id} not found`;
}
const { id, type, kind, rtpParameters, producerPaused } = consumer;
if (['simulcast', 'svc'].includes(type)) {
// simulcast - L1T3/L2T3/L3T3 | svc - L3T3
const { scalabilityMode } = rtpParameters.encodings[0];
const spatialLayer = parseInt(scalabilityMode.substring(1, 2)); // 1/2/3
const temporalLayer = parseInt(scalabilityMode.substring(3, 4)); // 1/2/3
try {
await consumer.setPreferredLayers({
spatialLayer: spatialLayer,
temporalLayer: temporalLayer,
});
log.debug(`Consumer [${type}-${kind}] ----->`, {
scalabilityMode,
spatialLayer,
temporalLayer,
});
} catch (error) {
return `Error to set Consumer preferred layers: ${error.message}`;
}
} else {
log.debug('Consumer ----->', { type: type, kind: kind });
}
this.consumers.set(id, consumer);
consumer.on('transportclose', () => {
log.debug('Consumer "transportclose" event');
this.removeConsumer(id);
});
return {
consumer: consumer,
params: {
producerId: producer_id,
id: id,
kind: kind,
rtpParameters: rtpParameters,
type: type,
producerPaused: producerPaused,
},
};
} catch (error) {
log.error('Error creating consumer', error.message);
return error.message;
}
}
removeConsumer(consumer_id) {
if (!this.consumers.has(consumer_id)) return;
const consumer = this.getConsumer(consumer_id);
const { id, kind, type } = consumer;
try {
consumer.close();
} catch (error) {
log.warn('Close Consumer', error.message);
}
this.delConsumer(consumer_id);
log.debug('Consumer closed and deleted', {
peer_name: this.peer_name,
kind: kind,
type: type,
consumer_id: id,
});
}
};

View File

@ -0,0 +1,577 @@
'use strict';
const config = require('./config');
const Logger = require('./Logger');
const log = new Logger('Room');
module.exports = class Room {
constructor(room_id, worker, io) {
this.id = room_id;
this.worker = worker;
this.webRtcServer = worker.appData.webRtcServer;
this.webRtcServerActive = config.mediasoup.webRtcServerActive;
this.io = io;
this.audioLevelObserver = null;
this.audioLevelObserverEnabled = true;
this.audioLastUpdateTime = 0;
// ##########################
this._isBroadcasting = false;
// ##########################
this._isLocked = false;
this._isLobbyEnabled = false;
this._roomPassword = null;
this._hostOnlyRecording = false;
// ##########################
this._recSyncServerRecording = config?.server?.recording?.enabled || false;
// ##########################
this._moderator = {
audio_start_muted: false,
video_start_hidden: false,
audio_cant_unmute: false,
video_cant_unhide: false,
screen_cant_share: false,
chat_cant_privately: false,
chat_cant_chatgpt: false,
};
this.survey = config.survey;
this.redirect = config.redirect;
this.peers = new Map();
this.bannedPeers = [];
this.webRtcTransport = config.mediasoup.webRtcTransport;
this.router = null;
this.routerSettings = config.mediasoup.router;
this.createTheRouter();
}
// ####################################################
// ROOM INFO
// ####################################################
toJson() {
return {
id: this.id,
broadcasting: this._isBroadcasting,
recSyncServerRecording: this._recSyncServerRecording,
config: {
isLocked: this._isLocked,
isLobbyEnabled: this._isLobbyEnabled,
hostOnlyRecording: this._hostOnlyRecording,
},
moderator: this._moderator,
survey: this.survey,
redirect: this.redirect,
peers: JSON.stringify([...this.peers]),
};
}
// ####################################################
// ROUTER
// ####################################################
createTheRouter() {
const { mediaCodecs } = this.routerSettings;
this.worker
.createRouter({
mediaCodecs,
})
.then((router) => {
this.router = router;
if (this.audioLevelObserverEnabled) {
this.startAudioLevelObservation();
}
this.router.observer.on('close', () => {
log.info('---------------> Router is now closed as the last peer has left the room', {
room: this.id,
});
});
});
}
closeRouter() {
this.router.close();
}
// ####################################################
// PRODUCER AUDIO LEVEL OBSERVER
// ####################################################
async startAudioLevelObservation() {
log.debug('Start audioLevelObserver for signaling active speaker...');
this.audioLevelObserver = await this.router.createAudioLevelObserver({
maxEntries: 1,
threshold: -70,
interval: 100,
});
this.audioLevelObserver.on('volumes', (volumes) => {
this.sendActiveSpeakerVolume(volumes);
});
this.audioLevelObserver.on('silence', () => {
//log.debug('audioLevelObserver', { volume: 'silence' });
});
}
sendActiveSpeakerVolume(volumes) {
try {
if (!Array.isArray(volumes) || volumes.length === 0) {
throw new Error('Invalid volumes array');
}
if (Date.now() > this.audioLastUpdateTime + 100) {
this.audioLastUpdateTime = Date.now();
const { producer, volume } = volumes[0];
const audioVolume = Math.round(Math.pow(10, volume / 70) * 10); // Scale volume to 1-10
if (audioVolume > 1) {
this.peers.forEach((peer) => {
const { id, peer_audio, peer_name } = peer;
peer.producers.forEach((peerProducer) => {
if (peerProducer.id === producer.id && peerProducer.kind === 'audio' && peer_audio) {
const data = {
peer_id: id,
peer_name: peer_name,
audioVolume: audioVolume,
};
// Uncomment the following line for debugging
// log.debug('Sending audio volume', data);
this.sendToAll('audioVolume', data);
}
});
});
}
}
} catch (error) {
log.error('Error sending active speaker volume', error.message);
}
}
addProducerToAudioLevelObserver(producer) {
if (this.audioLevelObserverEnabled) {
this.audioLevelObserver.addProducer(producer);
}
}
getRtpCapabilities() {
return this.router.rtpCapabilities;
}
// ####################################################
// ROOM MODERATOR
// ####################################################
updateRoomModeratorALL(data) {
this._moderator = data;
log.debug('Update room moderator all data', this._moderator);
}
updateRoomModerator(data) {
log.debug('Update room moderator', data);
switch (data.type) {
case 'audio_start_muted':
this._moderator.audio_start_muted = data.status;
break;
case 'video_start_hidden':
this._moderator.video_start_hidden = data.status;
case 'audio_cant_unmute':
this._moderator.audio_cant_unmute = data.status;
break;
case 'video_cant_unhide':
this._moderator.video_cant_unhide = data.status;
case 'screen_cant_share':
this._moderator.screen_cant_share = data.status;
break;
case 'chat_cant_privately':
this._moderator.chat_cant_privately = data.status;
break;
case 'chat_cant_chatgpt':
this._moderator.chat_cant_chatgpt = data.status;
break;
default:
break;
}
}
// ####################################################
// PEERS
// ####################################################
addPeer(peer) {
this.peers.set(peer.id, peer);
}
getPeer(socket_id) {
//
if (!this.peers.has(socket_id)) {
log.error('---> Peer not found for socket ID', socket_id);
return null;
}
const peer = this.peers.get(socket_id);
return peer;
}
getPeers() {
return this.peers;
}
getPeersCount() {
return this.peers.size;
}
getProducerListForPeer() {
const producerList = [];
this.peers.forEach((peer) => {
const { peer_name, peer_info } = peer;
peer.producers.forEach((producer) => {
producerList.push({
producer_id: producer.id,
peer_name: peer_name,
peer_info: peer_info,
type: producer.appData.mediaType,
});
});
});
return producerList;
}
async removePeer(socket_id) {
const peer = this.getPeer(socket_id);
if (!peer || typeof peer !== 'object') {
return;
}
const { id, peer_name } = peer;
peer.close();
this.peers.delete(socket_id);
if (this.getPeers().size === 0) {
this.closeRouter();
}
const peerTransports = peer.getTransports();
const peerProducers = peer.getProducers();
const peerConsumers = peer.getConsumers();
log.debug('REMOVE PEER', {
peer_id: id,
peer_name: peer_name,
peerTransports: peerTransports,
peerProducers: peerProducers,
peerConsumers: peerConsumers,
});
}
// ####################################################
// WebRTC TRANSPORT
// ####################################################
async createWebRtcTransport(socket_id) {
const { maxIncomingBitrate, initialAvailableOutgoingBitrate, listenInfos } = this.webRtcTransport;
const webRtcTransportOptions = {
...(this.webRtcServerActive ? { webRtcServer: this.webRtcServer } : { listenInfos: listenInfos }),
enableUdp: true,
enableTcp: true,
preferUdp: true,
iceConsentTimeout: 20,
initialAvailableOutgoingBitrate,
};
log.debug('webRtcTransportOptions ----->', webRtcTransportOptions);
const transport = await this.router.createWebRtcTransport(webRtcTransportOptions);
if (!transport) {
return this.callback('[Room|createWebRtcTransport] Failed to create WebRTC transport');
}
const { id, iceParameters, iceCandidates, dtlsParameters } = transport;
if (maxIncomingBitrate) {
try {
await transport.setMaxIncomingBitrate(maxIncomingBitrate);
} catch (error) {
log.debug('Transport setMaxIncomingBitrate error', error.message);
}
}
const peer = this.getPeer(socket_id);
if (!peer || typeof peer !== 'object') {
return this.callback(`[Room|createWebRtcTransport] Peer object not found for socket ID: ${socket_id}`);
}
const { peer_name } = peer;
transport.on('icestatechange', (iceState) => {
if (iceState === 'disconnected' || iceState === 'closed') {
log.debug('Transport closed "icestatechange" event', {
peer_name: peer_name,
transport_id: id,
iceState: iceState,
});
transport.close();
}
});
transport.on('sctpstatechange', (sctpState) => {
log.debug('Transport "sctpstatechange" event', {
peer_name: peer_name,
transport_id: id,
sctpState: sctpState,
});
});
transport.on('dtlsstatechange', (dtlsState) => {
if (dtlsState === 'failed' || dtlsState === 'closed') {
log.debug('Transport closed "dtlsstatechange" event', {
peer_name: peer_name,
transport_id: id,
dtlsState: dtlsState,
});
transport.close();
}
});
transport.on('close', () => {
log.debug('Transport closed', { peer_name: peer_name, transport_id: transport.id });
});
peer.addTransport(transport);
log.debug('Transport created', { transportId: id });
return {
id: id,
iceParameters: iceParameters,
iceCandidates: iceCandidates,
dtlsParameters: dtlsParameters,
};
}
async connectPeerTransport(socket_id, transport_id, dtlsParameters) {
try {
if (!socket_id || !transport_id || !dtlsParameters) {
return this.callback('[Room|connectPeerTransport] Invalid input parameters');
}
const peer = this.getPeer(socket_id);
if (!peer || typeof peer !== 'object') {
return this.callback(`[Room|connectPeerTransport] Peer object not found for socket ID: ${socket_id}`);
}
const connectTransport = await peer.connectTransport(transport_id, dtlsParameters);
if (!connectTransport) {
return this.callback(`[Room|connectPeerTransport] error: Transport with ID ${transport_id} not found`);
}
return '[Room|connectPeerTransport] done';
} catch (error) {
log.error('Error connecting peer transport', error.message);
return this.callback(`[Room|connectPeerTransport] error: ${error.message}`);
}
}
// ####################################################
// PRODUCE
// ####################################################
async produce(socket_id, producerTransportId, rtpParameters, kind, type) {
//
if (!socket_id || !producerTransportId || !rtpParameters || !kind || !type) {
return this.callback('[Room|produce] Invalid input parameters');
}
const peer = this.getPeer(socket_id);
if (!peer || typeof peer !== 'object') {
return this.callback(`[Room|produce] Peer object not found for socket ID: ${socket_id}`);
}
const peerProducer = await peer.createProducer(producerTransportId, rtpParameters, kind, type);
if (!peerProducer || !peerProducer.id) {
return this.callback(`[Room|produce] Peer producer error: '${peerProducer}'`);
}
const { id } = peerProducer;
const { peer_name, peer_info } = peer;
this.broadCast(socket_id, 'newProducers', [
{
producer_id: id,
producer_socket_id: socket_id,
peer_name: peer_name,
peer_info: peer_info,
type: type,
},
]);
return id;
}
closeProducer(socket_id, producer_id) {
if (!socket_id || !producer_id) return;
const peer = this.getPeer(socket_id);
if (!peer || typeof peer !== 'object') {
return;
}
peer.closeProducer(producer_id);
}
// ####################################################
// CONSUME
// ####################################################
async consume(socket_id, consumer_transport_id, producer_id, rtpCapabilities) {
try {
if (!socket_id || !consumer_transport_id || !producer_id || !rtpCapabilities) {
return this.callback('[Room|consume] Invalid input parameters');
}
if (!this.router.canConsume({ producerId: producer_id, rtpCapabilities })) {
log.warn('Cannot consume', {
socket_id,
consumer_transport_id,
producer_id,
});
return this.callback(`[Room|consume] Room router cannot consume producer_id: '${producer_id}'`);
}
const peer = this.getPeer(socket_id);
if (!peer || typeof peer !== 'object') {
return this.callback(`[Room|consume] Peer object not found for socket ID: ${socket_id}`);
}
const peerConsumer = await peer.createConsumer(consumer_transport_id, producer_id, rtpCapabilities);
if (!peerConsumer || !peerConsumer.consumer || !peerConsumer.params) {
log.debug('peerConsumer or params are not defined');
return this.callback(`[Room|consume] peerConsumer error: '${peerConsumer}'`);
}
const { consumer, params } = peerConsumer;
const { id, kind } = consumer;
consumer.on('producerclose', () => {
log.debug('Consumer closed due to "producerclose" event');
peer.removeConsumer(id);
// Notify the client that consumer is closed
this.send(socket_id, 'consumerClosed', {
consumer_id: id,
consumer_kind: kind,
});
});
return params;
} catch (error) {
log.error('Error occurred during consumption', error.message);
return this.callback(`[Room|consume] ${error.message}`);
}
}
// ####################################################
// HANDLE BANNED PEERS
// ####################################################
addBannedPeer(uuid) {
if (!this.bannedPeers.includes(uuid)) {
this.bannedPeers.push(uuid);
log.debug('Added to the banned list', {
uuid: uuid,
banned: this.bannedPeers,
});
}
}
isBanned(uuid) {
return this.bannedPeers.includes(uuid);
}
// ####################################################
// ROOM STATUS
// ####################################################
// GET
isBroadcasting() {
return this._isBroadcasting;
}
getPassword() {
return this._roomPassword;
}
// BOOL
isLocked() {
return this._isLocked;
}
isLobbyEnabled() {
return this._isLobbyEnabled;
}
isHostOnlyRecording() {
return this._hostOnlyRecording;
}
// SET
setIsBroadcasting(status) {
this._isBroadcasting = status;
}
setLocked(status, password) {
this._isLocked = status;
this._roomPassword = password;
}
setLobbyEnabled(status) {
this._isLobbyEnabled = status;
}
setHostOnlyRecording(status) {
this._hostOnlyRecording = status;
}
// ####################################################
// ERRORS
// ####################################################
callback(message) {
return { error: message };
}
// ####################################################
// SENDER
// ####################################################
broadCast(socket_id, action, data) {
for (let otherID of Array.from(this.peers.keys()).filter((id) => id !== socket_id)) {
this.send(otherID, action, data);
}
}
sendTo(socket_id, action, data) {
for (let peer_id of Array.from(this.peers.keys()).filter((id) => id === socket_id)) {
this.send(peer_id, action, data);
}
}
sendToAll(action, data) {
for (let peer_id of Array.from(this.peers.keys())) {
this.send(peer_id, action, data);
}
}
send(socket_id, action, data) {
this.io.to(socket_id).emit(action, data);
}
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,121 @@
'use strict';
const jwt = require('jsonwebtoken');
const CryptoJS = require('crypto-js');
const config = require('./config');
const { v4: uuidV4 } = require('uuid');
const JWT_KEY = (config.jwt && config.jwt.key) || 'mirotalksfu_jwt_secret';
const JWT_EXP = (config.jwt && config.jwt.exp) || '1h';
module.exports = class ServerApi {
constructor(host = null, authorization = null) {
this._host = host;
this._authorization = authorization;
this._api_key_secret = config.api.keySecret;
}
isAuthorized() {
if (this._authorization != this._api_key_secret) return false;
return true;
}
getMeetings(roomList) {
const meetings = Array.from(roomList.entries()).map(([id, room]) => {
const peers = Array.from(room.peers.values()).map(
({
peer_info: {
peer_name,
peer_presenter,
peer_video,
peer_audio,
peer_screen,
peer_hand,
os_name,
os_version,
browser_name,
browser_version,
},
}) => ({
name: peer_name,
presenter: peer_presenter,
video: peer_video,
audio: peer_audio,
screen: peer_screen,
hand: peer_hand,
os: os_name ? `${os_name} ${os_version}` : '',
browser: browser_name ? `${browser_name} ${browser_version}` : '',
}),
);
return {
roomId: id,
peers: peers,
};
});
return meetings;
}
getMeetingURL() {
return 'https://' + this._host + '/join/' + uuidV4();
}
getJoinURL(data) {
// Get data
const { room, roomPassword, name, audio, video, screen, hide, notify, token } = data;
const roomValue = room || uuidV4();
const nameValue = name || 'User-' + this.getRandomNumber();
const roomPasswordValue = roomPassword || false;
const audioValue = audio || false;
const videoValue = video || false;
const screenValue = screen || false;
const hideValue = hide || false;
const notifyValue = notify || false;
const jwtToken = token ? '&token=' + this.getToken(token) : '';
const joinURL =
'https://' +
this._host +
'/join?' +
`room=${roomValue}` +
`&roomPassword=${roomPasswordValue}` +
`&name=${encodeURIComponent(nameValue)}` +
`&audio=${audioValue}` +
`&video=${videoValue}` +
`&screen=${screenValue}` +
`&hide=${hideValue}` +
`&notify=${notifyValue}` +
jwtToken;
return joinURL;
}
getToken(token) {
if (!token) return '';
const { username = 'username', password = 'password', presenter = false, expire } = token;
const expireValue = expire || JWT_EXP;
// Constructing payload
const payload = {
username: String(username),
password: String(password),
presenter: String(presenter),
};
// Encrypt payload using AES encryption
const payloadString = JSON.stringify(payload);
const encryptedPayload = CryptoJS.AES.encrypt(payloadString, JWT_KEY).toString();
// Constructing JWT token
const jwtToken = jwt.sign({ data: encryptedPayload }, JWT_KEY, { expiresIn: expireValue });
return jwtToken;
}
getRandomNumber() {
return Math.floor(Math.random() * 999999);
}
};

View File

@ -0,0 +1,66 @@
'use strict';
const xss = require('xss');
const Logger = require('./Logger');
const log = new Logger('Xss');
const checkXSS = (dataObject) => {
try {
if (Array.isArray(dataObject)) {
if (Object.keys(dataObject).length > 0 && typeof dataObject[0] === 'object') {
dataObject.forEach((obj) => {
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
let objectJson = objectToJSONString(obj[key]);
if (objectJson) {
let jsonString = xss(objectJson);
let jsonObject = JSONStringToObject(jsonString);
if (jsonObject) {
obj[key] = jsonObject;
}
}
}
}
});
log.debug('XSS Array of Object sanitization done');
return dataObject;
}
} else if (typeof dataObject === 'object') {
let objectJson = objectToJSONString(dataObject);
if (objectJson) {
let jsonString = xss(objectJson);
let jsonObject = JSONStringToObject(jsonString);
if (jsonObject) {
log.debug('XSS Object sanitization done');
return jsonObject;
}
}
} else if (typeof dataObject === 'string' || dataObject instanceof String) {
log.debug('XSS String sanitization done');
return xss(dataObject);
}
log.warn('XSS not sanitized', dataObject);
return dataObject;
} catch (error) {
log.error('XSS error', { data: dataObject, error: error });
return dataObject;
}
};
function objectToJSONString(dataObject) {
try {
return JSON.stringify(dataObject);
} catch (error) {
return false;
}
}
function JSONStringToObject(jsonString) {
try {
return JSON.parse(jsonString);
} catch (error) {
return false;
}
}
module.exports = checkXSS;

View File

@ -0,0 +1,433 @@
'use strict';
const os = require('os');
// https://api.ipify.org
function getIPv4() {
const ifaces = os.networkInterfaces();
for (const interfaceName in ifaces) {
const iface = ifaces[interfaceName];
for (const { address, family, internal } of iface) {
if (family === 'IPv4' && !internal) {
return address;
}
}
}
return '0.0.0.0'; // Default to 0.0.0.0 if no external IPv4 address found
}
const IPv4 = getIPv4();
const numWorkers = require('os').cpus().length;
module.exports = {
console: {
/*
timeZone: Time Zone corresponding to timezone identifiers from the IANA Time Zone Database es 'Europe/Rome' default UTC
*/
timeZone: 'UTC',
debug: true,
colors: true,
},
server: {
listen: {
// app listen on
ip: '0.0.0.0',
port: process.env.PORT || 3010,
},
ssl: {
// ssl/README.md
cert: '../ssl/cert.pem',
key: '../ssl/key.pem',
},
cors: {
/*
origin: Allow specified origin es ['https://example.com', 'https://subdomain.example.com', 'http://localhost:3010'] or all origins if not specified
methods: Allow only GET and POST methods
*/
origin: '*',
methods: ['GET', 'POST'],
},
recording: {
/*
The recording will be saved to the directory designated within your Server app/<dir>
Note: if you use Docker: Create the "app/rec" directory, configure it as a volume in docker-compose.yml,
ensure proper permissions, and start the Docker container.
*/
dir: 'rec',
enabled: false,
},
},
middleware: {
/*
Middleware:
- IP Whitelist: Access to the instance is restricted to only the specified IP addresses in the allowed list. This feature is disabled by default.
- ...
*/
IpWhitelist: {
enabled: false,
allowed: ['127.0.0.1', '::1'],
},
},
api: {
// Default secret key for app/api
keySecret: 'mirotalksfu_default_secret',
// Define which endpoints are allowed
allowed: {
meetings: false,
meeting: true,
join: true,
token: false,
slack: true,
//...
},
},
jwt: {
/*
JWT https://jwt.io/
Securely manages credentials for host configurations and user authentication, enhancing security and streamlining processes.
*/
key: 'mirotalksfu_jwt_secret',
exp: '1h',
},
host: {
/*
Host Protection (default: false)
To enhance host security, enable host protection - user auth and provide valid
usernames and passwords in the users array or active users_from_db using users_api_endpoint for check.
*/
protected: false,
user_auth: false,
users_from_db: false, // if true ensure that api.token is also set to true.
//users_api_endpoint: 'http://localhost:9000/api/v1/user/isAuth',
users_api_endpoint: 'https://webrtc.mirotalk.com/api/v1/user/isAuth',
users_api_secret_key: 'mirotalkweb_default_secret',
users: [
{
username: 'username',
password: 'password',
},
{
username: 'username2',
password: 'password2',
},
//...
],
},
presenters: {
list: [
/*
By default, the presenter is identified as the first participant to join the room, distinguished by their username and UUID.
Additional layers can be added to specify valid presenters and co-presenters by setting designated usernames.
*/
'Miroslav Pejic',
'miroslav.pejic.85@gmail.com',
],
join_first: true, // Set to true for traditional behavior, false to prioritize presenters
},
chatGPT: {
/*
ChatGPT
1. Goto https://platform.openai.com/
2. Create your account
3. Generate your APIKey https://platform.openai.com/account/api-keys
*/
enabled: false,
basePath: 'https://api.openai.com/v1/',
apiKey: '',
model: 'gpt-3.5-turbo',
max_tokens: 1000,
temperature: 0,
},
email: {
/*
Configure email settings for notifications or alerts
Refer to the documentation for Gmail configuration: https://support.google.com/mail/answer/185833?hl=en
*/
alert: false,
host: 'smtp.gmail.com',
port: 587,
username: 'your_username',
password: 'your_password',
sendTo: 'sfu.mirotalk@gmail.com',
},
ngrok: {
/*
Ngrok
1. Goto https://ngrok.com
2. Get started for free
3. Copy YourNgrokAuthToken: https://dashboard.ngrok.com/get-started/your-authtoken
*/
authToken: '',
},
sentry: {
/*
Sentry
1. Goto https://sentry.io/
2. Create account
3. On dashboard goto Settings/Projects/YourProjectName/Client Keys (DSN)
*/
enabled: false,
DSN: '',
tracesSampleRate: 0.5,
},
slack: {
/*
Slack
1. Goto https://api.slack.com/apps/
2. Create your app
3. On Settings - Basic Information - App Credentials, chose your Signing Secret
4. Create a Slash Commands and put as Request URL: https://your.domain.name/slack
*/
enabled: false,
signingSecret: '',
},
IPLookup: {
/*
GeoJS
https://www.geojs.io/docs/v1/endpoints/geo/
*/
enabled: false,
getEndpoint(ip) {
return `https://get.geojs.io/v1/ip/geo/${ip}.json`;
},
},
survey: {
/*
QuestionPro
1. GoTo https://www.questionpro.com/
2. Create your account
3. Create your custom survey
*/
enabled: false,
url: '',
},
redirect: {
/*
Redirect URL on leave room
Upon leaving the room, users who either opt out of providing feedback or if the survey is disabled
will be redirected to a specified URL. If enabled false the default '/newroom' URL will be used.
*/
enabled: false,
url: '',
},
ui: {
/*
Customize your MiroTalk instance
*/
brand: {
app: {
name: 'MiroTalk SFU',
title: 'MiroTalk SFU<br />Free browser based Real-time video calls.<br />Simple, Secure, Fast.',
description:
'Start your next video call with a single click. No download, plug-in, or login is required. Just get straight to talking, messaging, and sharing your screen.',
},
site: {
title: 'MiroTalk SFU, Free Video Calls, Messaging and Screen Sharing',
icon: '../images/logo.svg',
appleTouchIcon: '../images/logo.svg',
},
meta: {
description:
'MiroTalk SFU powered by WebRTC and mediasoup, Real-time Simple Secure Fast video calls, messaging and screen sharing capabilities in the browser.',
keywords:
'webrtc, miro, mediasoup, mediasoup-client, self hosted, voip, sip, real-time communications, chat, messaging, meet, webrtc stun, webrtc turn, webrtc p2p, webrtc sfu, video meeting, video chat, video conference, multi video chat, multi video conference, peer to peer, p2p, sfu, rtc, alternative to, zoom, microsoft teams, google meet, jitsi, meeting',
},
og: {
type: 'app-webrtc',
siteName: 'MiroTalk SFU',
title: 'Click the link to make a call.',
description: 'MiroTalk SFU calling provides real-time video calls, messaging and screen sharing.',
image: 'https://sfu.mirotalk.com/images/mirotalksfu.png',
},
html: {
features: true,
teams: true, // Please keep me always visible, thank you!
tryEasier: true,
poweredBy: true,
sponsors: true,
advertisers: true,
footer: true,
},
//...
},
/*
Toggle the visibility of specific HTML elements within the room
*/
buttons: {
main: {
shareButton: true, // presenter
hideMeButton: true,
startAudioButton: true,
startVideoButton: true,
startScreenButton: true,
swapCameraButton: true,
chatButton: true,
raiseHandButton: true,
transcriptionButton: true,
whiteboardButton: true,
emojiRoomButton: true,
settingsButton: true,
aboutButton: true, // Please keep me always visible, thank you!
exitButton: true,
},
settings: {
fileSharing: true,
lockRoomButton: true, // presenter
unlockRoomButton: true, // presenter
broadcastingButton: true, // presenter
lobbyButton: true, // presenter
sendEmailInvitation: true, // presenter
micOptionsButton: true, // presenter
tabModerator: true, // presenter
tabRecording: true,
host_only_recording: true, // presenter
pushToTalk: true,
},
producerVideo: {
videoPictureInPicture: true,
fullScreenButton: true,
snapShotButton: true,
muteAudioButton: true,
videoPrivacyButton: true,
},
consumerVideo: {
videoPictureInPicture: true,
fullScreenButton: true,
snapShotButton: true,
sendMessageButton: true,
sendFileButton: true,
sendVideoButton: true,
muteVideoButton: true,
muteAudioButton: true,
audioVolumeInput: true, // Disabled for mobile
geolocationButton: true, // Presenter
banButton: true, // presenter
ejectButton: true, // presenter
},
videoOff: {
sendMessageButton: true,
sendFileButton: true,
sendVideoButton: true,
muteAudioButton: true,
audioVolumeInput: true, // Disabled for mobile
geolocationButton: true, // Presenter
banButton: true, // presenter
ejectButton: true, // presenter
},
chat: {
chatPinButton: true,
chatMaxButton: true,
chatSaveButton: true,
chatEmojiButton: true,
chatMarkdownButton: true,
chatSpeechStartButton: true,
chatGPT: true,
},
participantsList: {
saveInfoButton: true, // presenter
sendFileAllButton: true, // presenter
ejectAllButton: true, // presenter
sendFileButton: true, // presenter & guests
geoLocationButton: true, // presenter
banButton: true, // presenter
ejectButton: true, // presenter
},
whiteboard: {
whiteboardLockButton: true, // presenter
},
//...
},
},
stats: {
/*
Umami: https://github.com/umami-software/umami
We use our Self-hosted Umami to track aggregated usage statistics in order to improve our service.
*/
enabled: true,
src: 'https://stats.mirotalk.com/script.js',
id: '41d26670-f275-45bb-af82-3ce91fe57756',
},
mediasoup: {
// Worker settings
numWorkers: numWorkers,
worker: {
logLevel: 'error',
logTags: ['info', 'ice', 'dtls', 'rtp', 'srtp', 'rtcp', 'rtx', 'bwe', 'score', 'simulcast', 'svc', 'sctp'],
},
// Router settings
router: {
mediaCodecs: [
{
kind: 'audio',
mimeType: 'audio/opus',
clockRate: 48000,
channels: 2,
},
{
kind: 'video',
mimeType: 'video/VP8',
clockRate: 90000,
parameters: {
'x-google-start-bitrate': 1000,
},
},
{
kind: 'video',
mimeType: 'video/VP9',
clockRate: 90000,
parameters: {
'profile-id': 2,
'x-google-start-bitrate': 1000,
},
},
{
kind: 'video',
mimeType: 'video/h264',
clockRate: 90000,
parameters: {
'packetization-mode': 1,
'profile-level-id': '4d0032',
'level-asymmetry-allowed': 1,
'x-google-start-bitrate': 1000,
},
},
{
kind: 'video',
mimeType: 'video/h264',
clockRate: 90000,
parameters: {
'packetization-mode': 1,
'profile-level-id': '42e01f',
'level-asymmetry-allowed': 1,
'x-google-start-bitrate': 1000,
},
},
],
},
// WebRtcServerOptions
webRtcServerActive: false,
webRtcServerOptions: {
listenInfos: [
{ protocol: 'udp', ip: '0.0.0.0', announcedAddress: IPv4, port: 44444 },
{ protocol: 'tcp', ip: '0.0.0.0', announcedAddress: IPv4, port: 44444 },
// { protocol: 'udp', ip: '0.0.0.0', announcedAddress: IPv4, portRange: { min: 44444, max: 44444 + numWorkers }},
// { protocol: 'tcp', ip: '0.0.0.0', announcedAddress: IPv4, portRange: { min: 44444, max: 44444 + numWorkers }},
],
},
// WebRtcTransportOptions
webRtcTransport: {
listenInfos: [
{ protocol: 'udp', ip: '0.0.0.0', announcedAddress: IPv4, portRange: { min: 40000, max: 40100 } },
{ protocol: 'tcp', ip: '0.0.0.0', announcedAddress: IPv4, portRange: { min: 40000, max: 40100 } },
],
initialAvailableOutgoingBitrate: 1000000,
minimumAvailableOutgoingBitrate: 600000,
maxSctpMessageSize: 262144,
maxIncomingBitrate: 1500000,
},
//announcedAddress: replace by 'public static IPV4 address' https://api.ipify.org (type string --> 'xx.xxx.xxx.xx' not xx.xxx.xxx.xx)
//announcedAddress: '' will be auto-detected on server start, for docker localPC set '127.0.0.1' otherwise the 'public static IPV4 address'
},
};

View File

@ -0,0 +1,150 @@
'use-strict';
const nodemailer = require('nodemailer');
const config = require('../config');
const Logger = require('../Logger');
const log = new Logger('NodeMailer');
// ####################################################
// EMAIL CONFIG
// ####################################################
const EMAIL_HOST = config.email ? config.email.host : false;
const EMAIL_PORT = config.email ? config.email.port : false;
const EMAIL_USERNAME = config.email ? config.email.username : false;
const EMAIL_PASSWORD = config.email ? config.email.password : false;
const EMAIL_SEND_TO = config.email ? config.email.sendTo : false;
const EMAIL_ALERT = config.email ? config.email.alert : false;
log.info('Email', {
alert: EMAIL_ALERT,
host: EMAIL_HOST,
port: EMAIL_PORT,
username: EMAIL_USERNAME,
password: EMAIL_PASSWORD,
});
const transport = nodemailer.createTransport({
host: EMAIL_HOST,
port: EMAIL_PORT,
auth: {
user: EMAIL_USERNAME,
pass: EMAIL_PASSWORD,
},
});
// ####################################################
// EMAIL SEND ALERTS AND NOTIFICATIONS
// ####################################################
function sendEmailAlert(event, data) {
if (!EMAIL_ALERT || !EMAIL_HOST || !EMAIL_PORT || !EMAIL_USERNAME || !EMAIL_PASSWORD || !EMAIL_SEND_TO) return;
log.info('sendEMailAlert', {
event: event,
data: data,
});
let subject = false;
let body = false;
switch (event) {
case 'join':
subject = getJoinRoomSubject(data);
body = getJoinRoomBody(data);
break;
// ...
default:
break;
}
if (subject && body) sendEmail(subject, body);
}
function sendEmail(subject, body) {
transport
.sendMail({
from: EMAIL_USERNAME,
to: EMAIL_SEND_TO,
subject: subject,
html: body,
})
.catch((err) => log.error(err));
}
// ####################################################
// EMAIL TEMPLATES
// ####################################################
function getJoinRoomSubject(data) {
const { room_id } = data;
return `MiroTalk SFU - New user Join to Room ${room_id}`;
}
function getJoinRoomBody(data) {
const { peer_name, room_id, domain, os, browser } = data;
const currentDataTime = getCurrentDataTime();
const localDomains = ['localhost', '127.0.0.1'];
const currentDomain = localDomains.some((localDomain) => domain.includes(localDomain))
? `${domain}:${config.server.listen.port}`
: domain;
const room_join = `https://${currentDomain}/join/`;
return `
<h1>New user join</h1>
<style>
table {
font-family: arial, sans-serif;
border-collapse: collapse;
width: 100%;
}
td {
border: 1px solid #dddddd;
text-align: left;
padding: 8px;
}
tr:nth-child(even) {
background-color: #dddddd;
}
</style>
<table>
<tr>
<td>User</td>
<td>${peer_name}</td>
</tr>
<tr>
<td>Os</td>
<td>${os}</td>
</tr>
<tr>
<td>Browser</td>
<td>${browser}</td>
</tr>
<tr>
<td>Room</td>
<td>${room_join}${room_id}</td>
</tr>
<tr>
<td>Date, Time</td>
<td>${currentDataTime}</td>
</tr>
</table>
`;
}
// ####################################################
// UTILITY
// ####################################################
function getCurrentDataTime() {
const currentTime = new Date().toLocaleString('en-US', log.tzOptions);
const milliseconds = String(new Date().getMilliseconds()).padStart(3, '0');
return `${currentTime}:${milliseconds}`;
}
module.exports = {
sendEmailAlert,
};

View File

@ -0,0 +1,24 @@
'use strict';
const config = require('../config');
const Logger = require('../Logger');
const log = new Logger('RestrictAccessByIP');
const IpWhitelistEnabled = config.middleware ? config.middleware.IpWhitelist.enabled : false;
const allowedIPs = config.middleware ? config.middleware.IpWhitelist.allowed : [];
const restrictAccessByIP = (req, res, next) => {
if (!IpWhitelistEnabled) return next();
//
const clientIP =
req.headers['x-forwarded-for'] || req.headers['X-Forwarded-For'] || req.socket.remoteAddress || req.ip;
log.debug('Check IP', clientIP);
if (allowedIPs.includes(clientIP)) {
next();
} else {
log.info('Forbidden: Access denied from this IP address', { clientIP: clientIP });
res.status(403).json({ error: 'Forbidden', message: 'Access denied from this IP address.' });
}
};
module.exports = restrictAccessByIP;

View File

@ -0,0 +1,159 @@
'use strict';
const config = require('../config');
const net = require('net');
/*
Run: node bindable.js
In networking, "bindable" refers to the ability to assign or allocate a specific IP address and port combination
to a network service or application. Binding an IP address and port allows the service or application to listen for
incoming network connections on that particular address and port.
When we say an IP address and port are "bindable," it means that there are no conflicts or issues preventing the service
or application from using that specific combination. In other words, the IP address is available, and the port is not already
in use by another process or service on the same machine.
If an IP address and port are bindable, it indicates that the network service or application can successfully bind to that
combination, allowing it to accept incoming connections and communicate over the network. On the other hand, if the IP address
and port are not bindable, it suggests that there may be conflicts or restrictions preventing the service or application
from using them, such as another process already listening on the same IP address and port.
*/
async function main() {
// Server listen
const serverListenIp = config.server.listen.ip;
const serverListenPort = config.server.listen.port;
// WebRtcServerActive
const webRtcServerActive = config.mediasoup.webRtcServerActive;
// WebRtcTransportOptions
const webRtcTransportIpInfo = config.mediasoup.webRtcTransport.listenInfos[0];
const webRtcTransportIpAddress =
webRtcTransportIpInfo.ip !== '0.0.0.0' ? webRtcTransportIpInfo.ip : webRtcTransportIpInfo.announcedAddress;
// WorkersOptions | webRtcTransportOptions
const workers = config.mediasoup.numWorkers;
const { min, max } = config.mediasoup.webRtcTransport.listenInfos[0].portRange;
const rtcMinPort = config.mediasoup.worker.rtcMinPort || min || 40000;
const rtcMaxPort = config.mediasoup.worker.rtcMaxPort || max || 40100;
console.log('==================================');
console.log('checkServerListenPorts');
console.log('==================================');
await checkServerListenPorts(serverListenIp, serverListenPort);
console.log('==================================');
console.log('checkWebRtcTransportPorts');
console.log('==================================');
await checkWebRtcTransportPorts(webRtcTransportIpAddress, rtcMinPort, rtcMaxPort);
if (webRtcServerActive) {
console.log('==================================');
console.log('checkWebRtcServerPorts');
console.log('==================================');
// WebRtcServerOptions
const webRtcServerIpInfo = config.mediasoup.webRtcServerOptions.listenInfos[0];
const webRtcServerIpAddress =
webRtcServerIpInfo.ip !== '0.0.0.0' ? webRtcServerIpInfo.ip : webRtcServerIpInfo.announcedAddress;
const webRtcServerStartPort = webRtcServerIpInfo.port;
await checkWebRtcServerPorts(webRtcServerIpAddress, webRtcServerStartPort, workers);
}
}
/**
* Check if Server listen port is bindable
* @param {string} ipAddress
* @param {integer} port
*/
async function checkServerListenPorts(ipAddress, port) {
const bindable = await isBindable(ipAddress, port);
if (bindable) {
console.log(`${ipAddress}:${port} is bindable 🟢`);
} else {
console.log(`${ipAddress}:${port} is not bindable 🔴`);
}
}
/**
* Check if WebRtcServer ports are bindable
* @param {string} ipAddress
* @param {integer} startPort
* @param {integer} workers
*/
async function checkWebRtcServerPorts(ipAddress, startPort, workers) {
let port = startPort;
for (let i = 0; i < workers; i++) {
try {
const bindable = await isBindable(ipAddress, port);
if (bindable) {
console.log(`${ipAddress}:${port} is bindable 🟢`);
} else {
console.log(`${ipAddress}:${port} is not bindable 🔴`);
}
port++;
} catch (err) {
console.error('Error occurred:', err);
}
}
}
/**
* Check if WebRtcTransport Worker ports are bindable
* @param {string} ipAddress
* @param {integer} minPort
* @param {integer} maxPort
*/
async function checkWebRtcTransportPorts(ipAddress, minPort, maxPort) {
let port = minPort;
for (let i = 0; i <= maxPort - minPort; i++) {
try {
const bindable = await isBindable(ipAddress, port);
if (bindable) {
console.log(`${ipAddress}:${port} is bindable 🟢`);
} else {
console.log(`${ipAddress}:${port} is not bindable 🔴`);
}
port++;
} catch (err) {
console.error('Error occurred:', err);
}
}
}
/**
* Check if ipAddress:port are bindable
* @param {string} ipAddress
* @param {integer} port
* @returns {Promise<boolean>} A promise that resolves to true if the address is bindable, false otherwise.
*/
async function isBindable(ipAddress, port) {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', (err) => {
if (err.code === 'EADDRINUSE') {
resolve(false); // Address is already in use
} else {
reject(err); // Other error occurred
}
});
server.once('listening', () => {
server.close();
resolve(true); // Address is bindable
});
server.listen(port, ipAddress);
});
}
main().catch((err) => {
console.error('Error occurred in main function:', err.message);
});

View File

@ -0,0 +1,22 @@
## Self-signed certificate
[What is self-signed-certificate](https://en.wikipedia.org/wiki/Self-signed_certificate)
![mirotalksfu-https](https.png)
```bash
# install openssl 4 ubuntu
apt install openssl
# install openssl 4 mac
brew install openssl
# self-signed certificate
openssl genrsa -out key.pem
openssl req -new -key key.pem -out csr.pem
openssl x509 -req -days 9999 -in csr.pem -signkey key.pem -out cert.pem
rm csr.pem
# https://www.sslchecker.com/certdecoder
```
For trusted certificate, take a look at [Let's Encrypt](https://letsencrypt.org/i) and [Certbot](https://certbot.eff.org/).

View File

@ -0,0 +1,22 @@
-----BEGIN CERTIFICATE-----
MIIDlTCCAn0CFBM91H+g2aRKsaRrCmo3NdYjwfWUMA0GCSqGSIb3DQEBCwUAMIGG
MQswCQYDVQQGEwJJVDEOMAwGA1UECAwFSXRhbHkxETAPBgNVBAoMCE1pcm9UYWxr
MQ8wDQYDVQQLDAZXZWJSVEMxFzAVBgNVBAMMDk1pcm9zbGF2IFBlamljMSowKAYJ
KoZIhvcNAQkBFhttaXJvc2xhdi5wZWppYy44NUBnbWFpbC5jb20wHhcNMjEwODEx
MTUwOTUzWhcNNDgxMjI2MTUwOTUzWjCBhjELMAkGA1UEBhMCSVQxDjAMBgNVBAgM
BUl0YWx5MREwDwYDVQQKDAhNaXJvVGFsazEPMA0GA1UECwwGV2ViUlRDMRcwFQYD
VQQDDA5NaXJvc2xhdiBQZWppYzEqMCgGCSqGSIb3DQEJARYbbWlyb3NsYXYucGVq
aWMuODVAZ21haWwuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA
rn2tC9W6wqjDI7B/4LfEnE4ILBOdwa9889QjmWUToKua7iXTpaSNP3SefKY50Q8T
BFfkZXEGyqAESBUn2rYeHtebgLQTKHsixhSCdHqpBDOyYFeTywGiRP4gQHFKbExd
X2AAD1ptTjHVuSlg/ojWstESBh/4TktifKzy3PKVKX6p889eDfyJtlv0PADAkon/
rZp3hHq0FORhvQEER1sm6g58WyIfqGWjW7bb7/bkyS1baQI16fPeexfu1Rs7y4kx
gX7+9/oA40D3rz0Wf378PTwVzbYCF+hZo/H9yJGTUAZSz84zNbSLvKBZFPfabA2A
l92uPgNWoct06uf7ubEJhwIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQAyH68yi+mf
2HNwEscSLN1oU9uXNC4hwHi2xoPrpxoLsbhoc/aVhgd5peOEmIlOQPQofCO/JSKt
HqidURSt8U+WrcsXz3HTdq+m/Qw3E/BA5CDXL/LUmIh43cZzkWSawx2EocJr7g1W
JeAtUt8xpDtuLlTMjoGGmTsKQG7ub8NcYN7EEqWa2v+2qSTqvhASMs+cf7DT2HZI
I3q2v6l1N+DcpO8ey8dbLhbhd4d+bGjyjEcT+clDHFrKsqiYDCS99sOmedmHoyZk
+h+CzXICtqFSrAAGE/PoetQtJlojwnu9mJN/xj3i/zJTZTRh3jOGF8Hfg2bvwgdg
vMYRLwtknqya
-----END CERTIFICATE-----

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEArn2tC9W6wqjDI7B/4LfEnE4ILBOdwa9889QjmWUToKua7iXT
paSNP3SefKY50Q8TBFfkZXEGyqAESBUn2rYeHtebgLQTKHsixhSCdHqpBDOyYFeT
ywGiRP4gQHFKbExdX2AAD1ptTjHVuSlg/ojWstESBh/4TktifKzy3PKVKX6p889e
DfyJtlv0PADAkon/rZp3hHq0FORhvQEER1sm6g58WyIfqGWjW7bb7/bkyS1baQI1
6fPeexfu1Rs7y4kxgX7+9/oA40D3rz0Wf378PTwVzbYCF+hZo/H9yJGTUAZSz84z
NbSLvKBZFPfabA2Al92uPgNWoct06uf7ubEJhwIDAQABAoIBAAhskNota2LSevlS
IBpdROS277YRDGC5dDLhXwac1qG/Jy+wK9OnahpSKwShkdECBU0EYUZ0ent11j8U
pmPsvu+GQT+pcfNWXotpmhK9iUNmq4nzMHNwlMD3896omYs49JkSLW6QUw6fYU4b
LU+ck6D2bwRUrsw433xdbSw1mfXyzyCIWfPNzRmkEcUkCe1RHkGqFv8FrePhezOB
tByAwQQLtBt7FMioTSCedOe7B+tuxwqj5Px7Vr8K3x/PKccSId6hS2ihnptYB+n7
3eAxoa049wazsW3SXJQrTxUB1z2bh2p2lA0AedgVNme1FCq4zzA7qa2WppcV2ava
Gu+dCgECgYEA1xjLFQmiUqCJVqA99DZNvgiPecuHSe2+2QRIk10uUQcxQAF8K9XT
ORpO05lc0ccPZt7tIbQytsnt/NxL2mnvXQ+dzzrQCsPu/HH8fLEaJ4xBdxuIFeDU
qCBKYlckCQkDwnNYUqCZCNfxb0Csx98RBDYlwDwZa0hFsLPbGen538cCgYEAz6wh
iRXZVTqfhy3meWYFmHqeMb0agFugl4d0Lwl/kI/0M62Jc1u1OU+uI6u5fuevEeBB
xYjnaDpBuCWccLw6R5luc98QIbxldbx2A07rUGo2JlQafmDX8wI1GVBXgzOw7HAK
jHhCw+ZgtvF2c1XjaTPaunKDomPX6Pjt6R23CEECgYEArRRvPbNt4Wz6djElCSC1
N+ftg4TJjSx4eGog+CtvvJW8BJPtVdyORZGswknSzZ6O/yj8yTUV5c3g6apegxbh
HBIX2wupIjB9WrdiAvgDYrVSbEREIc6zb8Hj+PPDtF2Dn/FurbY6zkntJad2ILKX
H7tubxwtHA2gvkpLULPcdDsCgYAxm3WbUHvM7ycCXIWMhEFb7hZx3TFCbiDLcZDg
V42AU9LKsW5+/u4oVY9MeA3kcaWRSJeNfyl/7UKboWhgSaZGSjFnPmaVGHLIEA/E
tIpjeCudNkPp4mpTYziZ5mYxMhzWLeFnMqcIMrTxnnZkEKU1ESzzkr09AkqmHSh/
ohiBwQKBgQCvaDQS7aBS/1+iBrOyMwxARwFirReJHdgEtkBVrB+sasbj1B4Jd3zO
gL6FzxKCKBMIF+sSAjKs0XoRG1K6OPGFg1b5naEq023gObO6aBZqSXhHCajGK790
Xhrvj6BojWkJUnc8T0cocrwhMJrTFC0u00KgAnRNyNYw1vccd5q2uQ==
-----END RSA PRIVATE KEY-----