diff --git a/bin/quickstart.sh b/bin/quickstart.sh --- a/bin/quickstart.sh +++ b/bin/quickstart.sh @@ -51,11 +51,11 @@ docker pull docker.io/kolab/centos7:latest docker-compose down --remove-orphans -docker-compose build coturn kolab mariadb openvidu kurento-media-server pdns-sql proxy redis nginx +docker-compose build coturn kolab mariadb meet pdns-sql proxy redis nginx bin/regen-certs -docker-compose up -d coturn kolab mariadb openvidu kurento-media-server pdns-sql proxy redis +docker-compose up -d coturn kolab mariadb meet pdns-sql proxy redis pushd ${base_dir}/src/ diff --git a/docker-compose.openvidu.yml b/docker-compose.openvidu.yml deleted file mode 100644 --- a/docker-compose.openvidu.yml +++ /dev/null @@ -1,27 +0,0 @@ -version: '3' -services: - kurento-media-server2: - build: - context: ./docker/kurento-media-server/ - container_name: kolab-kurento-media-server2 - environment: - - GST_DEBUG=3,Kurento*:4,kms*:4,sdp*:4,webrtc*:4,*rtpendpoint:4,rtp*handler:4,rtpsynchronizer:4,agnosticbin:4 - - KMS_PORT=8889 - hostname: kurento-media-server.hosted.com - image: apheleia/kurento-media-server:6.15.0 - network_mode: host - openvidu: - build: - context: ./docker/openvidu-dev/ - privileged: true - container_name: kolab-openvidu - depends_on: - - kurento-media-server - - kurento-media-server2 - environment: - - KMS_URIS=["ws://localhost:8888/kurento"] - #- KMS_URIS=["ws://localhost:8888/kurento", "ws://localhost:8889/kurento"] - volumes: - - /etc/letsencrypt/:/etc/letsencrypt/:ro - - ~/src/openvidu:/src/openvidu/:ro - - ./docker/openvidu-dev/build/.m2:/root/.m2/ diff --git a/docker-compose.yml b/docker-compose.yml --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,8 @@ version: '3' services: coturn: + build: + context: ./docker/coturn/ container_name: kolab-coturn healthcheck: interval: 10s @@ -8,13 +10,11 @@ timeout: 5s retries: 30 environment: - - DB_NAME=${OPENVIDU_COTURN_REDIS_DATABASE} - - DB_PASSWORD=${OPENVIDU_COTURN_REDIS_PASSWORD} - - REDIS_IP=${OPENVIDU_COTURN_REDIS_IP} - - TURN_PUBLIC_IP=${OPENVIDU_COTURN_IP} + - TURN_PUBLIC_IP=${COTURN_PUBLIC_IP} - TURN_LISTEN_PORT=3478 + - TURN_STATIC_SECRET==${COTURN_STATIC_SECRET} hostname: sturn.mgmt.com - image: openvidu/openvidu-coturn:1.0.0 + image: kolab-coturn network_mode: host restart: on-failure tty: true @@ -55,15 +55,6 @@ - ./docker/kolab/utils:/root/utils:ro - ./src/.env:/.dockerenv:ro - /sys/fs/cgroup:/sys/fs/cgroup:ro - kurento-media-server: - build: - context: ./docker/kurento-media-server/ - container_name: kolab-kurento-media-server - environment: - - GST_DEBUG=3,Kurento*:4,kms*:4,sdp*:4,webrtc*:4,*rtpendpoint:4,rtp*handler:4,rtpsynchronizer:4,agnosticbin:4 - hostname: kurento-media-server.hosted.com - image: apheleia/kurento-media-server:6.15.0 - network_mode: host mariadb: container_name: kolab-mariadb environment: @@ -99,39 +90,6 @@ volumes: - ./docker/certs/imap.hosted.com.cert:/etc/pki/tls/certs/imap.hosted.com.cert - ./docker/certs/imap.hosted.com.key:/etc/pki/tls/private/imap.hosted.com.key - openvidu: - build: - context: ./docker/openvidu/ - container_name: kolab-openvidu - depends_on: - - kurento-media-server - environment: - - APP_DOMAIN=${APP_DOMAIN} - - CERTIFICATE_TYPE=letsencrypt - - COTURN_IP=${OPENVIDU_COTURN_IP} - - COTURN_REDIS_DBNAME=${OPENVIDU_COTURN_REDIS_DATABASE} - - COTURN_REDIS_PASSWORD=${OPENVIDU_COTURN_REDIS_PASSWORD} - - COTURN_REDIS_IP=${OPENVIDU_COTURN_REDIS_IP} - - DOMAIN_OR_PUBLIC_IP=${OPENVIDU_PUBLIC_IP} - - SERVER_PORT=${OPENVIDU_SERVER_PORT} - - KMS_STUN_IP=${OPENVIDU_COTURN_IP} - - KMS_STUN_PORT=3478 - - KMS_URIS=["ws://localhost:8888/kurento", "ws://localhost:8889/kurento"] - - OPENVIDU_SECRET=${OPENVIDU_API_PASSWORD} - - OPENVIDU_WEBHOOK=${OPENVIDU_WEBHOOK} - - OPENVIDU_WEBHOOK_ENDPOINT=${OPENVIDU_WEBHOOK_ENDPOINT} - - SERVER_SSL_ENABLED=false - hostname: openvidu.hosted.com - image: apheleia/openvidu:2.18.0 - network_mode: host - tmpfs: - - /run - - /tmp - - /var/run - - /var/tmp - tty: true - volumes: - - /etc/letsencrypt/:/etc/letsencrypt/:ro pdns-sql: build: context: ./docker/pdns-sql/ @@ -208,3 +166,31 @@ volumes: - ./src:/home/worker/src.orig:ro - /sys/fs/cgroup:/sys/fs/cgroup:ro + meet: + build: + context: ./docker/meet/ + healthcheck: + interval: 10s + test: "curl --insecure -H 'X-AUTH-TOKEN: ${MEET_SERVER_TOKEN}' --fail https://localhost:12443/meetmedia/api/health || exit 1" + timeout: 5s + retries: 30 + environment: + - WEBRTC_LISTEN_IP=${MEET_WEBRTC_LISTEN_IP:?err} + - PUBLIC_DOMAIN=${MEET_PUBLIC_DOMAIN:?err} + - LISTENING_HOST=0.0.0.0 + - LISTENING_PORT=12443 + - TURN_SERVER=${MEET_TURN_SERVER} + - TURN_STATIC_SECRET=${COTURN_STATIC_SECRET} + - AUTH_TOKEN=${MEET_SERVER_TOKEN:?err} + - WEBHOOK_TOKEN=${MEET_WEBHOOK_TOKEN:?err} + - WEBHOOK_URL=${APP_PUBLIC_URL:?err}/api/webhooks/meet + - SSL_CERT=/etc/pki/tls/certs/meet.${APP_WEBSITE_DOMAIN:?err}.cert + - SSL_KEY=/etc/pki/tls/private/meet.${APP_WEBSITE_DOMAIN:?err}.key + network_mode: host + container_name: kolab-meet + image: kolab-meet + volumes: + - ./meet/server:/src/meet/:ro + - ./docker/meet/build/node_modules:/root/node_modules + - ./docker/certs/meet.${APP_WEBSITE_DOMAIN}.cert:/etc/pki/tls/certs/meet.${APP_WEBSITE_DOMAIN}.cert + - ./docker/certs/meet.${APP_WEBSITE_DOMAIN}.key:/etc/pki/tls/private/meet.${APP_WEBSITE_DOMAIN}.key diff --git a/docker/coturn/Dockerfile b/docker/coturn/Dockerfile --- a/docker/coturn/Dockerfile +++ b/docker/coturn/Dockerfile @@ -1,36 +1,10 @@ -FROM fedora:31 +FROM fedora:34 MAINTAINER Jeroen van Meeuwen RUN dnf -y install \ --setopt 'tsflags=nodocs' \ - bash-completion \ - bind-utils \ - coturn \ - curl \ - dhcp-client \ - iproute \ - iptraf-ng \ - iputils \ - less \ - lsof \ - mtr \ - net-tools \ - NetworkManager \ - NetworkManager-tui \ - network-scripts \ - nmap-ncat \ - openssh-clients \ - openssh-server \ - procps-ng \ - redis \ - strace \ - systemd-udev \ - tcpdump \ - telnet \ - traceroute \ - vim-enhanced \ - wget && \ + coturn && \ dnf clean all COPY rootfs/ / diff --git a/docker/coturn/rootfs/usr/local/bin/coturn.sh b/docker/coturn/rootfs/usr/local/bin/coturn.sh --- a/docker/coturn/rootfs/usr/local/bin/coturn.sh +++ b/docker/coturn/rootfs/usr/local/bin/coturn.sh @@ -6,14 +6,25 @@ external-ip=${TURN_PUBLIC_IP:-127.0.0.1} listening-port=${TURN_LISTEN_PORT:-3478} fingerprint -lt-cred-mech + +# For testing +#allow-loopback-peers +#cli-password=qwerty + +# Disabled by default to avoid DoS attacks. Logs all bind attempts in verbose log mode (useful for debugging) +#log-binding + max-port=${MAX_PORT:-65535} min-port=${MIN_PORT:-40000} pidfile="$(pwd)/turnserver.pid" -realm=openvidu -simple-log -redis-userdb="ip=${REDIS_IP:-127.0.0.1} dbname=${DB_NAME:-2} password=${DB_PASSWORD:-turn} connect_timeout=30" -verbose +realm=kolabmeet +log-file=stdout + +# Dynamically generate username/password for turn +use-auth-secret +static-auth-secret=${TURN_STATIC_SECRET:-uzYguvIl9tpZFMuQOE78DpOi6Jc7VFSD0UAnvgMsg5n4e74MgIf6vQvbc6LWzZjz} + +# verbose EOF /usr/bin/turnserver -c ./turnserver.conf diff --git a/docker/meet/.gitignore b/docker/meet/.gitignore new file mode 100644 --- /dev/null +++ b/docker/meet/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/docker/meet/Dockerfile b/docker/meet/Dockerfile new file mode 100644 --- /dev/null +++ b/docker/meet/Dockerfile @@ -0,0 +1,11 @@ +FROM fedora:34 + +MAINTAINER Jeroen van Meeuwen + +RUN dnf -y install \ + --setopt 'tsflags=nodocs' \ + npm nodejs python3 python3-pip meson ninja-build make gcc g++ && \ + dnf clean all + +COPY init.sh /init.sh +CMD [ "/init.sh" ] diff --git a/docker/meet/build/node_modules/.gitkeep b/docker/meet/build/node_modules/.gitkeep new file mode 100644 diff --git a/docker/meet/init.sh b/docker/meet/init.sh new file mode 100755 --- /dev/null +++ b/docker/meet/init.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -e +cp -R /src/meet /src/meetsrc +rm -Rf /src/meetsrc/node_modules +ln -s /root/node_modules /src/meetsrc/node_modules +cd /src/meetsrc +npm install +npm install -g nodemon +export DEBUG="kolabmeet-server* mediasoup*" +nodemon server.js diff --git a/docker/proxy/rootfs/etc/nginx/nginx.conf b/docker/proxy/rootfs/etc/nginx/nginx.conf --- a/docker/proxy/rootfs/etc/nginx/nginx.conf +++ b/docker/proxy/rootfs/etc/nginx/nginx.conf @@ -65,14 +65,26 @@ proxy_cache_bypass 1; } - location /openvidu { - proxy_pass https://127.0.0.1:8443; + location /meetmedia { + proxy_pass https://127.0.0.1:12443; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; } + location /meetmedia/api { + proxy_pass https://127.0.0.1:12443; + proxy_redirect off; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_no_cache 1; + proxy_cache_bypass 1; + } + location /roundcubemail { proxy_pass http://127.0.0.1:9080; proxy_redirect off; diff --git a/meet/README.md b/meet/README.md new file mode 100644 --- /dev/null +++ b/meet/README.md @@ -0,0 +1,114 @@ +This is the kolab meet server side component. + +Run it with nodejs (or use the meet container). + +It should become available on on port 12433 (curl -k -v http://localhost:12443/ping) + +# To get an interactive console +/src/meetsrc/connect.js + +# To dump some stats +/src/meetsrc/connect.js --stats + +# Test the websocket +npm -g install wscat +wscat --no-check -c "wss://172.20.0.2:12443/socket.io/?peerId=peer1&roomId=room1&EIO=3&transport=websocket" + +# Update code in container +docker exec -ti kolab-meet /bin/bash -c "/bin/cp -rf /src/meet/* /src/meetsrc/" + +# Quick WebRTC overview + +In our setup there are the following components involved: +* Client (Browser with some javascript) +* Kolab 4 (Webserver runnnign the kolab 4 application) +* Turn server (coturn) +* Kolabmeet server (nodejs application) + +Kolabmeet itself has two 3 interaction points: +* A webserver for the API +* A websocket for signaling +* Mediasoup for webrtc + +To join a meeting this is roughly what happens: +* The Client asks the webserver to join a room +* The webserver contacts kolabmeet to create the room and returns a url for a signaling websocket +* The client connects to kolabmeet via the signaling websocket +* The client now asks kolabmeet via the websocket to prepare the media channels +* Mediasoup then ultimately establishes the webrtc connection, potentially routing the data via the configured turn server. + +This leads to the following topolgy: +* Client <-> Kolab 4 API <-> Kolabmeet API +* Client <-> Kolabmeet Websocket (Signaling) +* Client <-> (Turn Server) <-> Kolabmeet WebRTC <-> (Turn Server) <-> Client + + +# Troubleshooting + +* Socket.io (signaling) has a debug option that can be set in the browser local storage. +* Mediasoup has a debug flag that can be set in the config. +* Coturn has config flags to enable logging of all connection attempts. +* Firefox has about:webrtc and chrome has chrome://webrtc-internals +* wscat can be used to test websockets. +* On the kolabmeet server you can connect to the server by executing connect.js, which allows to inspect the internal state. +* The browser won't allow a ws: connection from a https:// site, but only chrome will tell you about it. +* If in question, restart your browser. Sometimes things suddenly start working again. +* Access to media (webcam and microphone) only works on https or localhost sites (secure context). Otherwise the client side will start to break. +* 127.0.0.1 as webrtc listening host may not work for a local webrtc setup (and will not give you any warnings about it either). See also firefoxes media.peerconnection.ice.* config options. Use a local interfaces ip instead. + +## Connection setup + +In order: +* The client first opens a room via the meet laravel controller. This should work as it's the regular Kolab4 API. +* Next the controller needs to access the meet API, which it does via MEET_SERVER_URL +* The client next opens a websocket with the meet server directly, which requires that the client has access to the meet server API. +* Finally the client needs to establish a webrtc transport with the meet server (possibly via the turn server), and then create producers and consumers for audio/video/screenshare. + +Establishing the webrtc connection is the most unclear part, because there is a lot of hidden negotiation done by the browser. +Important checks are: +* Which ICE candidates does the meet server communicate to the client (typically a turn server with an IP that the client can reach directly, typically a public IP) +* These ice candidates should then be visible in firefoxe's about:webrtc view, and one should be selected to establish the transport. +* The mediasoup-client transport should reach the "connected" state. (This is also visible on the server as the "dtlsState" of the transport) +* Ultimately you should see data packets on the server when enabling the trace messages, once a client is connected. + +# Scalability + +The number of participants a server can handle, greatly depends on the number of streams that need to be handled. +In principle there's at least 2 streams per participant (ignoring screensharing) for audio + video incoming (upstream), and then each of those streams is sent to all participants (excluding the sender). This leads to 2n * (n - 1) streams when everyone is sending and receiving vide + audio. A single cpu core is expected to be able to handle ~500 streams, which leads to ~16 participants. + +This number can of course be greatly affected by reducing the number of streams that need to be handled, e.g. listeners not sending video. + +## Horizontal scaling + +Currently we can scale with the number of threads on a system by using multiple workers, but not across multiple servers. + +In the simplest form it would of course be possible to load balance and just distribute rooms on different nodes. + +To distribute a single room across different nodes more work is required: +* A transport needs to be established between the nodes. +* For each participant on the remote node all streams need to be proxied as well as the signaling. + +The benefits of this should be: +* A room can grow beyond the limits of a server (which would be very large rooms). +* If we assume peers join a geo-local server: +** Instead of having to send N streams across to all peers, we can send 1 stream to the server which then distributes to N peers, reducing the required bandwidth on the path between servers. +** If a reencoder is implemented in each server, latency for request of keyframes is reduced. +** Local peers can use a more efficient direct path between each other and thus further relieve the server interconnection. + +## High availability + +In the simplest form the server is simply restarted and all clients reconnect. This results in a brief interruption and some state is lost (chat history), but everyone should be back in the same room relatively quickly. + +More advanced forms could potentially recover the internal state from e.g. redis, to recover quicker and relatively transparent to the user. I think the transports need to be reestablished, but webrtc should allow for this. + +## Reencoder + +The reencoder is a process (running on the server) that consumes a track and simply reencodes it and forwards it to the server again. The server will then have to serve that reencoded track to the end user instead of the direct track. +That way a keyframe request can be handled by the reencoder instead of the original client, which is especially useful with geolocated nodes (due to latency), but in general protects the client from constantly having to generate keyframes whenevery a client looses connection. + +A reencoder can be implemented using libmediasoupclient and thus probably is a c++ endavour (or potentially rust?). + +## Ideas to explore + +* Automatically disable video for silent participants, and only enable for last N active speakers: https://docs.openvidu.io/en/2.19.0/openvidu-enterprise/#large-scale-sessions +* Ensure we make use of simulcast (per peer adaptive stream quality, depending on available bandwidth and processing power) diff --git a/meet/server/.eslintrc.json b/meet/server/.eslintrc.json new file mode 100644 --- /dev/null +++ b/meet/server/.eslintrc.json @@ -0,0 +1,21 @@ +{ + "env": { + "es6": true, + "node": true + }, + "extends": [ + "eslint:recommended" + ], + "settings": {}, + "parserOptions": { + "ecmaVersion": 2018, + "sourceType": "module", + "ecmaFeatures": { + "impliedStrict": true + } + }, + "rules": { + "brace-style": ["error", "1tbs"], + "indent": ["error", 4] + } +} diff --git a/meet/server/.gitignore b/meet/server/.gitignore new file mode 100644 --- /dev/null +++ b/meet/server/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/meet/server/config/config.js b/meet/server/config/config.js new file mode 100644 --- /dev/null +++ b/meet/server/config/config.js @@ -0,0 +1,124 @@ +const os = require('os'); + +module.exports = +{ + // Authentication token for API (not websocket) requests + authToken: process.env.AUTH_TOKEN, + // Turn server configuration + turn: process.env.TURN_SERVER === 'none' ? null : { + urls: [ + // Using transport=tcp prevents the use of udp for the connection to the server, which is useful for testing, + // but most likely not desired for production: https://datatracker.ietf.org/doc/html/rfc5766#section-2.1 + process.env.TURN_SERVER || 'turn:127.0.0.1:3478?transport=tcp' + ], + staticSecret: process.env.TURN_STATIC_SECRET || 'uzYguvIl9tpZFMuQOE78DpOi6Jc7VFSD0UAnvgMsg5n4e74MgIf6vQvbc6LWzZjz', + }, + // Webhook URL + webhookURL: process.env.WEBHOOK_URL, + // Webhook authentication token + webhookToken: process.env.WEBHOOK_TOKEN, + // if you use encrypted private key the set the passphrase + tls: process.env.SSL_CERT === 'none' ? null : { + // passphrase: 'key_password' + cert: process.env.SSL_CERT || `/etc/pki/tls/certs/kolab.hosted.com.cert`, + key: process.env.SSL_KEY || `/etc/pki/tls/certs/kolab.hosted.com.key`, + }, + // listening Host or IP + // Use "0.0.0.0" or "::") to listen on every IP. + listeningHost: process.env.LISTENING_HOST || "0.0.0.0", + // Listening port for https server. + listeningPort: process.env.LISTENING_PORT || 12443, + // Used to establish the websocket connection from the client. + publicDomain: process.env.PUBLIC_DOMAIN || '127.0.0.1:12443', + // API path prefix + pathPrefix: '/meetmedia', + // Room size before spreading to new router + routerScaleSize: process.env.ROUTER_SCALE_SIZE || 16, + // Socket timeout value + requestTimeout: 20000, + // Socket retries when timeout + requestRetries: 3, + // Mediasoup settings + mediasoup: { + numWorkers: process.env.MEDIASOUP_NUM_WORKERS || Object.keys(os.cpus()).length, + // mediasoup Worker settings. + worker: { + logLevel: 'warn', + logTags: [ + 'info', + 'ice', + 'dtls', + 'rtp', + 'srtp', + 'rtcp' + ], + rtcMinPort: 40000, + rtcMaxPort: 49999 + }, + // mediasoup Router settings. + router: { + // Router media codecs. + mediaCodecs: [ + { + kind : 'audio', + mimeType : 'audio/opus', + clockRate : 48000, + channels : 2 + }, + { + kind : 'video', + mimeType : 'video/VP8', + clockRate : 90000, + parameters : + { + 'x-google-start-bitrate' : 1000 + } + }, + { + kind : 'video', + mimeType : 'video/VP9', + clockRate : 90000, + parameters : + { + 'profile-id' : 2, + 'x-google-start-bitrate' : 1000 + } + }, + { + kind : 'video', + mimeType : 'video/h264', + clockRate : 90000, + parameters : + { + 'packetization-mode' : 1, + 'profile-level-id' : '4d0032', + 'level-asymmetry-allowed' : 1, + 'x-google-start-bitrate' : 1000 + } + }, + { + kind : 'video', + mimeType : 'video/h264', + clockRate : 90000, + parameters : + { + 'packetization-mode' : 1, + 'profile-level-id' : '42e01f', + 'level-asymmetry-allowed' : 1, + 'x-google-start-bitrate' : 1000 + } + } + ] + }, + // mediasoup WebRtcTransport settings. + webRtcTransport: { + listenIps: [ + { ip: process.env.WEBRTC_LISTEN_IP, announcedIp: null } + ], + // Initial bitrate estimation + initialAvailableOutgoingBitrate: 1000000, + // Additional options that are not part of WebRtcTransportOptions. + maxIncomingBitrate: 1500000 + } + } +}; diff --git a/meet/server/connect.js b/meet/server/connect.js new file mode 100755 --- /dev/null +++ b/meet/server/connect.js @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +const interactiveClient = require('./lib/interactiveClient'); + +interactiveClient(); diff --git a/meet/server/lib/Logger.js b/meet/server/lib/Logger.js new file mode 100644 --- /dev/null +++ b/meet/server/lib/Logger.js @@ -0,0 +1,44 @@ +const debug = require('debug'); + +const APP_NAME = 'kolabmeet-server'; + +class Logger { + constructor(prefix) { + if (prefix) { + this._debug = debug(`${APP_NAME}:${prefix}`); + this._info = debug(`${APP_NAME}:INFO:${prefix}`); + this._warn = debug(`${APP_NAME}:WARN:${prefix}`); + this._error = debug(`${APP_NAME}:ERROR:${prefix}`); + } else { + this._debug = debug(APP_NAME); + this._info = debug(`${APP_NAME}:INFO`); + this._warn = debug(`${APP_NAME}:WARN`); + this._error = debug(`${APP_NAME}:ERROR`); + } + + /* eslint-disable no-console */ + this._debug.log = console.info.bind(console); + this._info.log = console.info.bind(console); + this._warn.log = console.warn.bind(console); + this._error.log = console.error.bind(console); + /* eslint-enable no-console */ + } + + get debug() { + return this._debug; + } + + get info() { + return this._info; + } + + get warn() { + return this._warn; + } + + get error() { + return this._error; + } +} + +module.exports = Logger; diff --git a/meet/server/lib/Peer.js b/meet/server/lib/Peer.js new file mode 100644 --- /dev/null +++ b/meet/server/lib/Peer.js @@ -0,0 +1,298 @@ +const EventEmitter = require('events').EventEmitter; +const Logger = require('./Logger'); +const crypto = require('crypto'); +const Roles = require('./userRoles'); +const { v4: uuidv4 } = require('uuid'); + +const logger = new Logger('Peer'); + +class Peer extends EventEmitter { + constructor({ roomId }) { + logger.info('Peer constructor()'); + + super(); + + this._id = uuidv4(); + + this._roomId = roomId; + + this._socket = null; + + this._closed = false; + + this._role = 0; + + this._nickname = false; + + this._language = null; + + this._routerId = null; + + this._rtpCapabilities = null; + + this._raisedHand = false; + + this._disconnected = false; + + this._transports = new Map(); + + this._producers = new Map(); + + this._consumers = new Map(); + + this._authToken = crypto.randomBytes(16).toString('hex'); + } + + close() { + if (this._closed) + return; + + logger.info('close()'); + + this._closed = true; + + // Iterate and close all mediasoup Transport associated to this Peer, so all + // its Producers and Consumers will also be closed. + for (const transport of this.transports.values()) { + transport.close(); + } + + if (this.socket) + this.socket.disconnect(true); + + + if (this._selfDestructTimeout) + clearTimeout(this._selfDestructTimeout); + + this._selfDestructTimeout = null; + + this.emit('close'); + } + + get authToken() { + return this._authToken; + } + + get id() { + return this._id; + } + + get roomId() { + return this._roomId; + } + + get workerId() { + return this._workerId; + } + + get socket() { + return this._socket; + } + + set socket(socket) { + this._socket = socket; + + if (this._socket) { + this._disconnectListener = (reason) => { + this._disconnected = true + + logger.debug('"disconnect" event [id:%s, reason:%s]', this.id, reason); + + if (reason === "client namespace disconnect") { + this.close(); + } else { + //If this was a connection interruption we allow to reconnect within 10s + //TODO inform peers about disconnected state + clearTimeout(this._selfDestructTimeout); + + this._selfDestructTimeout = setTimeout(() => { + logger.info( + 'Closing peer after reconnect timeout [id:"%s"]', + this.id); + this.close(); + }, 10000); + } + } + this._socket.on('disconnect', this._disconnectListener); + + this._requestListener = (request, cb) => { + this.emit('request', request, cb); + } + + this._socket.on('request', this._requestListener); + } + } + + get closed() { + return this._closed; + } + + get disconnected() { + return this._disconnected; + } + + get role() { + return this._role; + } + + get nickname() { + return this._nickname; + } + + get language() { + return this._language; + } + + set nickname(nickname) { + if (nickname !== this._nickname) { + this._nickname = nickname; + + this.emit('nicknameChanged'); + } + } + + set language(language) { + if (language != this._language) { + this._language = language; + + this.emit('languageChanged'); + } + } + + get routerId() { + return this._routerId; + } + + set routerId(routerId) { + this._routerId = routerId; + } + + set workerId(workerId) { + this._workerId = workerId; + } + + get rtpCapabilities() { + return this._rtpCapabilities; + } + + set rtpCapabilities(rtpCapabilities) { + this._rtpCapabilities = rtpCapabilities; + } + + get raisedHand() { + return this._raisedHand; + } + + set raisedHand(raisedHand) { + if (this._raisedHand != raisedHand) { + this._raisedHand = raisedHand; + + this.emit('raisedHandChanged'); + } + } + + get transports() { + return this._transports; + } + + get producers() { + return this._producers; + } + + get consumers() { + return this._consumers; + } + + setRole(newRole) { + if (this._role != newRole) { + this._role = newRole; + + this.emit('roleChanged'); + } + } + + isValidRole(newRole) { + Object.keys(Roles).forEach(roleId => { + const role = Roles[roleId] + if (newRole & role) { + newRole = newRole ^ role; + } + }) + + return newRole == 0; + } + + hasRole(role) { + return !!(this._role & role); + } + + addTransport(id, transport) { + this.transports.set(id, transport); + } + + getTransport(id) { + return this.transports.get(id); + } + + getConsumerTransport() { + return Array.from(this.transports.values()) + .find((t) => t.appData.consuming); + } + + removeTransport(id) { + this.transports.delete(id); + } + + addProducer(id, producer) { + this.producers.set(id, producer); + } + + getProducer(id) { + return this.producers.get(id); + } + + removeProducer(id) { + this.producers.delete(id); + } + + addConsumer(id, consumer) { + this.consumers.set(id, consumer); + } + + getConsumer(id) { + return this.consumers.get(id); + } + + removeConsumer(id) { + this.consumers.delete(id); + } + + get peerInfo() { + const peerInfo = + { + id: this.id, + language: this.language, + nickname: this.nickname, + role: this.role, + raisedHand: this.raisedHand + }; + + return peerInfo; + } + + joinRoom(newSocket) { + clearTimeout(this._selfDestructTimeout); + + if (this._socket) { + logger.debug("Peer is reconnecting ", this.id) + this._socket.removeListener('disconnect', this._disconnectListener); + this._socket.removeListener('request', this._requestListener); + this._socket.disconnect(); + } + + this.socket = newSocket; + this.socket.join(this._roomId); + } +} + +module.exports = Peer; diff --git a/meet/server/lib/Room.js b/meet/server/lib/Room.js new file mode 100644 --- /dev/null +++ b/meet/server/lib/Room.js @@ -0,0 +1,1080 @@ +const EventEmitter = require('events').EventEmitter; +const crypto = require('crypto'); +const Logger = require('./Logger'); +const { SocketTimeoutError } = require('./errors'); +const Roles = require('./userRoles'); +const { v4: uuidv4 } = require('uuid'); + +const config = require('../config/config'); + +const logger = new Logger('Room'); + +const ROUTER_SCALE_SIZE = config.routerScaleSize; + +class Room extends EventEmitter { + + routerLoad(routerId) { + let load = 0; + Object.values(this._peers).forEach(peer => { + if (peer.routerId == routerId) { + load++; + } + }); + return load; + } + + /* + * Find a router that is on a worker that is least loaded. + */ + async getLeastLoadedRouter() { + //Look for an existing router with capacity + for (const router of this._mediasoupRouters.values()) { + if (this.routerLoad(router.id) < ROUTER_SCALE_SIZE) { + return router + } + } + + const worker = await this._getLeastLoadedWorker(); + + //If we already have a router for this worker just reuse, + //there is no point in creating multiple routers on the same worker. + for (const router of this._mediasoupRouters.values()) { + if (router.appData.workerPid == worker.pid) { + return router; + } + } + + //Create a new router in the least loaded worker + const newRouter = await worker.createRouter({ + mediaCodecs: config.mediasoup.router.mediaCodecs, + appData : { workerPid: worker.pid } + }); + this._mediasoupRouters.set(newRouter.id, newRouter); + + //Pipe existing producers to new router + for (const peer of Object.values(this._peers)) { + const srcRouter = this._mediasoupRouters.get(peer.routerId); + for (const producerId of peer.producers.keys()) { + await srcRouter.pipeToRouter({ + producerId : producerId, + router : newRouter + }); + } + } + + return newRouter; + } + + /** + * Factory function that creates and returns Room instance. + * + * @async + * + * @param {callback} getLeastLoadedWorker - Callback to request a worker for a new router + */ + static async create({ getLeastLoadedWorker }) { + const roomId = uuidv4(); + + logger.info('create() [roomId:"%s"]', roomId); + + return new Room({ + roomId, + getLeastLoadedWorker + }); + } + + constructor({ + roomId, + getLeastLoadedWorker + }) { + logger.info('constructor() [roomId:"%s"]', roomId); + + super(); + this.setMaxListeners(Infinity); + + this._getLeastLoadedWorker = getLeastLoadedWorker + + this._roomId = roomId; + + this._closed = false; + + this._peers = {}; + + this._selfDestructTimeout = null; + + this._mediasoupRouters = new Map(); + + this._createdAt = parseInt(Date.now() / 1000); + } + + stats() { + const peers = this.getPeers(); + return { + numberOfRouters: this._mediasoupRouters.size, + numberOfPeers: peers.length, + }; + } + + dumpStats() { + console.log(this.stats()); + } + + close() { + logger.debug('close()'); + + this._closed = true; + + if (this._selfDestructTimeout) + clearTimeout(this._selfDestructTimeout); + + this._selfDestructTimeout = null; + + Object.values(this._peers).forEach(peer => { + peer.close(); + }); + + this._peers = {}; + + for (const router of this._mediasoupRouters.values()) { + router.close(); + } + + this._mediasoupRouters.clear(); + this.emit('close'); + } + + async joinRoom({ peer, socket }) { + logger.info('handlePeer() [peer:"%s", role:%s]', peer.id, peer.role); + + if (peer.roomId != this._roomId) { + logger.info('handlePeer() Peer is in the wrong room [peer:"%s", peer roomId:%s, roomId: %s]', peer.id, peer.roomId, this._roomId); + return; + } + + clearTimeout(this._selfDestructTimeout); + + if (this._peers[peer.id]) { + peer.joinRoom(socket); + this._handlePeer(peer); + logger.info("Triggering a room back notification for peer %s", peer.id) + this._notification(peer.socket, 'roomBack', {}); + } else { + peer.joinRoom(socket); + this._peers[peer.id] = peer; + const router = await this.getLeastLoadedRouter(); + peer.routerId = router.id + peer.workerId = router.appData.workerPid; + this._handlePeer(peer); + const iceServers = this._getIceServers(peer); + this._notification(peer.socket, 'roomReady', { iceServers, roomId: this._roomId }); + } + } + + logStatus() { + logger.info( + 'logStatus() [room id:"%s", peers:"%s"]', + this._roomId, + Object.keys(this._peers).length + ); + } + + dump() { + return { + roomId : this._roomId, + peers : Object.keys(this._peers).length + }; + } + + get id() { + return this._roomId; + } + + get createdAt() { + return this._createdAt; + } + + selfDestructCountdown() { + logger.debug('selfDestructCountdown() started'); + + clearTimeout(this._selfDestructTimeout); + + this._selfDestructTimeout = setTimeout(() => { + logger.info( + 'Room deserted for some time, closing the room [roomId:"%s"]', + this._roomId); + this.close(); + }, 10000); + } + + checkEmpty() { + return Object.keys(this._peers).length === 0; + } + + _getTURNCredentials(name, secret) { + const unixTimeStamp = parseInt(Date.now()/1000) + 24*3600; // this credential would be valid for the next 24 hours + // If there is no name, the timestamp alone can also be used. + const username = name ? `${unixTimeStamp}:${name}` : `${unixTimeStamp}`; + const hmac = crypto.createHmac('sha1', secret); + hmac.setEncoding('base64'); + hmac.write(username); + hmac.end(); + const password = hmac.read(); + return { + username, + password + }; + } + + _getIceServers(peer) { + if (config.turn) { + // Generate time-limited credentials. The name is only relevant for the logs. + const {username, password} = this._getTURNCredentials(peer.id, config.turn.staticSecret); + + return [ { + urls : config.turn.urls, + username : username, + credential : password + } ]; + } + return null; + } + + _handlePeer(peer) { + logger.debug('_handlePeer() [peer:"%s"]', peer.id); + + peer.on('close', () => { + this._handlePeerClose(peer); + }); + + peer.on('nicknameChanged', () => { + // Spread to others (and self) + const data = { peerId: peer.id, nickname: peer.nickname }; + this._notification(peer.socket, 'changeNickname', data, true, true); + }); + + peer.on('languageChanged', () => { + // Spread to others (and self) + const data = { peerId: peer.id, language: peer.language }; + this._notification(peer.socket, 'changeLanguage', data, true, true); + }); + + peer.on('roleChanged', () => { + // Spread to others (and self) + const data = { peerId: peer.id, role: peer.role }; + this._notification(peer.socket, 'changeRole', data, true, true); + }); + + peer.on('raisedHandChanged', () => { + // Spread to others (and self) + const data = { peerId: peer.id, raisedHand: peer.raisedHand }; + this._notification(peer.socket, 'changeRaisedHand', data, true, true); + }); + + peer.on('request', (request, cb) => { + logger.debug( + 'Peer "request" event [method:"%s", peerId:"%s"]', + request.method, peer.id); + + this._handlePeerRequest(peer, request, cb) + .catch((error) => { + logger.error('"request" failed [error:"%o"]', error); + + cb(500, `request ${request.method} failed`); + }); + }); + + // Peer left before we were done joining + if (peer.closed) + this._handlePeerClose(peer); + } + + _handlePeerClose(peer) { + logger.debug('_handlePeerClose() [peer:"%s"]', peer.id); + + if (this._closed) + return; + + this._notification(peer.socket, 'peerClosed', { peerId: peer.id }, true); + + delete this._peers[peer.id]; + + // If this is the last Peer in the room close the room after a while. + if (this.checkEmpty()) + this.selfDestructCountdown(); + } + + async _handlePeerRequest(peer, request, cb) { + const router = this._mediasoupRouters.get(peer.routerId); + + switch (request.method) { + case 'ping': + { + console.warn("ping") + cb(null); + break; + } + + case 'getRouterRtpCapabilities': + { + cb(null, router.rtpCapabilities); + break; + } + + case 'dumpStats': + { + this.dumpStats() + + cb(null); + break; + } + + case 'join': + { + const { + nickname, + rtpCapabilities + } = request.data; + + // Store client data into the Peer data object. + peer.nickname = nickname; + peer.rtpCapabilities = rtpCapabilities; + + // Tell the new Peer about already joined Peers. + const otherPeers = this.getPeers(peer); + + const peerInfos = otherPeers.map(otherPeer => otherPeer.peerInfo); + + cb(null, { peers: peerInfos, ...peer.peerInfo }); + + // Create Consumers for existing Producers. + for (const otherPeer of otherPeers) { + for (const producer of otherPeer.producers.values()) { + this._createConsumer({ + consumerPeer: peer, + producerPeer: otherPeer, + producer + }); + } + } + + // Notify the new Peer to all other Peers. + this._notification(peer.socket, 'newPeer', peer.peerInfo, true); + + logger.debug( + 'peer joined [peer: "%s", nickname: "%s"]', + peer.id, nickname); + + break; + } + + case 'createPlainTransport': + { + const { producing, consuming } = request.data; + + const transport = await router.createPlainTransport( + { + //When consuming we manually connect using connectPlainTransport, + //otherwise we let the port autodetection work. + comedia: producing, + // FFmpeg and GStreamer don't support RTP/RTCP multiplexing ("a=rtcp-mux" in SDP) + rtcpMux: false, + listenIp: config.mediasoup.webRtcTransport.listenIps[0], + appData : { producing, consuming } + } + ); + // await transport.enableTraceEvent([ "probation", "bwe" ]); + // transport.on("trace", (trace) => { + // console.log(trace); + // }); + + peer.addTransport(transport.id, transport); + + cb( + null, + { + id : transport.id, + ip : transport.tuple.localIp, + port : transport.tuple.localPort, + rtcpPort : transport.rtcpTuple ? transport.rtcpTuple.localPort : undefined + }); + + break; + } + + case 'connectPlainTransport': + { + const { transportId, ip, port, rtcpPort } = request.data; + const transport = peer.getTransport(transportId); + + if (!transport) + throw new Error(`transport with id "${transportId}" not found`); + + await transport.connect({ + ip: ip, + port: port, + rtcpPort: rtcpPort, + }); + + cb(); + + break; + } + + case 'createWebRtcTransport': + { + // NOTE: Don't require that the Peer is joined here, so the client can + // initiate mediasoup Transports and be ready when he later joins. + + const { forceTcp, producing, consuming } = request.data; + + const webRtcTransportOptions = + { + ...config.mediasoup.webRtcTransport, + appData : { producing, consuming } + }; + + webRtcTransportOptions.enableTcp = true; + + if (forceTcp) + webRtcTransportOptions.enableUdp = false; + else { + webRtcTransportOptions.enableUdp = true; + webRtcTransportOptions.preferUdp = true; + } + + const transport = await router.createWebRtcTransport( + webRtcTransportOptions + ); + + transport.on('dtlsstatechange', (dtlsState) => { + if (dtlsState === 'failed' || dtlsState === 'closed') { + logger.warn('WebRtcTransport "dtlsstatechange" event [dtlsState:%s]', dtlsState); + } + }); + + // Store the WebRtcTransport into the Peer data Object. + peer.addTransport(transport.id, transport); + + cb( + null, + { + id : transport.id, + iceParameters : transport.iceParameters, + iceCandidates : transport.iceCandidates, + dtlsParameters : transport.dtlsParameters + }); + + const { maxIncomingBitrate } = config.mediasoup.webRtcTransport; + + // If set, apply max incoming bitrate limit. + if (maxIncomingBitrate) { + try { + await transport.setMaxIncomingBitrate(maxIncomingBitrate); + } catch (error) { + logger.info("Setting the incoming bitrate failed") + } + } + + break; + } + + case 'connectWebRtcTransport': + { + const { transportId, dtlsParameters } = request.data; + const transport = peer.getTransport(transportId); + + if (!transport) + throw new Error(`transport with id "${transportId}" not found`); + + await transport.connect({ dtlsParameters }); + + cb(); + + break; + } + case 'restartIce': + { + const { transportId } = request.data; + const transport = peer.getTransport(transportId); + + if (!transport) + throw new Error(`transport with id "${transportId}" not found`); + + const iceParameters = await transport.restartIce(); + + cb(null, iceParameters); + + break; + } + case 'produce': + { + let { appData } = request.data; + + if (!appData.source || ![ 'mic', 'webcam', 'screen' ].includes(appData.source)) + throw new Error('invalid producer source'); + + if (appData.source === 'mic' && !peer.hasRole(Roles.PUBLISHER)) + throw new Error('peer not authorized'); + + if (appData.source === 'webcam' && !peer.hasRole(Roles.PUBLISHER)) + throw new Error('peer not authorized'); + + if (appData.source === 'screen' && !peer.hasRole(Roles.PUBLISHER)) + throw new Error('peer not authorized'); + + const { transportId, kind, rtpParameters } = request.data; + const transport = peer.getTransport(transportId); + + if (!transport) + throw new Error(`transport with id "${transportId}" not found`); + + const producer = await transport.produce({ kind, rtpParameters, appData }); + + // Pipe new producer to all other routers (besides this router) + for (const routerId of this._getRoutersToPipeTo(peer.routerId)) { + const destinationRouter = this._mediasoupRouters.get(routerId) + await router.pipeToRouter({ + producerId : producer.id, + router : destinationRouter + }); + } + + // Store the Producer into the Peer data Object. + peer.addProducer(producer.id, producer); + + producer.on('videoorientationchange', (videoOrientation) => { + logger.debug( + 'producer "videoorientationchange" event [producerId:"%s", videoOrientation:"%o"]', + producer.id, videoOrientation); + }); + + // Trace individual packets for debugging + // await producer.enableTraceEvent([ "rtp", "pli", "keyframe", "nack" ]); + // producer.on("trace", (trace) => { + // console.log(`Trace on ${producer.id}`, trace); + // }); + + cb(null, { id: producer.id }); + + // Optimization: Create a server-side Consumer for each Peer. + for (const otherPeer of this.getPeers(peer)) { + this._createConsumer({ + consumerPeer: otherPeer, + producerPeer: peer, + producer + }); + } + + break; + } + + case 'closeProducer': + { + const { producerId } = request.data; + const producer = peer.getProducer(producerId); + + if (!producer) + throw new Error(`producer with id "${producerId}" not found`); + + producer.close(); + + // Remove from its map. + peer.removeProducer(producer.id); + + cb(); + + break; + } + + case 'pauseProducer': + { + const { producerId } = request.data; + const producer = peer.getProducer(producerId); + + if (!producer) + throw new Error(`producer with id "${producerId}" not found`); + + await producer.pause(); + + cb(); + + break; + } + + case 'resumeProducer': + { + const { producerId } = request.data; + const producer = peer.getProducer(producerId); + + if (!producer) + throw new Error(`producer with id "${producerId}" not found`); + + await producer.resume(); + + cb(); + + break; + } + + case 'pauseConsumer': + { + const { consumerId } = request.data; + const consumer = peer.getConsumer(consumerId); + + if (!consumer) + throw new Error(`consumer with id "${consumerId}" not found`); + + await consumer.pause(); + + cb(); + + break; + } + + case 'resumeConsumer': + { + const { consumerId } = request.data; + const consumer = peer.getConsumer(consumerId); + + if (!consumer) + throw new Error(`consumer with id "${consumerId}" not found`); + + await consumer.resume(); + + cb(); + + break; + } + + case 'changeNickname': + { + const { nickname } = request.data; + + peer.nickname = nickname; + + // This will be spread through events from the peer object + + // Return no error + cb(); + + break; + } + + case 'chatMessage': + { + const { message } = request.data; + + // Spread to others + this._notification(peer.socket, 'chatMessage', { + peerId: peer.id, + nickname: peer.nickname, + message: message + }, true, true); + + // Return no error + cb(); + + break; + } + + case 'moderator:addRole': + { + if (!peer.hasRole(Roles.MODERATOR)) + throw new Error('peer not authorized'); + + const { peerId, role } = request.data; + + const rolePeer = this._peers[peerId]; + + if (!rolePeer) + throw new Error(`peer with id "${peerId}" not found`); + + if (!rolePeer.isValidRole(role)) + throw new Error('invalid role'); + + if (!rolePeer.hasRole(role)) { + // The 'owner' role is not assignable + if (role & Roles.OWNER) + throw new Error('the OWNER role is not assignable'); + + // Promotion to publisher? Put the user hand down + if (role & Roles.PUBLISHER && rolePeer.raisedHand) + rolePeer.raisedHand = false; + + // This will propagate the event automatically + rolePeer.setRole(rolePeer.role | role); + } + + // Return no error + cb(); + + break; + } + + case 'moderator:removeRole': + { + if (!peer.hasRole(Roles.MODERATOR)) + throw new Error('peer not authorized'); + + const { peerId, role } = request.data; + + const rolePeer = this._peers[peerId]; + + if (!rolePeer) + throw new Error(`peer with id "${peerId}" not found`); + + if (!rolePeer.isValidRole(role)) + throw new Error('invalid role'); + + if (rolePeer.hasRole(role)) { + if (role & Roles.OWNER) + throw new Error('the OWNER role is not removable'); + + if (role & Roles.MODERATOR && rolePeer.role & Roles.OWNER) + throw new Error('the MODERATOR role cannot be removed from the OWNER'); + + // Non-publisher cannot be a language interpreter + if (role & Roles.PUBLISHER) + rolePeer.language = null; + + // This will propagate the event automatically + rolePeer.setRole(rolePeer.role ^ role); + } + + // Return no error + cb(); + + break; + } + + case 'moderator:changeLanguage': + { + if (!peer.hasRole(Roles.MODERATOR)) + throw new Error('peer not authorized'); + + const { peerId, language } = request.data; + + if (language && !/^[a-z]{2}$/.test(language)) + throw new Error('invalid language code'); + + const langPeer = this._peers[peerId]; + + if (!langPeer) + throw new Error(`peer with id "${peerId}" not found`); + + langPeer.language = language; + + // This will be spread through events from the peer object + + // Return no error + cb(); + + break; + } + + case 'moderator:joinRequestAccept': + { + if (!peer.hasRole(Roles.MODERATOR)) + throw new Error('peer not authorized'); + + const { requestId } = request.data; + + // Return no error + cb(); + + this.emit('joinRequestAccepted', requestId); + + break; + } + + case 'moderator:joinRequestDeny': + { + if (!peer.hasRole(Roles.MODERATOR)) + throw new Error('peer not authorized'); + + const { requestId } = request.data; + + // Return no error + cb(); + + this.emit('joinRequestDenied', requestId); + + break; + } + + case 'moderator:closeRoom': + { + if (!peer.hasRole(Roles.OWNER)) + throw new Error('peer not authorized'); + + this._notification(peer.socket, 'moderator:closeRoom', null, true); + + cb(); + + // Close the room + this.close(); + + break; + } + + case 'moderator:kickPeer': + { + if (!peer.hasRole(Roles.MODERATOR)) + throw new Error('peer not authorized'); + + const { peerId } = request.data; + + const kickPeer = this._peers[peerId]; + + if (!kickPeer) + throw new Error(`peer with id "${peerId}" not found`); + + this._notification(kickPeer.socket, 'moderator:kickPeer'); + + kickPeer.close(); + + cb(); + + break; + } + + case 'raisedHand': + { + const { raisedHand } = request.data; + + peer.raisedHand = raisedHand; + + // This will be spread through events from the peer object + + // Return no error + cb(); + + break; + } + + default: + { + logger.error('unknown request.method "%s"', request.method); + + cb(500, `unknown request.method "${request.method}"`); + } + } + } + + /** + * Creates a mediasoup Consumer for the given mediasoup Producer. + * + * @async + */ + async _createConsumer({ consumerPeer, producerPeer, producer }) { + logger.debug( + '_createConsumer() [consumerPeer:"%s", producerPeer:"%s", producer:"%s"]', + consumerPeer.id, + producerPeer.id, + producer.id + ); + + const router = this._mediasoupRouters.get(producerPeer.routerId); + + // Optimization: + // - Create the server-side Consumer. If video, do it paused. + // - Tell its Peer about it and wait for its response. + // - Upon receipt of the response, resume the server-side Consumer. + // - If video, this will mean a single key frame requested by the + // server-side Consumer (when resuming it). + + // NOTE: Don't create the Consumer if the remote Peer cannot consume it. + if ( + !consumerPeer.rtpCapabilities || + !router.canConsume( + { + producerId : producer.id, + rtpCapabilities : consumerPeer.rtpCapabilities + }) + ) { + return; + } + + // Must take the Transport the remote Peer is using for consuming. + const transport = consumerPeer.getConsumerTransport(); + + // This should not happen. + if (!transport) { + logger.warn('_createConsumer() | Transport for consuming not found'); + + return; + } + + // Create the Consumer in paused mode. + let consumer; + + try { + consumer = await transport.consume( + { + producerId : producer.id, + rtpCapabilities : consumerPeer.rtpCapabilities, + paused : producer.kind === 'video' + }); + + if (producer.kind === 'audio') + await consumer.setPriority(255); + } catch (error) { + logger.warn('_createConsumer() | [error:"%o"]', error); + + return; + } + + // Trace individual packets for debugging + // await consumer.enableTraceEvent([ "rtp", "pli", "fir" ]); + // consumer.on("trace", (trace) => { + // console.log(`Trace on ${consumer.id}`, trace); + // }); + + // Store the Consumer into the consumerPeer data Object. + consumerPeer.addConsumer(consumer.id, consumer); + + // Set Consumer events. + consumer.on('transportclose', () => { + // Remove from its map. + consumerPeer.removeConsumer(consumer.id); + }); + + consumer.on('producerclose', () => { + // Remove from its map. + consumerPeer.removeConsumer(consumer.id); + + this._notification(consumerPeer.socket, 'consumerClosed', { consumerId: consumer.id }); + }); + + consumer.on('producerpause', () => { + this._notification(consumerPeer.socket, 'consumerPaused', { consumerId: consumer.id }); + }); + + consumer.on('producerresume', () => { + this._notification(consumerPeer.socket, 'consumerResumed', { consumerId: consumer.id }); + }); + + consumer.on("layerschange", (layers) => { + this._notification(consumerPeer.socket, 'consumerLayersChanged', { consumerId: consumer.id, layers: layers }); + }) + + consumer.on("score", (score) => { + this._notification(consumerPeer.socket, 'consumerScoreChanged', { consumerId: consumer.id, score: score }); + }) + + // Send a request to the remote Peer with Consumer parameters. + try { + await this._request( + consumerPeer.socket, + 'newConsumer', + { + peerId : producerPeer.id, + kind : consumer.kind, + producerId : producer.id, + id : consumer.id, + rtpParameters : consumer.rtpParameters, + type : consumer.type, + appData : producer.appData, + producerPaused : consumer.producerPaused + } + ); + + // Now that we got the positive response from the remote Peer and, if + // video, resume the Consumer to ask for an efficient key frame. + await consumer.resume(); + } catch (error) { + logger.warn('_createConsumer() | [error:"%o"]', error); + } + } + + /** + * Get the list of peers. + */ + getPeers(excludePeer = undefined) { + return Object.values(this._peers) + .filter((peer) => peer !== excludePeer); + } + + _timeoutCallback(callback) { + let called = false; + + const interval = setTimeout( + () => { + if (called) + return; + called = true; + callback(new SocketTimeoutError('Request timed out')); + }, + config.requestTimeout || 20000 + ); + + return (...args) => { + if (called) + return; + called = true; + clearTimeout(interval); + + callback(...args); + }; + } + + _sendRequest(socket, method, data = {}) { + return new Promise((resolve, reject) => { + socket.emit( + 'request', + { method, data }, + this._timeoutCallback((err, response) => { + if (err) { + reject(err); + } else { + resolve(response); + } + }) + ); + }); + } + + async _request(socket, method, data) { + logger.debug('_request() [method:"%s", data:"%o"]', method, data); + + const { + requestRetries = 3 + } = config; + + for (let tries = 0; tries < requestRetries; tries++) { + try { + return await this._sendRequest(socket, method, data); + } catch (error) { + if ( + error instanceof SocketTimeoutError && + tries < requestRetries + ) + logger.warn('_request() | timeout, retrying [attempt:"%s"]', tries); + else + throw error; + } + } + } + + _notification(socket, method, data = {}, broadcast = false, includeSender = false) { + if (broadcast) { + socket.broadcast.to(this._roomId).emit( + 'notification', { method, data } + ); + + if (includeSender) + socket.emit('notification', { method, data }); + } else { + socket.emit('notification', { method, data }); + } + } + + // Returns an array of router ids we need to pipe to: + // The combined set of routers of all peers, exluding the router of the peer itself. + _getRoutersToPipeTo(originRouterId) { + return Array.from(this._mediasoupRouters.keys()) + .filter((routerId) => routerId !== originRouterId); + } +} + +module.exports = Room; diff --git a/meet/server/lib/errors.js b/meet/server/lib/errors.js new file mode 100644 --- /dev/null +++ b/meet/server/lib/errors.js @@ -0,0 +1,21 @@ +/** + * Error produced when a socket request has a timeout. + */ +class SocketTimeoutError extends Error { + constructor(message) { + super(message); + + this.name = 'SocketTimeoutError'; + + // eslint-disable-next-line no-prototype-builtins + if (Error.hasOwnProperty('captureStackTrace')) // Just in V8. + Error.captureStackTrace(this, SocketTimeoutError); + else + this.stack = (new Error(message)).stack; + } +} + +module.exports = +{ + SocketTimeoutError +}; diff --git a/meet/server/lib/interactiveClient.js b/meet/server/lib/interactiveClient.js new file mode 100644 --- /dev/null +++ b/meet/server/lib/interactiveClient.js @@ -0,0 +1,25 @@ +const net = require('net'); +const os = require('os'); +const path = require('path'); + +const SOCKET_PATH_UNIX = '/tmp/kolabmeet-server.sock'; +const SOCKET_PATH_WIN = path.join('\\\\?\\pipe', process.cwd(), 'kolabmeet-server'); +const SOCKET_PATH = os.platform() === 'win32'? SOCKET_PATH_WIN : SOCKET_PATH_UNIX; + +module.exports = async function() { + const socket = net.connect(SOCKET_PATH); + + process.stdin.pipe(socket); + socket.pipe(process.stdout); + + socket.on('connect', () => process.stdin.setRawMode(true)); + + socket.on('close', () => process.exit(0)); + socket.on('exit', () => socket.end()); + + if (process.argv && process.argv[2] === '--stats') { + await socket.write('stats\n'); + + socket.end(); + } +}; diff --git a/meet/server/lib/interactiveServer.js b/meet/server/lib/interactiveServer.js new file mode 100644 --- /dev/null +++ b/meet/server/lib/interactiveServer.js @@ -0,0 +1,503 @@ +const os = require('os'); +const path = require('path'); +const repl = require('repl'); +const readline = require('readline'); +const net = require('net'); +const fs = require('fs'); +const mediasoup = require('mediasoup'); +const colors = require('colors/safe'); +const pidusage = require('pidusage'); + +const SOCKET_PATH_UNIX = '/tmp/kolabmeet-server.sock'; +const SOCKET_PATH_WIN = path.join('\\\\?\\pipe', process.cwd(), 'kolabmeet-server'); +const SOCKET_PATH = os.platform() === 'win32' ? SOCKET_PATH_WIN : SOCKET_PATH_UNIX; + +// Maps to store all mediasoup objects. +const workers = new Map(); +const routers = new Map(); +const transports = new Map(); +const producers = new Map(); +const consumers = new Map(); +const dataProducers = new Map(); +const dataConsumers = new Map(); + +class Interactive { + constructor(socket) { + this._socket = socket; + + this._isTerminalOpen = false; + } + + async dump(name, params, entries) { + const list = params[0] ? [entries.get(params[0])] : entries.values(); + + for (const entry of list) { + if (!entry) { + this.error(`${name} not found`); + break; + } + + try { + const dump = await entry.dump(); + this.log(`${name}.dump():\n${JSON.stringify(dump, null, ' ')}`); + } catch (error) { + this.error(`${name}.dump() failed: ${error}`); + } + } + } + + async dumpStats(name, params, entries) { + const list = params[0] ? [entries.get(params[0])] : entries.values(); + + for (const entry of list) { + if (!entry) { + this.error(`${name} not found`); + break; + } + + try { + const stats = await entry.getStats(); + this.log(`${name}.getStats():\n${JSON.stringify(stats, null, ' ')}`); + } catch (error) { + this.error(`${name}.getStats() failed: ${error}`); + } + } + } + + async processCommand(command, params) { + switch (command) { + case '': + { + break; + } + + case 'h': + case 'help': + { + this.log(''); + this.log('available commands:'); + this.log('- h, help : show this message'); + this.log('- usage : show CPU and memory usage of the Node.js and mediasoup-worker processes'); + this.log('- logLevel level : changes logLevel in all mediasoup Workers'); + this.log('- logTags [tag] [tag] : changes logTags in all mediasoup Workers (values separated by space)'); + this.log('- dumpRooms : dump all rooms'); + this.log('- dumpPeers : dump all peers'); + this.log('- dw, dumpWorkers : dump mediasoup Workers'); + this.log('- dr, dumpRouter [id] : dump mediasoup Router with given id (or the latest created one)'); + this.log('- dt, dumpTransport [id] : dump mediasoup Transport with given id (or the latest created one)'); + this.log('- dp, dumpProducer [id] : dump mediasoup Producer with given id (or the latest created one)'); + this.log('- dc, dumpConsumer [id] : dump mediasoup Consumer with given id (or the latest created one)'); + this.log('- sr, statsRoom [id] : get stats for the room with id'); + this.log('- st, statsTransport [id] : get stats for mediasoup Transport with given id (or all)'); + this.log('- sp, statsProducer [id] : get stats for mediasoup Producer with given id (or all)'); + this.log('- sc, statsConsumer [id] : get stats for mediasoup Consumer with given id (or all)'); + this.log('- ddp, dumpDataProducer [id] : dump mediasoup DataProducer with given id (or the latest created one)'); + this.log('- ddc, dumpDataConsumer [id] : dump mediasoup DataConsumer with given id (or the latest created one)'); + this.log('- sdp, statsDataProducer [id] : get stats for mediasoup DataProducer with given id (or the latest created one)'); + this.log('- sdc, statsDataConsumer [id] : get stats for mediasoup DataConsumer with given id (or the latest created one)'); + this.log('- t, terminal : open Node REPL Terminal'); + this.log(''); + + break; + } + + case 'u': + case 'usage': + { + let usage = await pidusage(process.pid); + + this.log(`Node.js process [pid:${process.pid}]:\n${JSON.stringify(usage, null, ' ')}`); + + for (const worker of workers.values()) { + usage = await pidusage(worker.pid); + + this.log(`mediasoup-worker process [pid:${worker.pid}]:\n${JSON.stringify(usage, null, ' ')}`); + } + + break; + } + + case 'logLevel': + { + const level = params[0]; + const promises = []; + + for (const worker of workers.values()) { + promises.push(worker.updateSettings({ logLevel: level })); + } + + try { + await Promise.all(promises); + + this.log('done'); + } catch (error) { + this.error(String(error)); + } + + break; + } + + case 'logTags': + { + const tags = params; + const promises = []; + + for (const worker of workers.values()) { + promises.push(worker.updateSettings({ logTags: tags })); + } + + try { + await Promise.all(promises); + + this.log('done'); + } catch (error) { + this.error(String(error)); + } + + break; + } + + case 'stats': + { + this.log(`rooms:${global.rooms.size}\npeers:${global.peers.size}`); + + break; + } + + case 'sr': + case 'statsRoom': { + const room = global.rooms.get(params[0]); + this.log(`Room ${room._roomId}`); + this.log(`Stats \n${JSON.stringify(room.stats(), null, ' ')}`); + for (const peer of Object.values(room._peers)) { + this.log(`Peer ${peer._nickname}`); + for (const entry of peer._consumers.values()) { + const stats = await entry.getStats(); + this.log(`Consumer:\n${JSON.stringify(stats, null, ' ')}`); + } + for (const entry of peer._producers.values()) { + const stats = await entry.getStats(); + this.log(`Producer:\n${JSON.stringify(stats, null, ' ')}`); + } + for (const entry of peer._transports.values()) { + const stats = await entry.getStats(); + this.log(`Transport:\n${JSON.stringify(stats, null, ' ')}`); + } + } + break; + } + + case 'dumpRooms': + { + for (const room of global.rooms.values()) { + try { + const dump = await room.dump(); + + this.log(`room.dump():\n${JSON.stringify(dump, null, ' ')}`); + } catch (error) { + this.error(`room.dump() failed: ${error}`); + } + } + + break; + } + + case 'dumpPeers': + { + for (const peer of global.peers.values()) { + try { + const dump = await peer.peerInfo; + + this.log(`peer.peerInfo():\n${JSON.stringify(dump, null, ' ')}`); + } catch (error) { + this.error(`peer.peerInfo() failed: ${error}`); + } + } + + break; + } + + case 'dw': + case 'dumpWorkers': + { + for (const worker of workers.values()) { + try { + const dump = await worker.dump(); + + this.log(`worker.dump():\n${JSON.stringify(dump, null, ' ')}`); + } catch (error) { + this.error(`worker.dump() failed: ${error}`); + } + } + + break; + } + + case 'dr': + case 'dumpRouter': + { + await this.dump('router', params, routers); + break; + } + + case 'dt': + case 'dumpTransport': + { + await this.dump('transport', params, transports); + break; + } + + case 'dp': + case 'dumpProducer': + { + await this.dump('producer', params, producers); + break; + } + + case 'dc': + case 'dumpConsumer': + { + await this.dump('consumer', params, consumers); + break; + } + + case 'ddp': + case 'dumpDataProducer': + { + await this.dump('dataProducer', params, dataProducers); + break; + } + + case 'ddc': + case 'dumpDataConsumer': + { + await this.dump('dataConsumer', params, dataConsumers); + break; + } + + case 'st': + case 'statsTransport': + { + await this.dumpStats('transport', params, transports); + break; + } + + case 'sp': + case 'statsProducer': + { + await this.dumpStats('producer', params, producers); + break; + } + + case 'sc': + case 'statsConsumer': + { + await this.dumpStats('consumer', params, consumers); + break; + } + + case 'sdp': + case 'statsDataProducer': + { + await this.dumpStats('dataProducer', params, dataProducers); + break; + } + + case 'sdc': + case 'statsDataConsumer': + { + await this.dumpStats('dataConsumer', params, dataConsumers); + break; + } + + case 't': + case 'terminal': + { + return false; + } + + default: + { + this.error(`unknown command '${command}'`); + this.log('press \'h\' or \'help\' to get the list of available commands'); + } + } + return true; + } + + openCommandConsole() { + const cmd = readline.createInterface( + { + input : this._socket, + output : this._socket, + terminal : true + }); + + cmd.on('close', () => { + if (this._isTerminalOpen) + return; + + this.log('\nexiting...'); + + this._socket.end(); + }); + + const readStdin = () => { + cmd.question('cmd> ', async (input) => { + const params = input.split(/[\s\t]+/); + const command = params.shift(); + try { + const ret = await this.processCommand(command, params); + if (!ret) { + this._isTerminalOpen = true; + cmd.close(); + this.openTerminal(); + return; + } + } catch (error) { + this.error(`Processing of command ${command} ${params} failed: ${error}`); + } + + readStdin(); + }); + }; + + readStdin(); + } + + openTerminal() { + this.log('\n[opening Node REPL Terminal...]'); + this.log('here you have access to workers, routers, transports, producers, consumers, dataProducers and dataConsumers ES6 maps'); + + const terminal = repl.start( + { + input : this._socket, + output : this._socket, + terminal : true, + prompt : 'terminal> ', + useColors : true, + useGlobal : true, + ignoreUndefined : false + }); + + this._isTerminalOpen = true; + + terminal.on('exit', () => { + this.log('\n[exiting Node REPL Terminal...]'); + + this._isTerminalOpen = false; + + this.openCommandConsole(); + }); + } + + log(msg) { + try { + this._socket.write(`${colors.green(msg)}\n`); + } catch (error) { + //Do nothing + } + } + + error(msg) { + try { + this._socket.write(`${colors.red.bold('ERROR: ')}${colors.red(msg)}\n`); + } catch (error) { + //Do nothing + } + } +} + +function runMediasoupObserver() { + mediasoup.observer.on('newworker', (worker) => { + // Store the latest worker in a global variable. + global.worker = worker; + + workers.set(worker.pid, worker); + worker.observer.on('close', () => workers.delete(worker.pid)); + + worker.observer.on('newrouter', (router) => { + // Store the latest router in a global variable. + global.router = router; + + routers.set(router.id, router); + router.observer.on('close', () => routers.delete(router.id)); + + router.observer.on('newtransport', (transport) => { + // Store the latest transport in a global variable. + global.transport = transport; + + transports.set(transport.id, transport); + transport.observer.on('close', () => transports.delete(transport.id)); + + transport.observer.on('newproducer', (producer) => { + // Store the latest producer in a global variable. + global.producer = producer; + + producers.set(producer.id, producer); + producer.observer.on('close', () => producers.delete(producer.id)); + }); + + transport.observer.on('newconsumer', (consumer) => { + // Store the latest consumer in a global variable. + global.consumer = consumer; + + consumers.set(consumer.id, consumer); + consumer.observer.on('close', () => consumers.delete(consumer.id)); + }); + + transport.observer.on('newdataproducer', (dataProducer) => { + // Store the latest dataProducer in a global variable. + global.dataProducer = dataProducer; + + dataProducers.set(dataProducer.id, dataProducer); + dataProducer.observer.on('close', () => dataProducers.delete(dataProducer.id)); + }); + + transport.observer.on('newdataconsumer', (dataConsumer) => { + // Store the latest dataConsumer in a global variable. + global.dataConsumer = dataConsumer; + + dataConsumers.set(dataConsumer.id, dataConsumer); + dataConsumer.observer.on('close', () => dataConsumers.delete(dataConsumer.id)); + }); + }); + }); + }); +} + +module.exports = async function(rooms, peers) { + try { + // Run the mediasoup observer API. + runMediasoupObserver(); + + // Make maps global so they can be used during the REPL terminal. + global.rooms = rooms; + global.peers = peers; + global.workers = workers; + global.routers = routers; + global.transports = transports; + global.producers = producers; + global.consumers = consumers; + global.dataProducers = dataProducers; + global.dataConsumers = dataConsumers; + + const server = net.createServer((socket) => { + const interactive = new Interactive(socket); + + interactive.openCommandConsole(); + }); + + await new Promise((resolve) => { + try { + fs.unlinkSync(SOCKET_PATH); + } catch (error) { + //Do nothing + } + + server.listen(SOCKET_PATH, resolve); + }); + } catch (error) { + //Do nothing + } +}; diff --git a/meet/server/lib/userRoles.js b/meet/server/lib/userRoles.js new file mode 100644 --- /dev/null +++ b/meet/server/lib/userRoles.js @@ -0,0 +1,7 @@ +module.exports = { + SUBSCRIBER: 1 << 0, + PUBLISHER: 1 << 1, + MODERATOR: 1 << 2, + SCREEN: 1 << 3, + OWNER: 1 << 4 +}; diff --git a/meet/server/package-lock.json b/meet/server/package-lock.json new file mode 100644 --- /dev/null +++ b/meet/server/package-lock.json @@ -0,0 +1,2530 @@ +{ + "name": "kolabmeet-server", + "requires": true, + "lockfileVersion": 1, + "dependencies": { + "@eslint/eslintrc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.0.3.tgz", + "integrity": "sha512-DHI1wDPoKCBPoLZA3qDR91+3te/wDSc1YhKg3jR8NxKKRJq2hwHwcWv31cSwSYvIBrmbENoYMWcenW8uproQqg==", + "dev": true, + "requires": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.0.0", + "globals": "^13.9.0", + "ignore": "^4.0.6", + "import-fresh": "^3.2.1", + "js-yaml": "^3.13.1", + "minimatch": "^3.0.4", + "strip-json-comments": "^3.1.1" + }, + "dependencies": { + "js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + } + } + }, + "@humanwhocodes/config-array": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.6.0.tgz", + "integrity": "sha512-JQlEKbcgEUjBFhLIF4iqM7u/9lwgHRBcpHrmUNCALK0Q3amXN6lxdoXLnF0sm11E9VqTmBALR87IlUg1bZ8A9A==", + "dev": true, + "requires": { + "@humanwhocodes/object-schema": "^1.2.0", + "debug": "^4.1.1", + "minimatch": "^3.0.4" + } + }, + "@humanwhocodes/object-schema": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz", + "integrity": "sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w==", + "dev": true + }, + "@types/component-emitter": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@types/component-emitter/-/component-emitter-1.2.11.tgz", + "integrity": "sha512-SRXjM+tfsSlA9VuG8hGO2nft2p8zjXCK1VcC6N4NXbBbYbSia9kzCChYQajIjzIqOOOuh5Ock6MmV2oux4jDZQ==" + }, + "@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==" + }, + "@types/cors": { + "version": "2.8.12", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.12.tgz", + "integrity": "sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==" + }, + "@types/debug": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.7.tgz", + "integrity": "sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==", + "dev": true, + "requires": { + "@types/ms": "*" + } + }, + "@types/events": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz", + "integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==", + "dev": true + }, + "@types/ms": { + "version": "0.7.31", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", + "integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==", + "dev": true + }, + "@types/node": { + "version": "16.11.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.1.tgz", + "integrity": "sha512-PYGcJHL9mwl1Ek3PLiYgyEKtwTMmkMw4vbiyz/ps3pfdRYLVv+SN7qHVAImrjdAXxgluDEw6Ph4lyv+m9UpRmA==" + }, + "@ungap/promise-all-settled": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz", + "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==", + "dev": true + }, + "accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==", + "requires": { + "mime-types": "~2.1.24", + "negotiator": "0.6.2" + } + }, + "acorn": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.5.0.tgz", + "integrity": "sha512-yXbYeFy+jUuYd3/CDcg2NkIYE991XYX/bje7LmjJigUciaeO1JR4XxXgCIV1/Zc/dRuFEyw1L0pbA+qynJkW5Q==", + "dev": true + }, + "acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", + "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "dev": true + }, + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true + }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "anymatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", + "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", + "dev": true, + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", + "dev": true + }, + "awaitqueue": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/awaitqueue/-/awaitqueue-1.0.1.tgz", + "integrity": "sha512-v5XFR8slds87u7WkjWUtuMXUBEaqfg1WJ8yxrUDbo8aodUQLEVag0MZYBptRP/TmMGOYn0YHS6ar8aqVCJVynA==" + }, + "axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "requires": { + "follow-redirects": "^1.14.0" + } + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "base64-arraybuffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.1.tgz", + "integrity": "sha512-vFIUq7FdLtjZMhATwDul5RZWv2jpXQ09Pd6jcVEOvIsqCWTRFD/ONHNfyOS8dA/Ippi5dsIgpyKWKZaAKZltbA==" + }, + "base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==" + }, + "binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true + }, + "bintrees": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.1.tgz", + "integrity": "sha1-DmVcm5wkNeqraL9AJyJtK1WjRSQ=" + }, + "body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==", + "requires": { + "bytes": "3.1.0", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "~1.1.2", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "on-finished": "~2.3.0", + "qs": "6.7.0", + "raw-body": "2.4.0", + "type-is": "~1.6.17" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + } + } + }, + "bowser": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.9.0.tgz", + "integrity": "sha512-2ld76tuLBNFekRgmJfT2+3j5MIrP6bFict8WAIT3beq+srz1gcKNAdNKMqHqauQt63NmAa88HfP1/Ypa9Er3HA==" + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true + }, + "bytes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", + "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==" + }, + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true + }, + "camelcase": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz", + "integrity": "sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==", + "dev": true + }, + "camelize": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.0.tgz", + "integrity": "sha1-FkpUg+Yw+kMh5a8HAg5TGDGyYJs=" + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "child_process": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/child_process/-/child_process-1.0.2.tgz", + "integrity": "sha1-sffn/HPSXn/R1FWtyU4UODAYK1o=", + "dev": true + }, + "chokidar": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz", + "integrity": "sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ==", + "dev": true, + "requires": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "fsevents": "~2.3.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + } + }, + "cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + }, + "dependencies": { + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + } + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==" + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "component-emitter": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz", + "integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==" + }, + "compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "requires": { + "mime-db": ">= 1.43.0 < 2" + } + }, + "compression": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", + "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "requires": { + "accepts": "~1.3.5", + "bytes": "3.0.0", + "compressible": "~2.0.16", + "debug": "2.6.9", + "on-headers": "~1.0.2", + "safe-buffer": "5.1.2", + "vary": "~1.1.2" + }, + "dependencies": { + "bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=" + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + } + } + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "content-disposition": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz", + "integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==", + "requires": { + "safe-buffer": "5.1.2" + } + }, + "content-security-policy-builder": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-security-policy-builder/-/content-security-policy-builder-2.1.0.tgz", + "integrity": "sha512-/MtLWhJVvJNkA9dVLAp6fg9LxD2gfI6R2Fi1hPmfjYXSahJJzcfvoeDOxSyp4NvxMuwWv3WMssE9o31DoULHrQ==" + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" + }, + "cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==" + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" + }, + "cookiejar": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.3.tgz", + "integrity": "sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ==", + "dev": true + }, + "core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, + "cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "dasherize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dasherize/-/dasherize-2.0.0.tgz", + "integrity": "sha1-bYCcnNDPe7iVLYD8hPoT1H3bEwg=" + }, + "debug": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "requires": { + "ms": "2.1.2" + }, + "dependencies": { + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, + "decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true + }, + "deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "dev": true + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + }, + "detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==" + }, + "dgram": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dgram/-/dgram-1.0.1.tgz", + "integrity": "sha1-N/OyAPgDOl/3WTAwicgc42G2UcM=", + "dev": true + }, + "diff": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", + "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", + "dev": true + }, + "doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "dont-sniff-mimetype": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/dont-sniff-mimetype/-/dont-sniff-mimetype-1.1.0.tgz", + "integrity": "sha512-ZjI4zqTaxveH2/tTlzS1wFp+7ncxNZaIEWYg3lzZRHkKf5zPT/MnEG6WL0BhHMJUabkh8GeU5NL5j+rEUCb7Ug==" + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" + }, + "engine.io": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.0.0.tgz", + "integrity": "sha512-Ui7yl3JajEIaACg8MOUwWvuuwU7jepZqX3BKs1ho7NQRuP4LhN4XIykXhp8bEy+x/DhA0LBZZXYSCkZDqrwMMg==", + "requires": { + "@types/cookie": "^0.4.1", + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.4.1", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.0.0", + "ws": "~8.2.3" + }, + "dependencies": { + "cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==" + } + } + }, + "engine.io-parser": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.1.tgz", + "integrity": "sha512-j4p3WwJrG2k92VISM0op7wiq60vO92MlF3CRGxhKHy9ywG1/Dkc72g0dXeDQ+//hrcDn8gqQzoEkdO9FN0d9AA==", + "requires": { + "base64-arraybuffer": "~1.0.1" + } + }, + "enquirer": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", + "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", + "dev": true, + "requires": { + "ansi-colors": "^4.1.1" + } + }, + "escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true + }, + "eslint": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.0.1.tgz", + "integrity": "sha512-LsgcwZgQ72vZ+SMp4K6pAnk2yFDWL7Ti4pJaRvsZ0Hsw2h8ZjUIW38a9AFn2cZXdBMlScMFYYgsSp4ttFI/0bA==", + "dev": true, + "requires": { + "@eslint/eslintrc": "^1.0.3", + "@humanwhocodes/config-array": "^0.6.0", + "ajv": "^6.10.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "enquirer": "^2.3.5", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^6.0.0", + "eslint-utils": "^3.0.0", + "eslint-visitor-keys": "^3.0.0", + "espree": "^9.0.0", + "esquery": "^1.4.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "functional-red-black-tree": "^1.0.1", + "glob-parent": "^6.0.1", + "globals": "^13.6.0", + "ignore": "^4.0.6", + "import-fresh": "^3.0.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.0.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.1", + "progress": "^2.0.0", + "regexpp": "^3.2.0", + "semver": "^7.2.1", + "strip-ansi": "^6.0.0", + "strip-json-comments": "^3.1.0", + "text-table": "^0.2.0", + "v8-compile-cache": "^2.0.3" + }, + "dependencies": { + "glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "requires": { + "is-glob": "^4.0.3" + } + } + } + }, + "eslint-scope": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-6.0.0.tgz", + "integrity": "sha512-uRDL9MWmQCkaFus8RF5K9/L/2fn+80yoW3jkD53l4shjCh26fCtvJGasxjUqP5OT87SYTxCVA3BwTUzuELx9kA==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + } + }, + "eslint-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", + "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^2.0.0" + }, + "dependencies": { + "eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true + } + } + }, + "eslint-visitor-keys": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.0.0.tgz", + "integrity": "sha512-mJOZa35trBTb3IyRmo8xmKBZlxf+N7OnUl4+ZhJHs/r+0770Wh/LEACE2pqMGMe27G/4y8P2bYGk4J70IC5k1Q==", + "dev": true + }, + "espree": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.0.0.tgz", + "integrity": "sha512-r5EQJcYZ2oaGbeR0jR0fFVijGOcwai07/690YRXLINuhmVeRY4UKSAsQPe/0BNuDgwP7Ophoc1PRsr2E3tkbdQ==", + "dev": true, + "requires": { + "acorn": "^8.5.0", + "acorn-jsx": "^5.3.1", + "eslint-visitor-keys": "^3.0.0" + } + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + }, + "esquery": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", + "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", + "dev": true, + "requires": { + "estraverse": "^5.1.0" + } + }, + "esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "requires": { + "estraverse": "^5.2.0" + } + }, + "estraverse": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", + "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==", + "dev": true + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" + }, + "event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true + }, + "events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true + }, + "express": { + "version": "4.17.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz", + "integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==", + "requires": { + "accepts": "~1.3.7", + "array-flatten": "1.1.1", + "body-parser": "1.19.0", + "content-disposition": "0.5.3", + "content-type": "~1.0.4", + "cookie": "0.4.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~1.1.2", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.1.2", + "fresh": "0.5.2", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.5", + "qs": "6.7.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.1.2", + "send": "0.17.1", + "serve-static": "1.14.1", + "setprototypeof": "1.1.1", + "statuses": "~1.5.0", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + } + } + }, + "fake-mediastreamtrack": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/fake-mediastreamtrack/-/fake-mediastreamtrack-1.1.6.tgz", + "integrity": "sha512-lcoO5oPsW57istAsnjvQxNjBEahi18OdUhWfmEewwfPfzNZnji5OXuodQM+VnUPi/1HnQRJ6gBUjbt1TNXrkjQ==", + "dev": true, + "requires": { + "event-target-shim": "^5.0.1", + "uuid": "^8.1.0" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true + } + } + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true + }, + "fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true + }, + "feature-policy": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/feature-policy/-/feature-policy-0.3.0.tgz", + "integrity": "sha512-ZtijOTFN7TzCujt1fnNhfWPFPSHeZkesff9AXZj+UEjYBynWNUIYpC87Ve4wHzyexQsImicLu7WsC2LHq7/xrQ==" + }, + "file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "requires": { + "flat-cache": "^3.0.4" + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + } + } + }, + "find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true + }, + "flat-cache": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", + "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "dev": true, + "requires": { + "flatted": "^3.1.0", + "rimraf": "^3.0.2" + } + }, + "flatted": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.2.tgz", + "integrity": "sha512-JaTY/wtrcSyvXJl4IMFHPKyFur1sE9AUqc0QnhOaJ0CxHtAoIV8pYDzeEfAaNEtGkOfq4gr3LBFmdXW5mOQFnA==", + "dev": true + }, + "follow-redirects": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz", + "integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==" + }, + "form-data": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", + "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, + "formidable": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.2.tgz", + "integrity": "sha512-V8gLm+41I/8kguQ4/o1D3RIHRmhYFG4pnNyonvua+40rqcEmT4+V71yaZ3B457xbbgCsCfjSPi65u/W6vK1U5Q==", + "dev": true + }, + "forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "optional": true + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", + "dev": true + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true + }, + "get-intrinsic": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", + "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", + "dev": true, + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.1" + } + }, + "glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, + "globals": { + "version": "13.11.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.11.0.tgz", + "integrity": "sha512-08/xrJ7wQjK9kkkRoI3OFUBbLx4f+6x3SGwcPvQ0QH6goFDrOU2oyAWrmh3dJezu65buo+HBMzAMQy6rovVC3g==", + "dev": true, + "requires": { + "type-fest": "^0.20.2" + } + }, + "growl": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", + "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", + "dev": true + }, + "h264-profile-level-id": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/h264-profile-level-id/-/h264-profile-level-id-1.0.1.tgz", + "integrity": "sha512-D3Rln/jKNjKDW5ZTJTK3niSoOGE+pFqPvRHHVgQN3G7umcn/zWGPUo8Q8VpDj16x3hKz++zVviRNRmXu5cpN+Q==", + "requires": { + "debug": "^4.1.1" + } + }, + "handle-thing": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==" + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-flag": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-5.0.1.tgz", + "integrity": "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==" + }, + "has-symbols": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", + "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==", + "dev": true + }, + "he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true + }, + "helmet": { + "version": "3.23.3", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-3.23.3.tgz", + "integrity": "sha512-U3MeYdzPJQhtvqAVBPntVgAvNSOJyagwZwyKsFdyRa8TV3pOKVFljalPOCxbw5Wwf2kncGhmP0qHjyazIdNdSA==", + "requires": { + "depd": "2.0.0", + "dont-sniff-mimetype": "1.1.0", + "feature-policy": "0.3.0", + "helmet-crossdomain": "0.4.0", + "helmet-csp": "2.10.0", + "hide-powered-by": "1.1.0", + "hpkp": "2.0.0", + "hsts": "2.2.0", + "nocache": "2.1.0", + "referrer-policy": "1.2.0", + "x-xss-protection": "1.3.0" + }, + "dependencies": { + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + } + } + }, + "helmet-crossdomain": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/helmet-crossdomain/-/helmet-crossdomain-0.4.0.tgz", + "integrity": "sha512-AB4DTykRw3HCOxovD1nPR16hllrVImeFp5VBV9/twj66lJ2nU75DP8FPL0/Jp4jj79JhTfG+pFI2MD02kWJ+fA==" + }, + "helmet-csp": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/helmet-csp/-/helmet-csp-2.10.0.tgz", + "integrity": "sha512-Rz953ZNEFk8sT2XvewXkYN0Ho4GEZdjAZy4stjiEQV3eN7GDxg1QKmYggH7otDyIA7uGA6XnUMVSgeJwbR5X+w==", + "requires": { + "bowser": "2.9.0", + "camelize": "1.0.0", + "content-security-policy-builder": "2.1.0", + "dasherize": "2.0.0" + } + }, + "hide-powered-by": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hide-powered-by/-/hide-powered-by-1.1.0.tgz", + "integrity": "sha512-Io1zA2yOA1YJslkr+AJlWSf2yWFkKjvkcL9Ni1XSUqnGLr/qRQe2UI3Cn/J9MsJht7yEVCe0SscY1HgVMujbgg==" + }, + "hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha1-h3dMCUnlE/QuhFdbPEVoH63ioLI=", + "requires": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + } + } + }, + "hpkp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hpkp/-/hpkp-2.0.0.tgz", + "integrity": "sha1-EOFCJk52IVpdMMROxD3mTe5tFnI=" + }, + "hsts": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hsts/-/hsts-2.2.0.tgz", + "integrity": "sha512-ToaTnQ2TbJkochoVcdXYm4HOCliNozlviNsg+X2XQLQvZNI/kCHR9rZxVYpJB3UPcHz80PgxRyWQ7PdU1r+VBQ==", + "requires": { + "depd": "2.0.0" + }, + "dependencies": { + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + } + } + }, + "http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc=" + }, + "http-errors": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz", + "integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + } + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ignore": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", + "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", + "dev": true + }, + "import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "requires": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + } + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + }, + "ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" + }, + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "requires": { + "binary-extensions": "^2.0.0" + } + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true + }, + "is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "requires": { + "argparse": "^2.0.1" + }, + "dependencies": { + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + } + } + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", + "dev": true + }, + "levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + } + }, + "locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "requires": { + "p-locate": "^5.0.0" + } + }, + "lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "requires": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" + }, + "mediasoup": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/mediasoup/-/mediasoup-3.8.4.tgz", + "integrity": "sha512-l6rQwjcWN5VJ85yg8darZyPjSQVlE1GqrR1mIjUVa0F8MX6jOxsscXNzFisxTly4ngB2DTjnI8HEabmD5a5Vog==", + "requires": { + "@types/node": "^16.9.1", + "awaitqueue": "^2.3.3", + "debug": "^4.3.2", + "h264-profile-level-id": "^1.0.1", + "netstring": "^0.3.0", + "random-number": "^0.0.9", + "supports-color": "^9.0.2", + "uuid": "^8.3.2" + }, + "dependencies": { + "awaitqueue": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/awaitqueue/-/awaitqueue-2.3.3.tgz", + "integrity": "sha512-RbzQg6VtPUtyErm55iuQLTrBJ2uihy5BKBOEkyBwv67xm5Fn2o/j+Bz+a5BmfSoe2oZ5dcz9Z3fExS8pL+LLhw==" + }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + } + } + }, + "mediasoup-client": { + "version": "3.6.43", + "resolved": "https://registry.npmjs.org/mediasoup-client/-/mediasoup-client-3.6.43.tgz", + "integrity": "sha512-fieD9iLlLpyKMaW7EdE8RjOLonQYUoKHwDKHecwCZLXmP2RIwizTJN6NLXCOyappWX7rJNk95BgbLbJSxT28Xw==", + "dev": true, + "requires": { + "@types/debug": "^4.1.7", + "@types/events": "^3.0.0", + "awaitqueue": "^2.3.3", + "bowser": "^2.11.0", + "debug": "^4.3.2", + "events": "^3.3.0", + "fake-mediastreamtrack": "^1.1.6", + "h264-profile-level-id": "^1.0.1", + "sdp-transform": "^2.14.1", + "supports-color": "^8.1.1" + }, + "dependencies": { + "awaitqueue": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/awaitqueue/-/awaitqueue-2.3.3.tgz", + "integrity": "sha512-RbzQg6VtPUtyErm55iuQLTrBJ2uihy5BKBOEkyBwv67xm5Fn2o/j+Bz+a5BmfSoe2oZ5dcz9Z3fExS8pL+LLhw==", + "dev": true + }, + "bowser": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", + "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + }, + "mime-db": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.50.0.tgz", + "integrity": "sha512-9tMZCDlYHqeERXEHO9f/hKfNXhre5dK2eE/krIvUjZbS2KPcqGDfNShIWS1uW9XOTKQKqK6qbeOci18rbfW77A==" + }, + "mime-types": { + "version": "2.1.33", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.33.tgz", + "integrity": "sha512-plLElXp7pRDd0bNZHw+nMd52vRYjLwQjygaNg7ddJ2uJtTlmnTCjWuPKxVu6//AdaRuME84SvLW91sIkBqGT0g==", + "requires": { + "mime-db": "1.50.0" + } + }, + "minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "mocha": { + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-9.1.3.tgz", + "integrity": "sha512-Xcpl9FqXOAYqI3j79pEtHBBnQgVXIhpULjGQa7DVb0Po+VzmSIK9kanAiWLHoRR/dbZ2qpdPshuXr8l1VaHCzw==", + "dev": true, + "requires": { + "@ungap/promise-all-settled": "1.1.2", + "ansi-colors": "4.1.1", + "browser-stdout": "1.3.1", + "chokidar": "3.5.2", + "debug": "4.3.2", + "diff": "5.0.0", + "escape-string-regexp": "4.0.0", + "find-up": "5.0.0", + "glob": "7.1.7", + "growl": "1.10.5", + "he": "1.2.0", + "js-yaml": "4.1.0", + "log-symbols": "4.1.0", + "minimatch": "3.0.4", + "ms": "2.1.3", + "nanoid": "3.1.25", + "serialize-javascript": "6.0.0", + "strip-json-comments": "3.1.1", + "supports-color": "8.1.1", + "which": "2.0.2", + "workerpool": "6.1.5", + "yargs": "16.2.0", + "yargs-parser": "20.2.4", + "yargs-unparser": "2.0.0" + }, + "dependencies": { + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true + }, + "glob": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", + "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "requires": { + "argparse": "^2.0.1" + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "nanoid": { + "version": "3.1.25", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.25.tgz", + "integrity": "sha512-rdwtIXaXCLFAQbnfqDRnI6jaRHp9fTcYBjtFKE8eezcZ7LuLjhUaQGNeMXf1HmRoCH32CLz6XwX0TtxEOS/A3Q==", + "dev": true + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", + "dev": true + }, + "negotiator": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", + "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" + }, + "netstring": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/netstring/-/netstring-0.3.0.tgz", + "integrity": "sha1-ho3FsgxY0/cwVTHUk2jqqr0ZtxI=" + }, + "nocache": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/nocache/-/nocache-2.1.0.tgz", + "integrity": "sha512-0L9FvHG3nfnnmaEQPjT9xhfN4ISk0A8/2j4M37Np4mcDesJjHgEUfgPhdCyZuFI954tjokaIj/A3NdpFNdEh4Q==" + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, + "object-inspect": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.11.0.tgz", + "integrity": "sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg==", + "dev": true + }, + "obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==" + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "requires": { + "ee-first": "1.1.1" + } + }, + "on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==" + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "optionator": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", + "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "dev": true, + "requires": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.3" + } + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "requires": { + "p-limit": "^3.0.2" + } + }, + "parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "requires": { + "callsites": "^3.0.0" + } + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" + }, + "picomatch": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", + "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==", + "dev": true + }, + "pidusage": { + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/pidusage/-/pidusage-2.0.21.tgz", + "integrity": "sha512-cv3xAQos+pugVX+BfXpHsbyz/dLzX+lr44zNMsYiGxUw+kV5sgQCIcLd1z+0vq+KyC7dJ+/ts2PsfgWfSC3WXA==", + "requires": { + "safe-buffer": "^5.2.1" + }, + "dependencies": { + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + } + } + }, + "prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true + }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true + }, + "prom-client": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-14.0.0.tgz", + "integrity": "sha512-etPa4SMO4j6qTn2uaSZy7+uahGK0kXUZwO7WhoDpTf3yZ837I3jqUDYmG6N0caxuU6cyqrg0xmOxh+yneczvyA==", + "requires": { + "tdigest": "^0.1.1" + } + }, + "proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "requires": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + } + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true + }, + "qs": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", + "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" + }, + "random-number": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/random-number/-/random-number-0.0.9.tgz", + "integrity": "sha512-ipG3kRCREi/YQpi2A5QGcvDz1KemohovWmH6qGfboVyyGdR2t/7zQz0vFxrfxpbHQgPPdtVlUDaks3aikD1Ljw==" + }, + "randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.0" + } + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, + "raw-body": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz", + "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==", + "requires": { + "bytes": "3.1.0", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + } + }, + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "requires": { + "picomatch": "^2.2.1" + } + }, + "referrer-policy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/referrer-policy/-/referrer-policy-1.2.0.tgz", + "integrity": "sha512-LgQJIuS6nAy1Jd88DCQRemyE3mS+ispwlqMk3b0yjZ257fI1v9c+/p6SD5gP5FGyXUIgrNOAfmyioHwZtYv2VA==" + }, + "regexpp": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", + "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", + "dev": true + }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", + "dev": true + }, + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "sdp-transform": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.14.1.tgz", + "integrity": "sha512-RjZyX3nVwJyCuTo5tGPx+PZWkDMCg7oOLpSlhjDdZfwUoNqG1mM8nyj31IGHyaPWXhjbP7cdK3qZ2bmkJ1GzRw==", + "dev": true + }, + "select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo=" + }, + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "send": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz", + "integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==", + "requires": { + "debug": "2.6.9", + "depd": "~1.1.2", + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "~1.7.2", + "mime": "1.6.0", + "ms": "2.1.1", + "on-finished": "~2.3.0", + "range-parser": "~1.2.1", + "statuses": "~1.5.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + }, + "dependencies": { + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + } + } + }, + "serialize-javascript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", + "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", + "dev": true, + "requires": { + "randombytes": "^2.1.0" + } + }, + "serve-static": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz", + "integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==", + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.17.1" + } + }, + "setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, + "side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + } + }, + "socket.io": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.3.1.tgz", + "integrity": "sha512-HC5w5Olv2XZ0XJ4gOLGzzHEuOCfj3G0SmoW3jLHYYh34EVsIr3EkW9h6kgfW+K3TFEcmYy8JcPWe//KUkBp5jA==", + "requires": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "debug": "~4.3.2", + "engine.io": "~6.0.0", + "socket.io-adapter": "~2.3.2", + "socket.io-parser": "~4.0.4" + } + }, + "socket.io-adapter": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.3.2.tgz", + "integrity": "sha512-PBZpxUPYjmoogY0aoaTmo1643JelsaS1CiAwNjRVdrI0X9Seuc19Y2Wife8k88avW6haG8cznvwbubAZwH4Mtg==" + }, + "socket.io-parser": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.0.4.tgz", + "integrity": "sha512-t+b0SS+IxG7Rxzda2EVvyBZbvFPBCjJoyHuE0P//7OAsN23GItzDRdWa6ALxZI/8R5ygK7jAR6t028/z+7295g==", + "requires": { + "@types/component-emitter": "^1.2.10", + "component-emitter": "~1.3.0", + "debug": "~4.3.1" + } + }, + "spdy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", + "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "requires": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + } + }, + "spdy-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", + "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "requires": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" + }, + "string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "dependencies": { + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + } + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true + }, + "superagent": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-6.1.0.tgz", + "integrity": "sha512-OUDHEssirmplo3F+1HWKUrUjvnQuA+nZI6i/JJBdXb5eq9IyEQwPyPpqND+SSsxf6TygpBEkUjISVRN4/VOpeg==", + "dev": true, + "requires": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.2", + "debug": "^4.1.1", + "fast-safe-stringify": "^2.0.7", + "form-data": "^3.0.0", + "formidable": "^1.2.2", + "methods": "^1.1.2", + "mime": "^2.4.6", + "qs": "^6.9.4", + "readable-stream": "^3.6.0", + "semver": "^7.3.2" + }, + "dependencies": { + "mime": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.5.2.tgz", + "integrity": "sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==", + "dev": true + }, + "qs": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.1.tgz", + "integrity": "sha512-M528Hph6wsSVOBiYUnGf+K/7w0hNshs/duGsNXPUCLH5XAqjEtiPGwNONLV0tBH8NoGb0mvD5JubnUTrujKDTg==", + "dev": true, + "requires": { + "side-channel": "^1.0.4" + } + }, + "semver": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz", + "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "supertest": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.1.6.tgz", + "integrity": "sha512-0hACYGNJ8OHRg8CRITeZOdbjur7NLuNs0mBjVhdpxi7hP6t3QIbOzLON5RTUmZcy2I9riuII3+Pr2C7yztrIIg==", + "dev": true, + "requires": { + "methods": "^1.1.2", + "superagent": "^6.1.0" + } + }, + "supports-color": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-9.0.2.tgz", + "integrity": "sha512-ii6tc8ImGFrgMPYq7RVAMKkhPo9vk8uA+D3oKbJq/3Pk2YSMv1+9dUAesa9UxMbxBTvxwKTQffBahNVNxEvM8Q==", + "requires": { + "has-flag": "^5.0.0" + } + }, + "tdigest": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.1.tgz", + "integrity": "sha1-Ljyyw56kSeVdHmzZEReszKRYgCE=", + "requires": { + "bintrees": "1.0.1" + } + }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", + "dev": true + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" + }, + "type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "requires": { + "prelude-ls": "^1.2.1" + } + }, + "type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true + }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + } + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" + }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" + }, + "uuid": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", + "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==" + }, + "v8-compile-cache": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", + "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==", + "dev": true + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" + }, + "wbuf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", + "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "requires": { + "minimalistic-assert": "^1.0.0" + } + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "word-wrap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", + "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "dev": true + }, + "workerpool": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.1.5.tgz", + "integrity": "sha512-XdKkCK0Zqc6w3iTxLckiuJ81tiD/o5rBE/m+nXpRCB+/Sq4DqkfXZ/x0jW02DG1tGsfUGXbTJyZDP+eu67haSw==", + "dev": true + }, + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "requires": { + "ansi-regex": "^5.0.1" + } + } + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "ws": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", + "integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==" + }, + "x-xss-protection": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/x-xss-protection/-/x-xss-protection-1.3.0.tgz", + "integrity": "sha512-kpyBI9TlVipZO4diReZMAHWtS0MMa/7Kgx8hwG/EuZLiA6sg4Ah/4TRdASHhRRN3boobzcYgFRUFSgHRge6Qhg==" + }, + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "requires": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + } + }, + "yargs-parser": { + "version": "20.2.4", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", + "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", + "dev": true + }, + "yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "requires": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + } + }, + "yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true + } + } +} diff --git a/meet/server/package.json b/meet/server/package.json new file mode 100644 --- /dev/null +++ b/meet/server/package.json @@ -0,0 +1,39 @@ +{ + "name": "kolabmeet-server", + "private": true, + "license": "MIT", + "scripts": { + "start": "node server.js", + "connect": "node connect.js", + "lint": "eslint -c .eslintrc.json --ext .js *.js lib/", + "lint-fix": "eslint --fix -c .eslintrc.json --ext .js *.js lib/", + "test": "mocha --bail --inline-diffs --async-stack-traces --full-trace --exit test/test.js", + "performancetestbench": "mocha -b -t 0 test/performancetestbench.js", + "videoproducer": "node test/videoproducer.js" + }, + "dependencies": { + "awaitqueue": "^1.0.0", + "axios": "^0.21.1", + "body-parser": "^1.19.0", + "colors": "^1.4.0", + "compression": "^1.7.4", + "debug": "^4.1.1", + "express": "^4.17.1", + "helmet": "^3.21.2", + "mediasoup": "~3.9.2", + "pidusage": "^2.0.17", + "prom-client": ">=12.0.0", + "socket.io": "~4.3.1", + "spdy": "^4.0.1", + "uuid": "^7.0.2" + }, + "devDependencies": { + "child_process": "^1.0.2", + "eslint": "^8.0.1", + "mediasoup-client": "^3.6.37", + "mocha": "^9.1.1", + "supertest": "^6.1.6", + "socket.io-client": "^4.3.2", + "superagent": "^6.1.0" + } +} diff --git a/meet/server/server.js b/meet/server/server.js new file mode 100755 --- /dev/null +++ b/meet/server/server.js @@ -0,0 +1,421 @@ +#!/usr/bin/env node + +process.title = 'kolabmeet-server'; + +const config = require('./config/config'); +const fs = require('fs'); +const http = require('http'); +const spdy = require('spdy'); +const express = require('express'); +const bodyParser = require('body-parser'); +const compression = require('compression'); +const mediasoup = require('mediasoup'); +const Logger = require('./lib/Logger'); +const Room = require('./lib/Room'); +const Peer = require('./lib/Peer'); +const helmet = require('helmet'); +const axios = require('axios'); +const interactiveServer = require('./lib/interactiveServer'); +const { v4: uuidv4 } = require('uuid'); + +/* eslint-disable no-console */ +console.log('- process.env.DEBUG:', process.env.DEBUG); +console.log('- config.mediasoup.worker.logLevel:', config.mediasoup.worker.logLevel); +console.log('- config.mediasoup.worker.logTags:', config.mediasoup.worker.logTags); +/* eslint-enable no-console */ + + +if (!config.mediasoup.webRtcTransport.listenIps[0].ip) { + console.error('A webrtc listen ip is reuquired'); + process.exit(3) +} + +const logger = new Logger(); + +// mediasoup Workers. +// @type {Array} +const mediasoupWorkers = []; + +// Map of Room instances indexed by roomId. +const rooms = new Map(); + +// Map of Peer instances indexed by peerId. +const peers = new Map(); + +// HTTP client instance for webhook "pushes" +let webhook = null; +if (config.webhookURL) { + webhook = axios.create({ + baseURL: config.webhookURL, + headers: { 'X-Auth-Token': config.webhookToken }, + timeout: 5000 + }); +} + +const app = express(); + +app.use(helmet.hsts()); + +app.use((req, res, next) => { + if (req.get('X-Auth-Token') !== config.authToken) { + logger.debug("X-Auth-Token mismatch") + res.status(403).send(); + } else { + next(); + } +}); + +app.use(bodyParser.json({ limit: '5mb' })); +app.use(bodyParser.urlencoded({ limit: '5mb', extended: true })); + +let mainListener; +let io; + +async function run() { + try { + await interactiveServer(rooms, peers); + await runMediasoupWorkers(); + await runHttpsServer(); + await runWebSocketServer(); + + // eslint-disable-next-line no-unused-vars + const errorHandler = (err, req, res, next) => { + const trackingId = uuidv4(); + + res.status(500).send( + `

Internal Server Error

+

If you report this error, please also report this + tracking ID which makes it possible to locate your session + in the logs which are available to the system administrator: + ${trackingId}

` + ); + logger.error( + 'Express error handler dump with tracking ID: %s, error dump: %o', + trackingId, err); + }; + + app.use(errorHandler); + } catch (error) { + logger.error('run() [error:"%o"]', error); + } + + app.emit('ready'); +} + +async function runHttpsServer() { + app.use(compression()); + + app.get(`${config.pathPrefix}/api/stats`, async function (req, res) { + let stats = {}; + for (const room of rooms) { + let roomStats; + for (const peer of Object.values(room._peers)) { + let peerStats = { + id: peer.id, + nickname: peer._nickname, + consumers: [], + producers: [], + transports: [], + }; + for (const entry of peer._consumers.values()) { + peerStats.consumers.push(await entry.getStats()) + } + for (const entry of peer._producers.values()) { + peerStats.producers.push(await entry.getStats()) + } + for (const entry of peer._transports.values()) { + peerStats.transports.push(await entry.getStats()) + } + roomStats[peer.id] = peerStats; + } + stats[room.id] = roomStats; + } + res.send(stats); + }); + + app.get(`${config.pathPrefix}/api/health`, function (req, res) { + res.send({ success: true, message: "Healthy" }); + }); + + app.get(`${config.pathPrefix}/api/ping`, function (req, res) { + res.send('PONG'); + }) + + app.get(`${config.pathPrefix}/api/sessions`, function (req, res) { + let list = []; + rooms.forEach(room => { + list.push({ + roomId: room.id, + createdAt: room.createdAt + }) + }) + + res.json(list) + }) + + // Check if the room exists + app.get(`${config.pathPrefix}/api/sessions/:session_id`, function (req, res) { + const room = rooms.get(req.params.session_id); + + if (!room) { + res.status(404).send(); + } else { + res.status(200).send(); + } + }) + + // Create room and return id + app.post(`${config.pathPrefix}/api/sessions`, async function (req, res) { + console.log("Creating new room"); + + const room = await createRoom(); + + res.json({ + id : room.id + }) + }) + + // Send a websocket notification signals to the room participants + app.post(`${config.pathPrefix}/api/signal`, async function (req, res) { + const data = req.body; + const roomId = data.roomId; + const emit = (socket) => { + socket.emit('notification', { + method: `signal:${data.type}`, + data: data.data + }) + }; + + if ('role' in data) { + peers.forEach(peer => { + if (peer.socket && peer.roomId == roomId && peer.hasRole(data.role)) { + emit(peer.socket); + } + }) + } else { + emit(io.to(roomId)); + } + + res.json({}); + }); + + // Create connection in room (just wait for websocket instead? + // $post = [ + // 'json' => [ + // 'role' => self::OV_ROLE_PUBLISHER, + // 'data' => json_encode(['role' => $role]) + // ] + // ]; + app.post(`${config.pathPrefix}/api/sessions/:session_id/connection`, function (req, res) { + logger.info('Creating peer connection [roomId:"%s"]', req.params.session_id); + + const roomId = req.params.session_id; + const room = rooms.get(roomId); + + if (!room) { + res.status(404).send(); + return; + } + + const peer = new Peer({ roomId }); + + peers.set(peer.id, peer); + + peer.on('close', () => { + peers.delete(peer.id); + }); + + const data = req.body; + + if ('role' in data) + peer.setRole(data.role); + + const proto = config.publicDomain.includes('localhost') || config.publicDomain.includes('127.0.0.1') ? 'ws' : 'wss'; + + res.json({ + id: peer.id, + // Note: socket.io client will end up using (hardcoded) /meetmedia/signaling path + token: `${proto}://${config.publicDomain}?peerId=${peer.id}&roomId=${roomId}&authToken=${peer.authToken}` + }); + }) + + if (config.tls) { + // TLS server configuration. + const tls = { + cert: fs.readFileSync(config.tls.cert), + key: fs.readFileSync(config.tls.key), + secureOptions: 'tlsv12', + ciphers: [ + 'ECDHE-ECDSA-AES128-GCM-SHA256', + 'ECDHE-RSA-AES128-GCM-SHA256', + 'ECDHE-ECDSA-AES256-GCM-SHA384', + 'ECDHE-RSA-AES256-GCM-SHA384', + 'ECDHE-ECDSA-CHACHA20-POLY1305', + 'ECDHE-RSA-CHACHA20-POLY1305', + 'DHE-RSA-AES128-GCM-SHA256', + 'DHE-RSA-AES256-GCM-SHA384' + ].join(':'), + honorCipherOrder: true + }; + + mainListener = spdy.createServer(tls, app); + } else { + mainListener = http.createServer(app); + } + console.info(`Listening on ${config.listeningPort} ${config.listeningHost}`) + mainListener.listen(config.listeningPort, config.listeningHost); +} + +/** + * Create a WebSocketServer to allow WebSocket connections from browsers. + */ +async function runWebSocketServer() { + io = require('socket.io')(mainListener, { + path: `${config.pathPrefix}/signaling`, + cookie: false + }); + + // Handle connections from clients. + io.on('connection', async (socket) => { + const { roomId, peerId, authToken } = socket.handshake.query; + + if (!roomId || !peerId || !authToken) { + logger.warn('connection request without roomId and/or peerId'); + socket.disconnect(true); + return; + } + + logger.info('connection request [roomId:"%s", peerId:"%s"]', roomId, peerId); + + try { + const room = rooms.get(roomId); + + if (!room) { + logger.warn("Room does not exist %s", roomId); + socket.disconnect(true); + return; + } + + const peer = peers.get(peerId); + + if (!peer || peer.roomId != roomId || peer.authToken != authToken) { + logger.warn("Peer does not exist %s", peerId); + socket.disconnect(true); + return; + } + + await room.joinRoom({ peer, socket }); + } catch (error) { + logger.error('room creation or room joining failed [error:"%o"]', error); + + if (socket) + socket.disconnect(true); + } + }); +} + +/** + * Launch as many mediasoup Workers as given in the configuration file. + */ +async function runMediasoupWorkers() { + const { numWorkers } = config.mediasoup; + + logger.info('running %d mediasoup Workers...', numWorkers); + + for (let i = 0; i < numWorkers; ++i) { + const worker = await mediasoup.createWorker( + { + logLevel : config.mediasoup.worker.logLevel, + logTags : config.mediasoup.worker.logTags, + rtcMinPort : config.mediasoup.worker.rtcMinPort, + rtcMaxPort : config.mediasoup.worker.rtcMaxPort + }); + + worker.on('died', () => { + logger.error( + 'mediasoup Worker died, exiting in 2 seconds... [pid:%d]', worker.pid); + + setTimeout(() => process.exit(1), 2000); + }); + + mediasoupWorkers.push(worker); + } +} + +async function getLeastLoadedWorker() { + let workerLoads = new Map(); + for (const worker of mediasoupWorkers) { + workerLoads.set(worker.pid, 0); + } + + for (const peer of peers.values()) { + if (peer.workerId) { + const workerId = peer.workerId; + workerLoads.set(workerId, workerLoads.get(workerId) + 1); + } + } + + const sortedWorkerLoads = new Map([ ...workerLoads.entries() ].sort( + (a, b) => a[1] - b[1])); + + const workerId = sortedWorkerLoads.keys().next().value; + return mediasoupWorkers.find((worker) => worker.pid == workerId) +} + +/** + * Get a Room instance (or create one if it does not exist). + */ +async function createRoom() { + logger.info('creating a new Room'); + + // Create the room + const room = await Room.create({ getLeastLoadedWorker }); + + room.on('close', () => { + logger.info('closing a Room [roomId:"%s"]', room.id); + + rooms.delete(room.id); + + if (webhook) { + webhook.post('', { roomId: room.id, event: 'roomClosed' }) + .then(function (/* response */) { + logger.info(`Room ${room.id} closed. Webhook succeeded.`); + }) + .catch(function (error) { + logger.error(error); + }); + } + }); + + room.on('joinRequestAccepted', (requestId) => { + if (webhook) { + webhook.post('', { requestId, roomId: room.id, event: 'joinRequestAccepted' }) + .then(function (/* response */) { + logger.info(`Accepted join request ${requestId}. Webhook succeeded.`); + }) + .catch(function (error) { + logger.error(error); + }); + } + }); + + room.on('joinRequestDenied', (requestId) => { + if (webhook) { + webhook.post('', { requestId, roomId: room.id, event: 'joinRequestDenied' }) + .then(function (/* response */) { + logger.info(`Denied join request ${requestId}. Webhook succeeded.`); + }) + .catch(function (error) { + logger.error(error); + }); + } + }); + + rooms.set(room.id, room); + + return room; +} + +run(); + +module.exports = app; // export for testing diff --git a/meet/server/test/fakeParameters.js b/meet/server/test/fakeParameters.js new file mode 100644 --- /dev/null +++ b/meet/server/test/fakeParameters.js @@ -0,0 +1,609 @@ +const uuidv4 = require('uuid/v4'); + +exports.generateRouterRtpCapabilities = function() { + return { + codecs : + [ + { + mimeType : 'audio/opus', + kind : 'audio', + preferredPayloadType : 100, + clockRate : 48000, + channels : 2, + rtcpFeedback : + [ + { type: 'transport-cc' } + ], + parameters : + { + useinbandfec : 1, + foo : 'bar' + } + }, + { + mimeType : 'video/VP8', + kind : 'video', + preferredPayloadType : 101, + clockRate : 90000, + rtcpFeedback : + [ + { type: 'nack' }, + { type: 'nack', parameter: 'pli' }, + { type: 'ccm', parameter: 'fir' }, + { type: 'goog-remb' }, + { type: 'transport-cc' } + ], + parameters : + { + 'x-google-start-bitrate' : 1500 + } + }, + { + mimeType : 'video/rtx', + kind : 'video', + preferredPayloadType : 102, + clockRate : 90000, + rtcpFeedback : [], + parameters : + { + apt : 101 + } + }, + { + mimeType : 'video/H264', + kind : 'video', + preferredPayloadType : 103, + clockRate : 90000, + rtcpFeedback : + [ + { type: 'nack' }, + { type: 'nack', parameter: 'pli' }, + { type: 'ccm', parameter: 'fir' }, + { type: 'goog-remb' }, + { type: 'transport-cc' } + ], + parameters : + { + 'level-asymmetry-allowed' : 1, + 'packetization-mode' : 1, + 'profile-level-id' : '42e01f' + } + }, + { + mimeType : 'video/rtx', + kind : 'video', + preferredPayloadType : 104, + clockRate : 90000, + rtcpFeedback : [], + parameters : + { + apt : 103 + } + } + ], + headerExtensions : + [ + { + kind : 'audio', + uri : 'urn:ietf:params:rtp-hdrext:sdes:mid', + preferredId : 1, + preferredEncrypt : false, + direction : 'sendrecv' + }, + { + kind : 'video', + uri : 'urn:ietf:params:rtp-hdrext:sdes:mid', + preferredId : 1, + preferredEncrypt : false, + direction : 'sendrecv' + }, + { + kind : 'video', + uri : 'urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id', + preferredId : 2, + preferredEncrypt : false, + direction : 'recvonly' + }, + { + kind : 'video', + uri : 'urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id', + preferredId : 3, + preferredEncrypt : false, + direction : 'recvonly' + }, + { + kind : 'audio', + uri : 'http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time', + preferredId : 4, + preferredEncrypt : false, + direction : 'sendrecv' + }, + { + kind : 'video', + uri : 'http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time', + preferredId : 4, + preferredEncrypt : false, + direction : 'sendrecv' + }, + { + kind : 'audio', + uri : 'http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01', + preferredId : 5, + preferredEncrypt : false, + direction : 'recvonly' + }, + { + kind : 'video', + uri : 'http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01', + preferredId : 5, + preferredEncrypt : false, + direction : 'sendrecv' + }, + { + kind : 'video', + uri : 'http://tools.ietf.org/html/draft-ietf-avtext-framemarking-07', + preferredId : 6, + preferredEncrypt : false, + direction : 'sendrecv' + }, + { + kind : 'video', + uri : 'urn:ietf:params:rtp-hdrext:framemarking', + preferredId : 7, + preferredEncrypt : false, + direction : 'sendrecv' + }, + { + kind : 'audio', + uri : 'urn:ietf:params:rtp-hdrext:ssrc-audio-level', + preferredId : 10, + preferredEncrypt : false, + direction : 'sendrecv' + }, + { + kind : 'video', + uri : 'urn:3gpp:video-orientation', + preferredId : 11, + preferredEncrypt : false, + direction : 'sendrecv' + }, + { + kind : 'video', + uri : 'urn:ietf:params:rtp-hdrext:toffset', + preferredId : 12, + preferredEncrypt : false, + direction : 'sendrecv' + } + ], + fecMechanisms : [] + }; +}; + +exports.generateNativeRtpCapabilities = function() { + return { + codecs : + [ + { + mimeType : 'audio/opus', + kind : 'audio', + preferredPayloadType : 111, + clockRate : 48000, + channels : 2, + rtcpFeedback : + [ + { type: 'transport-cc' } + ], + parameters : + { + minptime : 10, + useinbandfec : 1 + } + }, + { + mimeType : 'audio/ISAC', + kind : 'audio', + preferredPayloadType : 103, + clockRate : 16000, + channels : 1, + rtcpFeedback : + [ + { type: 'transport-cc' } + ], + parameters : {} + }, + { + mimeType : 'audio/CN', + kind : 'audio', + preferredPayloadType : 106, + clockRate : 32000, + channels : 1, + rtcpFeedback : + [ + { type: 'transport-cc' } + ], + parameters : {} + }, + { + mimeType : 'video/VP8', + kind : 'video', + preferredPayloadType : 96, + clockRate : 90000, + rtcpFeedback : + [ + { type: 'goog-remb' }, + { type: 'transport-cc' }, + { type: 'ccm', parameter: 'fir' }, + { type: 'nack' }, + { type: 'nack', parameter: 'pli' } + ], + parameters : + { + baz : '1234abcd' + } + }, + { + mimeType : 'video/rtx', + kind : 'video', + preferredPayloadType : 97, + clockRate : 90000, + rtcpFeedback : [], + parameters : + { + apt : 96 + } + } + ], + headerExtensions : + [ + { + kind : 'audio', + uri : 'urn:ietf:params:rtp-hdrext:sdes:mid', + preferredId : 1 + }, + { + kind : 'video', + uri : 'urn:ietf:params:rtp-hdrext:sdes:mid', + preferredId : 1 + }, + { + kind : 'video', + uri : 'urn:ietf:params:rtp-hdrext:toffset', + preferredId : 2 + }, + { + kind : 'video', + uri : 'http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time', + preferredId : 3 + }, + { + kind : 'video', + uri : 'urn:3gpp:video-orientation', + preferredId : 4 + }, + { + kind : 'video', + uri : 'http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01', + preferredId : 5 + }, + { + kind : 'video', + uri : 'http://www.webrtc.org/experiments/rtp-hdrext/playout-delay', + preferredId : 6 + }, + { + kind : 'video', + uri : 'http://www.webrtc.org/experiments/rtp-hdrext/video-content-type', + preferredId : 7 + }, + { + kind : 'video', + uri : 'http://www.webrtc.org/experiments/rtp-hdrext/video-timing', + preferredId : 8 + }, + { + kind : 'audio', + uri : 'urn:ietf:params:rtp-hdrext:ssrc-audio-level', + preferredId : 10 + } + ], + fecMechanisms : [] + }; +}; + +exports.generateNativeSctpCapabilities = function() { + return { + numStreams : { OS: 2048, MIS: 2048 } + }; +}; + +exports.generateLocalDtlsParameters = function() { + return { + fingerprints : + [ + { + algorithm : 'sha-256', + value : '82:5A:68:3D:36:C3:0A:DE:AF:E7:32:43:D2:88:83:57:AC:2D:65:E5:80:C4:B6:FB:AF:1A:A0:21:9F:6D:0C:AD' + } + ], + role : 'auto' + }; +}; + +exports.generateTransportRemoteParameters = function() { + return { + id : uuidv4(), + iceParameters : + { + iceLite : true, + password : 'yku5ej8nvfaor28lvtrabcx0wkrpkztz', + usernameFragment : 'h3hk1iz6qqlnqlne' + }, + iceCandidates : + [ + { + family : 'ipv4', + foundation : 'udpcandidate', + ip : '9.9.9.9', + port : 40533, + priority : 1078862079, + protocol : 'udp', + type : 'host' + }, + { + family : 'ipv6', + foundation : 'udpcandidate', + ip : '9:9:9:9:9:9', + port : 41333, + priority : 1078862089, + protocol : 'udp', + type : 'host' + } + ], + dtlsParameters : + { + fingerprints : + [ + { + algorithm : 'sha-256', + value : 'A9:F4:E0:D2:74:D3:0F:D9:CA:A5:2F:9F:7F:47:FA:F0:C4:72:DD:73:49:D0:3B:14:90:20:51:30:1B:90:8E:71' + }, + { + algorithm : 'sha-384', + value : '03:D9:0B:87:13:98:F6:6D:BC:FC:92:2E:39:D4:E1:97:32:61:30:56:84:70:81:6E:D1:82:97:EA:D9:C1:21:0F:6B:C5:E7:7F:E1:97:0C:17:97:6E:CF:B3:EF:2E:74:B0' + }, + { + algorithm : 'sha-512', + value : '84:27:A4:28:A4:73:AF:43:02:2A:44:68:FF:2F:29:5C:3B:11:9A:60:F4:A8:F0:F5:AC:A0:E3:49:3E:B1:34:53:A9:85:CE:51:9B:ED:87:5E:B8:F4:8E:3D:FA:20:51:B8:96:EE:DA:56:DC:2F:5C:62:79:15:23:E0:21:82:2B:2C' + } + ], + role : 'auto' + }, + sctpParameters : + { + port : 5000, + numStreams : 2048, + maxMessageSize : 2000000 + } + }; +}; + +exports.generateProducerRemoteParameters = function() { + return { + id : uuidv4() + }; +}; + +exports.generateConsumerRemoteParameters = function({ id, codecMimeType } = {}) { + switch (codecMimeType) { + case 'audio/opus': + { + return { + id : id || uuidv4(), + producerId : uuidv4(), + kind : 'audio', + rtpParameters : + { + codecs : + [ + { + mimeType : 'audio/opus', + payloadType : 100, + clockRate : 48000, + channels : 2, + rtcpFeedback : + [ + { type: 'transport-cc' } + ], + parameters : + { + useinbandfec : 1, + foo : 'bar' + } + } + ], + encodings : + [ + { + ssrc : 46687003 + } + ], + headerExtensions : + [ + { + uri : 'urn:ietf:params:rtp-hdrext:sdes:mid', + id : 1 + }, + { + uri : 'http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01', + id : 5 + }, + { + uri : 'urn:ietf:params:rtp-hdrext:ssrc-audio-level', + id : 10 + } + ], + rtcp : + { + cname : 'wB4Ql4lrsxYLjzuN', + reducedSize : true, + mux : true + } + } + }; + } + + case 'audio/ISAC': + { + return { + id : id || uuidv4(), + producerId : uuidv4(), + kind : 'audio', + rtpParameters : + { + codecs : + [ + { + mimeType : 'audio/ISAC', + payloadType : 111, + clockRate : 16000, + channels : 1, + rtcpFeedback : + [ + { type: 'transport-cc' } + ], + parameters : {} + } + ], + encodings : + [ + { + ssrc : 46687004 + } + ], + headerExtensions : + [ + { + uri : 'urn:ietf:params:rtp-hdrext:sdes:mid', + id : 1 + }, + { + uri : 'http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01', + id : 5 + } + ], + rtcp : + { + cname : 'wB4Ql4lrsxYLjzuN', + reducedSize : true, + mux : true + } + } + }; + } + + case 'video/VP8': + { + return { + id : id || uuidv4(), + producerId : uuidv4(), + kind : 'video', + rtpParameters : + { + codecs : + [ + { + mimeType : 'video/VP8', + payloadType : 101, + clockRate : 90000, + rtcpFeedback : + [ + { type: 'nack' }, + { type: 'nack', parameter: 'pli' }, + { type: 'ccm', parameter: 'fir' }, + { type: 'goog-remb' }, + { type: 'transport-cc' } + ], + parameters : + { + 'x-google-start-bitrate' : 1500 + } + }, + { + mimeType : 'video/rtx', + payloadType : 102, + clockRate : 90000, + rtcpFeedback : [], + parameters : + { + apt : 101 + } + } + ], + encodings : + [ + { + ssrc : 99991111, + rtx : + { + ssrc : 99991112 + } + } + ], + headerExtensions : + [ + { + uri : 'urn:ietf:params:rtp-hdrext:sdes:mid', + id : 1 + }, + { + uri : 'http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time', + id : 4 + }, + { + uri : 'http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01', + id : 5 + }, + { + uri : 'urn:3gpp:video-orientation', + id : 11 + }, + { + uri : 'urn:ietf:params:rtp-hdrext:toffset', + id : 12 + } + ], + rtcp : + { + cname : 'wB4Ql4lrsxYLjzuN', + reducedSize : true, + mux : true + } + } + }; + } + + default: + { + throw new TypeError(`unknown codecMimeType "${codecMimeType}"`); + } + } +}; + +exports.generateDataProducerRemoteParameters = function() { + return { + id : uuidv4() + }; +}; + +exports.generateDataConsumerRemoteParameters = function({ id } = {}) { + return { + id : id || uuidv4(), + dataProducerId : uuidv4(), + sctpStreamParameters : + { + streamId : 666, + maxPacketLifeTime : 5000, + maxRetransmits : undefined + } + }; +}; diff --git a/meet/server/test/performancetestbench.js b/meet/server/test/performancetestbench.js new file mode 100644 --- /dev/null +++ b/meet/server/test/performancetestbench.js @@ -0,0 +1,248 @@ +process.env.DEBUG = '' + +const assert = require('assert'); +let request = require('supertest') +const io = require("socket.io-client"); +const child_process = require("child_process"); +const udp = require('dgram'); + +const Roles = require('../lib/userRoles'); + +let recvUdpSocket +let recvRtcpUdpSocket +let app +let processes = []; + +let rtpParameters = { + codecs: [ + { + mimeType: "video/H264", + payloadType: 125, + clockRate: 90000, + parameters: { + "level-asymmetry-allowed": 1, + "packetization-mode": 1, + "profile-level-id": "42e01f", + }, + }, + ], +} + +function startFFMPEGStream(peers, ssrc) { + const cmdProgram = "ffmpeg"; + + //Build a video stream per producer + const streams = peers.map((peer) => `[select=v:f=rtp:ssrc=${ssrc}:payload_type=125]rtp://127.0.0.1:${peer.senderTransportInfo.port}?rtcpport=${peer.senderTransportInfo.rtcpPort}`); + + const cmdArgStr = [ + "-i /dev/video0", //We are streaming from the webcam (a looping videofile would be an alternative) + `-c:v h264`, //The codec + "-map 0:v:0", + "-f tee", //This option allows us to read the source once, encode once, and then output multiple streams + streams.join('|').trim() + ].join(" ").trim(); + + console.log(`Run command: ${cmdProgram} ${cmdArgStr}`); + + let recProcess = child_process.spawn(cmdProgram, cmdArgStr.split(/\s+/)); + + recProcess.on("error", (err) => { + console.error("Recording process error:", err); + }); + + recProcess.on("exit", (code, signal) => { + console.log("Recording process exit, code: %d, signal: %s", code, signal); + + recProcess = null; + }); + + // FFmpeg writes its logs to stderr + recProcess.stderr.on("data", (chunk) => { + chunk + .toString() + .split(/\r?\n/g) + .filter(Boolean) // Filter out empty strings + .forEach((line) => { + // console.log(line); + }); + }); + + return recProcess; +} + +async function sendRequest(socket, method, data = null) { + return await new Promise((resolve, /*reject*/) => { + socket.emit( + 'request', + {method: method, + data: data}, + (error, response) => { + assert(!error) + resolve(response) + } + ) + }) +} + + +async function createPeer(roomId, request, receiverPort, receiverRtcpPort) { + let signalingSocket + await request + .post(`/meetmedia/api/sessions/${roomId}/connection`) + .send({role: Roles.PUBLISHER | Roles.SUBSCRIBER | Roles.MODERATOR}) + .expect(200) + .then(async (res) => { + let data = res.body; + const signalingUrl = data['token']; + signalingSocket = io(signalingUrl, { path: '/meetmedia/signaling', transports: ["websocket"], rejectUnauthorized: false }); + let roomReady = new Promise((resolve, /*reject*/) => { + signalingSocket.once('notification', (reason) => { + // console.warn("Received notification", reason) + if (reason['method'] == 'roomReady') { + resolve(); + } + }); + }) + + signalingSocket.connect(); + await roomReady + }) + .catch(err => { + console.warn(err); throw err + }) + + + //Necessary later for the server to resume the consumer, + //once we join with another peer + signalingSocket.on('request', async (reason, cb) => { + // console.warn("Received request", reason) + if (reason['method'] == 'newConsumer') { + cb(); + } + }); + + //Join + await sendRequest(signalingSocket, 'join', { + nickname: "nickname", + rtpCapabilities: rtpParameters + }) + + //Create sending transport + const senderTransportInfo = await sendRequest(signalingSocket, 'createPlainTransport', { + producing: true, + consuming: false, + }) + + //Create consuming transport + const consumerTransportInfo = await sendRequest(signalingSocket, 'createPlainTransport', { + producing: false, + consuming: true, + }) + + await sendRequest(signalingSocket, 'connectPlainTransport', { + transportId: consumerTransportInfo.id, + ip: '127.0.0.1', + port: receiverPort, + rtcpPort: receiverRtcpPort, + }) + + //Create sending producer + await sendRequest(signalingSocket, 'produce', { + transportId: senderTransportInfo.id, + kind: 'video', + + rtpParameters: { + codecs: [ + { + mimeType: "video/H264", + payloadType: 125, + clockRate: 90000, + parameters: { + "level-asymmetry-allowed": 1, + "packetization-mode": 1, + "profile-level-id": "42e01f", + }, + }, + ], + encodings: [{ ssrc: 2222 }] + }, + appData: { + source: 'webcam' + } + }) + + return {senderTransportInfo, consumerTransportInfo, signalingSocket}; +} + + +before(function (done) { + process.env.SSL_CERT = "none" + process.env.MEDIASOUP_NUM_WORKERS = 3 + process.env.ROUTER_SCALE_SIZE = 3 + process.env.WEBRTC_LISTEN_IP = "127.0.0.1" + app = require('../server.js') + request = request(app); + + + recvUdpSocket = udp.createSocket('udp4'); + recvUdpSocket.on('message',function(msg,info){ + // console.warn("Received message", msg, info) + }); + + recvRtcpUdpSocket = udp.createSocket('udp4'); + recvRtcpUdpSocket.on('message',function(msg,info){ + // console.warn("Received RTCP message", msg, info) + }); + + app.on("ready", function(){ + done(); + }); +}); + +describe('Testbench', function() { + let roomId; + let peers = []; + + it('prepare udp sockets', async () => { + await new Promise(resolve => recvUdpSocket.bind(22222, '127.0.0.1', resolve)); + await new Promise(resolve => recvRtcpUdpSocket.bind(22223, '127.0.0.1', resolve)); + }); + + it('create room', async () => { + return request + .post(`/meetmedia/api/sessions`) + .expect(200) + .then(async (res) => { + roomId = res.body['id']; + }) + .catch(err => { + console.warn(err); throw err + }) + }); + + it('create peers', async () => { + for (var i = 0; i < 20; i++) { + peers.push(await createPeer(roomId, request, recvUdpSocket.address().port, recvRtcpUdpSocket.address().port)) + } + }); + + it('start ffmpg stream', async () => { + processes.push(startFFMPEGStream(peers, 2222)) + }); + + it('wait forever', async () => { + setInterval(function(){ + sendRequest(peers[0].signalingSocket, 'dumpStats', {}) + }, 5000) + const promise = new Promise((res, _rej) => {}); + return promise; + }) +}); + +after(function () { + for (const process of processes) { + process.kill() + } + process.exit(); +}) + diff --git a/meet/server/test/test.js b/meet/server/test/test.js new file mode 100644 --- /dev/null +++ b/meet/server/test/test.js @@ -0,0 +1,280 @@ +const assert = require('assert'); +let request = require('supertest') +const io = require("socket.io-client"); + +const Roles = require('../lib/userRoles'); + +const mediasoupClient = require('mediasoup-client'); +const { FakeHandler } = require('mediasoup-client/lib/handlers/FakeHandler'); +const fakeParameters = require('./fakeParameters'); + +let app +let authToken = "Welcome2KolabSystems" + +before(function (done) { + process.env.SSL_CERT = "none" + process.env.AUTH_TOKEN = authToken + process.env.LISTENING_PORT = 12999 + process.env.PUBLIC_DOMAIN = "127.0.0.1:12999" + process.env.WEBRTC_LISTEN_IP = "127.0.0.1" + process.env.DEBUG = '*' + + app = require('../server.js') + request = request(app); + + app.on("ready", function(){ + done(); + }); +}); + +describe('GET /ping', function() { + it('responds', function(done) { + request + .get('/meetmedia/api/ping') + .set('X-Auth-Token', authToken) + .expect(200, done); + }); +}); + +describe('Join room', function() { + let roomId + let signalingUrl; + + let signalingSocket + let signalingSocket2 + let peerId + + async function sendRequest(socket, method, data = null) { + return await new Promise((resolve, /*reject*/) => { + socket.emit( + 'request', + {method: method, + data: data}, + (error, response) => { + assert(!error) + resolve(response) + } + ) + }) + } + + it('create room', async () => { + return request + .post(`/meetmedia/api/sessions`) + .set('X-Auth-Token', authToken) + .expect(200) + .then(async (res) => { + roomId = res.body['id']; + }) + .catch(err => { + console.warn(err); throw err + }) + }); + + it('list rooms', async () => { + return request + .get(`/meetmedia/api/sessions`) + .set('X-Auth-Token', authToken) + .expect(200); + }) + + it('connect', async () => { + return request + .post(`/meetmedia/api/sessions/${roomId}/connection`) + .set('X-Auth-Token', authToken) + .send({role: Roles.PUBLISHER | Roles.SUBSCRIBER | Roles.MODERATOR}) + .expect(200) + .then(async (res) => { + let data = res.body; + peerId = data['id']; + signalingUrl = data['token']; + assert(signalingUrl.includes(peerId)) + assert(signalingUrl.includes(roomId)) + // console.info(signalingUrl); + + signalingSocket = io(signalingUrl, { path: '/meetmedia/signaling', transports: ["websocket"], rejectUnauthorized: false }); + let roomReady = new Promise((resolve, /*reject*/) => { + signalingSocket.on('notification', (reason) => { + if (reason['method'] == 'roomReady') { + resolve(); + } + }); + }) + + signalingSocket.connect(); + await roomReady + }) + .catch(err => { + console.warn(err); throw err + }) + }); + + it('getRtpCapabilities', async () => { + const routerRtpCapabilities = await sendRequest(signalingSocket, 'getRouterRtpCapabilities') + assert(Object.keys(routerRtpCapabilities).length != 0) + }); + + + it('join', async () => { + const { id, role, peers } = await sendRequest(signalingSocket, 'join', { + nickname: "nickname", + rtpCapabilities: fakeParameters.generateNativeRtpCapabilities() + }) + assert.equal(id, peerId) + assert.equal(role, Roles.PUBLISHER | Roles.SUBSCRIBER | Roles.MODERATOR) + assert.equal(peers.length, 0) + }) + + it('second peer joining', async () => { + return request + .post(`/meetmedia/api/sessions/${roomId}/connection`) + .set('X-Auth-Token', authToken) + .expect(200) + .then(async (res) => { + let data = res.body; + const newId = data['id']; + const signalingUrl = data['token']; + + signalingSocket2 = io(signalingUrl, { path: '/meetmedia/signaling', transports: ["websocket"], rejectUnauthorized: false }); + + let roomReady = new Promise((resolve, /*reject*/) => { + signalingSocket2.on('notification', async (reason) => { + if (reason['method'] == 'roomReady') { + resolve(reason); + } + }); + }) + + let newPeer = new Promise((resolve, /*reject*/) => { + signalingSocket.on('notification', (reason) => { + if (reason.method == 'newPeer') { + resolve(reason); + } + }); + }) + + signalingSocket2.connect(); + + + let reason = await roomReady; + const { peers } = await sendRequest(signalingSocket2, 'join', { + nickname: "nickname", + rtpCapabilities: fakeParameters.generateNativeRtpCapabilities() + }) + assert.equal(peers.length, 1) + assert.equal(peers[0].id, peerId) + + reason = await newPeer; + assert(reason.data.id == newId); + }) + .catch(err => { + console.warn(err); throw err + }) + }); + + let transportInfo; + + it('createWebRtcTransport', async () => { + transportInfo = await sendRequest(signalingSocket, 'createWebRtcTransport', { + forceTcp: false, + producing: true, + consuming: false + }) + + const { id, iceParameters, iceCandidates, dtlsParameters } = transportInfo + assert(transportInfo != null); + }); + + it('createDevice', async () => { + let device; + try{ + device = new mediasoupClient.Device({ handlerFactory: FakeHandler.createFactory(fakeParameters) }); + + let caps = fakeParameters.generateRouterRtpCapabilities(); + await device.load({routerRtpCapabilities: caps}) + assert(device.canProduce('video')) + + const { id, iceParameters, iceCandidates, dtlsParameters } = transportInfo + //FIXME it doesn't look like this device can actually connect + let sendTransport = device.createSendTransport({ + id, + iceParameters, + iceCandidates, + dtlsParameters, + // iceServers: turnServers, + // iceTransportPolicy: iceTransportPolicy, + proprietaryConstraints: { optional: [{ googDscp: true }] } + }) + + sendTransport.on('connect', ({ dtlsParameters }, callback, errback) => { + console.warn("on connect"); + // done(); + // socket.sendRequest('connectWebRtcTransport', + // { transportId: sendTransport.id, dtlsParameters }) + // .then(callback) + // .catch(errback) + }) + + //TODO we should get it to connected + // assert.equal(sendTransport.connectionState, 'new'); + + } catch (error) { + console.warn(error) + } + }); + + it('reconnect', async () => { + //Connect a new socket first, simulating a dangling old socket. + let reconnectSocket = io(signalingUrl, { path: '/meetmedia/signaling', transports: ["websocket"], rejectUnauthorized: false , forceNew: true}); + + //Listen for peer closed events + let peerClosed = false; + signalingSocket2.on('notification', (reason) => { + if (reason['method'] == 'peerClosed') { + peerClosed = true; + } + }); + + let roomBack = new Promise((resolve, /*reject*/) => { + reconnectSocket.on('notification', (reason) => { + if (reason['method'] == 'roomBack') { + resolve(); + } + }); + }) + + await reconnectSocket.connect(); + await roomBack + + //Now disconnect the old socket, it shouldn't affect the new one and shouldn't trigger a peer closure. + await signalingSocket.disconnect(); + + await new Promise(resolve => setTimeout(resolve, 1000)); + + //We shouldn't receive a peer closed event + assert(!peerClosed); + //For further tests + signalingSocket = reconnectSocket; + }); + + + it('disconnect', async () => { + let peerClosed = new Promise((resolve, /*reject*/) => { + signalingSocket2.on('notification', (reason) => { + if (reason['method'] == 'peerClosed') { + resolve(); + } + }); + }) + + //Disconnect and wait for the peer closed signal + await signalingSocket.disconnect(); + await peerClosed + }); + + after(function () { + signalingSocket.close(); + signalingSocket2.close(); + }) + +}); diff --git a/meet/server/test/videoproducer.js b/meet/server/test/videoproducer.js new file mode 100644 --- /dev/null +++ b/meet/server/test/videoproducer.js @@ -0,0 +1,398 @@ +#!/usr/bin/env node + +process.env.DEBUG = '*' + +process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = 0; + +let request = require('superagent'); +const io = require("socket.io-client"); +const child_process = require("child_process"); + +const Roles = require('../lib/userRoles'); + +let processes = []; + +//e.g. http://kolab1.mkpf.ch:12443 +let serverUrl = process.argv[2] +// Welcome2KolabSystems +let webhookToken = process.argv[3] +//This is the mediasoup internal id, not what we have in kolab4 +let roomId = process.argv[4] +let numStreams = 1 +let filename = "input.mp4"; + + +const codecIndex = 1 + +let rtpParameters = { + codecs: [ + { + mimeType: "video/H264", + payloadType: 125, + clockRate: 90000, + parameters: { + "level-asymmetry-allowed": 1, + "packetization-mode": 1, + "profile-level-id": "42e01f", + }, + rtcpFeedback: [] + }, + { + kind : 'video', + mimeType : 'video/VP8', + payloadType: 96, + clockRate : 90000, + parameters : + { + 'profile-id' : 2, + 'x-google-start-bitrate' : 1000 + }, + rtcpFeedback: [] + }, + { + kind : 'audio', + channels: 2, + clockRate: 48000, + mimeType: "audio/opus", + parameters: { + "maxplaybackrate": 48000, + "ptime": "20", + "sprop-stereo": 0, + "stereo": 1, + "usedtx": 1, + "useinbandfec": 1 + }, + payloadType: 109, + rtcpFeedback: [] + } + ], +} + + +function startGStream(peers, ssrc, audioSsrc) { + const cmdProgram = "gst-launch-1.0"; + + const payloadType = rtpParameters.codecs[codecIndex]['payloadType'] + const audioPayloadType = 109 + + //FIXME currently only handles a single peer + const peer = peers[0]; + + const cmdArgStr = [ + "-v", + "rtpbin name=rtpbin rtp-profile=avpf", + + //The source named "dec" + `filesrc location="${filename}" ! decodebin name=dec`, + //The video stream + `dec. ! queue ! videoconvert ! videoscale ! videorate ! video/x-raw,width=1280,height=720,framerate=25/1 ! vp8enc deadline=1 cpu-used=-5 ! rtpvp8pay pt=${payloadType} ssrc=${ssrc} picture-id-mode=2 ! rtprtxqueue max-size-time=2000 max-size-packets=0 ! rtpbin.send_rtp_sink_0`, + //The audio stream from the same source + `dec. ! queue ! audioconvert ! audioresample ! opusenc ! rtpopuspay pt=${audioPayloadType} ssrc=${audioSsrc} ! rtprtxqueue ! rtpbin.send_rtp_sink_1`, + + //Send video over udp + `rtpbin.send_rtp_src_0 ! udpsink host=${peer.senderTransportInfo.ip} port=${peer.senderTransportInfo.port} sync=true async=false`, + `rtpbin.send_rtcp_src_0 ! udpsink host=${peer.senderTransportInfo.ip} port=${peer.senderTransportInfo.rtcpPort} sync=false async=false`, + //Send audio over udp + `rtpbin.send_rtp_src_1 ! udpsink host=${peer.senderAudioTransportInfo.ip} port=${peer.senderAudioTransportInfo.port} sync=true async=false`, + `rtpbin.send_rtcp_src_1 ! udpsink host=${peer.senderAudioTransportInfo.ip} port=${peer.senderAudioTransportInfo.rtcpPort} sync=false async=false` + ].join(" ").trim(); + + + console.log(`Run command: ${cmdProgram} ${cmdArgStr}`); + + let recProcess = child_process.spawn(cmdProgram, cmdArgStr.split(/\s+/)); + + recProcess.on("error", (err) => { + console.error("gstreamer process error:", err); + }); + + recProcess.on("exit", (code, signal) => { + console.log("gstreamer process exit, code: %d, signal: %s", code, signal); + + recProcess = null; + process.exit() + }); + + recProcess.stdout.on("data", (chunk) => { + chunk + .toString() + .split(/\r?\n/g) + .filter(Boolean) // Filter out empty strings + .forEach((line) => { + console.log(line); + }); + }); + + recProcess.stderr.on("data", (chunk) => { + chunk + .toString() + .split(/\r?\n/g) + .filter(Boolean) // Filter out empty strings + .forEach((line) => { + console.log(line); + }); + }); + + return recProcess; +} + +//ffmpeg kind of sucks https://mediasoup.discourse.group/t/very-high-packet-loss-with-ffmpeg-broadcasting/322 +function startFFMPEGStream(peers, ssrc, audioSsrc) { + const cmdProgram = "ffmpeg"; + + const payloadType = rtpParameters.codecs[codecIndex]['payloadType'] + const audioPayloadType = 109 + + //Build a video stream per producer + const streams = peers.map((peer) => `[select=a:f=rtp:ssrc=${audioSsrc}:payload_type=${audioPayloadType}]rtp://${peer.senderAudioTransportInfo.ip}:${peer.senderAudioTransportInfo.port}?rtcpport=${peer.senderAudioTransportInfo.rtcpPort}|[select=v:f=rtp:ssrc=${ssrc}:payload_type=${payloadType}]rtp://${peer.senderTransportInfo.ip}:${peer.senderTransportInfo.port}?rtcpport=${peer.senderTransportInfo.rtcpPort}`); + + const cmdArgStr = [ + //The source + `-stream_loop -1 -re -i ${filename}`, //Loop a videofile (-re for original speed) + // "-i /dev/video0", //Stream from the webcam + + '-map 0:a:0', + '-c:a libopus -ab 128k -ac 2 -ar 48000 -application lowdelay -cutoff 12000', + + '-vf scale=640:480', + + //The vp8 codec + '-c:v libvpx -crf 10 -b:v 1000k', + + //The vp9 codec + // '-strict experimental', + // '-c:v libvpx-vp9 -crf 30 -b:v 0', + // + //The h264 codec + // '-c:v h264 -b:v 500k', + // '', + // '-c:v libx264 -tune zerolatency -preset ultrafast -threads 0 -crf 23 -minrate 5M -maxrate 5M -bufsize 10M', + + //Frame rate? + // '-r 25', + + "-map 0:v:0", + "-f tee", //This option allows us to read the source once, encode once, and then output multiple streams + streams.join('|').trim() + ].join(" ").trim(); + + console.log(`Run command: ${cmdProgram} ${cmdArgStr}`); + + let recProcess = child_process.spawn(cmdProgram, cmdArgStr.split(/\s+/)); + + recProcess.on("error", (err) => { + console.error("ffmpeg process error:", err); + }); + + recProcess.on("exit", (code, signal) => { + console.log("ffmpeg process exit, code: %d, signal: %s", code, signal); + + recProcess = null; + }); + + // FFmpeg writes its logs to stderr + recProcess.stderr.on("data", (chunk) => { + chunk + .toString() + .split(/\r?\n/g) + .filter(Boolean) // Filter out empty strings + .forEach((line) => { + console.log(line); + }); + }); + + return recProcess; +} + +async function sendRequest(socket, method, data = null) { + return await new Promise((resolve, /*reject*/) => { + socket.emit( + 'request', + {method: method, + data: data}, + (error, response) => { + resolve(response) + } + ) + }) +} + + +async function createPeer(index, roomId/*, request, receiverPort, receiverRtcpPort*/) { + console.warn("Creating peer") + let signalingSocket + await request + .post(`${serverUrl}/meetmedia/api/sessions/${roomId}/connection`) + .send({role: Roles.PUBLISHER | Roles.SUBSCRIBER | Roles.MODERATOR}) + .set('X-Auth-Token', webhookToken) + .then(async (res) => { + let data = res.body; + console.warn(data) + const signalingUrl = data['token']; + + signalingSocket = io(signalingUrl, { path: '/meetmedia/signaling', transports: ["websocket"], rejectUnauthorized: false }); + console.warn("Waiting for room ready") + let roomReady = new Promise((resolve, /*reject*/) => { + console.warn("waiting for notification") + //For some reason this notification is never emitted + signalingSocket.once('notification', (reason) => { + console.warn("Received notification", reason) + if (reason['method'] == 'roomReady') { + resolve(); + } + }); + }) + + signalingSocket.on("connect", () => { console.warn("connected"); }); + signalingSocket.on("disconnect", () => { console.warn("disconnect"); }); + + signalingSocket.on('notification', (reason) => { + console.warn("1Received notification", reason) + }); + + signalingSocket.connect(); + signalingSocket.on('notification', (reason) => { + console.warn("1Received notification", reason) + }); + + console.warn("Connecting") + + //FIXME this does not currently seem to work as it should. + + await roomReady + console.warn("Connected") + }) + .catch(err => { + console.warn(err); throw err + }) + + console.warn("Created connection") + + //Necessary later for the server to resume the consumer, + //once we join with another peer + // signalingSocket.on('request', async (reason, cb) => { + // // console.warn("Received request", reason) + // if (reason['method'] == 'newConsumer') { + // cb(); + // } + // }); + + //Join + await sendRequest(signalingSocket, 'join', { + nickname: `videoproducer ${index}`, + rtpCapabilities: { + codecs: [rtpParameters.codecs[codecIndex]], + } + }) + console.warn("Joined") + + //Create sending transport + const senderTransportInfo = await sendRequest(signalingSocket, 'createPlainTransport', { + producing: true, + consuming: false, + }) + console.warn("Created transport", senderTransportInfo) + + const senderAudioTransportInfo = await sendRequest(signalingSocket, 'createPlainTransport', { + producing: true, + consuming: false, + }) + + //Create consuming transport + // const consumerTransportInfo = await sendRequest(signalingSocket, 'createPlainTransport', { + // producing: false, + // consuming: true, + // }) + + // await sendRequest(signalingSocket, 'connectPlainTransport', { + // transportId: consumerTransportInfo.id, + // ip: '127.0.0.1', + // port: receiverPort, + // rtcpPort: receiverRtcpPort, + // }) + + //Create sending producer + await sendRequest(signalingSocket, 'produce', { + transportId: senderTransportInfo.id, + kind: 'video', + + rtpParameters: { + codecs: [rtpParameters.codecs[codecIndex]], + encodings: [{ ssrc: 2222 }] + }, + appData: { + source: 'webcam' + } + }) + + await sendRequest(signalingSocket, 'produce', { + transportId: senderAudioTransportInfo.id, + kind: 'audio', + + rtpParameters: { + codecs: [ + { + "channels": 2, + "clockRate": 48000, + "mimeType": "audio/opus", + "parameters": { + "maxplaybackrate": 48000, + "ptime": "20", + "sprop-stereo": 0, + "stereo": 1, + "usedtx": 1, + "useinbandfec": 1 + }, + "payloadType": 109, + "rtcpFeedback": [] + } + ], + encodings: [{ ssrc: 2223 }] + }, + appData: { + source: 'mic' + } + }) + + console.warn("Produced") + + // return {senderTransportInfo, consumerTransportInfo, signalingSocket}; + return {senderTransportInfo, senderAudioTransportInfo, signalingSocket}; +} + +async function run() { + // let roomId; + let peers = []; + + // await request + // .post(`${serverUrl}/meetmedia/api/sessions`) + // .set('X-Auth-Token', webhookToken) + // .then(async (res) => { + // roomId = res.body['id']; + // }) + // .catch(err => { + // console.warn(err); throw err + // }) + + for (var i = 0; i < numStreams; i++) { + peers.push(await createPeer(i, roomId)) + } + + if (true) { + processes.push(startGStream(peers, 2222, 2223)) + } else { + processes.push(startFFMPEGStream(peers, 2222, 2223)) + } + + const promise = new Promise((res, _rej) => {}); + await promise; +} + +run() + .then(() => { + for (const process of processes) { + process.kill() + } + process.exit(); + }); + diff --git a/meet/server/throttle.sh b/meet/server/throttle.sh new file mode 100755 --- /dev/null +++ b/meet/server/throttle.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +# Throttle local network connections + +set -x + +# Requires kernel-modules-extra (and perhaps kernel-debug-modules-extra) and a reboot +sudo modprobe sch_netem || exit 1 + +INTERFACE=$(sudo route | grep -m 1 '^default' | grep -o '[^ ]*$' | tr -d '\n') + +if [[ $1 == "stop" ]] +then + echo "Stopping" + sudo tc qdisc del dev $INTERFACE root + sudo tc qdisc del dev $INTERFACE ingress + sudo tc qdisc del dev ifb0 root + exit 0 +fi + +DOWN=500 +UP=500 +HALFWAYRTT=10 + +echo "Throttling $INTERFACE Up $UP Down $DOWN RTT/2 $HALFWAYRTT" +# Setup: +# This creates a virtual ifb interface to redirect ingress traffic over it, +# so we can then apply egress rules to the mirrored ingress traffic. +# This allows to use the more flexible egress rules for ingress traffic, instead of the more limited tc ingress filters. +sudo modprobe ifb || exit 1 +sudo ip link set dev ifb0 up +sudo tc qdisc add dev $INTERFACE ingress +sudo tc filter add dev $INTERFACE parent ffff: protocol ip u32 match u32 0 0 flowid 1:1 action mirred egress redirect dev ifb0 + +# Set bandwith +sudo tc qdisc add dev ifb0 root handle 1:0 netem delay ${HALFWAYRTT}ms rate ${DOWN}kbit +sudo tc qdisc add dev $INTERFACE root handle 1:0 netem delay ${HALFWAYRTT}ms rate ${UP}kbit diff --git a/src/.env.example b/src/.env.example --- a/src/.env.example +++ b/src/.env.example @@ -80,24 +80,17 @@ LDAP_HOSTED_BIND_PW="Welcome2KolabSystems" LDAP_HOSTED_ROOT_DN="dc=hosted,dc=com" -OPENVIDU_API_PASSWORD=MY_SECRET -OPENVIDU_API_URL=http://localhost:8080/api/ -OPENVIDU_API_USERNAME=OPENVIDUAPP -OPENVIDU_API_VERIFY_TLS=true -OPENVIDU_COTURN_IP=127.0.0.1 -OPENVIDU_COTURN_REDIS_DATABASE=2 -OPENVIDU_COTURN_REDIS_IP=127.0.0.1 -OPENVIDU_COTURN_REDIS_PASSWORD=turn -# Used as COTURN_IP, TURN_PUBLIC_IP, for KMS_TURN_URL -OPENVIDU_PUBLIC_IP=127.0.0.1 -OPENVIDU_PUBLIC_PORT=3478 -OPENVIDU_SERVER_PORT=8080 -OPENVIDU_WEBHOOK=true -OPENVIDU_WEBHOOK_ENDPOINT=http://127.0.0.1:8000/webhooks/meet/openvidu - -# "CDR" events, see https://docs.openvidu.io/en/2.13.0/reference-docs/openvidu-server-cdr/ -#OPENVIDU_WEBHOOK_EVENTS=[sessionCreated,sessionDestroyed,participantJoined,participantLeft,webrtcConnectionCreated,webrtcConnectionDestroyed,recordingStatusChanged,filterEventDispatched,mediaNodeStatusChanged] -#OPENVIDU_WEBHOOK_HEADERS=[\"Authorization:\ Basic\ SOMETHING\"] +COTURN_PUBLIC_IP=127.0.0.1 +COTURN_STATIC_SECRET="Welcome2KolabSystems" + +MEET_WEBHOOK_TOKEN=Welcome2KolabSystems +MEET_SERVER_TOKEN=Welcome2KolabSystems +MEET_SERVER_URLS=https://localhost:12443/meetmedia/api/ +MEET_SERVER_VERIFY_TLS=true + +MEET_WEBRTC_LISTEN_IP= +MEET_PUBLIC_DOMAIN=127.0.0.1:12443 +MEET_TURN_SERVER='turn:127.0.0.1:3478?transport=tcp' PGP_ENABLED= PGP_BINARY= diff --git a/src/app/Console/Commands/OpenVidu/RoomCreate.php b/src/app/Console/Commands/Meet/RoomCreate.php rename from src/app/Console/Commands/OpenVidu/RoomCreate.php rename to src/app/Console/Commands/Meet/RoomCreate.php --- a/src/app/Console/Commands/OpenVidu/RoomCreate.php +++ b/src/app/Console/Commands/Meet/RoomCreate.php @@ -1,6 +1,6 @@ first(); + $room = \App\Meet\Room::where('name', $roomName)->first(); if ($room) { $this->error("Room already exists."); return 1; } - \App\OpenVidu\Room::create( - [ + \App\Meet\Room::create([ 'name' => $roomName, 'user_id' => $user->id - ] - ); + ]); } } diff --git a/src/app/Console/Commands/OpenVidu/Rooms.php b/src/app/Console/Commands/Meet/Rooms.php rename from src/app/Console/Commands/OpenVidu/Rooms.php rename to src/app/Console/Commands/Meet/Rooms.php --- a/src/app/Console/Commands/OpenVidu/Rooms.php +++ b/src/app/Console/Commands/Meet/Rooms.php @@ -1,8 +1,8 @@ info("{$room->name}"); diff --git a/src/app/Console/Commands/Meet/Sessions.php b/src/app/Console/Commands/Meet/Sessions.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Meet/Sessions.php @@ -0,0 +1,70 @@ + false, // No exceptions from Guzzle + 'base_uri' => \config('meet.api_url'), + 'verify' => \config('meet.api_verify_tls'), + 'headers' => [ + 'X-Auth-Token' => \config('meet.api_token'), + ], + 'connect_timeout' => 10, + 'timeout' => 10, + ]); + + $response = $client->request('GET', 'sessions'); + + if ($response->getStatusCode() !== 200) { + return 1; + } + + $sessions = json_decode($response->getBody(), true); + + foreach ($sessions as $session) { + $room = \App\Meet\Room::where('session_id', $session['roomId'])->first(); + if ($room) { + $owner = $room->owner->email; + $roomName = $room->name; + } else { + $owner = '(none)'; + $roomName = '(none)'; + } + + $this->info( + sprintf( + "Session: %s for %s since %s (by %s)", + $session['roomId'], + $roomName, + \Carbon\Carbon::parse($session['createdAt'], 'UTC'), + $owner + ) + ); + } + } +} diff --git a/src/app/Console/Commands/OpenVidu/Sessions.php b/src/app/Console/Commands/OpenVidu/Sessions.php deleted file mode 100644 --- a/src/app/Console/Commands/OpenVidu/Sessions.php +++ /dev/null @@ -1,72 +0,0 @@ - \config('openvidu.api_url'), - 'verify' => \config('openvidu.api_verify_tls') - ] - ); - - $response = $client->request( - 'GET', - 'sessions', - ['auth' => [\config('openvidu.api_username'), \config('openvidu.api_password')]] - ); - - if ($response->getStatusCode() !== 200) { - return 1; - } - - $sessionResponse = json_decode($response->getBody(), true); - - foreach ($sessionResponse['content'] as $session) { - $room = \App\OpenVidu\Room::where('session_id', $session['sessionId'])->first(); - if ($room) { - $owner = $room->owner->email; - $roomName = $room->name; - } else { - $owner = '(none)'; - $roomName = '(none)'; - } - - $this->info( - sprintf( - "Session: %s for %s since %s (by %s)", - $session['sessionId'], - $roomName, - \Carbon\Carbon::parse((int)substr($session['createdAt'], 0, 10), 'UTC'), - $owner - ) - ); - } - } -} diff --git a/src/app/Http/Controllers/API/V4/MeetController.php b/src/app/Http/Controllers/API/V4/MeetController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/MeetController.php @@ -0,0 +1,293 @@ +user(); + + $rooms = Room::where('user_id', $user->id)->orderBy('name')->get(); + + if (count($rooms) == 0) { + // Create a room for the user (with a random and unique name) + while (true) { + $name = strtolower(\App\Utils::randStr(3, 3, '-')); + if (!Room::where('name', $name)->count()) { + break; + } + } + + $room = Room::create([ + 'name' => $name, + 'user_id' => $user->id + ]); + + $rooms = collect([$room]); + } + + $result = [ + 'list' => $rooms, + 'count' => count($rooms), + ]; + + return response()->json($result); + } + + /** + * Join the room session. Each room has one owner, and the room isn't open until the owner + * joins (and effectively creates the session). + * + * @param string $id Room identifier (name) + * + * @return \Illuminate\Http\JsonResponse + */ + public function joinRoom($id) + { + $room = Room::where('name', $id)->first(); + + // Room does not exist, or the owner is deleted + if (!$room || !$room->owner || $room->owner->isDegraded(true)) { + return $this->errorResponse(404, \trans('meet.room-not-found')); + } + + // Check if there's still a valid meet entitlement for the room owner + if (!$room->owner->hasSku('meet')) { + return $this->errorResponse(404, \trans('meet.room-not-found')); + } + + $user = Auth::guard()->user(); + $isOwner = $user && $user->id == $room->user_id; + $init = !empty(request()->input('init')); + + // There's no existing session + if (!$room->hasSession()) { + // Participants can't join the room until the session is created by the owner + if (!$isOwner) { + return $this->errorResponse(422, \trans('meet.session-not-found'), ['code' => 323]); + } + + // The room owner can create the session on request + if (!$init) { + return $this->errorResponse(422, \trans('meet.session-not-found'), ['code' => 324]); + } + + $session = $room->createSession(); + + if (empty($session)) { + return $this->errorResponse(500, \trans('meet.session-create-error')); + } + } + + $settings = $room->getSettings(['locked', 'nomedia', 'password']); + $password = (string) $settings['password']; + + $config = [ + 'locked' => $settings['locked'] === 'true', + 'nomedia' => $settings['nomedia'] === 'true', + 'password' => $isOwner ? $password : '', + 'requires_password' => !$isOwner && strlen($password), + ]; + + $response = ['config' => $config]; + + // Validate room password + if (!$isOwner && strlen($password)) { + $request_password = request()->input('password'); + if ($request_password !== $password) { + return $this->errorResponse(422, \trans('meet.session-password-error'), $response + ['code' => 325]); + } + } + + // Handle locked room + if (!$isOwner && $config['locked']) { + $nickname = request()->input('nickname'); + $picture = request()->input('picture'); + $requestId = request()->input('requestId'); + + $request = $requestId ? $room->requestGet($requestId) : null; + + $error = \trans('meet.session-room-locked-error'); + + // Request already has been processed (not accepted yet, but it could be denied) + if (empty($request['status']) || $request['status'] != Room::REQUEST_ACCEPTED) { + if (!$request) { + if (empty($nickname) || empty($requestId) || !preg_match('/^[a-z0-9]{8,32}$/i', $requestId)) { + return $this->errorResponse(422, $error, $response + ['code' => 326]); + } + + if (empty($picture)) { + $svg = file_get_contents(resource_path('images/user.svg')); + $picture = 'data:image/svg+xml;base64,' . base64_encode($svg); + } elseif (!preg_match('|^data:image/png;base64,[a-zA-Z0-9=+/]+$|', $picture)) { + return $this->errorResponse(422, $error, $response + ['code' => 326]); + } + + // TODO: Resize when big/make safe the user picture? + + $request = ['nickname' => $nickname, 'requestId' => $requestId, 'picture' => $picture]; + + if (!$room->requestSave($requestId, $request)) { + // FIXME: should we use error code 500? + return $this->errorResponse(422, $error, $response + ['code' => 326]); + } + + // Send the request (signal) to all moderators + $result = $room->signal('joinRequest', $request, Room::ROLE_MODERATOR); + } + + return $this->errorResponse(422, $error, $response + ['code' => 327]); + } + } + + // Initialize connection tokens + if ($init) { + // Choose the connection role + $canPublish = !empty(request()->input('canPublish')) && (empty($config['nomedia']) || $isOwner); + $role = $canPublish ? Room::ROLE_PUBLISHER : Room::ROLE_SUBSCRIBER; + if ($isOwner) { + $role |= Room::ROLE_MODERATOR; + $role |= Room::ROLE_OWNER; + } + + // Create session token for the current user/connection + $response = $room->getSessionToken($role); + + if (empty($response)) { + return $this->errorResponse(500, \trans('meet.session-join-error')); + } + + $response_code = 200; + $response['role'] = $role; + $response['config'] = $config; + } else { + $response_code = 422; + $response['code'] = 322; + } + + return response()->json($response, $response_code); + } + + /** + * Set the domain configuration. + * + * @param string $id Room identifier (name) + * + * @return \Illuminate\Http\JsonResponse|void + */ + public function setRoomConfig($id) + { + $room = Room::where('name', $id)->first(); + + // Room does not exist, or the owner is deleted + if (!$room || !$room->owner || $room->owner->isDegraded(true)) { + return $this->errorResponse(404); + } + + $user = Auth::guard()->user(); + + // Only room owner can configure the room + if ($user->id != $room->user_id) { + return $this->errorResponse(403); + } + + $input = request()->input(); + $errors = []; + + foreach ($input as $key => $value) { + switch ($key) { + case 'password': + if ($value === null || $value === '') { + $input[$key] = null; + } else { + // TODO: Do we have to validate the password in any way? + } + break; + + case 'locked': + $input[$key] = $value ? 'true' : null; + break; + + case 'nomedia': + $input[$key] = $value ? 'true' : null; + break; + + default: + $errors[$key] = \trans('meet.room-unsupported-option-error'); + } + } + + if (!empty($errors)) { + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + if (!empty($input)) { + $room->setSettings($input); + } + + return response()->json([ + 'status' => 'success', + 'message' => \trans('meet.room-setconfig-success'), + ]); + } + + /** + * Webhook as triggered from the Meet server + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\Response The response + */ + public function webhook(Request $request) + { + \Log::debug($request->getContent()); + + // Authenticate the request + if ($request->headers->get('X-Auth-Token') != \config('meet.webhook_token')) { + return response('Unauthorized', 403); + } + + $sessionId = (string) $request->input('roomId'); + $event = (string) $request->input('event'); + + switch ($event) { + case 'roomClosed': + // When all participants left the room the server will dispatch roomClosed + // event. We'll remove the session reference from the database. + $room = Room::where('session_id', $sessionId)->first(); + + if ($room) { + $room->session_id = null; + $room->save(); + } + + break; + + case 'joinRequestAccepted': + case 'joinRequestDenied': + $room = Room::where('session_id', $sessionId)->first(); + + if ($room) { + $method = $event == 'joinRequestAccepted' ? 'requestAccept' : 'requestDeny'; + + $room->{$method}($request->input('requestId')); + } + + break; + } + + return response('Success', 200); + } +} diff --git a/src/app/Http/Controllers/API/V4/OpenViduController.php b/src/app/Http/Controllers/API/V4/OpenViduController.php deleted file mode 100644 --- a/src/app/Http/Controllers/API/V4/OpenViduController.php +++ /dev/null @@ -1,590 +0,0 @@ -first(); - - // This isn't a room, bye bye - if (!$room) { - return $this->errorResponse(404, \trans('meet.room-not-found')); - } - - // Only the moderator can do it - if (!$this->isModerator($room)) { - return $this->errorResponse(403); - } - - if (!$room->requestAccept($reqid)) { - return $this->errorResponse(500, \trans('meet.session-request-accept-error')); - } - - return response()->json(['status' => 'success']); - } - - /** - * Deny the room join request. - * - * @param string $id Room identifier (name) - * @param string $reqid Request identifier - * - * @return \Illuminate\Http\JsonResponse - */ - public function denyJoinRequest($id, $reqid) - { - $room = Room::where('name', $id)->first(); - - // This isn't a room, bye bye - if (!$room) { - return $this->errorResponse(404, \trans('meet.room-not-found')); - } - - // Only the moderator can do it - if (!$this->isModerator($room)) { - return $this->errorResponse(403); - } - - if (!$room->requestDeny($reqid)) { - return $this->errorResponse(500, \trans('meet.session-request-deny-error')); - } - - return response()->json(['status' => 'success']); - } - - /** - * Close the room session. - * - * @param string $id Room identifier (name) - * - * @return \Illuminate\Http\JsonResponse - */ - public function closeRoom($id) - { - $room = Room::where('name', $id)->first(); - - // This isn't a room, bye bye - if (!$room) { - return $this->errorResponse(404, \trans('meet.room-not-found')); - } - - $user = Auth::guard()->user(); - - // Only the room owner can do it - if (!$user || $user->id != $room->user_id) { - return $this->errorResponse(403); - } - - if (!$room->deleteSession()) { - return $this->errorResponse(500, \trans('meet.session-close-error')); - } - - return response()->json([ - 'status' => 'success', - 'message' => __('meet.session-close-success'), - ]); - } - - /** - * Create a connection for screen sharing. - * - * @param string $id Room identifier (name) - * - * @return \Illuminate\Http\JsonResponse - */ - public function createConnection($id) - { - $room = Room::where('name', $id)->first(); - - // This isn't a room, bye bye - if (!$room) { - return $this->errorResponse(404, \trans('meet.room-not-found')); - } - - $connection = $this->getConnectionFromRequest(); - - if ( - !$connection - || $connection->session_id != $room->session_id - || ($connection->role & Room::ROLE_PUBLISHER) == 0 - ) { - return $this->errorResponse(403); - } - - $response = $room->getSessionToken(Room::ROLE_SCREEN); - - return response()->json(['status' => 'success', 'token' => $response['token']]); - } - - /** - * Dismiss the participant/connection from the session. - * - * @param string $id Room identifier (name) - * @param string $conn Connection identifier - * - * @return \Illuminate\Http\JsonResponse - */ - public function dismissConnection($id, $conn) - { - $connection = Connection::where('id', $conn)->first(); - - // There's no such connection, bye bye - if (!$connection || $connection->room->name != $id) { - return $this->errorResponse(404, \trans('meet.connection-not-found')); - } - - // Only the moderator can do it - if (!$this->isModerator($connection->room)) { - return $this->errorResponse(403); - } - - if (!$connection->dismiss()) { - return $this->errorResponse(500, \trans('meet.connection-dismiss-error')); - } - - return response()->json(['status' => 'success']); - } - - /** - * Listing of rooms that belong to the authenticated user. - * - * @return \Illuminate\Http\JsonResponse - */ - public function index() - { - $user = Auth::guard()->user(); - - $rooms = Room::where('user_id', $user->id)->orderBy('name')->get(); - - if (count($rooms) == 0) { - // Create a room for the user (with a random and unique name) - while (true) { - $name = strtolower(\App\Utils::randStr(3, 3, '-')); - if (!Room::where('name', $name)->count()) { - break; - } - } - - $room = Room::create([ - 'name' => $name, - 'user_id' => $user->id - ]); - - $rooms = collect([$room]); - } - - $result = [ - 'list' => $rooms, - 'count' => count($rooms), - ]; - - return response()->json($result); - } - - /** - * Join the room session. Each room has one owner, and the room isn't open until the owner - * joins (and effectively creates the session). - * - * @param string $id Room identifier (name) - * - * @return \Illuminate\Http\JsonResponse - */ - public function joinRoom($id) - { - $room = Room::where('name', $id)->first(); - - // Room does not exist, or the owner is deleted - if (!$room || !$room->owner || $room->owner->isDegraded(true)) { - return $this->errorResponse(404, \trans('meet.room-not-found')); - } - - // Check if there's still a valid meet entitlement for the room owner - if (!$room->owner->hasSku('meet')) { - return $this->errorResponse(404, \trans('meet.room-not-found')); - } - - $user = Auth::guard()->user(); - $isOwner = $user && $user->id == $room->user_id; - $init = !empty(request()->input('init')); - - // There's no existing session - if (!$room->hasSession()) { - // Participants can't join the room until the session is created by the owner - if (!$isOwner) { - return $this->errorResponse(422, \trans('meet.session-not-found'), ['code' => 323]); - } - - // The room owner can create the session on request - if (!$init) { - return $this->errorResponse(422, \trans('meet.session-not-found'), ['code' => 324]); - } - - $session = $room->createSession(); - - if (empty($session)) { - return $this->errorResponse(500, \trans('meet.session-create-error')); - } - } - - $settings = $room->getSettings(['locked', 'nomedia', 'password']); - $password = (string) $settings['password']; - - $config = [ - 'locked' => $settings['locked'] === 'true', - 'nomedia' => $settings['nomedia'] === 'true', - 'password' => $isOwner ? $password : '', - 'requires_password' => !$isOwner && strlen($password), - ]; - - $response = ['config' => $config]; - - // Validate room password - if (!$isOwner && strlen($password)) { - $request_password = request()->input('password'); - if ($request_password !== $password) { - return $this->errorResponse(422, \trans('meet.session-password-error'), $response + ['code' => 325]); - } - } - - // Handle locked room - if (!$isOwner && $config['locked']) { - $nickname = request()->input('nickname'); - $picture = request()->input('picture'); - $requestId = request()->input('requestId'); - - $request = $requestId ? $room->requestGet($requestId) : null; - - $error = \trans('meet.session-room-locked-error'); - - // Request already has been processed (not accepted yet, but it could be denied) - if (empty($request['status']) || $request['status'] != Room::REQUEST_ACCEPTED) { - if (!$request) { - if (empty($nickname) || empty($requestId) || !preg_match('/^[a-z0-9]{8,32}$/i', $requestId)) { - return $this->errorResponse(422, $error, $response + ['code' => 326]); - } - - if (empty($picture)) { - $svg = file_get_contents(resource_path('images/user.svg')); - $picture = 'data:image/svg+xml;base64,' . base64_encode($svg); - } elseif (!preg_match('|^data:image/png;base64,[a-zA-Z0-9=+/]+$|', $picture)) { - return $this->errorResponse(422, $error, $response + ['code' => 326]); - } - - // TODO: Resize when big/make safe the user picture? - - $request = ['nickname' => $nickname, 'requestId' => $requestId, 'picture' => $picture]; - - if (!$room->requestSave($requestId, $request)) { - // FIXME: should we use error code 500? - return $this->errorResponse(422, $error, $response + ['code' => 326]); - } - - // Send the request (signal) to the owner - $result = $room->signal('joinRequest', $request, Room::ROLE_MODERATOR); - } - - return $this->errorResponse(422, $error, $response + ['code' => 327]); - } - } - - // Initialize connection tokens - if ($init) { - // Choose the connection role - $canPublish = !empty(request()->input('canPublish')) && (empty($config['nomedia']) || $isOwner); - $role = $canPublish ? Room::ROLE_PUBLISHER : Room::ROLE_SUBSCRIBER; - if ($isOwner) { - $role |= Room::ROLE_MODERATOR; - $role |= Room::ROLE_OWNER; - } - - // Create session token for the current user/connection - $response = $room->getSessionToken($role); - - if (empty($response)) { - return $this->errorResponse(500, \trans('meet.session-join-error')); - } - - // Get up-to-date connections metadata - $response['connections'] = $room->getSessionConnections(); - - $response_code = 200; - $response['role'] = $role; - $response['config'] = $config; - } else { - $response_code = 422; - $response['code'] = 322; - } - - return response()->json($response, $response_code); - } - - /** - * Set the domain configuration. - * - * @param string $id Room identifier (name) - * - * @return \Illuminate\Http\JsonResponse|void - */ - public function setRoomConfig($id) - { - $room = Room::where('name', $id)->first(); - - // Room does not exist, or the owner is deleted - if (!$room || !$room->owner || $room->owner->isDegraded(true)) { - return $this->errorResponse(404); - } - - $user = Auth::guard()->user(); - - // Only room owner can configure the room - if ($user->id != $room->user_id) { - return $this->errorResponse(403); - } - - $input = request()->input(); - $errors = []; - - foreach ($input as $key => $value) { - switch ($key) { - case 'password': - if ($value === null || $value === '') { - $input[$key] = null; - } else { - // TODO: Do we have to validate the password in any way? - } - break; - - case 'locked': - $input[$key] = $value ? 'true' : null; - break; - - case 'nomedia': - $input[$key] = $value ? 'true' : null; - break; - - default: - $errors[$key] = \trans('meet.room-unsupported-option-error'); - } - } - - if (!empty($errors)) { - return response()->json(['status' => 'error', 'errors' => $errors], 422); - } - - if (!empty($input)) { - $room->setSettings($input); - } - - return response()->json([ - 'status' => 'success', - 'message' => \trans('meet.room-setconfig-success'), - ]); - } - - /** - * Update the participant/connection parameters (e.g. role). - * - * @param string $id Room identifier (name) - * @param string $conn Connection identifier - * - * @return \Illuminate\Http\JsonResponse - */ - public function updateConnection($id, $conn) - { - $connection = Connection::where('id', $conn)->first(); - - // There's no such connection, bye bye - if (!$connection || $connection->room->name != $id) { - return $this->errorResponse(404, \trans('meet.connection-not-found')); - } - - foreach (request()->input() as $key => $value) { - switch ($key) { - case 'hand': - // Only possible on user's own connection(s) - if (!$this->isSelfConnection($connection)) { - return $this->errorResponse(403); - } - - if ($value) { - // Store current time, so we know the order in the queue - $connection->metadata = ['hand' => time()] + $connection->metadata; - } else { - $connection->metadata = array_diff_key($connection->metadata, ['hand' => 0]); - } - - break; - - case 'language': - // Only the moderator can do it - if (!$this->isModerator($connection->room)) { - return $this->errorResponse(403); - } - - if ($value) { - if (preg_match('/^[a-z]{2}$/', $value)) { - $connection->metadata = ['language' => $value] + $connection->metadata; - } - } else { - $connection->metadata = array_diff_key($connection->metadata, ['language' => 0]); - } - - break; - - case 'role': - // Only the moderator can do it - if (!$this->isModerator($connection->room)) { - return $this->errorResponse(403); - } - - // The 'owner' role is not assignable - if ($value & Room::ROLE_OWNER && !($connection->role & Room::ROLE_OWNER)) { - return $this->errorResponse(403); - } elseif (!($value & Room::ROLE_OWNER) && ($connection->role & Room::ROLE_OWNER)) { - return $this->errorResponse(403); - } - - // The room owner has always a 'moderator' role - if (!($value & Room::ROLE_MODERATOR) && $connection->role & Room::ROLE_OWNER) { - $value |= Room::ROLE_MODERATOR; - } - - // Promotion to publisher? Put the user hand down - if ($value & Room::ROLE_PUBLISHER && !($connection->role & Room::ROLE_PUBLISHER)) { - $connection->metadata = array_diff_key($connection->metadata, ['hand' => 0]); - } - - // Non-publisher cannot be a language interpreter - if (!($value & Room::ROLE_PUBLISHER)) { - $connection->metadata = array_diff_key($connection->metadata, ['language' => 0]); - } - - $connection->{$key} = $value; - break; - } - } - - // The connection observer will send a signal to everyone when needed - $connection->save(); - - return response()->json(['status' => 'success']); - } - - /** - * Webhook as triggered from OpenVidu server - * - * @param \Illuminate\Http\Request $request The API request. - * - * @return \Illuminate\Http\Response The response - */ - public function webhook(Request $request) - { - \Log::debug($request->getContent()); - - switch ((string) $request->input('event')) { - case 'sessionDestroyed': - // When all participants left the room OpenVidu dispatches sessionDestroyed - // event. We'll remove the session reference from the database. - $sessionId = $request->input('sessionId'); - $room = Room::where('session_id', $sessionId)->first(); - - if ($room) { - $room->session_id = null; - $room->save(); - } - - // Remove all connections - // Note: We could remove connections one-by-one via the 'participantLeft' event - // but that could create many INSERTs when the session (with many participants) ends - // So, it is better to remove them all in a single INSERT. - Connection::where('session_id', $sessionId)->delete(); - - break; - } - - return response('Success', 200); - } - - /** - * Check if current user is a moderator for the specified room. - * - * @param \App\OpenVidu\Room $room The room - * - * @return bool True if the current user is the room moderator - */ - protected function isModerator(Room $room): bool - { - $user = Auth::guard()->user(); - - // The room owner is a moderator - if ($user && $user->id == $room->user_id) { - return true; - } - - // Moderator's authentication via the extra request header - if ( - ($connection = $this->getConnectionFromRequest()) - && $connection->session_id === $room->session_id - && $connection->role & Room::ROLE_MODERATOR - ) { - return true; - } - - return false; - } - - /** - * Check if current user "owns" the specified connection. - * - * @param \App\OpenVidu\Connection $connection The connection - * - * @return bool - */ - protected function isSelfConnection(Connection $connection): bool - { - return ($conn = $this->getConnectionFromRequest()) - && $conn->id === $connection->id; - } - - /** - * Get the connection object for the token in current request headers. - * It will also validate the token. - * - * @return \App\OpenVidu\Connection|null Connection (if exists and the token is valid) - */ - protected function getConnectionFromRequest() - { - // Authenticate the user via the extra request header - if ($token = request()->header(self::AUTH_HEADER)) { - list($connId, ) = explode(':', base64_decode($token), 2); - - if ( - ($connection = Connection::find($connId)) - && $connection->metadata['authToken'] === $token - ) { - return $connection; - } - } - - return null; - } -} diff --git a/src/app/Meet/Room.php b/src/app/Meet/Room.php new file mode 100644 --- /dev/null +++ b/src/app/Meet/Room.php @@ -0,0 +1,316 @@ +name), 16) % $count); + + return $urls[$index]; + } + + /** + * Creates HTTP client for connections to Meet server + * + * @return \GuzzleHttp\Client HTTP client instance + */ + private function client() + { + if (!self::$client) { + $url = $this->selectMeetServer(); + + self::$client = new \GuzzleHttp\Client( + [ + 'http_errors' => false, // No exceptions from Guzzle + 'base_uri' => $url, + 'verify' => \config('meet.api_verify_tls'), + 'headers' => [ + 'X-Auth-Token' => \config('meet.api_token'), + ], + 'connect_timeout' => 10, + 'timeout' => 10, + 'on_stats' => function (\GuzzleHttp\TransferStats $stats) { + $threshold = \config('logging.slow_log'); + if ($threshold && ($sec = $stats->getTransferTime()) > $threshold) { + $url = $stats->getEffectiveUri(); + $method = $stats->getRequest()->getMethod(); + \Log::warning(sprintf("[STATS] %s %s: %.4f sec.", $method, $url, $sec)); + } + }, + ] + ); + } + + return self::$client; + } + + /** + * Create a Meet session + * + * @return array|null Session data on success, NULL otherwise + */ + public function createSession(): ?array + { + $params = [ + 'json' => [ /* request params here */ ] + ]; + + $response = $this->client()->request('POST', "sessions", $params); + + if ($response->getStatusCode() !== 200) { + $this->logError("Failed to create the meet session", $response); + $this->session_id = null; + $this->save(); + return null; + } + + $session = json_decode($response->getBody(), true); + + $this->session_id = $session['id']; + $this->save(); + + return $session; + } + + /** + * Create a Meet session (connection) token + * + * @param int $role User role (see self::ROLE_* constants) + * + * @return array|null Token data on success, NULL otherwise + * @throws \Exception if session does not exist + */ + public function getSessionToken($role = self::ROLE_SUBSCRIBER): ?array + { + if (!$this->session_id) { + throw new \Exception("The room session does not exist"); + } + + $url = 'sessions/' . $this->session_id . '/connection'; + $post = [ + 'json' => [ + 'role' => $role, + ] + ]; + + $response = $this->client()->request('POST', $url, $post); + + if ($response->getStatusCode() == 200) { + $json = json_decode($response->getBody(), true); + + return [ + 'token' => $json['token'], + 'role' => $role, + ]; + } + + $this->logError("Failed to create the meet peer connection", $response); + + return null; + } + + /** + * Check if the room has an active session + * + * @return bool True when the session exists, False otherwise + */ + public function hasSession(): bool + { + if (!$this->session_id) { + return false; + } + + $response = $this->client()->request('GET', "sessions/{$this->session_id}"); + + $this->logError("Failed to check that a meet session exists", $response); + + return $response->getStatusCode() == 200; + } + + /** + * The room owner. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function owner() + { + return $this->belongsTo('\App\User', 'user_id', 'id'); + } + + /** + * Accept the join request. + * + * @param string $id Request identifier + * + * @return bool True on success, False on failure + */ + public function requestAccept(string $id): bool + { + $request = Cache::get($this->session_id . '-' . $id); + + if ($request) { + $request['status'] = self::REQUEST_ACCEPTED; + + return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1)); + } + + return false; + } + + /** + * Deny the join request. + * + * @param string $id Request identifier + * + * @return bool True on success, False on failure + */ + public function requestDeny(string $id): bool + { + $request = Cache::get($this->session_id . '-' . $id); + + if ($request) { + $request['status'] = self::REQUEST_DENIED; + + return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1)); + } + + return false; + } + + /** + * Get the join request data. + * + * @param string $id Request identifier + * + * @return array|null Request data (e.g. nickname, status, picture?) + */ + public function requestGet(string $id): ?array + { + return Cache::get($this->session_id . '-' . $id); + } + + /** + * Save the join request. + * + * @param string $id Request identifier + * @param array $request Request data + * + * @return bool True on success, False on failure + */ + public function requestSave(string $id, array $request): bool + { + // We don't really need the picture in the cache + // As we use this cache for the request status only + unset($request['picture']); + + return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1)); + } + + /** + * Any (additional) properties of this room. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function settings() + { + return $this->hasMany('App\Meet\RoomSetting', 'room_id'); + } + + /** + * Send a signal to the Meet session participants (peers) + * + * @param string $name Signal name (type) + * @param array $data Signal data array + * @param int $target Limit targets by their participant role + * + * @return bool True on success, False on failure + * @throws \Exception if session does not exist + */ + public function signal(string $name, array $data = [], $target = null): bool + { + if (!$this->session_id) { + throw new \Exception("The room session does not exist"); + } + + $post = [ + 'roomId' => $this->session_id, + 'type' => $name, + 'role' => $target, + 'data' => $data, + ]; + + $response = $this->client()->request('POST', 'signal', ['json' => $post]); + + $this->logError("Failed to send a signal to the meet session", $response); + + return $response->getStatusCode() == 200; + } + + /** + * Log an error for a failed request to the meet server + * + * @param string $str The error string + * @param object $response Guzzle client response + */ + private function logError(string $str, $response) + { + $code = $response->getStatusCode(); + if ($code != 200) { + \Log::error("$str [$code]"); + } + } +} diff --git a/src/app/OpenVidu/RoomSetting.php b/src/app/Meet/RoomSetting.php rename from src/app/OpenVidu/RoomSetting.php rename to src/app/Meet/RoomSetting.php --- a/src/app/OpenVidu/RoomSetting.php +++ b/src/app/Meet/RoomSetting.php @@ -1,6 +1,6 @@ belongsTo('\App\OpenVidu\Room', 'room_id', 'id'); + return $this->belongsTo('\App\Meet\Room', 'room_id', 'id'); } } diff --git a/src/app/Observers/OpenVidu/ConnectionObserver.php b/src/app/Observers/OpenVidu/ConnectionObserver.php deleted file mode 100644 --- a/src/app/Observers/OpenVidu/ConnectionObserver.php +++ /dev/null @@ -1,50 +0,0 @@ -role != $connection->getOriginal('role')) { - $params['role'] = $connection->role; - - // TODO: When demoting publisher to subscriber maybe we should - // destroy all streams using REST API. For now we trust the - // participant browser to do this. - } - - // Detect metadata changes for specified properties - $keys = [ - 'hand' => 'bool', - 'language' => '', - ]; - - foreach ($keys as $key => $type) { - $newState = $connection->metadata[$key] ?? null; - $oldState = $connection->getOriginal('metadata')[$key] ?? null; - - if ($newState !== $oldState) { - $params[$key] = $type == 'bool' ? !empty($newState) : $newState; - } - } - - // Send the signal to all participants - if (!empty($params)) { - $params['connectionId'] = $connection->id; - $connection->room->signal('connectionUpdate', $params); - } - } -} diff --git a/src/app/OpenVidu/Connection.php b/src/app/OpenVidu/Connection.php deleted file mode 100644 --- a/src/app/OpenVidu/Connection.php +++ /dev/null @@ -1,98 +0,0 @@ - 'array', - ]; - - /** - * Dismiss (close) the connection. - * - * @return bool True on success, False on failure - */ - public function dismiss() - { - if ($this->room->closeOVConnection($this->id)) { - $this->delete(); - - return true; - } - - return false; - } - - /** - * The room to which this connection belongs. - * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function room() - { - return $this->belongsTo(Room::class, 'room_id', 'id'); - } - - /** - * Connection role mutator - * - * @throws \Exception - */ - public function setRoleAttribute($role) - { - $new_role = 0; - - $allowed_values = [ - Room::ROLE_SUBSCRIBER, - Room::ROLE_PUBLISHER, - Room::ROLE_MODERATOR, - Room::ROLE_SCREEN, - Room::ROLE_OWNER, - ]; - - foreach ($allowed_values as $value) { - if ($role & $value) { - $new_role |= $value; - $role ^= $value; - } - } - - if ($role > 0) { - throw new \Exception("Invalid connection role: {$role}"); - } - - // It is either screen sharing connection or publisher/subscriber connection - if ($new_role & Room::ROLE_SCREEN) { - if ($new_role & Room::ROLE_PUBLISHER) { - $new_role ^= Room::ROLE_PUBLISHER; - } - if ($new_role & Room::ROLE_SUBSCRIBER) { - $new_role ^= Room::ROLE_SUBSCRIBER; - } - } - - $this->attributes['role'] = $new_role; - } -} diff --git a/src/app/OpenVidu/Room.php b/src/app/OpenVidu/Room.php deleted file mode 100644 --- a/src/app/OpenVidu/Room.php +++ /dev/null @@ -1,427 +0,0 @@ - false, // No exceptions from Guzzle - 'base_uri' => \config('openvidu.api_url'), - 'verify' => \config('openvidu.api_verify_tls'), - 'auth' => [ - \config('openvidu.api_username'), - \config('openvidu.api_password') - ], - 'on_stats' => function (\GuzzleHttp\TransferStats $stats) { - $threshold = \config('logging.slow_log'); - if ($threshold && ($sec = $stats->getTransferTime()) > $threshold) { - $url = $stats->getEffectiveUri(); - $method = $stats->getRequest()->getMethod(); - \Log::warning(sprintf("[STATS] %s %s: %.4f sec.", $method, $url, $sec)); - } - }, - ] - ); - } - - return self::$client; - } - - /** - * Destroy a OpenVidu connection - * - * @param string $conn Connection identifier - * - * @return bool True on success, False otherwise - * @throws \Exception if session does not exist - */ - public function closeOVConnection($conn): bool - { - if (!$this->session_id) { - throw new \Exception("The room session does not exist"); - } - - $url = 'sessions/' . $this->session_id . '/connection/' . urlencode($conn); - - $response = $this->client()->request('DELETE', $url); - - return $response->getStatusCode() == 204; - } - - /** - * Fetch a OpenVidu connection information. - * - * @param string $conn Connection identifier - * - * @return ?array Connection data on success, Null otherwise - * @throws \Exception if session does not exist - */ - public function getOVConnection($conn): ?array - { - // Note: getOVConnection() not getConnection() because Eloquent\Model::getConnection() exists - // TODO: Maybe use some other name? getParticipant? - if (!$this->session_id) { - throw new \Exception("The room session does not exist"); - } - - $url = 'sessions/' . $this->session_id . '/connection/' . urlencode($conn); - - $response = $this->client()->request('GET', $url); - - if ($response->getStatusCode() == 200) { - return json_decode($response->getBody(), true); - } - - return null; - } - - /** - * Create a OpenVidu session - * - * @return array|null Session data on success, NULL otherwise - */ - public function createSession(): ?array - { - $response = $this->client()->request( - 'POST', - "sessions", - [ - 'json' => [ - 'mediaMode' => 'ROUTED', - 'recordingMode' => 'MANUAL' - ] - ] - ); - - if ($response->getStatusCode() !== 200) { - $this->session_id = null; - $this->save(); - return null; - } - - $session = json_decode($response->getBody(), true); - - $this->session_id = $session['id']; - $this->save(); - - return $session; - } - - /** - * Delete a OpenVidu session - * - * @return bool - */ - public function deleteSession(): bool - { - if (!$this->session_id) { - return true; - } - - $response = $this->client()->request( - 'DELETE', - "sessions/" . $this->session_id, - ); - - if ($response->getStatusCode() == 204) { - $this->session_id = null; - $this->save(); - - return true; - } - - return false; - } - - /** - * Returns metadata for every connection in a session. - * - * @return array Connections metadata, indexed by connection identifier - * @throws \Exception if session does not exist - */ - public function getSessionConnections(): array - { - if (!$this->session_id) { - throw new \Exception("The room session does not exist"); - } - - return Connection::where('session_id', $this->session_id) - // Ignore screen sharing connection for now - ->whereRaw("(role & " . self::ROLE_SCREEN . ") = 0") - ->get() - ->keyBy('id') - ->map(function ($item) { - // Warning: Make sure to not return all metadata here as it might contain sensitive data. - return [ - 'role' => $item->role, - 'hand' => $item->metadata['hand'] ?? 0, - 'language' => $item->metadata['language'] ?? null, - ]; - }) - // Sort by order in the queue, so UI can re-build the existing queue in order - ->sort(function ($a, $b) { - return $a['hand'] <=> $b['hand']; - }) - ->all(); - } - - /** - * Create a OpenVidu session (connection) token - * - * @param int $role User role (see self::ROLE_* constants) - * - * @return array|null Token data on success, NULL otherwise - * @throws \Exception if session does not exist - */ - public function getSessionToken($role = self::ROLE_SUBSCRIBER): ?array - { - if (!$this->session_id) { - throw new \Exception("The room session does not exist"); - } - - // FIXME: Looks like passing the role in 'data' param is the only way - // to make it visible for everyone in a room. So, for example we can - // handle/style subscribers/publishers/moderators differently on the - // client-side. Is this a security issue? - $data = ['role' => $role]; - - $url = 'sessions/' . $this->session_id . '/connection'; - $post = [ - 'json' => [ - 'role' => self::OV_ROLE_PUBLISHER, - 'data' => json_encode($data) - ] - ]; - - $response = $this->client()->request('POST', $url, $post); - - if ($response->getStatusCode() == 200) { - $json = json_decode($response->getBody(), true); - - $authToken = base64_encode($json['id'] . ':' . \random_bytes(16)); - - // Extract the 'token' part of the token, it will be used to authenticate the connection. - // It will be needed in next iterations e.g. to authenticate moderators that aren't - // Kolab4 users (or are just not logged in to Kolab4). - // FIXME: we could as well generate our own token for auth purposes - parse_str(parse_url($json['token'], PHP_URL_QUERY), $url); - - // Create the connection reference in our database - $conn = new Connection(); - $conn->id = $json['id']; - $conn->session_id = $this->session_id; - $conn->room_id = $this->id; - $conn->role = $role; - $conn->metadata = ['token' => $url['token'], 'authToken' => $authToken]; - $conn->save(); - - return [ - 'session' => $this->session_id, - 'token' => $json['token'], - 'authToken' => $authToken, - 'connectionId' => $json['id'], - 'role' => $role, - ]; - } - - return null; - } - - /** - * Check if the room has an active session - * - * @return bool True when the session exists, False otherwise - */ - public function hasSession(): bool - { - if (!$this->session_id) { - return false; - } - - $response = $this->client()->request('GET', "sessions/{$this->session_id}"); - - return $response->getStatusCode() == 200; - } - - /** - * The room owner. - * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo - */ - public function owner() - { - return $this->belongsTo('\App\User', 'user_id', 'id'); - } - - /** - * Accept the join request. - * - * @param string $id Request identifier - * - * @return bool True on success, False on failure - */ - public function requestAccept(string $id): bool - { - $request = Cache::get($this->session_id . '-' . $id); - - if ($request) { - $request['status'] = self::REQUEST_ACCEPTED; - - return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1)); - } - - return false; - } - - /** - * Deny the join request. - * - * @param string $id Request identifier - * - * @return bool True on success, False on failure - */ - public function requestDeny(string $id): bool - { - $request = Cache::get($this->session_id . '-' . $id); - - if ($request) { - $request['status'] = self::REQUEST_DENIED; - - return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1)); - } - - return false; - } - - /** - * Get the join request data. - * - * @param string $id Request identifier - * - * @return array|null Request data (e.g. nickname, status, picture?) - */ - public function requestGet(string $id): ?array - { - return Cache::get($this->session_id . '-' . $id); - } - - /** - * Save the join request. - * - * @param string $id Request identifier - * @param array $request Request data - * - * @return bool True on success, False on failure - */ - public function requestSave(string $id, array $request): bool - { - // We don't really need the picture in the cache - // As we use this cache for the request status only - unset($request['picture']); - - return Cache::put($this->session_id . '-' . $id, $request, now()->addHours(1)); - } - - /** - * Any (additional) properties of this room. - * - * @return \Illuminate\Database\Eloquent\Relations\HasMany - */ - public function settings() - { - return $this->hasMany('App\OpenVidu\RoomSetting', 'room_id'); - } - - /** - * Send a OpenVidu signal to the session participants (connections) - * - * @param string $name Signal name (type) - * @param array $data Signal data array - * @param null|int|string[] $target List of target connections, Null for all connections. - * It can be also a participant role. - * - * @return bool True on success, False on failure - * @throws \Exception if session does not exist - */ - public function signal(string $name, array $data = [], $target = null): bool - { - if (!$this->session_id) { - throw new \Exception("The room session does not exist"); - } - - $post = [ - 'session' => $this->session_id, - 'type' => $name, - 'data' => $data ? json_encode($data) : '', - ]; - - // Get connection IDs by participant role - if (is_int($target)) { - $connections = Connection::where('session_id', $this->session_id) - ->whereRaw("(role & $target)") - ->pluck('id') - ->all(); - - if (empty($connections)) { - return false; - } - - $target = $connections; - } - - if (!empty($target)) { - $post['to'] = $target; - } - - $response = $this->client()->request('POST', 'signal', ['json' => $post]); - - return $response->getStatusCode() == 200; - } -} diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -48,7 +48,6 @@ \App\Entitlement::observe(\App\Observers\EntitlementObserver::class); \App\Group::observe(\App\Observers\GroupObserver::class); \App\GroupSetting::observe(\App\Observers\GroupSettingObserver::class); - \App\OpenVidu\Connection::observe(\App\Observers\OpenVidu\ConnectionObserver::class); \App\PackageSku::observe(\App\Observers\PackageSkuObserver::class); \App\PlanPackage::observe(\App\Observers\PlanPackageObserver::class); \App\Resource::observe(\App\Observers\ResourceObserver::class); diff --git a/src/config/meet.php b/src/config/meet.php new file mode 100644 --- /dev/null +++ b/src/config/meet.php @@ -0,0 +1,11 @@ + env('MEET_SERVER_TOKEN', 'MY_SECRET'), + 'api_urls' => explode( + ',', + env('MEET_SERVER_URLS', "https://localhost:12443/meetmedia/api/,https://localhost:12444/meetmedia/api/") + ), + 'api_verify_tls' => (bool) env('MEET_SERVER_VERIFY_TLS', true), + 'webhook_token' => env('MEET_WEBHOOK_TOKEN', 'MY_SECRET'), + ]; diff --git a/src/config/openvidu.php b/src/config/openvidu.php deleted file mode 100644 --- a/src/config/openvidu.php +++ /dev/null @@ -1,7 +0,0 @@ - env('OPENVIDU_API_PASSWORD', 'MY_SECRET'), - 'api_url' => env('OPENVIDU_API_URL', 'https://localhost:8443/api/'), - 'api_username' => env('OPENVIDU_API_USERNAME', 'OPENVIDUAPP'), - 'api_verify_tls' => (bool) env('OPENVIDU_API_VERIFY_TLS', true) - ]; diff --git a/src/database/migrations/2021_10_27_120000_extend_openvidu_rooms_session_id.php b/src/database/migrations/2021_10_27_120000_extend_openvidu_rooms_session_id.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2021_10_27_120000_extend_openvidu_rooms_session_id.php @@ -0,0 +1,39 @@ +string('session_id', 36)->change(); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table( + 'openvidu_rooms', + function (Blueprint $table) { + $table->string('session_id', 16)->change(); + } + ); + } +} diff --git a/src/database/seeds/DatabaseSeeder.php b/src/database/seeds/DatabaseSeeder.php --- a/src/database/seeds/DatabaseSeeder.php +++ b/src/database/seeds/DatabaseSeeder.php @@ -23,10 +23,10 @@ 'PlanSeeder', 'PowerDNSSeeder', 'UserSeeder', - 'OpenViduRoomSeeder', 'OauthClientSeeder', 'ResourceSeeder', 'SharedFolderSeeder', + 'MeetRoomSeeder' ]; $env = ucfirst(App::environment()); diff --git a/src/database/seeds/local/OpenViduRoomSeeder.php b/src/database/seeds/local/MeetRoomSeeder.php rename from src/database/seeds/local/OpenViduRoomSeeder.php rename to src/database/seeds/local/MeetRoomSeeder.php --- a/src/database/seeds/local/OpenViduRoomSeeder.php +++ b/src/database/seeds/local/MeetRoomSeeder.php @@ -2,10 +2,10 @@ namespace Database\Seeds\Local; -use App\OpenVidu\Room; +use App\Meet\Room; use Illuminate\Database\Seeder; -class OpenViduRoomSeeder extends Seeder +class MeetRoomSeeder extends Seeder { /** * Run the database seeds. @@ -17,14 +17,14 @@ $john = \App\User::where('email', 'john@kolab.org')->first(); $jack = \App\User::where('email', 'jack@kolab.org')->first(); - \App\OpenVidu\Room::create( + \App\Meet\Room::create( [ 'user_id' => $john->id, 'name' => 'john' ] ); - \App\OpenVidu\Room::create( + \App\Meet\Room::create( [ 'user_id' => $jack->id, 'name' => strtolower(\App\Utils::randStr(3, 3, '-')) diff --git a/src/package-lock.json b/src/package-lock.json --- a/src/package-lock.json +++ b/src/package-lock.json @@ -1270,6 +1270,12 @@ "integrity": "sha512-xDu17cEfh7Kid/d95kB6tZsLOmSWKCZKtprnhVepjsSaCij+lM3mItSJDuuHDMbCWTh8Ejmebwb+KONcCJ0eXQ==", "dev": true }, + "@socket.io/component-emitter": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.0.0.tgz", + "integrity": "sha512-2pTGuibAXJswAPJjaKisthqS/NOK5ypG4LYT6tEAV0S/mxW0zOIvYvGK0V8w8+SHxAm6vRMSjqSalFXeBAqs+Q==", + "dev": true + }, "@stylelint/postcss-css-in-js": { "version": "0.37.2", "resolved": "https://registry.npmjs.org/@stylelint/postcss-css-in-js/-/postcss-css-in-js-0.37.2.tgz", @@ -1354,6 +1360,15 @@ } } }, + "@types/debug": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.7.tgz", + "integrity": "sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==", + "dev": true, + "requires": { + "@types/ms": "*" + } + }, "@types/eslint": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-7.28.0.tgz", @@ -1380,6 +1395,12 @@ "integrity": "sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==", "dev": true }, + "@types/events": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz", + "integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==", + "dev": true + }, "@types/glob": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.4.tgz", @@ -1472,6 +1493,12 @@ "integrity": "sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==", "dev": true }, + "@types/ms": { + "version": "0.7.31", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", + "integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==", + "dev": true + }, "@types/node": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-16.6.1.tgz", @@ -1897,9 +1924,9 @@ "dev": true }, "ansi-regex": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", - "integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true }, "ansi-styles": { @@ -2024,13 +2051,19 @@ "postcss-value-parser": "^4.1.0" } }, + "awaitqueue": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/awaitqueue/-/awaitqueue-2.3.3.tgz", + "integrity": "sha512-RbzQg6VtPUtyErm55iuQLTrBJ2uihy5BKBOEkyBwv67xm5Fn2o/j+Bz+a5BmfSoe2oZ5dcz9Z3fExS8pL+LLhw==", + "dev": true + }, "axios": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", - "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", "dev": true, "requires": { - "follow-redirects": "^1.10.0" + "follow-redirects": "^1.14.0" } }, "babel-loader": { @@ -2084,6 +2117,12 @@ "@babel/helper-define-polyfill-provider": "^0.2.2" } }, + "backo2": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", + "integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc=", + "dev": true + }, "bail": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz", @@ -2096,6 +2135,12 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "base64-arraybuffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.1.tgz", + "integrity": "sha512-vFIUq7FdLtjZMhATwDul5RZWv2jpXQ09Pd6jcVEOvIsqCWTRFD/ONHNfyOS8dA/Ippi5dsIgpyKWKZaAKZltbA==", + "dev": true + }, "base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -2199,6 +2244,12 @@ "integrity": "sha512-bs74WNI9BgBo3cEovmdMHikSKoXnDgA6VQjJ7TyTotU6L7d41ZyCEEelPwkYEzsG/Zjv3ie9IE3EMAje0W9Xew==", "dev": true }, + "bowser": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", + "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==", + "dev": true + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -3423,6 +3474,40 @@ "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=", "dev": true }, + "engine.io-client": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.0.2.tgz", + "integrity": "sha512-cAep9lhZV6Q8jMXx3TNSU5cydMzMed8/O7Tz5uzyqZvpNPtQ3WQXrLYGADxlsuaFmOLN7wZLmT7ImiFhUOku8g==", + "dev": true, + "requires": { + "@socket.io/component-emitter": "~3.0.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.0.0", + "has-cors": "1.1.0", + "parseqs": "0.0.6", + "parseuri": "0.0.6", + "ws": "~8.2.3", + "xmlhttprequest-ssl": "~2.0.0", + "yeast": "0.1.2" + }, + "dependencies": { + "ws": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", + "integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==", + "dev": true + } + } + }, + "engine.io-parser": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.1.tgz", + "integrity": "sha512-j4p3WwJrG2k92VISM0op7wiq60vO92MlF3CRGxhKHy9ywG1/Dkc72g0dXeDQ+//hrcDn8gqQzoEkdO9FN0d9AA==", + "dev": true, + "requires": { + "base64-arraybuffer": "~1.0.1" + } + }, "enhanced-resolve": { "version": "5.8.2", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.8.2.tgz", @@ -3668,6 +3753,12 @@ "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=", "dev": true }, + "event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true + }, "eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -3783,6 +3874,24 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "dev": true }, + "fake-mediastreamtrack": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/fake-mediastreamtrack/-/fake-mediastreamtrack-1.1.6.tgz", + "integrity": "sha512-lcoO5oPsW57istAsnjvQxNjBEahi18OdUhWfmEewwfPfzNZnji5OXuodQM+VnUPi/1HnQRJ6gBUjbt1TNXrkjQ==", + "dev": true, + "requires": { + "event-target-shim": "^5.0.1", + "uuid": "^8.1.0" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true + } + } + }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4218,6 +4327,15 @@ "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", "dev": true }, + "h264-profile-level-id": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/h264-profile-level-id/-/h264-profile-level-id-1.0.1.tgz", + "integrity": "sha512-D3Rln/jKNjKDW5ZTJTK3niSoOGE+pFqPvRHHVgQN3G7umcn/zWGPUo8Q8VpDj16x3hKz++zVviRNRmXu5cpN+Q==", + "dev": true, + "requires": { + "debug": "^4.1.1" + } + }, "handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", @@ -4248,6 +4366,12 @@ "function-bind": "^1.1.1" } }, + "has-cors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-cors/-/has-cors-1.1.0.tgz", + "integrity": "sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk=", + "dev": true + }, "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -5420,6 +5544,41 @@ "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=", "dev": true }, + "mediasoup-client": { + "version": "3.6.37", + "resolved": "https://registry.npmjs.org/mediasoup-client/-/mediasoup-client-3.6.37.tgz", + "integrity": "sha512-mGP2FNphHLwZApxUMSdWvLYlq6RICQssLFdvcUFIOVmO32++1ZW7eZNBv2oRtpnUY+4t4mPr7K3pOgTxxPyl/A==", + "dev": true, + "requires": { + "@types/debug": "^4.1.7", + "@types/events": "^3.0.0", + "awaitqueue": "^2.3.3", + "bowser": "^2.11.0", + "debug": "^4.3.2", + "events": "^3.3.0", + "fake-mediastreamtrack": "^1.1.6", + "h264-profile-level-id": "^1.0.1", + "sdp-transform": "^2.14.1", + "supports-color": "^8.1.1" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, "mem": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/mem/-/mem-8.1.1.tgz", @@ -5878,9 +6037,9 @@ } }, "nth-check": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.0.0.tgz", - "integrity": "sha512-i4sc/Kj8htBrAiH1viZ0TgU8Y5XqCaV/FziYK6TBczxmeKm3AEFWqqF3195yKudrarqy7Zu80Ra5dobFjn9X/Q==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.0.1.tgz", + "integrity": "sha512-it1vE95zF6dTT9lBsYbxvqh0Soy4SPowchj0UBGj/V6cTPnXXtQOPUbhZ6CmGzAD/rW22LQK6E96pcdJXk4A4w==", "dev": true, "requires": { "boolbase": "^1.0.0" @@ -6160,6 +6319,18 @@ "lines-and-columns": "^1.1.6" } }, + "parseqs": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.6.tgz", + "integrity": "sha512-jeAGzMDbfSHHA091hr0r31eYfTig+29g3GKKE/PPbEQ65X0lmMwlEoqmhzu0iztID5uJpZsFlUPDP8ThPL7M8w==", + "dev": true + }, + "parseuri": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.6.tgz", + "integrity": "sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow==", + "dev": true + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -7641,6 +7812,12 @@ "ajv-keywords": "^3.5.2" } }, + "sdp-transform": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.14.1.tgz", + "integrity": "sha512-RjZyX3nVwJyCuTo5tGPx+PZWkDMCg7oOLpSlhjDdZfwUoNqG1mM8nyj31IGHyaPWXhjbP7cdK3qZ2bmkJ1GzRw==", + "dev": true + }, "select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -7886,6 +8063,30 @@ } } }, + "socket.io-client": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.3.2.tgz", + "integrity": "sha512-2B9LqSunN60yV8F7S84CCEEcgbYNfrn7ejIInZtLZ7ppWtiX8rGZAjvdCvbnC8bqo/9RlCNOUsORLyskxSFP1g==", + "dev": true, + "requires": { + "@socket.io/component-emitter": "~3.0.0", + "backo2": "~1.0.2", + "debug": "~4.3.2", + "engine.io-client": "~6.0.1", + "parseuri": "0.0.6", + "socket.io-parser": "~4.1.1" + } + }, + "socket.io-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.1.1.tgz", + "integrity": "sha512-USQVLSkDWE5nbcY760ExdKaJxCE65kcsG/8k5FDGZVVxpD1pA7hABYXYkCUvxUuYYh/+uQw0N/fvBzfT8o07KA==", + "dev": true, + "requires": { + "@socket.io/component-emitter": "~3.0.0", + "debug": "~4.3.1" + } + }, "sockjs": { "version": "0.3.21", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.21.tgz", @@ -9323,6 +9524,12 @@ "integrity": "sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg==", "dev": true }, + "xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "dev": true + }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -9368,6 +9575,12 @@ "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", "dev": true }, + "yeast": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz", + "integrity": "sha1-AI4G2AlDIMNy28L47XagymyKxBk=", + "dev": true + }, "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/src/package.json b/src/package.json --- a/src/package.json +++ b/src/package.json @@ -19,7 +19,7 @@ "@fortawesome/vue-fontawesome": "^0.1.10", "@popperjs/core": "^2.9.2", "anchorme": "^2.1.2", - "axios": "^0.21.1", + "axios": "^0.21.4", "bootstrap": "^5.0.0", "cash-dom": "^8.1.0", "cross-env": "^7.0.3", @@ -27,11 +27,13 @@ "eslint-plugin-vue": "^7.9.0", "frappe-charts": "^1.5.8", "laravel-mix": "^6.0.27", + "mediasoup-client": "^3.6.37", "openvidu-browser": "^2.18.0", "postcss": "^8.3.6", "resolve-url-loader": "^4.0.0", "sass": "^1.32.8", "sass-loader": "^8.0.0", + "socket.io-client": "~4.3.2", "stylelint": "^13.13.1", "stylelint-config-standard": "^22.0.0", "vue": "^2.6.12", diff --git a/src/resources/js/meet/app.js b/src/resources/js/meet/app.js deleted file mode 100644 --- a/src/resources/js/meet/app.js +++ /dev/null @@ -1,1726 +0,0 @@ -import anchorme from 'anchorme' -import { Dropdown } from 'bootstrap' -import { library } from '@fortawesome/fontawesome-svg-core' -import { OpenVidu } from 'openvidu-browser' - -class Roles { - static get SUBSCRIBER() { return 1 << 0; } - static get PUBLISHER() { return 1 << 1; } - static get MODERATOR() { return 1 << 2; } - static get SCREEN() { return 1 << 3; } - static get OWNER() { return 1 << 4; } -} - -// Disable jsnlog's error handlers added in OpenVidu 2.18 -// https://github.com/OpenVidu/openvidu/issues/631 -window.onerror = () => { return false } -window.onunhandledrejection = () => { return false } - -function Meet(container) -{ - let OV // OpenVidu object to initialize a session - let session // Session object where the user will connect - let publisher // Publisher object which the user will publish - let audioActive = false // True if the audio track of the publisher is active - let videoActive = false // True if the video track of the publisher is active - let audioSource = '' // Currently selected microphone - let videoSource = '' // Currently selected camera - let sessionData // Room session metadata - - let screenOV // OpenVidu object to initialize a screen sharing session - let screenSession // Session object where the user will connect for screen sharing - let screenPublisher // Publisher object which the user will publish the screen sharing - - let publisherDefaults = { - publishAudio: true, // Whether to start publishing with your audio unmuted or not - publishVideo: true, // Whether to start publishing with your video enabled or not - resolution: '640x480', // The resolution of your video - frameRate: 30, // The frame rate of your video - mirror: true // Whether to mirror your local video or not - } - - let cameras = [] // List of user video devices - let microphones = [] // List of user audio devices - let connections = {} // Connected users in the session - - let chatCount = 0 - let volumeElement - let publishersContainer - let subscribersContainer - let scrollStop - let $t - - OV = ovInit() - - // Disconnect participant when browser's window close - window.addEventListener('beforeunload', () => { - leaveRoom() - }) - - window.addEventListener('resize', resize) - - // Public methods - this.isScreenSharingSupported = isScreenSharingSupported - this.joinRoom = joinRoom - this.leaveRoom = leaveRoom - this.setupStart = setupStart - this.setupStop = setupStop - this.setupSetAudioDevice = setupSetAudioDevice - this.setupSetVideoDevice = setupSetVideoDevice - this.switchAudio = switchAudio - this.switchChannel = switchChannel - this.switchScreen = switchScreen - this.switchVideo = switchVideo - this.updateSession = updateSession - - /** - * Initialize OpenVidu instance - */ - function ovInit() - { - let ov = new OpenVidu() - - // If there's anything to do, do it here. - //ov.setAdvancedConfiguration(config) - - // Disable all logging except errors - // ov.enableProdMode() - - return ov - } - - /** - * Join the room session - * - * @param data Session metadata and event handlers: - * token - OpenVidu token for the main connection, - * shareToken - OpenVidu token for screen-sharing connection, - * nickname - Participant name, - * role - connection (participant) role(s), - * connections - Optional metadata for other users connections (current state), - * channel - Selected interpreted language channel (two-letter language code) - * languages - Supported languages (code-to-label map) - * chatElement - DOM element for the chat widget, - * counterElement - DOM element for the participants counter, - * menuElement - DOM element of the room toolbar, - * queueElement - DOM element for the Q&A queue (users with a raised hand) - * onSuccess - Callback for session connection (join) success - * onError - Callback for session connection (join) error - * onDestroy - Callback for session disconnection event, - * onDismiss - Callback for Dismiss action, - * onJoinRequest - Callback for join request, - * onConnectionChange - Callback for participant changes, e.g. role update, - * onSessionDataUpdate - Callback for current user connection update, - * onMediaSetup - Called when user clicks the Media setup button - * translate - Translation function - */ - function joinRoom(data) { - // Create a container for subscribers and publishers - publishersContainer = $('
').appendTo(container).get(0) - subscribersContainer = $('
').appendTo(container).get(0) - - resize(); - volumeMeterStop() - - data.params = { - nickname: data.nickname, // user nickname - // avatar: undefined // avatar image - } - - $t = data.translate - - // Make sure all supported callbacks exist, so we don't have to check - // their existence everywhere anymore - let events = ['Success', 'Error', 'Destroy', 'Dismiss', 'JoinRequest', 'ConnectionChange', - 'SessionDataUpdate', 'MediaSetup'] - - events.map(event => 'on' + event).forEach(event => { - if (!data[event]) { - data[event] = () => {} - } - }) - - sessionData = data - - // Init a session - session = OV.initSession() - - // Handle connection creation events - session.on('connectionCreated', event => { - // Ignore the current user connection - if (event.connection.role) { - return - } - - // This is the first event executed when a user joins in. - // We'll create the video wrapper here, which can be re-used - // in 'streamCreated' event handler. - - let metadata = connectionData(event.connection) - const connId = metadata.connectionId - - // The connection metadata here is the initial metadata set on - // connection initialization. There's no way to update it via OpenVidu API. - // So, we merge the initial connection metadata with up-to-dated one that - // we got from our database. - if (sessionData.connections && connId in sessionData.connections) { - Object.assign(metadata, sessionData.connections[connId]) - } - - metadata.element = participantCreate(metadata) - - connections[connId] = metadata - }) - - session.on('connectionDestroyed', event => { - let connectionId = event.connection.connectionId - let conn = connections[connectionId] - - if (conn) { - // Remove elements related to the participant - connectionHandDown(connectionId) - $(conn.element).remove() - delete connections[connectionId] - } - - resize() - }) - - // On every new Stream received... - session.on('streamCreated', event => { - let connectionId = event.stream.connection.connectionId - let metadata = connections[connectionId] - let props = { - // Prepend the video element so it is always before the watermark element - insertMode: 'PREPEND' - } - - // Subscribe to the Stream to receive it - let subscriber = session.subscribe(event.stream, metadata.element, props); - - Object.assign(metadata, { - audioActive: event.stream.audioActive, - videoActive: event.stream.videoActive, - videoDimensions: event.stream.videoDimensions - }) - - subscriber.on('videoElementCreated', event => { - $(event.element).prop({ - tabindex: -1 - }) - - resize() - }) - - // Update the wrapper controls/status - participantUpdate(metadata.element, metadata) - - // Send the current user status to the connecting user - // otherwise e.g. nickname might be not up to date - signalUserUpdate(event.stream.connection) - }) - - // Stream properties changes e.g. audio/video muted/unmuted - session.on('streamPropertyChanged', event => { - let connectionId = event.stream.connection.connectionId - let metadata = connections[connectionId] - - if (session.connection.connectionId == connectionId) { - metadata = sessionData - metadata.audioActive = audioActive - metadata.videoActive = videoActive - } - - if (metadata) { - metadata[event.changedProperty] = event.newValue - - if (event.changedProperty == 'videoDimensions') { - resize() - } else { - participantUpdate(metadata.element, metadata) - } - } - }) - - // Handle session disconnection events - session.on('sessionDisconnected', event => { - data.onDestroy(event) - session = null - resize() - }) - - // Handle signals from all participants - session.on('signal', signalEventHandler) - - // Connect with the token - session.connect(data.token, data.params) - .then(() => { - data.onSuccess() - - let params = { - connectionId: session.connection.connectionId, - role: data.role, - audioActive, - videoActive - } - - params = Object.assign({}, data.params, params) - - publisher.on('videoElementCreated', event => { - $(event.element).prop({ - muted: true, // Mute local video to avoid feedback - disablePictureInPicture: true, // this does not work in Firefox - tabindex: -1 - }) - resize() - }) - - let wrapper = participantCreate(params) - - if (data.role & Roles.PUBLISHER) { - publisher.createVideoElement(wrapper, 'PREPEND') - session.publish(publisher) - } - - sessionData.element = wrapper - - // Create Q&A queue from the existing connections with rised hand. - // Here we expect connections in a proper queue order - Object.keys(data.connections || {}).forEach(key => { - let conn = data.connections[key] - - if (conn.hand) { - conn.connectionId = key - connectionHandUp(conn) - } - }) - - sessionData.channels = getChannels(data.connections) - - // Inform the vue component, so it can update some UI controls - if (sessionData.channels.length) { - sessionData.onSessionDataUpdate(sessionData) - } - }) - .catch(error => { - console.error('There was an error connecting to the session: ', error.message); - data.onError(error) - }) - - // Prepare the chat - setupChat() - } - - /** - * Leave the room (disconnect) - */ - function leaveRoom() { - if (publisher) { - volumeMeterStop() - - // Release any media - let mediaStream = publisher.stream.getMediaStream() - if (mediaStream) { - mediaStream.getTracks().forEach(track => track.stop()) - } - - publisher = null - } - - if (session) { - session.disconnect(); - session = null - } - - if (screenSession) { - screenSession.disconnect(); - screenSession = null - } - } - - /** - * Sets the audio and video devices for the session. - * This will ask user for permission to access media devices. - * - * @param props Setup properties (videoElement, volumeElement, onSuccess, onError) - */ - function setupStart(props) { - // Note: After changing media permissions in Chrome/Firefox a page refresh is required. - // That means that in a scenario where you first blocked access to media devices - // and then allowed it we can't ask for devices list again and expect a different - // result than before. - // That's why we do not bother, and return ealy when we open the media setup dialog. - if (publisher) { - volumeMeterStart() - return - } - - publisher = OV.initPublisher(undefined, publisherDefaults) - - publisher.once('accessDenied', error => { - props.onError(error) - }) - - publisher.once('accessAllowed', async () => { - let mediaStream = publisher.stream.getMediaStream() - let videoStream = mediaStream.getVideoTracks()[0] - let audioStream = mediaStream.getAudioTracks()[0] - - audioActive = !!audioStream - videoActive = !!videoStream - volumeElement = props.volumeElement - - publisher.addVideoElement(props.videoElement) - - volumeMeterStart() - - const devices = await OV.getDevices() - - devices.forEach(device => { - // device's props: deviceId, kind, label - if (device.kind == 'videoinput') { - cameras.push(device) - if (videoStream && videoStream.label == device.label) { - videoSource = device.deviceId - } - } else if (device.kind == 'audioinput') { - microphones.push(device) - if (audioStream && audioStream.label == device.label) { - audioSource = device.deviceId - } - } - }) - - props.onSuccess({ - microphones, - cameras, - audioSource, - videoSource, - audioActive, - videoActive - }) - }) - } - - /** - * Stop the setup "process", cleanup after it. - */ - function setupStop() { - volumeMeterStop() - } - - /** - * Change the publisher audio device - * - * @param deviceId Device identifier string - */ - async function setupSetAudioDevice(deviceId) { - if (!deviceId) { - publisher.publishAudio(false) - volumeMeterStop() - audioActive = false - } else if (deviceId == audioSource) { - publisher.publishAudio(true) - volumeMeterStart() - audioActive = true - } else { - const mediaStream = publisher.stream.mediaStream - const properties = Object.assign({}, publisherDefaults, { - publishAudio: true, - publishVideo: videoActive, - audioSource: deviceId, - videoSource: videoSource - }) - - volumeMeterStop() - - // Stop and remove the old track, otherwise you get "Concurrent mic process limit." error - mediaStream.getAudioTracks().forEach(track => { - track.stop() - mediaStream.removeTrack(track) - }) - - // TODO: Handle errors - - await OV.getUserMedia(properties) - .then(async (newMediaStream) => { - await replaceTrack(newMediaStream.getAudioTracks()[0]) - volumeMeterStart() - audioActive = true - audioSource = deviceId - }) - } - - return audioActive - } - - /** - * Change the publisher video device - * - * @param deviceId Device identifier string - */ - async function setupSetVideoDevice(deviceId) { - if (!deviceId) { - publisher.publishVideo(false) - videoActive = false - } else if (deviceId == videoSource) { - publisher.publishVideo(true) - videoActive = true - } else { - const mediaStream = publisher.stream.mediaStream - const properties = Object.assign({}, publisherDefaults, { - publishAudio: audioActive, - publishVideo: true, - audioSource: audioSource, - videoSource: deviceId - }) - - volumeMeterStop() - - // Stop and remove the old track, otherwise you get "Concurrent mic process limit." error - mediaStream.getVideoTracks().forEach(track => { - track.stop() - mediaStream.removeTrack(track) - }) - - // TODO: Handle errors - - await OV.getUserMedia(properties) - .then(async (newMediaStream) => { - await replaceTrack(newMediaStream.getVideoTracks()[0]) - volumeMeterStart() - videoActive = true - videoSource = deviceId - }) - } - - return videoActive - } - - /** - * A way to switch tracks in a stream. - * Note: This is close to what publisher.replaceTrack() does but it does not - * require the session. - * Note: The old track needs to be removed before OV.getUserMedia() call, - * otherwise we get "Concurrent mic process limit" error. - */ - function replaceTrack(track) { - const stream = publisher.stream - - const replaceMediaStreamTrack = () => { - stream.mediaStream.addTrack(track); - - if (session) { - session.sendVideoData(publisher.stream.streamManager, 5, true, 5); - } - } - - // Fix a bug in Chrome where you would start hearing yourself after audio device change - // https://github.com/OpenVidu/openvidu/issues/449 - publisher.videoReference.muted = true - - return new Promise((resolve, reject) => { - if (stream.isLocalStreamPublished) { - // Only if the Publisher has been published it is necessary to call the native - // Web API RTCRtpSender.replaceTrack() - const senders = stream.getRTCPeerConnection().getSenders() - let sender - - if (track.kind === 'video') { - sender = senders.find(s => !!s.track && s.track.kind === 'video') - } else { - sender = senders.find(s => !!s.track && s.track.kind === 'audio') - } - - if (!sender) return - - sender.replaceTrack(track).then(() => { - replaceMediaStreamTrack() - resolve() - }).catch(error => { - reject(error) - }) - } else { - // Publisher not published. Simply modify local MediaStream tracks - replaceMediaStreamTrack() - resolve() - } - }) - } - - /** - * Setup the chat UI - */ - function setupChat() { - // The UI elements are created in the vue template - // Here we add a logic for how they work - - const chat = $(sessionData.chatElement).find('.chat').get(0) - const textarea = $(sessionData.chatElement).find('textarea') - const button = $(sessionData.menuElement).find('.link-chat') - - textarea.on('keydown', e => { - if (e.keyCode == 13 && !e.shiftKey) { - if (textarea.val().length) { - signalChat(textarea.val()) - textarea.val('') - } - - return false - } - }) - - // Add an element for the count of unread messages on the chat button - button.append('') - .on('click', () => { - button.find('.badge').text('') - chatCount = 0 - // When opening the chat scroll it to the bottom, or we shouldn't? - scrollStop = false - chat.scrollTop = chat.scrollHeight - }) - - $(chat).on('scroll', event => { - // Detect manual scrollbar moves, disable auto-scrolling until - // the scrollbar is positioned on the element bottom again - scrollStop = chat.scrollTop + chat.offsetHeight < chat.scrollHeight - }) - } - - /** - * Signal events handler - */ - function signalEventHandler(signal) { - let conn, data - let connId = signal.from ? signal.from.connectionId : null - - switch (signal.type) { - case 'signal:userChanged': - // TODO: Use 'signal:connectionUpdate' for nickname updates? - if (conn = connections[connId]) { - data = JSON.parse(signal.data) - - conn.nickname = data.nickname - participantUpdate(conn.element, conn) - nicknameUpdate(data.nickname, connId) - } - break - - case 'signal:chat': - data = JSON.parse(signal.data) - data.id = connId - pushChatMessage(data) - break - - case 'signal:joinRequest': - // accept requests from the server only - if (!connId) { - sessionData.onJoinRequest(JSON.parse(signal.data)) - } - break - - case 'signal:connectionUpdate': - // accept requests from the server only - if (!connId) { - data = JSON.parse(signal.data) - - connectionUpdate(data) - } - break - } - } - - /** - * Send the chat message to other participants - * - * @param message Message string - */ - function signalChat(message) { - let data = { - nickname: sessionData.params.nickname, - message - } - - session.signal({ - data: JSON.stringify(data), - type: 'chat' - }) - } - - /** - * Add a message to the chat - * - * @param data Object with a message, nickname, id (of the connection, empty for self) - */ - function pushChatMessage(data) { - let message = $('').text(data.message).text() // make the message secure - - // Format the message, convert emails and urls to links - message = anchorme({ - input: message, - options: { - attributes: { - target: "_blank" - }, - // any link above 20 characters will be truncated - // to 20 characters and ellipses at the end - truncate: 20, - // characters will be taken out of the middle - middleTruncation: true - } - // TODO: anchorme is extensible, we could support - // github/phabricator's markup e.g. backticks for code samples - }) - - message = message.replace(/\r?\n/, '
') - - // Display the message - let isSelf = data.id == session.connectionId - let chat = $(sessionData.chatElement).find('.chat') - let box = chat.find('.message').last() - - message = $('
').html(message) - - message.find('a').attr('rel', 'noreferrer') - - if (box.length && box.data('id') == data.id) { - // A message from the same user as the last message, no new box needed - message.appendTo(box) - } else { - box = $('
').data('id', data.id) - .append($('
').text(data.nickname || '')) - .append(message) - .appendTo(chat) - - if (isSelf) { - box.addClass('self') - } - } - - // Count unread messages - if (!$(sessionData.chatElement).is('.open')) { - if (!isSelf) { - chatCount++ - } - } else { - chatCount = 0 - } - - $(sessionData.menuElement).find('.link-chat .badge').text(chatCount ? chatCount : '') - - // Scroll the chat element to the end - if (!scrollStop) { - chat.get(0).scrollTop = chat.get(0).scrollHeight - } - } - - /** - * Send the user properties update signal to other participants - * - * @param connection Optional connection to which the signal will be sent - * If not specified the signal is sent to all participants - */ - function signalUserUpdate(connection) { - let data = { - nickname: sessionData.params.nickname - } - - session.signal({ - data: JSON.stringify(data), - type: 'userChanged', - to: connection ? [connection] : undefined - }) - - // The same nickname for screen sharing session - if (screenSession) { - screenSession.signal({ - data: JSON.stringify(data), - type: 'userChanged', - to: connection ? [connection] : undefined - }) - } - } - - /** - * Switch interpreted language channel - * - * @param channel Two-letter language code - */ - function switchChannel(channel) { - sessionData.channel = channel - - // Mute/unmute all connections depending on the selected channel - participantUpdateAll() - } - - /** - * Mute/Unmute audio for current session publisher - */ - function switchAudio() { - // TODO: If user has no devices or denied access to them in the setup, - // the button will just not work. Find a way to make it working - // after user unlocks his devices. For now he has to refresh - // the page and join the room again. - if (microphones.length) { - try { - publisher.publishAudio(!audioActive) - audioActive = !audioActive - } catch (e) { - console.error(e) - } - } - - return audioActive - } - - /** - * Mute/Unmute video for current session publisher - */ - function switchVideo() { - // TODO: If user has no devices or denied access to them in the setup, - // the button will just not work. Find a way to make it working - // after user unlocks his devices. For now he has to refresh - // the page and join the room again. - if (cameras.length) { - try { - publisher.publishVideo(!videoActive) - videoActive = !videoActive - } catch (e) { - console.error(e) - } - } - - return videoActive - } - - /** - * Switch on/off screen sharing - */ - function switchScreen(callback) { - if (screenPublisher) { - // Note: This is what the original openvidu-call app does. - // It is probably better for performance reasons to close the connection, - // than to use unpublish() and keep the connection open. - screenSession.disconnect() - screenSession = null - screenPublisher = null - - if (callback) { - // Note: Disconnecting invalidates the token, we have to inform the vue component - // to update UI state (and be prepared to request a new token). - callback(false) - } - - return - } - - screenConnect(callback) - } - - /** - * Detect if screen sharing is supported by the browser - */ - function isScreenSharingSupported() { - return !!OV.checkScreenSharingCapabilities(); - } - - /** - * Update participant connection state - */ - function connectionUpdate(data) { - let conn = connections[data.connectionId] - let refresh = false - let handUpdate = conn => { - if ('hand' in data && data.hand != conn.hand) { - if (data.hand) { - connectionHandUp(conn) - } else { - connectionHandDown(data.connectionId) - } - } - } - - // It's me - if (session.connection.connectionId == data.connectionId) { - const rolePublisher = data.role && data.role & Roles.PUBLISHER - const roleModerator = data.role && data.role & Roles.MODERATOR - const isPublisher = sessionData.role & Roles.PUBLISHER - const isModerator = sessionData.role & Roles.MODERATOR - - // demoted to a subscriber - if ('role' in data && isPublisher && !rolePublisher) { - session.unpublish(publisher) - // FIXME: There's a reference in OpenVidu to a video element that should not - // exist anymore. It causes issues when we try to do publish/unpublish - // sequence multiple times in a row. So, we're clearing the reference here. - let videos = publisher.stream.streamManager.videos - publisher.stream.streamManager.videos = videos.filter(video => video.video.parentNode != null) - } - - handUpdate(sessionData) - - // merge the changed data into internal session metadata object - sessionData = Object.assign({}, sessionData, data, { audioActive, videoActive }) - - // update the participant element - sessionData.element = participantUpdate(sessionData.element, sessionData) - - // promoted/demoted to/from a moderator - if ('role' in data) { - // Update all participants, to enable/disable the popup menu - refresh = (!isModerator && roleModerator) || (isModerator && !roleModerator) - } - - // promoted to a publisher - if ('role' in data && !isPublisher && rolePublisher) { - publisher.createVideoElement(sessionData.element, 'PREPEND') - session.publish(publisher).then(() => { - sessionData.audioActive = publisher.stream.audioActive - sessionData.videoActive = publisher.stream.videoActive - - sessionData.onSessionDataUpdate(sessionData) - }) - - // Open the media setup dialog - // Note: If user didn't give permission to media before joining the room - // he will not be able to use them now. Changing permissions requires - // a page refresh. - // Note: In Firefox I'm always being asked again for media permissions. - // It does not happen in Chrome. In Chrome the cam/mic will be just re-used. - // I.e. streaming starts automatically. - // It might make sense to not start streaming automatically in any cirmustances, - // display the dialog and wait until user closes it, but this would be - // a bigger refactoring. - sessionData.onMediaSetup() - } - } else if (conn) { - handUpdate(conn) - - // merge the changed data into internal session metadata object - Object.keys(data).forEach(key => { conn[key] = data[key] }) - - conn.element = participantUpdate(conn.element, conn) - } - - // Update channels list - sessionData.channels = getChannels(connections) - - // The channel user was using has been removed (or rather the participant stopped being an interpreter) - if (sessionData.channel && !sessionData.channels.includes(sessionData.channel)) { - sessionData.channel = null - refresh = true - } - - if (refresh) { - participantUpdateAll() - } - - // Inform the vue component, so it can update some UI controls - sessionData.onSessionDataUpdate(sessionData) - } - - /** - * Handler for Hand-Up "signal" - */ - function connectionHandUp(connection) { - connection.isSelf = session.connection.connectionId == connection.connectionId - - let element = $(nicknameWidget(connection)) - - participantUpdate(element, connection) - - element.attr('id', 'qa' + connection.connectionId) - .appendTo($(sessionData.queueElement).show()) - - setTimeout(() => element.addClass('widdle'), 50) - } - - /** - * Handler for Hand-Down "signal" - */ - function connectionHandDown(connectionId) { - let list = $(sessionData.queueElement) - - list.find('#qa' + connectionId).remove(); - - if (!list.find('.meet-nickname').length) { - list.hide(); - } - } - - /** - * Update participant nickname in the UI - * - * @param nickname Nickname - * @param connectionId Connection identifier of the user - */ - function nicknameUpdate(nickname, connectionId) { - if (connectionId) { - $(sessionData.chatElement).find('.chat').find('.message').each(function() { - let elem = $(this) - if (elem.data('id') == connectionId) { - elem.find('.nickname').text(nickname || '') - } - }) - - $(sessionData.queueElement).find('#qa' + connectionId + ' .content').text(nickname || '') - } - } - - /** - * Create a participant element in the matrix. Depending on the connection role - * parameter it will be a video element wrapper inside the matrix or a simple - * tag-like element on the subscribers list. - * - * @param params Connection metadata/params - * @param content Optional content to prepend to the element - * - * @return The element - */ - function participantCreate(params, content) { - let element - - params.isSelf = params.isSelf || session.connection.connectionId == params.connectionId - - if ((!params.language && params.role & Roles.PUBLISHER) || params.role & Roles.SCREEN) { - // publishers and shared screens - element = publisherCreate(params, content) - } else { - // subscribers and language interpreters - element = subscriberCreate(params, content) - } - - setTimeout(resize, 50); - - return element - } - - /** - * Create a