├── compose ├── .gitignore ├── config │ ├── edumeet-app-config.js │ ├── grafana-dashboards.yml │ ├── edumeet-server-config.yaml │ ├── grafana-prometheus-datasource.yml │ ├── prometheus.yml │ ├── nginx.conf │ └── edumeet-server-config.js ├── edumeet │ └── Dockerfile ├── README.md └── docker-compose.yml ├── server ├── .eslintignore ├── connect.js ├── utils │ ├── password_encode.js │ └── gen-config-docs.ts ├── tsconfig.json ├── lib │ ├── access │ │ ├── access.ts │ │ ├── roles.js │ │ └── perms.js │ ├── helpers │ │ ├── errors.js │ │ └── httpHelper.js │ ├── interactive │ │ └── Client.js │ ├── logger │ │ └── Logger.ts │ └── stats │ │ └── promExporter.js ├── config │ ├── config.example.toml │ ├── config.example.yaml │ └── config.example.json ├── certs │ ├── edumeet-demo-cert.pem │ └── edumeet-demo-key.pem └── package.json ├── munin ├── mm-plugin-conf ├── munin.md └── mm-plugin ├── app ├── .eslintignore ├── Procfile ├── src │ ├── react-app-env.d.ts │ ├── images │ │ ├── background.jpg │ │ ├── avatar-empty.jpeg │ │ ├── pin-icon-baseline.svg │ │ ├── pin-icon-outline.svg │ │ └── buddy.svg │ ├── store │ │ ├── actions │ │ │ ├── peerVolumeActions.js │ │ │ ├── transportActions.js │ │ │ ├── intlActions.js │ │ │ ├── recorderActions.js │ │ │ ├── toolareaActions.js │ │ │ ├── notificationActions.js │ │ │ ├── chatActions.js │ │ │ ├── lobbyPeerActions.js │ │ │ ├── producerActions.js │ │ │ ├── requestActions.js │ │ │ ├── fileActions.js │ │ │ ├── consumerActions.js │ │ │ ├── meActions.js │ │ │ └── peerActions.js │ │ ├── reducers │ │ │ ├── intl.js │ │ │ ├── transports.js │ │ │ ├── config.js │ │ │ ├── peerVolumes.js │ │ │ ├── recorder.js │ │ │ ├── rootReducer.js │ │ │ ├── notifications.js │ │ │ ├── lobbyPeers.js │ │ │ ├── chat.js │ │ │ ├── producers.js │ │ │ ├── toolarea.js │ │ │ ├── files.js │ │ │ ├── consumers.js │ │ │ ├── me.js │ │ │ └── peers.js │ │ └── store.js │ ├── components │ │ ├── Loader │ │ │ ├── LazyPreload.js │ │ │ └── LoadingView.js │ │ ├── MeetingDrawer │ │ │ ├── Chat │ │ │ │ ├── Chat.js │ │ │ │ └── Menu │ │ │ │ │ └── Moderator.js │ │ │ └── ParticipantList │ │ │ │ ├── ListMe.js │ │ │ │ └── ListModerator.js │ │ ├── Controls │ │ │ ├── EditableInput.js │ │ │ └── ExtraVideo.js │ │ ├── App.js │ │ ├── PeerAudio │ │ │ ├── AudioPeers.js │ │ │ └── PeerAudio.js │ │ ├── ConfigError.tsx │ │ ├── appPropTypes.js │ │ ├── FullScreen.js │ │ ├── VideoWindow │ │ │ └── VideoWindow.js │ │ ├── VideoContainers │ │ │ └── FullView.js │ │ ├── ConfigDocumentation.tsx │ │ ├── AccessControl │ │ │ ├── LockDialog │ │ │ │ └── ListLobbyPeer.js │ │ │ └── LoginDialog.js │ │ ├── Settings │ │ │ ├── AdvancedSettings.js │ │ │ └── Settings.js │ │ ├── UnsupportedBrowser.js │ │ ├── Containers │ │ │ └── Volume.js │ │ └── LeaveDialog.js │ ├── __tests__ │ │ ├── RoomClient.spec.js │ │ ├── App.spec.js │ │ └── Room.spec.js │ ├── RoomContext.js │ ├── urlFactory.js │ ├── utils.js │ ├── electron-wait-react.js │ ├── Logger.js │ ├── deviceInfo.js │ ├── index.css │ ├── electron-starter.js │ ├── permissions.js │ ├── serviceWorker.js │ ├── intl │ │ └── locales.ts │ └── transforms │ │ └── receiver.ts ├── .env ├── public │ ├── robots.txt │ ├── favicon.png │ ├── sounds │ │ ├── notify.mp3 │ │ ├── notify-chat.mp3 │ │ └── notify-hand.mp3 │ ├── images │ │ └── background.jpg │ ├── manifest.json │ ├── privacy │ │ └── privacy.html │ └── index.html ├── tsconfig.json └── package.json ├── LTI ├── lti1.png ├── lti2.png ├── lti3.png ├── lti4.png └── LTI.md ├── CONTRIBUTING.md ├── .gitignore ├── docs ├── README.md ├── HAproxy.md └── SCALING_AND_HARDWARE.md ├── edumeet.service ├── LICENSE.md ├── prom.md └── .github └── workflows └── develop-deb.yml /compose/.gitignore: -------------------------------------------------------------------------------- 1 | data 2 | -------------------------------------------------------------------------------- /server/.eslintignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /munin/mm-plugin-conf: -------------------------------------------------------------------------------- 1 | [mm] 2 | user root 3 | -------------------------------------------------------------------------------- /app/.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintrc.js 2 | src/react-app-env.d.ts -------------------------------------------------------------------------------- /app/Procfile: -------------------------------------------------------------------------------- 1 | react: npm start 2 | electron: node src/electron-wait-react -------------------------------------------------------------------------------- /app/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /LTI/lti1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muaz-khan/edumeet/master/LTI/lti1.png -------------------------------------------------------------------------------- /LTI/lti2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muaz-khan/edumeet/master/LTI/lti2.png -------------------------------------------------------------------------------- /LTI/lti3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muaz-khan/edumeet/master/LTI/lti3.png -------------------------------------------------------------------------------- /LTI/lti4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muaz-khan/edumeet/master/LTI/lti4.png -------------------------------------------------------------------------------- /app/.env: -------------------------------------------------------------------------------- 1 | REACT_APP_VERSION=$npm_package_version 2 | REACT_APP_NAME=$npm_package_name -------------------------------------------------------------------------------- /app/public/robots.txt: -------------------------------------------------------------------------------- 1 | # Allow crawling of all content 2 | User-agent: * 3 | Disallow: -------------------------------------------------------------------------------- /app/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muaz-khan/edumeet/master/app/public/favicon.png -------------------------------------------------------------------------------- /app/public/sounds/notify.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muaz-khan/edumeet/master/app/public/sounds/notify.mp3 -------------------------------------------------------------------------------- /app/src/images/background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muaz-khan/edumeet/master/app/src/images/background.jpg -------------------------------------------------------------------------------- /app/public/images/background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muaz-khan/edumeet/master/app/public/images/background.jpg -------------------------------------------------------------------------------- /app/public/sounds/notify-chat.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muaz-khan/edumeet/master/app/public/sounds/notify-chat.mp3 -------------------------------------------------------------------------------- /app/public/sounds/notify-hand.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muaz-khan/edumeet/master/app/public/sounds/notify-hand.mp3 -------------------------------------------------------------------------------- /app/src/images/avatar-empty.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/muaz-khan/edumeet/master/app/src/images/avatar-empty.jpeg -------------------------------------------------------------------------------- /server/connect.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const interactiveClient = require('./lib/interactive/Client'); 4 | 5 | interactiveClient(); 6 | -------------------------------------------------------------------------------- /compose/config/edumeet-app-config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | var config = 3 | { 4 | developmentPort : 8443, 5 | productionPort : 3443 6 | }; 7 | -------------------------------------------------------------------------------- /compose/config/grafana-dashboards.yml: -------------------------------------------------------------------------------- 1 | - name: 'default' 2 | org_id: 1 3 | folder: '' 4 | type: 'file' 5 | options: 6 | folder: '/var/lib/grafana/dashboards' 7 | -------------------------------------------------------------------------------- /app/src/store/actions/peerVolumeActions.js: -------------------------------------------------------------------------------- 1 | export const setPeerVolume = (peerId, volume) => 2 | ({ 3 | type : 'SET_PEER_VOLUME', 4 | payload : { peerId, volume } 5 | }); 6 | -------------------------------------------------------------------------------- /app/src/store/actions/transportActions.js: -------------------------------------------------------------------------------- 1 | export const addTransportStats = (transport, type) => 2 | ({ 3 | type : 'ADD_TRANSPORT_STATS', 4 | payload : { transport, type } 5 | }); -------------------------------------------------------------------------------- /app/src/images/pin-icon-baseline.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Source code contributions should pass static code analysis as performed by `npm run lint` in `server` and `app` respectively. 2 | 3 | Please contribute by creating your pull requests against the `develop` branch. 4 | -------------------------------------------------------------------------------- /app/src/images/pin-icon-outline.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/src/store/actions/intlActions.js: -------------------------------------------------------------------------------- 1 | import { UPDATE } from 'react-intl-redux'; 2 | 3 | export const updateIntl = ({ locale, formats, messages, list }) => 4 | ({ 5 | type : UPDATE, 6 | payload : { locale, formats, messages, list } 7 | }); 8 | -------------------------------------------------------------------------------- /compose/config/edumeet-server-config.yaml: -------------------------------------------------------------------------------- 1 | redisOptions: 2 | host: redis 3 | port: 6379 4 | 5 | listeningPort: 3443 6 | listeningRedirectPort: 0 7 | httpOnly: false 8 | trustProxy: '' 9 | 10 | prometheus: 11 | enabled: true 12 | listen: 0.0.0.0 13 | -------------------------------------------------------------------------------- /app/src/components/Loader/LazyPreload.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const LazyPreload = (importStatement) => 4 | { 5 | const Component = React.lazy(importStatement); 6 | 7 | Component.preload = importStatement; 8 | 9 | return Component; 10 | }; -------------------------------------------------------------------------------- /app/src/__tests__/RoomClient.spec.js: -------------------------------------------------------------------------------- 1 | import RoomClient from '../RoomClient'; 2 | 3 | describe('new RoomClient() without parameters throws Error', () => 4 | { 5 | test('Matches the snapshot', () => 6 | { 7 | expect(() => new RoomClient()).toThrow(Error); 8 | }); 9 | }); -------------------------------------------------------------------------------- /server/utils/password_encode.js: -------------------------------------------------------------------------------- 1 | const bcrypt = require('bcrypt'); 2 | const saltRounds=10; 3 | 4 | if (process.argv.length == 3) 5 | { 6 | const cleartextPassword = process.argv[2]; 7 | 8 | // eslint-disable-next-line no-console 9 | console.log(bcrypt.hashSync(cleartextPassword, saltRounds)); 10 | } 11 | -------------------------------------------------------------------------------- /app/src/store/actions/recorderActions.js: -------------------------------------------------------------------------------- 1 | export const setLocalRecordingState = (status) => 2 | ({ 3 | type : 'SET_LOCAL_RECORDING_STATE', 4 | payload : { status } 5 | }); 6 | export const setLocalRecordingConsent = (agreed) => 7 | ({ 8 | type : 'SET_LOCAL_RECORDING_CONSENT', 9 | payload : { agreed } 10 | }); 11 | -------------------------------------------------------------------------------- /app/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "edumeet", 3 | "name": "edumeet - Simple web meetings", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /app/src/store/reducers/intl.js: -------------------------------------------------------------------------------- 1 | import { UPDATE } from 'react-intl-redux'; 2 | 3 | const initialState = { 4 | locale : null, 5 | messages : null 6 | }; 7 | 8 | const intlReducer = (state = initialState, action) => 9 | { 10 | if (action.type !== UPDATE) 11 | { 12 | return state; 13 | } 14 | 15 | return { ...state, ...action.payload }; 16 | }; 17 | 18 | export default intlReducer; 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | /app/build/ 4 | /app/public/config/config.js 5 | /app/public/images/logo.* 6 | !/app/public/images/logo.edumeet.svg 7 | /server/config/ 8 | !/server/config/config.example.* 9 | !/server/config/README.md 10 | /server/public/ 11 | /server/certs/ 12 | /server/dist/ 13 | !/server/certs/mediasoup-demo.localhost.* 14 | .vscode 15 | /app/public/config/*.pem 16 | yarn-error.log 17 | -------------------------------------------------------------------------------- /app/src/RoomContext.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const RoomContext = React.createContext(); 4 | 5 | export default RoomContext; 6 | 7 | export function withRoomContext(Component) 8 | { 9 | return (props) => ( // eslint-disable-line react/display-name 10 | 11 | {(roomClient) => } 12 | 13 | ); 14 | } -------------------------------------------------------------------------------- /app/src/store/reducers/transports.js: -------------------------------------------------------------------------------- 1 | const initialState = {}; 2 | 3 | const transports = (state = initialState, action) => 4 | { 5 | switch (action.type) 6 | { 7 | case 'ADD_TRANSPORT_STATS': 8 | { 9 | const { transport, type } = action.payload; 10 | 11 | return { ...state, [type]: transport[0] }; 12 | } 13 | 14 | default: 15 | return state; 16 | } 17 | }; 18 | 19 | export default transports; 20 | -------------------------------------------------------------------------------- /app/src/store/reducers/config.js: -------------------------------------------------------------------------------- 1 | import { config as defaultConfig } from '../../config'; 2 | 3 | const initialState = 4 | { 5 | ...defaultConfig 6 | }; 7 | 8 | const config = (state = initialState, action) => 9 | { 10 | switch (action.type) 11 | { 12 | case 'CONFIG_SET': 13 | { 14 | return { ...action.payload }; 15 | } 16 | 17 | default: 18 | return state; 19 | } 20 | }; 21 | 22 | export default config; 23 | -------------------------------------------------------------------------------- /app/src/store/actions/toolareaActions.js: -------------------------------------------------------------------------------- 1 | export const toggleToolArea = () => 2 | ({ 3 | type : 'TOGGLE_TOOL_AREA' 4 | }); 5 | 6 | export const openToolArea = () => 7 | ({ 8 | type : 'OPEN_TOOL_AREA' 9 | }); 10 | 11 | export const closeToolArea = () => 12 | ({ 13 | type : 'CLOSE_TOOL_AREA' 14 | }); 15 | 16 | export const setToolTab = (toolTab) => 17 | ({ 18 | type : 'SET_TOOL_TAB', 19 | payload : { toolTab } 20 | }); -------------------------------------------------------------------------------- /app/src/urlFactory.js: -------------------------------------------------------------------------------- 1 | import { config } from './config'; 2 | 3 | export function getSignalingUrl(peerId, roomId) 4 | { 5 | const hostname = config.serverHostname || window.location.hostname; 6 | const port = 7 | process.env.NODE_ENV !== 'production' ? 8 | config.developmentPort 9 | : 10 | config.productionPort; 11 | 12 | const url = `wss://${hostname}:${port}/?peerId=${peerId}&roomId=${roomId}`; 13 | 14 | return url; 15 | } 16 | -------------------------------------------------------------------------------- /app/src/components/Loader/LoadingView.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { withStyles } from '@material-ui/core/styles'; 3 | 4 | const styles = (/* theme */) => 5 | ({ 6 | root : 7 | { 8 | height : '100%', 9 | width : '100%' 10 | } 11 | }); 12 | 13 | const LoadingView = ({ 14 | classes 15 | }) => 16 | { 17 | return ( 18 |
19 | ); 20 | }; 21 | 22 | export default withStyles(styles)(LoadingView); 23 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # ![edumeet logo](/app/public/images/logo.edumeet.svg) 2 | # Documentation / Table Of Contents 3 | ### [Overview eduMEET](/README.md) 4 | ### [Documentation of configuration for client/app](/app/public/config/README.md) 5 | ### [Documentation of configuration for server](/server/config/README.md) 6 | ### [Documentation of Development enviroment with Docker](/compose/README.md) 7 | ### [Setup HAproxy / load balancing edumeet](HAproxy.md) 8 | ### [Scaling and recommended Hardware](SCALING_AND_HARDWARE.md) 9 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "module": "commonjs", 5 | "esModuleInterop": true, 6 | "allowSyntheticDefaultImports": true, 7 | "allowJs": true, 8 | "moduleResolution": "node", 9 | "sourceMap": true, 10 | "outDir": "dist", 11 | "rootDir": "." 12 | }, 13 | "include": [ 14 | "**/*.ts", 15 | "**/*.js" 16 | ], 17 | "exclude": [ 18 | "./public", 19 | "./dist" 20 | ] 21 | } -------------------------------------------------------------------------------- /compose/edumeet/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14-buster-slim 2 | RUN apt-get update && \ 3 | apt-get install -y git build-essential python pkg-config libssl-dev && \ 4 | apt-get clean 5 | WORKDIR /edumeet 6 | ENV DEBUG=edumeet*,mediasoup* 7 | RUN npm install -g nodemon && \ 8 | npm install -g concurrently 9 | RUN touch /.yarnrc && mkdir -p /.yarn /.cache/yarn && chmod -R 775 /.yarn /.yarnrc /.cache 10 | CMD concurrently --names "server,app" \ 11 | "cd server && yarn && yarn dev" \ 12 | "cd app && yarn && yarn build && yarn start" 13 | -------------------------------------------------------------------------------- /server/lib/access/access.ts: -------------------------------------------------------------------------------- 1 | // The role(s) will gain access to the room 2 | // even if it is locked (!) 3 | export const BYPASS_ROOM_LOCK = 'BYPASS_ROOM_LOCK'; 4 | 5 | // The role(s) will gain access to the room without 6 | // going into the lobby. If you want to restrict access to your 7 | // server to only directly allow authenticated users, you could 8 | // add the userRoles.AUTHENTICATED to the user in the userMapping 9 | // function, and change to BYPASS_LOBBY : [ userRoles.AUTHENTICATED ] 10 | export const BYPASS_LOBBY = 'BYPASS_LOBBY'; 11 | -------------------------------------------------------------------------------- /app/src/store/actions/notificationActions.js: -------------------------------------------------------------------------------- 1 | export const addNotification = (notification) => 2 | ({ 3 | type : 'ADD_NOTIFICATION', 4 | payload : { notification } 5 | }); 6 | 7 | export const removeNotification = (notificationId) => 8 | ({ 9 | type : 'REMOVE_NOTIFICATION', 10 | payload : { notificationId } 11 | }); 12 | 13 | export const removeAllNotifications = () => 14 | ({ 15 | type : 'REMOVE_ALL_NOTIFICATIONS' 16 | }); 17 | 18 | export const closeNotification = (notificationId) => 19 | ({ 20 | type : 'CLOSE_NOTIFICATION', 21 | payload : { notificationId } 22 | }); 23 | -------------------------------------------------------------------------------- /compose/config/grafana-prometheus-datasource.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | deleteDatasources: 4 | - name: Prometheus 5 | orgId: 1 6 | 7 | datasources: 8 | - name: Prometheus 9 | type: prometheus 10 | access: proxy 11 | url: http://prometheus:9090 12 | password: 13 | user: 14 | database: prometheus 15 | basicAuth: false 16 | basicAuthUser: 17 | basicAuthPassword: 18 | withCredentials: 19 | isDefault: true 20 | jsonData: 21 | tlsAuth: false 22 | tlsAuthWithCACert: false 23 | secureJsonData: 24 | tlsCACert: "" 25 | tlsClientCert: "" 26 | tlsClientKey: "" 27 | version: 1 28 | editable: true -------------------------------------------------------------------------------- /app/public/privacy/privacy.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Placeholder for Privacy Statement / Policy, AUP 7 | 8 | 9 | 10 |

Privacy Statement

11 |

Privacy Policy

12 | 16 |

Acceptable use policy (AUP)

17 | 18 | -------------------------------------------------------------------------------- /edumeet.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=edumeet is a audio / video meeting service running in the browser and powered by webRTC 3 | After=network.target 4 | 5 | [Service] 6 | # modify the paths accordingly with your installation 7 | ExecStart=/usr/local/src/edumeet/server/dist/server.js 8 | WorkingDirectory=/usr/local/src/edumeet/server 9 | Restart=always 10 | RestartSec=1 11 | User=nobody 12 | Group=nogroup 13 | Environment=PATH=/usr/bin:/usr/local/bin 14 | Environment=NODE_ENV=production 15 | Environment=DEBUG="*ERROR*,*WARN*,*INFO*" 16 | StandardOutput=syslog 17 | StandardError=syslog 18 | AmbientCapabilities=CAP_NET_BIND_SERVICE 19 | 20 | [Install] 21 | WantedBy=multi-user.target 22 | -------------------------------------------------------------------------------- /server/lib/helpers/errors.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Error produced when a socket request has a timeout. 3 | */ 4 | class SocketTimeoutError extends Error 5 | { 6 | constructor(message) 7 | { 8 | super(message); 9 | 10 | this.name = 'SocketTimeoutError'; 11 | 12 | if (Error.hasOwnProperty('captureStackTrace')) // Just in V8. 13 | Error.captureStackTrace(this, SocketTimeoutError); 14 | else 15 | this.stack = (new Error(message)).stack; 16 | } 17 | } 18 | 19 | class NotFoundInMediasoupError extends Error 20 | { 21 | constructor(message) 22 | { 23 | super(message); 24 | 25 | this.name = 'NotFoundInMediasoupError'; 26 | } 27 | } 28 | 29 | module.exports = 30 | { 31 | SocketTimeoutError, 32 | NotFoundInMediasoupError 33 | }; -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx", 22 | "downlevelIteration": true 23 | }, 24 | "include": ["src/**/*.ts", "src/**/*.tsx"], 25 | "exclude": ["node_modules", "**/*.spec.ts"] 26 | } 27 | -------------------------------------------------------------------------------- /app/src/store/actions/chatActions.js: -------------------------------------------------------------------------------- 1 | export const addMessage = (message) => 2 | ({ 3 | type : 'ADD_MESSAGE', 4 | payload : { message } 5 | }); 6 | 7 | export const addChatHistory = (chatHistory) => 8 | ({ 9 | type : 'ADD_CHAT_HISTORY', 10 | payload : { chatHistory } 11 | }); 12 | 13 | export const clearChat = () => 14 | ({ 15 | type : 'CLEAR_CHAT' 16 | }); 17 | 18 | export const sortChat = (order) => 19 | ({ 20 | type : 'SORT_CHAT', 21 | payload : { order } 22 | }); 23 | 24 | export const setIsScrollEnd = (flag) => 25 | ({ 26 | type : 'SET_IS_SCROLL_END', 27 | payload : { flag } 28 | }); 29 | 30 | export const setIsMessageRead = (id, isRead) => 31 | { 32 | return ({ 33 | type : 'SET_IS_MESSAGE_READ', 34 | payload : { id, isRead } 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /app/src/store/actions/lobbyPeerActions.js: -------------------------------------------------------------------------------- 1 | export const addLobbyPeer = (peerId) => 2 | ({ 3 | type : 'ADD_LOBBY_PEER', 4 | payload : { peerId } 5 | }); 6 | 7 | export const removeLobbyPeer = (peerId) => 8 | ({ 9 | type : 'REMOVE_LOBBY_PEER', 10 | payload : { peerId } 11 | }); 12 | 13 | export const setLobbyPeerDisplayName = (displayName, peerId) => 14 | ({ 15 | type : 'SET_LOBBY_PEER_DISPLAY_NAME', 16 | payload : { displayName, peerId } 17 | }); 18 | 19 | export const setLobbyPeerPicture = (picture, peerId) => 20 | ({ 21 | type : 'SET_LOBBY_PEER_PICTURE', 22 | payload : { picture, peerId } 23 | }); 24 | 25 | export const setLobbyPeerPromotionInProgress = (peerId, flag) => 26 | ({ 27 | type : 'SET_LOBBY_PEER_PROMOTION_IN_PROGRESS', 28 | payload : { peerId, flag } 29 | }); -------------------------------------------------------------------------------- /server/config/config.example.toml: -------------------------------------------------------------------------------- 1 | listeningPort = "443" 2 | listeningHost = "host.domain.tld" 3 | fileTracker = "wss://tracker.openwebtorrent.com" 4 | turnAPIKey = "Your API key" 5 | turnAPIURI = "https://host.domain.tld/turn" 6 | 7 | [tls] 8 | cert = "./certs/edumeet-demo-cert.pem" 9 | key = "./certs/edumeet-demo-key.pem" 10 | 11 | [backupTurnServers] 12 | urls = [ "turn:host.domain.tld:443?transport=tcp" ] 13 | username = "Your username" 14 | credential = "Your's credential" 15 | 16 | [redisOptions] 17 | host = "127.0.0.1" 18 | port = "6379" 19 | password = "_REDIS_PASSWORD_" 20 | 21 | [prometheus] 22 | enabled = "true" 23 | deidentify = "true" 24 | numeric = "true" 25 | listen = "host.domain.tld" 26 | 27 | [[mediasoup.webRtcTransport.listenIps]] 28 | ip = "PUBLIC_IP_ADDRESS" 29 | announcedIp = "" 30 | 31 | -------------------------------------------------------------------------------- /server/lib/interactive/Client.js: -------------------------------------------------------------------------------- 1 | const net = require('net'); 2 | const os = require('os'); 3 | const path = require('path'); 4 | 5 | const SOCKET_PATH_UNIX = '/tmp/edumeet-server.sock'; 6 | const SOCKET_PATH_WIN = path.join('\\\\?\\pipe', process.cwd(), 'edumeet-server'); 7 | const SOCKET_PATH = os.platform() === 'win32'? SOCKET_PATH_WIN : SOCKET_PATH_UNIX; 8 | 9 | module.exports = async function() 10 | { 11 | const socket = net.connect(SOCKET_PATH); 12 | 13 | process.stdin.pipe(socket); 14 | socket.pipe(process.stdout); 15 | 16 | socket.on('connect', () => process.stdin.setRawMode(true)); 17 | 18 | socket.on('close', () => process.exit(0)); 19 | socket.on('exit', () => socket.end()); 20 | 21 | if (process.argv && process.argv[2] === '--stats') 22 | { 23 | await socket.write('stats\n'); 24 | 25 | socket.end(); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /app/src/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Create a function which will call the callback function 3 | * after the given amount of milliseconds has passed since 4 | * the last time the callback function was called. 5 | */ 6 | export const idle = (callback, delay) => 7 | { 8 | let handle; 9 | 10 | return () => 11 | { 12 | if (handle) 13 | { 14 | clearTimeout(handle); 15 | } 16 | 17 | handle = setTimeout(callback, delay); 18 | }; 19 | }; 20 | 21 | /** 22 | * Error produced when a socket request has a timeout. 23 | */ 24 | export class SocketTimeoutError extends Error 25 | { 26 | constructor(message) 27 | { 28 | super(message); 29 | 30 | this.name = 'SocketTimeoutError'; 31 | 32 | if (Error.hasOwnProperty('captureStackTrace')) // Just in V8. 33 | Error.captureStackTrace(this, SocketTimeoutError); 34 | else 35 | this.stack = (new Error(message)).stack; 36 | } 37 | } -------------------------------------------------------------------------------- /server/lib/helpers/httpHelper.js: -------------------------------------------------------------------------------- 1 | exports.loginHelper = function(data) 2 | { 3 | const html = ` 4 | 5 | 6 | 7 | edumeet 8 | 9 | 10 | 17 | 18 | `; 19 | 20 | return html; 21 | }; 22 | 23 | exports.logoutHelper = function() 24 | { 25 | const html = ` 26 | 27 | 28 | 29 | edumeet 30 | 31 | 32 | 37 | 38 | `; 39 | 40 | return html; 41 | }; -------------------------------------------------------------------------------- /server/lib/access/roles.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // These can be changed, id must be unique. 3 | 4 | // A person can give other peers any role that is promotable: true 5 | // with a level up to and including their own highest role. 6 | // Example: A MODERATOR can give other peers PRESENTER and MODERATOR 7 | // roles (all peers always have NORMAL) 8 | ADMIN : { id: 2529, label: 'admin', level: 50, promotable: true }, 9 | MODERATOR : { id: 5337, label: 'moderator', level: 40, promotable: true }, 10 | PRESENTER : { id: 9583, label: 'presenter', level: 30, promotable: true }, 11 | AUTHENTICATED : { id: 5714, label: 'authenticated', level: 20, promotable: false }, 12 | // Don't change anything after this point 13 | 14 | // All users have this role by default, do not change or remove this role 15 | NORMAL : { id: 4261, label: 'normal', level: 10, promotable: false } 16 | }; -------------------------------------------------------------------------------- /app/src/components/MeetingDrawer/Chat/Chat.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { withStyles } from '@material-ui/core/styles'; 4 | import Paper from '@material-ui/core/Paper'; 5 | import Moderator from './Menu/Moderator'; 6 | import List from './List/List'; 7 | import Input from './Menu/Input'; 8 | 9 | const styles = () => 10 | ({ 11 | root : 12 | { 13 | display : 'flex', 14 | flexDirection : 'column', 15 | width : '100%', 16 | height : '100%', 17 | overflowY : 'auto' 18 | } 19 | }); 20 | 21 | const Chat = (props) => 22 | { 23 | const { 24 | classes 25 | } = props; 26 | 27 | return ( 28 | 29 | 30 | 31 | 32 | 33 | ); 34 | }; 35 | 36 | Chat.propTypes = 37 | { 38 | classes : PropTypes.object.isRequired 39 | }; 40 | 41 | export default withStyles(styles)(Chat); -------------------------------------------------------------------------------- /app/src/store/actions/producerActions.js: -------------------------------------------------------------------------------- 1 | export const addProducer = (producer) => 2 | ({ 3 | type : 'ADD_PRODUCER', 4 | payload : { producer } 5 | }); 6 | 7 | export const removeProducer = (producerId) => 8 | ({ 9 | type : 'REMOVE_PRODUCER', 10 | payload : { producerId } 11 | }); 12 | 13 | export const setProducerPaused = (producerId, originator) => 14 | ({ 15 | type : 'SET_PRODUCER_PAUSED', 16 | payload : { producerId, originator } 17 | }); 18 | 19 | export const setProducerResumed = (producerId, originator) => 20 | ({ 21 | type : 'SET_PRODUCER_RESUMED', 22 | payload : { producerId, originator } 23 | }); 24 | 25 | export const setProducerTrack = (producerId, track) => 26 | ({ 27 | type : 'SET_PRODUCER_TRACK', 28 | payload : { producerId, track } 29 | }); 30 | 31 | export const setProducerScore = (producerId, score) => 32 | ({ 33 | type : 'SET_PRODUCER_SCORE', 34 | payload : { producerId, score } 35 | }); -------------------------------------------------------------------------------- /app/src/store/actions/requestActions.js: -------------------------------------------------------------------------------- 1 | import randomString from 'random-string'; 2 | import * as notificationActions from './notificationActions'; 3 | 4 | // This returns a redux-thunk action (a function). 5 | export const notify = ({ type = 'info', text, timeout }) => 6 | { 7 | if (!timeout) 8 | { 9 | switch (type) 10 | { 11 | case 'info': 12 | timeout = 3000; 13 | break; 14 | case 'error': 15 | timeout = 5000; 16 | break; 17 | default: 18 | timeout = 3000; 19 | break; 20 | } 21 | } 22 | 23 | const notification = 24 | { 25 | id : randomString({ length: 6 }).toLowerCase(), 26 | type : type, 27 | text : text, 28 | timeout : timeout 29 | }; 30 | 31 | return (dispatch) => 32 | { 33 | dispatch(notificationActions.addNotification(notification)); 34 | 35 | setTimeout(() => 36 | { 37 | dispatch(notificationActions.removeNotification(notification.id)); 38 | }, timeout); 39 | }; 40 | }; 41 | -------------------------------------------------------------------------------- /app/src/store/reducers/peerVolumes.js: -------------------------------------------------------------------------------- 1 | const initialState = {}; 2 | 3 | const peerVolumes = (state = initialState, action) => 4 | { 5 | switch (action.type) 6 | { 7 | case 'SET_ME': 8 | { 9 | const { 10 | peerId 11 | } = action.payload; 12 | 13 | return { ...state, [peerId]: -100 }; 14 | } 15 | case 'ADD_PEER': 16 | { 17 | const { peer } = action.payload; 18 | 19 | return { ...state, [peer.id]: -100 }; 20 | } 21 | 22 | case 'REMOVE_PEER': 23 | { 24 | const { peerId } = action.payload; 25 | const newState = { ...state }; 26 | 27 | delete newState[peerId]; 28 | 29 | return newState; 30 | } 31 | 32 | case 'SET_PEER_VOLUME': 33 | { 34 | const { peerId } = action.payload; 35 | const dBs = action.payload.volume < -100 ? -100 : action.payload.volume; 36 | 37 | return { ...state, [peerId]: dBs }; 38 | } 39 | 40 | default: 41 | return state; 42 | } 43 | }; 44 | 45 | export default peerVolumes; 46 | -------------------------------------------------------------------------------- /app/src/electron-wait-react.js: -------------------------------------------------------------------------------- 1 | const net = require('net'); 2 | const port = process.env.PORT ? (process.env.PORT - 100) : 3000; 3 | 4 | process.env.ELECTRON_START_URL = `http://localhost:${port}`; 5 | 6 | const client = new net.Socket(); 7 | 8 | let startedElectron = false; 9 | 10 | const tryConnection = () => 11 | client.connect({ port: port }, () => 12 | { 13 | client.end(); 14 | 15 | if (!startedElectron) 16 | { 17 | // eslint-disable-next-line no-console 18 | console.log('starting electron'); 19 | 20 | startedElectron = true; 21 | 22 | const exec = require('child_process').exec; 23 | 24 | const electron = exec('npm run electron'); 25 | 26 | electron.stdout.on('data', (data) => 27 | { 28 | // eslint-disable-next-line no-console 29 | console.log(`stdout: ${data.toString()}`); 30 | }); 31 | } 32 | }); 33 | 34 | tryConnection(); 35 | 36 | client.on('error', () => 37 | { 38 | setTimeout(tryConnection, 1000); 39 | }); 40 | -------------------------------------------------------------------------------- /app/src/store/actions/fileActions.js: -------------------------------------------------------------------------------- 1 | export const addFile = (file) => 2 | ({ 3 | type : 'ADD_FILE', 4 | payload : { ...file } 5 | }); 6 | 7 | export const addFileHistory = (fileHistory) => 8 | ({ 9 | type : 'ADD_FILE_HISTORY', 10 | payload : { fileHistory } 11 | }); 12 | 13 | export const setFileActive = (magnetUri) => 14 | ({ 15 | type : 'SET_FILE_ACTIVE', 16 | payload : { magnetUri } 17 | }); 18 | 19 | export const setFileInActive = (magnetUri) => 20 | ({ 21 | type : 'SET_FILE_INACTIVE', 22 | payload : { magnetUri } 23 | }); 24 | 25 | export const setFileProgress = (magnetUri, progress) => 26 | ({ 27 | type : 'SET_FILE_PROGRESS', 28 | payload : { magnetUri, progress } 29 | }); 30 | 31 | export const setFileDone = (magnetUri, sharedFiles) => 32 | ({ 33 | type : 'SET_FILE_DONE', 34 | payload : { magnetUri, sharedFiles } 35 | }); 36 | 37 | export const clearFiles = () => 38 | ({ 39 | type : 'CLEAR_FILES' 40 | }); -------------------------------------------------------------------------------- /app/src/Logger.js: -------------------------------------------------------------------------------- 1 | import debug from 'debug'; 2 | 3 | const APP_NAME = 'edumeet'; 4 | 5 | export default class Logger 6 | { 7 | constructor(prefix) 8 | { 9 | if (prefix) 10 | { 11 | this._debug = debug(`${APP_NAME}:${prefix}`); 12 | this._warn = debug(`${APP_NAME}:WARN:${prefix}`); 13 | this._error = debug(`${APP_NAME}:ERROR:${prefix}`); 14 | } 15 | else 16 | { 17 | this._debug = debug(APP_NAME); 18 | this._warn = debug(`${APP_NAME}:WARN`); 19 | this._error = debug(`${APP_NAME}:ERROR`); 20 | } 21 | 22 | /* eslint-disable no-console */ 23 | this._debug.log = console.info.bind(console); 24 | this._warn.log = console.warn.bind(console); 25 | this._error.log = console.error.bind(console); 26 | /* eslint-enable no-console */ 27 | } 28 | 29 | get debug() 30 | { 31 | return this._debug; 32 | } 33 | 34 | get warn() 35 | { 36 | return this._warn; 37 | } 38 | 39 | get error() 40 | { 41 | return this._error; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/src/deviceInfo.js: -------------------------------------------------------------------------------- 1 | import bowser from 'bowser'; 2 | 3 | window.BB = bowser; 4 | 5 | export default function deviceInfo() 6 | { 7 | const ua = navigator.userAgent; 8 | const browser = bowser.getParser(ua); 9 | 10 | let flag; 11 | 12 | if (browser.satisfies({ chrome: '>=0', chromium: '>=0' })) 13 | flag = 'chrome'; 14 | else if (browser.satisfies({ firefox: '>=0' })) 15 | flag = 'firefox'; 16 | else if (browser.satisfies({ safari: '>=0' })) 17 | flag = 'safari'; 18 | else if (browser.satisfies({ opera: '>=0' })) 19 | flag = 'opera'; 20 | else if (browser.satisfies({ 'microsoft edge': '>=0' })) 21 | flag = 'edge'; 22 | else 23 | flag = 'unknown'; 24 | 25 | return { 26 | flag, 27 | os : browser.getOSName(true), // ios, android, linux... 28 | platform : browser.getPlatformType(true), // mobile, desktop, tablet 29 | name : browser.getBrowserName(true), 30 | version : browser.getBrowserVersion(), 31 | bowser : browser 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /app/src/store/reducers/recorder.js: -------------------------------------------------------------------------------- 1 | const initialState = { 2 | localRecordingState : { 3 | status : 'init', 4 | consent : 'init' 5 | } 6 | } 7 | 8 | ; 9 | 10 | const recorder = (state = initialState, action) => 11 | { 12 | switch (action.type) 13 | { 14 | case 'SET_LOCAL_RECORDING_STATE': 15 | { 16 | const { status } = action.payload; 17 | 18 | const localRecordingState = state['localRecordingState']; 19 | 20 | localRecordingState.status = status; 21 | 22 | return { ...state, 23 | localRecordingState : localRecordingState 24 | }; 25 | } 26 | 27 | case 'SET_LOCAL_RECORDING_CONSENT': 28 | { 29 | const { agreed } = action.payload; 30 | const localRecordingState = state['localRecordingState']; 31 | 32 | localRecordingState.consent = agreed; 33 | 34 | return { ...state, 35 | localRecordingState : localRecordingState 36 | }; 37 | } 38 | 39 | default: 40 | return state; 41 | } 42 | }; 43 | 44 | export default recorder; 45 | -------------------------------------------------------------------------------- /app/src/store/reducers/rootReducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import room from './room'; 3 | import me from './me'; 4 | import producers from './producers'; 5 | import consumers from './consumers'; 6 | import transports from './transports'; 7 | import peers from './peers'; 8 | import lobbyPeers from './lobbyPeers'; 9 | import peerVolumes from './peerVolumes'; 10 | import notifications from './notifications'; 11 | import toolarea from './toolarea'; 12 | import chat from './chat'; 13 | import files from './files'; 14 | import recorder from './recorder'; 15 | import settings from './settings'; 16 | import config from './config'; 17 | import intl from './intl'; 18 | 19 | export default combineReducers({ 20 | // intl : intlReducer, 21 | room, 22 | me, 23 | producers, 24 | consumers, 25 | transports, 26 | peers, 27 | lobbyPeers, 28 | peerVolumes, 29 | notifications, 30 | toolarea, 31 | chat, 32 | files, 33 | recorder, 34 | settings, 35 | config, 36 | intl 37 | }); 38 | -------------------------------------------------------------------------------- /server/config/config.example.yaml: -------------------------------------------------------------------------------- 1 | listeningPort: 443 2 | listeningHost: host.domain.tld 3 | 4 | fileTracker: "wss://tracker.openwebtorrent.com" 5 | 6 | tls: 7 | cert: ./certs/edumeet-demo-cert.pem 8 | key: ./certs/edumeet-demo-key.pem 9 | 10 | turnAPIURI: "https://host.domain.tld/turn" 11 | turnAPIKey: "Your API key" 12 | 13 | backupTurnServers: 14 | - urls: 15 | - "turn:host.domain.tld:443?transport=tcp" 16 | username: "Your username" 17 | credential: "Your's credential" 18 | 19 | redisOptions: 20 | host: "127.0.0.1" 21 | port: "6379" 22 | password: "_REDIS_PASSWORD_" 23 | 24 | prometheus: 25 | enabled: true 26 | deidentify: true 27 | numeric: true 28 | listen: host.domain.tld 29 | 30 | mediasoup: 31 | webRtcTransport: 32 | listenIps: 33 | - ip: "PUBLIC_IP_ADDRESS" 34 | announcedIp: "" 35 | -------------------------------------------------------------------------------- /app/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --background-color: rgba(114, 119, 143, 1.0); 3 | 4 | --peer-shadow: 0px /* 1px 5px 0px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 3px 1px -2px rgba(0, 0, 0, 0.12) */; 5 | --peer-border: 0px /* solid rgba(49, 49, 49, 0.9) */; 6 | --peer-empty-avatar: url('./images/buddy.svg'); 7 | --peer-bg-color: rgba(49, 49, 49, 0.9); 8 | --peer-video-bg-color: rgba(19, 19, 19, 1); 9 | 10 | --active-speaker-border-color: rgba(255, 255, 255, 1.0); 11 | --selected-peer-border-color: rgba(55, 126, 255, 1.0); 12 | --active-speaker-shadow: 0px 0px 8px rgba(255, 255, 255, 0.9); 13 | } 14 | 15 | html 16 | { 17 | height: 100%; 18 | width: 100%; 19 | font-family: 'Roboto'; 20 | font-weight: 300; 21 | margin : 0; 22 | box-sizing: border-box; 23 | } 24 | 25 | *, *:before, *:after { 26 | box-sizing: inherit; 27 | } 28 | 29 | body 30 | { 31 | height: 100%; 32 | width: 100%; 33 | font-size: 16px; 34 | margin: 0; 35 | } 36 | 37 | #edumeet 38 | { 39 | height: 100%; 40 | width: 100%; 41 | } -------------------------------------------------------------------------------- /munin/munin.md: -------------------------------------------------------------------------------- 1 | # Install a munin plugin, as a very basic monitoring 2 | 3 | ## munin-node 4 | 5 | * install on your docker host munin-node on mm.example.com 6 | 7 | ```bash 8 | apt install munin-node 9 | ``` 10 | 11 | * Copy mm-plugin from this directory to plugins dir as mm 12 | 13 | cp mm-plugin /usr/share/munin/plugins/mm 14 | 15 | ```bash 16 | sudo ln -s /usr/share/munin/plugins/mm /etc/munin/plugins/mm 17 | ``` 18 | 19 | * Copy mm-plugin-conf from this directory to munin plugins conf dir as mm 20 | 21 | ```bash 22 | cp mm-plugin-conf /etc/munin/plugin-conf.d/mm 23 | ``` 24 | 25 | * Restart munin 26 | 27 | ```bash 28 | systemctl restart munin-node 29 | ``` 30 | 31 | ## munin master 32 | 33 | * Install a munin master on different host if you don't have munin already. 34 | 35 | ```bash 36 | apt install munin 37 | ``` 38 | 39 | * On your munin master configure the new node 40 | edit and add to /etc/munin.conf 41 | 42 | ```bash 43 | [mm] 44 | mm.example.com 45 | ``` 46 | -------------------------------------------------------------------------------- /compose/config/prometheus.yml: -------------------------------------------------------------------------------- 1 | # global config 2 | global: 3 | scrape_interval: 120s # By default, scrape targets every 15 seconds. 4 | evaluation_interval: 120s # By default, scrape targets every 15 seconds. 5 | # scrape_timeout is set to the global default (10s). 6 | # Attach these labels to any time series or alerts when communicating with 7 | # external systems (federation, remote storage, Alertmanager). 8 | external_labels: 9 | monitor: 'edumeet' 10 | 11 | # Load and evaluate rules in this file every 'evaluation_interval' seconds. 12 | rule_files: 13 | # - "alert.rules" 14 | # - "first.rules" 15 | # - "second.rules" 16 | 17 | scrape_configs: 18 | - job_name: 'prometheus' 19 | scrape_interval: 15s 20 | static_configs: 21 | - targets: ['localhost:9090','node-exporter:9100'] 22 | - job_name: 'edumeet' 23 | scrape_interval: 15s 24 | metrics_path: /metrics 25 | scheme: http 26 | # authorization: 27 | # type: Bearer 28 | # credentials: "prometheus-secret" 29 | # tls_config: 30 | # insecure_skip_verify: true 31 | static_configs: 32 | - targets: ['edumeet:8889'] 33 | -------------------------------------------------------------------------------- /server/config/config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "listeningPort" : "443", 3 | "listeningHost" : "host.domain.tld", 4 | 5 | "fileTracker" : "wss://tracker.openwebtorrent.com", 6 | 7 | "tls" : { 8 | "cert" : "./certs/edumeet-demo-cert.pem", 9 | "key" : "./certs/edumeet-demo-key.pem" 10 | }, 11 | 12 | "turnAPIKey" : "Your API key", 13 | "turnAPIURI" : "https://host.domain.tld/turn", 14 | 15 | "backupTurnServers" : { 16 | "urls": [ "turn:host.domain.tld:443?transport=tcp" ], 17 | "username" : "Your username", 18 | "credential" : "Your's credential" 19 | }, 20 | 21 | "redisOptions": { 22 | "host" : "127.0.0.1", 23 | "port" : "6379", 24 | "password" : "_REDIS_PASSWORD_" 25 | }, 26 | 27 | "prometheus" : { 28 | "enabled" : "true", 29 | "deidentify" : "true", 30 | "numeric" : "true", 31 | "listen" : "host.domain.tld" 32 | }, 33 | 34 | "mediasoup" : { 35 | "webRtcTransport" : { 36 | "listenIps" : [ 37 | { 38 | "ip": "PUBLIC_IP_ADDRESS", 39 | "announcedIp" : "" 40 | } 41 | ] 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/src/electron-starter.js: -------------------------------------------------------------------------------- 1 | const electron = require('electron'); 2 | 3 | const app = electron.app; 4 | 5 | const BrowserWindow = electron.BrowserWindow; 6 | const Menu = electron.Menu; 7 | 8 | const path = require('path'); 9 | const url = require('url'); 10 | 11 | let mainWindow; 12 | 13 | function createWindow() 14 | { 15 | mainWindow = new BrowserWindow({ 16 | width : 1280, 17 | height : 720, 18 | webPreferences : { nodeIntegration: true } 19 | }); 20 | 21 | Menu.setApplicationMenu(null); 22 | 23 | const startUrl = process.env.ELECTRON_START_URL || url.format({ 24 | pathname : path.join(__dirname, '/../build/index.html'), 25 | protocol : 'file:', 26 | slashes : true 27 | }); 28 | 29 | mainWindow.loadURL(startUrl); 30 | 31 | mainWindow.on('closed', () => 32 | { 33 | mainWindow = null; 34 | }); 35 | } 36 | 37 | app.on('ready', createWindow); 38 | 39 | app.on('window-all-closed', () => 40 | { 41 | if (process.platform !== 'darwin') 42 | { 43 | app.quit(); 44 | } 45 | }); 46 | 47 | app.on('activate', () => 48 | { 49 | if (mainWindow === null) 50 | { 51 | createWindow(); 52 | } 53 | }); 54 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 GÉANT Association 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/src/store/reducers/notifications.js: -------------------------------------------------------------------------------- 1 | const notifications = (state = [], action) => 2 | { 3 | switch (action.type) 4 | { 5 | case 'ADD_NOTIFICATION': 6 | { 7 | const { notification } = action.payload; 8 | 9 | notification.toBeClosed=false; 10 | 11 | return [ ...state, notification ]; 12 | } 13 | 14 | case 'ADD_CONSENT_NOTIFICATION': 15 | { 16 | const { notification } = action.payload; 17 | 18 | notification.toBeClosed=false; 19 | 20 | return [ ...state, notification ]; 21 | } 22 | 23 | case 'REMOVE_NOTIFICATION': 24 | { 25 | const { notificationId } = action.payload; 26 | 27 | return state.filter((notification) => notification.id !== notificationId); 28 | } 29 | 30 | case 'REMOVE_ALL_NOTIFICATIONS': 31 | { 32 | return []; 33 | } 34 | 35 | case 'CLOSE_NOTIFICATION': 36 | { 37 | const { notificationId } = action.payload; 38 | 39 | return (state.map((e) => 40 | { 41 | if (e.id === notificationId) 42 | { 43 | e.toBeClosed=true; 44 | } 45 | 46 | return e; 47 | })); 48 | } 49 | 50 | default: 51 | return state; 52 | } 53 | }; 54 | 55 | export default notifications; 56 | -------------------------------------------------------------------------------- /app/src/components/Controls/EditableInput.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { RIEInput } from 'riek'; 4 | 5 | export default class EditableInput extends React.Component 6 | { 7 | render() 8 | { 9 | const { 10 | value, 11 | propName, 12 | className, 13 | classLoading, 14 | classInvalid, 15 | editProps, 16 | onChange 17 | } = this.props; 18 | 19 | return ( 20 | onChange(data)} 29 | /> 30 | ); 31 | } 32 | 33 | shouldComponentUpdate(nextProps) 34 | { 35 | if (nextProps.value === this.props.value) 36 | return false; 37 | 38 | return true; 39 | } 40 | } 41 | 42 | EditableInput.propTypes = 43 | { 44 | value : PropTypes.string, 45 | propName : PropTypes.string.isRequired, 46 | className : PropTypes.string, 47 | classLoading : PropTypes.string, 48 | classInvalid : PropTypes.string, 49 | editProps : PropTypes.any, 50 | onChange : PropTypes.func.isRequired 51 | }; 52 | -------------------------------------------------------------------------------- /app/src/components/App.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, Suspense } from 'react'; 2 | import { useParams } from 'react-router'; 3 | import { connect } from 'react-redux'; 4 | import PropTypes from 'prop-types'; 5 | import JoinDialog from './JoinDialog'; 6 | import LoadingView from './Loader/LoadingView'; 7 | import { LazyPreload } from './Loader/LazyPreload'; 8 | 9 | const Room = LazyPreload(() => import(/* webpackChunkName: "room" */ './Room')); 10 | 11 | const App = (props) => 12 | { 13 | const { 14 | room 15 | } = props; 16 | 17 | const id = useParams().id.toLowerCase(); 18 | 19 | useEffect(() => 20 | { 21 | Room.preload(); 22 | 23 | return; 24 | }, []); 25 | 26 | if (!room.joined) 27 | { 28 | return ( 29 | 30 | ); 31 | } 32 | else 33 | { 34 | return ( 35 | }> 36 | 37 | 38 | ); 39 | } 40 | }; 41 | 42 | App.propTypes = 43 | { 44 | room : PropTypes.object.isRequired 45 | }; 46 | 47 | const mapStateToProps = (state) => 48 | ({ 49 | room : state.room 50 | }); 51 | 52 | export default connect( 53 | mapStateToProps, 54 | null, 55 | null, 56 | { 57 | areStatesEqual : (next, prev) => 58 | { 59 | return ( 60 | prev.room === next.room 61 | ); 62 | } 63 | } 64 | )(App); -------------------------------------------------------------------------------- /server/utils/gen-config-docs.ts: -------------------------------------------------------------------------------- 1 | import { configDocs } from '../lib/config/config'; 2 | import { writeFile } from 'fs/promises'; 3 | 4 | function formatJson(data) 5 | { 6 | return `\`${data.replace(/\n/g, '')}\``; 7 | } 8 | 9 | let data = `# ![edumeet logo](/app/public/images/logo.edumeet.svg) server configuration properties list: 10 | 11 | | Name | Description | Format | Default value | 12 | | :--- | :---------- | :----- | :------------ | 13 | `; 14 | 15 | Object.entries(configDocs).forEach((entry: [string, any]) => 16 | { 17 | const [ name, value ] = entry; 18 | 19 | // escape dynamically created default values 20 | switch (name) 21 | { 22 | case 'mediasoup.webRtcTransport.listenIps': 23 | value.default = '[ { "ip": "0.0.0.0", "announcedIp": null } ]'; 24 | break; 25 | case 'mediasoup.numWorkers': 26 | value.default = '4'; 27 | break; 28 | } 29 | 30 | data += `| ${name} | ${value.doc} | ${formatJson(value.format)} | \`${formatJson(value.default)}\` |\n`; 31 | }); 32 | 33 | data += ` 34 | 35 | --- 36 | 37 | *Document generated with:* \`yarn gen-config-docs\` 38 | `; 39 | 40 | writeFile('config/README.md', data).then(() => 41 | { 42 | console.log('done'); // eslint-disable-line 43 | }, (err) => 44 | { 45 | console.error(`Error writing file: ${err.message}`); // eslint-disable-line 46 | }); 47 | -------------------------------------------------------------------------------- /server/lib/access/perms.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // The role(s) have permission to lock/unlock a room 3 | CHANGE_ROOM_LOCK : 'CHANGE_ROOM_LOCK', 4 | // The role(s) have permission to promote a peer from the lobby 5 | PROMOTE_PEER : 'PROMOTE_PEER', 6 | // The role(s) have permission to give/remove other peers roles 7 | MODIFY_ROLE : 'MODIFY_ROLE', 8 | // The role(s) have permission to send chat messages 9 | SEND_CHAT : 'SEND_CHAT', 10 | // The role(s) have permission to moderate chat 11 | MODERATE_CHAT : 'MODERATE_CHAT', 12 | // The role(s) have permission to share audio 13 | SHARE_AUDIO : 'SHARE_AUDIO', 14 | // The role(s) have permission to share video 15 | SHARE_VIDEO : 'SHARE_VIDEO', 16 | // The role(s) have permission to share screen 17 | SHARE_SCREEN : 'SHARE_SCREEN', 18 | // The role(s) have permission to produce extra video 19 | EXTRA_VIDEO : 'EXTRA_VIDEO', 20 | // The role(s) have permission to share files 21 | SHARE_FILE : 'SHARE_FILE', 22 | // The role(s) have permission to moderate files 23 | MODERATE_FILES : 'MODERATE_FILES', 24 | // The role(s) have permission to moderate room (e.g. kick user) 25 | MODERATE_ROOM : 'MODERATE_ROOM', 26 | // The role(s) have permission to local record room 27 | LOCAL_RECORD_ROOM : 'LOCAL_RECORD_ROOM' 28 | }; -------------------------------------------------------------------------------- /app/src/permissions.js: -------------------------------------------------------------------------------- 1 | export const permissions = { 2 | // The role(s) have permission to lock/unlock a room 3 | CHANGE_ROOM_LOCK : 'CHANGE_ROOM_LOCK', 4 | // The role(s) have permission to promote a peer from the lobby 5 | PROMOTE_PEER : 'PROMOTE_PEER', 6 | // The role(s) have permission to give/remove other peers roles 7 | MODIFY_ROLE : 'MODIFY_ROLE', 8 | // The role(s) have permission to send chat messages 9 | SEND_CHAT : 'SEND_CHAT', 10 | // The role(s) have permission to moderate chat 11 | MODERATE_CHAT : 'MODERATE_CHAT', 12 | // The role(s) have permission to share audio 13 | SHARE_AUDIO : 'SHARE_AUDIO', 14 | // The role(s) have permission to share video 15 | SHARE_VIDEO : 'SHARE_VIDEO', 16 | // The role(s) have permission to share screen 17 | SHARE_SCREEN : 'SHARE_SCREEN', 18 | // The role(s) have permission to produce extra video 19 | EXTRA_VIDEO : 'EXTRA_VIDEO', 20 | // The role(s) have permission to share files 21 | SHARE_FILE : 'SHARE_FILE', 22 | // The role(s) have permission to moderate files 23 | MODERATE_FILES : 'MODERATE_FILES', 24 | // The role(s) have permission to moderate room (e.g. kick user) 25 | MODERATE_ROOM : 'MODERATE_ROOM', 26 | // The role(s) have permission to start room recording localy 27 | LOCAL_RECORD_ROOM : 'LOCAL_RECORD_ROOM' 28 | }; -------------------------------------------------------------------------------- /server/lib/logger/Logger.ts: -------------------------------------------------------------------------------- 1 | import debug from 'debug'; 2 | 3 | const APP_NAME = 'edumeet-server'; 4 | 5 | export default class Logger 6 | { 7 | private _debug: debug.Debugger; 8 | 9 | private _info: debug.Debugger; 10 | 11 | private _warn: debug.Debugger; 12 | 13 | private _error: debug.Debugger; 14 | 15 | constructor(prefix: string) 16 | { 17 | if (prefix) 18 | { 19 | this._debug = debug(`${APP_NAME}:${prefix}`); 20 | this._info = debug(`${APP_NAME}:INFO:${prefix}`); 21 | this._warn = debug(`${APP_NAME}:WARN:${prefix}`); 22 | this._error = debug(`${APP_NAME}:ERROR:${prefix}`); 23 | } 24 | else 25 | { 26 | this._debug = debug(APP_NAME); 27 | this._info = debug(`${APP_NAME}:INFO`); 28 | this._warn = debug(`${APP_NAME}:WARN`); 29 | this._error = debug(`${APP_NAME}:ERROR`); 30 | } 31 | 32 | /* eslint-disable no-console */ 33 | this._debug.log = console.info.bind(console); 34 | this._info.log = console.info.bind(console); 35 | this._warn.log = console.warn.bind(console); 36 | this._error.log = console.error.bind(console); 37 | /* eslint-enable no-console */ 38 | } 39 | 40 | get debug() 41 | { 42 | return this._debug; 43 | } 44 | 45 | get info() 46 | { 47 | return this._info; 48 | } 49 | 50 | get warn() 51 | { 52 | return this._warn; 53 | } 54 | 55 | get error() 56 | { 57 | return this._error; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /compose/README.md: -------------------------------------------------------------------------------- 1 | # Running the development environment 2 | 3 | Installing `docker-compose`: 4 | ```sh 5 | sudo curl -L "https://github.com/docker/compose/releases/download/1.28.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose 6 | chmod +x /usr/local/bin/docker-compose 7 | ``` 8 | 9 | Starting: 10 | 11 | ```sh 12 | CURRENT_USER=$UID:$GID docker-compose up --build -d 13 | 14 | docker-compose logs -f --tail=50 edumeet 15 | ``` 16 | 17 | Accessing endpoints: 18 | 19 | - Edumeet (app live dev): https://127.0.0.1:8443/ 20 | - Edumeet (app build): https://127.0.0.1:3443/ 21 | - Prometheus: http://127.0.0.1:9090/ 22 | - Grafana: http://127.0.0.1:9091/ (user:pass `admin`:`admin`) 23 | 24 | Note: to use the https://127.0.0.1:3443/ endpoint, visit first 25 | https://127.0.0.1:8443/ accepting the self-signed certificate exception valid 26 | also for the websocket connection. 27 | 28 | Rebuild the web application bundle: 29 | 30 | ```sh 31 | docker-compose exec -u $UID edumeet sh -c "cd app && yarn && yarn build" 32 | ``` 33 | 34 | ## Known issues 35 | 36 | The docker virtual network used by this compose configuration (`172.22.0.0/24`) 37 | should be reacheble from all the started services. If iptables is filtering the 38 | INPUT chain, this rule is required to make the services communicating with each other: 39 | 40 | ``` 41 | iptables -A INPUT --src 172.22.0.0/16 -j ACCEPT 42 | ``` 43 | -------------------------------------------------------------------------------- /munin/mm-plugin: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # -*- sh -*- 4 | 5 | : << =cut 6 | 7 | =head1 NAME 8 | 9 | turn - Plugin to monitor the turn server test probe. 10 | 11 | =head1 CONFIGURATION 12 | 13 | No configuration 14 | 15 | =head1 AUTHOR 16 | 17 | Unknown author 18 | 19 | =head1 LICENSE 20 | 21 | GPLv2 22 | 23 | =head1 MAGIC MARKERS 24 | 25 | #%# family=auto 26 | #%# capabilities=autoconf 27 | 28 | =cut 29 | 30 | . "$MUNIN_LIBDIR/plugins/plugin.sh" 31 | 32 | if [ "$1" = "autoconf" ]; then 33 | if [ -r /proc/sys/kernel/random/entropy_avail ]; then 34 | echo yes 35 | exit 0 36 | else 37 | echo no 38 | exit 0 39 | fi 40 | fi 41 | 42 | if [ "$1" = "config" ]; then 43 | echo 'graph_title MM stats' 44 | #echo 'graph_args --base 1000 -l 0' 45 | echo 'graph_vlabel Actual Session Count' 46 | echo 'graph_category other' 47 | echo 'graph_info This graph shows the mm stats.' 48 | echo 'rooms.label rooms' 49 | echo 'rooms.info The count of rooms.' 50 | echo 'peers.label peers' 51 | echo 'peers.info The count of peers.' 52 | exit 0 53 | fi 54 | 55 | ROOMS=`docker exec -t mm_mm_1 /opt/edumeet/server/connect.js --stats | grep 'rooms' | sed -r "s/\x1B\[([0-9]{1,2}(;[0-9]{1,2})?)?[m|K]//g" | sed -E 's/rooms:([0-9]+)/\1/g'` 56 | PEERS=`docker exec -t mm_mm_1 /opt/edumeet/server/connect.js --stats | grep 'peers' | sed -r "s/\x1B\[([0-9]{1,2}(;[0-9]{1,2})?)?[m|K]//g" | sed -E 's/peers:([0-9]+)/\1/g'` 57 | 58 | echo "rooms.value ${ROOMS}" 59 | echo "peers.value ${PEERS}" 60 | 61 | : 62 | -------------------------------------------------------------------------------- /app/src/components/PeerAudio/AudioPeers.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { micConsumerSelector } from '../../store/selectors'; 4 | import PropTypes from 'prop-types'; 5 | import PeerAudio from './PeerAudio'; 6 | 7 | const AudioPeers = (props) => 8 | { 9 | const { 10 | micConsumers, 11 | audioOutputDevice 12 | } = props; 13 | 14 | return ( 15 |
16 | { 17 | micConsumers.map((micConsumer) => 18 | { 19 | return ( 20 | 26 | ); 27 | }) 28 | } 29 |
30 | ); 31 | }; 32 | 33 | AudioPeers.propTypes = 34 | { 35 | micConsumers : PropTypes.array, 36 | audioOutputDevice : PropTypes.string 37 | }; 38 | 39 | const mapStateToProps = (state) => 40 | ({ 41 | micConsumers : micConsumerSelector(state), 42 | audioOutputDevice : state.settings.selectedAudioOutputDevice 43 | }); 44 | 45 | const AudioPeersContainer = connect( 46 | mapStateToProps, 47 | null, 48 | null, 49 | { 50 | areStatesEqual : (next, prev) => 51 | { 52 | return ( 53 | prev.consumers === next.consumers && 54 | prev.room.spotlights === next.room.spotlights && 55 | prev.settings.selectedAudioOutputDevice === 56 | next.settings.selectedAudioOutputDevice 57 | ); 58 | } 59 | } 60 | )(AudioPeers); 61 | 62 | export default AudioPeersContainer; 63 | -------------------------------------------------------------------------------- /app/src/store/reducers/lobbyPeers.js: -------------------------------------------------------------------------------- 1 | const lobbyPeer = (state = {}, action) => 2 | { 3 | switch (action.type) 4 | { 5 | case 'ADD_LOBBY_PEER': 6 | return { id: action.payload.peerId }; 7 | 8 | case 'SET_LOBBY_PEER_DISPLAY_NAME': 9 | return { ...state, displayName: action.payload.displayName }; 10 | case 'SET_LOBBY_PEER_PICTURE': 11 | return { ...state, picture: action.payload.picture }; 12 | case 'SET_LOBBY_PEER_PROMOTION_IN_PROGRESS': 13 | return { ...state, promotionInProgress: action.payload.flag }; 14 | 15 | default: 16 | return state; 17 | } 18 | }; 19 | 20 | const lobbyPeers = (state = {}, action) => 21 | { 22 | switch (action.type) 23 | { 24 | case 'ADD_LOBBY_PEER': 25 | { 26 | return { ...state, [action.payload.peerId]: lobbyPeer(undefined, action) }; 27 | } 28 | 29 | case 'REMOVE_LOBBY_PEER': 30 | { 31 | const { peerId } = action.payload; 32 | const newState = { ...state }; 33 | 34 | delete newState[peerId]; 35 | 36 | return newState; 37 | } 38 | 39 | case 'SET_LOBBY_PEER_DISPLAY_NAME': 40 | case 'SET_LOBBY_PEER_PICTURE': 41 | case 'SET_LOBBY_PEER_PROMOTION_IN_PROGRESS': 42 | { 43 | const oldLobbyPeer = state[action.payload.peerId]; 44 | 45 | if (!oldLobbyPeer) 46 | { 47 | // Tried to update non-existent lobbyPeer. Has probably been promoted, or left. 48 | return state; 49 | } 50 | 51 | return { ...state, [oldLobbyPeer.id]: lobbyPeer(oldLobbyPeer, action) }; 52 | } 53 | 54 | default: 55 | return state; 56 | } 57 | }; 58 | 59 | export default lobbyPeers; 60 | -------------------------------------------------------------------------------- /LTI/LTI.md: -------------------------------------------------------------------------------- 1 | # Learning Tools Interoperability (LTI) 2 | 3 | ## LTI 4 | 5 | Read more about IMS Global defined interface for tools like our VideoConference system integration with Learning Management Systems(LMS) (e.g. moodle). 6 | See: [IMS Global Learning Tool Interoperability](https://www.imsglobal.org/activity/learning-tools-interoperability) 7 | 8 | We implemented LTI interface version 1.0/1.1 9 | 10 | ### Server config auth section LTI settings 11 | 12 | Set in server configuration a random key and secret 13 | 14 | ``` json 15 | auth : 16 | { 17 | lti : 18 | { 19 | consumerKey : 'key', 20 | consumerSecret : 'secret' 21 | }, 22 | } 23 | ``` 24 | 25 | ### Configure your LMS system with secret and key settings above 26 | 27 | #### Auth tool URL 28 | 29 | Set tool URL to your server with path /auth/lti 30 | 31 | ``` url 32 | https://mm.example.com/auth/lti 33 | ``` 34 | 35 | #### In moodle find external tool plugin setting and external tool action 36 | 37 | See: [moodle external tool settings](https://docs.moodle.org/38/en/External_tool_settings) 38 | 39 | #### Add and activity 40 | 41 | ![Add external tool](lti1.png) 42 | 43 | #### Setup Activity 44 | 45 | ##### Activity setup basic form 46 | 47 | Open fully the settings **Click on show more!!** 48 | ![Add external tool config](lti2.png) 49 | 50 | ##### Empty full form 51 | 52 | ![Opened external tool config](lti3.png) 53 | 54 | ##### Filled out form 55 | 56 | ![Filled out external tool config](lti4.png) 57 | 58 | ## moodle plugin 59 | 60 | Alternatively you can use edumeet moodle plugin: 61 | [https://github.com/edumeet/moodle-mod_edumeet](https://github.com/edumeet/moodle-mod_edumeet) 62 | -------------------------------------------------------------------------------- /app/src/store/reducers/chat.js: -------------------------------------------------------------------------------- 1 | const initialState = 2 | { 3 | order : 'asc', 4 | isScrollEnd : true, 5 | messages : [], 6 | count : 0, 7 | countUnread : 0 8 | }; 9 | 10 | const chat = (state = initialState, action) => 11 | { 12 | switch (action.type) 13 | { 14 | case 'ADD_MESSAGE': 15 | { 16 | const { message } = action.payload; 17 | 18 | return { 19 | ...state, 20 | messages : [ ...state.messages, message ], 21 | count : state.count + 1, 22 | countUnread : message.sender === 'response' ? ++state.countUnread : state.countUnread 23 | }; 24 | 25 | } 26 | 27 | case 'ADD_CHAT_HISTORY': 28 | { 29 | const { chatHistory } = action.payload; 30 | 31 | chatHistory.forEach( 32 | (item, index) => { chatHistory[index].isRead = true; } 33 | ); 34 | 35 | return { 36 | ...state, 37 | messages : chatHistory, 38 | count : chatHistory.length 39 | }; 40 | } 41 | 42 | case 'CLEAR_CHAT': 43 | { 44 | return { 45 | ...state, 46 | messages : [], 47 | count : 0, 48 | countUnread : 0 49 | }; 50 | } 51 | 52 | case 'SORT_CHAT': 53 | { 54 | const { order } = action.payload; 55 | 56 | return { ...state, order: order }; 57 | } 58 | 59 | case 'SET_IS_SCROLL_END': 60 | { 61 | const { flag } = action.payload; 62 | 63 | return { ...state, isScrollEnd: flag }; 64 | } 65 | 66 | case 'SET_IS_MESSAGE_READ': 67 | { 68 | const { id, isRead } = action.payload; 69 | 70 | state.messages.forEach((key, index) => 71 | { 72 | if (state.messages[index].time === Number(id)) 73 | { 74 | state.messages[index].isRead = isRead; 75 | 76 | state.countUnread--; 77 | } 78 | }); 79 | 80 | return { ...state }; 81 | } 82 | 83 | default: 84 | return state; 85 | } 86 | }; 87 | 88 | export default chat; 89 | -------------------------------------------------------------------------------- /app/src/store/reducers/producers.js: -------------------------------------------------------------------------------- 1 | const initialState = {}; 2 | 3 | const producers = (state = initialState, action) => 4 | { 5 | switch (action.type) 6 | { 7 | case 'ADD_PRODUCER': 8 | { 9 | const { producer } = action.payload; 10 | 11 | return { ...state, [producer.id]: producer }; 12 | } 13 | 14 | case 'REMOVE_PRODUCER': 15 | { 16 | const { producerId } = action.payload; 17 | const newState = { ...state }; 18 | 19 | delete newState[producerId]; 20 | 21 | return newState; 22 | } 23 | 24 | case 'SET_PRODUCER_PAUSED': 25 | { 26 | const { producerId, originator } = action.payload; 27 | const producer = state[producerId]; 28 | 29 | let newProducer; 30 | 31 | if (originator === 'local') 32 | newProducer = { ...producer, locallyPaused: true }; 33 | else 34 | newProducer = { ...producer, remotelyPaused: true }; 35 | 36 | return { ...state, [producerId]: newProducer }; 37 | } 38 | 39 | case 'SET_PRODUCER_RESUMED': 40 | { 41 | const { producerId, originator } = action.payload; 42 | const producer = state[producerId]; 43 | 44 | let newProducer; 45 | 46 | if (originator === 'local') 47 | newProducer = { ...producer, locallyPaused: false }; 48 | else 49 | newProducer = { ...producer, remotelyPaused: false }; 50 | 51 | return { ...state, [producerId]: newProducer }; 52 | } 53 | 54 | case 'SET_PRODUCER_TRACK': 55 | { 56 | const { producerId, track } = action.payload; 57 | const producer = state[producerId]; 58 | const newProducer = { ...producer, track }; 59 | 60 | return { ...state, [producerId]: newProducer }; 61 | } 62 | 63 | case 'SET_PRODUCER_SCORE': 64 | { 65 | const { producerId, score } = action.payload; 66 | 67 | const producer = state[producerId]; 68 | 69 | const newProducer = { ...producer, score }; 70 | 71 | return { ...state, [producerId]: newProducer }; 72 | } 73 | 74 | default: 75 | return state; 76 | } 77 | }; 78 | 79 | export default producers; 80 | -------------------------------------------------------------------------------- /app/src/store/reducers/toolarea.js: -------------------------------------------------------------------------------- 1 | const initialState = 2 | { 3 | toolAreaOpen : false, 4 | currentToolTab : 'chat', // chat, settings, users 5 | unreadMessages : 0, 6 | unreadFiles : 0 7 | }; 8 | 9 | const toolarea = (state = initialState, action) => 10 | { 11 | switch (action.type) 12 | { 13 | case 'TOGGLE_TOOL_AREA': 14 | { 15 | const toolAreaOpen = !state.toolAreaOpen; 16 | const unreadMessages = toolAreaOpen && state.currentToolTab === 'chat' ? 0 : state.unreadMessages; 17 | const unreadFiles = toolAreaOpen && state.currentToolTab === 'chat' ? 0 : state.unreadFiles; 18 | 19 | return { ...state, toolAreaOpen, unreadMessages, unreadFiles }; 20 | } 21 | 22 | case 'OPEN_TOOL_AREA': 23 | { 24 | const toolAreaOpen = true; 25 | const unreadMessages = state.currentToolTab === 'chat' ? 0 : state.unreadMessages; 26 | const unreadFiles = state.currentToolTab === 'chat' ? 0 : state.unreadFiles; 27 | 28 | return { ...state, toolAreaOpen, unreadMessages, unreadFiles }; 29 | } 30 | 31 | case 'CLOSE_TOOL_AREA': 32 | { 33 | const toolAreaOpen = false; 34 | 35 | return { ...state, toolAreaOpen }; 36 | } 37 | 38 | case 'SET_TOOL_TAB': 39 | { 40 | const { toolTab } = action.payload; 41 | const unreadMessages = toolTab === 'chat' ? 0 : state.unreadMessages; 42 | const unreadFiles = toolTab === 'chat' ? 0 : state.unreadFiles; 43 | 44 | return { ...state, currentToolTab: toolTab, unreadMessages, unreadFiles }; 45 | } 46 | 47 | case 'ADD_MESSAGE': 48 | { 49 | if (state.toolAreaOpen && state.currentToolTab === 'chat') 50 | { 51 | return state; 52 | } 53 | 54 | return { ...state, unreadMessages: state.unreadMessages + 1 }; 55 | } 56 | 57 | case 'ADD_FILE': 58 | { 59 | if (state.toolAreaOpen && state.currentToolTab === 'chat') 60 | { 61 | return state; 62 | } 63 | 64 | return { ...state, unreadFiles: state.unreadFiles + 1 }; 65 | } 66 | 67 | default: 68 | return state; 69 | } 70 | }; 71 | 72 | export default toolarea; 73 | -------------------------------------------------------------------------------- /app/src/store/actions/consumerActions.js: -------------------------------------------------------------------------------- 1 | export const addConsumer = (consumer, peerId) => 2 | ({ 3 | type : 'ADD_CONSUMER', 4 | payload : { consumer, peerId } 5 | }); 6 | 7 | export const removeConsumer = (consumerId, peerId) => 8 | ({ 9 | type : 'REMOVE_CONSUMER', 10 | payload : { consumerId, peerId } 11 | }); 12 | 13 | export const clearConsumers = () => 14 | ({ 15 | type : 'CLEAR_CONSUMERS' 16 | }); 17 | 18 | export const setConsumerPaused = (consumerId, originator) => 19 | ({ 20 | type : 'SET_CONSUMER_PAUSED', 21 | payload : { consumerId, originator } 22 | }); 23 | 24 | export const setConsumerResumed = (consumerId, originator) => 25 | ({ 26 | type : 'SET_CONSUMER_RESUMED', 27 | payload : { consumerId, originator } 28 | }); 29 | 30 | export const setConsumerCurrentLayers = (consumerId, spatialLayer, temporalLayer) => 31 | ({ 32 | type : 'SET_CONSUMER_CURRENT_LAYERS', 33 | payload : { consumerId, spatialLayer, temporalLayer } 34 | }); 35 | 36 | export const setConsumerPreferredLayers = (consumerId, spatialLayer, temporalLayer) => 37 | ({ 38 | type : 'SET_CONSUMER_PREFERRED_LAYERS', 39 | payload : { consumerId, spatialLayer, temporalLayer } 40 | }); 41 | 42 | export const setConsumerPriority = (consumerId, priority) => 43 | ({ 44 | type : 'SET_CONSUMER_PRIORITY', 45 | payload : { consumerId, priority } 46 | }); 47 | 48 | export const setConsumerTrack = (consumerId, track) => 49 | ({ 50 | type : 'SET_CONSUMER_TRACK', 51 | payload : { consumerId, track } 52 | }); 53 | 54 | export const setConsumerScore = (consumerId, score) => 55 | ({ 56 | type : 'SET_CONSUMER_SCORE', 57 | payload : { consumerId, score } 58 | }); 59 | 60 | export const setConsumerAudioGain = (consumerId, audioGain) => 61 | ({ 62 | type : 'SET_CONSUMER_AUDIO_GAIN', 63 | payload : { consumerId, audioGain } 64 | }); 65 | 66 | export const setConsumerOpusConfig = (consumerId, opusConfig) => 67 | ({ 68 | type : 'SET_CONSUMER_OPUS_CONFIG', 69 | payload : { consumerId, opusConfig } 70 | }); 71 | -------------------------------------------------------------------------------- /compose/config/nginx.conf: -------------------------------------------------------------------------------- 1 | map $http_upgrade $connection_upgrade { 2 | default upgrade; 3 | '' close; 4 | } 5 | 6 | server { 7 | listen 80 default_server; 8 | listen [::]:80 default_server; 9 | listen 443 ssl http2 default_server; 10 | listen [::]:443 ssl http2 default_server; 11 | resolver 127.0.0.11:53 ipv6=off; 12 | # 13 | ssl_certificate /etc/nginx/cert.pem; 14 | ssl_certificate_key /etc/nginx/key.pem; 15 | ssl_ciphers EECDH+CHACHA20:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5; 16 | 17 | # server 18 | location ~ ^/(socket\.io) { 19 | proxy_pass https://edumeet:3443; 20 | # 21 | proxy_http_version 1.1; 22 | proxy_redirect off; 23 | proxy_request_buffering off; 24 | proxy_set_header Upgrade $http_upgrade; 25 | proxy_set_header Connection $connection_upgrade; 26 | proxy_read_timeout 86400; 27 | proxy_set_header Referer $http_referer; 28 | proxy_set_header Host $http_host; 29 | proxy_set_header X-Real-IP $remote_addr; 30 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 31 | proxy_set_header X-Forwarded-Proto $scheme; 32 | proxy_max_temp_file_size 2048m; 33 | } 34 | 35 | # app 36 | location ~ ^/(sockjs-node) { 37 | proxy_pass https://edumeet:4443; 38 | # 39 | proxy_http_version 1.1; 40 | proxy_redirect off; 41 | proxy_request_buffering off; 42 | proxy_set_header Upgrade $http_upgrade; 43 | proxy_set_header Connection $connection_upgrade; 44 | proxy_read_timeout 86400; 45 | proxy_set_header Referer $http_referer; 46 | proxy_set_header Host $http_host; 47 | proxy_set_header X-Real-IP $remote_addr; 48 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 49 | proxy_set_header X-Forwarded-Proto $scheme; 50 | proxy_max_temp_file_size 2048m; 51 | } 52 | 53 | location / { 54 | proxy_pass https://edumeet:4443; 55 | } 56 | } -------------------------------------------------------------------------------- /server/certs/edumeet-demo-cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFhTCCA22gAwIBAgIUKWAvYO7nVQFx8sxCwR4K8hlj/GEwDQYJKoZIhvcNAQEL 3 | BQAwUjELMAkGA1UEBhMCRVUxEDAOBgNVBAgMB2VkdW1lZXQxDTALBgNVBAcMBGRl 4 | bW8xEDAOBgNVBAoMB2VkdW1lZXQxEDAOBgNVBAMMB2VkdW1lZXQwHhcNMjIwMjI2 5 | MjIxNzQ0WhcNMjMwMjI2MjIxNzQ0WjBSMQswCQYDVQQGEwJFVTEQMA4GA1UECAwH 6 | ZWR1bWVldDENMAsGA1UEBwwEZGVtbzEQMA4GA1UECgwHZWR1bWVldDEQMA4GA1UE 7 | AwwHZWR1bWVldDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALNo8HRJ 8 | LVKW2ngMjAoFT6Dh1gP0eivscqHKEdc4LHWtoYjMpUNPbXH2E3+sc5NHTmYUm+e9 9 | /D+xJ3vF9EOB4W7IY2EROPeLF3mxSGizJMst3/p7rbOG+1qErODd1o21oy1fsJHS 10 | 1HB1udeYKRyTpsGrlc8faMGBnwkqaJm9SwuUU75sreTSdDwZEVGs49OSGZPKo42S 11 | J2gcYbEDUw/cUZRcueck/fN5Wx/05TWsMUKZ/kbsFgWrdfrZvMTfyrs0XgBTRUc0 12 | P9IdQAHRkOtFQZ18xAgsbJ/9KFgz429lb4BZrEZEjVOrhQ1Vq1H55rNb5+urFzFP 13 | xFBqJGA1I4I3D6pcP+RIphWEbDGWelBQ4L/PaLRIJw6ShdpEfTPZYjZro7kbMxJC 14 | JEoqrJChu0JO+l+N8qtLkpnghgrI8I5FfZKxDEuGHk6YnQWIeusY9PZ91gyFpy1S 15 | Y2Kq8e4mpuF/jO/857UXo2JQhGsRPItHvj9AeqmjjZr2dm1mIqWGTmPyNpLm5sWA 16 | bsFtLgBTKfpgErugh6KvK15jWVhl3i0Ls9C5feU2wU7dk61qH1RiOU8gBeZiYW+6 17 | kD8WSddd8PEiYHMZ0peMMJoFfbl+02uCLi4LACWzwl2Gm9JkUgL7vX0SOA96F2f+ 18 | +QyLLvIB+nhhMaitw0QvT+Hugab4yXBJBFvZAgMBAAGjUzBRMB0GA1UdDgQWBBQy 19 | a2ibEZ7zhfBF/KZNUrBO0wIIODAfBgNVHSMEGDAWgBQya2ibEZ7zhfBF/KZNUrBO 20 | 0wIIODAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4ICAQAKbNSY1dnm 21 | gj36y4+XJ2CC7o3JtY17sjCRpQxgj/vIoX72OyV5HYbkwGq9k4jJ00fIEuzojuGa 22 | i4ZuxYgVfdZG2W2/tI8H8G/JfNkolm2sOX8SUmAEbUsgCiT32Er/y9Asm0UY/sWA 23 | t6TPV0dxrcH58yHOU0Wgu2MPcaidBxc3MAE12MHoIOk4ioCrgvJQyCszJRBSTSd1 24 | UO6L1pMBzV6/ElxalU64lEO9/v25KBDAeI96g5jEYIEz9Wo1xLeflU3Ug6/XyytM 25 | 4egYwKZpHNKzUOTr1eq1tBbaRY4nBGNVR0MIwSskgahzCR1ieYPvSXGceme9a4Ig 26 | bXkFuOo+xEVFXCgdImK+TuDEhOlH/ioP8v9zUqwXHGWzO95gtY2lnPufFJjSvXL3 27 | BicYHBQ2G37rcv7cDLF3JuJRzn7PrWtBB2OOFvixQHP9H7ynOMf00myFUG8q4isC 28 | q1/ZP79/uEB3Aq+1cMUTaoDt7uFIqSTowQiOi6aInW6eqeToJETR0wVIGkOgieMC 29 | +qgS1lKQGqKNOgsuz4BQub/lr9jhOrmDMNGI82ZNNmRlLJgN8zxf5ZCnO7OF+WCr 30 | +9vzJ8MJa4gO9EAWWeJQi9hyrmLIY9mA1pOhPCcROXhc5eyz/Q9q2AWSZlmDeN3e 31 | UWbGZK1XT8BAIEcpJZczbYt/XJKnQ8K2HA== 32 | -----END CERTIFICATE----- 33 | -------------------------------------------------------------------------------- /app/src/components/ConfigError.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; // eslint-disable-line no-use-before-define 2 | import { withStyles } from '@material-ui/core/styles'; 3 | import PropTypes from 'prop-types'; 4 | import { FormattedMessage } from 'react-intl'; 5 | 6 | import Dialog from '@material-ui/core/Dialog'; 7 | import DialogTitle from '@material-ui/core/DialogTitle'; 8 | import DialogContent from '@material-ui/core/DialogContent'; 9 | import Grid from '@material-ui/core/Grid'; 10 | import ErrorIcon from '@material-ui/icons/Error'; 11 | import Button from '@material-ui/core/Button'; 12 | 13 | const styles = () => 14 | ({ 15 | error : { 16 | color : 'red' 17 | } 18 | }); 19 | 20 | const ConfigError = ({ 21 | classes, 22 | configError 23 | }: { 24 | classes : any; 25 | configError : string; 26 | }) => 27 | { 28 | return ( 29 | 36 | 37 | 38 | 42 | 43 | 44 | 48 | 49 | 50 |

{configError}

51 |
52 | 63 |
64 |
65 |
66 | ); 67 | }; 68 | 69 | ConfigError.propTypes = 70 | { 71 | classes : PropTypes.object.isRequired, 72 | configError : PropTypes.string.isRequired 73 | }; 74 | 75 | export default withStyles(styles)(ConfigError); 76 | -------------------------------------------------------------------------------- /server/lib/stats/promExporter.js: -------------------------------------------------------------------------------- 1 | import Logger from '../logger/Logger'; 2 | 3 | const express = require('express'); 4 | const promClient = require('prom-client'); 5 | 6 | const collectDefaultMetrics = require('../stats/metrics/default'); 7 | const RegisterAggregated = require('../stats/metrics/aggregated'); 8 | 9 | const logger = new Logger('promClient'); 10 | 11 | import { config } from '../config/config'; 12 | 13 | module.exports = async function(workers, rooms, peers) 14 | { 15 | try 16 | { 17 | logger.debug(`config.prometheus.deidentify=${config.prometheus.deidentify}`); 18 | logger.debug(`config.prometheus.listen=${config.prometheus.listen}`); 19 | logger.debug(`config.prometheus.numeric=${config.prometheus.numeric}`); 20 | logger.debug(`config.prometheus.port=${config.prometheus.port}`); 21 | logger.debug(`config.prometheus.quiet=${config.prometheus.quiet}`); 22 | 23 | const app = express(); 24 | 25 | // default register 26 | app.get('/', async (req, res) => 27 | { 28 | logger.debug(`GET ${req.originalUrl}`); 29 | const registry = new promClient.Registry(); 30 | 31 | await collectDefaultMetrics( 32 | workers, rooms, peers, registry, config.prometheus); 33 | res.set('Content-Type', registry.contentType); 34 | const data = await registry.metrics(); 35 | 36 | res.end(data); 37 | }); 38 | 39 | // aggregated register 40 | const registerAggregated = RegisterAggregated( 41 | workers, rooms, peers, config.prometheus); 42 | 43 | app.get('/metrics', async (req, res) => 44 | { 45 | logger.debug(`GET ${req.originalUrl}`); 46 | 47 | if (config.prometheus.secret 48 | && req.headers.authorization !== `Bearer ${ config.prometheus.secret}`) 49 | { 50 | logger.error('Invalid authorization header'); 51 | 52 | return res.status(401).end(); 53 | } 54 | 55 | res.set('Content-Type', registerAggregated.contentType); 56 | const data = await registerAggregated.metrics(); 57 | 58 | res.end(data); 59 | }); 60 | 61 | const server = app.listen(config.prometheus.port, config.prometheus.listen, () => 62 | { 63 | const address = server.address(); 64 | 65 | logger.info(`listening ${address.address}:${address.port}`); 66 | }); 67 | } 68 | catch (err) 69 | { 70 | logger.error(err); 71 | } 72 | }; 73 | -------------------------------------------------------------------------------- /app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | edumeet 17 | 18 | 19 | 20 | 21 | 57 | 58 | 59 | 60 |
61 | 62 | 63 | -------------------------------------------------------------------------------- /app/src/__tests__/App.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Provider } from 'react-redux'; 4 | import { Route, MemoryRouter } from 'react-router-dom'; 5 | import { act } from 'react-dom/test-utils'; 6 | import { createIntl, createIntlCache, RawIntlProvider } from 'react-intl'; 7 | import App from '../components/App'; 8 | import ChooseRoom from '../components/ChooseRoom'; 9 | import RoomContext from '../RoomContext'; 10 | 11 | import configureStore from 'redux-mock-store'; 12 | 13 | const mockStore = configureStore([]); 14 | 15 | let container; 16 | 17 | let store; 18 | 19 | let intl; 20 | 21 | const roomClient = {}; 22 | 23 | beforeEach(() => 24 | { 25 | container = document.createElement('div'); 26 | 27 | store = mockStore({ 28 | me : { 29 | displayNameInProgress : false, 30 | id : 'jesttester', 31 | loggedIn : false, 32 | loginEnabled : true 33 | }, 34 | room : { 35 | }, 36 | settings : { 37 | displayName : 'Jest Tester' 38 | } 39 | }); 40 | 41 | const cache = createIntlCache(); 42 | 43 | const locale = 'en'; 44 | 45 | intl = createIntl({ 46 | locale, 47 | messages : {} 48 | }, cache); 49 | 50 | document.body.appendChild(container); 51 | }); 52 | 53 | afterEach(() => 54 | { 55 | document.body.removeChild(container); 56 | container = null; 57 | }); 58 | 59 | describe('', () => 60 | { 61 | test('renders chooseroom', () => 62 | { 63 | act(() => 64 | { 65 | ReactDOM.render( 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | , 75 | container); 76 | }); 77 | }); 78 | }); 79 | 80 | describe('', () => 81 | { 82 | test('renders joindialog', () => 83 | { 84 | act(() => 85 | { 86 | ReactDOM.render( 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | , 96 | container); 97 | }); 98 | }); 99 | }); -------------------------------------------------------------------------------- /app/src/components/appPropTypes.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | export const Room = PropTypes.shape( 4 | { 5 | state : PropTypes.oneOf( 6 | [ 'new', 'connecting', 'connected', 'closed' ]).isRequired, 7 | activeSpeakerId : PropTypes.string 8 | }); 9 | 10 | export const Me = PropTypes.shape( 11 | { 12 | id : PropTypes.string.isRequired, 13 | canSendMic : PropTypes.bool.isRequired, 14 | canSendWebcam : PropTypes.bool.isRequired, 15 | webcamInProgress : PropTypes.bool.isRequired 16 | }); 17 | 18 | export const Producer = PropTypes.shape( 19 | { 20 | id : PropTypes.string.isRequired, 21 | source : PropTypes.oneOf([ 'mic', 'webcam', 'screen', 'extravideo' ]).isRequired, 22 | deviceLabel : PropTypes.string, 23 | type : PropTypes.oneOf([ 'front', 'back', 'screen', 'extravideo' ]), 24 | paused : PropTypes.bool.isRequired, 25 | track : PropTypes.any, 26 | codec : PropTypes.string.isRequired 27 | }); 28 | 29 | export const Peer = PropTypes.shape( 30 | { 31 | id : PropTypes.string.isRequired, 32 | displayName : PropTypes.string, 33 | consumers : PropTypes.arrayOf(PropTypes.string).isRequired 34 | }); 35 | 36 | export const Consumer = PropTypes.shape( 37 | { 38 | id : PropTypes.string.isRequired, 39 | peerId : PropTypes.string.isRequired, 40 | source : PropTypes.oneOf([ 'mic', 'webcam', 'screen', 'extravideo' ]).isRequired, 41 | locallyPaused : PropTypes.bool.isRequired, 42 | remotelyPaused : PropTypes.bool.isRequired, 43 | width : PropTypes.number, 44 | height : PropTypes.number, 45 | profile : PropTypes.oneOf([ 'none', 'default', 'low', 'medium', 'high' ]), 46 | track : PropTypes.any, 47 | codec : PropTypes.string 48 | }); 49 | 50 | export const Notification = PropTypes.shape( 51 | { 52 | id : PropTypes.string.isRequired, 53 | type : PropTypes.oneOf([ 'info', 'error' ]).isRequired, 54 | timeout : PropTypes.number 55 | }); 56 | 57 | export const Message = PropTypes.shape( 58 | { 59 | type : PropTypes.string, 60 | component : PropTypes.string, 61 | text : PropTypes.string, 62 | sender : PropTypes.string 63 | }); 64 | 65 | export const FileEntryProps = PropTypes.shape( 66 | { 67 | data : PropTypes.shape({ 68 | id : PropTypes.string.isRequired, 69 | picture : PropTypes.string, 70 | file : PropTypes.shape({ 71 | magnet : PropTypes.string.isRequired 72 | }).isRequired, 73 | me : PropTypes.bool 74 | }).isRequired, 75 | notify : PropTypes.func.isRequired 76 | }); -------------------------------------------------------------------------------- /app/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | const isLocalhost = Boolean( 2 | window.location.hostname === 'localhost' || 3 | window.location.hostname === '[::1]' || 4 | window.location.hostname.match( 5 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 6 | ) 7 | ); 8 | 9 | export function register(config) 10 | { 11 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) 12 | { 13 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 14 | 15 | if (publicUrl.origin !== window.location.origin) 16 | return; 17 | 18 | window.addEventListener('load', () => 19 | { 20 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 21 | 22 | if (isLocalhost) 23 | { 24 | checkValidServiceWorker(swUrl, config); 25 | 26 | navigator.serviceWorker.ready.then(() => 27 | {}); 28 | } 29 | else 30 | { 31 | registerValidSW(swUrl, config); 32 | } 33 | }); 34 | } 35 | } 36 | 37 | function registerValidSW(swUrl, config) 38 | { 39 | navigator.serviceWorker 40 | .register(swUrl) 41 | .then((registration) => 42 | { 43 | registration.onupdatefound = () => 44 | { 45 | const installingWorker = registration.installing; 46 | 47 | if (installingWorker == null) 48 | return; 49 | 50 | installingWorker.onstatechange = () => 51 | { 52 | if (installingWorker.state === 'installed') 53 | { 54 | if (navigator.serviceWorker.controller) 55 | { 56 | if (config && config.onUpdate) 57 | config.onUpdate(registration); 58 | } 59 | else if (config && config.onSuccess) 60 | config.onSuccess(registration); 61 | } 62 | }; 63 | }; 64 | }) 65 | .catch(() => 66 | {}); 67 | } 68 | 69 | function checkValidServiceWorker(swUrl, config) 70 | { 71 | fetch(swUrl) 72 | .then((response) => 73 | { 74 | const contentType = response.headers.get('content-type'); 75 | 76 | if (response.status === 404 || 77 | (contentType != null && contentType.indexOf('javascript') === -1)) 78 | { 79 | navigator.serviceWorker.ready.then((registration) => 80 | { 81 | registration.unregister().then(() => 82 | { 83 | window.location.reload(); 84 | }); 85 | }); 86 | } 87 | else 88 | { 89 | registerValidSW(swUrl, config); 90 | } 91 | }) 92 | .catch(() => 93 | {}); 94 | } 95 | 96 | export function unregister() 97 | { 98 | if ('serviceWorker' in navigator) 99 | { 100 | navigator.serviceWorker.ready.then((registration) => 101 | { 102 | registration.unregister(); 103 | }); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /prom.md: -------------------------------------------------------------------------------- 1 | # Prometheus exporter 2 | 3 | The goal of this version is to offer a few basic metrics for 4 | initial testing. The set of supported metrics can be extended. 5 | 6 | The current implementation is partly 7 | [unconventional](https://prometheus.io/docs/instrumenting/writing_exporters) 8 | in that it creates new metrics each time but does not register a 9 | custom collector. Reasons are that the exporter should 10 | [clear out metrics](https://github.com/prometheus/client_python/issues/182) 11 | for closed connections but that `prom-client` 12 | [does not yet support](https://github.com/siimon/prom-client/issues/241) 13 | custom collectors. 14 | 15 | This version has been ported from an earlier Python version that was not part 16 | of `edumeet` but connected as an interactive client. 17 | 18 | ## Configuration 19 | 20 | See `prometheus` in `server/config/config.example.js` for options and 21 | applicable defaults. 22 | 23 | If `edumeet` was installed with 24 | [`mm-absible`](https://github.com/edumeet/edumeet-ansible) 25 | it may be necessary to open the `iptables` firewall for incoming TCP traffic 26 | on the allocated port (see `/etc/ferm/ferm.conf`). 27 | 28 | ## Metrics 29 | 30 | | metric | value | 31 | |--------|-------| 32 | | `edumeet_peers`| | 33 | | `edumeet_rooms`| | 34 | | `mediasoup_consumer_byte_count_bytes`| [`byteCount`](https://mediasoup.org/documentation/v3/mediasoup/rtc-statistics/#Consumer-Statistics) | 35 | | `mediasoup_consumer_score`| [`score`](https://mediasoup.org/documentation/v3/mediasoup/rtc-statistics/#Consumer-Statistics) | 36 | | `mediasoup_producer_byte_count_bytes`| [`byteCount`](https://mediasoup.org/documentation/v3/mediasoup/rtc-statistics/#Producer-Statistics) | 37 | | `mediasoup_producer_score`| [`score`](https://mediasoup.org/documentation/v3/mediasoup/rtc-statistics/#Producer-Statistics) | 38 | 39 | ## Architecture 40 | 41 | ``` 42 | +-----------+ +---------------------------------------------+ 43 | | workers | | server observer API | 44 | | | sock | +------o------+----o-----+ 45 | | +------+ | int. server | exporter | 46 | | | | | | | 47 | | mediasoup | | express socket.io | net | express | 48 | +-----+-----+ +----+---------+-----+-----+-------+-----+----+ 49 | ^ min-max ^ 443 ^ 443 ^ sock ^ 8889 50 | | RTP | HTTPS | ws | | HTTP 51 | | | | | | 52 | | +-+---------+-+ +------+------+ +---+--------+ 53 | +---------------+ app | | int. client | | Prometheus | 54 | +-------------+ +-------------+ +------------+ 55 | ``` 56 | -------------------------------------------------------------------------------- /app/src/components/FullScreen.js: -------------------------------------------------------------------------------- 1 | const key = { 2 | fullscreenEnabled : 0, 3 | fullscreenElement : 1, 4 | requestFullscreen : 2, 5 | exitFullscreen : 3, 6 | fullscreenchange : 4, 7 | fullscreenerror : 5 8 | }; 9 | 10 | const webkit = [ 11 | 'webkitFullscreenEnabled', 12 | 'webkitFullscreenElement', 13 | 'webkitRequestFullscreen', 14 | 'webkitExitFullscreen', 15 | 'webkitfullscreenchange', 16 | 'webkitfullscreenerror' 17 | ]; 18 | 19 | const moz = [ 20 | 'mozFullScreenEnabled', 21 | 'mozFullScreenElement', 22 | 'mozRequestFullScreen', 23 | 'mozCancelFullScreen', 24 | 'mozfullscreenchange', 25 | 'mozfullscreenerror' 26 | ]; 27 | 28 | const ms = [ 29 | 'msFullscreenEnabled', 30 | 'msFullscreenElement', 31 | 'msRequestFullscreen', 32 | 'msExitFullscreen', 33 | 'MSFullscreenChange', 34 | 'MSFullscreenError' 35 | ]; 36 | 37 | export default class FullScreen 38 | { 39 | constructor(document) 40 | { 41 | this.document = document; 42 | this.vendor = ( 43 | ('fullscreenEnabled' in this.document && Object.keys(key)) || 44 | (webkit[0] in this.document && webkit) || 45 | (moz[0] in this.document && moz) || 46 | (ms[0] in this.document && ms) || 47 | [] 48 | ); 49 | } 50 | 51 | requestFullscreen(element) 52 | { 53 | element[this.vendor[key.requestFullscreen]](); 54 | } 55 | 56 | requestFullscreenFunction(element) 57 | { 58 | // eslint-disable-next-line 59 | element[this.vendor[key.requestFullscreen]]; 60 | } 61 | 62 | addEventListener(type, handler) 63 | { 64 | this.document.addEventListener(this.vendor[key[type]], handler); 65 | } 66 | 67 | removeEventListener(type, handler) 68 | { 69 | this.document.removeEventListener(this.vendor[key[type]], handler); 70 | } 71 | 72 | get exitFullscreen() 73 | { 74 | return this.document[this.vendor[key.exitFullscreen]].bind(this.document); 75 | } 76 | 77 | get fullscreenEnabled() 78 | { 79 | return Boolean(this.document[this.vendor[key.fullscreenEnabled]]); 80 | } 81 | set fullscreenEnabled(val) {} 82 | 83 | get fullscreenElement() 84 | { 85 | return this.document[this.vendor[key.fullscreenElement]]; 86 | } 87 | set fullscreenElement(val) {} 88 | 89 | get onfullscreenchange() 90 | { 91 | return this.document[`on${this.vendor[key.fullscreenchange]}`.toLowerCase()]; 92 | } 93 | 94 | set onfullscreenchange(handler) 95 | { 96 | this.document[`on${this.vendor[key.fullscreenchange]}`.toLowerCase()] = handler; 97 | } 98 | 99 | get onfullscreenerror() 100 | { 101 | return this.document[`on${this.vendor[key.fullscreenerror]}`.toLowerCase()]; 102 | } 103 | 104 | set onfullscreenerror(handler) 105 | { 106 | this.document[`on${this.vendor[key.fullscreenerror]}`.toLowerCase()] = handler; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /app/src/components/VideoWindow/VideoWindow.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import NewWindow from './NewWindow'; 4 | import PropTypes from 'prop-types'; 5 | import * as appPropTypes from '../appPropTypes'; 6 | import * as roomActions from '../../store/actions/roomActions'; 7 | import FullView from '../VideoContainers/FullView'; 8 | 9 | const VideoWindow = (props) => 10 | { 11 | const { 12 | advancedMode, 13 | consumer, 14 | aspectRatio, 15 | toggleConsumerWindow 16 | } = props; 17 | 18 | if (!consumer) 19 | return null; 20 | 21 | const consumerVisible = ( 22 | Boolean(consumer) && 23 | !consumer.locallyPaused && 24 | !consumer.remotelyPaused 25 | ); 26 | 27 | return ( 28 | 29 | 51 | 52 | ); 53 | }; 54 | 55 | VideoWindow.propTypes = 56 | { 57 | advancedMode : PropTypes.bool, 58 | consumer : appPropTypes.Consumer, 59 | aspectRatio : PropTypes.number.isRequired, 60 | toggleConsumerWindow : PropTypes.func.isRequired 61 | }; 62 | 63 | const mapStateToProps = (state) => 64 | { 65 | return { 66 | consumer : state.consumers[state.room.windowConsumer], 67 | aspectRatio : state.settings.aspectRatio 68 | }; 69 | }; 70 | 71 | const mapDispatchToProps = (dispatch) => 72 | { 73 | return { 74 | toggleConsumerWindow : () => 75 | { 76 | dispatch(roomActions.toggleConsumerWindow()); 77 | } 78 | }; 79 | }; 80 | 81 | const VideoWindowContainer = connect( 82 | mapStateToProps, 83 | mapDispatchToProps, 84 | null, 85 | { 86 | areStatesEqual : (next, prev) => 87 | { 88 | return ( 89 | prev.consumers[prev.room.windowConsumer] === 90 | next.consumers[next.room.windowConsumer] && 91 | prev.settings.aspectRatio === next.settings.aspectRatio 92 | ); 93 | } 94 | } 95 | )(VideoWindow); 96 | 97 | export default VideoWindowContainer; 98 | -------------------------------------------------------------------------------- /app/src/images/buddy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | 20 | 39 | 41 | 42 | 44 | image/svg+xml 45 | 47 | 48 | 49 | 50 | 51 | 56 | 63 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /app/src/store/actions/meActions.js: -------------------------------------------------------------------------------- 1 | export const setMe = ({ peerId, loginEnabled }) => 2 | ({ 3 | type : 'SET_ME', 4 | payload : { peerId, loginEnabled } 5 | }); 6 | 7 | export const setBrowser = (browser) => 8 | ({ 9 | type : 'SET_BROWSER', 10 | payload : { browser } 11 | }); 12 | 13 | export const loggedIn = (flag) => 14 | ({ 15 | type : 'LOGGED_IN', 16 | payload : { flag } 17 | }); 18 | 19 | export const addRole = (roleId) => 20 | ({ 21 | type : 'ADD_ROLE', 22 | payload : { roleId } 23 | }); 24 | 25 | export const removeRole = (roleId) => 26 | ({ 27 | type : 'REMOVE_ROLE', 28 | payload : { roleId } 29 | }); 30 | 31 | export const setPicture = (picture) => 32 | ({ 33 | type : 'SET_PICTURE', 34 | payload : { picture } 35 | }); 36 | 37 | export const setMediaCapabilities = ({ 38 | canSendMic, 39 | canSendWebcam, 40 | canShareScreen, 41 | canShareFiles 42 | }) => 43 | ({ 44 | type : 'SET_MEDIA_CAPABILITIES', 45 | payload : { canSendMic, canSendWebcam, canShareScreen, canShareFiles } 46 | }); 47 | 48 | export const setAudioDevices = (devices) => 49 | ({ 50 | type : 'SET_AUDIO_DEVICES', 51 | payload : { devices } 52 | }); 53 | 54 | export const setAudioOutputDevices = (devices) => 55 | ({ 56 | type : 'SET_AUDIO_OUTPUT_DEVICES', 57 | payload : { devices } 58 | }); 59 | 60 | export const setWebcamDevices = (devices) => 61 | ({ 62 | type : 'SET_WEBCAM_DEVICES', 63 | payload : { devices } 64 | }); 65 | 66 | export const setRaisedHand = (flag) => 67 | ({ 68 | type : 'SET_RAISED_HAND', 69 | payload : { flag } 70 | }); 71 | 72 | export const setAudioInProgress = (flag) => 73 | ({ 74 | type : 'SET_AUDIO_IN_PROGRESS', 75 | payload : { flag } 76 | }); 77 | 78 | export const setAudioOutputInProgress = (flag) => 79 | ({ 80 | type : 'SET_AUDIO_OUTPUT_IN_PROGRESS', 81 | payload : { flag } 82 | }); 83 | 84 | export const setWebcamInProgress = (flag) => 85 | ({ 86 | type : 'SET_WEBCAM_IN_PROGRESS', 87 | payload : { flag } 88 | }); 89 | 90 | export const setScreenShareInProgress = (flag) => 91 | ({ 92 | type : 'SET_SCREEN_SHARE_IN_PROGRESS', 93 | payload : { flag } 94 | }); 95 | 96 | export const setRaisedHandInProgress = (flag) => 97 | ({ 98 | type : 'SET_RAISED_HAND_IN_PROGRESS', 99 | payload : { flag } 100 | }); 101 | 102 | export const setDisplayNameInProgress = (flag) => 103 | ({ 104 | type : 'SET_DISPLAY_NAME_IN_PROGRESS', 105 | payload : { flag } 106 | }); 107 | 108 | export const setIsSpeaking = (flag) => 109 | ({ 110 | type : 'SET_IS_SPEAKING', 111 | payload : { flag } 112 | }); 113 | 114 | export const setAutoMuted = (flag) => 115 | ({ 116 | type : 'SET_AUTO_MUTED', 117 | payload : { flag } 118 | }); 119 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "edumeet-server", 3 | "version": "3.5.1", 4 | "private": true, 5 | "description": "edumeet server", 6 | "author": "Håvar Aambø Fosstveit ", 7 | "contributors": [ 8 | "Stefan Otto", 9 | "Mészáros Mihály", 10 | "Roman Drozd", 11 | "Rémai Gábor László", 12 | "Piotr Pawałowski" 13 | ], 14 | "license": "MIT", 15 | "main": "lib/index.js", 16 | "scripts": { 17 | "start": "node dist/server.js", 18 | "build": "mkdir -p dist && find dist/* -maxdepth 0 ! -name public -exec rm -rf {} \\; && tsc && ln -s ../certs dist/certs && chmod 755 dist/server.js && ( for fileExt in yaml json toml ; do [ -f config/config.$fileExt ] && cp config/config.$fileExt dist/config/; done ) | true && touch 'dist/ __AUTO_GENERATED_CONTENT_REFRESHED_AFTER_REBUILDING!__ '", 19 | "dev": "nodemon --exec ts-node --ignore dist/ -e js,ts server.js", 20 | "connect": "ts-node connect.js", 21 | "lint": "eslint ./ --ext .js,.ts; exit 0", 22 | "lint-fix": "eslint ./ --fix --ext .js,.ts; exit 0", 23 | "gen-config-docs": "ts-node utils/gen-config-docs.ts" 24 | }, 25 | "dependencies": { 26 | "awaitqueue": "^1.0.0", 27 | "axios": "^0.21.1", 28 | "base-64": "^0.1.0", 29 | "bcrypt": "^5.0.1", 30 | "body-parser": "^1.19.0", 31 | "colors": "^1.4.0", 32 | "compression": "^1.7.4", 33 | "connect-redis": "^4.0.3", 34 | "convict": "^6.1.0", 35 | "convict-format-with-validator": "^6.0.1", 36 | "cookie-parser": "^1.4.4", 37 | "debug": "^4.3.1", 38 | "express": "^4.17.1", 39 | "express-session": "^1.17.0", 40 | "express-socket.io-session": "^1.3.5", 41 | "fast-stats": "^0.0.6", 42 | "helmet": "^3.21.2", 43 | "ims-lti": "^3.0.2", 44 | "json5": "^2.2.0", 45 | "jsonwebtoken": "^8.5.1", 46 | "mediasoup": "3.9.6", 47 | "openid-client": "^3.7.3", 48 | "passport": "^0.4.0", 49 | "passport-local": "^1.0.0", 50 | "passport-lti": "0.0.7", 51 | "passport-saml": "^3.1.0", 52 | "pidusage": "^2.0.21", 53 | "prom-client": "^13.1.0", 54 | "redis": "v3", 55 | "socket.io": "^2.4.0", 56 | "spdy": "^4.0.2", 57 | "toml": "^3.0.0", 58 | "uuid": "^7.0.2", 59 | "yaml": "^1.10.2" 60 | }, 61 | "devDependencies": { 62 | "@types/base-64": "^0.1.3", 63 | "@types/body-parser": "^1.19.0", 64 | "@types/compression": "^1.7.0", 65 | "@types/connect-redis": "^0.0.18", 66 | "@types/cookie-parser": "^1.4.2", 67 | "@types/debug": "^4.1.5", 68 | "@types/express": "^4.17.11", 69 | "@types/express-session": "^1.17.3", 70 | "@types/jsonwebtoken": "^8.5.1", 71 | "@types/node": "^14.14.37", 72 | "@types/passport": "^1.0.6", 73 | "@types/passport-local": "^1.0.33", 74 | "@types/uuid": "^8.3.0", 75 | "@typescript-eslint/eslint-plugin": "^4.21.0", 76 | "@typescript-eslint/parser": "^4.21.0", 77 | "eslint": "6.8.0", 78 | "eslint-plugin-import": "^2.22.1", 79 | "ts-node": "^9.1.1", 80 | "typescript": "^4.2.4" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /app/src/components/PeerAudio/PeerAudio.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | export default class PeerAudio extends React.PureComponent 5 | { 6 | constructor(props) 7 | { 8 | super(props); 9 | 10 | // Latest received audio track. 11 | // @type {MediaStreamTrack} 12 | this._audioTrack = null; 13 | this._audioOutputDevice = null; 14 | this._gainNode = null; 15 | } 16 | 17 | render() 18 | { 19 | return ( 20 |