Merge branch 'develop' into feat-advanced-audio-options

This commit is contained in:
Vittorio Palmisano 2021-05-28 18:29:50 +02:00
commit 29a2edc13c
41 changed files with 3486 additions and 2888 deletions

View file

@ -60,31 +60,42 @@ $ sudo systemctl start edumeet
## Manual installation
* Prerequisites:
Currently edumeet will run on nodejs v14.x
To install see here [here](https://github.com/nodesource/distributions/blob/master/README.md#debinstall).
* Install all the required dependencies and NodeJS v14 (Debian/Ubuntu):
```bash
$ sudo apt install git npm yarnpkg build-essential redis libssl-dev openssl pkg-config
sudo apt install -y curl git python build-essential redis openssl libssl-dev pkg-config
curl -fsSL https://deb.nodesource.com/setup_14.x | sudo bash -
sudo apt update && sudo apt install -y nodejs
```
* Install the Yarn package manager (recommended):
```bash
curl -sL https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --dearmor | sudo tee /usr/share/keyrings/yarnkey.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/yarnkey.gpg] https://dl.yarnpkg.com/debian stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
sudo apt update && sudo apt install -y yarn
```
* Clone the project:
```bash
$ git clone https://github.com/edumeet/edumeet.git
$ cd edumeet
git clone https://github.com/edumeet/edumeet.git
cd edumeet
# switch to the "develop" branch to get the latest version for developing
git checkout develop
```
* Copy `server/config/config.example.js` to `server/config/config.js` :
```bash
$ cp server/config/config.example.js server/config/config.js
cp server/config/config.example.js server/config/config.js
```
* Copy `app/public/config/config.example.js` to `app/public/config/config.js` :
```bash
$ cp app/public/config/config.example.js app/public/config/config.js
cp app/public/config/config.example.js app/public/config/config.js
```
* Edit your two `config.js` with appropriate settings (listening IP/port, logging options, **valid** TLS certificate, don't forget ip setting in last section in server config: (webRtcTransport), etc).
@ -92,19 +103,27 @@ $ cp app/public/config/config.example.js app/public/config/config.js
* Set up the browser app:
```bash
$ cd app
$ yarn
$ yarn build
cd app
# using Yarn (recommended)
yarn && yarn build
# using NPM
npm i && npm run build
```
This will build the client application and copy everythink to `server/public` from where the server can host client code to browser requests.
This will build the client application and copy everything to `server/public` from where the server can host client code to browser requests.
* Set up the server:
```bash
$ cd ..
$ cd server
$ yarn
cd ../server
# using Yarn (recommended)
yarn && yarn build
# using NPM
npm i && npm run build
```
## Run it locally
@ -112,8 +131,13 @@ $ yarn
* Run the Node.js server application in a terminal:
```bash
$ cd server
$ yarn start
cd server
# using Yarn (recommended)
yarn start
# using NPM
npm run start
```
* Note: Do not run the server as root. If you need to use port 80/443 make a iptables-mapping for that or use systemd configuration for that (see further down this doc).
@ -124,21 +148,23 @@ $ yarn start
* Stop your locally running server. Copy systemd-service file `edumeet.service` to `/etc/systemd/system/` and check location path settings:
```bash
$ cp edumeet.service /etc/systemd/system/
$ edit /etc/systemd/system/edumeet.service
cp edumeet.service /etc/systemd/system/
# modify the install paths, if required
sudo edit /etc/systemd/system/edumeet.service
```
* Reload systemd configuration and start service:
```bash
$ systemctl daemon-reload
$ systemctl start edumeet
sudo systemctl daemon-reload
sudo systemctl start edumeet
```
* If you want to start edumeet at boot time:
```bash
$ systemctl enable edumeet
sudo systemctl enable edumeet
```
## Ports and firewall

View file

@ -6,91 +6,48 @@
},
"overrides": [
{
"files":["**/*.{ts,tsx}"],
"plugins": ["prettier", "@typescript-eslint"],
"extends": ["airbnb-typescript", "react-app", "prettier"],
"files": ["**/*.{ts,tsx}"],
"plugins": ["@typescript-eslint"],
"extends":[
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json"
"project": "./tsconfig.json"
},
"settings": {
"import/resolver": {
"typescript": {
"alwaysTryTypes": true
}
}
"import/resolver": {
"typescript": {
"alwaysTryTypes": true
}
}
},
"rules": {
"object-curly-spacing": ["warn", "always"],
"no-unused-vars": [
"warn",
{
"vars": "all",
"args": "none"
}
],
"@typescript-eslint/semi": [
"off"
],
"@typescript-eslint/no-unused-vars": [
"warn",
{
"vars": "all",
"args": "none"
}
],
"max-len": [
"warn",
{
"code": 100,
"ignoreStrings": true,
"ignoreTemplateLiterals": true,
"ignoreComments": true
}
],
"prefer-destructuring": [
"error",
{
"VariableDeclarator": {
"array": false,
"object": true
},
"AssignmentExpression": {
"array": false,
"object": false
}
}, {
"enforceForRenamedProperties": false
}
],
"no-unused-vars" : 0,
"@typescript-eslint/ban-types" : 0,
"@typescript-eslint/ban-ts-comment" : 0,
"@typescript-eslint/ban-ts-ignore" : 0,
"@typescript-eslint/explicit-module-boundary-types" : 0,
"@typescript-eslint/member-delimiter-style" : [ 2,
"no-plusplus": [
"error",
{
"allowForLoopAfterthoughts": true
}
],
"react/jsx-key": "error",
"import/no-extraneous-dependencies": [
"error",
{
"devDependencies": [
"**/*.test.js",
"**/*.test.jsx",
"**/*.test.ts",
"**/*.test.tsx",
"src/tests/**/*"
]
}
],
"react/jsx-props-no-spreading": "off",
"import/prefer-default-export": "off",
"react/jsx-boolean-value": "off",
"react/prop-types": "off",
"react/no-unescaped-entities": "off",
"react/jsx-one-expression-per-line": "off",
"react/jsx-wrap-multilines": "off",
"react/destructuring-assignment": "off"
{
"multiline" : { "delimiter": "semi", "requireLast": true },
"singleline" : { "delimiter": "semi", "requireLast": false }
}
],
"@typescript-eslint/no-explicit-any" : 0,
"@typescript-eslint/no-unused-vars" : [ 2,
{
"vars" : "all",
"args" : "after-used",
"ignoreRestSiblings" : false
}
],
"@typescript-eslint/no-use-before-define" : [ 2, { "functions": false } ],
"@typescript-eslint/no-empty-function" : 0,
"@typescript-eslint/no-non-null-assertion" : 0
}
}
],

View file

@ -3,5 +3,5 @@
"printWidth": 100,
"semi": false,
"singleQuote": true,
"tabWidth": 2
"tabWidth": 4
}

View file

@ -20,12 +20,15 @@
"@material-ui/lab": "^4.0.0-alpha.57",
"@react-hook/window-size": "^3.0.7",
"@types/node": "^14.14.37",
"@types/react": "^16.10.2",
"@types/react-dom": "^16.9.12",
"bowser": "^2.11.0",
"@types/react": "^17.0.3",
"@types/react-dom": "^17.0.3",
"bowser": "^2.7.0",
"classnames": "^2.2.6",
"chroma-js": "^2.1.1",
"classnames": "^2.3.1",
"convict": "^6.0.1",
"convict-format-with-validator": "^6.0.1",
"create-torrent": "^4.4.1",
"deep-object-diff": "^1.1.0",
"dompurify": "^2.0.7",
"domready": "^1.0.8",
"end-of-stream": "1.4.1",
@ -71,6 +74,7 @@
"electron": "electron --no-sandbox .",
"dev": "nf start -p 3000",
"lint": "eslint -c .eslintrc.json --ext .js,.ts,.tsx src",
"lint:fix": "eslint -c .eslintrc.json --ext .js,.ts,.tsx src --fix",
"format": "prettier --write 'src/**/*.{ts,tsx}'"
},
"browserslist": [
@ -81,6 +85,8 @@
],
"devDependencies": {
"@types/chroma-js": "^2.1.3",
"@types/convict": "^6.0.1",
"@types/convict-format-with-validator": "^6.0.2",
"@typescript-eslint/eslint-plugin": "^4.20.0",
"@typescript-eslint/parser": "^4.20.0",
"babel-eslint": "^10.1.0",
@ -101,6 +107,7 @@
"prettier": "^2.2.1",
"prettier-eslint": "^12.0.0",
"prettier-eslint-cli": "^5.0.1",
"redux-mock-store": "^1.5.3"
"redux-mock-store": "^1.5.3",
"ts-node": "^9.1.1"
}
}

View file

@ -78,17 +78,17 @@ var config =
} ],
// The aspect ratio of the video from the camera
// this is not changeable in settings, only config
videoAspectRatio : 1.777,
defaultResolution : 'medium',
defaultFrameRate : 15,
defaultScreenResolution : 'veryhigh',
defaultScreenSharingFrameRate : 5,
videoAspectRatio : 1.777,
resolution : 'medium',
frameRate : 15,
screenResolution : 'veryhigh',
screenSharingFrameRate : 5,
// Enable or disable simulcast for webcam video
simulcast : true,
simulcast : true,
// Enable or disable simulcast for screen sharing video
simulcastSharing : false,
simulcastSharing : false,
// Define different encodings for various resolutions of the video
simulcastProfiles :
simulcastProfiles :
{
3840 :
[

View file

@ -0,0 +1,99 @@
import React from 'react'; // eslint-disable-line no-use-before-define
import { withStyles } from '@material-ui/core/styles';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import Card from '@material-ui/core/Card';
import CardActions from '@material-ui/core/CardActions';
import CardContent from '@material-ui/core/CardContent';
import Typography from '@material-ui/core/Typography';
import Table from '@material-ui/core/Table';
import TableBody from '@material-ui/core/TableBody';
import TableCell from '@material-ui/core/TableCell';
import TableContainer from '@material-ui/core/TableContainer';
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
import Paper from '@material-ui/core/Paper';
import Button from '@material-ui/core/Button';
import { configDocs } from '../config';
const styles = () =>
({
table : {
minWidth : 700
},
pre : {
fontSize : '0.8rem'
},
cell : {
maxWidth : '25vw',
overflow : 'auto'
}
});
const ConfigDocumentation = ({
classes
}: {
classes : any;
}) =>
{
return (
<Card className={classes.root}>
<CardContent>
<Typography className={classes.title} variant='h5' component='h2'>
<FormattedMessage
id='configDocumentation.title'
defaultMessage='Edumeet configuration'
/>
</Typography>
<Typography variant='body2' component='div'>
<TableContainer component={Paper}>
<Table className={classes.table} size='small' aria-label='Configuration'>
<TableHead>
<TableRow>
<TableCell>Property</TableCell>
<TableCell align='left'>Description</TableCell>
<TableCell align='left'>Format</TableCell>
<TableCell align='left'>Default value</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.entries(configDocs).map(([ name, value ] : [ string, any ]) =>
{
return (
<TableRow key={name}>
<TableCell component='th' scope='row' className={classes.cell}>{name}</TableCell>
<TableCell className={classes.cell}>{value.doc}</TableCell>
<TableCell className={classes.cell}>
<pre>{value.format}</pre>
</TableCell>
<TableCell className={classes.cell}>
<pre className={classes.pre}>{value.default}</pre>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</Typography>
</CardContent>
<CardActions>
<Button size='small' onClick={(e) =>
{
e.preventDefault();
window.location.href = '/';
}}
>Home</Button>
</CardActions>
</Card>
);
};
ConfigDocumentation.propTypes =
{
classes : PropTypes.object.isRequired
};
export default withStyles(styles)(ConfigDocumentation);

View file

@ -0,0 +1,75 @@
import React from 'react'; // eslint-disable-line no-use-before-define
import { withStyles } from '@material-ui/core/styles';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import Dialog from '@material-ui/core/Dialog';
import DialogTitle from '@material-ui/core/DialogTitle';
import DialogContent from '@material-ui/core/DialogContent';
import Grid from '@material-ui/core/Grid';
import ErrorIcon from '@material-ui/icons/Error';
import Button from '@material-ui/core/Button';
const styles = () =>
({
error : {
color : 'red'
}
});
const ConfigError = ({
classes,
configError
}: {
classes : any;
configError : string;
}) =>
{
return (
<Dialog
open
scroll={'body'}
classes={{
paper : classes.dialogPaper
}}
>
<DialogTitle id='form-dialog-title'>
<ErrorIcon className={classes.errorAvatar} color='error'/>
<FormattedMessage
id='configError.title'
defaultMessage='Configuration error'
/>
</DialogTitle>
<DialogContent dividers>
<FormattedMessage
id='configError.bodyText'
defaultMessage='The Edumeet configuration contains errors:'
/>
<Grid container spacing={2} alignItems='center'>
<Grid item>
<p className={classes.error}>{configError}</p>
</Grid>
<Button size='small' onClick={(e) =>
{
e.preventDefault();
window.location.href = '/config';
}}
>
<FormattedMessage
id='configError.link'
defaultMessage='See the configuration documentation'
/>
</Button>
</Grid>
</DialogContent>
</Dialog>
);
};
ConfigError.propTypes =
{
classes : PropTypes.object.isRequired,
configError : PropTypes.string.isRequired
};
export default withStyles(styles)(ConfigError);

View file

@ -50,6 +50,8 @@ const styles = (theme) =>
{
borderRadius : '50%',
height : '2rem',
width : '2rem',
objectFit : 'cover',
alignSelf : 'center'
}
});

View file

@ -25,7 +25,9 @@ const styles = (theme) =>
avatar :
{
borderRadius : '50%',
height : '2rem'
height : '2rem',
width : '2rem',
objectFit : 'cover'
},
text :
{

View file

@ -24,6 +24,8 @@ const styles = (theme) =>
{
borderRadius : '50%',
height : '2rem',
width : '2rem',
objectFit : 'cover',
marginTop : theme.spacing(0.5)
},
peerInfo :

View file

@ -49,6 +49,8 @@ const styles = (theme) =>
{
borderRadius : '50%',
height : '2rem',
width : '2rem',
objectFit : 'cover',
marginTop : theme.spacing(0.5)
},
peerInfo :

580
app/src/config.ts Normal file
View file

@ -0,0 +1,580 @@
import convict from 'convict';
import * as convictFormatWithValidator from 'convict-format-with-validator';
convict.addFormats(convictFormatWithValidator);
function assert(assertion: Boolean, msg: string)
{
if (!assertion)
throw new Error(msg);
}
convict.addFormat({
name : 'float',
coerce : (v: string) => parseFloat(v),
validate : (v: number) => assert(Number.isFinite(v), 'must be a number')
});
const configSchema = convict({
loginEnabled :
{
doc : 'If the login is enabled.',
format : 'Boolean',
default : false
},
developmentPort :
{
doc : 'The development server listening port.',
format : 'port',
default : 3443
},
productionPort :
{
doc : 'The production server listening port.',
format : 'port',
default : 443
},
serverHostname :
{
doc : 'If the server component runs on a different host than the app you can specify the host name.',
format : '*',
default : null
},
/**
* Supported browsers version in bowser satisfy format.
* See more:
* https://www.npmjs.com/package/bowser#filtering-browsers
* Otherwise you got a unsupported browser page
*/
supportedBrowsers :
{
doc : 'Supported browsers version in bowser satisfy format.',
format : Object,
default :
{
'windows' : {
'internet explorer' : '>12',
'microsoft edge' : '>18'
},
'microsoft edge' : '>18',
'safari' : '>12',
'firefox' : '>=60',
'chrome' : '>=74',
'chromium' : '>=74',
'opera' : '>=62',
'samsung internet for android' : '>=11.1.1.52'
}
},
/**
* Network priorities
* DSCP bits set by browser according this priority values.
* ("high" means actually: EF for audio, and AF41 for Video in chrome)
* https://en.wikipedia.org/wiki/Differentiated_services
*/
networkPriorities :
{
doc : 'Network priorities.',
format : Object,
default :
{
audio : 'high',
mainVideo : 'high',
additionalVideos : 'medium',
screenShare : 'medium'
}
},
// The aspect ratio of the videos as shown on the screen.
// This is changeable in client settings.
// This value must match one of the defined values in
// viewAspectRatios EXACTLY (e.g. 1.333)
viewAspectRatio :
{
doc : 'The aspect ratio of the videos as shown on the screen.',
format : 'float',
default : 1.777
},
viewAspectRatios :
{
doc : 'The selectable aspect ratios in the settings.',
format : Array,
default :
[
{
value : 1.333, // 4 / 3
label : '4 : 3'
},
{
value : 1.777, // 16 / 9
label : '16 : 9'
}
]
},
// The aspect ratio of the video from the camera
// this is not changeable in settings, only config
videoAspectRatio :
{
doc : 'The aspect ratio of the video from the camera.',
format : 'float',
default : 1.777
},
resolution :
{
doc : 'The default video camera capture resolution.',
format : [ 'low', 'medium', 'high', 'veryhigh', 'ultra' ],
default : 'medium'
},
frameRate :
{
doc : 'The default video camera capture framerate.',
format : 'nat',
default : 15
},
screenResolution :
{
doc : 'The default screen sharing resolution.',
format : [ 'low', 'medium', 'high', 'veryhigh', 'ultra' ],
default : 'veryhigh'
},
screenSharingFrameRate :
{
doc : 'The default screen sharing framerate.',
format : 'nat',
default : 5
},
simulcast :
{
doc : 'Enable or disable simulcast for webcam video.',
format : 'Boolean',
default : true
},
simulcastSharing :
{
doc : 'Enable or disable simulcast for screen sharing video.',
format : 'Boolean',
default : false
},
simulcastProfiles :
{
doc : 'Define different encodings for various resolutions of the video.',
format : Object,
default :
{
3840 :
[
{ scaleResolutionDownBy: 4, maxBitRate: 1500000 },
{ scaleResolutionDownBy: 2, maxBitRate: 4000000 },
{ scaleResolutionDownBy: 1, maxBitRate: 10000000 }
],
1920 :
[
{ scaleResolutionDownBy: 4, maxBitRate: 750000 },
{ scaleResolutionDownBy: 2, maxBitRate: 1500000 },
{ scaleResolutionDownBy: 1, maxBitRate: 4000000 }
],
1280 :
[
{ scaleResolutionDownBy: 4, maxBitRate: 250000 },
{ scaleResolutionDownBy: 2, maxBitRate: 900000 },
{ scaleResolutionDownBy: 1, maxBitRate: 3000000 }
],
640 :
[
{ scaleResolutionDownBy: 2, maxBitRate: 250000 },
{ scaleResolutionDownBy: 1, maxBitRate: 900000 }
],
320 :
[
{ scaleResolutionDownBy: 1, maxBitRate: 250000 }
]
}
},
// The adaptive spatial layer selection scaling factor (in the range [0.5, 1.0])
// example:
// with level width=640px, the minimum width required to trigger the
// level change will be: 640 * 0.75 = 480px
adaptiveScalingFactor :
{
doc : 'The adaptive spatial layer selection scaling factor.',
format : (value: number) => value >= 0.5 && value <= 1.0,
default : 0.75
},
/**
* White listing browsers that support audio output device selection.
* It is not yet fully implemented in Firefox.
* See: https://bugzilla.mozilla.org/show_bug.cgi?id=1498512
*/
audioOutputSupportedBrowsers :
{
doc : 'White listing browsers that support audio output device selection.',
format : Array,
default : [
'chrome',
'opera'
]
},
requestTimeout :
{
doc : 'The Socket.io request timeout.',
format : 'nat',
default : 20000
},
requestRetries :
{
doc : 'The Socket.io request maximum retries.',
format : 'nat',
default : 3
},
transportOptions :
{
doc : '',
format : Object,
default : {
tcp : true
}
},
autoGainControl :
{
doc : 'Auto gain control enabled.',
format : 'Boolean',
default : true
},
echoCancellation :
{
doc : 'Echo cancellation enabled.',
format : 'Boolean',
default : true
},
noiseSuppression :
{
doc : 'Noise suppression enabled.',
format : 'Boolean',
default : true
},
voiceActivatedUnmute :
{
doc : 'Automatically unmute speaking above noiseThreshold.',
format : 'Boolean',
default : false
},
noiseThreshold :
{
doc : 'This is only for voiceActivatedUnmute and audio-indicator.',
format : 'int',
default : -60
},
// Audio options for now only centrally from config file:
centralAudioOptions :
{
doc : 'Defaults audio settings.',
format : Object,
default :
{
// will not eat that much bandwith thanks to opus
sampleRate : 48000, // default : 48000 and don't go higher
// usually mics are mono so this saves bandwidth
channelCount : 1, // default : 1
volume : 1.0, // default : 1.0
sampleSize : 16, // default : 16
// usually mics are mono so this saves bandwidth
opusStereo : false, // default : false
opusDtx : true, // default : true / will save bandwidth
opusFec : true, // default : true / forward error correction
opusPtime : '20', // default : 20 / minimum packet time (3, 5, 10, 20, 40, 60, 120)
opusMaxPlaybackRate : 48000 // default : 48000 and don't go higher
}
},
/**
* Set max 'int' participants in one room that join
* unmuted. Next participant will join automatically muted
* Default value is 4
*
* Set it to 0 to auto mute all,
* Set it to negative (-1) to never automatically auto mute
* but use it with caution
* full mesh audio strongly decrease room capacity!
*/
autoMuteThreshold :
{
doc : 'Set the max number of participants in one room that join unmuted.',
format : 'nat',
default : 4
},
background :
{
doc : 'The page background image URL',
format : String,
default : 'images/background.jpg'
},
defaultLayout :
{
doc : 'The default layout',
format : [ 'democratic', 'filmstrip' ],
default : 'democratic'
},
buttonControlBar :
{
doc : 'If true, will show media control buttons in separate control bar, not in the ME container.',
format : 'Boolean',
default : false
},
drawerOverlayed :
{
doc : 'If false, will push videos away to make room for side drawer. If true, will overlay side drawer over videos.',
format : 'Boolean',
default : true
},
notificationPosition :
{
doc : 'The position of notifications.',
format : [ 'left', 'right' ],
default : 'right'
},
/**
* Set the notificationSounds. Valid keys are:
* 'parkedPeer', 'parkedPeers', 'raisedHand', 'chatMessage',
* 'sendFile', 'newPeer' and 'default'.
*
* Not defining a key is equivalent to using the default notification sound.
* Setting 'play' to null disables the sound notification.
*/
notificationSounds :
{
doc : 'Set the notifications sounds.',
format : Object,
default :
{
chatMessage : {
play : '/sounds/notify-chat.mp3'
},
raisedHand : {
play : '/sounds/notify-hand.mp3'
},
default : {
delay : 5000, // minimum delay between alert sounds [ms]
play : '/sounds/notify.mp3'
}
}
},
hideTimeout :
{
doc : 'Timeout for autohiding topbar and button control bar.',
format : 'int',
default : 3000
},
lastN :
{
doc : 'The max number of participant that will be visible in as speaker.',
format : 'nat',
default : 4
},
mobileLastN :
{
doc : 'The max number of participant that will be visible in as speaker for mobile users.',
format : 'nat',
default : 1
},
maxLastN :
{
doc : 'The highest number of lastN the user can select manually in the user interface.',
format : 'nat',
default : 5
},
lockLastN :
{
doc : 'If truthy, users can NOT change number of speakers visible.',
format : 'Boolean',
default : false
},
// Show logo if "logo" is not null, else show title
// Set logo file name using logo.* pattern like "logo.png" to not track it by git
logo :
{
doc : 'If not null, it shows the logo loaded from URL, else it shows the title.',
format : 'url',
default : 'images/logo.edumeet.svg'
},
title :
{
doc : 'The title to show if the logo is not specified.',
format : String,
default : 'edumeet'
},
supportUrl :
{
doc : 'Service & Support URL if not set then not displayed on the about modals.',
format : 'url',
default : 'https://support.example.com'
},
// Privacy and data protection URL or path by default privacy/privacy.html
// that is a placeholder for your policies
//
// but an external url could be also used here
privacyUrl :
{
doc : 'Privacy and data protection URL or path by default privacy/privacy.html.',
format : String,
default : 'privacy/privacy.html'
},
// UI theme elements
theme :
{
palette :
{
primary :
{
main : { format: String, default: '#313131' }
}
},
overrides :
{
MuiAppBar :
{
colorPrimary :
{
backgroundColor : { format: String, default: '#313131' }
}
},
MuiButton :
{
containedPrimary :
{
backgroundColor : { format: String, default: '#5F9B2D' },
'&:hover' :
{
backgroundColor : { format: String, default: '#5F9B2D' }
}
},
containedSecondary :
{
backgroundColor : { format: String, default: '#f50057' },
'&:hover' :
{
backgroundColor : { format: String, default: '#f50057' }
}
}
},
/*
MuiIconButton :
{
colorPrimary :
{
backgroundColor : '#5F9B2D',
'&:hover' :
{
backgroundColor : '#5F9B2D'
}
},
colorSecondary :
{
backgroundColor : '#f50057',
'&:hover' :
{
backgroundColor : '#f50057'
}
}
},
*/
MuiFab :
{
primary :
{
backgroundColor : { format: String, default: '#5F9B2D' },
'&:hover' :
{
backgroundColor : { format: String, default: '#5F9B2D' }
}
},
secondary :
{
backgroundColor : { format: String, default: '#f50057' },
'&:hover' :
{
backgroundColor : { format: String, default: '#f50057' }
}
}
},
MuiBadge :
{
colorPrimary :
{
backgroundColor : { format: String, default: '#5F9B2D' },
'&:hover' :
{
backgroundColor : { format: String, default: '#518029' }
}
}
}
},
typography :
{
useNextVariants : { format: 'Boolean', default: true }
}
}
});
function formatDocs(docs: any, property: string | null, schema: any)
{
if (schema._cvtProperties)
{
Object.entries(schema._cvtProperties).forEach(([ name, value ]) =>
{
formatDocs(docs, `${property ? `${property}.` : ''}${name}`, value);
});
return docs;
}
else if (property)
{
docs[property] =
{
doc : schema.doc,
format : JSON.stringify(schema.format, null, 2),
default : JSON.stringify(schema.default, null, 2)
};
}
return docs;
}
let config: any = {};
let configError = '';
// Load config from window object
configSchema.load((window as any).config);
// Perform validation
try
{
configSchema.validate({ allowed: 'strict' });
config = configSchema.getProperties();
}
catch (error: any)
{
configError = error.message;
}
// format docs
const configDocs = formatDocs({}, null, configSchema.getSchema());
// eslint-disable-next-line
console.log('Using config:', config, configDocs);
// Override the window config with the validated properties.
(window as any)['config'] = config;
export {
configSchema,
config,
configError,
configDocs
};

View file

@ -16,6 +16,8 @@ import RoomContext from './RoomContext';
import deviceInfo from './deviceInfo';
import * as meActions from './actions/meActions';
import UnsupportedBrowser from './components/UnsupportedBrowser';
import ConfigDocumentation from './components/ConfigDocumentation';
import ConfigError from './components/ConfigError';
import JoinDialog from './components/JoinDialog';
import LoginDialog from './components/AccessControl/LoginDialog';
import LoadingView from './components/Loader/LoadingView';
@ -29,11 +31,14 @@ import { detectDevice } from 'mediasoup-client';
import './index.css';
import { config, configError } from './config';
const App = LazyPreload(() => import(/* webpackChunkName: "app" */ './components/App'));
// const cache = createIntlCache();
const supportedBrowsers={
const supportedBrowsers =
{
'windows' : {
'internet explorer' : '>12',
'microsoft edge' : '>18'
@ -141,14 +146,48 @@ function run()
if (unsupportedBrowser || webrtcUnavailable)
{
render(
<MuiThemeProvider theme={theme}>
<IntlProvider value={intl}>
<UnsupportedBrowser
webrtcUnavailable={webrtcUnavailable}
platform={device.platform}
/>
</IntlProvider>
</MuiThemeProvider>,
<Provider store={store}>
<MuiThemeProvider theme={theme}>
<IntlProvider value={intl}>
<UnsupportedBrowser
webrtcUnavailable={webrtcUnavailable}
platform={device.platform}
/>
</IntlProvider>
</MuiThemeProvider>
</Provider>,
document.getElementById('edumeet')
);
return;
}
if (basePath === '/config')
{
render(
<Provider store={store}>
<MuiThemeProvider theme={theme}>
<IntlProvider value={intl}>
<ConfigDocumentation />
</IntlProvider>
</MuiThemeProvider>
</Provider>,
document.getElementById('edumeet')
);
return;
}
if (configError)
{
render(
<Provider store={store}>
<MuiThemeProvider theme={theme}>
<IntlProvider value={intl}>
<ConfigError configError={configError} />
</IntlProvider>
</MuiThemeProvider>
</Provider>,
document.getElementById('edumeet')
);

View file

@ -0,0 +1,22 @@
import { config as defaultConfig } from '../config';
const initialState =
{
...defaultConfig
};
const config = (state = initialState, action) =>
{
switch (action.type)
{
case 'CONFIG_SET':
{
return { ...action.payload };
}
default:
return state;
}
};
export default config;

View file

@ -13,6 +13,7 @@ import files from './files';
import settings from './settings';
import transports from './transports';
import intl from './intl';
import config from './config';
// import { intlReducer } from 'react-intl-redux';
export default combineReducers({
@ -30,5 +31,6 @@ export default combineReducers({
files,
settings,
// intl : intlReducer
intl
intl,
config
});

View file

@ -1,6 +1,37 @@
export const defaultSettings = {
audioPreset : 'conference',
audioPresets :
import { config } from '../config';
const initialState =
{
displayName : '',
selectedWebcam : null,
selectedAudioDevice : null,
advancedMode : false,
autoGainControl : config.autoGainControl,
echoCancellation : config.echoCancellation,
noiseSuppression : config.noiseSuppression,
voiceActivatedUnmute : config.voiceActivatedUnmute,
noiseThreshold : config.noiseThreshold,
audioMuted : false,
videoMuted : false,
// low, medium, high, veryhigh, ultra
resolution : config.resolution,
frameRate : config.frameRate,
screenSharingResolution : config.screenResolution,
screenSharingFrameRate : config.screenSharingFrameRate,
lastN : 4,
permanentTopBar : true,
hiddenControls : false,
showNotifications : true,
notificationSounds : true,
mirrorOwnVideo : true,
hideNoVideoParticipants : false,
buttonControlBar : config.buttonControlBar,
drawerOverlayed : config.drawerOverlayed,
aspectRatio : config.viewAspectRatio,
mediaPerms : { audio: true, video: true },
localPicture : null,
audioPreset : 'conference',
audioPresets :
{
conference :
{
@ -49,41 +80,6 @@ export const defaultSettings = {
}
};
const initialState =
{
displayName : '',
selectedWebcam : null,
selectedAudioDevice : null,
advancedMode : false,
autoGainControl : true,
echoCancellation : true,
noiseSuppression : true,
voiceActivatedUnmute : false,
noiseThreshold : -50,
audioMuted : false,
videoMuted : false,
// low, medium, high, veryhigh, ultra
resolution : window.config.defaultResolution || 'medium',
frameRate : window.config.defaultFrameRate || 15,
screenSharingResolution : window.config.defaultScreenResolution || 'veryhigh',
screenSharingFrameRate : window.config.defaultScreenSharingFrameRate || 5,
lastN : 4,
permanentTopBar : true,
hiddenControls : false,
showNotifications : true,
notificationSounds : true,
mirrorOwnVideo : true,
hideNoVideoParticipants : false,
buttonControlBar : window.config.buttonControlBar || false,
drawerOverlayed : (typeof window.config.drawerOverlayed === 'undefined') ? true : window.config.drawerOverlayed,
aspectRatio : window.config.viewAspectRatio || 1.777, // 16 : 9
mediaPerms : { audio: true, video: true },
localPicture : null
};
Object.assign(initialState, defaultSettings);
Object.assign(initialState, defaultSettings.audioPresets[defaultSettings.audioPreset]);
const settings = (state = initialState, action) =>
{
switch (action.type)
@ -355,6 +351,11 @@ const settings = (state = initialState, action) =>
return { ...state, localPicture };
}
case 'SETTINGS_UPDATE':
{
return { ...state, ...action.payload };
}
default:
return state;
}

View file

@ -8,9 +8,14 @@ import { createLogger } from 'redux-logger';
import { createMigrate, persistStore, persistReducer } from 'redux-persist';
import storage from 'redux-persist/lib/storage';
import autoMergeLevel2 from 'redux-persist/lib/stateReconciler/autoMergeLevel2';
import rootReducer from './reducers/rootReducer';
import { createFilter } from 'redux-persist-transform-filter';
import { defaultSettings } from './reducers/settings';
import { diff } from 'deep-object-diff';
import rootReducer from './reducers/rootReducer';
import Logger from './Logger';
import { config } from './config';
const logger = new Logger('store');
const migrations =
{
@ -65,7 +70,7 @@ const persistConfig =
version : 3,
migrate : createMigrate(migrations, { debug: true }),
stateReconciler : autoMergeLevel2,
whitelist : [ 'settings', 'intl' ]
whitelist : [ 'settings', 'intl', 'config' ]
};
const saveSubsetFilter = createFilter(
@ -124,7 +129,27 @@ export const store = createStore(
enhancer
);
export const persistor = persistStore(store);
export const persistor = persistStore(store, null, () =>
{
// Check if the app config differs from the stored version.
const currentConfig = store.getState().config;
const changed = diff(currentConfig, config);
const changedKeys = Object.keys(changed);
if (changedKeys.length)
{
logger.debug('store config changed:', changed);
const changedSettings = {};
changedKeys.forEach((key) =>
{
changedSettings[key] = config[key];
});
store.dispatch({ type: 'SETTINGS_UPDATE', payload: changedSettings });
store.dispatch({ type: 'CONFIG_SET', payload: config });
}
});
/*

View file

@ -2,151 +2,158 @@
/* eslint-disable import/no-dynamic-require */
const list = [
{
name: 'English',
file: 'en',
locale: ['en', 'en-en'],
},
{
name: 'Czech',
file: 'cs',
locale: ['cs', 'cs-cs'],
},
{
name: 'Chinese (Simplified)',
file: 'cn',
locale: ['zn', 'zn-zn', 'zn-cn'],
}, // hans
{
name: 'Chinese (Traditional)',
file: 'tw',
locale: ['zn-tw', 'zn-hk', 'zn-sg'],
}, // hant
{
name: 'Croatian',
file: 'hr',
locale: ['hr', 'hr-hr'],
},
{
name: 'Danish',
file: 'dk',
locale: ['dk', 'dk-dk'],
},
{
name: 'French',
file: 'fr',
locale: ['fr', 'fr-fr'],
},
{
name: 'German',
file: 'de',
locale: ['de', 'de-de'],
},
{
name: 'Greek',
file: 'el',
locale: ['el', 'el-el'],
},
{
name: 'Hindi',
file: 'hi',
locale: ['hi', 'hi-hi'],
},
{
name: 'Hungarian',
file: 'hu',
locale: ['hu', 'hu-hu'],
},
{
name: 'Italian',
file: 'it',
locale: ['it', 'it-it'],
},
{
name: 'Kazakh',
file: 'kk',
locale: ['kk', 'kk-kz '],
},
{
name: 'Latvian',
file: 'lv',
locale: ['lv', 'lv-lv'],
},
{
name: 'Norwegian',
file: 'nb',
locale: ['nb', 'nb-no'],
},
{
name: 'Polish',
file: 'pl',
locale: ['pl', 'pl-pl'],
},
{
name: 'Portuguese',
file: 'pt',
locale: ['pt', 'pt-pt'],
},
{
name: 'Romanian',
file: 'ro',
locale: ['ro', 'ro-ro'],
},
{
name: 'Russian',
file: 'ru',
locale: ['ru', 'ru-ru'],
},
{
name: 'Spanish',
file: 'es',
locale: ['es', 'es-es'],
},
{
name: 'Turkish',
file: 'tr',
locale: ['tr', 'tr-tr'],
},
{
name: 'Ukrainian',
file: 'uk',
locale: ['uk', 'uk-uk'],
},
]
{
name : 'English',
file : 'en',
locale : [ 'en', 'en-en' ]
},
{
name : 'Czech',
file : 'cs',
locale : [ 'cs', 'cs-cs' ]
},
{
name : 'Chinese (Simplified)',
file : 'cn',
locale : [ 'zn', 'zn-zn', 'zn-cn' ]
}, // hans
{
name : 'Chinese (Traditional)',
file : 'tw',
locale : [ 'zn-tw', 'zn-hk', 'zn-sg' ]
}, // hant
{
name : 'Croatian',
file : 'hr',
locale : [ 'hr', 'hr-hr' ]
},
{
name : 'Danish',
file : 'dk',
locale : [ 'dk', 'dk-dk' ]
},
{
name : 'French',
file : 'fr',
locale : [ 'fr', 'fr-fr' ]
},
{
name : 'German',
file : 'de',
locale : [ 'de', 'de-de' ]
},
{
name : 'Greek',
file : 'el',
locale : [ 'el', 'el-el' ]
},
{
name : 'Hindi',
file : 'hi',
locale : [ 'hi', 'hi-hi' ]
},
{
name : 'Hungarian',
file : 'hu',
locale : [ 'hu', 'hu-hu' ]
},
{
name : 'Italian',
file : 'it',
locale : [ 'it', 'it-it' ]
},
{
name : 'Kazakh',
file : 'kk',
locale : [ 'kk', 'kk-kz ' ]
},
{
name : 'Latvian',
file : 'lv',
locale : [ 'lv', 'lv-lv' ]
},
{
name : 'Norwegian',
file : 'nb',
locale : [ 'nb', 'nb-no' ]
},
{
name : 'Polish',
file : 'pl',
locale : [ 'pl', 'pl-pl' ]
},
{
name : 'Portuguese',
file : 'pt',
locale : [ 'pt', 'pt-pt' ]
},
{
name : 'Romanian',
file : 'ro',
locale : [ 'ro', 'ro-ro' ]
},
{
name : 'Russian',
file : 'ru',
locale : [ 'ru', 'ru-ru' ]
},
{
name : 'Spanish',
file : 'es',
locale : [ 'es', 'es-es' ]
},
{
name : 'Turkish',
file : 'tr',
locale : [ 'tr', 'tr-tr' ]
},
{
name : 'Ukrainian',
file : 'uk',
locale : [ 'uk', 'uk-uk' ]
}
];
export const detect = () => {
const localeFull = (navigator.language || (navigator as any).browserLanguage).toLowerCase()
export const detect = () =>
{
const localeFull = (navigator.language ||
(navigator as any).browserLanguage).toLowerCase();
// const localeCountry = localeFull.split(/[-_]/)[0];
// const localeCountry = localeFull.split(/[-_]/)[0];
// const localeRegion = localeFull.split(/[-_]/)[1] || null;
// const localeRegion = localeFull.split(/[-_]/)[1] || null;
return localeFull
}
return localeFull;
};
export const getList = () => list
export const getList = () => list;
export interface ILocale {
name: string
file: string
locale: string[]
messages: any
name: string;
file: string;
locale: string[];
messages: any;
}
export const loadOne = (locale: string): ILocale => {
let res: any = {}
export const loadOne = (locale: string): ILocale =>
{
let res: any = {};
try {
res = list.filter(
(item) => item.locale.includes(locale) || item.locale.includes(locale.split(/[-_]/)[0])
)[0]
try
{
res = list.filter(
// (item) => item.locale.includes(locale) || item.locale.includes(locale.split(/[-_]/)[0])
(item) => item.locale.includes(locale)
)[0];
res.messages = require(`./${res.file}`)
} catch {
res = list.filter((item) => item.locale.includes('en'))[0]
res.messages = require(`./${res.file}`);
}
catch
{
res = list.filter((item) => item.locale.includes('en'))[0];
res.messages = require(`./${res.file}`)
}
res.messages = require(`./${res.file}`);
}
return res
}
return res;
};

View file

@ -1,58 +1,58 @@
{
"socket.disconnected": "您已斷開連接",
"socket.reconnecting": "嘗試重新連",
"socket.reconnected": "您已重新連接",
"socket.requestError": "器請求錯誤",
"socket.disconnected": "您已中斷連線",
"socket.reconnecting": "嘗試重新連",
"socket.reconnected": "已恢復連線",
"socket.requestError": "服器請求發生錯誤",
"room.chooseRoom": "選擇您要加入的房間的名稱",
"room.cookieConsent": "這個網站使用Cookies來提升您的使用者體驗",
"room.chooseRoom": "選擇您要加入的會議室名稱",
"room.cookieConsent": "本網站使用 Cookies 技術來提升您的使用者體驗",
"room.consentUnderstand": "了解",
"room.joined": "您已加入房間",
"room.cantJoin": "無法加入房間",
"room.youLocked": "您已鎖定房間",
"room.cantLock": "無法鎖定房間",
"room.youUnLocked": "您解鎖了房間",
"room.cantUnLock": "無法解鎖房間",
"room.locked": "房間已鎖定",
"room.unlocked": "房間現已解鎖",
"room.joined": "您已加入會議室",
"room.cantJoin": "無法加入會議室",
"room.youLocked": "您已鎖定會議室",
"room.cantLock": "無法鎖定會議室",
"room.youUnLocked": "您解鎖了會議室",
"room.cantUnLock": "無法解鎖會議室",
"room.locked": "會議室已鎖定",
"room.unlocked": "會議室現已解鎖",
"room.newLobbyPeer": "新參與者進入大廳",
"room.lobbyPeerLeft": "參與者離開大廳",
"room.lobbyPeerChangedDisplayName": "大廳的參與者將名稱變更為 {displayName}",
"room.lobbyPeerChangedPicture": "大廳的參與者變更了圖片",
"room.setAccessCode": "設置房間的進入密碼",
"room.accessCodeOn": "房間的進入密碼現已啟用",
"room.accessCodeOff": "房間的進入密碼已停用",
"room.peerChangedDisplayName": "{oldDisplayName} 已更名為 {displayName}",
"room.setAccessCode": "設定會議室的密碼",
"room.accessCodeOn": "已啟用會議室密碼",
"room.accessCodeOff": "已停用會議室密碼",
"room.peerChangedDisplayName": "{oldDisplayName} 已更名為 {displayName}",
"room.newPeer": "{displayName} 加入了會議室",
"room.newFile": "有新文件",
"room.newFile": "有新檔案",
"room.toggleAdvancedMode": "切換進階模式",
"room.setDemocraticView": "已更改為使用者佈局",
"room.setFilmStripView": "已更改為投影片佈局",
"room.setDemocraticView": "已更改為使用者版面",
"room.setFilmStripView": "已更改為投影片版面",
"room.loggedIn": "您已登入",
"room.loggedOut": "您已登出",
"room.changedDisplayName": "您的顯示名稱已變更為 {displayName}",
"room.changeDisplayNameError": "更改顯示名稱時發生錯誤",
"room.chatError": "無法發送聊天消息",
"room.aboutToJoin": "您即將參加會議",
"room.roomId": "房間ID: {roomName}",
"room.setYourName": "設您的顯示名稱,並選擇您想加入的方式:",
"room.roomId": "會議室 ID: {roomName}",
"room.setYourName": "設您的顯示名稱,並選擇您想加入的方式:",
"room.audioOnly": "僅通話",
"room.audioVideo": "通話和視訊",
"room.youAreReady": "準備完畢!",
"room.emptyRequireLogin": "房間是空的! 您可以登錄以開始會議或等待主持人加入",
"room.locketWait": "房間已鎖定! 請等待其他人允許您進入...",
"room.emptyRequireLogin": "會議室空蕩蕩的… 您可以登入以開始會議或等待主持人加入",
"room.locketWait": "會議室已上鎖! 請等待其他人允許您進入…",
"room.lobbyAdministration": "大廳管理",
"room.peersInLobby": "大廳的參與者",
"room.lobbyEmpty": "大廳目前沒有人",
"room.hiddenPeers": "{hiddenPeersCount, plural, one {participant} other {participants}}",
"room.me": "我",
"room.spotlights": "Spotlight中的參與者",
"room.spotlights": "Spotlight 中的參與者",
"room.passive": "被動參與者",
"room.videoPaused": "視訊已關閉",
"room.muteAll": "全部靜音",
"room.stopAllVideo": "關閉全部視訊",
"room.closeMeeting": "關閉會議",
"room.clearChat": "清除聊天",
"room.clearChat": "清除聊天訊息",
"room.clearFileSharing": "清除檔案",
"room.speechUnsupported": "您的瀏覽器不支援語音辨識",
"room.moderatoractions": "管理員動作",
@ -60,7 +60,7 @@
"room.loweredHand": "{displayName} 放下了他的手",
"room.extraVideo": "其他視訊",
"room.extraVideoDuplication": null,
"room.overRoomLimit": "房間已滿,請稍後重試",
"room.overRoomLimit": "會議室已滿,請稍後重試",
"room.help": "幫助",
"room.about": "關於",
"room.shortcutKeys": "鍵盤快速鍵",
@ -68,7 +68,7 @@
"room.hideSelfView": null,
"room.showSelfView": null,
"me.mutedPTT": "您已靜音,請按下 空白鍵 來說話",
"me.mutedPTT": "您已將麥克風靜音,請按下「空白鍵」來發言",
"roles.gotRole": "您已取得身份: {role}",
"roles.lostRole": "您的 {role} 身份已被撤銷",
@ -76,14 +76,14 @@
"tooltip.login": "登入",
"tooltip.logout": "登出",
"tooltip.admitFromLobby": "從大廳允許",
"tooltip.lockRoom": "鎖定房間",
"tooltip.unLockRoom": "解鎖房間",
"tooltip.lockRoom": "鎖上會議室",
"tooltip.unLockRoom": "解鎖會議室",
"tooltip.enterFullscreen": "進入全螢幕",
"tooltip.leaveFullscreen": "退出全螢幕",
"tooltip.lobby": "顯示大廳",
"tooltip.settings": "顯示設置",
"tooltip.settings": "設定",
"tooltip.participants": "顯示參加者",
"tooltip.kickParticipant": "踢出",
"tooltip.kickParticipant": "踢出會議室",
"tooltip.muteParticipant": "靜音",
"tooltip.muteParticipantVideo": "隱藏視訊",
"tooltip.inSpotlight": null,
@ -95,11 +95,11 @@
"tooltip.unMuteParticipant": null,
"tooltip.addParticipantToSpotlight": null,
"tooltip.removeParticipantFromSpotlight": null,
"tooltip.muteParticipantAudioModerator": "關閉聲音",
"tooltip.muteParticipantAudioModerator": "噤聲",
"tooltip.muteParticipantVideoModerator": "關閉視訊",
"tooltip.muteScreenSharingModerator": "關閉螢幕分享",
"label.roomName": "房間名稱",
"label.roomName": "會議室名稱",
"label.chooseRoomButton": "繼續",
"label.yourName": "您的名字",
"label.newWindow": "新視窗",
@ -108,14 +108,14 @@
"label.leave": "離開",
"label.chatInput": "輸入聊天訊息",
"label.chat": "聊天",
"label.filesharing": "文件分享",
"label.filesharing": "檔案分享",
"label.participants": "參與者",
"label.shareFile": "分享文件",
"label.shareFile": "分享檔案",
"label.shareGalleryFile": "分享圖片",
"label.fileSharingUnsupported": "不支援文件分享",
"label.fileSharingUnsupported": "不支援檔案分享",
"label.unknown": "未知",
"label.democratic": "使用者佈局",
"label.filmstrip": "投影片佈局",
"label.democratic": "使用者版面",
"label.filmstrip": "投影片版面",
"label.low": "低",
"label.medium": "中",
"label.high": "高 (HD)",
@ -135,7 +135,7 @@
"label.logout": null,
"label.join": null,
"settings.settings": "設",
"settings.settings": "設",
"settings.camera": "視訊來源",
"settings.selectCamera": "選擇視訊來源",
"settings.cantSelectCamera": "無法選擇此視訊來源",
@ -149,8 +149,8 @@
"settings.frameRate": null,
"settings.screenSharingResolution": null,
"settings.screenSharingFrameRate": null,
"settings.layout": "房間佈局",
"settings.selectRoomLayout": "選擇房間佈局",
"settings.layout": "會議室版面",
"settings.selectRoomLayout": "選擇會議室版面",
"settings.advancedMode": "進階模式",
"settings.permanentTopBar": "固定頂端列",
"settings.aspectRatio": null,
@ -162,10 +162,10 @@
"settings.buttonControlBar": "獨立控制按鈕",
"settings.showAdvancedVideo": null,
"settings.showAdvancedAudio": null,
"settings.echoCancellation": "回音消除",
"settings.echoCancellation": "抑制回音",
"settings.autoGainControl": "自動增益控制",
"settings.noiseSuppression": "噪音消除",
"settings.drawerOverlayed": "側邊欄覆蓋畫面",
"settings.noiseSuppression": "消除噪音",
"settings.drawerOverlayed": "側邊欄覆蓋",
"settings.voiceActivatedUnmute": null,
"settings.noiseThreshold": null,
"settings.mirrorOwnVideo": null,
@ -176,18 +176,18 @@
"settings.myPhotoSizeError": null,
"settings.myPhotoTypeError": null,
"filesharing.saveFileError": "無法保存文件",
"filesharing.startingFileShare": "開始分享文件",
"filesharing.successfulFileShare": "文件已成功分享",
"filesharing.unableToShare": "無法分享文件",
"filesharing.error": "文件分享發生錯誤",
"filesharing.finished": "文件分享成功",
"filesharing.save": "保存文件",
"filesharing.sharedFile": "{displayName} 分享了一個文件",
"filesharing.download": "下載文件",
"filesharing.missingSeeds": "如果過了很久還是無法下載,則可能沒有人播種了。請讓上傳者重新上傳您想要的文件。",
"filesharing.saveFileError": "無法儲存檔案",
"filesharing.startingFileShare": "開始分享檔案",
"filesharing.successfulFileShare": "成功分享檔案",
"filesharing.unableToShare": "無法分享檔案",
"filesharing.error": "分享檔案時發生錯誤",
"filesharing.finished": "檔案分享完成",
"filesharing.save": "儲存檔案",
"filesharing.sharedFile": "{displayName} 分享了一份檔案",
"filesharing.download": "下載檔案",
"filesharing.missingSeeds": "如果過了很久還是無法下載,則可能沒有人播種了。請讓上傳者重新上傳您需要的檔案。",
"devices.devicesChanged": "您的設備已更改,請在設置中設定您的設備",
"devices.devicesChanged": "您已變更裝置,請在設定中調整您的裝置",
"devices.enableOnlyMicrophone": null,
"device.audioUnsupported": "不支援您的音訊格式",
@ -201,18 +201,18 @@
"device.stopVideo": "關閉視訊",
"device.screenSharingUnsupported": "不支援您的螢幕分享格式",
"device.startScreenSharing": "開始螢幕分享",
"device.startScreenSharing": "開始分享螢幕",
"device.stopScreenSharing": "停止螢幕分享",
"devices.microphoneDisconnected": "麥克風已斷開",
"devices.microphoneDisconnected": "麥克風連線中斷",
"devices.microphoneError": "麥克風發生錯誤",
"devices.microphoneMute": "麥克風靜音",
"devices.microphoneUnMute": "取消麥克風靜音",
"devices.microphoneEnable": "麥克風已啟用",
"devices.microphoneEnable": "已啟用麥克風",
"devices.microphoneMuteError": "無法使麥克風靜音",
"devices.microphoneUnMuteError": "無法取消麥克風靜音",
"devices.screenSharingDisconnected": "螢幕分享已",
"devices.screenSharingDisconnected": "螢幕分享已斷",
"devices.screenSharingError": "螢幕分享時發生錯誤",
"devices.cameraDisconnected": "相機已斷開連接",

View file

@ -21,7 +21,6 @@
"jsx": "react-jsx",
"downlevelIteration": true
},
"include": [
"src"
]
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "**/*.spec.ts"]
}

File diff suppressed because it is too large Load diff

View file

@ -28,5 +28,15 @@ also for the websocket connection.
Rebuild the web application bundle:
```sh
docker-compose exec edumeet sh -c "cd app && yarn && yarn build"
docker-compose exec -u $UID edumeet sh -c "cd app && yarn && yarn build"
```
## Known issues
The docker virtual network used by this compose configuration (`172.22.0.0/24`)
should be reacheble from all the started services. If iptables is filtering the
INPUT chain, this rule is required to make the services communicating with each other:
```
iptables -A INPUT --src 172.22.0.0/16 -j ACCEPT
```

View file

@ -1,311 +1,6 @@
// eslint-disable-next-line
var config =
{
loginEnabled : false,
developmentPort : 8443,
productionPort : 3443,
/*
// If the server component runs on a different host than the app
// you can uncomment the following line and specify the host name.
serverHostname : 'external-server.com',
*/
/**
* Supported browsers version
* in bowser satisfy format.
* See more:
* https://www.npmjs.com/package/bowser#filtering-browsers
* Otherwise you got a unsupported browser page
*/
supportedBrowsers :
{
'windows' : {
'internet explorer' : '>12',
'microsoft edge' : '>18'
},
'safari' : '>12',
'firefox' : '>=60',
'chrome' : '>=74',
'chromium' : '>=74',
'opera' : '>=62',
'samsung internet for android' : '>=11.1.1.52'
},
/**
* Network priorities
* DSCP bits set by browser according this priority values.
* ("high" means actually: EF for audio, and AF41 for Video in chrome)
* https://en.wikipedia.org/wiki/Differentiated_services
*/
networkPriorities :
{
'audio' : 'high',
'mainVideo' : 'high',
'additionalVideos' : 'medium',
'screenShare' : 'medium'
},
/**
* Resolutions:
*
* low ~ 320x240
* medium ~ 640x480
* high ~ 1280x720
* veryhigh ~ 1920x1080
* ultra ~ 3840x2560
*
**/
/**
* Frame rates:
*
* 1, 5, 10, 15, 20, 25, 30
*
**/
// The aspect ratio of the videos as shown on
// the screen. This is changeable in client settings.
// This value must match one of the defined values in
// viewAspectRatios EXACTLY (e.g. 1.333)
viewAspectRatio : 1.777,
// These are the selectable aspect ratios in the settings
viewAspectRatios : [ {
value : 1.333, // 4 / 3
label : '4 : 3'
}, {
value : 1.777, // 16 / 9
label : '16 : 9'
} ],
// The aspect ratio of the video from the camera
// this is not changeable in settings, only config
videoAspectRatio : 1.777,
defaultResolution : 'medium',
defaultFrameRate : 15,
defaultScreenResolution : 'veryhigh',
defaultScreenSharingFrameRate : 5,
// Enable or disable simulcast for webcam video
simulcast : true,
// Enable or disable simulcast for screen sharing video
simulcastSharing : false,
// Define different encodings for various resolutions of the video
simulcastProfiles :
{
3840 :
[
{ scaleResolutionDownBy: 4, maxBitRate: 1500000 },
{ scaleResolutionDownBy: 2, maxBitRate: 4000000 },
{ scaleResolutionDownBy: 1, maxBitRate: 10000000 }
],
1920 :
[
{ scaleResolutionDownBy: 4, maxBitRate: 750000 },
{ scaleResolutionDownBy: 2, maxBitRate: 1500000 },
{ scaleResolutionDownBy: 1, maxBitRate: 4000000 }
],
1280 :
[
{ scaleResolutionDownBy: 4, maxBitRate: 250000 },
{ scaleResolutionDownBy: 2, maxBitRate: 900000 },
{ scaleResolutionDownBy: 1, maxBitRate: 3000000 }
],
640 :
[
{ scaleResolutionDownBy: 2, maxBitRate: 250000 },
{ scaleResolutionDownBy: 1, maxBitRate: 900000 }
],
320 :
[
{ scaleResolutionDownBy: 1, maxBitRate: 250000 }
]
},
// The adaptive spatial layer selection scaling factor (in the range [0.5, 1.0])
// example:
// with level width=640px, the minimum width required to trigger the
// level change will be: 640 * 0.75 = 480px
adaptiveScalingFactor : 0.75,
/**
* White listing browsers that support audio output device selection.
* It is not yet fully implemented in Firefox.
* See: https://bugzilla.mozilla.org/show_bug.cgi?id=1498512
*/
audioOutputSupportedBrowsers :
[
'chrome',
'opera'
],
// Socket.io request timeout
requestTimeout : 20000,
requestRetries : 3,
transportOptions :
{
tcp : true
},
/**
* Set max number participants in one room that join
* unmuted. Next participant will join automatically muted
* Default value is 4
*
* Set it to 0 to auto mute all,
* Set it to negative (-1) to never automatically auto mute
* but use it with caution
* full mesh audio strongly decrease room capacity!
*/
autoMuteThreshold : 4,
background : 'images/background.jpg',
defaultLayout : 'democratic', // democratic, filmstrip
// If true, will show media control buttons in separate
// control bar, not in the ME container.
buttonControlBar : false,
// If false, will push videos away to make room for side
// drawer. If true, will overlay side drawer over videos
drawerOverlayed : true,
// Position of notifications
notificationPosition : 'right',
/**
* Set the notificationSounds. Valid keys are:
* 'parkedPeer', 'parkedPeers', 'raisedHand', 'chatMessage',
* 'sendFile', 'newPeer' and 'default'.
*
* Not defining a key is equivalent to using the default notification sound.
* Setting 'play' to null disables the sound notification.
*/
notificationSounds : {
chatMessage : {
play : '/sounds/notify-chat.mp3'
},
raisedHand : {
play : '/sounds/notify-hand.mp3'
},
default : {
delay : 5000, // minimum delay between alert sounds [ms]
play : '/sounds/notify.mp3'
}
},
// Timeout for autohiding topbar and button control bar
hideTimeout : 3000,
// max number of participant that will be visible in
// as speaker
lastN : 4,
mobileLastN : 1,
// Highest number of lastN the user can select manually in
// userinteface
maxLastN : 5,
// If truthy, users can NOT change number of speakers visible
lockLastN : false,
// Show logo if "logo" is not null, else show title
// Set logo file name using logo.* pattern like "logo.png" to not track it by git
logo : 'images/logo.edumeet.svg',
title : 'edumeet',
// Service & Support URL
// if not set then not displayed on the about modals
supportUrl : 'https://support.example.com',
// Privacy and dataprotection URL or path
// by default privacy/privacy.html
// that is a placeholder for your policies
//
// but an external url could be also used here
privacyUrl : 'privacy/privacy.html',
theme :
{
palette :
{
primary :
{
main : '#313131'
}
},
overrides :
{
MuiAppBar :
{
colorPrimary :
{
backgroundColor : '#313131'
}
},
MuiButton :
{
containedPrimary :
{
backgroundColor : '#5F9B2D',
'&:hover' :
{
backgroundColor : '#5F9B2D'
}
},
containedSecondary :
{
backgroundColor : '#f50057',
'&:hover' :
{
backgroundColor : '#f50057'
}
}
},
/*
MuiIconButton :
{
colorPrimary :
{
backgroundColor : '#5F9B2D',
'&:hover' :
{
backgroundColor : '#5F9B2D'
}
},
colorSecondary :
{
backgroundColor : '#f50057',
'&:hover' :
{
backgroundColor : '#f50057'
}
}
},
*/
MuiFab :
{
primary :
{
backgroundColor : '#5F9B2D',
'&:hover' :
{
backgroundColor : '#5F9B2D'
}
},
secondary :
{
backgroundColor : '#f50057',
'&:hover' :
{
backgroundColor : '#f50057'
}
}
},
MuiBadge :
{
colorPrimary :
{
backgroundColor : '#5F9B2D',
'&:hover' :
{
backgroundColor : '#518029'
}
}
}
},
typography :
{
useNextVariants : true
}
}
productionPort : 3443
};

View file

@ -1,33 +1,7 @@
const os = require('os');
// const fs = require('fs');
const userRoles = require('../userRoles');
import {
BYPASS_ROOM_LOCK,
BYPASS_LOBBY
} from '../access';
const {
CHANGE_ROOM_LOCK,
PROMOTE_PEER,
MODIFY_ROLE,
SEND_CHAT,
MODERATE_CHAT,
SHARE_AUDIO,
SHARE_VIDEO,
SHARE_SCREEN,
EXTRA_VIDEO,
SHARE_FILE,
MODERATE_FILES,
MODERATE_ROOM
} = require('../permissions');
// const AwaitQueue = require('awaitqueue');
// const axios = require('axios');
// To gather ip address only on interface like eth0, ens0p3
const ifaceWhiteListRegex = /^(eth.*)|(ens.*)|(tun.*)/
const ifaceWhiteListRegex = /^(eth.*)|(ens.*)|(tun.*)|(wlp.*)/
function getListenIps() {
let listenIP = [];
@ -53,351 +27,6 @@ function getListenIps() {
module.exports =
{
// Auth conf
/*
auth :
{
// Always enabled if configured
lti :
{
consumerKey : 'key',
consumerSecret : 'secret'
},
// Auth strategy to use (default oidc)
strategy : 'oidc',
oidc :
{
// The issuer URL for OpenID Connect discovery
// The OpenID Provider Configuration Document
// could be discovered on:
// issuerURL + '/.well-known/openid-configuration'
// e.g. google OIDC config
// Follow this guide to get credential:
// https://developers.google.com/identity/protocols/oauth2/openid-connect
// use this issuerURL
// issuerURL : 'https://accounts.google.com/',
issuerURL : 'https://example.com',
clientOptions :
{
client_id : '',
client_secret : '',
scope : 'openid email profile',
// where client.example.com is your edumeet server
redirect_uri : 'https://client.example.com/auth/callback'
}
},
saml :
{
// where edumeet.example.com is your edumeet server
callbackUrl : 'https://edumeet.example.com/auth/callback',
issuer : 'https://edumeet.example.com',
entryPoint : 'https://openidp.feide.no/simplesaml/saml2/idp/SSOService.php',
privateCert : fs.readFileSync('config/saml_privkey.pem', 'utf-8'),
signingCert : fs.readFileSync('config/saml_cert.pem', 'utf-8'),
decryptionPvk : fs.readFileSync('config/saml_privkey.pem', 'utf-8'),
decryptionCert : fs.readFileSync('config/saml_cert.pem', 'utf-8'),
// Federation cert
cert : fs.readFileSync('config/federation_cert.pem', 'utf-8')
},
// to create password hash use: node server/utils/password_encode.js cleartextpassword
local :
{
users : [
{
id : 1,
username : 'alice',
passwordHash : '$2b$10$PAXXw.6cL3zJLd7ZX.AnL.sFg2nxjQPDmMmGSOQYIJSa0TrZ9azG6',
displayName : 'Alice',
emails : [ { value: 'alice@atlanta.com' } ]
},
{
id : 2,
username : 'bob',
passwordHash : '$2b$10$BzAkXcZ54JxhHTqCQcFn8.H6klY/G48t4jDBeTE2d2lZJk/.tvv0G',
displayName : 'Bob',
emails : [ { value: 'bob@biloxi.com' } ]
}
]
}
},
*/
// URI and key for requesting geoip-based TURN server closest to the client
//turnAPIKey : 'examplekey',
//turnAPIURI : 'https://example.com/api/turn',
//turnAPIparams : {
// 'uri_schema' : 'turn',
// 'transport' : 'tcp',
// 'ip_ver' : 'ipv4',
// 'servercount' : '2'
//},
//turnAPITimeout : 2 * 1000,
// Backup turnservers if REST fails or is not configured
//backupTurnServers : [
// {
// urls : [
// 'turn:turn.example.com:443?transport=tcp'
// ],
// username : 'example',
// credential : 'example'
// }
//],
// bittorrent tracker
fileTracker : 'wss://tracker.lab.vvc.niif.hu:443',
// redis server options
redisOptions : {
host: 'redis',
port: 6379
},
// session cookie secret
cookieSecret : 'T0P-S3cR3t_cook!e',
cookieName : 'edumeet.sid',
// if you use encrypted private key the set the passphrase
tls :
{
cert : `${__dirname}/../certs/mediasoup-demo.localhost.cert.pem`,
// passphrase: 'key_password'
key : `${__dirname}/../certs/mediasoup-demo.localhost.key.pem`
},
// listening Host or IP
// If omitted listens on every IP. ("0.0.0.0" and "::")
// listeningHost: 'localhost',
// Listening port for https server.
listeningPort : 3443,
// Any http request is redirected to https.
// Listening port for http server.
listeningRedirectPort : 8080,
// Listens only on http, only on listeningPort
// listeningRedirectPort disabled
// use case: loadbalancer backend
httpOnly : false,
// WebServer/Express trust proxy config for httpOnly mode
// You can find more info:
// - https://expressjs.com/en/guide/behind-proxies.html
// - https://www.npmjs.com/package/proxy-addr
// use case: loadbalancer backend
trustProxy : '',
// This logger class will have the log function
// called every time there is a room created or destroyed,
// or peer created or destroyed. This would then be able
// to log to a file or external service.
/* StatusLogger : class
{
constructor()
{
this._queue = new AwaitQueue();
}
// rooms: rooms object
// peers: peers object
// eslint-disable-next-line no-unused-vars
async log({ rooms, peers })
{
this._queue.push(async () =>
{
// Do your logging in here, use queue to keep correct order
// eslint-disable-next-line no-console
console.log('Number of rooms: ', rooms.size);
// eslint-disable-next-line no-console
console.log('Number of peers: ', peers.size);
})
.catch((error) =>
{
// eslint-disable-next-line no-console
console.log('error in log', error);
});
}
}, */
// This function will be called on successful login through oidc.
// Use this function to map your oidc userinfo to the Peer object.
// The roomId is equal to the room name.
// See examples below.
// Examples:
/*
// All authenicated users will be MODERATOR and AUTHENTICATED
userMapping : async ({ peer, room, roomId, userinfo }) =>
{
peer.addRole(userRoles.MODERATOR);
peer.addRole(userRoles.AUTHENTICATED);
},
// All authenicated users will be AUTHENTICATED,
// and those with the moderator role set in the userinfo
// will also be MODERATOR
userMapping : async ({ peer, room, roomId, userinfo }) =>
{
if (
Array.isArray(userinfo.meet_roles) &&
userinfo.meet_roles.includes('moderator')
)
{
peer.addRole(userRoles.MODERATOR);
}
if (
Array.isArray(userinfo.meet_roles) &&
userinfo.meet_roles.includes('meetingadmin')
)
{
peer.addRole(userRoles.ADMIN);
}
peer.addRole(userRoles.AUTHENTICATED);
},
// First authenticated user will be moderator,
// all others will be AUTHENTICATED
userMapping : async ({ peer, room, roomId, userinfo }) =>
{
if (room)
{
const peers = room.getJoinedPeers();
if (peers.some((_peer) => _peer.authenticated))
peer.addRole(userRoles.AUTHENTICATED);
else
{
peer.addRole(userRoles.MODERATOR);
peer.addRole(userRoles.AUTHENTICATED);
}
}
},
// All authenicated users will be AUTHENTICATED,
// and those with email ending with @example.com
// will also be MODERATOR
userMapping : async ({ peer, room, roomId, userinfo }) =>
{
if (userinfo.email && userinfo.email.endsWith('@example.com'))
{
peer.addRole(userRoles.MODERATOR);
}
peer.addRole(userRoles.AUTHENTICATED);
},
// All authenicated users will be AUTHENTICATED,
// and those with email ending with @example.com
// will also be MODERATOR
userMapping : async ({ peer, room, roomId, userinfo }) =>
{
if (userinfo.email && userinfo.email.endsWith('@example.com'))
{
peer.addRole(userRoles.MODERATOR);
}
peer.addRole(userRoles.AUTHENTICATED);
},
*/
// eslint-disable-next-line no-unused-vars
userMapping : async ({ peer, room, roomId, userinfo }) =>
{
if (userinfo.picture != null)
{
if (!userinfo.picture.match(/^http/g))
{
peer.picture = `data:image/jpeg;base64, ${userinfo.picture}`;
}
else
{
peer.picture = userinfo.picture;
}
}
if (userinfo['urn:oid:0.9.2342.19200300.100.1.60'] != null)
{
peer.picture = `data:image/jpeg;base64, ${userinfo['urn:oid:0.9.2342.19200300.100.1.60']}`;
}
if (userinfo.nickname != null)
{
peer.displayName = userinfo.nickname;
}
if (userinfo.name != null)
{
peer.displayName = userinfo.name;
}
if (userinfo.displayName != null)
{
peer.displayName = userinfo.displayName;
}
if (userinfo['urn:oid:2.16.840.1.113730.3.1.241'] != null)
{
peer.displayName = userinfo['urn:oid:2.16.840.1.113730.3.1.241'];
}
if (userinfo.email != null)
{
peer.email = userinfo.email;
}
},
// All users have the role "NORMAL" by default. Other roles need to be
// added in the "userMapping" function. The following accesses and
// permissions are arrays of roles. Roles can be changed in userRoles.js
//
// Example:
// [ userRoles.MODERATOR, userRoles.AUTHENTICATED ]
accessFromRoles : {
// The role(s) will gain access to the room
// even if it is locked (!)
[BYPASS_ROOM_LOCK] : [ userRoles.ADMIN ],
// The role(s) will gain access to the room without
// going into the lobby. If you want to restrict access to your
// server to only directly allow authenticated users, you could
// add the userRoles.AUTHENTICATED to the user in the userMapping
// function, and change to BYPASS_LOBBY : [ userRoles.AUTHENTICATED ]
[BYPASS_LOBBY] : [ userRoles.NORMAL ]
},
permissionsFromRoles : {
// The role(s) have permission to lock/unlock a room
[CHANGE_ROOM_LOCK] : [ userRoles.MODERATOR ],
// The role(s) have permission to promote a peer from the lobby
[PROMOTE_PEER] : [ userRoles.NORMAL ],
// The role(s) have permission to give/remove other peers roles
[MODIFY_ROLE] : [ userRoles.NORMAL ],
// The role(s) have permission to send chat messages
[SEND_CHAT] : [ userRoles.NORMAL ],
// The role(s) have permission to moderate chat
[MODERATE_CHAT] : [ userRoles.MODERATOR ],
// The role(s) have permission to share audio
[SHARE_AUDIO] : [ userRoles.NORMAL ],
// The role(s) have permission to share video
[SHARE_VIDEO] : [ userRoles.NORMAL ],
// The role(s) have permission to share screen
[SHARE_SCREEN] : [ userRoles.NORMAL ],
// The role(s) have permission to produce extra video
[EXTRA_VIDEO] : [ userRoles.NORMAL ],
// The role(s) have permission to share files
[SHARE_FILE] : [ userRoles.NORMAL ],
// The role(s) have permission to moderate files
[MODERATE_FILES] : [ userRoles.MODERATOR ],
// The role(s) have permission to moderate room (e.g. kick user)
[MODERATE_ROOM] : [ userRoles.MODERATOR ]
},
// Array of permissions. If no peer with the permission in question
// is in the room, all peers are permitted to do the action. The peers
// that are allowed because of this rule will not be able to do this
// action as soon as a peer with the permission joins. In this example
// everyone will be able to lock/unlock room until a MODERATOR joins.
allowWhenRoleMissing : [ CHANGE_ROOM_LOCK ],
// When truthy, the room will be open to all users when as long as there
// are allready users in the room
activateOnHostJoin : true,
// When set, maxUsersPerRoom defines how many users can join
// a single room. If not set, there is no limit.
// maxUsersPerRoom : 20,
// Room size before spreading to new router
routerScaleSize : 40,
// Socket timout value
requestTimeout : 20000,
// Socket retries when timeout
requestRetries : 3,
// If > 0, sets a cache-control max-age (in seconds) to static files responses.
staticFilesCachePeriod : 0,
// Mediasoup settings
mediasoup :
{
@ -479,32 +108,6 @@ module.exports =
webRtcTransport :
{
listenIps : getListenIps(),
/*[
// change 192.0.2.1 IPv4 to your server's IPv4 address!!
//{ ip: '192.0.2.1', announcedIp: null }
// Can have multiple listening interfaces
// change 2001:DB8::1 IPv6 to your server's IPv6 address!!
// { ip: '2001:DB8::1', announcedIp: null }
],*/
initialAvailableOutgoingBitrate : 1000000,
minimumAvailableOutgoingBitrate : 600000,
// Additional options that are not part of WebRtcTransportOptions.
maxIncomingBitrate : 1500000
}
}
,
// Prometheus exporter
prometheus : {
deidentify : false, // deidentify IP addresses
// listen : 'localhost', // exporter listens on this address
numeric : true, // show numeric IP addresses
port : 8889, // allocated port
quiet : false, // include fewer labels
// aggregated metrics options
period : 15, // update period (seconds)
secret : null // if set, checks the authorization header: `Bearer <secret>`
}
};

View file

@ -0,0 +1,12 @@
redisOptions:
host: redis
port: 6379
listeningPort: 3443
listeningRedirectPort: 0
httpOnly: false
trustProxy: ''
prometheus:
enabled: true
listen: 0.0.0.0

View file

@ -18,6 +18,7 @@ services:
volumes:
- ${PWD}/..:/edumeet
- ${PWD}/config/edumeet-server-config.js:/edumeet/server/config/config.js:ro
- ${PWD}/config/edumeet-server-config.yaml:/edumeet/server/config/config.yaml:ro
- ${PWD}/config/edumeet-app-config.js:/edumeet/app/public/config/config.js:ro
network_mode: "host"
extra_hosts:

View file

@ -6,5 +6,7 @@ WORKDIR /edumeet
ENV DEBUG=edumeet*,mediasoup*
RUN npm install -g nodemon && \
npm install -g concurrently
RUN touch /.yarnrc && mkdir /.yarn && chmod 775 /.yarn /.yarnrc
CMD concurrently --names "server,app" "cd server && yarn && yarn dev" "cd app && yarn && yarn build && yarn start"
RUN touch /.yarnrc && mkdir -p /.yarn /.cache/yarn && chmod 775 /.yarn /.yarnrc /.cache/yarn
CMD concurrently --names "server,app" \
"cd server && yarn && yarn dev" \
"cd app && yarn && yarn build && yarn start"

View file

@ -3,13 +3,18 @@ Description=edumeet is a audio / video meeting service running in the browser an
After=network.target
[Service]
ExecStart=/usr/local/src/edumeet/server/server.js
# modify the paths accordingly with your installation
ExecStart=/usr/local/src/edumeet/server/dist/server.js
WorkingDirectory=/usr/local/src/edumeet/server
Restart=always
RestartSec=1
User=nobody
Group=nogroup
Environment=PATH=/usr/bin:/usr/local/bin
Environment=NODE_ENV=production
WorkingDirectory=/usr/local/src/edumeet/server
Environment=DEBUG="*ERROR*,*WARN*,*INFO*"
StandardOutput=syslog
StandardError=syslog
AmbientCapabilities=CAP_NET_BIND_SERVICE
[Install]

View file

@ -7,8 +7,12 @@
"overrides": [
{
"files":["**/*.ts"],
"plugins": ["prettier", "@typescript-eslint"],
"extends": ["airbnb-typescript/base", "prettier"],
"plugins": ["@typescript-eslint"],
"extends":[
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json"
@ -21,69 +25,29 @@
// }
// },
"rules": {
"no-underscore-dangle":"off",
"object-curly-spacing": [
"warn",
"always"
],
"no-unused-vars": [
"warn",
"no-unused-vars" : 0,
"@typescript-eslint/ban-types" : 0,
"@typescript-eslint/ban-ts-comment" : 0,
"@typescript-eslint/ban-ts-ignore" : 0,
"@typescript-eslint/explicit-module-boundary-types" : 0,
"@typescript-eslint/member-delimiter-style" : [ 2,
{
"vars": "all",
"args": "none"
"multiline" : { "delimiter": "semi", "requireLast": true },
"singleline" : { "delimiter": "semi", "requireLast": false }
}
],
"@typescript-eslint/semi": [
"off"
],
"@typescript-eslint/no-unused-vars": [
"warn",
"@typescript-eslint/no-explicit-any" : 0,
"@typescript-eslint/no-unused-vars" : [ 2,
{
"vars": "all",
"args": "none"
"vars" : "all",
"args" : "after-used",
"ignoreRestSiblings" : false
}
],
"max-len": [
"warn",
{
"code": 100,
"ignoreStrings": true,
"ignoreTemplateLiterals": true,
"ignoreComments": true
}
],
"prefer-destructuring": [
"error",
{
"VariableDeclarator": {
"array": false,
"object": true
},
"AssignmentExpression": {
"array": false,
"object": false
}
},
{
"enforceForRenamedProperties": false
}
],
"no-plusplus": [
"error",
{
"allowForLoopAfterthoughts": true
}
],
"import/no-extraneous-dependencies": [
"error",
{
"devDependencies": [
"**/*.test.js",
"**/*.test.ts",
"src/tests/**/*"
]
}
]
"@typescript-eslint/no-use-before-define" : [ 2, { "functions": false } ],
"@typescript-eslint/no-empty-function" : 0,
"@typescript-eslint/no-non-null-assertion" : 0
}
}
],

81
server/README.md Normal file
View file

@ -0,0 +1,81 @@
# Edumeet Server Configuration
The server configuration file can use one of the following formats:
- config/config.json
- config/config.json5
- config/config.yaml
- config/config.yml
- config/config.toml
Example `config.yaml`:
```yaml
redisOptions:
host: redis
port: 6379
listeningPort: 3443
```
Additionally, a `config/config.js` can be used to override specific properties
with runtime generated values and to set additional configuration functions and classes.
Look at the default `config/config.example.js` file for documentation.
## Configuration properties
| Name | Description | Format | Default value |
| :--- | :---------- | :----- | :------------ |
| turnAPIKey | TURN server key for requesting a geoip-based TURN server closest to the client. | `"string"` | ``""`` |
| turnAPIURI | TURN server URL for requesting a geoip-based TURN server closest to the client. | `"string"` | ``""`` |
| turnAPIparams.uri_schema | TURN server URL schema. | `"string"` | ``"turn"`` |
| turnAPIparams.transport | TURN server transport. | `[ "tcp", "udp"]` | ``"tcp"`` |
| turnAPIparams.ip_ver | TURN server IP version. | `[ "ipv4", "ipv6"]` | ``"ipv4"`` |
| turnAPIparams.servercount | TURN server count. | `"nat"` | ``2`` |
| turnAPITimeout | TURN server API timeout (seconds). | `"nat"` | ``2000`` |
| backupTurnServers | Backup TURN servers if REST fails or is not configured | `"*"` | ``[ { "urls": [ "turn:turn.example.com:443?transport=tcp" ], "username": "example", "credential": "example" }]`` |
| fileTracker | Bittorrent tracker. | `"string"` | ``"wss://tracker.lab.vvc.niif.hu:443"`` |
| redisOptions.host | Redis server host. | `"string"` | ``"localhost"`` |
| redisOptions.port | Redis server port. | `"port"` | ``6379`` |
| redisOptions.password | Redis server password. | `"string"` | ``""`` |
| cookieSecret | Session cookie secret. | `"string"` | ``"T0P-S3cR3t_cook!e"`` |
| cookieName | Session cookie name. | `"string"` | ``"edumeet.sid"`` |
| tls.cert | SSL certificate path. | `"string"` | ``"./certs/mediasoup-demo.localhost.cert.pem"`` |
| tls.key | SSL key path. | `"string"` | ``"./certs/mediasoup-demo.localhost.key.pem"`` |
| listeningHost | The listening Host or IP address. | `"string"` | ``"0.0.0.0"`` |
| listeningPort | The HTTPS listening port. | `"port"` | ``8443`` |
| listeningRedirectPort | The HTTP server listening port used for redirecting any HTTP request to HTTPS. If 0, the redirect server is disabled. | `"port"` | ``8080`` |
| httpOnly | Listens only on HTTP on listeningPort; listeningRedirectPort disabled. Use case: load balancer backend. | `"boolean"` | ``false`` |
| trustProxy | WebServer/Express trust proxy config for httpOnly mode. More infos: [expressjs](https://expressjs.com/en/guide/behind-proxies.html), [proxy-addr](https://www.npmjs.com/package/proxy-addr) | `"string"` | ``""`` |
| staticFilesCachePeriod | The max-age in milliseconds for HTTP caching of static resources. This can also be a string accepted by the [ms module](https://www.npmjs.com/package/ms#readme). | `"*"` | ``0`` |
| activateOnHostJoin | When true, the room will be open to all users since there are users in the room. | `"boolean"` | ``true`` |
| roomsUnlocked | An array of rooms users can enter without waiting in the lobby. | `"array"` | ``[]`` |
| maxUsersPerRoom | It defines how many users can join a single room. If not set, no limit is applied. | `"nat"` | ``0`` |
| routerScaleSize | Room size before spreading to a new router. | `"nat"` | ``40`` |
| requestTimeout | Socket timeout value (ms). | `"nat"` | ``20000`` |
| requestRetries | Socket retries when a timeout occurs. | `"nat"` | ``3`` |
| mediasoup.numWorkers | The number of Mediasoup workers to spawn. Defaults to the available CPUs count. | `"nat"` | ``8`` |
| mediasoup.worker.logLevel | The Mediasoup log level. | `"string"` | ``"warn"`` |
| mediasoup.worker.logTags | The Mediasoup log tags. | `"array"` | ``[ "info", "ice", "dtls", "rtp", "srtp", "rtcp"]`` |
| mediasoup.worker.rtcMinPort | The Mediasoup start listening port number. | `"port"` | ``40000`` |
| mediasoup.worker.rtcMaxPort | The Mediasoup end listening port number. | `"port"` | ``49999`` |
| mediasoup.router.mediaCodecs | The Mediasoup codecs settings. [supportedRtpCapabilities](https://github.com/versatica/mediasoup/blob/v3/src/supportedRtpCapabilities.ts) | `"*"` | ``[ { "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 } }]`` |
| mediasoup.webRtcTransport.listenIps | The Mediasoup listen IPs. [TransportListenIp](https://mediasoup.org/documentation/v3/mediasoup/api/#TransportListenIp) | `"array"` | ``[ { "ip": "0.0.0.0", "announcedIp": null }]`` |
| mediasoup.webRtcTransport.initialAvailableOutgoingBitrate | The Mediasoup initial available outgoing bitrate (in bps). [WebRtcTransportOptions](https://mediasoup.org/documentation/v3/mediasoup/api/#WebRtcTransportOptions) | `"nat"` | ``1000000`` |
| mediasoup.webRtcTransport.maxIncomingBitrate | The Mediasoup maximum incoming bitrate for each transport. (in bps). [setMaxIncomingBitrate](https://mediasoup.org/documentation/v3/mediasoup/api/#transport-setMaxIncomingBitrate) | `"nat"` | ``1500000`` |
| prometheus.enabled | Enables the Prometheus metrics exporter. | `"boolean"` | ``false`` |
| prometheus.listen | Prometheus metrics exporter listening address. | `"string"` | ``"localhost"`` |
| prometheus.port | The Prometheus metrics exporter listening port. | `"port"` | ``8889`` |
| prometheus.deidentify | De-identify IP addresses in Prometheus logs. | `"boolean"` | ``false`` |
| prometheus.numeric | Show numeric IP addresses in Prometheus logs. | `"boolean"` | ``false`` |
| prometheus.quiet | Include fewer labels in Prometheus metrics. | `"boolean"` | ``false`` |
| prometheus.period | The Prometheus metrics exporter update period (seconds). | `"nat"` | ``15`` |
| prometheus.secret | The Prometheus metrics exporter authorization header: `Bearer <secret>` required to allow scraping. | `"string"` | ``""`` |
| accessFromRoles | User roles. | `"*"` | ``{ "BYPASS_ROOM_LOCK": [ { "id": 2529, "label": "admin", "level": 50, "promotable": true } ], "BYPASS_LOBBY": [ { "id": 4261, "label": "normal", "level": 10, "promotable": false } ]}`` |
| permissionsFromRoles | User permissions from roles. | `"*"` | ``{ "CHANGE_ROOM_LOCK": [ { "id": 5337, "label": "moderator", "level": 40, "promotable": true } ], "PROMOTE_PEER": [ { "id": 4261, "label": "normal", "level": 10, "promotable": false } ], "MODIFY_ROLE": [ { "id": 4261, "label": "normal", "level": 10, "promotable": false } ], "SEND_CHAT": [ { "id": 4261, "label": "normal", "level": 10, "promotable": false } ], "MODERATE_CHAT": [ { "id": 5337, "label": "moderator", "level": 40, "promotable": true } ], "SHARE_AUDIO": [ { "id": 4261, "label": "normal", "level": 10, "promotable": false } ], "SHARE_VIDEO": [ { "id": 4261, "label": "normal", "level": 10, "promotable": false } ], "SHARE_SCREEN": [ { "id": 4261, "label": "normal", "level": 10, "promotable": false } ], "EXTRA_VIDEO": [ { "id": 4261, "label": "normal", "level": 10, "promotable": false } ], "SHARE_FILE": [ { "id": 4261, "label": "normal", "level": 10, "promotable": false } ], "MODERATE_FILES": [ { "id": 5337, "label": "moderator", "level": 40, "promotable": true } ], "MODERATE_ROOM": [ { "id": 5337, "label": "moderator", "level": 40, "promotable": true } ]}`` |
| allowWhenRoleMissing | Allow when role missing. | `"array"` | ``[ "CHANGE_ROOM_LOCK"]`` |
---
*Document generated with:* `yarn gen-config-docs`

View file

@ -1,4 +1,4 @@
const os = require('os');
// const os = require('os');
// const fs = require('fs');
const userRoles = require('../userRoles');
@ -28,7 +28,6 @@ const {
module.exports =
{
// Auth conf
/*
auth :
@ -104,58 +103,6 @@ module.exports =
}
},
*/
// URI and key for requesting geoip-based TURN server closest to the client
turnAPIKey : 'examplekey',
turnAPIURI : 'https://example.com/api/turn',
turnAPIparams : {
'uri_schema' : 'turn',
'transport' : 'tcp',
'ip_ver' : 'ipv4',
'servercount' : '2'
},
turnAPITimeout : 2 * 1000,
// Backup turnservers if REST fails or is not configured
backupTurnServers : [
{
urls : [
'turn:turn.example.com:443?transport=tcp'
],
username : 'example',
credential : 'example'
}
],
// bittorrent tracker
fileTracker : 'wss://tracker.lab.vvc.niif.hu:443',
// redis server options
redisOptions : {},
// session cookie secret
cookieSecret : 'T0P-S3cR3t_cook!e',
cookieName : 'edumeet.sid',
// if you use encrypted private key the set the passphrase
tls :
{
cert : `${__dirname}/../certs/mediasoup-demo.localhost.cert.pem`,
// passphrase: 'key_password'
key : `${__dirname}/../certs/mediasoup-demo.localhost.key.pem`
},
// listening Host or IP
// If omitted listens on every IP. ("0.0.0.0" and "::")
// listeningHost: 'localhost',
// Listening port for https server.
listeningPort : 443,
// Any http request is redirected to https.
// Listening port for http server.
listeningRedirectPort : 80,
// Listens only on http, only on listeningPort
// listeningRedirectPort disabled
// use case: loadbalancer backend
httpOnly : false,
// WebServer/Express trust proxy config for httpOnly mode
// You can find more info:
// - https://expressjs.com/en/guide/behind-proxies.html
// - https://www.npmjs.com/package/proxy-addr
// use case: loadbalancer backend
trustProxy : '',
// This logger class will have the log function
// called every time there is a room created or destroyed,
// or peer created or destroyed. This would then be able
@ -345,135 +292,5 @@ module.exports =
// that are allowed because of this rule will not be able to do this
// action as soon as a peer with the permission joins. In this example
// everyone will be able to lock/unlock room until a MODERATOR joins.
allowWhenRoleMissing : [ CHANGE_ROOM_LOCK ],
// When true, the room will be open to all users as long as there
// are allready users in the room
activateOnHostJoin : true,
// roomsUnlocked is an array of rooms users can enter without waiting
// in the lobby. If the array is undefined or null, users can enter
// any room without waiting in the lobby. This is the default. The
// aim of roomsUnlocked is to enforce moderated access to all rooms
// with the exception of the rooms defined in the array.
// roomsUnlocked : [ 'unlocked1', 'unlocked2', 'unlocked3' ],
// When set, maxUsersPerRoom defines how many users can join
// a single room. If not set, there is no limit.
// maxUsersPerRoom : 20,
// Room size before spreading to new router
routerScaleSize : 40,
// Socket timeout value
requestTimeout : 20000,
// Socket retries when timeout
requestRetries : 3,
// If > 0, sets a cache-control max-age (in seconds) to static files responses.
staticFilesCachePeriod : 0,
// Mediasoup settings
mediasoup :
{
numWorkers : Object.keys(os.cpus()).length,
// mediasoup Worker settings.
worker :
{
logLevel : 'warn',
logTags :
[
'info',
'ice',
'dtls',
'rtp',
'srtp',
'rtcp'
],
rtcMinPort : 40000,
rtcMaxPort : 49999
},
// mediasoup Router settings.
router :
{
// Router media codecs.
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
}
}
]
},
// mediasoup WebRtcTransport settings.
webRtcTransport :
{
listenIps :
[
// change 192.0.2.1 IPv4 to your server's IPv4 address!!
{ ip: '192.0.2.1', announcedIp: null }
// Can have multiple listening interfaces
// change 2001:DB8::1 IPv6 to your server's IPv6 address!!
// { ip: '2001:DB8::1', announcedIp: null }
],
initialAvailableOutgoingBitrate : 1000000,
minimumAvailableOutgoingBitrate : 600000,
// Additional options that are not part of WebRtcTransportOptions.
maxIncomingBitrate : 1500000
}
}
/*
,
// Prometheus exporter
prometheus : {
deidentify : false, // deidentify IP addresses
// listen : 'localhost', // exporter listens on this address
numeric : true, // show numeric IP addresses
port : 8889, // allocated port
quiet : false // include fewer labels
// aggregated metrics options
period : 15 // update period (seconds)
secret : null // if set, checks the authorization header: `Bearer <secret>`
}
*/
allowWhenRoleMissing : [ CHANGE_ROOM_LOCK ]
};

59
server/gen-config-docs.ts Normal file
View file

@ -0,0 +1,59 @@
import { configDocs } from './lib/config';
import { writeFile } from 'fs/promises';
function formatJson(data)
{
return `\`${data.replace(/\n/g, '')}\``;
}
let data = `# Edumeet Server Configuration
The server configuration file can use one of the following formats:
- config/config.json
- config/config.json5
- config/config.yaml
- config/config.yml
- config/config.toml
Example \`config.yaml\`:
\`\`\`yaml
redisOptions:
host: redis
port: 6379
listeningPort: 3443
\`\`\`
Additionally, a \`config/config.js\` can be used to override specific properties
with runtime generated values and to set additional configuration functions and classes.
Look at the default \`config/config.example.js\` file for documentation.
## Configuration properties
| Name | Description | Format | Default value |
| :--- | :---------- | :----- | :------------ |
`;
Object.entries(configDocs).forEach((entry: [string, any]) =>
{
const [ name, value ] = entry;
data += `| ${name} | ${value.doc} | ${formatJson(value.format)} | \`${formatJson(value.default)}\` |\n`;
});
data += `
---
*Document generated with:* \`yarn gen-config-docs\`
`;
writeFile('README.md', data).then(() =>
{
console.log('done'); // eslint-disable-line
}, (err) =>
{
console.error(`Error writing file: ${err.message}`); // eslint-disable-line
});

View file

@ -29,7 +29,7 @@ const permissions = require('../permissions'), {
MODERATE_ROOM
} = permissions;
const config = require('../config/config');
const { config } = require('./config');
const logger = new Logger('Room');
@ -263,7 +263,7 @@ class Room extends EventEmitter
this._queue = new AwaitQueue();
// Locked flag.
this._locked = config.roomsUnlocked && Array.isArray(config.roomsUnlocked)
this._locked = config.roomsUnlocked.length
&& !config.roomsUnlocked.includes(roomId);
// if true: accessCode is a possibility to open the room
@ -395,7 +395,7 @@ class Room extends EventEmitter
else if (this._hasAccess(peer, BYPASS_ROOM_LOCK))
this._peerJoining(peer);
else if (
'maxUsersPerRoom' in config &&
config.maxUsersPerRoom &&
(
Object.keys(this._peers).length +
this._lobby.peerList().length
@ -674,7 +674,7 @@ class Room extends EventEmitter
let turnServers;
if ('turnAPIURI' in config)
if (config.turnAPIURI)
{
try
{
@ -697,13 +697,13 @@ class Room extends EventEmitter
}
catch (error)
{
if ('backupTurnServers' in config)
if (config.backupTurnServers)
turnServers = config.backupTurnServers;
logger.error('_peerJoining() | error on REST turn [error:"%o"]', error);
}
}
else if ('backupTurnServers' in config)
else if (config.backupTurnServers)
{
turnServers = config.backupTurnServers;
}

546
server/lib/config.ts Normal file
View file

@ -0,0 +1,546 @@
import * as fs from 'fs';
import convict from 'convict';
import { ipaddress, url } from 'convict-format-with-validator';
import json5 from 'json5';
import yaml from 'yaml';
import toml from 'toml';
import { cpus } from 'os';
import Logger from './Logger';
import * as userRoles from '../userRoles';
import {
BYPASS_ROOM_LOCK,
BYPASS_LOBBY
} from '../access';
import {
CHANGE_ROOM_LOCK,
PROMOTE_PEER,
MODIFY_ROLE,
SEND_CHAT,
MODERATE_CHAT,
SHARE_AUDIO,
SHARE_VIDEO,
SHARE_SCREEN,
EXTRA_VIDEO,
SHARE_FILE,
MODERATE_FILES,
MODERATE_ROOM
} from '../permissions';
const logger = new Logger('config');
// add parsers
convict.addParser([
{ extension: 'json', parse: JSON.parse },
{ extension: 'json5', parse: json5.parse },
{ extension: [ 'yml', 'yaml' ], parse: yaml.parse },
{ extension: 'toml', parse: toml.parse }
]);
// add formats
function assert(assertion: Boolean, msg: string)
{
if (!assertion)
throw new Error(msg);
}
const isFloat = {
name : 'float',
coerce : (v: string) => parseFloat(v),
validate : (v: number) => assert(Number.isFinite(v), 'must be a number')
};
convict.addFormats({ ipaddress, url, isFloat });
// config schema
const configSchema = convict({
turnAPIKey :
{
doc : 'TURN server key for requesting a geoip-based TURN server closest to the client.',
format : String,
default : ''
},
turnAPIURI :
{
doc : 'TURN server URL for requesting a geoip-based TURN server closest to the client.',
format : String,
default : ''
},
turnAPIparams :
{
'uri_schema' : {
doc : 'TURN server URL schema.',
format : String,
default : 'turn'
},
'transport' : {
doc : 'TURN server transport.',
format : [ 'tcp', 'udp' ],
default : 'tcp'
},
'ip_ver' : {
doc : 'TURN server IP version.',
format : [ 'ipv4', 'ipv6' ],
default : 'ipv4'
},
'servercount' : {
doc : 'TURN server count.',
format : 'nat',
default : 2
}
},
turnAPITimeout : {
doc : 'TURN server API timeout (seconds).',
format : 'nat',
default : 2 * 1000
},
backupTurnServers : {
doc : 'Backup TURN servers if REST fails or is not configured',
format : '*',
default : [
{
urls : [
'turn:turn.example.com:443?transport=tcp'
],
username : 'example',
credential : 'example'
}
]
},
fileTracker : {
doc : 'Bittorrent tracker.',
format : String,
default : 'wss://tracker.lab.vvc.niif.hu:443'
},
redisOptions : {
host : {
doc : 'Redis server host.',
format : String,
default : 'localhost'
},
port : {
doc : 'Redis server port.',
format : 'port',
default : 6379
},
password : {
doc : 'Redis server password.',
format : String,
default : ''
}
},
cookieSecret : {
doc : 'Session cookie secret.',
format : String,
default : 'T0P-S3cR3t_cook!e'
},
cookieName : {
doc : 'Session cookie name.',
format : String,
default : 'edumeet.sid'
},
tls : {
cert : {
doc : 'SSL certificate path.',
format : String,
default : './certs/mediasoup-demo.localhost.cert.pem'
},
key : {
doc : 'SSL key path.',
format : String,
default : './certs/mediasoup-demo.localhost.key.pem'
}
},
listeningHost : {
doc : 'The listening Host or IP address.',
format : String,
default : '0.0.0.0'
},
listeningPort : {
doc : 'The HTTPS listening port.',
format : 'port',
default : 8443
},
listeningRedirectPort : {
doc : 'The HTTP server listening port used for redirecting any HTTP request to HTTPS. If 0, the redirect server is disabled.',
format : 'port',
default : 8080
},
httpOnly : {
doc : 'Listens only on HTTP on listeningPort; listeningRedirectPort disabled. Use case: load balancer backend.',
format : 'Boolean',
default : false
},
trustProxy : {
doc : 'WebServer/Express trust proxy config for httpOnly mode. More infos: [expressjs](https://expressjs.com/en/guide/behind-proxies.html), [proxy-addr](https://www.npmjs.com/package/proxy-addr)',
format : String,
default : ''
},
staticFilesCachePeriod : {
doc : 'The max-age in milliseconds for HTTP caching of static resources. This can also be a string accepted by the [ms module](https://www.npmjs.com/package/ms#readme).',
format : '*',
default : 0
},
activateOnHostJoin : {
doc : 'When true, the room will be open to all users since there are users in the room.',
format : 'Boolean',
default : true
},
roomsUnlocked : {
doc : 'An array of rooms users can enter without waiting in the lobby.',
format : Array,
default : []
},
maxUsersPerRoom : {
doc : 'It defines how many users can join a single room. If not set, no limit is applied.',
format : 'nat',
default : 0
},
routerScaleSize : {
doc : 'Room size before spreading to a new router.',
format : 'nat',
default : 40
},
requestTimeout : {
doc : 'Socket timeout value (ms).',
format : 'nat',
default : 20000
},
requestRetries : {
doc : 'Socket retries when a timeout occurs.',
format : 'nat',
default : 3
},
// Mediasoup settings
mediasoup :
{
numWorkers : {
doc : 'The number of Mediasoup workers to spawn. Defaults to the available CPUs count.',
format : 'nat',
default : Object.keys(cpus()).length
},
worker :
{
logLevel : {
doc : 'The Mediasoup log level.',
format : String,
default : 'warn'
},
logTags : {
doc : 'The Mediasoup log tags.',
format : Array,
default : [
'info',
'ice',
'dtls',
'rtp',
'srtp',
'rtcp'
]
},
rtcMinPort : {
doc : 'The Mediasoup start listening port number.',
format : 'port',
default : 40000
},
rtcMaxPort : {
doc : 'The Mediasoup end listening port number.',
format : 'port',
default : 49999
}
},
// mediasoup Router settings.
router :
{
// Router media codecs.
mediaCodecs : {
doc : 'The Mediasoup codecs settings. [supportedRtpCapabilities](https://github.com/versatica/mediasoup/blob/v3/src/supportedRtpCapabilities.ts)',
format : '*',
default :
[
{
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
}
}
]
}
},
// mediasoup WebRtcTransport settings.
webRtcTransport :
{
listenIps : {
doc : 'The Mediasoup listen IPs. [TransportListenIp](https://mediasoup.org/documentation/v3/mediasoup/api/#TransportListenIp)',
format : Array,
default : [
{ ip: '0.0.0.0', announcedIp: null }
]
},
initialAvailableOutgoingBitrate : {
doc : 'The Mediasoup initial available outgoing bitrate (in bps). [WebRtcTransportOptions](https://mediasoup.org/documentation/v3/mediasoup/api/#WebRtcTransportOptions)',
format : 'nat',
default : 1000000
},
maxIncomingBitrate : {
doc : 'The Mediasoup maximum incoming bitrate for each transport. (in bps). [setMaxIncomingBitrate](https://mediasoup.org/documentation/v3/mediasoup/api/#transport-setMaxIncomingBitrate)',
format : 'nat',
default : 1500000
}
}
},
// Prometheus exporter
prometheus : {
enabled : {
doc : 'Enables the Prometheus metrics exporter.',
format : 'Boolean',
default : false
},
listen : {
doc : 'Prometheus metrics exporter listening address.',
format : 'String',
default : 'localhost'
},
port : {
doc : 'The Prometheus metrics exporter listening port.',
format : 'port',
default : 8889
},
// default metrics options
deidentify : {
doc : 'De-identify IP addresses in Prometheus logs.',
format : 'Boolean',
default : false
},
numeric : {
doc : 'Show numeric IP addresses in Prometheus logs.',
format : 'Boolean',
default : false
},
quiet : {
doc : 'Include fewer labels in Prometheus metrics.',
format : 'Boolean',
default : false
},
// aggregated metrics options
period : {
doc : 'The Prometheus metrics exporter update period (seconds).',
format : 'nat',
default : 15
},
secret : {
doc : 'The Prometheus metrics exporter authorization header: `Bearer <secret>` required to allow scraping.',
format : String,
default : ''
}
},
// User roles
// All users have the role "NORMAL" by default. Other roles need to be
// added in the "userMapping" function. The following accesses and
// permissions are arrays of roles. Roles can be changed in userRoles.js
//
// Example:
// [ userRoles.MODERATOR, userRoles.AUTHENTICATED ]
accessFromRoles : {
doc : 'User roles.',
format : '*',
default : {
// The role(s) will gain access to the room
// even if it is locked (!)
[BYPASS_ROOM_LOCK] : [ userRoles.ADMIN ],
// The role(s) will gain access to the room without
// going into the lobby. If you want to restrict access to your
// server to only directly allow authenticated users, you could
// add the userRoles.AUTHENTICATED to the user in the userMapping
// function, and change to BYPASS_LOBBY : [ userRoles.AUTHENTICATED ]
[BYPASS_LOBBY] : [ userRoles.NORMAL ]
}
},
permissionsFromRoles : {
doc : 'User permissions from roles.',
format : '*',
default : {
// The role(s) have permission to lock/unlock a room
[CHANGE_ROOM_LOCK] : [ userRoles.MODERATOR ],
// The role(s) have permission to promote a peer from the lobby
[PROMOTE_PEER] : [ userRoles.NORMAL ],
// The role(s) have permission to give/remove other peers roles
[MODIFY_ROLE] : [ userRoles.NORMAL ],
// The role(s) have permission to send chat messages
[SEND_CHAT] : [ userRoles.NORMAL ],
// The role(s) have permission to moderate chat
[MODERATE_CHAT] : [ userRoles.MODERATOR ],
// The role(s) have permission to share audio
[SHARE_AUDIO] : [ userRoles.NORMAL ],
// The role(s) have permission to share video
[SHARE_VIDEO] : [ userRoles.NORMAL ],
// The role(s) have permission to share screen
[SHARE_SCREEN] : [ userRoles.NORMAL ],
// The role(s) have permission to produce extra video
[EXTRA_VIDEO] : [ userRoles.NORMAL ],
// The role(s) have permission to share files
[SHARE_FILE] : [ userRoles.NORMAL ],
// The role(s) have permission to moderate files
[MODERATE_FILES] : [ userRoles.MODERATOR ],
// The role(s) have permission to moderate room (e.g. kick user)
[MODERATE_ROOM] : [ userRoles.MODERATOR ]
}
},
// Array of permissions. If no peer with the permission in question
// is in the room, all peers are permitted to do the action. The peers
// that are allowed because of this rule will not be able to do this
// action as soon as a peer with the permission joins. In this example
// everyone will be able to lock/unlock room until a MODERATOR joins.
allowWhenRoleMissing : {
doc : 'Allow when role missing.',
format : Array,
default : [ CHANGE_ROOM_LOCK ]
}
});
/**
* Formats the schema documentation, calling the same function recursively.
* @param docs the documentation object to extend
* @param property the root property
* @param schema the config schema fragment
* @returns the documentation object
*/
function formatDocs(docs: any, property: string | null, schema: any)
{
if (schema._cvtProperties)
{
Object.entries(schema._cvtProperties).forEach(([ name, value ]) =>
{
formatDocs(docs, `${property ? `${property}.` : ''}${name}`, value);
});
return docs;
}
if (property)
{
docs[property] = // eslint-disable-line no-param-reassign
{
doc : schema.doc,
format : JSON.stringify(schema.format, null, 2),
default : JSON.stringify(schema.default, null, 2)
};
}
return docs;
}
// format docs
const configDocs = formatDocs({}, null, configSchema.getSchema());
let config: any = {};
let configError = '';
let configLoaded = false;
// Load config from file
for (const format of [ 'json', 'json5', 'yaml', 'yml', 'toml' ]) // eslint-disable-line no-restricted-syntax
{
const filepath = `./config/config.${format}`;
if (fs.existsSync(filepath))
{
try
{
logger.debug(`Loading config from ${filepath}`);
configSchema.loadFile(filepath);
configLoaded = true;
break;
}
catch (err)
{
logger.debug(`Loading config from ${filepath} failed: ${err.message}`);
}
}
}
if (!configLoaded)
{
logger.warn('No config file found, using defaults.');
configSchema.load({});
}
// Perform validation
try
{
configSchema.validate({ allowed: 'strict' });
config = configSchema.getProperties();
}
catch (error: any)
{
configError = error.message;
}
// load additional config module (no validation is performed)
if (fs.existsSync(`${__dirname}/../config/config.js`))
{
try
{
const configModule = require('../config/config.js'); // eslint-disable-line @typescript-eslint/no-var-requires
Object.assign(config, configModule);
}
catch (err)
{
logger.error(`Error loading ${config.configFile} module: ${err.message}`);
}
}
// eslint-disable-next-line
// logger.debug('Using config:', config);
//
export {
configSchema,
config,
configError,
configDocs
};

View file

@ -9,83 +9,88 @@ const logger = new Logger('metrics:aggregated');
//
module.exports = function(workers, rooms_, peers_, config)
{
const register = new promClient.Registry();
promClient.collectDefaultMetrics({ prefix: 'mediasoup_', register });
const mediasoupStats = {};
let mediasoupStatsUpdate = 0;
const register = new promClient.Registry();
const formatStats = (s) =>
{
return {
length: s.length || 0,
sum: s.sum || 0,
mean: s.amean() || 0,
stddev: s.stddev() || 0,
p25: s.percentile(25) || 0,
min: s.min || 0,
max: s.max || 0,
};
};
promClient.collectDefaultMetrics({ prefix: 'mediasoup_', register });
const collectStats = async () =>
{
const now = Date.now();
const mediasoupStats = {};
let mediasoupStatsUpdate = 0;
if (now - mediasoupStatsUpdate < config.period * 1000)
{
return;
}
mediasoupStatsUpdate = now;
const formatStats = (s) =>
{
return {
length : s.length || 0,
sum : s.sum || 0,
mean : s.amean() || 0,
stddev : s.stddev() || 0,
p25 : s.percentile(25) || 0,
min : s.min || 0,
max : s.max || 0
};
};
const start = process.hrtime();
const collectStats = async () =>
{
const now = Date.now();
let workers_cpu = new Stats();
let workers_memory = new Stats();
if (now - mediasoupStatsUpdate < config.period * 1000)
{
return;
}
mediasoupStatsUpdate = now;
let rooms = new Stats();
rooms.push(rooms_.size);
const start = process.hrtime();
let peers = new Stats();
peers.push(peers_.size);
const workersCpu = new Stats();
const workersMemory = new Stats();
// in
let video_bitrates_in = new Stats();
let audio_bitrates_in = new Stats();
let video_scores_in = new Stats();
let audio_scores_in = new Stats();
const rooms = new Stats();
let packets_counts_in = new Stats();
let packets_losts_in = new Stats();
let packets_retransmitted_in = new Stats();
rooms.push(rooms_.size);
// out
let video_bitrates_out = new Stats();
let audio_bitrates_out = new Stats();
const peers = new Stats();
let round_trip_times_out = new Stats();
let packets_counts_out = new Stats();
let packets_losts_out = new Stats();
peers.push(peers_.size);
let spatial_layers_out = new Stats();
let temporal_layers_out = new Stats();
// in
const videoBitratesIn = new Stats();
const audioBitratesIn = new Stats();
const videoScoresIn = new Stats();
const audioScoresIn = new Stats();
try {
// iterate workers
for (const worker of workers.values())
{
// worker process stats
const workerStats = await pidusage(worker._pid);
workers_cpu.push(workerStats.cpu / 100);
workers_memory.push(workerStats.memory);
// iterate routers
for (const router of worker._routers.values())
{
// iterate transports
for (const transport of router._transports.values())
{
/* let stats = [];
const packetsCountsIn = new Stats();
const packetsLostsIn = new Stats();
const packetsRetransmittedIn = new Stats();
// out
const videoBitratesOut = new Stats();
const audioBitratesOut = new Stats();
const roundTripTimesOut = new Stats();
const packetsCountsOut = new Stats();
const packetsLostsOut = new Stats();
const spatialLayersOut = new Stats();
const temporalLayersOut = new Stats();
try
{
// iterate workers
for (const worker of workers)
{
// worker process stats
const workerStats = await pidusage(worker.pid);
workersCpu.push(workerStats.cpu / 100);
workersMemory.push(workerStats.memory);
// iterate routers
for (const router of worker._routers.values())
{
// iterate transports
for (const transport of router._transports.values())
{
/* let stats = [];
try
{
stats = await transport.getStats();
@ -102,212 +107,225 @@ module.exports = function(workers, rooms_, peers_, config)
}
} */
// iterate producers
for (const producer of transport._producers.values())
{
let stats = [];
try
{
stats = await producer.getStats();
}
catch(err)
{
logger.error('producer.getStats error:', err.message);
continue;
}
for (const s of stats)
{
if (s.type !== 'inbound-rtp')
{
continue;
}
if (s.kind === 'video')
{
video_bitrates_in.push(s.bitrate);
video_scores_in.push(s.score);
}
else if (s.kind === 'audio')
{
audio_bitrates_in.push(s.bitrate);
audio_scores_in.push(s.score);
}
packets_counts_in.push(s.packetCount || 0);
packets_losts_in.push(s.packetsLost || 0);
packets_retransmitted_in.push(s.packetsRetransmitted || 0);
}
}
// iterate producers
for (const producer of transport._producers.values())
{
let stats = [];
// iterate consumers
for (const consumer of transport._consumers.values())
{
if (consumer.type === 'pipe')
{
continue;
}
let stats = [];
try
{
stats = await consumer.getStats();
}
catch(err)
{
logger.error('consumer.getStats error:', err.message);
continue;
}
for (const s of stats)
{
if(s.type !== 'outbound-rtp'){
continue;
}
if (s.kind === 'video')
{
video_bitrates_out.push(s.bitrate || 0);
spatial_layers_out.push(consumer.currentLayers ? consumer.currentLayers.spatialLayer : 0);
temporal_layers_out.push(consumer.currentLayers ? consumer.currentLayers.temporalLayer : 0);
}
else if(s.kind === 'audio')
{
audio_bitrates_out.push(s.bitrate || 0);
}
round_trip_times_out.push(s.roundTripTime || 0);
packets_counts_out.push(s.packetCount || 0);
packets_losts_out.push(s.packetsLost || 0);
}
}
}
}
}
}
catch(err)
{
logger.error('collectStats error:', err.message);
}
finally
{
Object.assign(mediasoupStats, {
workers_cpu: formatStats(workers_cpu),
workers_memory: formatStats(workers_memory),
rooms: formatStats(rooms),
peers: formatStats(peers),
// in
video_bitrates_in: formatStats(video_bitrates_in),
video_scores_in: formatStats(video_scores_in),
audio_bitrates_in: formatStats(audio_bitrates_in),
audio_scores_in: formatStats(audio_scores_in),
packets_counts_in: formatStats(packets_counts_in),
packets_losts_in: formatStats(packets_losts_in),
packets_retransmitted_in: formatStats(packets_retransmitted_in),
// out
video_bitrates_out: formatStats(video_bitrates_out),
audio_bitrates_out: formatStats(audio_bitrates_out),
round_trip_times_out: formatStats(round_trip_times_out),
packets_counts_out: formatStats(packets_counts_out),
packets_losts_out: formatStats(packets_losts_out),
spatial_layers_out: formatStats(spatial_layers_out),
temporal_layers_out: formatStats(temporal_layers_out),
});
}
try
{
stats = await producer.getStats();
}
catch (err)
{
logger.error('producer.getStats error:', err.message);
continue;
}
for (const s of stats)
{
if (s.type !== 'inbound-rtp')
{
continue;
}
if (s.kind === 'video')
{
videoBitratesIn.push(s.bitrate);
videoScoresIn.push(s.score);
}
else if (s.kind === 'audio')
{
audioBitratesIn.push(s.bitrate);
audioScoresIn.push(s.score);
}
packetsCountsIn.push(s.packetCount || 0);
packetsLostsIn.push(s.packetsLost || 0);
packetsRetransmittedIn.push(s.packetsRetransmitted || 0);
}
}
const end = process.hrtime(start);
logger.info(`collectStats (elapsed: ${end[0] * 1e3 + end[1] * 1e-6} ms)`);
}
// iterate consumers
for (const consumer of transport._consumers.values())
{
if (consumer.type === 'pipe')
{
continue;
}
let stats = [];
// mediasoup metrics
[
{ name: 'workers_count', statName: 'workers_cpu', statValue: 'length' },
{ name: 'workers_cpu', statName: 'workers_cpu', statValue: 'sum' },
{ name: 'workers_memory', statName: 'workers_memory', statValue: 'sum' },
//
{ name: 'rooms', statName: 'rooms', statValue: 'sum' },
{ name: 'peers', statName: 'peers', statValue: 'sum' },
// audio in
{ name: 'audio_in_count', statName: 'audio_bitrates_in', statValue: 'length' },
// audio in bitrates
{ name: 'audio_bitrates_in_sum', statName: 'audio_bitrates_in', statValue: 'sum' },
{ name: 'audio_bitrates_in_mean', statName: 'audio_bitrates_in', statValue: 'mean' },
{ name: 'audio_bitrates_in_min', statName: 'audio_bitrates_in', statValue: 'min' },
{ name: 'audio_bitrates_in_max', statName: 'audio_bitrates_in', statValue: 'max' },
{ name: 'audio_bitrates_in_p25', statName: 'audio_bitrates_in', statValue: 'p25' },
// audio in scores
{ name: 'audio_scores_in_mean', statName: 'audio_scores_in', statValue: 'mean' },
{ name: 'audio_scores_in_min', statName: 'audio_scores_in', statValue: 'min' },
{ name: 'audio_scores_in_max', statName: 'audio_scores_in', statValue: 'max' },
{ name: 'audio_scores_in_p25', statName: 'audio_scores_in', statValue: 'p25' },
// video in
{ name: 'video_in_count', statName: 'video_bitrates_in', statValue: 'length' },
// video in bitrates
{ name: 'video_bitrates_in_sum', statName: 'video_bitrates_in', statValue: 'sum' },
{ name: 'video_bitrates_in_mean', statName: 'video_bitrates_in', statValue: 'mean' },
{ name: 'video_bitrates_in_min', statName: 'video_bitrates_in', statValue: 'min' },
{ name: 'video_bitrates_in_max', statName: 'video_bitrates_in', statValue: 'max' },
{ name: 'video_bitrates_in_p25', statName: 'video_bitrates_in', statValue: 'p25' },
// video in scores
{ name: 'video_scores_in_mean', statName: 'video_scores_in', statValue: 'mean' },
{ name: 'video_scores_in_min', statName: 'video_scores_in', statValue: 'min' },
{ name: 'video_scores_in_max', statName: 'video_scores_in', statValue: 'max' },
{ name: 'video_scores_in_p25', statName: 'video_scores_in', statValue: 'p25' },
// packets in
{ name: 'packets_counts_in_sum', statName: 'packets_counts_in', statValue: 'sum' },
{ name: 'packets_counts_in_mean', statName: 'packets_counts_in', statValue: 'mean' },
{ name: 'packets_counts_in_min', statName: 'packets_counts_in', statValue: 'min' },
{ name: 'packets_counts_in_max', statName: 'packets_counts_in', statValue: 'max' },
{ name: 'packets_counts_in_p25', statName: 'packets_counts_in', statValue: 'p25' },
{ name: 'packets_losts_in_sum', statName: 'packets_losts_in', statValue: 'sum' },
{ name: 'packets_losts_in_mean', statName: 'packets_losts_in', statValue: 'mean' },
{ name: 'packets_losts_in_min', statName: 'packets_losts_in', statValue: 'min' },
{ name: 'packets_losts_in_max', statName: 'packets_losts_in', statValue: 'max' },
{ name: 'packets_losts_in_p25', statName: 'packets_losts_in', statValue: 'p25' },
{ name: 'packets_retransmitted_in_sum', statName: 'packets_retransmitted_in', statValue: 'sum' },
{ name: 'packets_retransmitted_in_mean', statName: 'packets_retransmitted_in', statValue: 'mean' },
{ name: 'packets_retransmitted_in_min', statName: 'packets_retransmitted_in', statValue: 'min' },
{ name: 'packets_retransmitted_in_max', statName: 'packets_retransmitted_in', statValue: 'max' },
{ name: 'packets_retransmitted_in_p25', statName: 'packets_retransmitted_in', statValue: 'p25' },
// audio out
{ name: 'audio_out_count', statName: 'audio_bitrates_out', statValue: 'length' },
{ name: 'audio_bitrates_out_sum', statName: 'audio_bitrates_out', statValue: 'sum' },
{ name: 'audio_bitrates_out_mean', statName: 'audio_bitrates_out', statValue: 'mean' },
{ name: 'audio_bitrates_out_min', statName: 'audio_bitrates_out', statValue: 'min' },
{ name: 'audio_bitrates_out_max', statName: 'audio_bitrates_out', statValue: 'max' },
{ name: 'audio_bitrates_out_p25', statName: 'audio_bitrates_out', statValue: 'p25' },
// video out
{ name: 'video_out_count', statName: 'video_bitrates_out', statValue: 'length' },
{ name: 'video_bitrates_out_sum', statName: 'video_bitrates_out', statValue: 'sum' },
{ name: 'video_bitrates_out_mean', statName: 'video_bitrates_out', statValue: 'mean' },
{ name: 'video_bitrates_out_min', statName: 'video_bitrates_out', statValue: 'min' },
{ name: 'video_bitrates_out_max', statName: 'video_bitrates_out', statValue: 'max' },
{ name: 'video_bitrates_out_p25', statName: 'video_bitrates_out', statValue: 'p25' },
// sl
{ name: 'spatial_layers_out_mean', statName: 'spatial_layers_out', statValue: 'mean' },
{ name: 'spatial_layers_out_min', statName: 'spatial_layers_out', statValue: 'min' },
{ name: 'spatial_layers_out_max', statName: 'spatial_layers_out', statValue: 'max' },
{ name: 'spatial_layers_out_p25', statName: 'spatial_layers_out', statValue: 'p25' },
// tl
{ name: 'temporal_layers_out_mean', statName: 'temporal_layers_out', statValue: 'mean' },
{ name: 'temporal_layers_out_min', statName: 'temporal_layers_out', statValue: 'min' },
{ name: 'temporal_layers_out_max', statName: 'temporal_layers_out', statValue: 'max' },
{ name: 'temporal_layers_out_p25', statName: 'temporal_layers_out', statValue: 'p25' },
// rtt out
{ name: 'round_trip_times_out_mean', statName: 'round_trip_times_out', statValue: 'mean' },
{ name: 'round_trip_times_out_min', statName: 'round_trip_times_out', statValue: 'min' },
{ name: 'round_trip_times_out_max', statName: 'round_trip_times_out', statValue: 'max' },
{ name: 'round_trip_times_out_p25', statName: 'round_trip_times_out', statValue: 'p25' },
try
{
stats = await consumer.getStats();
}
catch (err)
{
logger.error('consumer.getStats error:', err.message);
continue;
}
for (const s of stats)
{
if (s.type !== 'outbound-rtp')
{
continue;
}
if (s.kind === 'video')
{
videoBitratesOut.push(s.bitrate || 0);
spatialLayersOut.push(consumer.currentLayers
? consumer.currentLayers.spatialLayer : 0);
temporalLayersOut.push(consumer.currentLayers
? consumer.currentLayers.temporalLayer : 0);
}
else if (s.kind === 'audio')
{
audioBitratesOut.push(s.bitrate || 0);
}
roundTripTimesOut.push(s.roundTripTime || 0);
packetsCountsOut.push(s.packetCount || 0);
packetsLostsOut.push(s.packetsLost || 0);
}
}
}
}
}
}
catch (err)
{
logger.error('collectStats error:', err.message);
}
finally
{
Object.assign(mediasoupStats, {
workersCpu : formatStats(workersCpu),
workersMemory : formatStats(workersMemory),
rooms : formatStats(rooms),
peers : formatStats(peers),
// in
videoBitratesIn : formatStats(videoBitratesIn),
videoScoresIn : formatStats(videoScoresIn),
audioBitratesIn : formatStats(audioBitratesIn),
audioScoresIn : formatStats(audioScoresIn),
packetsCountsIn : formatStats(packetsCountsIn),
packetsLostsIn : formatStats(packetsLostsIn),
packetsRetransmittedIn : formatStats(packetsRetransmittedIn),
// out
videoBitratesOut : formatStats(videoBitratesOut),
audioBitratesOut : formatStats(audioBitratesOut),
roundTripTimesOut : formatStats(roundTripTimesOut),
packetsCountsOut : formatStats(packetsCountsOut),
packetsLostsOut : formatStats(packetsLostsOut),
spatialLayersOut : formatStats(spatialLayersOut),
temporalLayersOut : formatStats(temporalLayersOut)
});
}
].forEach(({ name, statName, statValue }) => {
new promClient.Gauge({
name: `mediasoup_${name}`,
help: `MediaSoup ${name}`,
labelNames: [],
registers: [ register ],
async collect()
{
await collectStats();
if (mediasoupStats[statName] !== undefined && mediasoupStats[statName][statValue] !== undefined)
{
this.set({}, mediasoupStats[statName][statValue]);
}
}
});
});
const end = process.hrtime(start);
return register;
logger.info(`collectStats (elapsed: ${(end[0] * 1e3) + (end[1] * 1e-6)} ms)`);
};
// mediasoup metrics
[
{ name: 'workers_count', statName: 'workersCpu', statValue: 'length' },
{ name: 'workers_cpu', statName: 'workersCpu', statValue: 'sum' },
{ name: 'workers_memory', statName: 'workersMemory', statValue: 'sum' },
//
{ name: 'rooms', statName: 'rooms', statValue: 'sum' },
{ name: 'peers', statName: 'peers', statValue: 'sum' },
// audio in
{ name: 'audio_in_count', statName: 'audioBitratesIn', statValue: 'length' },
// audio in bitrates
{ name: 'audio_bitrates_in_sum', statName: 'audioBitratesIn', statValue: 'sum' },
{ name: 'audio_bitrates_in_mean', statName: 'audioBitratesIn', statValue: 'mean' },
{ name: 'audio_bitrates_in_min', statName: 'audioBitratesIn', statValue: 'min' },
{ name: 'audio_bitrates_in_max', statName: 'audioBitratesIn', statValue: 'max' },
{ name: 'audio_bitrates_in_p25', statName: 'audioBitratesIn', statValue: 'p25' },
// audio in scores
{ name: 'audio_scores_in_mean', statName: 'audioScoresIn', statValue: 'mean' },
{ name: 'audio_scores_in_min', statName: 'audioScoresIn', statValue: 'min' },
{ name: 'audio_scores_in_max', statName: 'audioScoresIn', statValue: 'max' },
{ name: 'audio_scores_in_p25', statName: 'audioScoresIn', statValue: 'p25' },
// video in
{ name: 'video_in_count', statName: 'videoBitratesIn', statValue: 'length' },
// video in bitrates
{ name: 'video_bitrates_in_sum', statName: 'videoBitratesIn', statValue: 'sum' },
{ name: 'video_bitrates_in_mean', statName: 'videoBitratesIn', statValue: 'mean' },
{ name: 'video_bitrates_in_min', statName: 'videoBitratesIn', statValue: 'min' },
{ name: 'video_bitrates_in_max', statName: 'videoBitratesIn', statValue: 'max' },
{ name: 'video_bitrates_in_p25', statName: 'videoBitratesIn', statValue: 'p25' },
// video in scores
{ name: 'video_scores_in_mean', statName: 'videoScoresIn', statValue: 'mean' },
{ name: 'video_scores_in_min', statName: 'videoScoresIn', statValue: 'min' },
{ name: 'video_scores_in_max', statName: 'videoScoresIn', statValue: 'max' },
{ name: 'video_scores_in_p25', statName: 'videoScoresIn', statValue: 'p25' },
// packets in
{ name: 'packets_counts_in_sum', statName: 'packetsCountsIn', statValue: 'sum' },
{ name: 'packets_counts_in_mean', statName: 'packetsCountsIn', statValue: 'mean' },
{ name: 'packets_counts_in_min', statName: 'packetsCountsIn', statValue: 'min' },
{ name: 'packets_counts_in_max', statName: 'packetsCountsIn', statValue: 'max' },
{ name: 'packets_counts_in_p25', statName: 'packetsCountsIn', statValue: 'p25' },
{ name: 'packets_losts_in_sum', statName: 'packetsLostsIn', statValue: 'sum' },
{ name: 'packets_losts_in_mean', statName: 'packetsLostsIn', statValue: 'mean' },
{ name: 'packets_losts_in_min', statName: 'packetsLostsIn', statValue: 'min' },
{ name: 'packets_losts_in_max', statName: 'packetsLostsIn', statValue: 'max' },
{ name: 'packets_losts_in_p25', statName: 'packetsLostsIn', statValue: 'p25' },
{ name: 'packets_retransmitted_in_sum', statName: 'packetsRetransmittedIn', statValue: 'sum' },
{ name: 'packets_retransmitted_in_mean', statName: 'packetsRetransmittedIn', statValue: 'mean' },
{ name: 'packets_retransmitted_in_min', statName: 'packetsRetransmittedIn', statValue: 'min' },
{ name: 'packets_retransmitted_in_max', statName: 'packetsRetransmittedIn', statValue: 'max' },
{ name: 'packets_retransmitted_in_p25', statName: 'packetsRetransmittedIn', statValue: 'p25' },
// audio out
{ name: 'audio_out_count', statName: 'audioBitratesOut', statValue: 'length' },
{ name: 'audio_bitrates_out_sum', statName: 'audioBitratesOut', statValue: 'sum' },
{ name: 'audio_bitrates_out_mean', statName: 'audioBitratesOut', statValue: 'mean' },
{ name: 'audio_bitrates_out_min', statName: 'audioBitratesOut', statValue: 'min' },
{ name: 'audio_bitrates_out_max', statName: 'audioBitratesOut', statValue: 'max' },
{ name: 'audio_bitrates_out_p25', statName: 'audioBitratesOut', statValue: 'p25' },
// video out
{ name: 'video_out_count', statName: 'videoBitratesOut', statValue: 'length' },
{ name: 'video_bitrates_out_sum', statName: 'videoBitratesOut', statValue: 'sum' },
{ name: 'video_bitrates_out_mean', statName: 'videoBitratesOut', statValue: 'mean' },
{ name: 'video_bitrates_out_min', statName: 'videoBitratesOut', statValue: 'min' },
{ name: 'video_bitrates_out_max', statName: 'videoBitratesOut', statValue: 'max' },
{ name: 'video_bitrates_out_p25', statName: 'videoBitratesOut', statValue: 'p25' },
// sl
{ name: 'spatial_layers_out_mean', statName: 'spatialLayersOut', statValue: 'mean' },
{ name: 'spatial_layers_out_min', statName: 'spatialLayersOut', statValue: 'min' },
{ name: 'spatial_layers_out_max', statName: 'spatialLayersOut', statValue: 'max' },
{ name: 'spatial_layers_out_p25', statName: 'spatialLayersOut', statValue: 'p25' },
// tl
{ name: 'temporal_layers_out_mean', statName: 'temporalLayersOut', statValue: 'mean' },
{ name: 'temporal_layers_out_min', statName: 'temporalLayersOut', statValue: 'min' },
{ name: 'temporal_layers_out_max', statName: 'temporalLayersOut', statValue: 'max' },
{ name: 'temporal_layers_out_p25', statName: 'temporalLayersOut', statValue: 'p25' },
// rtt out
{ name: 'round_trip_times_out_mean', statName: 'roundTripTimesOut', statValue: 'mean' },
{ name: 'round_trip_times_out_min', statName: 'roundTripTimesOut', statValue: 'min' },
{ name: 'round_trip_times_out_max', statName: 'roundTripTimesOut', statValue: 'max' },
{ name: 'round_trip_times_out_p25', statName: 'roundTripTimesOut', statValue: 'p25' }
].forEach(({ name, statName, statValue }) =>
{
// eslint-disable-next-line no-new
new promClient.Gauge({
name : `mediasoup_${name}`,
help : `MediaSoup ${name}`,
labelNames : [],
registers : [ register ],
async collect()
{
await collectStats();
if (mediasoupStats[statName] !== undefined
&& mediasoupStats[statName][statValue] !== undefined)
{
this.set({}, mediasoupStats[statName][statValue]);
}
else
{
logger.warn(`${statName}.${statValue} not found`);
}
}
});
});
return register;
};

View file

@ -19,218 +19,218 @@ const metadata = {
module.exports = async function(workers, rooms, peers, registry, config)
{
const newMetrics = function(subsystem)
{
const namespace = 'mediasoup';
const metrics = new Map();
const newMetrics = function(subsystem)
{
const namespace = 'mediasoup';
const metrics = new Map();
for (const key in metadata)
{
if (Object.prototype.hasOwnProperty.call(metadata, key))
{
const value = metadata[key];
const name = key.split(/(?=[A-Z])/).join('_')
.toLowerCase();
const unit = value.unit;
const metricType = value.metricType;
let s = `${namespace}_${subsystem}_${name}`;
for (const key in metadata)
{
if (Object.prototype.hasOwnProperty.call(metadata, key))
{
const value = metadata[key];
const name = key.split(/(?=[A-Z])/).join('_')
.toLowerCase();
const unit = value.unit;
const metricType = value.metricType;
let s = `${namespace}_${subsystem}_${name}`;
if (unit)
{
s += `_${unit}`;
}
const m = new metricType({
name : s, help : `${subsystem}.${key}`, labelNames : labelNames, registers : [ registry ] });
if (unit)
{
s += `_${unit}`;
}
const m = new metricType({
name : s, help : `${subsystem}.${key}`, labelNames : labelNames, registers : [ registry ] });
metrics.set(key, m);
}
}
metrics.set(key, m);
}
}
return metrics;
};
return metrics;
};
const commonLabels = function(both, fn)
{
for (const roomId of rooms.keys())
{
for (const [ peerId, peer ] of peers)
{
if (fn(peer))
{
const displayName = peer._displayName;
const userAgent = peer._socket.client.request.headers['user-agent'];
const kind = both.kind;
const codec = both.rtpParameters.codecs[0].mimeType.split('/')[1];
const commonLabels = function(both, fn)
{
for (const roomId of rooms.keys())
{
for (const [ peerId, peer ] of peers)
{
if (fn(peer))
{
const displayName = peer._displayName;
const userAgent = peer._socket.client.request.headers['user-agent'];
const kind = both.kind;
const codec = both.rtpParameters.codecs[0].mimeType.split('/')[1];
return { roomId, peerId, displayName, userAgent, kind, codec };
}
}
}
throw new Error('cannot find common labels');
};
return { roomId, peerId, displayName, userAgent, kind, codec };
}
}
}
throw new Error('cannot find common labels');
};
const addr = async function(ip, port)
{
if (config.deidentify)
{
const a = ip.split('.');
const addr = async function(ip, port)
{
if (config.deidentify)
{
const a = ip.split('.');
for (let i = 0; i < a.length - 2; i++)
{
a[i] = 'xx';
}
for (let i = 0; i < a.length - 2; i++)
{
a[i] = 'xx';
}
return `${a.join('.')}:${port}`;
}
else if (config.numeric)
{
return `${ip}:${port}`;
}
else
{
try
{
const a = await resolver.reverse(ip);
return `${a.join('.')}:${port}`;
}
else if (config.numeric)
{
return `${ip}:${port}`;
}
else
{
try
{
const a = await resolver.reverse(ip);
ip = a[0];
}
catch (err)
{
logger.error(`reverse DNS query failed: ${ip} ${err.code}`);
}
ip = a[0];
}
catch (err)
{
logger.error(`reverse DNS query failed: ${ip} ${err.code}`);
}
return `${ip}:${port}`;
}
};
return `${ip}:${port}`;
}
};
const quiet = function(s)
{
return config.quiet ? '' : s;
};
const quiet = function(s)
{
return config.quiet ? '' : s;
};
const setValue = function(key, m, labels, v)
{
logger.debug(`setValue key=${key} v=${v}`);
switch (metadata[key].metricType)
{
case prom.Counter:
m.inc(labels, v);
break;
case prom.Gauge:
m.set(labels, v);
break;
default:
throw new Error(`unexpected metric: ${m}`);
}
};
const setValue = function(key, m, labels, v)
{
logger.debug(`setValue key=${key} v=${v}`);
switch (metadata[key].metricType)
{
case prom.Counter:
m.inc(labels, v);
break;
case prom.Gauge:
m.set(labels, v);
break;
default:
throw new Error(`unexpected metric: ${m}`);
}
};
logger.debug('collect');
const mRooms = new prom.Gauge({ name: 'edumeet_rooms', help: '#rooms', registers: [ registry ] });
logger.debug('collect');
const mRooms = new prom.Gauge({ name: 'edumeet_rooms', help: '#rooms', registers: [ registry ] });
mRooms.set(rooms.size);
const mPeers = new prom.Gauge({ name: 'edumeet_peers', help: '#peers', labelNames: [ 'room_id' ], registers: [ registry ] });
mRooms.set(rooms.size);
const mPeers = new prom.Gauge({ name: 'edumeet_peers', help: '#peers', labelNames: [ 'room_id' ], registers: [ registry ] });
for (const [ roomId, room ] of rooms)
{
mPeers.labels(roomId).set(Object.keys(room._peers).length);
}
for (const [ roomId, room ] of rooms)
{
mPeers.labels(roomId).set(Object.keys(room._peers).length);
}
const mConsumer = newMetrics('consumer');
const mProducer = newMetrics('producer');
const mConsumer = newMetrics('consumer');
const mProducer = newMetrics('producer');
for (const [ pid, worker ] of workers)
{
logger.debug(`visiting worker ${pid}`);
for (const router of worker._routers)
{
logger.debug(`visiting router ${router.id}`);
for (const [ transportId, transport ] of router._transports)
{
logger.debug(`visiting transport ${transportId}`);
const transportJson = await transport.dump();
for (const worker of workers)
{
logger.debug(`visiting worker ${worker.pid}`);
for (const router of worker._routers)
{
logger.debug(`visiting router ${router.id}`);
for (const [ transportId, transport ] of router._transports)
{
logger.debug(`visiting transport ${transportId}`);
const transportJson = await transport.dump();
if (transportJson.iceState != 'completed')
{
logger.debug(`skipping transport ${transportId}}: ${transportJson.iceState}`);
continue;
}
const iceSelectedTuple = transportJson.iceSelectedTuple;
const proto = iceSelectedTuple.protocol;
const localAddr = await addr(iceSelectedTuple.localIp,
iceSelectedTuple.localPort);
const remoteAddr = await addr(iceSelectedTuple.remoteIp,
iceSelectedTuple.remotePort);
if (transportJson.iceState != 'completed')
{
logger.debug(`skipping transport ${transportId}}: ${transportJson.iceState}`);
continue;
}
const iceSelectedTuple = transportJson.iceSelectedTuple;
const proto = iceSelectedTuple.protocol;
const localAddr = await addr(iceSelectedTuple.localIp,
iceSelectedTuple.localPort);
const remoteAddr = await addr(iceSelectedTuple.remoteIp,
iceSelectedTuple.remotePort);
for (const [ producerId, producer ] of transport._producers)
{
logger.debug(`visiting producer ${producerId}`);
const { roomId, peerId, displayName, userAgent, kind, codec } =
for (const [ producerId, producer ] of transport._producers)
{
logger.debug(`visiting producer ${producerId}`);
const { roomId, peerId, displayName, userAgent, kind, codec } =
commonLabels(producer, (peer) => peer._producers.has(producerId));
const a = await producer.getStats();
const a = await producer.getStats();
for (const x of a)
{
const type = x.type;
const labels = {
'pid' : pid,
'room_id' : roomId,
'peer_id' : peerId,
'display_name' : displayName,
'user_agent' : userAgent,
'transport_id' : quiet(transportId),
'proto' : proto,
'local_addr' : localAddr,
'remote_addr' : remoteAddr,
'id' : quiet(producerId),
'kind' : kind,
'codec' : codec,
'type' : type
};
for (const x of a)
{
const type = x.type;
const labels = {
'pid' : worker.pid,
'room_id' : roomId,
'peer_id' : peerId,
'display_name' : displayName,
'user_agent' : userAgent,
'transport_id' : quiet(transportId),
'proto' : proto,
'local_addr' : localAddr,
'remote_addr' : remoteAddr,
'id' : quiet(producerId),
'kind' : kind,
'codec' : codec,
'type' : type
};
for (const [ key, m ] of mProducer)
{
setValue(key, m, labels, x[key]);
}
}
}
for (const [ consumerId, consumer ] of transport._consumers)
{
logger.debug(`visiting consumer ${consumerId}`);
const { roomId, peerId, displayName, userAgent, kind, codec } =
for (const [ key, m ] of mProducer)
{
setValue(key, m, labels, x[key]);
}
}
}
for (const [ consumerId, consumer ] of transport._consumers)
{
logger.debug(`visiting consumer ${consumerId}`);
const { roomId, peerId, displayName, userAgent, kind, codec } =
commonLabels(consumer, (peer) => peer._consumers.has(consumerId));
const a = await consumer.getStats();
const a = await consumer.getStats();
for (const x of a)
{
if (x.type == 'inbound-rtp')
{
continue;
}
const type = x.type;
const labels =
{
'pid' : pid,
'room_id' : roomId,
'peer_id' : peerId,
'display_name' : displayName,
'user_agent' : userAgent,
'transport_id' : quiet(transportId),
'proto' : proto,
'local_addr' : localAddr,
'remote_addr' : remoteAddr,
'id' : quiet(consumerId),
'kind' : kind,
'codec' : codec,
'type' : type
};
for (const x of a)
{
if (x.type == 'inbound-rtp')
{
continue;
}
const type = x.type;
const labels =
{
'pid' : worker.pid,
'room_id' : roomId,
'peer_id' : peerId,
'display_name' : displayName,
'user_agent' : userAgent,
'transport_id' : quiet(transportId),
'proto' : proto,
'local_addr' : localAddr,
'remote_addr' : remoteAddr,
'id' : quiet(consumerId),
'kind' : kind,
'codec' : codec,
'type' : type
};
for (const [ key, m ] of mConsumer)
{
setValue(key, m, labels, x[key]);
}
}
}
}
}
}
for (const [ key, m ] of mConsumer)
{
setValue(key, m, labels, x[key]);
}
}
}
}
}
}
};

View file

@ -1,35 +1,24 @@
import Logger from './Logger';
const express = require('express');
const mediasoup = require('mediasoup');
const promClient = require('prom-client');
const collectDefaultMetrics = require('./metrics/default');
const RegisterAggregated = require('./metrics/aggregated');
const logger = new Logger('promClient');
const workers = new Map();
module.exports = async function(rooms, peers, config)
import { config } from './config';
module.exports = async function(workers, rooms, peers)
{
try
{
logger.debug(`config.deidentify=${config.deidentify}`);
logger.debug(`config.listen=${config.listen}`);
logger.debug(`config.numeric=${config.numeric}`);
logger.debug(`config.port=${config.port}`);
logger.debug(`config.quiet=${config.quiet}`);
mediasoup.observer.on('newworker', (worker) =>
{
logger.debug(`observing newworker ${worker.pid} #${workers.size}`);
workers.set(worker.pid, worker);
worker.observer.on('close', () =>
{
logger.debug(`observing close worker ${worker.pid} #${workers.size - 1}`);
workers.delete(worker.pid);
});
});
logger.debug(`config.prometheus.deidentify=${config.prometheus.deidentify}`);
logger.debug(`config.prometheus.listen=${config.prometheus.listen}`);
logger.debug(`config.prometheus.numeric=${config.prometheus.numeric}`);
logger.debug(`config.prometheus.port=${config.prometheus.port}`);
logger.debug(`config.prometheus.quiet=${config.prometheus.quiet}`);
const app = express();
@ -39,7 +28,8 @@ module.exports = async function(rooms, peers, config)
logger.debug(`GET ${req.originalUrl}`);
const registry = new promClient.Registry();
await collectDefaultMetrics(workers, rooms, peers, registry, config);
await collectDefaultMetrics(
workers, rooms, peers, registry, config.prometheus);
res.set('Content-Type', registry.contentType);
const data = await registry.metrics();
@ -47,31 +37,33 @@ module.exports = async function(rooms, peers, config)
});
// aggregated register
const registerAggregated = RegisterAggregated(workers, rooms, peers, config);
const registerAggregated = RegisterAggregated(
workers, rooms, peers, config.prometheus);
app.get('/metrics', async (req, res) =>
{
logger.debug(`GET ${req.originalUrl}`);
if (config.secret && req.headers.authorization !== 'Bearer ' + config.secret)
if (config.prometheus.secret
&& req.headers.authorization !== `Bearer ${ config.prometheus.secret}`)
{
logger.error(`Invalid authorization header`);
logger.error('Invalid authorization header');
return res.status(401).end();
}
res.set('Content-Type', registerAggregated.contentType);
const data = await registerAggregated.metrics();
res.end(data);
});
const server = app.listen(config.port || 8889,
config.listen || undefined, () =>
{
const address = server.address();
const server = app.listen(config.prometheus.port, config.prometheus.listen, () =>
{
const address = server.address();
logger.info(`listening ${address.address}:${address.port}`);
});
logger.info(`listening ${address.address}:${address.port}`);
});
}
catch (err)
{

View file

@ -14,12 +14,13 @@
"license": "MIT",
"main": "lib/index.js",
"scripts": {
"start": "yarn build && node dist/server.js",
"build": "rm -rf dist && tsc && ln -s ../certs dist/certs && ln -s ../public dist/public",
"start": "node dist/server.js",
"build": "rm -rf dist && tsc && ln -s ../certs dist/certs && ln -s ../public dist/public && chmod 755 dist/server.js",
"dev": "nodemon --exec ts-node --ignore dist/ -e js,ts server.js",
"connect": "ts-node connect.js",
"lint": "eslint -c .eslintrc.json --ext .js,.ts *.js *.ts lib/",
"format": "prettier --write '**/*.ts' && npm run lint --fix"
"format": "prettier --write '**/*.ts' && npm run lint --fix",
"gen-config-docs": "ts-node gen-config-docs.ts"
},
"dependencies": {
"awaitqueue": "^1.0.0",
@ -30,6 +31,8 @@
"colors": "^1.4.0",
"compression": "^1.7.4",
"connect-redis": "^4.0.3",
"convict": "^6.1.0",
"convict-format-with-validator": "^6.0.1",
"cookie-parser": "^1.4.4",
"debug": "^4.3.1",
"express": "^4.17.1",
@ -38,6 +41,7 @@
"fast-stats": "^0.0.6",
"helmet": "^3.21.2",
"ims-lti": "^3.0.2",
"json5": "^2.2.0",
"jsonwebtoken": "^8.5.1",
"mediasoup": "vpalmisano/mediasoup#3.7.1-build-with-system-openssl",
"openid-client": "^3.7.3",
@ -50,7 +54,9 @@
"redis": "^2.8.0",
"socket.io": "^2.4.0",
"spdy": "^4.0.1",
"uuid": "^7.0.2"
"toml": "^3.0.0",
"uuid": "^7.0.2",
"yaml": "^1.10.2"
},
"devDependencies": {
"@types/base-64": "^0.1.3",

View file

@ -3,9 +3,18 @@
process.title = 'edumeet-server';
import Logger from './lib/Logger';
const Room = require('./lib/Room');
const Peer = require('./lib/Peer');
const userRoles = require('./userRoles');
const {
loginHelper,
logoutHelper
} = require('./httpHelper');
const { config, configError } = require('./lib/config');
const interactiveServer = require('./lib/interactiveServer');
const promExporter = require('./lib/promExporter');
const bcrypt = require('bcrypt');
const config = require('./config/config');
const fs = require('fs');
const http = require('http');
const spdy = require('spdy');
@ -15,15 +24,8 @@ const cookieParser = require('cookie-parser');
const compression = require('compression');
const mediasoup = require('mediasoup');
const AwaitQueue = require('awaitqueue');
const Room = require('./lib/Room');
const Peer = require('./lib/Peer');
const base64 = require('base-64');
const helmet = require('helmet');
const userRoles = require('./userRoles');
const {
loginHelper,
logoutHelper
} = require('./httpHelper');
// auth
const passport = require('passport');
const LTIStrategy = require('passport-lti');
@ -31,15 +33,21 @@ const imsLti = require('ims-lti');
const SAMLStrategy = require('passport-saml').Strategy;
const LocalStrategy = require('passport-local').Strategy;
const redis = require('redis');
const redisClient = redis.createClient(config.redisOptions);
const { Issuer, Strategy } = require('openid-client');
const expressSession = require('express-session');
const RedisStore = require('connect-redis')(expressSession);
const sharedSession = require('express-socket.io-session');
const interactiveServer = require('./lib/interactiveServer');
const promExporter = require('./lib/promExporter');
const { v4: uuidv4 } = require('uuid');
if (configError)
{
/* eslint-disable no-console */
console.error(`Invalid config file: ${configError}`);
process.exit(-1);
}
const redisClient = redis.createClient(config.redisOptions);
/* eslint-disable no-console */
console.log('- process.env.DEBUG:', process.env.DEBUG);
console.log('- config.mediasoup.worker.logLevel:', config.mediasoup.worker.logLevel);
@ -138,12 +146,6 @@ async function run()
// Open the interactive server.
await interactiveServer(rooms, peers);
// start Prometheus exporter
if (config.prometheus)
{
await promExporter(rooms, peers, config.prometheus);
}
if (typeof (config.auth) === 'undefined')
{
logger.warn('Auth is not configured properly!');
@ -159,6 +161,12 @@ async function run()
// Run HTTPS server.
await runHttpsServer();
// start Prometheus exporter
if (config.prometheus.enabled)
{
await promExporter(mediasoupWorkers, rooms, peers);
}
// Run WebSocketServer.
await runWebSocketServer();
@ -630,11 +638,11 @@ async function runHttpsServer()
// Serve all files in the public folder as static files.
app.use(express.static('public', {
maxAge : (config.staticFilesCachePeriod || 0) * 1000
maxAge : config.staticFilesCachePeriod
}));
app.use((req, res) => res.sendFile(`${__dirname}/public/index.html`, {
maxAge : (config.staticFilesCachePeriod || 0) * 1000
maxAge : config.staticFilesCachePeriod
}));
if (config.httpOnly === true)
@ -647,13 +655,16 @@ async function runHttpsServer()
// https
mainListener = spdy.createServer(tls, app);
// http
const redirectListener = http.createServer(app);
// http -> https redirect server
if (config.listeningRedirectPort)
{
const redirectListener = http.createServer(app);
if (config.listeningHost)
redirectListener.listen(config.listeningRedirectPort, config.listeningHost);
else
redirectListener.listen(config.listeningRedirectPort);
if (config.listeningHost)
redirectListener.listen(config.listeningRedirectPort, config.listeningHost);
else
redirectListener.listen(config.listeningRedirectPort);
}
}
// https or http

View file

@ -801,6 +801,11 @@ camelcase@^2.0.0:
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f"
integrity sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=
camelcase@^5.0.0:
version "5.3.1"
resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
camelize@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.0.tgz#164a5483e630fa4321e5af07020e531831b2609b"
@ -1011,6 +1016,21 @@ content-type@~1.0.4:
resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
convict-format-with-validator@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/convict-format-with-validator/-/convict-format-with-validator-6.0.1.tgz#a0ff08b663c40beac480d1a0d39b40382b9e39a3"
integrity sha512-6/O0W9/0MESdL+0fY7glQlWHs+wnP9xXlB1tjY/Wb9ujCCEBqMP8VCjtLvRlBdfwgONrpb+DTPSHQRhUO5aTlQ==
dependencies:
validator "^11.1.0"
convict@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/convict/-/convict-6.1.0.tgz#5b393bd675a0e743390abd0c5bf9e2b9a2edf4b5"
integrity sha512-8dzppr6Z9URlm6P8N9NiydFRq2NWtQyf4RZOK5m0Q48fWWuKamHLXD7Qz/SiLvRXnjQcKCuHayIk9Fk51sax0w==
dependencies:
lodash.clonedeep "^4.5.0"
yargs-parser "^18.1.3"
cookie-parser@^1.4.4:
version "1.4.5"
resolved "https://registry.yarnpkg.com/cookie-parser/-/cookie-parser-1.4.5.tgz#3e572d4b7c0c80f9c61daf604e4336831b5d1d49"
@ -1138,7 +1158,7 @@ debug@~4.1.0:
dependencies:
ms "^2.1.1"
decamelize@^1.1.2:
decamelize@^1.1.2, decamelize@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
@ -2528,6 +2548,13 @@ json5@^1.0.1:
dependencies:
minimist "^1.2.0"
json5@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.0.tgz#2dfefe720c6ba525d9ebd909950f0515316c89a3"
integrity sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==
dependencies:
minimist "^1.2.5"
jsonwebtoken@^8.5.1:
version "8.5.1"
resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d"
@ -4261,6 +4288,11 @@ toidentifier@1.0.0:
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
toml@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/toml/-/toml-3.0.0.tgz#342160f1af1904ec9d204d03a5d61222d762c5ee"
integrity sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==
tough-cookie@~2.5.0:
version "2.5.0"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2"
@ -4441,6 +4473,11 @@ validate-npm-package-license@^3.0.1:
spdx-correct "^3.0.0"
spdx-expression-parse "^3.0.0"
validator@^11.1.0:
version "11.1.0"
resolved "https://registry.yarnpkg.com/validator/-/validator-11.1.0.tgz#ac18cac42e0aa5902b603d7a5d9b7827e2346ac4"
integrity sha512-qiQ5ktdO7CD6C/5/mYV4jku/7qnqzjrxb3C/Q5wR3vGGinHTgJZN/TdFT3ZX4vXhX2R1PXx42fB1cn5W+uJ4lg==
vary@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
@ -4608,6 +4645,19 @@ yallist@^4.0.0:
resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
yaml@^1.10.2:
version "1.10.2"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
yargs-parser@^18.1.3:
version "18.1.3"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0"
integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==
dependencies:
camelcase "^5.0.0"
decamelize "^1.2.0"
yeast@0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"