diff --git a/.dockerignore b/.dockerignore index c2658d7..c6e6657 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1,17 @@ +.dockerignore +.env +.git/ +.github/ +.gitignore +.nvmrc +*~ +*log.* +*swn +*swo +*swp +docker-compose.yaml +Dockerfile +example/ +extra/ node_modules/ +test/ diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..7c8251d --- /dev/null +++ b/.eslintignore @@ -0,0 +1,3 @@ +node_modules +*.log +*~ diff --git a/.eslintrc.yml b/.eslintrc.yml new file mode 100644 index 0000000..a0fb41c --- /dev/null +++ b/.eslintrc.yml @@ -0,0 +1,21 @@ +env: + node: true + es2022: true +extends: + - eslint:recommended + - plugin:import/recommended + - plugin:jsdoc/recommended +parserOptions: + sourceType: module + ecmaVersion: 2022 +rules: + #quotes: ["warn", "single"] + no-console: "warn" + consistent-return: "warn" + no-trailing-spaces: "warn" + no-whitespace-before-property: "warn" + no-multiple-empty-lines: ["warn", { max: 1 }] + import/no-extraneous-dependencies: "error" + jsdoc/no-undefined-types: "off" + keyword-spacing: ["warn", { before: true, after: true }] + indent: ["warn", 2, { "SwitchCase": 1 }] diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..6b402ff --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,13 @@ +Add the following secrets to the GitHub repo: +``` +REGISTRY_USERNAME +REGISTRY_TOKEN +``` +They are the credentials to be used to push the image to the docker images registry. + +Add the following variables to the GitHub repo: +``` +REGISTRY_URI +REGISTRY_ORGANIZATION +``` +Considering the image `bigbluebutton/bbb-webhooks:v3.0.0`, the value for `REGISTRY_URI` would be `docker.io` (URI for DockerHub) and `REGISTRY_ORGANIZATION` would be `bigbluebutton`. The image name `bbb-webhooks` isn't configurable, and the tag will be the GitHub tag OR `pr-`. diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000..b0480b2 --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,102 @@ +name: Build and push image to registry +on: + pull_request: + types: + - opened + - reopened + - synchronize + push: + tags: + - '*' +permissions: + contents: read +jobs: + hadolint: + uses: ./.github/workflows/docker-lint.yml + + tests: + uses: ./.github/workflows/docker-tests.yml + + build: + permissions: + contents: read # for actions/checkout to fetch code + security-events: write # for github/codeql-action/upload-sarif to upload SARIF results + actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status + pull-requests: write + name: Build and push + runs-on: ubuntu-22.04 + needs: + - hadolint + - tests + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + registry: ${{ vars.REGISTRY_URI }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - uses: rlespinasse/github-slug-action@v4.4.1 + + - name: Calculate tag + id: tag + run: | + if [ "$GITHUB_EVENT_NAME" == "pull_request" ]; then + TAG="pr-${{ github.event.number }}" + else + TAG=${{ github.ref_name }} + fi + echo "IMAGE=${{ vars.REGISTRY_URI }}/${{ vars.REGISTRY_ORGANIZATION }}/bbb-webhooks:$TAG" >> $GITHUB_OUTPUT + + - name: Docker meta + id: meta + uses: docker/metadata-action@v4 + with: + images: ${{ steps.tag.outputs.IMAGE }} + + - name: Build and push image + uses: docker/build-push-action@v5 + with: + push: true + tags: ${{ steps.tag.outputs.IMAGE }} + context: . + platforms: linux/amd64 + cache-from: type=registry,ref=${{ steps.tag.outputs.IMAGE }} + cache-to: type=registry,ref=${{ steps.tag.outputs.IMAGE }},image-manifest=true,oci-mediatypes=true,mode=max + labels: | + ${{ steps.meta.outputs.labels }} + + - name: Add comment to pr + if: ${{ github.event_name == 'pull_request' }} + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: "Updated Docker image pushed to `${{ steps.tag.outputs.IMAGE }}`" + }) + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@master + with: + image-ref: ${{ steps.tag.outputs.IMAGE }} + format: 'sarif' + output: 'trivy-results.sarif' + severity: 'CRITICAL,HIGH' + env: + TRIVY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + TRIVY_PASSWORD: ${{ secrets.REGISTRY_TOKEN }} + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v2 + with: + sarif_file: 'trivy-results.sarif' diff --git a/.github/workflows/docker-lint.yml b/.github/workflows/docker-lint.yml new file mode 100644 index 0000000..eb58884 --- /dev/null +++ b/.github/workflows/docker-lint.yml @@ -0,0 +1,19 @@ +name: Run hadolint +on: + workflow_dispatch: + workflow_call: +permissions: + contents: read +jobs: + hadolint: + name: Run hadolint check + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v3 + + # TODO add hadolint output as comment on PR + # https://github.com/hadolint/hadolint-action#output + - uses: hadolint/hadolint-action@v3.1.0 + with: + dockerfile: Dockerfile diff --git a/.github/workflows/docker-scan.yml b/.github/workflows/docker-scan.yml new file mode 100644 index 0000000..e06e4b1 --- /dev/null +++ b/.github/workflows/docker-scan.yml @@ -0,0 +1,30 @@ +name: Run trivy on filesystem +on: + workflow_dispatch: +permissions: + contents: read +jobs: + trivy: + permissions: + contents: read # for actions/checkout to fetch code + security-events: write # for github/codeql-action/upload-sarif to upload SARIF results + actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status + name: Run trivy check + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v3 + + - name: Run Trivy vulnerability scanner in repo mode + uses: aquasecurity/trivy-action@master + with: + scan-type: 'fs' + ignore-unfixed: true + format: 'sarif' + output: 'trivy-results.sarif' + severity: 'CRITICAL,HIGH' + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v2 + with: + sarif_file: 'trivy-results.sarif' diff --git a/.github/workflows/docker-tests.yml b/.github/workflows/docker-tests.yml new file mode 100644 index 0000000..b9ccf10 --- /dev/null +++ b/.github/workflows/docker-tests.yml @@ -0,0 +1,52 @@ +name: Run tests +on: + workflow_dispatch: + workflow_call: +permissions: + contents: read +jobs: + tests: + name: Run tests + # https://docs.github.com/en/actions/using-containerized-services/creating-redis-service-containers#running-jobs-in-containers + # Containers must run in Linux based operating systems + runs-on: ubuntu-22.04 + # Docker Hub image that `container-job` executes in + container: node:20-alpine + + # Service containers to run with `container-job` + services: + # Label used to access the service container + redis: + # Docker Hub image + image: redis + # Set health checks to wait until redis has started + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + # Downloads a copy of the code in your repository before running CI tests + - name: Check out repository code + uses: actions/checkout@v4 + + # Performs a clean installation of all dependencies in the `package.json` file + # For more information, see https://docs.npmjs.com/cli/ci.html + - name: Install dependencies + run: npm ci + + - name: Copy config + run: cp config/default.example.yml config/default.yml + + - name: Run tests + # Runs a script that creates a Redis client, populates + # the client with data, and retrieves data + run: npm run test + # Environment variable used by the `client.js` script to create a new Redis client. + env: + # The hostname used to communicate with the Redis service container + REDIS_HOST: redis + # The default Redis port + REDIS_PORT: 6379 + XAPI_ENABLED: true diff --git a/.gitignore b/.gitignore index 814f225..cc8e9bb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,9 @@ node_modules/ log/* config/default.yml +*swn +*swo +*swp +*log.* +.env +*.orig diff --git a/.nvmrc b/.nvmrc index a2f28f4..9de2256 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -8.4.0 +lts/iron diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..845bc36 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,112 @@ +# CHANGELOG + +All notable changes to this project will be documented in this file. + +### v3.0.0 + +#### Changelog since v3.0.0-beta.5 + +* chore: update github-slug-actions to v4.4.1 +* fix: adjust actions triggers for pr (+ opened, reopened) + +#### Changelog since v2.6.1 + +* feat: new xAPI output module with support for multitenancy + - Implements https://github.com/gaia-x-dases/xapi-virtual-classroom + - For more information: (README.md)[src/out/xapi/README.md] +* feat(xapi): add suport for meta_xapi-create-end-actor-name +* feat(webhooks): implement includeEvents/excludeEvents +* feat(events): add support for poll events +* feat(events): add support for raise-hand events +* feat(events): add support for emoji events +* feat(events): add user info to screenshare events +* feat(events): add support for audio muted/unmuted events +* feat: support internal_meeting_id != record_id on rap events +* feat: add Prometheus instrumentation +* feat: add JSDoc annotations to most of the codebase +* feat: log to file +* feat: add support for multiple checksum algorithms (SHA1,...,SHA512) +* feat(test): add support for modular test suites +* feat(test): add xAPI test suite +* feat: pipelines with GitHub Actions +* !refactor: application rewritten to use a modular input/processing/ouput system +* !refactor: modernize codebase (ES6 imports, Node.js >= 18 etc.) +* !refactor(webhooks): the webhooks functionality was rewritten into an output module +* !refactor(webhooks): hook IDs are now UUIDs instead of integers +* !refactor: new logging system (using Pino) +* !refactor: migrate node-redis from v3 to v4 +* !refactor: new queue system (using Bullmq) +* refactor(test): remove nock as a dependency +* refactor(webhooks): replace request with node-fetch +* refactor: replace sha1 dependency with native code +* refactor: remove unused events + * `rap-published`, `rap-unpublished`, `rap-deleted` +* !fix(webhooks): remove general getRaw configuration +* fix(events): user-left events are now emitted for trailing users on meeting-ended events +* fix(test): restore remaining out/webhooks tests +* fix: add Redis disconnection handling +* build: add docker-compose and updated Dockerfile examples +* build: set .nvmrc to lts/iron (Node.js 20) + +### v3.0.0-beta.5 + +* feat: pipelines with GitHub Actions + +### v3.0.0-beta.4 + +* fix: use ISO timestamps in production logs +* refactor: remove unused events + * `rap-published`, `rap-unpublished`, `rap-deleted` +* feat: support internal_meeting_id != record_id on rap events +* !fix(webhooks): remove general getRaw configuration +* fix(test): use redisUrl for node-redis client configuration +* fix(test): pick up mocha configs via new .mocharc.yml file +* build: set .nvmrc to lts/iron (Node.js 20) + +### v3.0.0-beta.3 + +* build: bullmq@4.17.0, bump transitive deps + +### v3.0.0-beta.2 + +* fix(webhooks): re-implement includeEvents/excludeEvents + +### v3.0.0-beta.1 + +* fix(xapi): ensure the correct lrs_endpoint is used +* feat(xapi): add suport for meta_xapi-create-end-actor-name + +### v3.0.0-beta.0 + +* feat(test): add support for modular test suites +* feat(test): add xAPI test suite +* refactor(test): remove nock as a dependency +* fix(test): restore remaining out/webhooks tests +* fix(xapi): set chat message statements timestamp to ISO format +* fix: add Redis disconnection handling + +### v3.0.0-alpha.1 + +* !refactor: application rewritten to use a modular input/processing/ouput system +* !refactor: modernize codebase (ES6 imports, Node.js >= 18 etc.) +* !refactor(webhooks): the webhooks functionality was rewritten into an output module +* !refactor(webhooks): hook IDs are now UUIDs instead of integers +* !refactor: new logging system (using Pino) +* !refactor: migrate node-redis from v3 to v4 +* !refactor: new queue system (using Bullmq) +* refactor(webhooks): replace request with node-fetch +* refactor: replace sha1 dependency with native code +* feat: new xAPI output module with support for multitenancy + - Implements https://github.com/gaia-x-dases/xapi-virtual-classroom + - For more information: (README.md)[src/out/xapi/README.md] +* feat(events): add support for poll events +* feat(events): add support for raise-hand events +* feat(events): add support for emoji events +* feat(events): add user info to screenshare events +* feat(events): add support for audio muted/unmuted events +* feat: add Prometheus instrumentation +* feat: add JSDoc annotations to most of the codebase +* feat: log to file +* feat: add support for multiple checksum algorithms (SHA1,...,SHA512) +* fix(events): user-left events are now emitted for trailing users on meeting-ended events +* build: add docker-compose and updated Dockerfile examples diff --git a/Dockerfile b/Dockerfile index 13b93b1..c9d88b0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,19 @@ -FROM node:18-slim - -ENV NODE_ENV production +FROM node:20-alpine WORKDIR /app -ADD package.json package-lock.json /app/ +COPY package.json package-lock.json ./ -RUN npm install \ - && npm cache clear --force +ENV NODE_ENV production -ADD . /app +RUN npm ci --omit=dev + +COPY . . RUN cp config/default.example.yml config/default.yml EXPOSE 3005 -CMD ["node", "app.js"] +USER node + +CMD [ "npm", "start" ] diff --git a/README.md b/README.md index 91c68dd..2e2602c 100644 --- a/README.md +++ b/README.md @@ -2,42 +2,55 @@ This is a node.js application that listens for all events on BigBlueButton and sends POST requests with details about these events to hooks registered via an API. A hook is any external URL that can receive HTTP POST requests. -You can read the full documentation at: https://docs.bigbluebutton.org/dev/webhooks.html +You can read the full documentation at: https://docs.bigbluebutton.org/development/webhooks +## Dependencies and pre-requisites + +There are a few dependencies that need to be installed before you can run this application. +Their minimum versions are listed below. + +| Dependency | Minimum version | +|-----------------------|:---------------------------:| +| Node.js | >= v18.x | +| npm | >= v7.x | +| Redis | >= v5.0 | ## Development -With a [working development](https://docs.bigbluebutton.org/2.2/dev.html#setup-a-development-environment) environment, follow the commands below from within the `bigbluebutton/bbb-webhooks` directory. +With a [working development environment](https://docs.bigbluebutton.org/development/guide), follow the commands below from within the `bigbluebutton/bbb-webhooks` directory. 1. Install the node dependencies: - `npm install` + * See the recommended node version in the `.nvmrc` file or `package.json`'s `engines.node` property. -2. Copy the configuration file: +2. Configure the application: - `cp config/default.example.yml config/default.yml` - - Update the `serverDomain` and `sharedSecret` values to match your BBB server configuration in the newly copied `config/default.yml`. + * This sets up the default configuration values for the application. + - `touch config/development.yml` + * Create a new configuration file for your development environment where you will be able to override specific values from the default configuration file: + - Add the `bbb.serverDomain` and `bbb.sharedSecret` values to match your BBB server configuration in the newly created `config/development.yml`. 3. Stop the bbb-webhook service: - - `sudo service bbb-webhooks stop` + - `sudo systemctl stop bbb-webhooks` 4. Run the application: - - `node app.js` - - **Note:** If the `node app.js` script stops, it's likely an issue with the configuration, or the `bbb-webhooks` service may have not been terminated. - + - `npm start` ### Persistent Hooks If you want all webhooks from a BBB server to post to your 3rd party application/s, you can modify the configuration file to include `permanentURLs` and define one or more server urls which you would like the webhooks to post back to. -To add these permanent urls, do the follow: - - `sudo nano config/default.yml` - - Locate `hooks.permanentURLs` in your config/default.yml and modify it as follows: +To add these permanent urls, do the following: + - `sudo nano config/development.yml` + - Add the `modules."../out/webhhooks/index.js".config.permanentURLs` property to the configuration file, and add one or more urls to the `url` property. You can also add a `getRaw` property to the object to specify whether or not you want the raw data to be sent to the url. If you do not specify this property, it will default to `false`. - ``` - hooks: - permanentURLs: - - url: 'https://staging.example.com/webhook-post-route', - getRaw: false - - url: 'https://app.example.com/webhook-post-route', + ../out/webhooks/index.js: + config: + permanentURLs: + - url: 'https://staging.example.com/webhook-post-route' getRaw: false + - url: 'https://app.example.com/webhook-post-route' + getRaw: true ``` Once you have adjusted your configuration file, you will need to restart your development/app server to adapt to the new configuration. @@ -46,15 +59,14 @@ If you are editing these permanent urls after they have already been committed t - `redis-cli flushall` - **_IMPORTANT:_** Running the above command clears the redis database entirely. This will result in all meetings, processing or not, to be cleared from the database, and may result in broken meetings currently processing. - -## Production +## Manually installing the application on a BBB server Follow the commands below starting within the `bigbluebutton/bbb-webhooks` directory. -1. Copy the entire webhooks directory: +1. Copy the entire webhooks directory: - `sudo cp -r . /usr/local/bigbluebutton/bbb-webhooks` - -2. Move into the directory we just copied the files to: + +2. Move into the directory we just copied the files to: - `cd /usr/local/bigbluebutton/bbb-webhooks` 3. Install the dependencies: @@ -66,4 +78,53 @@ Follow the commands below starting within the `bigbluebutton/bbb-webhooks` direc - `sudo nano config/default.yml` 9. Start the bbb-webhooks service: - - `sudo service bbb-webhooks restart` \ No newline at end of file + - `sudo systemctl bbb-webhooks restart` + +## Running via Docker + +A sample docker-compose for convenience that should get the application image +built and running with as little effort as possible. See the [Dockerfile](Dockerfile) +and [docker-compose.yml](docker-compose.yml) for more details. + +To build and run the application image, run the following command from within +the root directory of the project: + +``` +docker-compose up -d +``` + +To stop the application, run the following command from within the root directory +of the project: + +``` +docker-compose down +``` + +The container runs with the default Node container user, `node`. To override it, +feel free to set the `user` property in the `docker-compose.yml` file (e.g.: `user: ${UID}:${GID}`). + +### Configuring the application when running via Docker + +The application configuration can be modified by creating and editing an override +file in `/etc/bigbluebutton/bbb-webhooks/production.yml`. The file will be mounted +into the container and its contents will be *merged* with the default configuration. +The only exception to this are array attributes, which are *replaced*. + +The default configuration file used by the container can be found at +[config/default.example.yml](config/default.example.yml). + +As an example, suppose you want to override the `bbb.serverDomain` and +`bbb.sharedSecret` values as well as enable the `out/xapi` module. You would +create the following override file: + +```yaml +bbb: + serverDomain: 'bbb.example.com' + sharedSecret: 'secret' +modules: + ../out/xapi/index.js: + enabled: true +``` + +The rest of the configurable attributes can be found in the default configuration +file at [config/default.example.yml](config/default.example.yml). diff --git a/app.js b/app.js old mode 100755 new mode 100644 index 0a47b27..543dcfe --- a/app.js +++ b/app.js @@ -1,5 +1,8 @@ -// This is a simple wrapper to run the app with 'node app.js' +/* eslint-disable-next-line no-unused-vars */ +import { NODE_CONFIG_DIR, SUPPRESS_NO_CONFIG_WARNING } from "./src/common/env.js"; +// eslint-disable-next-line no-unused-vars +import config from 'config'; +import Application from './application.js'; -Application = require('./application.js'); -application = new Application(); +const application = new Application(); application.start(); diff --git a/application.js b/application.js index f36223d..d8e9ba6 100644 --- a/application.js +++ b/application.js @@ -1,50 +1,50 @@ -const config = require('config'); -const Hook = require("./hook.js"); -const IDMapping = require("./id_mapping.js"); -const WebHooks = require("./web_hooks.js"); -const WebServer = require("./web_server.js"); -const redis = require("redis"); -const UserMapping = require("./userMapping.js"); -const async = require("async"); - -// Class that defines the application. Listens for events on redis and starts the -// process to perform the callback calls. -// TODO: add port (-p) and log level (-l) to the command line args -module.exports = class Application { +import config from 'config'; +import Logger from './src/common/logger.js'; +import ModuleManager from './src/modules/index.js'; +import EventProcessor from './src/process/event-processor.js'; +/** + * Application. + * @class + * @classdesc Wrapper class for the whole bbb-webhooks application. + * @property {ModuleManager} moduleManager - Module manager. + * @property {EventProcessor} eventProcessor - Event processor. + * @property {boolean} _initialized - Initialized. + */ +class Application { + /** + * constructor. + * @constructs Application + */ constructor() { - this.webHooks = new WebHooks(); - this.webServer = new WebServer(); - } + this.moduleManager = new ModuleManager(config.get("modules")); + this.eventProcessor = null; - start(callback) { - Hook.initialize(() => { - UserMapping.initialize(() => { - IDMapping.initialize(()=> { - async.parallel([ - (callback) => { this.webServer.start(config.get("server.port"), config.get("server.bind"), callback) }, - (callback) => { this.webServer.createPermanents(callback) }, - (callback) => { this.webHooks.start(callback) } - ], (err,results) => { - if(err != null) {} - typeof callback === 'function' ? callback() : undefined; - }); - }); - }); - }); + this._initialized = false; } - static redisPubSubClient() { - if (!Application._redisPubSubClient) { - Application._redisPubSubClient = redis.createClient( { host: config.get("redis.host"), port: config.get("redis.port") } ); - } - return Application._redisPubSubClient; - } + /** + * start. + * @returns {Promise} Promise. + * @async + * @public + */ + async start() { + if (this._initialized) return Promise.resolve(); - static redisClient() { - if (!Application._redisClient) { - Application._redisClient = redis.createClient( { host: config.get("redis.host"), port: config.get("redis.port") } ); - } - return Application._redisClient; + const { inputModules, outputModules } = await this.moduleManager.load(); + this.eventProcessor = new EventProcessor(inputModules, outputModules); + await this.eventProcessor.start(); + + return Promise.all([ + ]).then(() => { + Logger.info("bbb-webhooks started"); + this._initialized = true; + }).catch((error) => { + Logger.error("Error starting bbb-webhooks", error); + process.exit(1); + }); } -}; +} + +export default Application; diff --git a/callback_emitter.js b/callback_emitter.js deleted file mode 100644 index aa3a721..0000000 --- a/callback_emitter.js +++ /dev/null @@ -1,142 +0,0 @@ -const _ = require('lodash'); -const request = require("request"); -const url = require('url'); -const EventEmitter = require('events').EventEmitter; - -const config = require('config'); -const Logger = require("./logger.js"); -const Utils = require("./utils.js"); - -// Use to perform a callback. Will try several times until the callback is -// properly emitted and stop when successful (or after a given number of tries). -// Used to emit a single callback. Destroy it and create a new class for a new callback. -// Emits "success" on success, "failure" on error and "stopped" when gave up trying -// to perform the callback. -module.exports = class CallbackEmitter extends EventEmitter { - - constructor(callbackURL, message, permanent) { - super(); - this.callbackURL = callbackURL; - this.message = message; - this.nextInterval = 0; - this.timestamp = 0; - this.permanent = permanent; - } - - start() { - this.timestamp = new Date().getTime(); - this.nextInterval = 0; - this._scheduleNext(0); - } - - _scheduleNext(timeout) { - setTimeout( () => { - this._emitMessage((error, result) => { - if ((error == null) && result) { - this.emit("success"); - } else { - this.emit("failure", error); - - // get the next interval we have to wait and schedule a new try - const interval = config.get("hooks.retryIntervals")[this.nextInterval]; - if (interval != null) { - Logger.warn(`[Emitter] trying the callback again in ${interval/1000.0} secs`); - this.nextInterval++; - this._scheduleNext(interval); - - // no intervals anymore, time to give up - } else { - this.nextInterval = config.get("hooks.permanentIntervalReset"); // Reset interval to permanent hooks - if(this.permanent){ - this._scheduleNext(this.nextInterval); - } - else { - return this.emit("stopped"); - } - } - } - }); - } - , timeout); - } - - _emitMessage(callback) { - let data, requestOptions; - const serverDomain = config.get("bbb.serverDomain"); - const sharedSecret = config.get("bbb.sharedSecret"); - const bearerAuth = config.get("bbb.auth2_0"); - const timeout = config.get('hooks.requestTimeout'); - - // data to be sent - // note: keep keys in alphabetical order - data = { - event: "[" + this.message + "]", - timestamp: this.timestamp, - domain: serverDomain - }; - - if (bearerAuth) { - const callbackURL = this.callbackURL; - - requestOptions = { - followRedirect: true, - maxRedirects: 10, - uri: callbackURL, - method: "POST", - form: data, - auth: { - bearer: sharedSecret - }, - timeout - }; - } - else { - // calculate the checksum - const checksum = Utils.checksum(`${this.callbackURL}${JSON.stringify(data)}${sharedSecret}`); - - // get the final callback URL, including the checksum - const urlObj = url.parse(this.callbackURL, true); - let callbackURL = this.callbackURL; - callbackURL += _.isEmpty(urlObj.search) ? "?" : "&"; - callbackURL += `checksum=${checksum}`; - - requestOptions = { - followRedirect: true, - maxRedirects: 10, - uri: callbackURL, - method: "POST", - form: data, - timeout - }; - } - - const responseFailed = (response) => { - var statusCode = (response != null ? response.statusCode : undefined) - // consider 401 as success, because the callback worked but was denied by the recipient - return !((statusCode >= 200 && statusCode < 300) || statusCode == 401) - }; - - request(requestOptions, function(error, response, body) { - if ((error != null) || responseFailed(response)) { - Logger.warn(`[Emitter] error in the callback call to: [${requestOptions.uri}] for ${simplifiedEvent(data)} error: ${error} status: ${response != null ? response.statusCode : undefined}`); - callback(error, false); - } else { - Logger.info(`[Emitter] successful callback call to: [${requestOptions.uri}] for ${simplifiedEvent(data)}`); - callback(null, true); - } - }); - } -}; - -// A simple string that identifies the event -var simplifiedEvent = function(event) { - if (event.event != null) { - event = event.event - } - try { - const eventJs = JSON.parse(event); - return `event: { name: ${(eventJs.data != null ? eventJs.data.id : undefined)}, timestamp: ${(eventJs.data.event != null ? eventJs.data.event.ts : undefined)} }`; - } catch (e) { - return `event: ${event}`; - } -}; diff --git a/config/custom-environment-variables.yml b/config/custom-environment-variables.yml index 2468014..78b4ed7 100644 --- a/config/custom-environment-variables.yml +++ b/config/custom-environment-variables.yml @@ -2,16 +2,60 @@ bbb: serverDomain: SERVER_DOMAIN sharedSecret: SHARED_SECRET auth2_0: BEARER_AUTH -hooks: - permanentURLs: - __name: PERMANENT_HOOKS - __format: json - requestTimeout: - __name: REQUEST_TIMEOUT - __format: json + redis: host: REDIS_HOST port: REDIS_PORT -server: - bind: SERVER_BIND_IP - port: SERVER_PORT + password: REDIS_PASSWORD + +log: + level: LOG_LEVEL + stdout: LOG_TO_STDOUT + file: LOG_TO_FILE + filename: LOG_FILENAME + +prometheus: + enabled: PROM_ENABLED + host: PROM_HOST + port: PROM_PORT + path: PROM_PATH + collectDefaultMetrics: PROM_COLLECT_DEFAULT_METRICS + +mappings: + timeout: MAPPINGS_TIMEOUT + +modules: + ../db/redis/index.js: + config: + host: REDIS_HOST + port: REDIS_PORT + password: REDIS_PASSWORD + ../in/redis/index.js: + config: + redis: + host: REDIS_HOST + port: REDIS_PORT + password: REDIS_PASSWORD + ../out/webhooks/index.js: + config: + api: + bind: SERVER_BIND_IP + port: SERVER_PORT + supportedChecksumAlgorithms: SUPPORTED_CHECKSUM_ALGORITHMS + hookChecksumAlgorithm: HOOK_CHECKSUM_ALGORITHM + includeEvents: + __name: INCLUDE_EVENTS + __format: json + excludeEvents: + __name: EXCLUDE_EVENTS + __format: json + queue: + enabled: ENABLE_WH_QUEUE + permanentURLs: + __name: PERMANENT_HOOKS + __format: json + requestTimeout: + __name: REQUEST_TIMEOUT + __format: json + ../out/xapi/index.js: + enabled: XAPI_ENABLED diff --git a/config/default.example.yml b/config/default.example.yml index 30117c2..50f5b33 100644 --- a/config/default.example.yml +++ b/config/default.example.yml @@ -1,3 +1,18 @@ +log: + level: info + # Wether to log to stdout or not + stdout: true + # Wether to log to log.filename or not + file: false + filename: /var/log/bbb-webhooks.log + +prometheus: + enabled: false + host: 127.0.0.1 + port: 3004 + path: /metrics + collectDefaultMetrics: false + # Shared secret of your BigBlueButton server. bbb: serverDomain: myserver.com @@ -6,65 +21,125 @@ bbb: auth2_0: false apiPath: /bigbluebutton/api -# The port in which the API server will run. -server: - bind: 127.0.0.1 - port: 3005 - -# Web hooks configs -hooks: - channels: - - from-akka-apps-redis-channel - - from-bbb-web-redis-channel - - from-akka-apps-chat-redis-channel - - from-akka-apps-pres-redis-channel - - bigbluebutton:from-bbb-apps:meeting - - bigbluebutton:from-bbb-apps:users - - bigbluebutton:from-rap - # IP where permanent hook will post data (more than 1 URL means more than 1 permanent hook) - permanentURLs: [] - # How many messages will be enqueued to be processed at the same time - queueSize: 10000 - # Allow permanent hooks to receive raw message, which is the message straight from BBB - getRaw: true - # If set to higher than 1, will send events on the format: - # "event=[{event1},{event2}],timestamp=000" or "[{event1},{event2}]" (based on using auth2_0 or not) - # when there are more than 1 event on the queue at the moment of processing the queue. - multiEvent: 1 - # Retry intervals for failed attempts for perform callback calls. - # In ms. Totals to around 5min. - retryIntervals: - - 100 - - 500 - - 1000 - - 2000 - - 4000 - - 8000 - - 10000 - - 30000 - - 60000 - - 60000 - - 60000 - - 60000 - # Reset permanent interval when exceeding maximum attemps - permanentIntervalReset: 8 - # Hook's request module timeout for socket conn establishment and/or responses (ms) - requestTimeout: 5000 - # Mappings of internal to external meeting IDs mappings: cleanupInterval: 10000 # 10 secs, in ms - timeout: 86400000 # 24 hours, in ms + timeout: 604800000 # 1 week, in ms # Redis redis: host: 127.0.0.1 port: 6379 keys: - hookPrefix: bigbluebutton:webhooks:hook - hooks: bigbluebutton:webhooks:hooks - mappings: bigbluebutton:webhooks:mappings - mappingPrefix: bigbluebutton:webhooks:mapping - eventsPrefix: bigbluebutton:webhooks:events - userMaps: bigbluebutton:webhooks:userMaps - userMapPrefix: bigbluebutton:webhooks:userMap + hookPrefix: bigbluebutton:webhooks:out:hook + hooks: bigbluebutton:webhooks:out:hooks + mappings: bigbluebutton:webhooks:proc:mappings + mappingPrefix: bigbluebutton:webhooks:proc:mapping + userMaps: bigbluebutton:webhooks:proc:userMaps + userMapPrefix: bigbluebutton:webhooks:proc:userMap + +# Basic module config entry template: +# key: , +# enabled: true|false (optional, default: true) +# type: in|out|db, +# config: (optional) +# +modules: + ../db/redis/index.js: + enabled: true + type: db + config: + host: 127.0.0.1 + port: 6379 + ../in/redis/index.js: + enabled: true + type: in + config: + redis: + host: 127.0.0.1 + port: 6379 + #password: foobar + inboundChannels: + - from-akka-apps-redis-channel + - from-bbb-web-redis-channel + - from-akka-apps-chat-redis-channel + - from-akka-apps-pres-redis-channel + - bigbluebutton:from-bbb-apps:meeting + - bigbluebutton:from-bbb-apps:users + - bigbluebutton:from-rap + ../out/webhooks/index.js: + enabled: true + type: out + config: + api: + bind: 127.0.0.1 + port: 3005 + # Supported checksum generation algorithms for BBB API calls + # This should mirror the supportedChecksumAlgorithms configuration + # in bbb-web.properties + supportedChecksumAlgorithms: + - sha1 + - sha256 + - sha384 + - sha512 + queue: + enabled: false + maxSize: 10000 + concurrency: 1 + # Defines the algorithm to be used when generating checksums for + # callback POST requests. One of: sha1, sha256, sha384 or sha512 + # Default: sha1 + hookChecksumAlgorithm: sha1 + # Events to be included on the callback POST request. If not set, + # all events will that aren't excluded will be included. + includeEvents: [] + # Events to be excluded on the callback POST request. + excludeEvents: [] + # IP where permanent hook will post data (more than 1 URL means more than 1 permanent hook) + permanentURLs: [] + # How many messages will be enqueued to be processed at the same time + queueSize: 10000 + # If set to higher than 1, will send events on the format: + # "event=[{event1},{event2}],timestamp=000" or "[{event1},{event2}]" (based on using auth2_0 or not) + # when there are more than 1 event on the queue at the moment of processing the queue. + multiEvent: 1 + # Retry intervals for failed attempts for perform callback calls. + # In ms. Totals to around 5min. + retryIntervals: + - 100 + - 500 + - 1000 + - 2000 + - 4000 + - 8000 + - 10000 + - 30000 + - 60000 + - 60000 + - 60000 + - 60000 + # Reset permanent interval when exceeding maximum attemps (ms) + permanentIntervalReset: 60000 + # Hook's request module timeout for socket conn establishment and/or responses (ms) + requestTimeout: 5000 + retry: + attempts: 12 + initialInterval: 1 + increaseFactor: 2 + ../out/xapi/index.js: + enabled: false + type: out + config: + lrs: + lrs_endpoint: https://your_lrs.endpoint + lrs_username: user + lrs_password: pass + uuid_namespace: 01234567-89ab-cdef-0123-456789abcdef + redis: + keys: + meetings: bigbluebutton:webhooks:xapi:meetings + meetingPrefix: bigbluebutton:webhooks:xapi:meeting + users: bigbluebutton:webhooks:xapi:users + userPrefix: bigbluebutton:webhooks:xapi:user + polls: bigbluebutton:webhooks:xapi:polls + pollPrefix: bigbluebutton:webhooks:xapi:poll diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..374b22a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +version: '3' +services: + webhooks: + build: + context: ./ + dockerfile: Dockerfile + container_name: bbb-webhooks + restart: always + environment: + - NODE_ENV=production + - NODE_CONFIG_DIR=/etc/bigbluebutton/bbb-webhooks/:/app/config/ + volumes: + - /etc/bigbluebutton/bbb-webhooks/:/etc/bigbluebutton/bbb-webhooks/ + - /var/log/bbb-webhooks/:/var/log/bbb-webhooks/ + network_mode: "host" diff --git a/example/events/mapped-events.json b/example/events/mapped-events.json new file mode 100644 index 0000000..cb6e8df --- /dev/null +++ b/example/events/mapped-events.json @@ -0,0 +1,24 @@ +{"data":{"type":"event","id":"meeting-created","attributes":{"meeting":{"internal-meeting-id":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","external-meeting-id":"random-9019868","name":"random-9019868","is-breakout":false,"parent-id":"bbb-none","duration":0,"create-time":1698771157700,"create-date":"Tue Oct 31 13:52:37 BRT 2023","moderator-pass":"mp","viewer-pass":"ap","record":false,"voice-conf":"71347","dial-number":"613-555-1234","max-users":0,"metadata":{}}},"event":{"ts":1698771158138}}} +{"data":{"type":"event","id":"user-joined","attributes":{"meeting":{"internal-meeting-id":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","external-meeting-id":"random-9019868"},"user":{"internal-user-id":"w_xfsb9gxtlfom","external-user-id":"w_xfsb9gxtlfom","name":"John Doe","role":"MODERATOR","presenter":false}},"event":{"ts":1698771164651}}} +{"data":{"type":"event","id":"user-presenter-assigned","attributes":{"meeting":{"internal-meeting-id":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","external-meeting-id":"random-9019868"},"user":{"internal-user-id":"w_xfsb9gxtlfom","external-user-id":"w_xfsb9gxtlfom"}},"event":{"ts":1698771164695}}} +{"data":{"type":"event","id":"user-presenter-assigned","attributes":{"meeting":{"internal-meeting-id":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","external-meeting-id":"random-9019868"},"user":{"internal-user-id":"w_xfsb9gxtlfom","external-user-id":"w_xfsb9gxtlfom"}},"event":{"ts":1698771164762}}} +{"data":{"type":"event","id":"user-presenter-assigned","attributes":{"meeting":{"internal-meeting-id":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","external-meeting-id":"random-9019868"},"user":{"internal-user-id":"w_xfsb9gxtlfom","external-user-id":"w_xfsb9gxtlfom"}},"event":{"ts":1698771164807}}} +{"data":{"type":"event","id":"user-joined","attributes":{"meeting":{"internal-meeting-id":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","external-meeting-id":"random-9019868"},"user":{"internal-user-id":"w_uxw0dzhtvk0l","external-user-id":"w_uxw0dzhtvk0l","name":"Mary Sue","role":"VIEWER","presenter":false}},"event":{"ts":1698771173896}}} +{"data":{"type":"event","id":"user-audio-voice-enabled","attributes":{"meeting":{"internal-meeting-id":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","external-meeting-id":"random-9019868"},"user":{"internal-user-id":"w_xfsb9gxtlfom","external-user-id":"w_xfsb9gxtlfom","listening-only":false,"sharing-mic":true,"muted":false}},"event":{"ts":1698771180561}}} +{"data":{"type":"event","id":"user-audio-muted","attributes":{"meeting":{"internal-meeting-id":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","external-meeting-id":"random-9019868"},"user":{"internal-user-id":"w_xfsb9gxtlfom","external-user-id":"w_xfsb9gxtlfom","muted":true}},"event":{"ts":1698771185945}}} +{"data":{"type":"event","id":"user-audio-unmuted","attributes":{"meeting":{"internal-meeting-id":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","external-meeting-id":"random-9019868"},"user":{"internal-user-id":"w_xfsb9gxtlfom","external-user-id":"w_xfsb9gxtlfom","muted":false}},"event":{"ts":1698771197509}}} +{"data":{"type":"event","id":"user-audio-voice-disabled","attributes":{"meeting":{"internal-meeting-id":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","external-meeting-id":"random-9019868"},"user":{"internal-user-id":"w_xfsb9gxtlfom","external-user-id":"w_xfsb9gxtlfom","listening-only":false,"sharing-mic":false,"muted":true}},"event":{"ts":1698771202728}}} +{"data":{"type":"event","id":"user-cam-broadcast-start","attributes":{"meeting":{"internal-meeting-id":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","external-meeting-id":"random-9019868"},"user":{"internal-user-id":"w_xfsb9gxtlfom","external-user-id":"w_xfsb9gxtlfom","stream":"w_xfsb9gxtlfom_0e2313d7c9d39860e908e20cd196adc3a6b8bf5c16110fdadc63741f48193c19"}},"event":{"ts":1698771212439}}} +{"data":{"type":"event","id":"user-cam-broadcast-end","attributes":{"meeting":{"internal-meeting-id":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","external-meeting-id":"random-9019868"},"user":{"internal-user-id":"w_xfsb9gxtlfom","external-user-id":"w_xfsb9gxtlfom","stream":"w_xfsb9gxtlfom_0e2313d7c9d39860e908e20cd196adc3a6b8bf5c16110fdadc63741f48193c19"}},"event":{"ts":1698771219314}}} +{"data":{"type":"event","id":"meeting-screenshare-started","attributes":{"meeting":{"internal-meeting-id":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","external-meeting-id":"random-9019868"},"user":{"internal-user-id":"w_xfsb9gxtlfom","external-user-id":"w_xfsb9gxtlfom"}},"event":{"ts":1698771224459}}} +{"data":{"type":"event","id":"meeting-screenshare-stopped","attributes":{"meeting":{"internal-meeting-id":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","external-meeting-id":"random-9019868"},"user":{"internal-user-id":"w_xfsb9gxtlfom","external-user-id":"w_xfsb9gxtlfom"}},"event":{"ts":1698771228671}}} +{"data":{"type":"event","id":"chat-group-message-sent","attributes":{"meeting":{"internal-meeting-id":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","external-meeting-id":"random-9019868"},"chat-message":{"id":"1698771232706-9idm9pxf","message":"Public chat test","sender":{"internal-user-id":"w_xfsb9gxtlfom","name":"John Doe","time":1698771232706}},"chat-id":"MAIN-PUBLIC-GROUP-CHAT"},"event":{"ts":1698771232718}}} +{"data":{"type":"event","id":"user-emoji-changed","attributes":{"meeting":{"internal-meeting-id":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","external-meeting-id":"random-9019868"},"user":{"internal-user-id":"w_xfsb9gxtlfom","external-user-id":"w_xfsb9gxtlfom","emoji":"🙁"}},"event":{"ts":1698771238519}}} +{"data":{"type":"event","id":"user-emoji-changed","attributes":{"meeting":{"internal-meeting-id":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","external-meeting-id":"random-9019868"},"user":{"internal-user-id":"w_xfsb9gxtlfom","external-user-id":"w_xfsb9gxtlfom","emoji":"none"}},"event":{"ts":1698771241768}}} +{"data":{"type":"event","id":"user-raise-hand-changed","attributes":{"meeting":{"internal-meeting-id":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","external-meeting-id":"random-9019868"},"user":{"internal-user-id":"w_xfsb9gxtlfom","external-user-id":"w_xfsb9gxtlfom","raise-hand":true}},"event":{"ts":1698771246543}}} +{"data":{"type":"event","id":"user-raise-hand-changed","attributes":{"meeting":{"internal-meeting-id":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","external-meeting-id":"random-9019868"},"user":{"internal-user-id":"w_xfsb9gxtlfom","external-user-id":"w_xfsb9gxtlfom","raise-hand":false}},"event":{"ts":1698771251456}}} +{"data":{"type":"event","id":"poll-started","attributes":{"meeting":{"internal-meeting-id":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","external-meeting-id":"random-9019868"},"user":{"internal-user-id":"w_xfsb9gxtlfom","external-user-id":"w_xfsb9gxtlfom"},"poll":{"id":"2be5820127b5f8efc050fa003e83ef53fa62c356-1698771157729/1/1698771266437","question":"ABCD Poll Test","answers":[{"id":0,"key":"A"},{"id":1,"key":"B"},{"id":2,"key":"C"},{"id":3,"key":"D"}]}},"event":{"ts":1698771266455}}} +{"data":{"type":"event","id":"poll-responded","attributes":{"meeting":{"internal-meeting-id":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","external-meeting-id":"random-9019868"},"user":{"internal-user-id":"w_xfsb9gxtlfom","external-user-id":"w_xfsb9gxtlfom"},"poll":{"id":"2be5820127b5f8efc050fa003e83ef53fa62c356-1698771157729/1/1698771266437","answerIds":[0]}},"event":{"ts":1698771271189}}} +{"data":{"type":"event","id":"user-left","attributes":{"meeting":{"internal-meeting-id":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","external-meeting-id":"random-9019868"},"user":{"internal-user-id":"w_uxw0dzhtvk0l","external-user-id":"w_uxw0dzhtvk0l"}},"event":{"ts":1698771293007}}} +{"data":{"type":"event","id":"user-left","attributes":{"meeting":{"internal-meeting-id":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","external-meeting-id":"random-9019868"},"user":{"internal-user-id":"w_xfsb9gxtlfom","external-user-id":"w_xfsb9gxtlfom"}},"event":{"ts":1698771303012}}} +{"data":{"type":"event","id":"meeting-ended","attributes":{"meeting":{"internal-meeting-id":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","external-meeting-id":"random-9019868"}},"event":{"ts":1698771310228}}} diff --git a/example/events/pretty-print.js b/example/events/pretty-print.js new file mode 100644 index 0000000..4380675 --- /dev/null +++ b/example/events/pretty-print.js @@ -0,0 +1,33 @@ +/* Read mapped-events.json and raw-events.json from the current directory and + * print them in a human-readable format (new files with a -pretty suffix). + * Overwrites existing files. + */ + +import { open, rm, writeFile } from 'node:fs/promises'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +(async () => { + await rm(join(__dirname, 'mapped-events-pretty.json'), { force: true }); + const mHandle = await open(join(__dirname, 'mapped-events.json'), 'r'); + for await (const line of mHandle.readLines()) { + // Parse JSON and write it pretty-printed (2 spaces) to the suffixed file + // Append a newline to the end of the file + await writeFile( + join(__dirname, 'mapped-events-pretty.json'), + JSON.stringify(JSON.parse(line), null, 2) + '\n', { flag: 'a' }, + ); + } + + await rm(join(__dirname, 'raw-events-pretty.json'), { force: true }); + const rHandle = await open(join(__dirname, 'raw-events.json'), 'r'); + for await (const line of rHandle.readLines()) { + await writeFile( + join(__dirname, 'raw-events-pretty.json'), + JSON.stringify(JSON.parse(line), null, 2) + '\n', { flag: 'a' }, + ); + } +})(); diff --git a/example/events/raw-events.json b/example/events/raw-events.json new file mode 100644 index 0000000..82df278 --- /dev/null +++ b/example/events/raw-events.json @@ -0,0 +1,24 @@ +{"envelope":{"name":"MeetingCreatedEvtMsg","routing":{"sender":"bbb-apps-akka"},"timestamp":1698771158093},"core":{"header":{"name":"MeetingCreatedEvtMsg"},"body":{"props":{"meetingProp":{"name":"random-9019868","extId":"random-9019868","intId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","meetingCameraCap":50,"maxPinnedCameras":3,"isBreakout":false,"disabledFeatures":[],"notifyRecordingIsOn":false,"presentationUploadExternalDescription":"","presentationUploadExternalUrl":""},"breakoutProps":{"parentId":"bbb-none","sequence":0,"freeJoin":false,"breakoutRooms":[],"record":false,"privateChatEnabled":true,"captureNotes":false,"captureSlides":false,"captureNotesFilename":"%%CONFNAME%%","captureSlidesFilename":"%%CONFNAME%%"},"durationProps":{"duration":0,"createdTime":1698771157700,"createdDate":"Tue Oct 31 13:52:37 BRT 2023","meetingExpireIfNoUserJoinedInMinutes":5,"meetingExpireWhenLastUserLeftInMinutes":1,"userInactivityInspectTimerInMinutes":0,"userInactivityThresholdInMinutes":30,"userActivitySignResponseDelayInMinutes":5,"endWhenNoModerator":false,"endWhenNoModeratorDelayInMinutes":1},"password":{"moderatorPass":"mp","viewerPass":"ap","learningDashboardAccessToken":"igucwyjkab6i"},"recordProp":{"record":false,"autoStartRecording":false,"allowStartStopRecording":true,"recordFullDurationMedia":false,"keepEvents":true},"welcomeProp":{"welcomeMsgTemplate":"
Welcome to %%CONFNAME%%!","welcomeMsg":"
Welcome to random-9019868!","modOnlyMessage":""},"voiceProp":{"telVoice":"71347","voiceConf":"71347","dialNumber":"613-555-1234","muteOnStart":false},"usersProp":{"maxUsers":0,"maxUserConcurrentAccesses":3,"webcamsOnlyForModerator":false,"userCameraCap":3,"guestPolicy":"ASK_MODERATOR","meetingLayout":"CUSTOM_LAYOUT","allowModsToUnmuteUsers":true,"allowModsToEjectCameras":true,"authenticatedGuest":false},"metadataProp":{"metadata":{}},"lockSettingsProps":{"disableCam":false,"disableMic":false,"disablePrivateChat":false,"disablePublicChat":false,"disableNotes":false,"hideUserList":false,"lockOnJoin":true,"lockOnJoinConfigurable":false,"hideViewersCursor":false,"hideViewersAnnotation":false},"systemProps":{"html5InstanceId":1},"groups":[]}}}} +{"envelope":{"name":"UserJoinedMeetingEvtMsg","routing":{"msgType":"BROADCAST_TO_MEETING","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"timestamp":1698771164638},"core":{"header":{"name":"UserJoinedMeetingEvtMsg","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"body":{"intId":"w_xfsb9gxtlfom","extId":"w_xfsb9gxtlfom","name":"John Doe","role":"MODERATOR","guest":false,"authed":true,"guestStatus":"ALLOW","emoji":"none","reactionEmoji":"none","raiseHand":false,"away":false,"pin":false,"presenter":false,"locked":true,"avatar":"","color":"#7b1fa2","clientType":"HTML5"}}} +{"envelope":{"name":"PresenterAssignedEvtMsg","routing":{"msgType":"BROADCAST_TO_MEETING","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"timestamp":1698771164647},"core":{"header":{"name":"PresenterAssignedEvtMsg","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"body":{"presenterId":"w_xfsb9gxtlfom","presenterName":"John Doe","assignedBy":"w_xfsb9gxtlfom"}}} +{"envelope":{"name":"PresenterAssignedEvtMsg","routing":{"msgType":"BROADCAST_TO_MEETING","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"timestamp":1698771164757},"core":{"header":{"name":"PresenterAssignedEvtMsg","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"body":{"presenterId":"w_xfsb9gxtlfom","presenterName":"John Doe","assignedBy":"w_xfsb9gxtlfom"}}} +{"envelope":{"name":"PresenterAssignedEvtMsg","routing":{"msgType":"BROADCAST_TO_MEETING","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"timestamp":1698771164805},"core":{"header":{"name":"PresenterAssignedEvtMsg","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"body":{"presenterId":"w_xfsb9gxtlfom","presenterName":"John Doe","assignedBy":"w_xfsb9gxtlfom"}}} +{"envelope":{"name":"UserJoinedMeetingEvtMsg","routing":{"msgType":"BROADCAST_TO_MEETING","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_uxw0dzhtvk0l"},"timestamp":1698771173894},"core":{"header":{"name":"UserJoinedMeetingEvtMsg","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_uxw0dzhtvk0l"},"body":{"intId":"w_uxw0dzhtvk0l","extId":"w_uxw0dzhtvk0l","name":"Mary Sue","role":"VIEWER","guest":false,"authed":true,"guestStatus":"ALLOW","emoji":"none","reactionEmoji":"none","raiseHand":false,"away":false,"pin":false,"presenter":false,"locked":true,"avatar":"","color":"#6a1b9a","clientType":"HTML5"}}} +{"envelope":{"name":"UserJoinedVoiceConfToClientEvtMsg","routing":{"msgType":"BROADCAST_TO_MEETING","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"timestamp":1698771180548},"core":{"header":{"name":"UserJoinedVoiceConfToClientEvtMsg","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"body":{"voiceConf":"71347","intId":"w_xfsb9gxtlfom","voiceUserId":"1","callerName":"John+Doe","callerNum":"w_xfsb9gxtlfom_1-bbbID-John+Doe","color":"#4a148c","muted":false,"talking":false,"callingWith":"none","listenOnly":false}}} +{"envelope":{"name":"UserMutedVoiceEvtMsg","routing":{"msgType":"BROADCAST_TO_MEETING","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"timestamp":1698771185939},"core":{"header":{"name":"UserMutedVoiceEvtMsg","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"body":{"voiceConf":"71347","intId":"w_xfsb9gxtlfom","voiceUserId":"w_xfsb9gxtlfom","muted":true}}} +{"envelope":{"name":"UserMutedVoiceEvtMsg","routing":{"msgType":"BROADCAST_TO_MEETING","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"timestamp":1698771197507},"core":{"header":{"name":"UserMutedVoiceEvtMsg","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"body":{"voiceConf":"71347","intId":"w_xfsb9gxtlfom","voiceUserId":"w_xfsb9gxtlfom","muted":false}}} +{"envelope":{"name":"UserLeftVoiceConfToClientEvtMsg","routing":{"msgType":"BROADCAST_TO_MEETING","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"timestamp":1698771202720},"core":{"header":{"name":"UserLeftVoiceConfToClientEvtMsg","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"body":{"voiceConf":"71347","intId":"w_xfsb9gxtlfom","voiceUserId":"w_xfsb9gxtlfom"}}} +{"envelope":{"name":"UserBroadcastCamStartedEvtMsg","routing":{"msgType":"BROADCAST_TO_MEETING","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"timestamp":1698771212433},"core":{"header":{"name":"UserBroadcastCamStartedEvtMsg","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"body":{"userId":"w_xfsb9gxtlfom","stream":"w_xfsb9gxtlfom_0e2313d7c9d39860e908e20cd196adc3a6b8bf5c16110fdadc63741f48193c19"}}} +{"envelope":{"name":"UserBroadcastCamStoppedEvtMsg","routing":{"msgType":"BROADCAST_TO_MEETING","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"timestamp":1698771219309},"core":{"header":{"name":"UserBroadcastCamStoppedEvtMsg","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"body":{"userId":"w_xfsb9gxtlfom","stream":"w_xfsb9gxtlfom_0e2313d7c9d39860e908e20cd196adc3a6b8bf5c16110fdadc63741f48193c19"}}} +{"envelope":{"name":"ScreenshareRtmpBroadcastStartedEvtMsg","routing":{"msgType":"BROADCAST_TO_MEETING","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"not-used"},"timestamp":1698771224452},"core":{"header":{"name":"ScreenshareRtmpBroadcastStartedEvtMsg","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"not-used"},"body":{"voiceConf":"71347","screenshareConf":"71347","stream":"182c133e-5512-4695-be20-8cc760dc1595","vidWidth":0,"vidHeight":0,"timestamp":"1698771224444","hasAudio":false,"contentType":"screenshare"}}} +{"envelope":{"name":"ScreenshareRtmpBroadcastStoppedEvtMsg","routing":{"msgType":"BROADCAST_TO_MEETING","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"not-used"},"timestamp":1698771228664},"core":{"header":{"name":"ScreenshareRtmpBroadcastStoppedEvtMsg","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"not-used"},"body":{"voiceConf":"","screenshareConf":"","stream":"","vidWidth":0,"vidHeight":0,"timestamp":""}}} +{"envelope":{"name":"GroupChatMessageBroadcastEvtMsg","routing":{"msgType":"BROADCAST_TO_MEETING","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"timestamp":1698771232712},"core":{"header":{"name":"GroupChatMessageBroadcastEvtMsg","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"body":{"chatId":"MAIN-PUBLIC-GROUP-CHAT","msg":{"id":"1698771232706-9idm9pxf","timestamp":1698771232706,"correlationId":"w_xfsb9gxtlfom-1698771232678","sender":{"id":"w_xfsb9gxtlfom","name":"John Doe","role":"MODERATOR"},"chatEmphasizedText":true,"message":"Public chat test"}}}} +{"envelope":{"name":"UserReactionEmojiChangedEvtMsg","routing":{"msgType":"BROADCAST_TO_MEETING","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"timestamp":1698771238514},"core":{"header":{"name":"UserReactionEmojiChangedEvtMsg","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"body":{"userId":"w_xfsb9gxtlfom","reactionEmoji":"🙁"}}} +{"envelope":{"name":"UserReactionEmojiChangedEvtMsg","routing":{"msgType":"BROADCAST_TO_MEETING","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"timestamp":1698771241767},"core":{"header":{"name":"UserReactionEmojiChangedEvtMsg","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"body":{"userId":"w_xfsb9gxtlfom","reactionEmoji":"none"}}} +{"envelope":{"name":"UserRaiseHandChangedEvtMsg","routing":{"msgType":"BROADCAST_TO_MEETING","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"timestamp":1698771246539},"core":{"header":{"name":"UserRaiseHandChangedEvtMsg","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"body":{"userId":"w_xfsb9gxtlfom","raiseHand":true}}} +{"envelope":{"name":"UserRaiseHandChangedEvtMsg","routing":{"msgType":"BROADCAST_TO_MEETING","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"timestamp":1698771251455},"core":{"header":{"name":"UserRaiseHandChangedEvtMsg","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"body":{"userId":"w_xfsb9gxtlfom","raiseHand":false}}} +{"envelope":{"name":"PollStartedEvtMsg","routing":{"msgType":"BROADCAST_TO_MEETING","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"timestamp":1698771266447},"core":{"header":{"name":"PollStartedEvtMsg","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"body":{"userId":"w_xfsb9gxtlfom","pollId":"2be5820127b5f8efc050fa003e83ef53fa62c356-1698771157729/1/1698771266437","pollType":"A-4","secretPoll":false,"question":"ABCD Poll Test","poll":{"id":"2be5820127b5f8efc050fa003e83ef53fa62c356-1698771157729/1/1698771266437","isMultipleResponse":false,"answers":[{"id":0,"key":"A"},{"id":1,"key":"B"},{"id":2,"key":"C"},{"id":3,"key":"D"}]}}}} +{"envelope":{"name":"UserRespondedToPollRespMsg","routing":{"msgType":"DIRECT","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"timestamp":1698771271169},"core":{"header":{"name":"UserRespondedToPollRespMsg","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"body":{"pollId":"2be5820127b5f8efc050fa003e83ef53fa62c356-1698771157729/1/1698771266437","userId":"w_uxw0dzhtvk0l","answerIds":[0]}}} +{"envelope":{"name":"UserLeftMeetingEvtMsg","routing":{"msgType":"BROADCAST_TO_MEETING","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_uxw0dzhtvk0l"},"timestamp":1698771293000},"core":{"header":{"name":"UserLeftMeetingEvtMsg","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_uxw0dzhtvk0l"},"body":{"intId":"w_uxw0dzhtvk0l","eject":false,"ejectedBy":"","reason":"","reasonCode":""}}} +{"envelope":{"name":"UserLeftMeetingEvtMsg","routing":{"msgType":"BROADCAST_TO_MEETING","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"timestamp":1698771303008},"core":{"header":{"name":"UserLeftMeetingEvtMsg","meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700","userId":"w_xfsb9gxtlfom"},"body":{"intId":"w_xfsb9gxtlfom","eject":false,"ejectedBy":"","reason":"","reasonCode":""}}} +{"envelope":{"name":"MeetingDestroyedEvtMsg","routing":{"sender":"bbb-apps-akka"},"timestamp":1698771310209},"core":{"header":{"name":"MeetingDestroyedEvtMsg"},"body":{"meetingId":"8043c8452ae9830aac14c517adff3839dbd9228f-1698771157700"}}} diff --git a/example/modules/in-file.js b/example/modules/in-file.js new file mode 100644 index 0000000..c4e924b --- /dev/null +++ b/example/modules/in-file.js @@ -0,0 +1,122 @@ +import { open } from 'node:fs/promises'; + +/* + * [MODULE_TYPES.in]: { + * load: 'function', + * unload: 'function', + * setContext: 'function', + * setCollector: 'function', + * }, + * + * This is an example input module that reads from a file (config.filename). + * The file should contain a JSON object per line, each object representing + * an input event (either BBB/raw format or webhooks format). + * + * To enable it, add the following entry to the `modules` section of your + * config.yml: + * ``` + * ../../example/modules/in-file.js: + * type: in + * config: + * fileName: + * delay: 1000 + * ``` + * + */ + +const timeout = (ms) => { + return new Promise(resolve => setTimeout(resolve, ms)); +}; + +export default class InFile { + static type = "in"; + + static _defaultCollector () { + throw new Error('Collector not set'); + } + + constructor (context, config = {}) { + this.type = InFile.type; + this.config = config; + this.setContext(context); + this.loaded = false; + + this._fileHandle = null; + } + + _validateConfig () { + if (this.config == null) { + throw new Error("config not set"); + } + + if (this.config.fileName == null) { + throw new Error("config.fileName not set"); + } + + if (this.config.delay == null) { + this.config.delay = 1000; + } + + return true; + } + + async _readFile () { + if (this._fileHandle == null) { + this._fileHandle = await open(this.config.fileName, 'r'); + } + + const lines = this._fileHandle.readLines(); + + for await (const line of lines) { + await timeout(this.config.delay); + this._dispatch(line); + } + } + + _dispatch (event) { + this.logger.debug(`read event from file: ${event}`); + + try { + const parsedEvent = JSON.parse(event); + this._collector(parsedEvent); + } catch (error) { + this.logger.error('error processing message:', error); + } + } + + async load () { + try { + this._validateConfig(); + setTimeout(() => { + this._readFile().catch((error) => { + this.logger.error('error reading file:', error); + }); + }, 0); + this.loaded = true; + } catch (error) { + this.logger.error('error loading InFile:', error); + throw error; + } + } + + async unload () { + if (this._fileHandle != null) { + await this._fileHandle.close(); + this._fileHandle = null; + } + + this.setCollector(InFile._defaultCollector); + } + + setContext (context) { + this.context = context; + this.logger = context.getLogger(); + + return context; + } + + setCollector (collector) { + this.logger.debug('InFile.setCollector:', { collector }); + this._collector = collector; + } +} diff --git a/example/modules/out-file.js b/example/modules/out-file.js new file mode 100644 index 0000000..03f6649 --- /dev/null +++ b/example/modules/out-file.js @@ -0,0 +1,125 @@ +import { open } from 'node:fs/promises'; + +/* + * [MODULE_TYPES.OUTPUT]: { + * load: 'function', + * unload: 'function', + * setContext: 'function', + * onEvent: 'function', + * }, + * + * This is an example output module that writes events to a file (config.filename). + * The file should contain one JSON object per line, each object representing + * an bbb-webhooks event. + * + * To enable it, add the following entry to the `modules` section of your + * config.yml: + * ``` + * ../../example/modules/out-file.js: + * type: out + * config: + * fileName: + * ``` + * + */ + +const timeout = (ms) => { + return new Promise(resolve => setTimeout(resolve, ms)); +}; + +export default class OutFile { + static type = "out"; + + static _defaultCollector () { + throw new Error('Collector not set'); + } + + constructor (context, config = {}) { + this.type = OutFile.type; + this.config = config; + this.setContext(context); + this.loaded = false; + + this._fileHandle = null; + } + + _validateConfig () { + if (this.config == null) { + throw new Error("config not set"); + } + + if (this.config.fileName == null) { + throw new Error("config.fileName not set"); + } + + if (this.config.delay == null) { + this.config.delay = 1000; + } + + return true; + } + + _dispatch (event) { + this.logger.debug(`read event from file: ${event}`); + + try { + const parsedEvent = JSON.parse(event); + this._collector(parsedEvent); + } catch (error) { + this.logger.error('error processing message:', error); + } + } + + async load () { + try { + this._validateConfig(); + this._fileHandle = await open(this.config.fileName, 'a'); + if (this.config.rawFileName != null) { + this._rawFileHandle = await open(this.config.rawFileName, 'a'); + } + this.loaded = true; + } catch (error) { + this.logger.error('error loading OutFile:', error); + throw error; + } + } + + async unload () { + if (this._fileHandle != null) { + await this._fileHandle.close(); + this._fileHandle = null; + } + + this.setCollector(OutFile._defaultCollector); + } + + setContext (context) { + this.context = context; + this.logger = context.getLogger(); + + return context; + } + + async onEvent (event, raw) { + if (!this.loaded || this._fileHandle == null) { + throw new Error("OutFile not loaded"); + } + + this.logger.debug('OutFile.onEvent:', event); + + // Write events as JSON objects to FILENAME, always appending each JSON + // is isolated and separated by a newline + // JSON is pretty-printed if this.config.prettyPrint is true + const writableMessage = this.config.prettyPrint + ? JSON.stringify(event, null, 2) + : JSON.stringify(event); + + await this._fileHandle.appendFile(writableMessage + "\n"); + if (this._rawFileHandle != null) { + const writableRaw = this.config.prettyPrint + ? JSON.stringify(raw, null, 2) + : JSON.stringify(raw); + await this._rawFileHandle.appendFile(writableRaw + "\n"); + } + } +} diff --git a/extra/events.js b/extra/events.js index a0997b4..06fb153 100644 --- a/extra/events.js +++ b/extra/events.js @@ -1,46 +1,84 @@ +/* eslint no-console: "off" */ + +import redis from 'redis'; +import fs from 'node:fs'; + // Lists all the events that happen in a meeting. Run with 'node events.js'. // Uses the first meeting started after the application runs and will list all // events, but only the first time they happen. +const eventsPrinted = []; +const REDIS_HOST = process.env.REDIS_HOST || '127.0.0.1'; +const REDIS_PORT = process.env.REDIS_PORT || 6379; +const REDIS_PASSWORD = process.env.REDIS_PASSWORD || ''; +const FILENAME = process.env.FILENAME; +const DEDUPE = (process.env.DEDUPE && process.env.DEDUPE === 'true') || false; +const PRETTY_PRINT = (process.env.PRETTY_PRINT && process.env.PRETTY_PRINT === 'true') || false; +const CHANNELS = process.env.CHANNELS || [ + 'from-akka-apps-redis-channel', + 'from-bbb-web-redis-channel', + 'from-akka-apps-chat-redis-channel', + 'from-akka-apps-pres-redis-channel', + 'bigbluebutton:from-rap', +]; -const redis = require("redis"); -const config = require('config'); -var target_meeting = null; -var events_printed = []; -var subscriber = redis.createClient(config.get(redis.port), config.get(redis.host)); - -subscriber.on("psubscribe", function(channel, count) { - console.log("subscribed to " + channel); -}); +const containsOrAdd = (list, value) => { + for (let i = 0; i <= list.length-1; i++) { + if (list[i] === value) { + return true; + } + } + list.push(value); + return false; +} -subscriber.on("pmessage", function(pattern, channel, message) { +const onMessage = (_message) => { try { - message = JSON.parse(message); - if (message.hasOwnProperty('envelope')) { - - var message_name = message.envelope.name; + const message = JSON.parse(_message); + if (Object.prototype.hasOwnProperty.call(message, 'envelope')) { + const messageName = message.envelope.name; - if (!containsOrAdd(events_printed, message_name)) { - console.log("\n###", message_name, "\n"); + if (!DEDUPE || !containsOrAdd(eventsPrinted, messageName)) { + console.log("\n###", messageName, "\n"); console.log(message); console.log("\n"); + + // Write events as a pretty-printed JSON object to FILENAME, always appending + // Each JSON is isolated and separated by a newline + if (FILENAME) { + const writableMessage = PRETTY_PRINT + ? JSON.stringify(message, null, 2) + : JSON.stringify(message); + fs.appendFile(FILENAME, writableMessage + "\n", (err) => { + if (err) console.error(err); + }); + } } } - } catch(e) { - console.log("error processing the message", message, ":", e); + } catch(error) { + console.error(`error processing ${_message}`, error); } -}); - -for (i = 0; i < config.get(hooks.channels); ++i) { - const channel = config.get(hooks.channels)[i]; - subscriber.psubscribe(channel); } -var containsOrAdd = function(list, value) { - for (i = 0; i <= list.length-1; i++) { - if (list[i] === value) { - return true; - } +const subscribe = (client, channels, messageHandler) => { + if (client == null) { + throw new Error("client not initialized"); } - list.push(value); - return false; -} + + return Promise.all( + channels.map((channel) => { + return client.subscribe(channel, messageHandler) + .then(() => console.info(`subscribed to: ${channel}`)) + .catch((error) => console.error(`error subscribing to: ${channel}: ${error}`)); + }) + ); +}; + +const client = redis.createClient({ + host: REDIS_HOST, + port: REDIS_PORT, + password: REDIS_PASSWORD, +}); + +client.connect() + .then(() => subscribe(client, CHANNELS, onMessage)) + .catch((error) => console.error("error connecting to redis:", error)); diff --git a/extra/interceptor.js b/extra/interceptor.js new file mode 100644 index 0000000..8575f3b --- /dev/null +++ b/extra/interceptor.js @@ -0,0 +1,110 @@ +/* eslint no-console: "off" */ + +// Lists all the events that happen in a meeting. Run with 'node events.js'. +// Uses the first meeting started after the application runs and will list all +// events, but only the first time they happen. +import express from "express"; +import fetch from "node-fetch"; +import crypto from "crypto"; +import bodyParser from 'body-parser'; + +// server configs +const eject = (reason) => { throw new Error(reason); } +const port = process.env.PORT || 3006; +const sharedSecret = process.env.SHARED_SECRET || eject("SHARED_SECRET not set"); +const catcherDomain = process.env.CATCHER_DOMAIN || eject("CATCHER_DOMAIN not set"); +const bbbDomain = process.env.BBB_DOMAIN || eject("BBB_DOMAIN not set"); +const FOREVER = (process.env.FOREVER && process.env.FOREVER === 'true') || false; +const GET_RAW = (process.env.GET_RAW && process.env.GET_RAW === 'true') || false; +const EVENT_ID = process.env.EVENT_ID || ''; +const MEETING_ID = process.env.MEETING_ID || ''; + +let server = null; + +const encodeForUrl = (value) => { + return encodeURIComponent(value) + .replace(/%20/g, '+') + .replace(/[!'()]/g, escape) + .replace(/\*/g, "%2A") +}; + +const shutdown = (code) => { + console.log(`Shutting down server, code ${code}`); + if (server) server.close(); + process.exit(code); +} + +// create a server to listen for callbacks +const app = express(); +app.use(bodyParser.json()); +app.use(bodyParser.urlencoded({ + extended: true +})); +server = app.listen(port); +app.post("/callback", (req, res) => { + try { + console.log("-------------------------------------"); + console.log("* Received:", req.url); + console.log("* Body:", req.body); + console.log("-------------------------------------\n"); + res.statusCode = 200; + res.send(); + } catch (error) { + console.error("Error processing callback:", error); + res.statusCode = 500; + res.send(); + } +}); +console.log("Server listening on port", port); + +// registers a hook on the webhooks app +const myUrl = "http://" + catcherDomain + ":" + port + "/callback"; +let params = "callbackURL=" + encodeForUrl(myUrl) + "&getRaw=" + GET_RAW; +if (EVENT_ID) params += "&eventID=" + EVENT_ID; +if (MEETING_ID) params += "&meetingID=" + MEETING_ID; +const checksum = crypto.createHash('sha1').update("hooks/create" + params + sharedSecret).digest('hex'); +const fullUrl = "http://" + bbbDomain + "/bigbluebutton/api/hooks/create?" + + params + "&checksum=" + checksum +console.log("Registering a hook with", fullUrl); + +const registerHook = async () => { + const controller = new AbortController(); + const abortTimeout = setTimeout(() => { + controller.abort(); + }, 2500); + + try { + const response = await fetch(fullUrl, { signal: controller.signal }); + const text = await response.text(); + if (response.ok) { + console.debug("Hook registered - response from hook/create:", text); + } else { + throw new Error(text); + } + } catch (error) { + console.error("Hook registration failed - response from hook/create:", error); + // if FOREVER, then keep trying to register the hook + // every 3s until it works - else exit with code 1 + if (FOREVER) { + console.log("Trying again in 3s..."); + setTimeout(registerHook, 3000); + } else { + shutdown(1); + } + } finally { + clearTimeout(abortTimeout); + } +}; + +process.on('SIGINT', () => shutdown(0)); +process.on('SIGTERM', () => shutdown(0)); +process.on('uncaughtException', (error) => { + console.error('uncaughtException:', error); + shutdown(1); +}); +process.on('unhandledRejection', (reason, promise) => { + console.error('unhandledRejection:', reason, promise); + shutdown(1); +}); + +registerHook(); diff --git a/extra/list-hooks.js b/extra/list-hooks.js new file mode 100644 index 0000000..70ac887 --- /dev/null +++ b/extra/list-hooks.js @@ -0,0 +1,66 @@ +/* eslint no-console: "off" */ + +import fetch from "node-fetch"; +import crypto from "crypto"; +import { XMLParser } from "fast-xml-parser"; + +const eject = (reason) => { throw new Error(reason); } +const sharedSecret = process.env.SHARED_SECRET || eject("SHARED_SECRET not set"); +const bbbDomain = process.env.BBB_DOMAIN || eject("BBB_DOMAIN not set"); +const MEETING_ID = process.env.MEETING_ID || ''; +const parser = new XMLParser(); + +const shutdown = (code) => { + console.log(`Shutting down, code ${code}`); + process.exit(code); +} + +// registers a hook on the webhooks app +let params = ""; +if (MEETING_ID) params += "&meetingID=" + MEETING_ID; +const checksum = crypto.createHash('sha1').update("hooks/list" + params + sharedSecret).digest('hex'); +const fullUrl = "http://" + bbbDomain + "/bigbluebutton/api/hooks/list?" + + params + "&checksum=" + checksum +console.log("Registering a hook with", fullUrl); + +const listHooks = async () => { + const controller = new AbortController(); + const abortTimeout = setTimeout(() => { + controller.abort(); + }, 2500); + + try { + const response = await fetch(fullUrl, { signal: controller.signal }); + const text = await response.text(); + if (response.ok) { + const hooksObj = parser.parse(text); + const hooks = hooksObj.response?.hooks?.hook || []; + const length = !Array.isArray(hooks) ? (Object.keys(hooks).length > 0 | 0): hooks.length; + + console.debug("Registered hooks (hooks/list):", { + hooks, + returncode: hooksObj.response.returncode, + numberOfHooks: length, + }); + } else { + throw new Error(text); + } + } catch (error) { + console.error("Hooks/list failed", error); + } finally { + clearTimeout(abortTimeout); + } +}; + +process.on('SIGINT', () => shutdown(0)); +process.on('SIGTERM', () => shutdown(0)); +process.on('uncaughtException', (error) => { + console.error('uncaughtException:', error); + shutdown(1); +}); +process.on('unhandledRejection', (reason, promise) => { + console.error('unhandledRejection:', reason, promise); + shutdown(1); +}); + +listHooks(); diff --git a/extra/post_catcher.js b/extra/post_catcher.js deleted file mode 100644 index 2ea9fa7..0000000 --- a/extra/post_catcher.js +++ /dev/null @@ -1,55 +0,0 @@ -// Lists all the events that happen in a meeting. Run with 'node events.js'. -// Uses the first meeting started after the application runs and will list all -// events, but only the first time they happen. - -var redis = require("redis"); -var express = require("express"); -var request = require("request"); -var sha1 = require("sha1"); -var bodyParser = require('body-parser'); - -// server configs -var port = 3006; // port in which to run this app -var shared_secret = "33e06642a13942004fd83b3ba6e4104a"; // shared secret of your server -var domain = "127.0.0.1"; // address of your server -var target_domain = "127.0.0.1:3005"; // address of the webhooks app - -var encodeForUrl = function(value) { - return encodeURIComponent(value) - .replace(/%20/g, '+') - .replace(/[!'()]/g, escape) - .replace(/\*/g, "%2A") -} - -// create a server to listen for callbacks -var app = express(); -app.use(bodyParser.json()); -app.use(bodyParser.urlencoded({ - extended: true -})); -app.listen(port); -app.post("/callback", function(req, res, next) { - console.log("-------------------------------------"); - console.log("* Received:", req.url); - console.log("* Body:", req.body); - console.log("-------------------------------------\n"); - res.statusCode = 200; - res.send(); -}); -console.log("Server listening on port", port); - -// registers a global hook on the webhooks app -var myurl = "http://" + domain + ":" + port + "/callback"; -var params = "callbackURL=" + encodeForUrl(myurl); -var checksum = sha1("hooks/create" + params + shared_secret); -var fullurl = "http://" + target_domain + "/bigbluebutton/api/hooks/create?" + - params + "&checksum=" + checksum - -var requestOptions = { - uri: fullurl, - method: "GET" -} -console.log("Registering a hook with", fullurl); -request(requestOptions, function(error, response, body) { - console.log("Response from hook/create:", body); -}); diff --git a/hook.js b/hook.js deleted file mode 100644 index 972d50e..0000000 --- a/hook.js +++ /dev/null @@ -1,338 +0,0 @@ -const _ = require("lodash"); -const async = require("async"); -const redis = require("redis"); - -const config = require('config'); -const CallbackEmitter = require("./callback_emitter.js"); -const IDMapping = require("./id_mapping.js"); -const Logger = require("./logger.js"); - -// The database of hooks. -// Used always from memory, but saved to redis for persistence. -// -// Format: -// { id: Hook } -// Format on redis: -// * a SET "...:hooks" with all ids -// * a HASH "...:hook:" for each hook with some of its attributes -let db = {}; -let nextID = 1; - -// The representation of a hook and its properties. Stored in memory and persisted -// to redis. -// Hooks can be global, receiving callback calls for events from all meetings on the -// server, or for a specific meeting. If an `externalMeetingID` is set in the hook, -// it will only receive calls related to this meeting, otherwise it will be global. -// Events are kept in a queue to be sent in the order they are received. -// But if the requests are going by but taking too long, the queue might be increasing -// faster than the callbacks are made. In this case the events will be concatenated -// and send up to 10 events in every post - -module.exports = class Hook { - - constructor() { - this.id = null; - this.callbackURL = null; - this.externalMeetingID = null; - this.eventID = null; - this.queue = []; - this.emitter = null; - this.redisClient = Application.redisClient(); - this.permanent = false; - this.getRaw = false; - } - - save(callback) { - this.redisClient.hmset(config.get("redis.keys.hookPrefix") + ":" + this.id, this.toRedis(), (error, reply) => { - if (error != null) { Logger.error(`[Hook] error saving hook to redis: ${error} ${reply}`); } - this.redisClient.sadd(config.get("redis.keys.hooks"), this.id, (error, reply) => { - if (error != null) { Logger.error(`[Hook] error saving hookID to the list of hooks: ${error} ${reply}`); } - - db[this.id] = this; - (typeof callback === 'function' ? callback(error, db[this.id]) : undefined); - }); - }); - } - - destroy(callback) { - this.redisClient.srem(config.get("redis.keys.hooks"), this.id, (error, reply) => { - if (error != null) { Logger.error(`[Hook] error removing hookID from the list of hooks: ${error} ${reply}`); } - this.redisClient.del(config.get("redis.keys.hookPrefix") + ":" + this.id, error => { - if (error != null) { Logger.error(`[Hook] error removing hook from redis: ${error}`); } - - if (db[this.id]) { - delete db[this.id]; - (typeof callback === 'function' ? callback(error, true) : undefined); - } else { - (typeof callback === 'function' ? callback(error, false) : undefined); - } - }); - }); - } - - // Is this a global hook? - isGlobal() { - return (this.externalMeetingID == null); - } - - // The meeting from which this hook should receive events. - targetMeetingID() { - return this.externalMeetingID; - } - - // Puts a new message in the queue. Will also trigger a processing in the queue so this - // message might be processed instantly. - enqueue(message) { - // If the event is not in the hook's event list, skip it. - if ((this.eventID != null) && ((message == null) || (message.data == null) || (message.data.id == null) || (!this.eventID.some( ev => ev == message.data.id.toLowerCase() )))) { - Logger.info(`[Hook] ${this.callbackURL} skipping message from queue because not in event list for hook: ${JSON.stringify(message)}`); - } - else { - this.redisClient.llen(config.get("redis.keys.eventsPrefix") + ":" + this.id, (error, reply) => { - const length = reply; - if (length < config.get("hooks.queueSize") && this.queue.length < config.get("hooks.queueSize")) { - Logger.info(`[Hook] ${this.callbackURL} enqueueing message: ${JSON.stringify(message)}`); - // Add message to redis queue - this.redisClient.rpush(config.get("redis.keys.eventsPrefix") + ":" + this.id, JSON.stringify(message), (error,reply) => { - if (error != null) { Logger.error(`[Hook] error pushing event to redis queue: ${JSON.stringify(message)} ${error}`); } - }); - this.queue.push(JSON.stringify(message)); - this._processQueue(); - } else { - Logger.warn(`[Hook] ${this.callbackURL} queue size exceed, event: ${JSON.stringify(message)}`); - } - }); - } - } - - toRedis() { - const r = { - "hookID": this.id, - "callbackURL": this.callbackURL, - "permanent": this.permanent, - "getRaw": this.getRaw - }; - if (this.externalMeetingID != null) { r.externalMeetingID = this.externalMeetingID; } - if (this.eventID != null) { r.eventID = this.eventID.join(); } - return r; - } - - fromRedis(redisData) { - this.id = parseInt(redisData.hookID); - this.callbackURL = redisData.callbackURL; - this.permanent = redisData.permanent.toLowerCase() == 'true'; - this.getRaw = redisData.getRaw.toLowerCase() == 'true'; - if (redisData.externalMeetingID != null) { - this.externalMeetingID = redisData.externalMeetingID; - } else { - this.externalMeetingID = null; - } - if (redisData.eventID != null) { - this.eventID = redisData.eventID.toLowerCase().split(','); - } else { - this.eventID = null; - } - } - - // Gets the first message in the queue and start an emitter to send it. Will only do it - // if there is no emitter running already and if there is a message in the queue. - _processQueue() { - // Will try to send up to a defined number of messages together if they're enqueued (defined on config.get("hooks.multiEvent")) - const lengthIn = this.queue.length > config.get("hooks.multiEvent") ? config.get("hooks.multiEvent") : this.queue.length; - let num = lengthIn + 1; - // Concat messages - let message = this.queue.slice(0,lengthIn); - message = message.join(","); - - if ((message == null) || (this.emitter != null) || (lengthIn <= 0)) { return; } - // Add params so emitter will 'know' when a hook is permanent and have backupURLs - this.emitter = new CallbackEmitter(this.callbackURL, message, this.permanent); - this.emitter.start(); - - this.emitter.on("success", () => { - delete this.emitter; - while ((num -= 1)) { - // Remove the sent message from redis - this.redisClient.lpop(config.get("redis.keys.eventsPrefix") + ":" + this.id, (error, reply) => { - if (error != null) { return Logger.error(`[Hook] error removing event from redis queue: ${error}`); } - }); - this.queue.shift(); - } // pop the first message just sent - this._processQueue(); // go to the next message - }); - - // gave up trying to perform the callback, remove the hook forever if the hook's not permanent (emmiter will validate that) - return this.emitter.on("stopped", error => { - Logger.warn(`[Hook] too many failed attempts to perform a callback call, removing the hook for: ${this.callbackURL}`); - this.destroy(); - }); - } - - static addSubscription(callbackURL, meetingID, eventID, getRaw, callback) { - let hook = Hook.findByCallbackURLSync(callbackURL); - if (hook != null) { - return (typeof callback === 'function' ? callback(new Error("There is already a subscription for this callback URL"), hook) : undefined); - } else { - let msg = `[Hook] adding a hook with callback URL: [${callbackURL}],`; - if (meetingID != null) { msg += ` for the meeting: [${meetingID}]`; } - Logger.info(msg); - - hook = new Hook(); - hook.callbackURL = callbackURL; - hook.externalMeetingID = meetingID; - if (eventID != null) { - hook.eventID = eventID.toLowerCase().split(','); - } - hook.getRaw = getRaw; - hook.permanent = config.get("hooks.permanentURLs").some( obj => { - return obj.url === callbackURL - }); - if (hook.permanent) { - hook.id = config.get("hooks.permanentURLs").map(obj => obj.url).indexOf(callbackURL) + 1; - nextID = config.get("hooks.permanentURLs").length + 1; - } else { - hook.id = nextID++; - } - // Sync permanent queue - if (hook.permanent) { - hook.redisClient.llen(config.get("redis.keys.eventsPrefix") + ":" + hook.id, (error, len) => { - if (len > 0) { - const length = len; - hook.redisClient.lrange(config.get("redis.keys.eventsPrefix") + ":" + hook.id, 0, len, (error, elements) => { - elements.forEach(element => { - hook.queue.push(element); - }); - if (hook.queue.length > 0) { return hook._processQueue(); } - }); - } - }); - } - hook.save((error, hook) => { typeof callback === 'function' ? callback(error, hook) : undefined }); - } - } - - static removeSubscription(hookID, callback) { - let hook = Hook.getSync(hookID); - if (hook != null && !hook.permanent) { - let msg = `[Hook] removing the hook with callback URL: [${hook.callbackURL}],`; - if (hook.externalMeetingID != null) { msg += ` for the meeting: [${hook.externalMeetingID}]`; } - Logger.info(msg); - - hook.destroy((error, removed) => { typeof callback === 'function' ? callback(error, removed) : undefined }); - } else { - return (typeof callback === 'function' ? callback(null, false) : undefined); - } - } - - static countSync() { - return Object.keys(db).length; - } - - static getSync(id) { - return db[id]; - } - - static firstSync() { - const keys = Object.keys(db); - if (keys.length > 0) { - return db[keys[0]]; - } else { - return null; - } - } - - static findByExternalMeetingIDSync(externalMeetingID) { - const hooks = Hook.allSync(); - return _.filter(hooks, hook => (externalMeetingID != null) && (externalMeetingID === hook.externalMeetingID)); - } - - static allGlobalSync() { - const hooks = Hook.allSync(); - return _.filter(hooks, hook => hook.isGlobal()); - } - - static allSync() { - let arr = Object.keys(db).reduce(function(arr, id) { - arr.push(db[id]); - return arr; - } - , []); - return arr; - } - - static clearSync() { - for (let id in db) { - delete db[id]; - } - return db = {}; - } - - static findByCallbackURLSync(callbackURL) { - for (let id in db) { - if (db[id].callbackURL === callbackURL) { - return db[id]; - } - } - } - - static initialize(callback) { - Hook.resync(callback); - } - - // Gets all hooks from redis to populate the local database. - // Calls `callback()` when done. - static resync(callback) { - let client = Application.redisClient(); - // Remove previous permanent hooks - for (let hk = 1; hk <= config.get("hooks.permanentURLs").length; hk++) { - client.srem(config.get("redis.keys.hooks"), hk, (error, reply) => { - if (error != null) { Logger.error(`[Hook] error removing previous permanent hook from list: ${error}`); } - client.del(config.get("redis.keys.hookPrefix") + ":" + hk, error => { - if (error != null) { Logger.error(`[Hook] error removing previous permanent hook from redis: ${error}`); } - }); - }); - } - - let tasks = []; - - client.smembers(config.get("redis.keys.hooks"), (error, hooks) => { - if (error != null) { Logger.error(`[Hook] error getting list of hooks from redis: ${error}`); } - hooks.forEach(id => { - tasks.push(done => { - client.hgetall(config.get("redis.keys.hookPrefix") + ":" + id, function(error, hookData) { - if (error != null) { Logger.error(`[Hook] error getting information for a hook from redis: ${error}`); } - - if (hookData != null) { - let length; - let hook = new Hook(); - hook.fromRedis(hookData); - // sync events queue - client.llen(config.get("redis.keys.eventsPrefix") + ":" + hook.id, (error, len) => { - length = len; - client.lrange(config.get("redis.keys.eventsPrefix") + ":" + hook.id, 0, len, (error, elements) => { - elements.forEach(element => { - hook.queue.push(element); - }); - }); - }); - // Persist hook to redis - hook.save( (error, hook) => { - if (hook.id >= nextID) { nextID = hook.id + 1; } - if (hook.queue.length > 0) { hook._processQueue(); } - done(null, hook); - }); - } else { - done(null, null); - } - }); - }); - }); - - async.series(tasks, function(errors, result) { - hooks = _.map(Hook.allSync(), hook => `[${hook.id}] ${hook.callbackURL}`); - Logger.info(`[Hook] finished resync, hooks registered: ${hooks}`); - (typeof callback === 'function' ? callback() : undefined); - }); - }); - } -}; diff --git a/id_mapping.js b/id_mapping.js deleted file mode 100644 index 7c7764f..0000000 --- a/id_mapping.js +++ /dev/null @@ -1,215 +0,0 @@ -const _ = require("lodash"); -const async = require("async"); -const redis = require("redis"); - -const config = require('config'); -const Logger = require("./logger.js"); -const UserMapping = require("./userMapping.js"); - -// The database of mappings. Uses the internal ID as key because it is unique -// unlike the external ID. -// Used always from memory, but saved to redis for persistence. -// -// Format: -// { -// internalMeetingID: { -// id: @id -// externalMeetingID: @externalMeetingID -// internalMeetingID: @internalMeetingID -// lastActivity: @lastActivity -// } -// } -// Format on redis: -// * a SET "...:mappings" with all ids (not meeting ids, the object id) -// * a HASH "...:mapping:" for each mapping with all its attributes -const db = {}; -let nextID = 1; - -// A simple model to store mappings for meeting IDs. -module.exports = class IDMapping { - - constructor() { - this.id = null; - this.externalMeetingID = null; - this.internalMeetingID = null; - this.lastActivity = null; - this.redisClient = Application.redisClient(); - } - - save(callback) { - this.redisClient.hmset(config.get("redis.keys.mappingPrefix") + ":" + this.id, this.toRedis(), (error, reply) => { - if (error != null) { Logger.error(`[IDMapping] error saving mapping to redis: ${error} ${reply}`); } - this.redisClient.sadd(config.get("redis.keys.mappings"), this.id, (error, reply) => { - if (error != null) { Logger.error(`[IDMapping] error saving mapping ID to the list of mappings: ${error} ${reply}`); } - - db[this.internalMeetingID] = this; - (typeof callback === 'function' ? callback(error, db[this.internalMeetingID]) : undefined); - }); - }); - } - - destroy(callback) { - this.redisClient.srem(config.get("redis.keys.mappings"), this.id, (error, reply) => { - if (error != null) { Logger.error(`[IDMapping] error removing mapping ID from the list of mappings: ${error} ${reply}`); } - this.redisClient.del(config.get("redis.keys.mappingPrefix") + ":" + this.id, error => { - if (error != null) { Logger.error(`[IDMapping] error removing mapping from redis: ${error}`); } - - if (db[this.internalMeetingID]) { - delete db[this.internalMeetingID]; - (typeof callback === 'function' ? callback(error, true) : undefined); - } else { - (typeof callback === 'function' ? callback(error, false) : undefined); - } - }); - }); - } - - toRedis() { - const r = { - "id": this.id, - "internalMeetingID": this.internalMeetingID, - "externalMeetingID": this.externalMeetingID, - "lastActivity": this.lastActivity - }; - return r; - } - - fromRedis(redisData) { - this.id = parseInt(redisData.id); - this.externalMeetingID = redisData.externalMeetingID; - this.internalMeetingID = redisData.internalMeetingID; - this.lastActivity = redisData.lastActivity; - } - - print() { - return JSON.stringify(this.toRedis()); - } - - static addOrUpdateMapping(internalMeetingID, externalMeetingID, callback) { - let mapping = new IDMapping(); - mapping.id = nextID++; - mapping.internalMeetingID = internalMeetingID; - mapping.externalMeetingID = externalMeetingID; - mapping.lastActivity = new Date().getTime(); - mapping.save(function(error, result) { - Logger.info(`[IDMapping] added or changed meeting mapping to the list ${externalMeetingID}: ${mapping.print()}`); - (typeof callback === 'function' ? callback(error, result) : undefined); - }); - } - - static removeMapping(internalMeetingID, callback) { - return (() => { - let result = []; - for (let internal in db) { - var mapping = db[internal]; - if (mapping.internalMeetingID === internalMeetingID) { - result.push(mapping.destroy( (error, result) => { - Logger.info(`[IDMapping] removing meeting mapping from the list ${external}: ${mapping.print()}`); - return (typeof callback === 'function' ? callback(error, result) : undefined); - })); - } else { - result.push(undefined); - } - } - return result; - })(); - } - - static getInternalMeetingID(externalMeetingID) { - const mapping = IDMapping.findByExternalMeetingID(externalMeetingID); - return (mapping != null ? mapping.internalMeetingID : undefined); - } - - static getExternalMeetingID(internalMeetingID) { - if (db[internalMeetingID]){ - return db[internalMeetingID].externalMeetingID; - } - } - - static findByExternalMeetingID(externalMeetingID) { - if (externalMeetingID != null) { - for (let internal in db) { - const mapping = db[internal]; - if (mapping.externalMeetingID === externalMeetingID) { - return mapping; - } - } - } - return null; - } - - static allSync() { - let arr = Object.keys(db).reduce(function(arr, id) { - arr.push(db[id]); - return arr; - } - , []); - return arr; - } - - // Sets the last activity of the mapping for `internalMeetingID` to now. - static reportActivity(internalMeetingID) { - let mapping = db[internalMeetingID]; - if (mapping != null) { - mapping.lastActivity = new Date().getTime(); - return mapping.save(); - } - } - - // Checks all current mappings for their last activity and removes the ones that - // are "expired", that had their last activity too long ago. - static cleanup() { - const now = new Date().getTime(); - const all = IDMapping.allSync(); - const toRemove = _.filter(all, mapping => mapping.lastActivity < (now - config.get("mappings.timeout"))); - if (!_.isEmpty(toRemove)) { - Logger.info(`[IDMapping] expiring the mappings: ${_.map(toRemove, map => map.print())}`); - toRemove.forEach(mapping => { - UserMapping.removeMappingMeetingId(mapping.internalMeetingID); - mapping.destroy() - }); - } - } - - // Initializes global methods for this model. - static initialize(callback) { - IDMapping.resync(callback); - IDMapping.cleanupInterval = setInterval(IDMapping.cleanup, config.get("mappings.cleanupInterval")); - } - - // Gets all mappings from redis to populate the local database. - // Calls `callback()` when done. - static resync(callback) { - let client = Application.redisClient(); - let tasks = []; - - return client.smembers(config.get("redis.keys.mappings"), (error, mappings) => { - if (error != null) { Logger.error(`[IDMapping] error getting list of mappings from redis: ${error}`); } - - mappings.forEach(id => { - tasks.push(done => { - client.hgetall(config.get("redis.keys.mappingPrefix") + ":" + id, function(error, mappingData) { - if (error != null) { Logger.error(`[IDMapping] error getting information for a mapping from redis: ${error}`); } - - if (mappingData != null) { - let mapping = new IDMapping(); - mapping.fromRedis(mappingData); - mapping.save(function(error, hook) { - if (mapping.id >= nextID) { nextID = mapping.id + 1; } - done(null, mapping); - }); - } else { - done(null, null); - } - }); - }); - }); - - return async.series(tasks, function(errors, result) { - mappings = _.map(IDMapping.allSync(), m => m.print()); - Logger.info(`[IDMapping] finished resync, mappings registered: ${mappings}`); - return (typeof callback === 'function' ? callback() : undefined); - }); - }); - } -}; diff --git a/logger.js b/logger.js deleted file mode 100644 index 399660e..0000000 --- a/logger.js +++ /dev/null @@ -1,16 +0,0 @@ -const { createLogger, format, transports } = require('winston'); - -const logger = createLogger({ - transports: [ - new transports.Console({ - format: format.combine(format.timestamp(), format.splat(), format.json()), - }), - new transports.File({ - filename: 'log/application.log', - format: format.combine(format.timestamp(), format.splat(), format.json()), - }), - ], -}); - -module.exports = logger; - diff --git a/messageMapping.js b/messageMapping.js deleted file mode 100644 index cac3375..0000000 --- a/messageMapping.js +++ /dev/null @@ -1,512 +0,0 @@ -const Logger = require("./logger.js"); -const IDMapping = require("./id_mapping.js"); -const UserMapping = require("./userMapping.js"); -module.exports = class MessageMapping { - - constructor() { - this.mappedObject = {}; - this.mappedMessage = {}; - this.meetingEvents = [ - "MeetingCreatedEvtMsg", - "MeetingDestroyedEvtMsg", - "ScreenshareRtmpBroadcastStartedEvtMsg", - "ScreenshareRtmpBroadcastStoppedEvtMsg", - "SetCurrentPresentationEvtMsg", - "RecordingStatusChangedEvtMsg", - ]; - this.userEvents = [ - "UserJoinedMeetingEvtMsg", - "UserLeftMeetingEvtMsg", - "UserJoinedVoiceConfToClientEvtMsg", - "UserLeftVoiceConfToClientEvtMsg", - "PresenterAssignedEvtMsg", - "PresenterUnassignedEvtMsg", - "UserBroadcastCamStartedEvtMsg", - "UserBroadcastCamStoppedEvtMsg", - "UserEmojiChangedEvtMsg", - ]; - this.chatEvents = [ - "GroupChatMessageBroadcastEvtMsg", - ]; - this.rapEvents = [ - "PublishedRecordingSysMsg", - "UnpublishedRecordingSysMsg", - "DeletedRecordingSysMsg", - ]; - this.compMeetingEvents = [ - "meeting_created_message", - "meeting_destroyed_event", - ]; - this.compUserEvents = [ - "user_joined_message", - "user_left_message", - "user_listening_only", - "user_joined_voice_message", - "user_left_voice_message", - "user_shared_webcam_message", - "user_unshared_webcam_message", - "user_status_changed_message", - ]; - this.compRapEvents = [ - "archive_started", - "archive_ended", - "sanity_started", - "sanity_ended", - "post_archive_started", - "post_archive_ended", - "process_started", - "process_ended", - "post_process_started", - "post_process_ended", - "publish_started", - "publish_ended", - "post_publish_started", - "post_publish_ended", - "published", - "unpublished", - "deleted", - ]; - this.padEvents = [ - "PadContentEvtMsg" - ]; - } - - // Map internal message based on it's type - mapMessage(messageObj) { - if (this.mappedEvent(messageObj,this.meetingEvents)) { - this.meetingTemplate(messageObj); - } else if (this.mappedEvent(messageObj,this.userEvents)) { - this.userTemplate(messageObj); - } else if (this.mappedEvent(messageObj,this.chatEvents)) { - this.chatTemplate(messageObj); - } else if (this.mappedEvent(messageObj,this.rapEvents)) { - this.rapTemplate(messageObj); - } else if (this.mappedEvent(messageObj,this.compMeetingEvents)) { - this.compMeetingTemplate(messageObj); - } else if (this.mappedEvent(messageObj,this.compUserEvents)) { - this.compUserTemplate(messageObj); - } else if (this.mappedEvent(messageObj,this.compRapEvents)) { - this.compRapTemplate(messageObj); - } else if (this.mappedEvent(messageObj,this.padEvents)) { - this.padTemplate(messageObj); - } - } - - mappedEvent(messageObj,events) { - return events.some( event => { - if ((messageObj.header != null ? messageObj.header.name : undefined) === event) { - return true; - } - if ((messageObj.envelope != null ? messageObj.envelope.name : undefined) === event) { - return true; - } - return false; - }); - } - - // Map internal to external message for meeting information - meetingTemplate(messageObj) { - const props = messageObj.core.body.props; - const meetingId = messageObj.core.body.meetingId || messageObj.core.header.meetingId; - this.mappedObject.data = { - "type": "event", - "id": this.mapInternalMessage(messageObj), - "attributes":{ - "meeting":{ - "internal-meeting-id": meetingId, - "external-meeting-id": IDMapping.getExternalMeetingID(meetingId) - } - }, - "event":{ - "ts": Date.now() - } - }; - if (messageObj.envelope.name === "MeetingCreatedEvtMsg") { - this.mappedObject.data.attributes = { - "meeting":{ - "internal-meeting-id": props.meetingProp.intId, - "external-meeting-id": props.meetingProp.extId, - "name": props.meetingProp.name, - "is-breakout": props.meetingProp.isBreakout, - "duration": props.durationProps.duration, - "create-time": props.durationProps.createdTime, - "create-date": props.durationProps.createdDate, - "moderator-pass": props.password.moderatorPass, - "viewer-pass": props.password.viewerPass, - "record": props.recordProp.record, - "voice-conf": props.voiceProp.voiceConf, - "dial-number": props.voiceProp.dialNumber, - "max-users": props.usersProp.maxUsers, - "metadata": props.metadataProp.metadata - } - }; - } - if (messageObj.envelope.name === "SetCurrentPresentationEvtMsg") { - this.mappedObject.data.attributes = { - "meeting":{ - "internal-meeting-id": meetingId, - "external-meeting-id": IDMapping.getExternalMeetingID(meetingId), - "presentation-id": messageObj.core.body.presentationId - } - }; - } - this.mappedMessage = JSON.stringify(this.mappedObject); - Logger.info(`[MessageMapping] Mapped message: ${this.mappedMessage}`); - } - - compMeetingTemplate(messageObj) { - const props = messageObj.payload; - const meetingId = props.meeting_id; - this.mappedObject.data = { - "type": "event", - "id": this.mapInternalMessage(messageObj), - "attributes":{ - "meeting":{ - "internal-meeting-id": meetingId, - "external-meeting-id": IDMapping.getExternalMeetingID(meetingId) - } - }, - "event":{ - "ts": Date.now() - } - }; - if (messageObj.header.name === "meeting_created_message") { - this.mappedObject.data.attributes = { - "meeting":{ - "internal-meeting-id": meetingId, - "external-meeting-id": props.external_meeting_id, - "name": props.name, - "is-breakout": props.is_breakout, - "duration": props.duration, - "create-time": props.create_time, - "create-date": props.create_date, - "moderator-pass": props.moderator_pass, - "viewer-pass": props.viewer_pass, - "record": props.recorded, - "voice-conf": props.voice_conf, - "dial-number": props.dial_number, - "max-users": props.max_users, - "metadata": props.metadata - } - }; - } - this.mappedMessage = JSON.stringify(this.mappedObject); - Logger.info(`[MessageMapping] Mapped message: ${this.mappedMessage}`); - } - - // Map internal to external message for user information - userTemplate(messageObj) { - const msgBody = messageObj.core.body; - const msgHeader = messageObj.core.header; - const extId = UserMapping.getExternalUserID(msgHeader.userId) || msgBody.extId || ""; - this.mappedObject.data = { - "type": "event", - "id": this.mapInternalMessage(messageObj), - "attributes":{ - "meeting":{ - "internal-meeting-id": messageObj.envelope.routing.meetingId, - "external-meeting-id": IDMapping.getExternalMeetingID(messageObj.envelope.routing.meetingId) - }, - "user":{ - "internal-user-id": msgHeader.userId, - "external-user-id": extId, - "name": msgBody.name, - "role": msgBody.role, - "presenter": msgBody.presenter, - "stream": msgBody.stream - } - }, - "event":{ - "ts": Date.now() - } - }; - if (this.mappedObject.data["id"] === "user-audio-voice-enabled") { - this.mappedObject.data["attributes"]["user"]["listening-only"] = msgBody.listenOnly; - this.mappedObject.data["attributes"]["user"]["sharing-mic"] = ! msgBody.listenOnly; - } else if (this.mappedObject.data["id"] === "user-audio-voice-disabled") { - this.mappedObject.data["attributes"]["user"]["listening-only"] = false; - this.mappedObject.data["attributes"]["user"]["sharing-mic"] = false; - } - this.mappedMessage = JSON.stringify(this.mappedObject); - Logger.info(`[MessageMapping] Mapped message: ${this.mappedMessage}`); - } - - // Map internal to external message for user information - compUserTemplate(messageObj) { - const msgBody = messageObj.payload; - const msgHeader = messageObj.header; - - let user; - if (msgHeader.name === "user_joined_message") { - user = { - "internal-user-id": msgBody.user.userid, - "external-user-id": msgBody.user.extern_userid, - "sharing-mic": msgBody.user.voiceUser.joined, - "name": msgBody.user.name, - "role": msgBody.user.role, - "presenter": msgBody.user.presenter, - "stream": msgBody.user.webcam_stream, - "listening-only": msgBody.user.listenOnly - } - } - else { - user = UserMapping.getUser(msgBody.userid) || { "internal-user-id": msgBody.userid || msgBody.user.userid }; - if (msgHeader.name === "user_status_changed_message") { - if (msgBody.status === "presenter") { - user["presenter"] = msgBody.value; - } - } - else if (msgHeader.name === "user_listening_only") { - user["listening-only"] = msgBody.listen_only; - } - else if (msgHeader.name === "user_joined_voice_message" || msgHeader.name === "user_left_voice_message") { - user["sharing-mic"] = msgBody.user.voiceUser.joined; - } - else if (msgHeader.name === "user_shared_webcam_message") { - user["stream"].push(msgBody.stream); - } - else if (msgHeader.name === "user_unshared_webcam_message") { - let streams = user["stream"]; - let index = streams.indexOf(msgBody.stream); - if (index != -1) { - streams.splice(index,1); - } - user["stream"] = streams; - } - } - - this.mappedObject.data = { - "type": "event", - "id": this.mapInternalMessage(messageObj), - "attributes":{ - "meeting":{ - "internal-meeting-id": msgBody.meeting_id, - "external-meeting-id": IDMapping.getExternalMeetingID(msgBody.meeting_id) - }, - "user": user - }, - "event":{ - "ts": Date.now() - } - }; - this.mappedMessage = JSON.stringify(this.mappedObject); - Logger.info(`[MessageMapping] Mapped message: ${this.mappedMessage}`); - } - - // Map internal to external message for chat information - chatTemplate(messageObj) { - const { body } = messageObj.core; - // Ignore private chats - if (body.chatId !== 'MAIN-PUBLIC-GROUP-CHAT') return; - - this.mappedObject.data = { - "type": "event", - "id": this.mapInternalMessage(messageObj), - "attributes":{ - "meeting":{ - "internal-meeting-id": messageObj.envelope.routing.meetingId, - "external-meeting-id": IDMapping.getExternalMeetingID(messageObj.envelope.routing.meetingId) - }, - "chat-message":{ - "message": body.msg.message, - "sender":{ - "internal-user-id": body.msg.sender.id, - "external-user-id": body.msg.sender.name, - "timezone-offset": body.msg.fromTimezoneOffset, - "time": body.msg.timestamp - } - }, - "chat-id": body.chatId - }, - "event":{ - "ts": Date.now() - } - }; - this.mappedMessage = JSON.stringify(this.mappedObject); - Logger.info(`[MessageMapping] Mapped message: ${this.mappedMessage}`); - } - - rapTemplate(messageObj) { - const data = messageObj.core.body; - this.mappedObject.data = { - "type": "event", - "id": this.mapInternalMessage(messageObj), - "attributes": { - "meeting": { - "internal-meeting-id": data.recordId, - "external-meeting-id": IDMapping.getExternalMeetingID(data.recordId) - } - }, - "event": { - "ts": Date.now() - } - }; - this.mappedMessage = JSON.stringify(this.mappedObject); - Logger.info(`[MessageMapping] Mapped message: ${this.mappedMessage}`); - } - - compRapTemplate(messageObj) { - const data = messageObj.payload; - this.mappedObject.data = { - "type": "event", - "id": this.mapInternalMessage(messageObj), - "attributes": { - "meeting": { - "internal-meeting-id": data.meeting_id, - "external-meeting-id": data.external_meeting_id || IDMapping.getExternalMeetingID(data.meeting_id) - } - }, - "event": { - "ts": messageObj.header.current_time - } - }; - - if (this.mappedObject.data.id === "published" || - this.mappedObject.data.id === "unpublished" || - this.mappedObject.data.id === "deleted") { - this.mappedObject.data.attributes["record-id"] = data.meeting_id; - this.mappedObject.data.attributes["format"] = data.format; - } else { - this.mappedObject.data.attributes["record-id"] = data.record_id; - this.mappedObject.data.attributes["success"] = data.success; - this.mappedObject.data.attributes["step-time"] = data.step_time; - } - - if (data.workflow) { - this.mappedObject.data.attributes.workflow = data.workflow; - } - - if (this.mappedObject.data.id === "rap-publish-ended") { - this.mappedObject.data.attributes.recording = { - "name": data.metadata.meetingName, - "is-breakout": data.metadata.isBreakout, - "start-time": data.startTime, - "end-time": data.endTime, - "size": data.playback.size, - "raw-size": data.rawSize, - "metadata": data.metadata, - "playback": data.playback, - "download": data.download - } - } - this.mappedMessage = JSON.stringify(this.mappedObject); - Logger.info(`[MessageMapping] Mapped message: ${this.mappedMessage}`); - } - - handleRecordingStatusChanged(message) { - const event = "meeting-recording"; - const { core } = message; - if (core && core.body) { - const { recording } = core.body; - if (typeof recording === 'boolean') { - if (recording) return `${event}-started`; - return `${event}-stopped`; - } - } - return `${event}-unhandled`; - } - - handleUserListeningOnly(message) { - const event = "user-audio-listen-only"; - if (message.payload.listen_only) return `${event}-enabled`; - return `${event}-disabled`; - } - - handleUserStatusChanged(message) { - const event = "user-presenter"; - if (message.payload.status === "presenter") { - if (message.payload.value === "true") return `${event}-assigned`; - return `${event}-unassigned`; - } - } - - padTemplate(messageObj) { - const { - body, - header, - } = messageObj.core; - this.mappedObject.data = { - "type": "event", - "id": this.mapInternalMessage(messageObj), - "attributes":{ - "meeting":{ - "internal-meeting-id": header.meetingId, - "external-meeting-id": IDMapping.getExternalMeetingID(header.meetingId) - }, - "pad":{ - "id": body.padId, - "external-pad-id": body.externalId, - "rev": body.rev, - "start": body.start, - "end": body.end, - "text": body.text - } - }, - "event":{ - "ts": Date.now() - } - }; - this.mappedMessage = JSON.stringify(this.mappedObject); - Logger.info(`[MessageMapping] Mapped message: ${this.mappedMessage}`); - } - - mapInternalMessage(message) { - let name; - if (message.envelope) { - name = message.envelope.name - } - else if (message.header) { - name = message.header.name - } - const mappedMsg = (() => { switch (name) { - case "MeetingCreatedEvtMsg": return "meeting-created"; - case "MeetingDestroyedEvtMsg": return "meeting-ended"; - case "RecordingStatusChangedEvtMsg": return this.handleRecordingStatusChanged(message); - case "ScreenshareRtmpBroadcastStartedEvtMsg": return "meeting-screenshare-started"; - case "ScreenshareRtmpBroadcastStoppedEvtMsg": return "meeting-screenshare-stopped"; - case "SetCurrentPresentationEvtMsg": return "meeting-presentation-changed"; - case "UserJoinedMeetingEvtMsg": return "user-joined"; - case "UserLeftMeetingEvtMsg": return "user-left"; - case "UserJoinedVoiceConfToClientEvtMsg": return "user-audio-voice-enabled"; - case "UserLeftVoiceConfToClientEvtMsg": return "user-audio-voice-disabled"; - case "UserBroadcastCamStartedEvtMsg": return "user-cam-broadcast-start"; - case "UserBroadcastCamStoppedEvtMsg": return "user-cam-broadcast-end"; - case "PresenterAssignedEvtMsg": return "user-presenter-assigned"; - case "PresenterUnassignedEvtMsg": return "user-presenter-unassigned"; - case "UserEmojiChangedEvtMsg": return "user-emoji-changed"; - case "GroupChatMessageBroadcastEvtMsg": return "chat-group-message-sent"; - case "archive_started": return "rap-archive-started"; - case "archive_ended": return "rap-archive-ended"; - case "sanity_started": return "rap-sanity-started"; - case "sanity_ended": return "rap-sanity-ended"; - case "post_archive_started": return "rap-post-archive-started"; - case "post_archive_ended": return "rap-post-archive-ended"; - case "process_started": return "rap-process-started"; - case "process_ended": return "rap-process-ended"; - case "post_process_started": return "rap-post-process-started"; - case "post_process_ended": return "rap-post-process-ended"; - case "publish_started": return "rap-publish-started"; - case "publish_ended": return "rap-publish-ended"; - case "published": return "rap-published"; - case "unpublished": return "rap-unpublished"; - case "deleted": return "rap-deleted"; - case "PublishedRecordingSysMsg": return "rap-published"; - case "UnpublishedRecordingSysMsg": return "rap-unpublished"; - case "DeletedRecordingSysMsg": return "rap-deleted"; - case "post_publish_started": return "rap-post-publish-started"; - case "post_publish_ended": return "rap-post-publish-ended"; - case "meeting_created_message": return "meeting-created"; - case "meeting_destroyed_event": return "meeting-ended"; - case "user_joined_message": return "user-joined"; - case "user_left_message": return "user-left"; - case "user_listening_only": return this.handleUserListeningOnly(message); - case "user_joined_voice_message": return "user-audio-voice-enabled"; - case "user_left_voice_message": return "user-audio-voice-disabled"; - case "user_shared_webcam_message": return "user-cam-broadcast-start"; - case "video_stream_unpublished": return "user-cam-broadcast-end"; - case "user_status_changed_message": return this.handleUserStatusChanged(message); - case "PadContentEvtMsg": return "pad-content"; - } })(); - return mappedMsg; - } -}; diff --git a/package-lock.json b/package-lock.json index a54fdf2..f34a730 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,56 +1,392 @@ { "name": "bbb-webhooks", - "version": "2.6.1", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bbb-webhooks", - "version": "2.6.1", + "version": "3.0.0", "dependencies": { - "async": "^3.2.3", + "bullmq": "4.17.0", "config": "^3.3.7", - "express": "^4.17.3", + "express": "^4.18.2", "js-yaml": "^4.1.0", - "lodash": "^4.17.21", - "redis": "^3.1.2", - "request": "^2.88.2", - "sha1": "^1.1.1", - "winston": "^3.7.2" + "luxon": "^3.4.3", + "node-fetch": "^3.3.2", + "pino": "^8.16.1", + "prom-client": "^14.2.0", + "redis": "^4.6.8", + "uuid": "^9.0.1" }, "devDependencies": { - "body-parser": "^1.20.0", + "body-parser": "^1.20.2", + "eslint": "^8.49.0", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-jsdoc": "^46.8.2", + "fast-xml-parser": "^4.3.2", + "jsdoc": "^4.0.2", "mocha": "^9.2.2", - "nock": "^13.2.4", + "nodemon": "^3.0.1", + "pino-pretty": "^10.2.3", "sinon": "^12.0.1", "supertest": "^3.4.2" }, "engines": { - "node": ">=12 <=20" + "node": ">=18" } }, - "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "node_modules/@aashutoshrathi/word-wrap": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", + "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "dev": true, "engines": { - "node": ">=0.1.90" + "node": ">=0.10.0" } }, - "node_modules/@dabh/diagnostics": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", - "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "node_modules/@babel/parser": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz", + "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==", + "dev": true, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@es-joy/jsdoccomment": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.41.0.tgz", + "integrity": "sha512-aKUhyn1QI5Ksbqcr3fFJj16p99QdjUxXAEuFst1Z47DRyoiMwivIH9MV/ARcJOCXVjPfjITciej8ZD2O/6qUmw==", + "dev": true, + "dependencies": { + "comment-parser": "1.4.1", + "esquery": "^1.5.0", + "jsdoc-type-pratt-parser": "~4.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", + "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/eslintrc/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/@eslint/js": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz", + "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", + "integrity": "sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.1", + "debug": "^4.1.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz", + "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", + "dev": true + }, + "node_modules/@ioredis/commands": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==" + }, + "node_modules/@jsdoc/salty": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@jsdoc/salty/-/salty-0.2.7.tgz", + "integrity": "sha512-mh8LbS9d4Jq84KLw8pzho7XC2q2/IJGiJss3xwRoLD1A+EE16SjN4PfaG4jRCzKegTFLlN0Zd8SdUPE6XdoPFg==", + "dev": true, + "dependencies": { + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v12.0.0" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.2.tgz", + "integrity": "sha512-9bfjwDxIDWmmOKusUcqdS4Rw+SETlp9Dy39Xui9BEGEk19dDwH0jhipwFzEff/pFg95NKymc6TOTbRKcWeRqyQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.2.tgz", + "integrity": "sha512-lwriRAHm1Yg4iDf23Oxm9n/t5Zpw1lVnxYU3HnJPTi2lJRkKTrps1KVgvL6m7WvmhYVt/FIsssWay+k45QHeuw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.2.tgz", + "integrity": "sha512-MOI9Dlfrpi2Cuc7i5dXdxPbFIgbDBGgKR5F2yWEa6FVEtSWncfVNKW5AKjImAQ6CZlBK9tympdsZJ2xThBiWWA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.2.tgz", + "integrity": "sha512-FU20Bo66/f7He9Fp9sP2zaJ1Q8L9uLPZQDub/WlUip78JlPeMbVL8546HbZfcW9LNciEXc8d+tThSJjSC+tmsg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.2.tgz", + "integrity": "sha512-gsWNDCklNy7Ajk0vBBf9jEx04RUxuDQfBse918Ww+Qb9HCPoGzS+XJTLe96iN3BVK7grnLiYghP/M4L8VsaHeA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.2.tgz", + "integrity": "sha512-O+6Gs8UeDbyFpbSh2CPEz/UOrrdWPTBYNblZK5CxxLisYt4kGX3Sc+czffFonyjiGSq3jWLwJS/CCJc7tBr4sQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.5.13", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.5.13.tgz", + "integrity": "sha512-epkUM9D0Sdmt93/8Ozk43PNjLi36RZzG+d/T1Gdu5AI8jvghonTeLYV69WVWdilvFo+PYxbP0TZ0saMvr6nscQ==", "dependencies": { - "colorspace": "1.1.x", - "enabled": "2.0.x", - "kuler": "^2.0.0" + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.6.tgz", + "integrity": "sha512-rcZO3bfQbm2zPRpqo82XbW8zg4G/w4W3tI7X8Mqleq9goQjAGLL7q/1n1ZX4dXEAmORVZ4s1+uKLaUOg7LrUhw==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.1.6.tgz", + "integrity": "sha512-mZXCxbTYKBQ3M2lZnEddwEAks0Kc7nauire8q20oA0oA/LoA+E/b5Y5KZn232ztPb1FkIGqo12vh3Lf+Vw5iTw==", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.0.5.tgz", + "integrity": "sha512-IFjIgTusQym2B5IZJG3XKr5llka7ey84fw/NOYqESP5WUfQs9zz1ww/9+qoz4ka/S6KcGBodzlCeZ5UImKbscg==", + "peerDependencies": { + "@redis/client": "^1.0.0" } }, "node_modules/@sinonjs/commons": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz", - "integrity": "sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==", + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", + "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", "dev": true, "dependencies": { "type-detect": "4.0.8" @@ -66,9 +402,9 @@ } }, "node_modules/@sinonjs/samsam": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-6.0.2.tgz", - "integrity": "sha512-jxPRPp9n93ci7b8hMfJOFDPRLFYadN6FSpeROFTR4UNF4i5b+EK6m4QXPO46BDhFgRy1JuS87zAnFOzCUwMJcQ==", + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-6.1.3.tgz", + "integrity": "sha512-nhOb2dWPeb1sd3IQXL/dVPnKHDOAFfvichtBf4xV00/rU1QbPCQqKMbvIheIjqwVjh7qIgf2AHTHi391yMOMpQ==", "dev": true, "dependencies": { "@sinonjs/commons": "^1.6.0", @@ -77,9 +413,37 @@ } }, "node_modules/@sinonjs/text-encoding": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz", - "integrity": "sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", + "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", + "dev": true + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true + }, + "node_modules/@types/linkify-it": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz", + "integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==", + "dev": true + }, + "node_modules/@types/markdown-it": { + "version": "12.2.3", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz", + "integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==", + "dev": true, + "dependencies": { + "@types/linkify-it": "*", + "@types/mdurl": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.5.tgz", + "integrity": "sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==", "dev": true }, "node_modules/@ungap/promise-all-settled": { @@ -88,6 +452,29 @@ "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==", "dev": true }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -100,29 +487,32 @@ "node": ">= 0.6" } }, - "node_modules/accepts/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, "engines": { - "node": ">= 0.6" + "node": ">=0.4.0" } }, - "node_modules/accepts/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" + "node_modules/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, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -167,28 +557,10 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/ansi-styles/node_modules/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, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/ansi-styles/node_modules/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 - }, "node_modules/anymatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", - "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "dev": true, "dependencies": { "normalize-path": "^3.0.0", @@ -198,68 +570,182 @@ "node": ">= 8" } }, + "node_modules/are-docs-informative": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", + "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", + "dev": true, + "engines": { + "node": ">=14" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", + "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "is-array-buffer": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, - "node_modules/asn1": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", - "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "node_modules/array-includes": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.7.tgz", + "integrity": "sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==", + "dev": true, "dependencies": { - "safer-buffer": "~2.1.0" + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", + "node_modules/array.prototype.findlastindex": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz", + "integrity": "sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0", + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", + "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, "engines": { - "node": ">=0.8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/async": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", - "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==" + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz", + "integrity": "sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1", + "is-array-buffer": "^3.0.2", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true }, - "node_modules/aws-sign2": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=", + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", "engines": { - "node": "*" + "node": ">=8.0.0" } }, - "node_modules/aws4": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz", - "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==" + "node_modules/available-typed-arrays": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", + "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/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 + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, - "node_modules/bcrypt-pbkdf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", - "dependencies": { - "tweetnacl": "^0.14.3" - } + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] }, "node_modules/binary-extensions": { "version": "2.2.0", @@ -270,22 +756,33 @@ "node": ">=8" } }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==" + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true + }, "node_modules/body-parser": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.0.tgz", - "integrity": "sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg==", + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", "dev": true, "dependencies": { "bytes": "3.1.2", - "content-type": "~1.0.4", + "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.10.3", - "raw-body": "2.5.1", + "qs": "6.11.0", + "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" }, @@ -294,21 +791,6 @@ "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/body-parser/node_modules/qs": { - "version": "6.10.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz", - "integrity": "sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==", - "dev": true, - "dependencies": { - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -337,6 +819,57 @@ "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", "dev": true }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bullmq": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-4.17.0.tgz", + "integrity": "sha512-URnHgB01rlCP8RTpmW3kFnvv3vdd2aI1OcBMYQwnqODxGiJUlz9MibDVXE83mq7ee1eS1IvD9lMQqGszX6E5Pw==", + "dependencies": { + "cron-parser": "^4.6.0", + "glob": "^8.0.3", + "ioredis": "^5.3.2", + "lodash": "^4.17.21", + "msgpackr": "^1.6.2", + "node-abort-controller": "^3.1.1", + "semver": "^7.5.4", + "tslib": "^2.0.0", + "uuid": "^9.0.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -346,18 +879,27 @@ } }, "node_modules/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, + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", + "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.1", + "set-function-length": "^1.1.1" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -370,10 +912,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/caseless": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" + "node_modules/catharsis": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/catharsis/-/catharsis-0.9.0.tgz", + "integrity": "sha512-prMTQVpcns/tzFgFVkVp6ak6RykZyWb3gu8ckUpd6YkTlacOd3DXGJjIpD4Q6zJirizvaiAjSSHlOsA+6sNh2A==", + "dev": true, + "dependencies": { + "lodash": "^4.17.15" + }, + "engines": { + "node": ">= 10" + } }, "node_modules/chalk": { "version": "4.1.2", @@ -391,14 +940,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/charenc": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", - "integrity": "sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=", - "engines": { - "node": "*" - } - }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -426,6 +967,18 @@ "fsevents": "~2.3.2" } }, + "node_modules/chokidar/node_modules/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, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", @@ -437,50 +990,43 @@ "wrap-ansi": "^7.0.0" } }, - "node_modules/color": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", - "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", - "dependencies": { - "color-convert": "^1.9.3", - "color-string": "^1.6.0" + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "engines": { + "node": ">=0.10.0" } }, "node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "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, "dependencies": { - "color-name": "1.1.3" + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" } }, "node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" - }, - "node_modules/color-string": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.0.tgz", - "integrity": "sha512-9Mrz2AQLefkH1UvASKj6v6hj/7eWgjnT/cVsR8CumieLoT+g900exWeNogqtweI8dxloXN9BDQTYro1oWu/5CQ==", - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, - "node_modules/colorspace": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", - "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", - "dependencies": { - "color": "^3.1.3", - "text-hex": "1.0.x" - } + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true }, "node_modules/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, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -488,16 +1034,28 @@ "node": ">= 0.8" } }, + "node_modules/comment-parser": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", + "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", + "dev": true, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/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==", - "dev": true + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, "node_modules/config": { @@ -522,37 +1080,18 @@ "node": ">= 0.6" } }, - "node_modules/content-disposition/node_modules/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==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/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==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "engines": { "node": ">= 0.6" } }, "node_modules/cookie": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", - "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", "engines": { "node": ">= 0.6" } @@ -560,7 +1099,7 @@ "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, "node_modules/cookiejar": { "version": "2.1.4", @@ -569,27 +1108,51 @@ "dev": true }, "node_modules/core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true }, - "node_modules/crypt": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", - "integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=", + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "dependencies": { + "luxon": "^3.2.1" + }, "engines": { - "node": "*" + "node": ">=12.0.0" } }, - "node_modules/dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "node_modules/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, "dependencies": { - "assert-plus": "^1.0.0" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, "engines": { - "node": ">=0.10" + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "engines": { + "node": ">= 12" + } + }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "dev": true, + "engines": { + "node": "*" } }, "node_modules/debug": { @@ -612,18 +1175,55 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/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 + }, + "node_modules/define-data-property": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", + "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "dependencies": { + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, "engines": { "node": ">=0.4.0" } }, "node_modules/denque": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.0.tgz", - "integrity": "sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", "engines": { "node": ">=0.10" } @@ -632,7 +1232,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, "engines": { "node": ">= 0.8" } @@ -641,7 +1240,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "dev": true, "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" @@ -656,19 +1254,22 @@ "node": ">=0.3.1" } }, - "node_modules/ecc-jsbn": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, "dependencies": { - "jsbn": "~0.1.0", - "safer-buffer": "^2.1.0" + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/emoji-regex": { "version": "8.0.0", @@ -676,19 +1277,125 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, - "node_modules/enabled": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", - "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" - }, "node_modules/encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", "engines": { "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==", + "dev": true, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-abstract": { + "version": "1.22.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", + "integrity": "sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "arraybuffer.prototype.slice": "^1.0.2", + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.5", + "es-set-tostringtag": "^2.0.1", + "es-to-primitive": "^1.2.1", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.2", + "get-symbol-description": "^1.0.0", + "globalthis": "^1.0.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0", + "internal-slot": "^1.0.5", + "is-array-buffer": "^3.0.2", + "is-callable": "^1.2.7", + "is-negative-zero": "^2.0.2", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.12", + "is-weakref": "^1.0.2", + "object-inspect": "^1.13.1", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "safe-array-concat": "^1.0.1", + "safe-regex-test": "^1.0.0", + "string.prototype.trim": "^1.2.8", + "string.prototype.trimend": "^1.0.7", + "string.prototype.trimstart": "^1.0.7", + "typed-array-buffer": "^1.0.0", + "typed-array-byte-length": "^1.0.0", + "typed-array-byte-offset": "^1.0.0", + "typed-array-length": "^1.0.4", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz", + "integrity": "sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.2", + "has-tostringtag": "^1.0.0", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", + "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + } + }, + "node_modules/es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -701,7 +1408,7 @@ "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, "node_modules/escape-string-regexp": { "version": "4.0.0", @@ -715,46 +1422,399 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/eslint": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", + "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.56.0", + "@humanwhocodes/config-array": "^0.11.13", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/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 + }, + "node_modules/eslint-module-utils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.0.tgz", + "integrity": "sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==", + "dev": true, + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-module-utils/node_modules/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 + }, + "node_modules/eslint-plugin-import": { + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", + "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.7", + "array.prototype.findlastindex": "^1.2.3", + "array.prototype.flat": "^1.3.2", + "array.prototype.flatmap": "^1.3.2", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.8.0", + "hasown": "^2.0.0", + "is-core-module": "^2.13.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.7", + "object.groupby": "^1.0.1", + "object.values": "^1.1.7", + "semver": "^6.3.1", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-import/node_modules/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 + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jsdoc": { + "version": "46.10.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-46.10.1.tgz", + "integrity": "sha512-x8wxIpv00Y50NyweDUpa+58ffgSAI5sqe+zcZh33xphD0AVh+1kqr1ombaTRb7Fhpove1zfUuujlX9DWWBP5ag==", + "dev": true, + "dependencies": { + "@es-joy/jsdoccomment": "~0.41.0", + "are-docs-informative": "^0.0.2", + "comment-parser": "1.4.1", + "debug": "^4.3.4", + "escape-string-regexp": "^4.0.0", + "esquery": "^1.5.0", + "is-builtin-module": "^3.2.1", + "semver": "^7.5.4", + "spdx-expression-parse": "^4.0.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eslint/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/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, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "engines": { "node": ">= 0.6" } }, + "node_modules/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==", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/express": { - "version": "4.17.3", - "resolved": "https://registry.npmjs.org/express/-/express-4.17.3.tgz", - "integrity": "sha512-yuSQpz5I+Ch7gFrPCk4/c+dIBKlQUxtgwqzph132bsT6qhuzss6I8cLJQz7B3rFblzd6wtcI0ZbGltH/C4LjUg==", + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.19.2", + "body-parser": "1.20.1", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.4.2", + "cookie": "0.5.0", "cookie-signature": "1.0.6", "debug": "2.6.9", - "depd": "~1.1.2", + "depd": "2.0.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "~1.1.2", + "finalhandler": "1.2.0", "fresh": "0.5.2", + "http-errors": "2.0.0", "merge-descriptors": "1.0.1", "methods": "~1.1.2", - "on-finished": "~2.3.0", + "on-finished": "2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "0.1.7", "proxy-addr": "~2.0.7", - "qs": "6.9.7", + "qs": "6.11.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.17.2", - "serve-static": "1.14.2", + "send": "0.18.0", + "serve-static": "1.15.0", "setprototypeof": "1.2.0", - "statuses": "~1.5.0", + "statuses": "2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" @@ -764,143 +1824,150 @@ } }, "node_modules/express/node_modules/body-parser": { - "version": "1.19.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.2.tgz", - "integrity": "sha512-SAAwOxgoCKMGs9uUAUFHygfLAyaniaoun6I8mFY9pRAJL9+Kec34aU+oIjDhTycub1jozEfEwx1W1IuOYxVSFw==", + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", + "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.4", "debug": "2.6.9", - "depd": "~1.1.2", - "http-errors": "1.8.1", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", "iconv-lite": "0.4.24", - "on-finished": "~2.3.0", - "qs": "6.9.7", - "raw-body": "2.4.3", - "type-is": "~1.6.18" + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.1", + "type-is": "~1.6.18", + "unpipe": "1.0.0" }, "engines": { - "node": ">= 0.8" - } - }, - "node_modules/express/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=", - "engines": { - "node": ">= 0.6" + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/express/node_modules/http-errors": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", - "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "node_modules/express/node_modules/raw-body": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", + "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.1" + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, - "node_modules/express/node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true }, - "node_modules/express/node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", - "dependencies": { - "ee-first": "1.1.1" - }, + "node_modules/fast-copy": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.1.tgz", + "integrity": "sha512-Knr7NOtK3HWRYGtHoJrjkaWepqT8thIVGAwt0p0aUs1zqkAzXZV4vo9fFNwyb5fcqK1GKYFYxldQdIDVKhUAfA==", + "dev": true + }, + "node_modules/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 + }, + "node_modules/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 + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fast-redact": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.3.0.tgz", + "integrity": "sha512-6T5V1QK1u4oF+ATxs1lWUmlEk6P2T9HqJG3e2DnHOdVgZy2rFJBoEnrIedcTXlkAHU/zKC+7KETJ+KGGKwxgMQ==", "engines": { - "node": ">= 0.8" + "node": ">=6" } }, - "node_modules/express/node_modules/qs": { - "version": "6.9.7", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.9.7.tgz", - "integrity": "sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw==", - "engines": { - "node": ">=0.6" + "node_modules/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 + }, + "node_modules/fast-xml-parser": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.3.2.tgz", + "integrity": "sha512-rmrXUXwbJedoXkStenj1kkljNF7ugn5ZjR9FJcwmCfcCbtOMDghPajbc+Tck6vE6F5XsDmx+Pr2le9fw8+pXBg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + }, + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + } + ], + "dependencies": { + "strnum": "^1.0.5" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "bin": { + "fxparser": "src/cli/cli.js" } }, - "node_modules/express/node_modules/raw-body": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.3.tgz", - "integrity": "sha512-UlTNLIcu0uzb4D2f4WltY6cVjLi+/jEN4lgEUj3E04tpMDpUlkBo/eSn6zou9hum2VMNpCCUone0O0WeJim07g==", + "node_modules/fastq": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.16.0.tgz", + "integrity": "sha512-ifCoaXsDrsdkWTtiNJX5uzHDsrck5TzfKKDcuFFTIrrc/BS076qgEIfoIy1VeZqViznfKiysPYTh/QeHtnIsYA==", + "dev": true, "dependencies": { - "bytes": "3.1.2", - "http-errors": "1.8.1", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" + "reusify": "^1.0.4" } }, - "node_modules/express/node_modules/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==", + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", "funding": [ { "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "url": "https://github.com/sponsors/jimmywarting" }, { - "type": "consulting", - "url": "https://feross.org/support" + "type": "paypal", + "url": "https://paypal.me/jimmywarting" } - ] - }, - "node_modules/express/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=", + ], + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, "engines": { - "node": ">= 0.6" + "node": "^12.20 || >= 14.13" } }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" - }, - "node_modules/extsprintf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", - "engines": [ - "node >=0.6.0" - ] - }, - "node_modules/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==" - }, - "node_modules/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==" - }, - "node_modules/fecha": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", - "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==" + "node_modules/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, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } }, "node_modules/fill-range": { "version": "7.0.1", @@ -915,41 +1982,22 @@ } }, "node_modules/finalhandler": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", - "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", - "on-finished": "~2.3.0", + "on-finished": "2.4.1", "parseurl": "~1.3.3", - "statuses": "~1.5.0", + "statuses": "2.0.1", "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8" } }, - "node_modules/finalhandler/node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -975,23 +2023,40 @@ "flat": "cli.js" } }, - "node_modules/fn.name": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", - "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" - }, - "node_modules/forever-agent": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, "engines": { - "node": "*" + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", + "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", + "dev": true + }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.3" } }, "node_modules/form-data": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", - "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", + "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.6", @@ -1001,10 +2066,21 @@ "node": ">= 0.12" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/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==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.6.tgz", + "integrity": "sha512-KcpbcpuLNOwrEjnbpMC0gS+X8ciDoZE1kkqzat4a8vrprf+s9pKNQ/QIwWfbfs4ltgmFl3MD177SNTkve3BwGQ==", "deprecated": "Please upgrade to latest, formidable@v2 or formidable@v3! Check these notes: https://bit.ly/2ZEqIau", "dev": true, "funding": { @@ -1022,7 +2098,7 @@ "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", "engines": { "node": ">= 0.6" } @@ -1030,28 +2106,64 @@ "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "engines": { + "node": ">= 4" + } }, "node_modules/get-caller-file": { "version": "2.0.5", @@ -1063,111 +2175,153 @@ } }, "node_modules/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, + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-symbols": "^1.0.1" + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/getpass": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "node_modules/get-symbol-description": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", + "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", + "dev": true, "dependencies": { - "assert-plus": "^1.0.0" + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", - "dev": true, + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", "dependencies": { "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" + "minimatch": "^5.0.1", + "once": "^1.3.0" }, "engines": { - "node": "*" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "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, "dependencies": { - "is-glob": "^4.0.1" + "is-glob": "^4.0.3" }, "engines": { - "node": ">= 6" + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" } }, "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^2.0.1" }, "engines": { - "node": "*" + "node": ">=10" } }, - "node_modules/growl": { - "version": "1.10.5", - "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", - "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, "engines": { - "node": ">=4.x" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/har-schema": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=", + "node_modules/globalthis": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", + "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "dev": true, + "dependencies": { + "define-properties": "^1.1.3" + }, "engines": { - "node": ">=4" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/har-validator": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", - "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", - "deprecated": "this library is no longer supported", + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", "dependencies": { - "ajv": "^6.12.3", - "har-schema": "^2.0.0" + "get-intrinsic": "^1.1.3" }, - "engines": { - "node": ">=6" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/growl": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", + "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", "dev": true, - "dependencies": { - "function-bind": "^1.1.1" - }, "engines": { - "node": ">= 0.4.0" + "node": ">=4.x" + } + }, + "node_modules/has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/has-flag": { @@ -1179,11 +2333,47 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", + "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", + "dependencies": { + "get-intrinsic": "^1.2.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", + "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", "dev": true, + "dependencies": { + "has-symbols": "^1.0.2" + }, "engines": { "node": ">= 0.4" }, @@ -1191,6 +2381,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -1200,97 +2401,289 @@ "he": "bin/he" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "dev": true + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/ignore": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz", + "integrity": "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true + }, + "node_modules/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, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/internal-slot": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz", + "integrity": "sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.2", + "hasown": "^2.0.0", + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ioredis": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.3.2.tgz", + "integrity": "sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==", + "dependencies": { + "@ioredis/commands": "^1.1.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ioredis/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/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==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", + "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.0", + "is-typed-array": "^1.1.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", "dev": true, "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" + "has-bigints": "^1.0.1" }, - "engines": { - "node": ">= 0.8" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/http-errors/node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/http-signature": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", - "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "node_modules/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, "dependencies": { - "assert-plus": "^1.0.0", - "jsprim": "^1.2.2", - "sshpk": "^1.7.0" + "binary-extensions": "^2.0.0" }, "engines": { - "node": ">=0.8", - "npm": ">=1.3.7" + "node": ">=8" } }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "node_modules/is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dev": true, "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "node_modules/is-builtin-module": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", + "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", "dev": true, "dependencies": { - "once": "^1.3.0", - "wrappy": "1" + "builtin-modules": "^3.3.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" - }, - "node_modules/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==", + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, "engines": { - "node": ">= 0.10" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/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==", + "node_modules/is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", "dev": true, "dependencies": { - "binary-extensions": "^2.0.0" + "has-tostringtag": "^1.0.0" }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "engines": { "node": ">=0.10.0" @@ -1317,6 +2710,18 @@ "node": ">=0.10.0" } }, + "node_modules/is-negative-zero": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", + "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -1326,6 +2731,30 @@ "node": ">=0.12.0" } }, + "node_modules/is-number-object": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/is-plain-obj": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", @@ -1335,21 +2764,78 @@ "node": ">=8" } }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "node_modules/is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, "engines": { - "node": ">=8" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + "node_modules/is-shared-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", + "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", + "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.11" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/is-unicode-supported": { "version": "0.1.0", @@ -1363,22 +2849,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "dev": true }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, - "node_modules/isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "engines": { + "node": ">=10" + } }, "node_modules/js-yaml": { "version": "4.1.0", @@ -1391,25 +2893,79 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" + "node_modules/js2xmlparser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/js2xmlparser/-/js2xmlparser-4.0.2.tgz", + "integrity": "sha512-6n4D8gLlLf1n5mNLQPRfViYzu9RATblzPEtm1SthMX1Pjao0r9YI9nw7ZIfRxQMERS87mcswrg+r/OYrPRX6jA==", + "dev": true, + "dependencies": { + "xmlcreate": "^2.0.4" + } }, - "node_modules/json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" + "node_modules/jsdoc": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-4.0.2.tgz", + "integrity": "sha512-e8cIg2z62InH7azBBi3EsSEqrKx+nUtAS5bBcYTSpZFA+vhNPyhv8PTFZ0WsjOPDj04/dOLlm08EDcQJDqaGQg==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.15", + "@jsdoc/salty": "^0.2.1", + "@types/markdown-it": "^12.2.3", + "bluebird": "^3.7.2", + "catharsis": "^0.9.0", + "escape-string-regexp": "^2.0.0", + "js2xmlparser": "^4.0.2", + "klaw": "^3.0.0", + "markdown-it": "^12.3.2", + "markdown-it-anchor": "^8.4.1", + "marked": "^4.0.10", + "mkdirp": "^1.0.4", + "requizzle": "^0.2.3", + "strip-json-comments": "^3.1.0", + "underscore": "~1.13.2" + }, + "bin": { + "jsdoc": "jsdoc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/jsdoc-type-pratt-parser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.0.0.tgz", + "integrity": "sha512-YtOli5Cmzy3q4dP26GraSOeAhqecewG04hoO8DY56CH4KJ9Fvv5qKWUCCo3HZob7esJQHCv6/+bnTy72xZZaVQ==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/jsdoc/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true }, "node_modules/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==" + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" + "node_modules/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": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true }, "node_modules/json5": { "version": "2.2.3", @@ -1422,30 +2978,51 @@ "node": ">=6" } }, - "node_modules/jsprim": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", - "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", - "dependencies": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.4.0", - "verror": "1.10.0" - }, - "engines": { - "node": ">=0.6.0" - } - }, "node_modules/just-extend": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", "dev": true }, - "node_modules/kuler": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", - "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/klaw": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz", + "integrity": "sha512-0Fo5oir+O9jnXu5EefYbVK+mHMBeEVEy2cmctR1O1NECcCkPRreJKrS6Qt/j3KC2C148Dfo9i3pCmCMsdqGr0g==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.9" + } + }, + "node_modules/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, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/linkify-it": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz", + "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==", + "dev": true, + "dependencies": { + "uc.micro": "^1.0.1" + } }, "node_modules/locate-path": { "version": "6.0.0", @@ -1467,16 +3044,26 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" + }, "node_modules/lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", "dev": true }, - "node_modules/lodash.set": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz", - "integrity": "sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=", + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" + }, + "node_modules/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 }, "node_modules/log-symbols": { @@ -1495,27 +3082,73 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/logform": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/logform/-/logform-2.4.0.tgz", - "integrity": "sha512-CPSJw4ftjf517EhXZGGvTHHkYobo7ZCc0kvwUoOYcjfR2UVrI66RHj8MCrfAdEitdmFqbu2BYdYs8FHHZSb6iw==", + "node_modules/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==", "dependencies": { - "@colors/colors": "1.5.0", - "fecha": "^4.2.0", - "ms": "^2.1.1", - "safe-stable-stringify": "^2.3.1", - "triple-beam": "^1.3.0" + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" } }, - "node_modules/logform/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + "node_modules/luxon": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", + "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/markdown-it": { + "version": "12.3.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz", + "integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1", + "entities": "~2.1.0", + "linkify-it": "^3.0.1", + "mdurl": "^1.0.1", + "uc.micro": "^1.0.5" + }, + "bin": { + "markdown-it": "bin/markdown-it.js" + } + }, + "node_modules/markdown-it-anchor": { + "version": "8.6.7", + "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-8.6.7.tgz", + "integrity": "sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA==", + "dev": true, + "peerDependencies": { + "@types/markdown-it": "*", + "markdown-it": "*" + } + }, + "node_modules/marked": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", + "integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==", + "dev": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", + "dev": true }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", "engines": { "node": ">= 0.6" } @@ -1523,52 +3156,75 @@ "node_modules/merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "engines": { "node": ">= 0.6" } }, "node_modules/mime": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.4.1.tgz", - "integrity": "sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ==", - "dev": true, + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "bin": { "mime": "cli.js" + }, + "engines": { + "node": ">=4" } }, "node_modules/mime-db": { - "version": "1.47.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.47.0.tgz", - "integrity": "sha512-QBmA/G2y+IfeS4oktet3qRZ+P5kPhCKRXxXnQEudYqUaEioAU1/Lq2us3D/t1Jfo4hE9REQPrbB7K5sOczJVIw==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { - "version": "2.1.30", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.30.tgz", - "integrity": "sha512-crmjA4bLtR8m9qLpHvgxSChT+XoSlZi8J4n/aIdn3z92e/U47Z0V/yl+Wh9W046GgFVAmoNR/fmdbZYcSSIUeg==", + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dependencies": { - "mime-db": "1.47.0" + "mime-db": "1.52.0" }, "engines": { "node": ">= 0.6" } }, "node_modules/minimatch": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-4.2.1.tgz", - "integrity": "sha512-9Uq1ChtSZO+Mxa/CL1eGizn2vRn3MlLgzhT0Iz8zaY8NdvxvB0d5QdPFmCKf7JKA9Lerx5vRrnwO03jsSfGG9g==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, "engines": { "node": ">=10" } @@ -1639,6 +3295,50 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "node_modules/mocha/node_modules/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "dev": true, + "dependencies": { + "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" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mocha/node_modules/minimatch": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-4.2.1.tgz", + "integrity": "sha512-9Uq1ChtSZO+Mxa/CL1eGizn2vRn3MlLgzhT0Iz8zaY8NdvxvB0d5QdPFmCKf7JKA9Lerx5vRrnwO03jsSfGG9g==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mocha/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1663,7 +3363,36 @@ "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/msgpackr": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.10.1.tgz", + "integrity": "sha512-r5VRLv9qouXuLiIBrLpl2d5ZvPt8svdQTl5/vMvE4nzDMyEX4sgW5yWhuBBj5UmgwOTWj8CIdSXn5sAfsHAWIQ==", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.2.tgz", + "integrity": "sha512-SdzXp4kD/Qf8agZ9+iTu6eql0m3kWm1A2y1hkpTeVNENutaB0BwHlSvAIaMxwntmRUAUjon2V4L8Z/njd0Ct8A==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.0.7" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.2", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.2", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.2", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.2", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.2", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.2" + } }, "node_modules/nanoid": { "version": "3.3.1", @@ -1677,6 +3406,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -1686,27 +3421,51 @@ } }, "node_modules/nise": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.0.tgz", - "integrity": "sha512-W5WlHu+wvo3PaKLsJJkgPup2LrsXCcm7AWwyNZkUnn5rwPkuPBi3Iwk5SQtN0mv+K65k7nKKjwNQ30wg3wLAQQ==", + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.5.tgz", + "integrity": "sha512-VJuPIfUFaXNRzETTQEEItTOP8Y171ijr+JLq42wHes3DiryR8vT+1TXQW/Rx8JNUhyYYWyIvjXTU6dOhJcs9Nw==", "dev": true, "dependencies": { - "@sinonjs/commons": "^1.7.0", - "@sinonjs/fake-timers": "^7.0.4", + "@sinonjs/commons": "^2.0.0", + "@sinonjs/fake-timers": "^10.0.2", "@sinonjs/text-encoding": "^0.7.1", "just-extend": "^4.0.2", "path-to-regexp": "^1.7.0" } }, + "node_modules/nise/node_modules/@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, "node_modules/nise/node_modules/@sinonjs/fake-timers": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-7.1.2.tgz", - "integrity": "sha512-iQADsW4LBMISqZ6Ci1dupJL9pprqwcVFTcOsEmQOEhW+KLCVn/Y4Jrvg2k19fIHCp+iFprriYPTdRcQR8NbUPg==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", "dev": true, "dependencies": { - "@sinonjs/commons": "^1.7.0" + "@sinonjs/commons": "^3.0.0" } }, + "node_modules/nise/node_modules/@sinonjs/fake-timers/node_modules/@sinonjs/commons": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", + "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/nise/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "dev": true + }, "node_modules/nise/node_modules/path-to-regexp": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", @@ -1716,22 +3475,86 @@ "isarray": "0.0.1" } }, - "node_modules/nock": { - "version": "13.2.4", - "resolved": "https://registry.npmjs.org/nock/-/nock-13.2.4.tgz", - "integrity": "sha512-8GPznwxcPNCH/h8B+XZcKjYPXnUV5clOKCjAqyjsiqA++MpNx9E9+t8YPp0MbThO+KauRo7aZJ1WuIZmOrT2Ug==", + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.7.tgz", + "integrity": "sha512-YlCCc6Wffkx0kHkmam79GKvDQ6x+QZkMjFGrIMxgFNILFvGSbCp2fCBC55pGTT9gVaz8Na5CLmxt/urtzRv36w==", + "optional": true, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/nodemon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.0.2.tgz", + "integrity": "sha512-9qIN2LNTrEzpOPBaWHTm4Asy1LxXLSickZStAQ4IZe7zsoIpD/A7LWxhZV3t4Zu352uBcqVnRsDXSMR2Sc3lTA==", "dev": true, "dependencies": { - "debug": "^4.1.0", - "json-stringify-safe": "^5.0.1", - "lodash.set": "^4.3.2", - "propagate": "^2.0.0" + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" }, "engines": { - "node": ">= 10.13" + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" } }, - "node_modules/nock/node_modules/debug": { + "node_modules/nodemon/node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", @@ -1748,12 +3571,48 @@ } } }, - "node_modules/nock/node_modules/ms": { + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/nopt": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", + "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==", + "dev": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -1763,28 +3622,99 @@ "node": ">=0.10.0" } }, - "node_modules/oauth-sign": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", - "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "node_modules/object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, "engines": { - "node": "*" + "node": ">= 0.4" } }, - "node_modules/object-inspect": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz", - "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==", + "node_modules/object.assign": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.7.tgz", + "integrity": "sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.1.tgz", + "integrity": "sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "get-intrinsic": "^1.2.1" + } + }, + "node_modules/object.values": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.7.tgz", + "integrity": "sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==", "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, "dependencies": { "ee-first": "1.1.1" }, @@ -1795,18 +3725,26 @@ "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dependencies": { "wrappy": "1" } }, - "node_modules/one-time": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", - "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "node_modules/optionator": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", + "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "dev": true, "dependencies": { - "fn.name": "1.x.x" + "@aashutoshrathi/word-wrap": "^1.2.3", + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0" + }, + "engines": { + "node": ">= 0.8.0" } }, "node_modules/p-limit": { @@ -1839,6 +3777,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/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, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1859,32 +3809,119 @@ "node_modules/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=", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/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, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, "node_modules/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=" + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "8.17.2", + "resolved": "https://registry.npmjs.org/pino/-/pino-8.17.2.tgz", + "integrity": "sha512-LA6qKgeDMLr2ux2y/YiUt47EfgQ+S9LznBWOJdN3q1dx2sv0ziDLUBeVpyVv17TEcGCBuWf0zNtg3M5m1NhhWQ==", + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.1.1", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "v1.1.0", + "pino-std-serializers": "^6.0.0", + "process-warning": "^3.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^3.7.0", + "thread-stream": "^2.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-1.1.0.tgz", + "integrity": "sha512-lsleG3/2a/JIWUtf9Q5gUNErBqwIu1tUKTT3dUzaf5DySw9ra1wcqKjJjLX1VTY64Wk1eEOYsVGSaGfCK85ekA==", + "dependencies": { + "readable-stream": "^4.0.0", + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-10.3.1.tgz", + "integrity": "sha512-az8JbIYeN/1iLj2t0jR9DV48/LQ3RC6hZPpapKPkb84Q+yTidMCpgWxIT3N0flnBDilyBQ1luWNpOeJptjdp/g==", + "dev": true, + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^3.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^1.0.0", + "pump": "^3.0.0", + "readable-stream": "^4.0.0", + "secure-json-parse": "^2.4.0", + "sonic-boom": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "bin": { + "pino-pretty": "bin.js" + } }, - "node_modules/performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" + "node_modules/pino-std-serializers": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-6.2.2.tgz", + "integrity": "sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==" }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "node_modules/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, "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": ">= 0.8.0" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "engines": { + "node": ">= 0.6.0" } }, "node_modules/process-nextick-args": { @@ -1893,13 +3930,20 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, - "node_modules/propagate": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", - "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", - "dev": true, + "node_modules/process-warning": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", + "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==" + }, + "node_modules/prom-client": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-14.2.0.tgz", + "integrity": "sha512-sF308EhTenb/pDRPakm+WgiN+VdM/T1RaHj1x+MvAuT8UiQP8JmOEbxVqtkbfR4LrvOg5n7ic01kRBDGXjYikA==", + "dependencies": { + "tdigest": "^0.1.1" + }, "engines": { - "node": ">= 8" + "node": ">=10" } }, "node_modules/proxy-addr": { @@ -1914,24 +3958,35 @@ "node": ">= 0.10" } }, - "node_modules/psl": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", - "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==" + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true + }, + "node_modules/pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } }, "node_modules/punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "engines": { "node": ">=6" } }, "node_modules/qs": { - "version": "6.11.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", - "integrity": "sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==", - "dev": true, + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", "dependencies": { "side-channel": "^1.0.4" }, @@ -1942,6 +3997,31 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -1960,9 +4040,9 @@ } }, "node_modules/raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "dev": true, "dependencies": { "bytes": "3.1.2", @@ -1975,26 +4055,20 @@ } }, "node_modules/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==", - "dev": true, + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", "dependencies": { - "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" + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/readable-stream/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true - }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -2007,33 +4081,31 @@ "node": ">=8.10.0" } }, - "node_modules/redis": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz", - "integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==", - "dependencies": { - "denque": "^1.5.0", - "redis-commands": "^1.7.0", - "redis-errors": "^1.2.0", - "redis-parser": "^3.0.0" - }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-redis" + "node": ">= 12.13.0" } }, - "node_modules/redis-commands": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", - "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==" + "node_modules/redis": { + "version": "4.6.12", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.6.12.tgz", + "integrity": "sha512-41Xuuko6P4uH4VPe5nE3BqXHB7a9lkFL0J29AlxKaIfD6eWO8VO/5PDF9ad2oS+mswMsfFxaM5DlE3tnXT+P8Q==", + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.5.13", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.6", + "@redis/search": "1.1.6", + "@redis/time-series": "1.0.5" + } }, "node_modules/redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", - "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", "engines": { "node": ">=4" } @@ -2041,7 +4113,7 @@ "node_modules/redis-parser": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", - "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", "dependencies": { "redis-errors": "^1.0.0" }, @@ -2049,163 +4121,247 @@ "node": ">=4" } }, - "node_modules/request": { - "version": "2.88.2", - "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", - "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", - "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", - "dependencies": { - "aws-sign2": "~0.7.0", - "aws4": "^1.8.0", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "form-data": "~2.3.2", - "har-validator": "~5.1.3", - "http-signature": "~1.2.0", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "~2.1.19", - "oauth-sign": "~0.9.0", - "performance-now": "^2.1.0", - "qs": "~6.5.2", - "safe-buffer": "^5.1.2", - "tough-cookie": "~2.5.0", - "tunnel-agent": "^0.6.0", - "uuid": "^3.3.2" + "node_modules/regexp.prototype.flags": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", + "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "set-function-name": "^2.0.0" }, "engines": { - "node": ">= 6" - } - }, - "node_modules/request/node_modules/qs": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", - "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", - "engines": { - "node": ">=0.6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true, "engines": { "node": ">=0.10.0" } }, - "node_modules/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==" + "node_modules/requizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/requizzle/-/requizzle-0.2.4.tgz", + "integrity": "sha512-JRrFk1D4OQ4SqovXOgdav+K8EAhSB/LJZqCz8tbX0KObcdeM15Ss59ozWMBWmmINMagCwmqn4ZNryUGpBsl6Jw==", + "dev": true, + "dependencies": { + "lodash": "^4.17.21" + } }, - "node_modules/safe-stable-stringify": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.3.1.tgz", - "integrity": "sha512-kYBSfT+troD9cDA85VDnHZ1rpHC50O0g1e6WlGHVCz/g+JS+9WKLj+XwFYyR8UbrZN8ll9HUpDAAddY58MGisg==", + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/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, "engines": { - "node": ">=10" + "node": ">=4" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } }, - "node_modules/send": { - "version": "0.17.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.17.2.tgz", - "integrity": "sha512-UJYB6wFSJE3G00nEivR5rgWp8c2xXvJ3OPWPhmuteU0IKj8nKbG3DrjiOmLwpnHGYWAVwA69zmTm++YG0Hmwww==", + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, "dependencies": { - "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.8.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.3.0", - "range-parser": "~1.2.1", - "statuses": "~1.5.0" + "glob": "^7.1.3" }, - "engines": { - "node": ">= 0.8.0" + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/send/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=", + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, "engines": { - "node": ">= 0.6" + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/send/node_modules/destroy": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", - "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } }, - "node_modules/send/node_modules/http-errors": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", - "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "node_modules/safe-array-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.1.tgz", + "integrity": "sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==", + "dev": true, "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.1" + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" }, "engines": { - "node": ">= 0.6" + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/send/node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + "node_modules/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==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] }, - "node_modules/send/node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "bin": { - "mime": "cli.js" + "node_modules/safe-regex-test": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", + "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "is-regex": "^1.1.4" }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz", + "integrity": "sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==", "engines": { - "node": ">=4" + "node": ">=10" } }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, - "node_modules/send/node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "dev": true + }, + "node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", "dependencies": { - "ee-first": "1.1.1" + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">= 0.8" + "node": ">=10" } }, - "node_modules/send/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=", + "node_modules/send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, "engines": { - "node": ">= 0.6" + "node": ">= 0.8.0" } }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "node_modules/serialize-javascript": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", @@ -2216,41 +4372,77 @@ } }, "node_modules/serve-static": { - "version": "1.14.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.2.tgz", - "integrity": "sha512-+TMNA9AFxUEGuC0z2mevogSnn9MXKb4fa7ngeRMJaaGv8vTwnIEkKi+QGvPt33HSnf8pRS+WGM0EbMtCJLKMBQ==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", "dependencies": { "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.17.2" + "send": "0.18.0" }, "engines": { "node": ">= 0.8.0" } }, + "node_modules/set-function-length": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", + "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "dependencies": { + "define-data-property": "^1.1.1", + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", + "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, - "node_modules/sha1": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/sha1/-/sha1-1.1.1.tgz", - "integrity": "sha1-rdqnqTFo85PxnrKxUJFhjicA+Eg=", + "node_modules/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, "dependencies": { - "charenc": ">= 0.0.1", - "crypt": ">= 0.0.1" + "shebang-regex": "^3.0.0" }, "engines": { - "node": "*" + "node": ">=8" + } + }, + "node_modules/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, + "engines": { + "node": ">=8" } }, "node_modules/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, "dependencies": { "call-bind": "^1.0.0", "get-intrinsic": "^1.0.2", @@ -2260,12 +4452,16 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=", + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, "dependencies": { - "is-arrayish": "^0.3.1" + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" } }, "node_modules/sinon": { @@ -2287,53 +4483,63 @@ "url": "https://opencollective.com/sinon" } }, - "node_modules/sshpk": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz", - "integrity": "sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ==", + "node_modules/sonic-boom": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-3.8.0.tgz", + "integrity": "sha512-ybz6OYOUjoQQCQ/i4LU8kaToD8ACtYP+Cj5qd2AO36bwbdewxWJ3ArmJ2cr6AvxlL2o0PqnCcPGUgkILbfkaCA==", "dependencies": { - "asn1": "~0.2.3", - "assert-plus": "^1.0.0", - "bcrypt-pbkdf": "^1.0.0", - "dashdash": "^1.12.0", - "ecc-jsbn": "~0.1.1", - "getpass": "^0.1.1", - "jsbn": "~0.1.0", - "safer-buffer": "^2.0.2", - "tweetnacl": "~0.14.0" - }, - "bin": { - "sshpk-conv": "bin/sshpk-conv", - "sshpk-sign": "bin/sshpk-sign", - "sshpk-verify": "bin/sshpk-verify" - }, - "engines": { - "node": ">=0.10.0" + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", + "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "dev": true + }, + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" } }, - "node_modules/stack-trace": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", - "integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=", + "node_modules/spdx-license-ids": { + "version": "3.0.16", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz", + "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==", + "dev": true + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "engines": { - "node": "*" + "node": ">= 10.x" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true, "engines": { "node": ">= 0.8" } }, "node_modules/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==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dependencies": { - "safe-buffer": "~5.1.0" + "safe-buffer": "~5.2.0" } }, "node_modules/string-width": { @@ -2350,6 +4556,51 @@ "node": ">=8" } }, + "node_modules/string.prototype.trim": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz", + "integrity": "sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz", + "integrity": "sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz", + "integrity": "sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -2362,6 +4613,15 @@ "node": ">=8" } }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -2374,6 +4634,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", + "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==", + "dev": true + }, "node_modules/superagent": { "version": "3.8.3", "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz", @@ -2405,12 +4671,48 @@ "ms": "^2.1.1" } }, + "node_modules/superagent/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true + }, "node_modules/superagent/node_modules/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 }, + "node_modules/superagent/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "dependencies": { + "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" + } + }, + "node_modules/superagent/node_modules/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==", + "dev": true + }, + "node_modules/superagent/node_modules/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==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/supertest": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/supertest/-/supertest-3.4.2.tgz", @@ -2436,10 +4738,39 @@ "node": ">=8" } }, - "node_modules/text-hex": { + "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", - "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==" + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "dependencies": { + "bintrees": "1.0.2" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "node_modules/thread-stream": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.4.1.tgz", + "integrity": "sha512-d/Ex2iWd1whipbT681JmTINKw0ZwOUBZm7+Gjs64DHuX34mmw8vJL2bFAaNacaW72zYiTJxSHi5abUuOi5nsfg==", + "dependencies": { + "real-require": "^0.2.0" + } }, "node_modules/to-regex-range": { "version": "5.0.1", @@ -2461,38 +4792,58 @@ "node": ">=0.6" } }, - "node_modules/tough-cookie": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "node_modules/touch": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", + "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", + "dev": true, "dependencies": { - "psl": "^1.1.28", - "punycode": "^2.1.1" + "nopt": "~1.0.10" }, - "engines": { - "node": ">=0.8" + "bin": { + "nodetouch": "bin/nodetouch.js" } }, - "node_modules/triple-beam": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.3.0.tgz", - "integrity": "sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==" + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, "dependencies": { - "safe-buffer": "^5.0.1" + "minimist": "^1.2.0" }, - "engines": { - "node": "*" + "bin": { + "json5": "lib/cli.js" } }, - "node_modules/tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, + "node_modules/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, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } }, "node_modules/type-detect": { "version": "4.0.8", @@ -2503,6 +4854,18 @@ "node": ">=4" } }, + "node_modules/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, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -2515,10 +4878,108 @@ "node": ">= 0.6" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", + "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", + "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", + "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "has-proto": "^1.0.1", + "is-typed-array": "^1.1.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", + "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "for-each": "^0.3.3", + "is-typed-array": "^1.1.9" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", + "dev": true + }, + "node_modules/unbox-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true + }, + "node_modules/underscore": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.6.tgz", + "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==", + "dev": true + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", "engines": { "node": ">= 0.8" } @@ -2527,6 +4988,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, "dependencies": { "punycode": "^2.1.0" } @@ -2534,44 +4996,43 @@ "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "engines": { "node": ">= 0.4.0" } }, "node_modules/uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { - "uuid": "bin/uuid" + "uuid": "dist/bin/uuid" } }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", "engines": { "node": ">= 0.8" } }, - "node_modules/verror": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", - "engines": [ - "node >=0.6.0" - ], - "dependencies": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" + "node_modules/web-streams-polyfill": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.2.tgz", + "integrity": "sha512-3pRGuxRF5gpuZc0W+EpwQRmCD7gRqcDOMt688KmdlDAgAyaB1XlN0zq2njfDNm44XVdIouE7pZ6GzbdyH47uIQ==", + "engines": { + "node": ">= 8" } }, "node_modules/which": { @@ -2589,63 +5050,39 @@ "node": ">= 8" } }, - "node_modules/winston": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.7.2.tgz", - "integrity": "sha512-QziIqtojHBoyzUOdQvQiar1DH0Xp9nF1A1y7NVy2DGEsz82SBDtOalS0ulTRGVT14xPX3WRWkCsdcJKqNflKng==", - "dependencies": { - "@dabh/diagnostics": "^2.0.2", - "async": "^3.2.3", - "is-stream": "^2.0.0", - "logform": "^2.4.0", - "one-time": "^1.0.0", - "readable-stream": "^3.4.0", - "safe-stable-stringify": "^2.3.1", - "stack-trace": "0.0.x", - "triple-beam": "^1.3.0", - "winston-transport": "^4.5.0" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/winston-transport": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.5.0.tgz", - "integrity": "sha512-YpZzcUzBedhlTAfJg6vJDlyEai/IFMIVcaEZZyl3UXIl4gmqRpU7AE89AHLkbzLUsv0NVmw7ts+iztqKxxPW1Q==", + "node_modules/which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, "dependencies": { - "logform": "^2.3.2", - "readable-stream": "^3.6.0", - "triple-beam": "^1.3.0" + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" }, - "engines": { - "node": ">= 6.4.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/winston-transport/node_modules/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==", + "node_modules/which-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", + "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", + "dev": true, "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" + "available-typed-arrays": "^1.0.5", + "call-bind": "^1.0.4", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.0" }, "engines": { - "node": ">= 6" - } - }, - "node_modules/winston/node_modules/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==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" + "node": ">= 0.4" }, - "engines": { - "node": ">= 6" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/workerpool": { @@ -2674,7 +5111,12 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/xmlcreate": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/xmlcreate/-/xmlcreate-2.0.4.tgz", + "integrity": "sha512-nquOebG4sngPmGPICTS5EnxqhKbCmz5Ox5hsszI2T6U5qdrJizBc+0ilYSEjTSzU0yZcmvppztXe/5Al5fUwdg==", "dev": true }, "node_modules/y18n": { @@ -2686,6 +5128,11 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "node_modules/yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", diff --git a/package.json b/package.json index ae37edd..c4895bc 100644 --- a/package.json +++ b/package.json @@ -1,33 +1,48 @@ { "name": "bbb-webhooks", - "version": "2.6.1", + "version": "3.0.0", "description": "A BigBlueButton mudule for events WebHooks", + "type": "module", + "scripts": { + "start": "node app.js", + "dev-start": "nodemon --watch src --ext js,json,yml,yaml --exec node app.js", + "test": "LOG_LEVEL=silent ALL_TESTS=true ALLOW_CONFIG_MUTATIONS=true mocha --config=test/.mocharc.yml --exit", + "test:webhooks": "LOG_LEVEL=silent ALL_TESTS=false ALLOW_CONFIG_MUTATIONS=true WEBHOOKS_SUITE=true mocha --config=test/.mocharc.yml --exit", + "test:xapi": "LOG_LEVEL=silent ALL_TESTS=false ALLOW_CONFIG_MUTATIONS=true XAPI_SUITE=true mocha --config=test/.mocharc.yml --exit", + "lint": "./node_modules/.bin/eslint ./", + "lint:file": "./node_modules/.bin/eslint", + "jsdoc": "./node_modules/.bin/jsdoc app.js application.js src/ -r" + }, "keywords": [ "bigbluebutton", "webhooks" ], "dependencies": { - "async": "^3.2.3", + "bullmq": "4.17.0", "config": "^3.3.7", - "express": "^4.17.3", + "express": "^4.18.2", "js-yaml": "^4.1.0", - "lodash": "^4.17.21", - "redis": "^3.1.2", - "request": "^2.88.2", - "sha1": "^1.1.1", - "winston": "^3.7.2" + "luxon": "^3.4.3", + "node-fetch": "^3.3.2", + "pino": "^8.16.1", + "prom-client": "^14.2.0", + "redis": "^4.6.8", + "uuid": "^9.0.1" }, "engines": { - "node": ">=12 <=20" + "node": ">=18" }, "devDependencies": { - "body-parser": "^1.20.0", + "body-parser": "^1.20.2", + "eslint": "^8.49.0", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-jsdoc": "^46.8.2", + "fast-xml-parser": "^4.3.2", + "jsdoc": "^4.0.2", "mocha": "^9.2.2", - "nock": "^13.2.4", + "nodemon": "^3.0.1", + "pino-pretty": "^10.2.3", "sinon": "^12.0.1", "supertest": "^3.4.2" - }, - "scripts": { - "test": "mocha" } } diff --git a/responses.js b/responses.js deleted file mode 100644 index fd814d0..0000000 --- a/responses.js +++ /dev/null @@ -1,46 +0,0 @@ -responses = {}; -responses.failure = (key, msg) => - ` \ -FAILED \ -${key} \ -${msg} \ -` -; -responses.checksumError = - responses.failure("checksumError", "You did not pass the checksum security check."); - -responses.createSuccess = (id, permanent, getRaw) => - ` \ -SUCCESS \ -${id} \ -${permanent} \ -${getRaw} \ -` -; -responses.createFailure = - responses.failure("createHookError", "An error happened while creating your hook. Check the logs."); -responses.createDuplicated = id => - ` \ -SUCCESS \ -${id} \ -duplicateWarning \ -There is already a hook for this callback URL. \ -` -; - -responses.destroySuccess = - ` \ -SUCCESS \ -true \ -`; -responses.destroyFailure = - responses.failure("destroyHookError", "An error happened while removing your hook. Check the logs."); -responses.destroyNoHook = - responses.failure("destroyMissingHook", "The hook informed was not found."); - -responses.missingParamCallbackURL = - responses.failure("missingParamCallbackURL", "You must specify a callbackURL in the parameters."); -responses.missingParamHookID = - responses.failure("missingParamHookID", "You must specify a hookID in the parameters."); - -module.exports = responses; diff --git a/src/common/env.js b/src/common/env.js new file mode 100644 index 0000000..4808ddb --- /dev/null +++ b/src/common/env.js @@ -0,0 +1,5 @@ +process.env.NODE_CONFIG_DIR = `/etc/bigbluebutton/bbb-webhooks/:${process.cwd()}/config/`; +process.env.SUPPRESS_NO_CONFIG_WARNING = "true"; + +export const NODE_CONFIG_DIR = process.env.NODE_CONFIG_DIR; +export const SUPPRESS_NO_CONFIG_WARNING = process.env.SUPPRESS_NO_CONFIG_WARNING; diff --git a/src/common/logger.js b/src/common/logger.js new file mode 100644 index 0000000..5ed009d --- /dev/null +++ b/src/common/logger.js @@ -0,0 +1,135 @@ +import pino from 'pino'; +import config from 'config'; +const LOG_CONFIG = config.get('log') || {}; +const { + level: DEFAULT_LEVEL, + file: DEFAULT_USE_FILE, + filename: DEFAULT_FILENAME, + stdout: STDOUT = true, +} = LOG_CONFIG; + +/** + * @typedef {object} LoggerOptions + * @property {string} filename - the filename to log to + * @property {string} level - the maximum log level to use + * @property {boolean} stdout - whether to log to stdout + */ + +/** + * _newLogger. + * @private + * @param {LoggerOptions} options - the options to be used when creating the logger + * @returns {external:pino.Logger} a Pino logger instance + */ +const _newLogger = ({ + filename, + level, + stdout, +}) => { + const loggingTransports = []; + + if (DEFAULT_USE_FILE && filename) { + try { + loggingTransports.push({ + level, + target: 'pino/file', + options: { + destination: filename, + mkdir: true, + } + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error("Failed to create file transport, won't log to file", error); + } + } + + if (stdout) { + if (process.env.NODE_ENV !== 'production') { + // Development logging - fancier, more human readable stuff + loggingTransports.push({ + level, + target: 'pino-pretty', + colorize: true, + }); + } else { + // Production logging - regular stdout, no colors, no fancy stuff + loggingTransports.push({ + level, + target: 'pino/file', + options: { + destination: 1, + }, + }); + } + } + + const hooks = { + // Reverse the order of arguments for the log method - message comes first, + // then the rest of the arguments (object params, errors, ...) + logMethod (inputArgs, method) { + if (inputArgs.length >= 2) { + const arg1 = inputArgs.shift() + const arg2 = inputArgs.shift() + return method.apply(this, [arg2, arg1, ...inputArgs]) + } + return method.apply(this, inputArgs) + } + } + + const targets = pino.transport({ targets: loggingTransports }) ; + + targets.on('error', error => { + // eslint-disable-next-line no-console + console.error('CRITICAL logger failure: ', error.toString()); + + if (filename && error.toString().includes('ENOENT') || error.toString().includes('EACCES')) { + // eslint-disable-next-line no-console + console.error(`CRITICAL: failed to get log file ${filename}`); + process.exit(1); + } + }) + + const logger = pino({ + level, + hooks, + redact: ['config.server.secret'], + timestamp: pino.stdTimeFunctions.isoTime, + }, targets); + + return logger; +} + +const BASE_LOGGER = _newLogger({ + filename: DEFAULT_FILENAME, + level: DEFAULT_LEVEL, + stdout: STDOUT, +}); + +/** + * Creates a new logger with the specified label prepended to all messages + * @name newLogger + * @instance + * @function + * @public + * @param {string} label - the label to be prepended to the message + * @returns {BbbWebhooksLogger} the new logger + */ +const newLogger = (label) => { + return BASE_LOGGER.child({ mod: label }); +}; + +/** + * The default logger instance for bbb-webhooks (with label 'bbb-webhooks') + * @name logger + * @instance + * @public + * @type {BbbWebhooksLogger} + */ +const logger = newLogger('bbb-webhooks'); + +export default logger; + +export { + newLogger, +}; diff --git a/src/common/utils.js b/src/common/utils.js new file mode 100644 index 0000000..63df371 --- /dev/null +++ b/src/common/utils.js @@ -0,0 +1,6 @@ +const isEmpty = (obj) => [Object, Array].includes((obj || {}).constructor) + && !Object.entries((obj || {})).length; + +export default { + isEmpty, +}; diff --git a/src/db/redis/base-storage.js b/src/db/redis/base-storage.js new file mode 100644 index 0000000..f565769 --- /dev/null +++ b/src/db/redis/base-storage.js @@ -0,0 +1,319 @@ +import { newLogger } from '../../common/logger.js'; +import { v4 as uuidv4 } from 'uuid'; +import config from 'config'; + +const stringifyValues = (obj) => { + // Deep clone the object so we don't modify the original. + const cObj = config.util.cloneDeep(obj); + Object.keys(cObj).forEach(k => { + // Make all values strings, but ignore nullish/undefined values. + if (cObj[k] == null) { + delete cObj[k]; + } else if (typeof cObj[k] === 'object') { + cObj[k] = JSON.stringify(stringifyValues(cObj[k])); + } else { + cObj[k] = '' + cObj[k]; + } + }); + + return cObj; +} + +class StorageItem { + static stringifyValues = stringifyValues; + + constructor(client, prefix, setId, payload, { + id = uuidv4(), + alias, + serializer, + deserializer, + ...appOptions + }) { + this.client = client; + // Prefix and setId must be strings - convert + this.prefix = typeof prefix !== 'string' ? prefix.toString() : prefix; + this.setId = typeof setId !== 'string' ? setId.toString() : setId; + this.id = id; + this.alias = alias; + this.payload = config.util.cloneDeep(payload); + if (typeof serializer === 'function') this.serialize = serializer.bind(this); + if (typeof deserializer === 'function') this.deserialize = deserializer.bind(this); + this.appOptions = appOptions; + this.redisClient = client; + this.logger = newLogger(`db:${this.prefix}|${this.setId}`); + } + + _accessNested (obj, path) { + return path.split('.').reduce((o, i) => o[i], obj); + } + + async save() { + try { + await this.redisClient.hSet(this.prefix + ":" + this.id, this.serialize(this)); + } catch (error) { + this.logger.error('error saving mapping to redis', error); + throw error; + } + + try { + await this.redisClient.sAdd(this.setId, (this.id).toString()); + } catch (error) { + this.logger.error('error saving mapping ID to the list of mappings', error); + throw error; + } + + return true; + } + + async destroy() { + try { + await this.redisClient.sRem(this.setId, (this.id).toString()); + } catch (error) { + this.logger.error('error removing mapping ID from the list of mappings', error); + } + + try { + await this.redisClient.del(this.prefix + ":" + this.id); + } catch (error) { + this.logger.error('error removing mapping from redis', error); + } + + return true; + } + + serialize(data) { + const r = { + id: data.id, + ...data.payload, + }; + + const s = Object.entries(stringifyValues(r)).flat(); + return s; + } + + deserialize(data) { + return JSON.parse(data); + } + + print() { + return this.serialize(this); + } +} + +class StorageCompartmentKV { + static stringifyValues = stringifyValues; + + constructor (client, prefix, setId, { + itemClass = StorageItem, + aliasField, + serializer, + deserializer, + ...appOptions + } = {}) { + this.redisClient = client; + this.prefix = prefix; + this.setId = setId; + this.itemClass = itemClass; + this.localStorage = {} + this.aliasField = aliasField; + this.appOptions = appOptions; + if (typeof serializer === 'function') this.serialize = serializer.bind(this); + if (typeof deserializer === 'function') this.deserialize = deserializer.bind(this); + + this.logger = newLogger(`db:${this.prefix}|${this.setId}`); + } + + _accessNested (obj, path) { + return path.split('.').reduce((o, i) => o[i], obj); + } + + serialize(data) { + const r = { + id: data.id, + ...data.payload, + }; + + const s = Object.entries(stringifyValues(r)).flat(); + return s; + } + + deserialize(data) { + return data; + } + + async save(payload, { + id = uuidv4(), + alias, + }) { + if (alias == null) { + alias = payload[this.aliasField]; + } + + let mapping = new this.itemClass(this.redisClient, this.prefix, this.setId, payload, { + id, + alias, + serializer: this.serialize, + deserializer: this.deserialize, + ...this.appOptions, + }); + + await mapping.save(); + this.localStorage[mapping.id] = mapping; + if (mapping.alias) this.localStorage[mapping.alias] = mapping; + + return mapping; + } + + async find(id) { + return this.localStorage[id]; + } + + async destroy(id) { + const mapping = this.localStorage[id]; + if (mapping) { + await mapping.destroy(); + delete this.localStorage[id]; + if (mapping.alias && this.localStorage[mapping.alias]) { + delete this.localStorage[mapping.alias]; + } + return mapping; + } + + return false; + } + + count() { + return Object.keys(this.localStorage).length; + } + + async destroyWithField(field, value) { + return Promise.all( + Object.keys(this.localStorage).filter(internal => { + return this.localStorage[internal]?.payload + && this._accessNested(this.localStorage[internal].payload, field) === value; + }).map(internal => { + let mapping = this.localStorage[internal]; + + if (this._accessNested(mapping.payload, field) === value) { + return mapping.destroy() + .then(() => { + return mapping; + }) + .catch(() => { + return false; + }).finally(() => { + if (this.localStorage[mapping.id]) { + delete this.localStorage[mapping.id]; + } + + if (mapping.alias && this.localStorage[mapping.alias]) { + delete this.localStorage[mapping.alias]; + } + }); + } else { + return Promise.resolve(false); + } + }) + ); + } + + findAllWithField(field, value) { + const dupe = Object.keys(this.localStorage).filter(internal => { + return this.localStorage[internal]?.payload + && this._accessNested(this.localStorage[internal].payload, field) === value; + }).map(internal => { + return this.localStorage[internal]; + }); + + return [...new Set(dupe)]; + } + + findByField(field, value) { + if (field != null && value != null) { + for (let internal in this.localStorage) { + const mapping = this.localStorage[internal]; + if (mapping != null && this._accessNested(mapping.payload, field) === value) { + return mapping; + } + } + } + + return null; + } + + updateWithField(field, value, payload) { + if (field != null && value != null) { + for (let internal in this.localStorage) { + const mapping = this.localStorage[internal]; + if (mapping != null && this._accessNested(mapping.payload, field) === value) { + mapping.payload = config.util.extendDeep({}, mapping.payload, payload, 5); + return mapping.save(); + } + } + } + + return Promise.resolve(false); + } + + getAll() { + const allWithAliases = Object.keys(this.localStorage).reduce((arr, id) => { + if (this.localStorage[id] == null) return arr; + + arr.push(this.localStorage[id]); + return arr; + }, []); + + return [...new Set(allWithAliases)]; + } + + // Initializes global methods for this model. + async initialize() { + return this.resync() + } + + // Gets all mappings from redis to populate the local database. + async resync() { + try { + const mappings = await this.redisClient.sMembers(this.setId); + + this.logger.info(`starting resync, mappings registered: [${mappings}]`); + if (mappings != null && mappings.length > 0) { + return Promise.all(mappings.map(async (id) => { + try { + const data = await this.redisClient.hGetAll(this.prefix + ":" + id); + const { id: rId, ...payload } = this.deserialize(data); + + if (payload && Object.keys(payload).length > 0) { + await this.save(payload, { + id: rId, + alias: payload[this.aliasField], + itemClass: this.itemClass + }); + } + + return Promise.resolve(); + } catch (error) { + this.logger.error('error getting information for a mapping from redis', error); + return Promise.resolve(); + } + })).then(() => { + const stringifiedMappings = this.getAll().map(m => m.print()); + this.logger.info(`finished resync, mappings registered: [${stringifiedMappings}]`); + }).catch((error) => { + this.logger.error('error getting list of mappings from redis', error); + }); + } + + this.logger.info(`finished resync, no mappings registered`); + return Promise.resolve(); + } catch (error) { + this.logger.error('error getting list of mappings from redis', error); + return Promise.resolve(); + } + } +} + +export { + StorageItem, + StorageCompartmentKV, +}; diff --git a/src/db/redis/hooks.js b/src/db/redis/hooks.js new file mode 100644 index 0000000..ca1c309 --- /dev/null +++ b/src/db/redis/hooks.js @@ -0,0 +1,215 @@ +import { + v4 as uuidv4, + v5 as uuidv5, +} from 'uuid'; +import { StorageCompartmentKV } from './base-storage.js'; + +// The database of hooks. +// Used always from memory, but saved to redis for persistence. +// +// Format: +// { id: Hook } +// Format on redis: +// * a SET "...:hooks" with all ids +// * a HASH "...:hook:" for each hook with some of its attributes + +// The representation of a hook and its properties. Stored in memory and persisted +// to redis. +// Hooks can be global, receiving callback calls for events from all meetings on the +// server, or for a specific meeting. If an `externalMeetingID` is set in the hook, +// it will only receive calls related to this meeting, otherwise it will be global. +// faster than the callbacks are made. In this case the events will be concatenated +// and send up to 10 events in every post +class HookCompartment extends StorageCompartmentKV { + static itemDeserializer(data) { + // hookID destructuring is here for backwards compatibility with previous + // versions of the hooks app. + const { id: rID, hookID, ...payload } = data; + const parsedPayload = { + callbackURL: payload.callbackURL, + externalMeetingID: payload.externalMeetingID, + // Should be an array + eventID: payload.eventID?.split(','), + // Should be a boolean + permanent: payload.permanent === 'true', + // Should be a boolean + getRaw: payload.getRaw === 'true', + }; + + return { + id: rID || hookID, + ...parsedPayload, + }; + } + + constructor(client, prefix, setId, options = {}) { + super(client, prefix, setId, options); + } + + _buildPayload({ + callbackURL, + meetingID = null, + eventID = null, + permanent = false, + getRaw = false, + }) { + if (callbackURL == null) throw new Error('callbackURL is required'); + + return { + callbackURL, + externalMeetingID: meetingID, + eventID, + permanent, + getRaw, + } + } + + setOptions(options) { + this.permanentURLs = options.permanentURLs; + } + + getHook(id) { + return this.find(id); + } + + isGlobal(item) { + return item?.externalMeetingID == null; + } + + getExternalMeetingID(id) { + const hook = this.getHook(id); + return hook?.externalMeetingID; + } + + findByExternalMeetingID(externalMeetingID) { + return this.findByField('externalMeetingID', externalMeetingID); + } + + async addSubscription({ + id, + callbackURL, + meetingID, + eventID, + permanent, + getRaw, + }) { + const payload = this._buildPayload({ + callbackURL, + externalMeetingID: meetingID, + eventID: eventID?.toLowerCase().split(','), + permanent, + getRaw, + }); + + let hook = this.findByField('callbackURL', callbackURL); + + if (hook != null) { + return { + duplicated: true, + hook, + } + } + + this.logger.info(`adding a hook with callback URL: [${callbackURL}]`, { payload }); + const finalID = id || (permanent ? uuidv5(callbackURL, uuidv5.URL) : uuidv4()); + hook = await this.save(payload, { + id: finalID, + alias: callbackURL, + }); + + return { + duplicated: false, + hook, + } + } + + async removeSubscription(hookID) { + const hook = await this.getSync(hookID); + + if (hook != null && !hook.payload.permanent) { + let msg = `removing the hook with callback URL: [${hook.payload.callbackURL}],`; + if (hook.externalMeetingID != null) msg += ` for the meeting: [${hook.payload.externalMeetingID}]`; + this.logger.info(msg); + return this.destroy(hookID); + } + + return Promise.resolve(false); + } + + countSync() { + return this.count(); + } + + getSync(id) { + return this.find(id); + } + + getAllGlobalHooks() { + return this.getAll().filter(hook => this.isGlobal(hook)); + } + + // Gets all mappings from redis to populate the local database. + async resync() { + try { + const mappings = await this.redisClient.sMembers(this.setId); + + this.logger.info(`starting hooks resync, mappings registered: [${mappings}]`); + if (mappings != null && mappings.length > 0) { + for (let id of mappings) { + try { + const data = await this.redisClient.hGetAll(this.prefix + ":" + id); + const payloadWithID = this.deserialize(data); + + if (payloadWithID && Object.keys(payloadWithID).length > 0) { + await this.addSubscription(payloadWithID); + } + } catch (error) { + this.logger.error('error getting information for a mapping from redis', error); + } + } + + const stringifiedMappings = this.getAll().map(m => m.print()); + this.logger.info(`finished resync, mappings registered: [${stringifiedMappings}]`); + } else { + this.logger.info(`finished resync, no mappings registered`); + } + } catch (error) { + this.logger.error('error getting list of mappings from redis', error); + } + } + + // Initializes global methods for this model. + initialize() { + return this.resync(); + } +} + +let Hooks = null; + +const init = (redisClient, prefix, setId) => { + if (Hooks == null) { + Hooks = new HookCompartment( + redisClient, + prefix, + setId, { + deserializer: HookCompartment.itemDeserializer, + }, + ); + return Hooks.initialize(); + } + + return Promise.resolve(Hooks); +} + +const get = () => { + if (Hooks == null) { + throw new Error('Hooks not initialized'); + } + + return Hooks; +} + +export default { + init, + get, +} diff --git a/src/db/redis/id-mapping.js b/src/db/redis/id-mapping.js new file mode 100644 index 0000000..fbedb45 --- /dev/null +++ b/src/db/redis/id-mapping.js @@ -0,0 +1,133 @@ +import config from 'config'; +import { StorageItem, StorageCompartmentKV } from './base-storage.js'; +import UserMapping from './user-mapping.js'; +import { v4 as uuidv4 } from 'uuid'; + +// The database of mappings. Uses the internal ID as key because it is unique +// unlike the external ID. +// Used always from memory, but saved to redis for persistence. +// +// Format: +// { +// internalMeetingID: { +// id: @id +// externalMeetingID: @externalMeetingID +// internalMeetingID: @internalMeetingID +// lastActivity: @lastActivity +// } +// } +// Format on redis: +// * a SET "...:mappings" with all ids (not meeting ids, the object id) +// * a HASH "...:mapping:" for each mapping with all its attributes + +// A simple model to store mappings for meeting IDs. +class IDMappingCompartment extends StorageCompartmentKV { + constructor(client, prefix, setId) { + super(client, prefix, setId, { + aliasField: 'internalMeetingID', + }); + } + + async addOrUpdateMapping(internalMeetingID, externalMeetingID) { + const payload = { + internalMeetingID: internalMeetingID, + externalMeetingID: externalMeetingID, + lastActivity: new Date().getTime(), + }; + + const mapping = await this.save(payload, { + alias: internalMeetingID, + }); + this.logger.info(`added or changed meeting mapping to the list ${externalMeetingID}: ${mapping.print()}`); + + return mapping; + } + + async removeMapping(internalMeetingID) { + const result = await this.destroyWithField('internalMeetingID', internalMeetingID); + return result; + } + + getInternalMeetingID(externalMeetingID) { + const mapping = this.findByField('externalMeetingID', externalMeetingID); + return (mapping != null ? mapping.payload?.internalMeetingID : undefined); + } + + getExternalMeetingID(internalMeetingID) { + if (this.localStorage[internalMeetingID]) { + return this.localStorage[internalMeetingID].payload?.externalMeetingID; + } + } + + findByExternalMeetingID(externalMeetingID) { + return this.findByField('externalMeetingID', externalMeetingID); + } + + allSync() { + return this.getAll(); + } + + // Sets the last activity of the mapping for `internalMeetingID` to now. + reportActivity(internalMeetingID) { + let mapping = this.localStorage[internalMeetingID]; + if (mapping != null) { + mapping.payload.lastActivity = new Date().getTime(); + return mapping.save(); + } + } + + // Checks all current mappings for their last activity and removes the ones that + // are "expired", that had their last activity too long ago. + cleanup() { + const now = new Date().getTime(); + const all = this.getAll(); + const toRemove = all.filter(mapping => mapping?.payload.lastActivity < (now - config.get("mappings.timeout"))); + + if (toRemove && toRemove.length > 0) { + this.logger.info(`expiring the mappings: ${toRemove.map(map => map.print())}`); + toRemove.forEach(mapping => { + UserMapping.get().removeMappingWithMeetingId(mapping.payload.internalMeetingID).catch((error) => { + this.logger.error(`error removing user mapping for ${mapping.payload.internalMeetingID}`, error); + }).finally(() => { + this.destroy(mapping.id).catch((error) => { + this.logger.error(`error removing mapping for ${mapping.id}`, error); + }); + }); + }); + } + } + + // Initializes global methods for this model. + initialize() { + this.cleanupInterval = setInterval(this.cleanup.bind(this), config.get("mappings.cleanupInterval")); + return this.resync(); + } +} + +let IDMapping = null; + +const init = (redisClient) => { + if (IDMapping == null) { + IDMapping = new IDMappingCompartment( + redisClient, + config.get('redis.keys.mappingPrefix'), + config.get('redis.keys.mappings') + ); + return IDMapping.initialize(); + } + + return Promise.resolve(IDMapping); +} + +const get = () => { + if (IDMapping == null) { + throw new Error('IDMapping not initialized'); + } + + return IDMapping; +} + +export default { + init, + get, +} diff --git a/src/db/redis/index.js b/src/db/redis/index.js new file mode 100644 index 0000000..5142606 --- /dev/null +++ b/src/db/redis/index.js @@ -0,0 +1,125 @@ +import { createClient } from 'redis'; +import config from 'config'; +import HookCompartment from './hooks.js'; +import IDMappingC from './id-mapping.js'; +import UserMappingC from './user-mapping.js'; + +/* + * + * [MODULE_TYPES.db]: { + * load: 'function', + * unload: 'function', + * setContext: 'function', + * save: 'function', + * read: 'function', + * remove: 'function', + * clear: 'function', + * } + * + */ + +export default class RedisDB { + static type = "db"; + + constructor (context, config = {}) { + this.name = 'db-redis'; + this.type = RedisDB.type; + this.context = this.setContext(context); + this.config = config; + this.logger = context.getLogger(this.name); + this.loaded = false; + + this._redisClient = null; + } + + _onRedisError(error) { + this.logger.error("Redis client failure", error); + } + + async load() { + const { password, host, port } = this.config; + const redisUrl = `redis://${password ? `:${password}@` : ''}${host}:${port}`; + this._redisClient = createClient({ + url: redisUrl, + }); + this._redisClient.on('error', this._onRedisError.bind(this)); + this._redisClient.on('ready', () => this.logger.info('Redis client is ready')); + await this._redisClient.connect(); + await IDMappingC.init(this._redisClient); + await UserMappingC.init(this._redisClient); + await HookCompartment.init(this._redisClient, + config.get('redis.keys.hookPrefix'), + config.get('redis.keys.hooks'), + ); + } + + async unload() { + if (this._redisClient) { + await this._redisClient.disconnect(); + this._redisClient = null; + } + + this.loaded = false; + } + + setContext(context) { + this.context = context; + this.logger = context.getLogger(this.name); + } + + async save(key, value) { + if (!this.loaded) { + throw new Error('DB not loaded'); + } + + try { + await this._redisClient.set(key, JSON.stringify(value)); + return true; + } catch (error) { + this.logger.error(error); + return false; + } + } + + async read(key) { + if (!this.loaded) { + throw new Error('DB not loaded'); + } + + try { + const value = await this._redisClient.get(key); + return JSON.parse(value); + } catch (error) { + this.logger.error(error); + throw error; + } + } + + async remove(key) { + if (!this.loaded) { + throw new Error('DB not loaded'); + } + + try { + await this._redisClient.del(key); + return true; + } catch (error) { + this.logger.error(error); + return false; + } + } + + async clear() { + if (!this.loaded) { + throw new Error('DB not loaded'); + } + + try { + await this._redisClient.flushall(); + return true; + } catch (error) { + this.logger.error(error); + return false; + } + } +} diff --git a/src/db/redis/user-mapping.js b/src/db/redis/user-mapping.js new file mode 100644 index 0000000..88170d2 --- /dev/null +++ b/src/db/redis/user-mapping.js @@ -0,0 +1,123 @@ +import config from 'config'; +import { StorageCompartmentKV } from './base-storage.js'; + +class UserMappingCompartment extends StorageCompartmentKV { + static itemDeserializer(data) { + const { id, ...payload } = data; + const { user, ...rest } = payload; + const parsedPayload = { + ...rest, + user: JSON.parse(user), + }; + + return { + id, + ...parsedPayload, + }; + } + + constructor(client, prefix, setId, options = {}) { + super(client, prefix, setId, { + aliasField: 'internalUserID', + ...options, + }); + } + + async addOrUpdateMapping(internalUserID, externalUserID, meetingId, user) { + const payload = { + internalUserID, + externalUserID, + meetingId, + user, + }; + + const mapping = await this.save(payload, { + alias: internalUserID, + }); + this.logger.info(`added user mapping to the list ${internalUserID}: ${mapping.print()}`); + + return mapping; + } + + async removeMapping(internalUserID) { + const result = await this.destroyWithField('internalUserID', internalUserID); + return result; + } + + async removeMappingWithMeetingId(meetingId) { + const result = await this.destroyWithField('meetingId', meetingId); + return result; + } + + async getInternalMeetingID(externalMeetingID) { + const mapping = await this.findByField('externalMeetingID', externalMeetingID); + return (mapping != null ? mapping.payload?.internalMeetingID : undefined); + } + + getUsersFromMeeting(internalMeetingID) { + const mappings = this.findAllWithField('meetingId', internalMeetingID); + + return mappings != null ? mappings.map((mapping) => mapping.payload) : []; + } + + getMeetingPresenter (internalMeetingID) { + const mappings = this.getUsersFromMeeting(internalMeetingID); + + return mappings.find((mapping) => + mapping?.user?.presenter === true || mapping?.user?.presenter === 'true' + ); + } + + getMeetingScreenShareOwner (internalMeetingID) { + const mappings = this.getUsersFromMeeting(internalMeetingID); + + return mappings.find((mapping) => + mapping?.user?.screenshare === true || mapping?.user?.screenshare === 'true' + ); + } + + getUser(internalUserID) { + const mapping = this.findByField('internalUserID', internalUserID); + return (mapping != null ? mapping.payload?.user : undefined); + } + + getExternalUserID(internalUserID) { + const mapping = this.findByField('internalUserID', internalUserID); + return (mapping != null ? mapping.payload?.externalUserID : undefined); + } + + // Initializes global methods for this model. + initialize() { + return this.resync(); + } +} + +let UserMapping = null; + +const init = (redisClient) => { + if (UserMapping == null) { + UserMapping = new UserMappingCompartment( + redisClient, + config.get('redis.keys.userMapPrefix'), + config.get('redis.keys.userMaps'), { + deserializer: UserMappingCompartment.itemDeserializer, + } + ); + return UserMapping.initialize(); + } + + return Promise.resolve(UserMapping); +} + +const get = () => { + if (UserMapping == null) { + throw new Error('UserMapping not initialized'); + } + + return UserMapping; +} + +export default { + init, + get, +} diff --git a/src/in/redis/index.js b/src/in/redis/index.js new file mode 100644 index 0000000..3af4918 --- /dev/null +++ b/src/in/redis/index.js @@ -0,0 +1,118 @@ +import { createClient } from 'redis'; +import Utils from '../../common/utils.js'; + +/* + * [MODULE_TYPES.in]: { + * load: 'function', + * unload: 'function', + * setContext: 'function', + * setCollector: 'function', + * }, + * + */ + +export default class InRedis { + static type = "in"; + + static _defaultCollector () { + throw new Error('Collector not set'); + } + + constructor (context, config = {}) { + this.type = InRedis.type; + this.config = config; + this.setContext(context); + + this.pubsub = null; + } + + _validateConfig () { + if (this.config == null) { + throw new Error("config not set"); + } + + if (this.config.redis == null) { + throw new Error("config.redis not set"); + } + + if (this.config.redis.host == null) { + throw new Error("config.host not set"); + } + + if (this.config.redis.port == null) { + throw new Error("config.port not set"); + } + + if (this.config.redis.inboundChannels == null || this.config.redis.inboundChannels.length == 0) { + throw new Error("config.inboundChannels not set"); + } + + return true; + } + + _onPubsubEvent(message, channel) { + this.logger.trace('Received message on pubsub', { message }); + + try { + const event = JSON.parse(message); + + if (Utils.isEmpty(event)) return; + + this._collector(event); + } catch (error) { + this.logger.error(`Error processing message on [${channel}]: ${error}`); + } + } + + _subscribeToEvents() { + if (this.pubsub == null) { + throw new Error("pubsub not initialized"); + } + + return Promise.all( + this.config.redis.inboundChannels.map((channel) => { + return this.pubsub.subscribe(channel, this._onPubsubEvent.bind(this)) + .then(() => this.logger.info(`subscribed to: ${channel}`)) + .catch((error) => this.logger.error(`error subscribing to: ${channel}: ${error}`)); + }) + ); + } + + _onRedisError(error) { + this.logger.error("Redis client failure", error); + } + + async load () { + if (this._validateConfig()) { + const { password, host, port } = this.config.redis; + const redisUrl = `redis://${password ? `:${password}@` : ''}${host}:${port}`; + this.pubsub = createClient({ + url: redisUrl, + }); + this.pubsub.on('error', this._onRedisError.bind(this)); + this.pubsub.on('ready', () => this.logger.info('Redis client is ready')); + await this.pubsub.connect(); + await this._subscribeToEvents(); + } + } + + async unload () { + if (this.pubsub != null) { + await this.pubsub.disconnect(); + this.pubsub = null; + } + + this.setCollector(InRedis._defaultCollector); + } + + setContext (context) { + this.context = context; + this.logger = context.getLogger(); + + return context; + } + + async setCollector (event) { + this._collector = event; + } +} diff --git a/src/metrics/http-server.js b/src/metrics/http-server.js new file mode 100644 index 0000000..c9ace6e --- /dev/null +++ b/src/metrics/http-server.js @@ -0,0 +1,87 @@ +import http from 'node:http'; + +/** + * HttpServer. + */ +class HttpServer { + /** + * constructor. + * @public + * @param {string} host - HTTP server host to bind to + * @param {number} port - HTTP server port to bind to + * @param {Function} callback - callback function to be called on each request + * @param {object} options - options object + * @param {object} options.logger - logger object + * @returns {HttpServer} - HttpServer object + */ + constructor(host, port, callback, { + logger = console, + } = {}) { + this.host = host; + this.port = port; + this.requestCallback = callback; + this.logger = logger; + } + + /** + * start - creates the HTTP server (without listening). + * @public + */ + start () { + this.server = http.createServer(this.requestCallback) + .on('error', this._handleError.bind(this)) + .on('clientError', this._handleError.bind(this)); + } + + /** + * close - closes the HTTP server. + * @public + * @param {Function} callback - callback function to be called on close + * @returns {http.Server} - server object + */ + close (callback) { + return this.server.close(callback); + } + + /** + * handleError - handles HTTP server 'error' and 'clientError' events. + * @private + * @param {Error} error - error object + */ + _handleError (error) { + if (error.code === 'EADDRINUSE') { + this.logger.warn("EADDRINUSE, won't spawn HTTP server", { + host: this.host, port: this.port, + }); + this.server.close(); + } else if (error.code === 'ECONNRESET') { + this.logger.warn("HTTPServer: ECONNRESET ", { errorMessage: error.message }); + } else { + this.logger.error("Returned error", error); + } + + throw error; + } + + /** + * getServerObject - returns the HTTP server object. + * @public + * @returns {http.Server} - server object + */ + getServerObject() { + return this.server; + } + + /** + * listen - starts listening on the HTTP server at the specified host and port. + * @public + * @param {Function} callback - callback function to be called on listen + * @returns {http.Server} - server object + */ + listen(callback) { + this.logger.info(`HTTPServer is listening: ${this.host}:${this.port}`); + return this.server.listen(this.port, this.host, callback); + } +} + +export default HttpServer; diff --git a/src/metrics/index.js b/src/metrics/index.js new file mode 100644 index 0000000..cc1d64d --- /dev/null +++ b/src/metrics/index.js @@ -0,0 +1,133 @@ +import config from 'config'; +import PrometheusAgent from './prometheus-agent.js'; +import { + Counter, + Gauge, + Histogram, + Summary, + register, +} from 'prom-client'; +import { newLogger } from '../common/logger.js'; + +const logger = newLogger('prometheus'); + +const { + enabled: METRICS_ENABLED = false, + host: METRICS_HOST = '127.0.0.1', + port: METRICS_PORT = '3004', + path: METRICS_PATH = '/metrics', + collectDefaultMetrics: COLLECT_DEFAULT_METRICS, +} = config.has('prometheus') ? config.get('prometheus') : { enabled: false }; + +const PREFIX = 'bbb_webhooks_'; +const METRIC_NAMES = { + OUTPUT_QUEUE_SIZE: `${PREFIX}output_queue_size`, + MODULE_STATUS: `${PREFIX}module_status`, + EVENT_PROCESS_FAILURES: `${PREFIX}event_process_failures`, + EVENT_DISPATCH_FAILURES: `${PREFIX}event_dispatch_failures`, +} + +let METRICS = {} +let AGENT; + +/** + * injectMetrics - Inject a metrics dictionary into the Prometheus agent. + * @param {object} metricsDictionary - Metrics dictionary (key: metric name, value: prom-client metric object) + * @param {object} options - Options object + * @param {PrometheusAgent} options.agent - Prometheus agent to inject the metrics into + * If not specified, the default agent will be used + * @returns {boolean} - True if metrics were injected, false otherwise + * @public + * @memberof module:exporter + */ +const injectMetrics = (metricsDictionary, { + agent = AGENT, +} = {}) => { + agent.injectMetrics(metricsDictionary); + return true; +} + +/** + * buildDefaultMetrics - Build the default metrics dictionary. + * @returns {object} - Metrics dictionary (key: metric name, value: prom-client metric object) + * @private + * @memberof module:exporter + */ +const buildDefaultMetrics = () => { + if (METRICS == null || Object.keys(METRICS).length === 0) { + METRICS = { + [METRIC_NAMES.MODULE_STATUS]: new Gauge({ + name: METRIC_NAMES.MODULE_STATUS, + help: 'Status of each module', + labelNames: ['module', 'moduleType'], + }), + + // TODO to be implemented + //[METRIC_NAMES.OUTPUT_QUEUE_SIZE]: new Gauge({ + // name: METRIC_NAMES.OUTPUT_QUEUE_SIZE, + // help: 'Event queue size for each output module', + // labelNames: ['module'], + //}), + + [METRIC_NAMES.EVENT_PROCESS_FAILURES]: new Counter({ + name: METRIC_NAMES.EVENT_PROCESS_FAILURES, + help: 'Number of event processing failures', + }), + + [METRIC_NAMES.EVENT_DISPATCH_FAILURES]: new Counter({ + name: METRIC_NAMES.EVENT_DISPATCH_FAILURES, + help: 'Number of event dispatch failures', + labelNames: ['outputEventId', 'module'], + }), + } + } + + return METRICS; +}; + +AGENT = new PrometheusAgent(METRICS_HOST, METRICS_PORT, { + path: METRICS_PATH, + prefix: PREFIX, + collectDefaultMetrics: COLLECT_DEFAULT_METRICS, + logger, +}); + +if (METRICS_ENABLED && injectMetrics(buildDefaultMetrics())) { + AGENT.start(); +} + +/** + * @module exporter + * @typedef {object} MetricsExporter + * @property {boolean} METRICS_ENABLED - Whether the exporter is enabled or not + * @property {object} METRIC_NAMES - Indexed metric names + * @property {object} METRICS - Active metrics dictionary (key: metric name, value: prom-client metric object) + * @property {Function} Counter - prom-client Counter class + * @property {Function} Gauge - prom-client Gauge class + * @property {Function} Histogram - prom-client Histogram class + * @property {Function} Summary - prom-client Summary class + * @property {Function} register - Register a new metric with the Prometheus agent + * @property {Function} injectMetrics - Inject a new metrics dictionary into the Prometheus agent + * Merges with the existing dictionary + * @property {PrometheusAgent} agent - Prometheus agent + */ + +/** + * Metrics exporter util singleton object. + * @type {MetricsExporter} + * @public + * @memberof module:exporter + */ +export default { + METRICS_ENABLED, + METRIC_NAMES, + METRICS, + Counter, + Gauge, + Histogram, + Summary, + register, + injectMetrics, + agent: AGENT, +}; + diff --git a/src/metrics/prometheus-agent.js b/src/metrics/prometheus-agent.js new file mode 100644 index 0000000..30c23fc --- /dev/null +++ b/src/metrics/prometheus-agent.js @@ -0,0 +1,214 @@ +import promclient from 'prom-client'; +import HttpServer from './http-server.js'; + +/** + * PrometheusScrapeAgent. + * @class + */ +class PrometheusScrapeAgent { + /** + * constructor. + * @param {string} host - Host to bind to for the metrics HTTP server + * @param {number} port - Port to bind to for the metrics HTTP server + * @param {object} options - Options object + * @param {string} options.path - Path to expose metrics on + * @param {string} options.prefix - Prefix to add to all metrics + * @param {number} options.collectionTimeout - Timeout for collecting metrics + * @param {boolean} options.collectDefaultMetrics - Whether to collect prom-client default metrics + * @param {object} options.logger - Logger object + * @returns {PrometheusScrapeAgent} - PrometheusScrapeAgent object + */ + constructor (host, port, options = {}) { + this.host = host; + this.port = port; + this.metrics = {}; + this.started = false; + + this.path = options.path || '/metrics'; + this.metricsPrefix = options.prefix || ''; + this.collectionTimeout = options.collectionTimeout || 10000; + this.collectDefaultMetrics = options.collectDefaultMetrics || false; + this.logger = options.logger || console; + } + + /** + * getMetric - Get a metric by name. + * @param {string} name - Name of the metric + * @returns {promclient.Metric} - Metric object + */ + getMetric (name) { + return this.metrics[name]; + } + + /** + * _collect - Collect metrics and expose them on the metrics HTTP server. + * @private + * @param {http.ServerResponse} response - HTTP server response to write metrics to + * @async + */ + async _collect (response) { + try { + const _response = await this.collect(response); + _response.writeHead(200, { 'Content-Type': promclient.register.contentType }); + const content = await promclient.register.metrics(); + _response.end(content); + } catch (error) { + response.writeHead(500) + response.end(error.message); + this.logger.error('Prometheus: error collecting metrics', + { errorCode: error.code, errorMessage: error.message }); + } + } + + /** + * collect - Override this method to add a custom collector. + * @param {http.ServerResponse} response - HTTP response + * @returns {Promise} - Promise object + * @async + * @abstract + */ + collect (response) { + return Promise.resolve(response); + } + + /** + * defaultMetricsHandler - Default request handler for metrics HTTP server. + * @param {http.IncomingMessage} request - HTTP request + * @param {http.ServerResponse} response - HTTP response + */ + defaultMetricsHandler (request, response) { + switch (request.method) { + case 'GET': + if (request.url === this.path) { + this._collect(response); + return; + } + response.writeHead(404).end(); + break; + default: + response.writeHead(501) + response.end(); + break; + } + } + + /** + * start - Start the metrics HTTP server. + * @param {Function} requestHandler - Request handler for metrics HTTP server + */ + start (requestHandler = this.defaultMetricsHandler.bind(this)) { + if (this.collectDefaultMetrics) promclient.collectDefaultMetrics({ + prefix: this.metricsPrefix, + timeout: this.collectionTimeout, + }); + + this.metricsServer = new HttpServer(this.host, this.port, requestHandler, { + logger: this.logger, + }); + this.metricsServer.start(); + this.metricsServer.listen(); + this.started = true; + } + + /** + * injectMetrics - Inject new metrics into the metrics dictionary. + * @param {object} metricsDictionary - Metrics dictionary + */ + injectMetrics (metricsDictionary) { + this.metrics = { ...this.metrics, ...metricsDictionary } + } + + /** + * increment - Increment a metric COUNTER + * @param {string} metricName - Name of the metric + * @param {object} labelsObject - An object containing labels and their values for the metric + */ + increment (metricName, labelsObject) { + if (!this.started) return; + + const metric = this.metrics[metricName]; + if (metric) { + metric.inc(labelsObject) + } + } + + /** + * decrement - Decrement a metric COUNTER + * @param {string} metricName - Name of the metric + * @param {object} labelsObject - An object containing labels and their values for the metric + */ + decrement (metricName, labelsObject) { + if (!this.started) return; + + const metric = this.metrics[metricName]; + if (metric) { + metric.dec(labelsObject) + } + } + + /** + * set - Set a metric GAUGE to a value + * @param {string} metricName - Name of the metric + * @param {number} value - Value to set the metric to + * @param {object} labelsObject - An object containing labels and their values for the metric + */ + set (metricName, value, labelsObject = {}) { + if (!this.started) return; + + const metric = this.metrics[metricName]; + if (metric) { + metric.set(labelsObject, value) + } + } + + /** + * setCollectorWithGenerator - Override a specific metric's collector with a generator function. + * @param {string} metricName - Name of the metric + * @param {Function} generator - Generator function to be called on each collect + */ + setCollectorWithGenerator (metricName, generator) { + const metric = this.getMetric(metricName); + if (metric) { + /** + * metric.collect. + */ + metric.collect = () => { + metric.set(generator()); + }; + } + } + + /** + * setCollector - Override a specific metric's collector with a custom collector. + * @param {string} metricName - Name of the metric + * @param {Function} collector - Custom collector function to be called on each collect + */ + setCollector (metricName, collector) { + const metric = this.getMetric(metricName); + + if (metric) { + metric.collect = collector.bind(metric); + } + } + + /** + * reset - Reset metrics values. Resets all metrics if no metric name is provided. + * @param {string[]} metrics - Array of metric names to reset + */ + reset (metrics = []) { + if (metrics == null || metrics.length === 0) { + promclient.register.resetMetrics(); + return; + } + + metrics.forEach(metricName => { + const metric = this.getMetric(metricName); + + if (metric) { + metric.reset(metricName); + } + }); + } +} + +export default PrometheusScrapeAgent; diff --git a/src/modules/context.js b/src/modules/context.js new file mode 100644 index 0000000..72896ee --- /dev/null +++ b/src/modules/context.js @@ -0,0 +1,92 @@ +import { newLogger } from '../common/logger.js'; +import config from 'config'; +import { StorageCompartmentKV, StorageItem } from '../db/redis/base-storage.js'; + +/** + * Context. + * @class + * @classdesc Context class containing utility functions and objects for submodules + * of the application. + * @property {string} name - Name of the context. + * @property {object} configuration - Configuration object. + * @property {Map} _loggers - Map of loggers. + */ +class Context { + /** + * constructor. + * @param {object} configuration - Context configuration data + * @param {string} configuration.name - Submodule name + * @param {object} configuration.logger - Logger object + * @param {object} configuration.config - Submodule-specific configuration + * @param {object} utils - Utility functions and objects + * @param {MetricsExporter} utils.exporter - Metrics exporter + */ + constructor(configuration, utils = {}) { + this.name = configuration.name; + this.configuration = config.util.cloneDeep(configuration); + this.utils = utils; + this._loggers = new Map(); + } + + /** + * getLogger - Get a new logger with the given label and append it to the + * context's logger map. + * @param {string} label - Label for the logger + * @returns {BbbWebhooksLogger} - Logger object + */ + getLogger(label = this.name) { + if (!this._loggers.has(label)) { + this._loggers.set(label, newLogger(label)); + } + + return this._loggers.get(label); + } + + /** + * destroy - Destroy the context. + * @public + * @returns {void} + */ + destroy () { + this._loggers.clear(); + } + + /** + * keyValueCompartmentConstructor - Return a key-value compartment util class. + * @returns {StorageCompartmentKV} - StorageCompartmentKV class + * @public + */ + keyValueCompartmentConstructor () { + return StorageCompartmentKV; + } + + /** + * keyValueItemConstructor - Return a key-value item util class. + * @returns {StorageItem} - StorageItem class + * @public + */ + keyValueItemConstructor () { + return StorageItem; + } + + /** + * keyValueStorageFactory - Return a key-value storage instance + * @param {string} prefix - Prefix for the storage compartment (namespace) + * @param {string} setId - Redis SET ID for the storage compartment + * @param {object} options - Options object + * @param {StorageItem} options.itemClass - Storage item class + * @param {string} options.aliasField - Item field to use as index (alias) + * @returns {StorageCompartmentKV} - StorageCompartmentKV instance + */ + keyValueStorageFactory (prefix, setId, { + itemClass = StorageItem, + aliasField, + } = {}) { + return new StorageCompartmentKV(this.redisClient, prefix, setId, { + itemClass, + aliasField, + }); + } +} + +export default Context; diff --git a/src/modules/definitions.js b/src/modules/definitions.js new file mode 100644 index 0000000..b88c60d --- /dev/null +++ b/src/modules/definitions.js @@ -0,0 +1,70 @@ +export const MODULE_TYPES = { + in: 'in', + out: 'out', + db: 'db', +}; + +export const MODULE_DEFINITION_SCHEMA = { + [MODULE_TYPES.in]: { + type: 'string', + load: 'function', + unload: 'function', + setContext: 'function', + setCollector: 'function', + }, + [MODULE_TYPES.out]: { + type: 'string', + load: 'function', + unload: 'function', + setContext: 'function', + onEvent: 'function', + }, + [MODULE_TYPES.db]: { + type: 'string', + load: 'function', + unload: 'function', + setContext: 'function', + }, +} + +export function validateModuleDefinition(module) { + if (!module.type || !MODULE_TYPES[module.type]) { + throw new Error('Module spec must be one of in | out | db'); + } + + Object.keys(MODULE_DEFINITION_SCHEMA[module.type]).forEach((key) => { + if (typeof module[key] !== MODULE_DEFINITION_SCHEMA[module.type][key]) { + throw new Error(`Module spec must have ${key} of type ${MODULE_DEFINITION_SCHEMA[module.type][key]}`); + } + }); + + return true; +} + +export function validateModuleConf (conf) { + if (!conf.name) { + throw new Error('Module spec must have a name'); + } + + if (!conf.type) { + throw new Error('Module spec must have a type'); + } + + if (!MODULE_TYPES[conf.type]) { + throw new Error(`Module spec has invalid type ${conf.type}`); + } + + return true; +} + +export function validateModulesConf(conf) { + if (typeof conf !== 'object') { + throw new Error('Module spec must be an object'); + } + + if (Object.keys(conf).length === 0) { + throw new Error('Module spec must have at least one element'); + } + + return true; +} diff --git a/src/modules/index.js b/src/modules/index.js new file mode 100644 index 0000000..8ffac0d --- /dev/null +++ b/src/modules/index.js @@ -0,0 +1,199 @@ +'use strict'; + +import config from 'config'; +import { newLogger } from '../common/logger.js'; +import ModuleWrapper from './module-wrapper.js'; +import Context from './context.js'; +import { + MODULE_TYPES, + validateModulesConf, + validateModuleConf +} from './definitions.js'; +import Exporter from '../metrics/index.js'; + +const UNEXPECTED_TERMINATION_SIGNALS = ['SIGABRT', 'SIGBUS', 'SIGSEGV', 'SIGILL']; +const REDIS_CONF = { + host: config.get('redis.host'), + port: config.get('redis.port'), + password: config.has('redis.password') ? config.get('redis.password') : undefined, +} +REDIS_CONF.REDIS_URL = `redis://${REDIS_CONF.password ? `:${REDIS_CONF.password}@` : ''}${REDIS_CONF.host}:${REDIS_CONF.port}`; +const BASE_CONFIGURATION = Object.freeze({ + server: { + domain: config.get('bbb.serverDomain'), + secret: config.get('bbb.sharedSecret'), + auth2_0: config.get('bbb.auth2_0'), + }, + redis: { + host: REDIS_CONF.host, + port: REDIS_CONF.port, + password: REDIS_CONF.password, + url: REDIS_CONF.REDIS_URL, + }, +}); + +export default class ModuleManager { + static moduleTypes = MODULE_TYPES; + + static flattenModulesConfig(config) { + // A configuration entry can either be an object (single module) or an array + // (multiple modules of different types, eg input and output) + // Need to flatten those into a single array of [name, description] tuples + // so we can sort them by priority + return Object.entries(config).flatMap(([name, description]) => { + if (Array.isArray(description)) { + return description.map((d) => [name, d]); + } else { + return [[name, description]]; + } + }); + } + + constructor(modulesConfig) { + this.modulesConfig = ModuleManager.flattenModulesConfig(modulesConfig); + this.modules = {}; + validateModulesConf(this.modulesConfig); + this.logger = newLogger('module-manager'); + } + + _buildContext(configuration) { + const base = config.util.cloneDeep(BASE_CONFIGURATION); + const extended = config.util.extendDeep(base, configuration.config, 8); + configuration.config = extended; + const utils = { + exporter: Exporter, + }; + + return new Context(configuration, utils); + } + + getModulesByType(type) { + return Object.values(this.modules).filter((module) => module.type === type); + } + + getInputModules() { + return this.getModulesByType(ModuleManager.moduleTypes.in); + } + + getOutputModules() { + return this.getModulesByType(ModuleManager.moduleTypes.out); + } + + getDBModules() { + return this.getModulesByType(ModuleManager.moduleTypes.db); + } + + _sortModulesByPriority(a, b) { + // Sort modules by priority: db modules first, then output modules, then input modules + const aD = a[1] + const bD = b[1] + + if (aD.type === ModuleManager.moduleTypes.db && bD.type !== ModuleManager.moduleTypes.db) { + return -1; + } else if (aD.type !== ModuleManager.moduleTypes.db && bD.type === ModuleManager.moduleTypes.db) { + return 1; + } else if (aD.type === ModuleManager.moduleTypes.out && bD.type !== ModuleManager.moduleTypes.out) { + return -1; + } else if (aD.type !== ModuleManager.moduleTypes.out && bD.type === ModuleManager.moduleTypes.out) { + return 1; + } else { + return 0; + } + } + + async load() { + const sortedModules = this.modulesConfig.sort(this._sortModulesByPriority); + + for (const [name, description] of sortedModules) { + try { + const fullConfiguration = { name, ...description }; + + if (description.enabled === false) { + this.logger.warn(`module ${name} disabled, skipping`); + continue; + } + + validateModuleConf(fullConfiguration); + const context = this._buildContext(fullConfiguration); + const module = new ModuleWrapper(name, description.type, context, context.configuration.config); + await module.load() + this.modules[module.id] = module; + this.logger.info(`module ${name} loaded`); + + Exporter.agent.set( + Exporter.METRIC_NAMES.MODULE_STATUS, + 1, + { module: name, moduleType: description.type }, + ); + } catch (error) { + this.logger.error(`failed to load module ${name}`, error); + Exporter.agent.set( + Exporter.METRIC_NAMES.MODULE_STATUS, + 0, + { module: name, moduleType: description.type }, + ); + } + } + + process.on('SIGTERM', async () => { + await this.stopModules(); + process.exit(0); + }); + + process.on('SIGINT', async () => { + await this.stopModules(); + process.exit(0); + }); + + process.on('uncaughtException', async (error) => { + this.logger.error("CRITICAL: uncaught exception, shutdown", { error: error.stack }); + await this.stopModules(); + if (process.env.NODE_ENV !== 'production') process.exit(1); + }); + + // Added this listener to identify unhandled promises, but we should start making + // sense of those as we find them + process.on('unhandledRejection', (reason) => { + this.logger.error("CRITICAL: Unhandled promise rejection", { + reason: reason.toString(), stack: reason.stack, + }); + if (process.env.NODE_ENV !== 'production') process.exit(1); + }); + + return { + inputModules: this.getInputModules(), + outputModules: this.getOutputModules(), + dbModules: this.getDBModules(), + }; + } + + trackModuleShutdown (proc) { + // Tries to restart process on unsucessful exit + proc.process.on('exit', (code, signal) => { + const shouldRestart = this.runningState === 'RUNNING' + && (code === 1 || UNEXPECTED_TERMINATION_SIGNALS.includes(signal)); + if (shouldRestart) { + this.logger.error("received exit event from child process, restarting it", + { code, signal, pid: proc.process.pid, process: proc.path }); + proc.restart(); + } else { + this.logger.warn("received final exit event from child process, process shutdown", + { code, signal, pid: proc.process.pid, process: proc.path }); + proc.stop(); + } + }); + } + + async stopModules () { + this.runningState = "STOPPING"; + + for (var proc in this.modules) { + if (Object.prototype.hasOwnProperty.call(this.modules, proc)) { + let procObj = this.modules[proc]; + if (typeof procObj.unload === 'function') procObj.unload(); + } + } + + this.runningState = "STOPPED"; + } +} diff --git a/src/modules/module-wrapper.js b/src/modules/module-wrapper.js new file mode 100644 index 0000000..b98d6f1 --- /dev/null +++ b/src/modules/module-wrapper.js @@ -0,0 +1,322 @@ +'use strict'; + +import EventEmitter from 'events'; +import { newLogger } from '../common/logger.js'; +import { MODULE_TYPES, validateModuleDefinition } from './definitions.js'; +import { createQueue, getQueue, deleteQueue } from './queue.js'; + +// [MODULE_TYPES.INPUT]: { +// load: 'function', +// setContext: 'function', +// setCollector: 'function', +// }, +// [MODULE_TYPES.OUTPUT]: { +// load: 'function', +// setContext: 'function', +// onEvent: 'function', +// }, +// [MODULE_TYPES.DB]: { +// load: 'function', +// setContext: 'function', +// create: 'function', +// read: 'function', +// update: 'function', +// delete: 'function', +// }, + +/** + * ModuleWrapper. + * @augments {EventEmitter} + * @class + */ +class ModuleWrapper extends EventEmitter { + /** + * _defaultCollector - Default event colletion function. + * @static + * @private + * @throws {Error} - Error thrown if the default collector is called + * @memberof ModuleWrapper + */ + static _defaultCollector () { + throw new Error('Collector not set'); + } + + /** + * constructor. + * @param {string} name - Module name + * @param {string} type - Module type + * @param {Context} context - Context object + * @param {object} config - Module-specific configuration + * @constructs ModuleWrapper + * @augments {EventEmitter} + */ + constructor (name, type, context, config = {}) { + super(); + this.name = name; + this.type = type; + this.id = `${name}-${type}`; + this.context = context; + this.config = config; + this.logger = newLogger('module-wrapper'); + // FIXME This is logging sensitive data, so we need to redact it + this.logger.debug(`created module wrapper for ${name}`, { type, config }); + + this._module = null; + this._queue = null; + this._worker = null; + } + + set config(config) { + if (config.queue == null) { + config.queue = { + enabled: false, + }; + } + + // Configuration enrichment - extend with defaults if some specific things + // are not set (eg redis host/port for queues) + if (config.queue?.enabled) { + if (!config.queue.host) { + config.queue.host = config.redis.host; + } + + if (!config.queue.port) { + config.queue.port = config.redis.port; + } + + if (!config.queue.password) { + config.queue.password = config.redis.password; + } + } + + this._config = config; + } + + get config() { + return this._config; + } + + set type(type) { + if (this._module) { + throw new Error(`module ${this.name} already loaded`); + } + + this._type = type; + } + + get type() { + return this._module?.type || this._type; + } + + /** + * _getQueueId - Get the queue ID for the module. + * @private + * @returns {string} - Queue ID + * @memberof ModuleWrapper + */ + _getQueueId() { + return this.config.queue.id || `${this.name}-out-queue`; + } + + /** + * _setupOutboundQueues - Setup outbound queues for the module only if the + * module is an output module and the queue is enabled. + * @private + * @memberof ModuleWrapper + */ + _setupOutboundQueues() { + if (this.type !== MODULE_TYPES.out) return; + + if (this.config.queue.enabled) { + this.logger.debug(`setting up outbound queues for module ${this.name}`, this.config.queue); + const queueId = this._getQueueId(); + const processor = async (job) => { + if (job.name !== 'event') { + this.logger.error(`job ${job.name}:${job.id} is not an event`); + return; + } + const { event, raw } = job.data; + await this._onEvent(event, raw); + }; + + const { queue, worker } = createQueue(queueId, processor, { + host: this.config.queue.host, + port: this.config.queue.port, + password: this.config.queue.password, + concurrency: this.config.queue.concurrency || 1, + limiter: this.config.queue.limiter || undefined, + }); + + this._queue = queue; + this._worker = worker; + this._worker.on('failed', (job, error) => { + if (job.name !== 'event') { + this.logger.error(`job ${job.name}:${job.id} failed`, error); + this.emit('eventDispatchFailed', { event: job.data?.event, raw: job.data?.raw, error }); + } + }); + this._worker.on('error', (error) => { + this.logger.error(`worker for queue ${queueId} received error`, error); + }); + this.logger.info(`created queue ${queueId} for module ${this.name}`, { + queueId, + queueConcurrency: this.config.queue.concurrency || 1, + }); + } + } + + /** + * _bootstrap - Initialize the necessary data for the module's creation + * @private + * @returns {Promise} - Promise object + * @memberof ModuleWrapper + */ + _bootstrap() { + if (!this._module) { + throw new Error(`module ${this.name} is not loaded`); + } + + switch (this.type) { + case MODULE_TYPES.in: + this.setContext(this.context); + this.setCollector(this.context.collector || ModuleWrapper._defaultCollector); + return Promise.resolve(); + case MODULE_TYPES.out: + this.setContext(this.context); + this._setupOutboundQueues(); + return Promise.resolve(); + case MODULE_TYPES.db: + this.setContext(this.context); + return Promise.resolve(); + default: + throw new Error(`module ${this.name} has an invalid type`); + } + } + + /** + * setCollector - Set the event collector function for input modules. + * This function MUST be called by the module when it wants + * to send an event to the event processor. + * @param {Function} collector - Event collector function + * @throws {Error} - Error thrown if the module does not support setCollector + * @memberof ModuleWrapper + */ + setCollector(collector) { + if (typeof collector !== 'function') { + throw new Error(`collector must be a function`); + } + + if (this._module?.setCollector) { + this._module.setCollector(collector); + } else { + throw new Error(`module ${this.name} does not support setCollector`); + } + } + + /** + * load - Load the module. + * @returns {Promise} - Promise object that resolves to the module wrapper + * @async + * @memberof ModuleWrapper + * @throws {Error} - Error thrown if the module cannot be loaded + */ + async load() { + // Dynamically import the module provided via this.name + // and instantiate it with the context and config provided + this._module = await import(this.name).then((module) => { + // Check if the module is an array of modules + // If so, select just the one that matches the provided type (this.type) + if (Array.isArray(module.default)) { + module = module.default.find((m) => m.type === this.type); + if (!module) throw new Error(`module ${this.name} does not exist or is badly defined`); + return new module(this.context, this.config); + } + + return new module.default(this.context, this.config); + }).catch((error) => { + this.logger.error(`error loading module ${this.name}`, error); + throw error; + }); + + // Validate the module + if (!validateModuleDefinition(this._module)) { + throw new Error(`module ${this.name} is not valid`); + } + + await this._bootstrap(); + // Call the module's load() method + await this._module.load(); + + this.logger.info(`module ${this.name} loaded`); + + return this; + } + + /** + * unload - Unload the module. + * @returns {Promise} - Promise object + * @async + * @memberof ModuleWrapper + */ + unload() { + this.removeAllListeners(); + this._worker = null; + this._queue = null; + deleteQueue(this._getQueueId()); + + if (this._module?.unload) { + return this._module.unload(); + } + + return Promise.resolve(); + } + + /** + * setContext - Set the context for the module. + * @param {Context} context - Context object + * @throws {Error} - Error thrown if the module does not support setContext + * @memberof ModuleWrapper + * @returns {Promise} - Promise object + */ + setContext(context) { + if (this._module?.setContext) { + return this._module.setContext(context); + } + + throw new Error("Not implemented"); + } + + _onEvent(event, raw) { + if (this._module?.onEvent) { + return this._module.onEvent(event, raw); + } + + throw new Error("Not implemented"); + } + + /** + * _onEvent - Event handler middleware for output modules. + * Catches events dispatched by the event processor and + * forwards them to the module's onEvent() method. + * @param {object} event - Event object in the format of a WebhooksEvent object + * @param {object} raw - Raw event object + * @memberof ModuleWrapper + * @returns {Promise} - Promise object + * @throws {Error} - Error thrown if the module does not support onEvent + */ + async onEvent(event, raw) { + if (this.type !== MODULE_TYPES.out) { + throw new Error(`module ${this.name} is not an output module`); + } + + if (this.config.queue.enabled) { + const queueId = this._getQueueId(); + const { queue } = getQueue(queueId); + await queue.add('event', { event, raw }); + } else { + await this._onEvent(event, raw); + } + } +} + +export default ModuleWrapper; diff --git a/src/modules/queue.js b/src/modules/queue.js new file mode 100644 index 0000000..0d2001a --- /dev/null +++ b/src/modules/queue.js @@ -0,0 +1,109 @@ +import { Queue, Worker } from 'bullmq' + +const queues = new Map(); + +/** + * createQueue - Create a new BullMQ queue and worker. + * @param {string} id - Queue ID + * @param {Function} processor - Job processor function + * @param {object} options - Queue options + * @param {string} options.host - Redis host + * @param {string} options.port - Redis port + * @param {string} options.password - Redis password + * @param {number} options.concurrency - Worker concurrency + * @returns {object} - a { queue, worker } object + */ +const createQueue = (id, processor, { + host, + port, + password, + concurrency = 1, +}) => { + if (queues.has(id)) return queues.get(id); + + const queue = new Queue(id, { + connection: { + host, + port, + password, + }, + concurrency, + }); + + const worker = new Worker(id, processor, { + connection: { + host, + port, + password, + }, + }); + + queues.set(id, { + queue, + worker, + }); + + return { + queue, + worker, + }; +}; + +/** + * addJob - Fetch the queue for the given ID and add a job to it. + * @param {string} id - Queue ID + * @param {object} job - Job to add (BullMQ job) + * @returns {object} - a { queue, worker } object + */ +const addJob = (id, job) => { + const queue = queues.get(id); + + if (queue) { + queue.queue.add(job); + } + + return queue; +} + +/** + * getQueue - Get the queue for the given ID. + * @param {string} id - Queue ID + * @returns {object} - a { queue, worker } object + */ +const getQueue = (id) => { + return queues.get(id); +} + +/** + * getQueues. + * @returns {Map} - a Map of { queue, worker } objects + */ +const getQueues = () => { + return queues; +} + +/** + * deleteQueue - Delete the queue for the given ID. + * @param {string} id - Queue ID + * @returns {boolean} - True if the queue was deleted, false otherwise + */ +const deleteQueue = (id) => { + const queue = queues.get(id); + + if (queue) { + queue.queue.close(); + queue.worker.close(); + queues.delete(id); + return true; + } + + return false; +} + +export { + createQueue, + addJob, + getQueue, + getQueues, + deleteQueue, +}; diff --git a/src/out/webhooks/api/api.js b/src/out/webhooks/api/api.js new file mode 100644 index 0000000..4c95b24 --- /dev/null +++ b/src/out/webhooks/api/api.js @@ -0,0 +1,250 @@ +import express from 'express'; +import url from 'url'; +import { newLogger } from '../../../common/logger.js'; +import Utils from '../utils.js'; +import responses from './responses.js'; +import { METRIC_NAMES } from '../metrics.js'; + +// Returns a simple string with a description of the client that made +// the request. It includes the IP address and the user agent. +const clientDataSimple = req => `ip ${Utils.ipFromRequest(req)}, using ${req.headers["user-agent"]}`; + +// Cleans up a string with an XML in it removing spaces and new lines from between the tags. +const cleanupXML = string => string.trim().replace(/>\s*/g, '>'); +// Web server that listens for API calls and process them. +export default class API { + static logger = newLogger('api'); + + static setStorage (storage) { + API.storage = storage; + } + + static respondWithXML(res, msg) { + msg = cleanupXML(msg); + API.logger.info(`respond with: ${msg}`); + res.setHeader("Content-Type", "text/xml"); + res.send(msg); + } + + constructor(options = {}) { + this.app = express(); + + this._permanentURLs = options.permanentURLs || []; + this._secret = options.secret; + this._exporter = options.exporter; + this._supportedChecksumAlgorithms = options.supportedChecksumAlgorithms; + + this._validateChecksum = this._validateChecksum.bind(this); + + this._registerRoutes(); + } + + _registerRoutes() { + this.app.use((req, res, next) => { + const { method, url, baseUrl, path } = req; + + API.logger.info(`received: ${method} request to ${baseUrl + path}`, { + clientData: clientDataSimple(req), + url, + }); + next(); + }); + + this.app.get("/bigbluebutton/api/hooks/create", this._validateChecksum, this._create.bind(this)); + this.app.get("/bigbluebutton/api/hooks/destroy", this._validateChecksum, this._destroy.bind(this)); + this.app.get("/bigbluebutton/api/hooks/list", this._validateChecksum, this._list.bind(this)); + this.app.get("/bigbluebutton/api/hooks/ping", (req, res) => { + res.write("bbb-webhooks API up!"); + res.end(); + }); + } + + _isHookPermanent(callbackURL) { + return this._permanentURLs.some(obj => { + return obj.url === callbackURL + }); + } + + async _create(req, res, next) { + const urlObj = url.parse(req.url, true); + const callbackURL = urlObj.query["callbackURL"]; + const meetingID = urlObj.query["meetingID"]; + const eventID = urlObj.query["eventID"]; + let getRaw = urlObj.query["getRaw"]; + let returncode = responses.RETURN_CODES.SUCCESS; + let messageKey; + + if (getRaw) { + getRaw = JSON.parse(getRaw.toLowerCase()); + } else { + getRaw = false; + } + + if (callbackURL == null) { + API.respondWithXML(res, responses.missingParamCallbackURL); + returncode = responses.RETURN_CODES.FAILED; + messageKey = responses.MESSAGE_KEYS.missingParamCallbackURL; + } else { + try { + const { hook, duplicated } = await API.storage.get().addSubscription({ + callbackURL, + meetingID, + eventID, + permanent: this._isHookPermanent(callbackURL), + getRaw, + }); + + let msg; + + if (duplicated) { + msg = responses.createDuplicated(hook.id); + messageKey = responses.MESSAGE_KEYS.duplicateWarning; + } else if (hook != null) { + const { permanent, getRaw } = hook.payload; + msg = responses.createSuccess(hook.id, permanent, getRaw); + } else { + msg = responses.createFailure; + returncode = responses.RETURN_CODES.FAILED; + messageKey = responses.MESSAGE_KEYS.createHookError; + } + + API.respondWithXML(res, msg); + } catch (error) { + API.logger.error('error creating hook', error); + API.respondWithXML(res, responses.createFailure); + returncode = responses.RETURN_CODES.FAILED; + messageKey = responses.MESSAGE_KEYS.createHookError; + } + } + + this._exporter.agent.increment(METRIC_NAMES.API_REQUESTS, { + method: req.method, + path: urlObj.pathname, + returncode, + messageKey, + }); + } + + // Create a permanent hook. Permanent hooks can't be deleted via API and will try to emit a message until it succeed + async _destroy(req, res, next) { + const urlObj = url.parse(req.url, true); + const hookID = urlObj.query["hookID"]; + let returncode = responses.RETURN_CODES.SUCCESS; + let messageKey; + + if (hookID == null) { + returncode = responses.RETURN_CODES.FAILED; + messageKey = responses.MESSAGE_KEYS.missingParamHookID; + API.respondWithXML(res, responses.missingParamHookID); + } else { + try { + const removed = await API.storage.get().removeSubscription(hookID); + if (removed) { + API.respondWithXML(res, responses.destroySuccess); + } else { + returncode = responses.RETURN_CODES.FAILED; + messageKey = responses.MESSAGE_KEYS.destroyMissingHook; + API.respondWithXML(res, responses.destroyNoHook); + } + } catch (error) { + API.logger.error('error removing hook', error); + returncode = responses.RETURN_CODES.FAILED; + messageKey = responses.MESSAGE_KEYS.destroyHookError; + API.respondWithXML(res, responses.destroyFailure); + } + } + + this._exporter.agent.increment(METRIC_NAMES.API_REQUESTS, { + method: req.method, + path: urlObj.pathname, + returncode, + messageKey, + }); + } + + _list(req, res, next) { + let hooks; + const urlObj = url.parse(req.url, true); + const meetingID = urlObj.query["meetingID"]; + let returncode = responses.RETURN_CODES.SUCCESS; + let messageKey; + + try { + if (meetingID != null) { + // all the hooks that receive events from this meeting + hooks = API.storage.get().getAllGlobalHooks(); + hooks = hooks.concat(API.storage.get().findByExternalMeetingID(meetingID)); + hooks = Utils.sortBy(hooks, hook => hook.id); + } else { + // no meetingID, return all hooks + hooks = API.storage.get().getAll(); + } + + let msg = "SUCCESS"; + hooks.forEach((hook) => { + const { + eventID, + externalMeetingID, + callbackURL, + permanent, + getRaw, + } = hook.payload; + msg += ""; + msg += `${hook.id}`; + msg += ``; + if (!API.storage.get().isGlobal(hook)) { msg += ``; } + if (eventID != null) { msg += `${eventID}`; } + msg += `${permanent}`; + msg += `${getRaw}`; + msg += ""; + }); + msg += ""; + + API.respondWithXML(res, msg); + } catch (error) { + API.logger.error('error listing hooks', error); + returncode = responses.RETURN_CODES.FAILED; + messageKey = responses.MESSAGE_KEYS.listHookError; + API.respondWithXML(res, responses.listFailure); + } + + this._exporter.agent.increment(METRIC_NAMES.API_REQUESTS, { + method: req.method, + path: urlObj.pathname, + returncode, + messageKey, + }); + } + + // Validates the checksum in the request `req`. + // If it doesn't match BigBlueButton's shared secret, will send an XML response + // with an error code just like BBB does. + _validateChecksum(req, res, next) { + if (Utils.isUrlChecksumValid(req.url, this._secret, this._supportedChecksumAlgorithms)) { + next(); + } else { + const urlObj = url.parse(req.url, true); + API.logger.warn('invalid checksum', { response: responses.checksumError }); + API.respondWithXML(res, responses.checksumError); + this._exporter.agent.increment(METRIC_NAMES.API_REQUEST_FAILURES_XML, { + method: req.method, + path: urlObj.pathname, + returncode: responses.RETURN_CODES.FAILED, + messageKey: responses.MESSAGE_KEYS.checksumError, + }); + } + } + + start(port, bind) { + return new Promise((resolve, reject) => { + this.server = this.app.listen(port, bind, () => { + if (this.server.address() == null) { + API.logger.error(`aborting, could not bind to port ${port}`); + return reject(new Error(`API failed to start, EARADDRINUSE`)); + } + API.logger.info(`listening on port ${port} in ${this.app.settings.env.toUpperCase()} mode`); + return resolve(); + }); + }); + } +} diff --git a/src/out/webhooks/api/responses.js b/src/out/webhooks/api/responses.js new file mode 100644 index 0000000..90b962c --- /dev/null +++ b/src/out/webhooks/api/responses.js @@ -0,0 +1,92 @@ +const RETURN_CODES = { + SUCCESS: "SUCCESS", + FAILED: "FAILED", +}; +const MESSAGE_KEYS = { + checksumError: "checksumError", + createHookError: "createHookError", + duplicateWarning: "duplicateWarning", + destroyHookError: "destroyHookError", + listHookError: "listHookError", + destroyMissingHook: "destroyMissingHook", + missingParamCallbackURL: "missingParamCallbackURL", + missingParamHookID: "missingParamHookID", +}; + +const failure = (key, msg) => + ` \ + ${RETURN_CODES.FAILED} \ + ${key} \ + ${msg} \ + `; + +const checksumError = failure( + MESSAGE_KEYS.checksumError, + "You did not pass the checksum security check.", +); + +const createSuccess = (id, permanent, getRaw) => + ` \ + ${RETURN_CODES.SUCCESS} \ + ${id} \ + ${permanent} \ + ${getRaw} \ + `; + +const createFailure = failure( + MESSAGE_KEYS.createHookError, + "An error happened while creating your hook. Check the logs." +); + +const createDuplicated = (id) => + ` \ + ${RETURN_CODES.SUCCESS} \ + ${id} \ + ${MESSAGE_KEYS.duplicateWarning} \ + There is already a hook for this callback URL. \ + `; + +const destroySuccess = + ` \ + ${RETURN_CODES.SUCCESS} \ + true \ + `; + +const destroyFailure = failure( + MESSAGE_KEYS.destroyHookError, + "An error happened while removing your hook. Check the logs." +); + +const destroyNoHook = failure( + MESSAGE_KEYS.destroyMissingHook, + "The hook informed was not found." +); + +const missingParamCallbackURL = failure( + MESSAGE_KEYS.missingParamCallbackURL, + "You must specify a callbackURL in the parameters." +); +const missingParamHookID = failure( + MESSAGE_KEYS.missingParamHookID, + "You must specify a hookID in the parameters." +); + +const listFailure = failure( + MESSAGE_KEYS.listHookError, + "An error happened while listing registered hooks. Check the logs." +); + +export default { + RETURN_CODES, + MESSAGE_KEYS, + checksumError, + createSuccess, + createFailure, + createDuplicated, + destroySuccess, + destroyFailure, + destroyNoHook, + listFailure, + missingParamCallbackURL, + missingParamHookID, +}; diff --git a/src/out/webhooks/callback-emitter.js b/src/out/webhooks/callback-emitter.js new file mode 100644 index 0000000..9b36e98 --- /dev/null +++ b/src/out/webhooks/callback-emitter.js @@ -0,0 +1,217 @@ +import url from 'url'; +import { EventEmitter } from 'node:events'; +import Utils from './utils.js'; +import fetch from 'node-fetch'; + +// A simple string that identifies the event +const simplifiedEvent = (_event) => { + + try { + const event = typeof _event === 'string' + ? JSON.parse(_event) + : _event.event + ? _event.event + : _event; + + return `event: { name: ${event?.data?.id}, timestamp: ${(event?.data?.event?.ts)} }`; + } catch (error) { + return `event: ${_event}`; + } +}; + +// Use to perform a callback. Will try several times until the callback is +// properly emitted and stop when successful (or after a given number of tries). +// Used to emit a single callback. Destroy it and create a new class for a new callback. +// Emits "success" on success, "failure" on error and "stopped" when gave up trying +// to perform the callback. +export default class CallbackEmitter extends EventEmitter { + static EVENTS = { + // The callback was successfully emitted + SUCCESS: "success", + // The callback could not be emitted + FAILURE: "failure", + // The callback could not be emitted and we gave up trying + STOPPED: "stopped", + }; + + constructor( + callbackURL, + event, + permanent, + domain, { + secret, + auth2_0, + requestTimeout, + retryIntervals, + permanentIntervalReset, + logger = console, + checksumAlgorithm, + } = {}, + ) { + super(); + this.callbackURL = callbackURL; + this.event = event; + this.eventStr = JSON.stringify(event); + this.nextInterval = 0; + this.timestamp = 0; + this.permanent = permanent; + this.logger = logger; + + if (callbackURL == null + || event == null + || domain == null + || domain == null) { + throw new Error("missing parameters"); + } + + this._dispatched = false; + this._serverDomain = domain; + this._secret = secret; + this._bearerAuth = auth2_0; + if (this._bearerAuth && this._secret == null) throw new Error("missing secret"); + this._requestTimeout = requestTimeout; + this._retryIntervals = retryIntervals || [ + 1000, + 2000, + 5000, + 10000, + 30000, + ]; + this._permanentIntervalReset = permanentIntervalReset || 60000; + this._checksumAlgorithm = checksumAlgorithm; + } + + _scheduleNext(timeout) { + this._clearDispatcher(); + this._dispatcher = setTimeout(async () => { + try { + await this._dispatch(); + this._dispatched = true; + this.emit(CallbackEmitter.EVENTS.SUCCESS); + } catch (error) { + this.emit(CallbackEmitter.EVENTS.FAILURE, error); + // get the next interval we have to wait and schedule a new try + const interval = this._retryIntervals[this.nextInterval]; + + if (interval != null) { + this.logger.warn(`trying the callback again in ${interval/1000.0} secs: ${this.callbackURL}`); + this.nextInterval++; + this._scheduleNext(interval); + // no intervals anymore, time to give up + } else { + if (this.permanent){ + this.logger.warn(`callback retries expired, but it's permanent - try the callback again in ${this._permanentIntervalReset/1000.0} secs: ${this.callbackURL}`); + this._scheduleNext(this._permanentIntervalReset); + } else { + this.emit(CallbackEmitter.EVENTS.STOPPED); + } + } + } + }, timeout); + } + + async _dispatch() { + let callbackURL; + const serverDomain = this._serverDomain; + const sharedSecret = this._secret; + const bearerAuth = this._bearerAuth; + const timeout = this._requestTimeout; + + // note: keep keys in alphabetical order + const data = new URLSearchParams({ + domain: serverDomain, + event: "[" + this.eventStr + "]", + timestamp: this.timestamp, + }); + const requestOptions = { + method: "POST", + body: data, + redirect: 'follow', + follow: 10, + // FIXME review - compress should be on? + compress: false, + timeout, + }; + + if (bearerAuth) { + callbackURL = this.callbackURL; + requestOptions.headers = { + Authorization: `Bearer ${sharedSecret}`, + }; + } else { + const checksum = Utils.shaHex( + `${this.callbackURL}${JSON.stringify(data)}${sharedSecret}`, + this._checksumAlgorithm, + ); + // get the final callback URL, including the checksum + callbackURL = this.callbackURL; + try { + const urlObj = url.parse(this.callbackURL, true); + callbackURL += Utils.isEmpty(urlObj.search) ? "?" : "&"; + callbackURL += `checksum=${checksum}`; + } catch (error) { + this.logger.error(`error parsing callback URL: ${this.callbackURL}`); + throw error; + } + } + + // consider 401 as success, because the callback worked but was denied by the recipient + const responseOk = (response) => response.ok || response.status === 401; + const controller = new AbortController(); + const abortTimeout = setTimeout(() => { + controller.abort(); + }, timeout); + requestOptions.signal = controller.signal; + const stringifiedEvent = simplifiedEvent(this.event); + + try { + const response = await fetch(callbackURL, requestOptions); + + if (!responseOk(response)) { + const failedResponseError = new Error( + `Invalid response: ${response?.status || 'unknown'}` || 'unknown error', + ); + failedResponseError.code = response?.status; + throw failedResponseError; + } + + this.logger.info(`successful callback call to: [${callbackURL}]`, { + event: stringifiedEvent, + status: response?.status, + statusText: response?.statusText, + }); + } catch (error) { + if (error.code == null) error.code = 'unknown'; + this.logger.warn(`error in the callback call to: [${callbackURL}]`, { + event: stringifiedEvent, + errorMessage: error?.message, + errorName: error?.name, + errorCode: error.code, + stack: error?.stack, + }); + throw error; + } finally { + clearTimeout(abortTimeout); + } + } + + _clearDispatcher() { + if (this._dispatcher != null) { + clearTimeout(this._dispatcher); + this._dispatcher = null; + } + } + + start() { + this.timestamp = new Date().getTime(); + this.nextInterval = 0; + this._scheduleNext(0); + } + + stop() { + this._clearDispatcher(); + if (!this._dispatched) this.emit(CallbackEmitter.EVENTS.STOPPED); + + this.removeAllListeners(); + } +} diff --git a/src/out/webhooks/index.js b/src/out/webhooks/index.js new file mode 100644 index 0000000..48f294a --- /dev/null +++ b/src/out/webhooks/index.js @@ -0,0 +1,164 @@ +import WebHooks from './web-hooks.js'; +import API from './api/api.js'; +import HookCompartment from '../../db/redis/hooks.js'; +import { buildMetrics, METRIC_NAMES } from './metrics.js'; + +/* + * [MODULE_TYPES.OUTPUT]: { + * load: 'function', + * unload: 'function', + * setContext: 'function', + * onEvent: 'function', + * }, + */ + +/** + * OutWebHooks - Entrypoint for the Webhooks output module. + * @class + * @classdesc Entrypoint for the Webhooks output module - relays incoming + * events to the internal Webhooks dispatcher, exposes + * the module API implementation for the main application to use + * as well as manages the Webhooks API server. + * @property {Context} context - This module's context as provided by the main application. + * @property {BbbWebhooksLogger} logger - The logger. + * @property {object} config - This module's configuration object. + * @property {API} api - The Webhooks API server. + * @property {WebHooks} webhooks - The Webhooks dispatcher. + * @property {boolean} loaded - Whether the module is loaded or not. + */ +class OutWebHooks { + /** + * @type {string} - The module's API implementation. + * @static + */ + static type = "out"; + + static _defaultCollector () { + throw new Error('Collector not set'); + } + + /** + * constructor. + * @param {Context} context - The main application's context. + * @param {object} config - The module's configuration object. + */ + constructor (context, config = {}) { + this.type = OutWebHooks.type; + this.config = config; + this.setContext(context); + this.loaded = false; + this._exporter = this.context.utils.exporter; + + this._bootstrapExporter(); + this.webhooks = new WebHooks( + this.context, + this.config, { + exporter: this._exporter, + permanentURLs: this.config.permanentURLs, + }, + ); + this.api = new API({ + secret: this.config.server.secret, + exporter: this._exporter, + permanentURLs: this.config.permanentURLs, + supportedChecksumAlgorithms: this.config.api.supportedChecksumAlgorithms, + }); + API.setStorage(HookCompartment); + } + + /** + * _collectRegisteredHooks - Collects registered hooks data for the Prometheus + * exporter. + * @private + */ + _collectRegisteredHooks () { + try { + const hooks = HookCompartment.get().getAll(); + this._exporter.agent.reset([METRIC_NAMES.REGISTERED_HOOKS]); + hooks.forEach(hook => { + this._exporter.agent.increment(METRIC_NAMES.REGISTERED_HOOKS, { + callbackURL: hook.payload.callbackURL, + permanent: hook.payload.permanent, + getRaw: hook.payload.getRaw, + // FIXME enabled is hardecoded until enabled/disabled logic is implemented + enabled: true, + }); + }); + } catch (error) { + this.logger.error('Prometheus failed to collect registered hooks', { error: error.stack }); + } + } + + /** + * _bootstrapExporter - Injects the module's metrics into the Prometheus + * exporter. + * This method is called in the constructor. + * @private + */ + _bootstrapExporter () { + this._exporter.injectMetrics(buildMetrics(this._exporter)); + this._exporter.agent.setCollector( + METRIC_NAMES.REGISTERED_HOOKS, + this._collectRegisteredHooks.bind(this) + ); + } + + /** + * load - Loads the out-webhooks module by starting the API server, + * creating permanent hooks and resyncing the hooks DB. + * @async + * @returns {Promise} + */ + async load () { + await this.api.start(this.config.api.port, this.config.api.bind); + await this.webhooks.createPermanentHooks(); + + this.loaded = true; + } + + /** + * unload - Unloads the out-webhooks module by stopping the API server, + * and re-setting the collector to the default one. + * @async + * @returns {Promise} + */ + async unload () { + if (this.webhooks) { + this.webhooks = null; + } + + this.setCollector(OutWebHooks._defaultCollector); + this.loaded = false; + } + + /** + * setContext - Sets the applicatino context for this module. + * and assigns a logger instance to it. + * @param {Context} context - The main application's context. + * @returns {Context} The assigned context. + */ + setContext (context) { + this.context = context; + this.logger = context.getLogger(); + + return context; + } + + /** + * onEvent - Relays an incoming event to the Webhooks dispatcher. + * @see {@link WebHooks#onEvent} + * @async + * @param {object} event - The mapped event object. + * @param {object } raw - The raw event object. + * @returns {Promise} + */ + async onEvent (event, raw) { + if (!this.loaded || !this.webhooks) { + throw new Error("OutWebHooks not loaded"); + } + + return this.webhooks.onEvent(event, raw); + } +} + +export default OutWebHooks; diff --git a/src/out/webhooks/metrics.js b/src/out/webhooks/metrics.js new file mode 100644 index 0000000..dd6d4cd --- /dev/null +++ b/src/out/webhooks/metrics.js @@ -0,0 +1,45 @@ +const METRICS_PREFIX = 'bbb_webhooks_out_hooks'; +const METRIC_NAMES = { + API_REQUESTS: `${METRICS_PREFIX}_api_requests`, + REGISTERED_HOOKS: `${METRICS_PREFIX}_registered_hooks`, + HOOK_FAILURES: `${METRICS_PREFIX}_hook_failures`, + PROCESSED_EVENTS: `${METRICS_PREFIX}_processed_events`, +} + +/** + * buildMetrics - Builds out-webhooks metrics for the Prometheus exporter. + * @param {MetricsExporter} exporter - The Prometheus exporter. + * @returns {object} - Metrics dictionary (key: metric name, value: prom-client metric object) + */ +const buildMetrics = ({ Counter, Gauge }) => { + return { + [METRIC_NAMES.API_REQUESTS]: new Counter({ + name: METRIC_NAMES.API_REQUESTS, + help: 'Webhooks API requests', + labelNames: ['method', 'path', 'statusCode', 'returncode', 'messageKey'], + }), + + [METRIC_NAMES.REGISTERED_HOOKS]: new Gauge({ + name: METRIC_NAMES.REGISTERED_HOOKS, + help: 'Registered hooks', + labelNames: ['callbackURL', 'permanent', 'getRaw', 'enabled'], + }), + + [METRIC_NAMES.HOOK_FAILURES]: new Counter({ + name: METRIC_NAMES.HOOK_FAILURES, + help: 'Hook failures', + labelNames: ['callbackURL', 'reason', 'eventId'], + }), + + [METRIC_NAMES.PROCESSED_EVENTS]: new Counter({ + name: METRIC_NAMES.PROCESSED_EVENTS, + help: 'Processed events', + labelNames: ['eventId', 'callbackURL'], + }), + } +}; + +export { + METRIC_NAMES, + buildMetrics, +}; diff --git a/src/out/webhooks/utils.js b/src/out/webhooks/utils.js new file mode 100644 index 0000000..1fc7c16 --- /dev/null +++ b/src/out/webhooks/utils.js @@ -0,0 +1,171 @@ +import url from "url"; +import config from "config"; +import crypto from "crypto"; + +/** + * queryFromUrl - Returns the query string from a URL string while preserving + * encoding. + * @param {string} fullUrl - The URL to extract the query string from. + * @returns {string} - The query string. + * @private + */ +const queryFromUrl = (fullUrl) => { + let query = fullUrl.replace(/&checksum=[^&]*/, ''); + query = query.replace(/checksum=[^&]*&/, ''); + query = query.replace(/checksum=[^&]*$/, ''); + const matched = query.match(/\?(.*)/); + if (matched != null) { + return matched[1]; + } else { + return ''; + } +}; + +/** + * methodFromUrl - Returns the API method string from a URL. + * @param {string} fullUrl - The URL to extract the API method string from. + * @returns {string} - The API method string. + * @private + */ +const methodFromUrl = (fullUrl) => { + const urlObj = url.parse(fullUrl, true); + return urlObj.pathname.substr((config.get("bbb.apiPath") + "/").length); +}; + +/** + * isChecksumAlgorithmSupported - Checks if a checksum algorithm is supported. + * @param {string} algorithm - The algorithm to check. + * @param {string} supported - The list of supported algorithms. + * @returns {boolean} - Whether the algorithm is supported or not. + * @private + */ +const isChecksumAlgorithmSupported = (algorithm, supported) => { + if (supported == null || supported.length === 0) return false; + return supported.indexOf(algorithm) !== -1; +}; + +/** + * getChecksumAlgorithmFromLength - Returns the checksum algorithm that matches a checksum length. + * @param {number} length - The length of the checksum. + * @returns {string} - The checksum algorithm (one of sha1, sha256, sha384, sha512). + * @private + * @throws {Error} - If no algorithm could be found that matches the provided checksum length. + */ +const getChecksumAlgorithmFromLength = (length) => { + switch (length) { + case 40: + return "sha1"; + case 64: + return "sha256"; + case 96: + return "sha384"; + case 128: + return "sha512"; + default: + throw new Error(`No algorithm could be found that matches the provided checksum length: ${length}`); + } +}; + +/* + * Public + */ + +/** + * ipFromRequest - Returns the IP address of the client that made a request `req`. + * If can not determine the IP, returns `127.0.0.1`. + * @param {object} req - The request object. + * @returns {string} - The IP address of the client. + * @public + */ +const ipFromRequest = (req) => { + // the first ip in the list if the ip of the client + // the others are proxys between him and us + let ipAddress; + if ((req.headers != null ? req.headers["x-forwarded-for"] : undefined) != null) { + let ips = req.headers["x-forwarded-for"].split(","); + ipAddress = ips[0] != null ? ips[0].trim() : undefined; + } + + // fallbacks + if (!ipAddress) { ipAddress = req.headers != null ? req.headers["x-real-ip"] : undefined; } // when behind nginx + if (!ipAddress) { ipAddress = req.connection != null ? req.connection.remoteAddress : undefined; } + if (!ipAddress) { ipAddress = "127.0.0.1"; } + return ipAddress; +}; + +/** + * shaHex - Calculates the SHA hash of a string. + * @param {string} data - The string to calculate the hash for. + * @param {string} algorithm - Hashing algorithm to use (sha1, sha256, sha384, sha512). + * @returns {string} - The hash of the string. + * @public + */ +const shaHex = (data, algorithm) => { + return crypto.createHash(algorithm).update(data).digest("hex"); +}; + +/** + * checksumAPI - Calculates the checksum of a URL using a secret and a hashing algorithm. + * @param {string} fullUrl - The URL to calculate the checksum for. + * @param {string} salt - The secret to use for the checksum. + * @param {string} algorithm - The hashing algorithm to use (sha1, sha256, sha384, sha512). + * @returns {string} - The checksum of the URL. + * @public + */ +const checksumAPI = (fullUrl, salt, algorithm) => { + const query = queryFromUrl(fullUrl); + const method = methodFromUrl(fullUrl); + + return shaHex(method + query + salt, algorithm); +}; + +/** + * isUrlChecksumValid - Checks if the checksum of a URL is valid against a secret + * and a hashing algorithm. + * @param {string} urlStr - The URL to check. + * @param {string} secret - The secret to use for the checksum. + * @param {Array} supportedAlgorithms - The list of supported algorithms. + * @returns {boolean} - Whether the checksum is valid or not. + * @public + */ +const isUrlChecksumValid = (urlStr, secret, supportedAlgorithms) => { + const urlObj = url.parse(urlStr, true); + const checksum = urlObj.query["checksum"]; + const algorithm = getChecksumAlgorithmFromLength(checksum.length); + + if (!isChecksumAlgorithmSupported(algorithm, supportedAlgorithms)) { + return false; + } + + return checksum === checksumAPI(urlStr, secret, algorithm, supportedAlgorithms); +}; + +/** + * isEmpty - Checks if an arbitrary value classifies as an empty array or object. + * @param {*} obj - The value to check. + * @returns {boolean} - Whether the value is empty or not. + * @public + */ +const isEmpty = (obj) => [Object, Array].includes((obj || {}).constructor) + && !Object.entries((obj || {})).length; + +/** + * sortBy - Sorts an array of objects by a key. + * @param {string|number} key - The key to sort by. + * @returns {Function} - A function that can be used to sort an array of objects by the given key. + * @public + */ +const sortBy = (key) => (a, b) => { + if (a[key] > b[key]) return 1; + if (a[key] < b[key]) return -1; + return 0; +}; + +export default { + ipFromRequest, + shaHex, + checksumAPI, + isUrlChecksumValid, + isEmpty, + sortBy, +}; diff --git a/src/out/webhooks/web-hooks.js b/src/out/webhooks/web-hooks.js new file mode 100644 index 0000000..1ef53fa --- /dev/null +++ b/src/out/webhooks/web-hooks.js @@ -0,0 +1,265 @@ +import CallbackEmitter from './callback-emitter.js'; +import HookCompartment from '../../db/redis/hooks.js'; +import { METRIC_NAMES } from './metrics.js'; + +/** + * WebHooks. + * @class + * @classdesc Relays incoming events to registered webhook URLs. + * @property {Context} context - This module's context as provided by the main application. + * @property {BbbWebhooksLogger} logger - The logger. + * @property {object} config - This module's configuration object. + */ +class WebHooks { + /** + * constructor. + * @param {Context} context - This module's context as provided by the main application. + * @param {object} config - This module's configuration object. + * @param {object} options - Options. + * @param {MetricsExporter} options.exporter - The exporter. + * @param {Array} options.permanentURLs - An array of permanent webhook URLs to be registered. + */ + constructor(context, config, { + exporter = {}, + permanentURLs = [], + } = {}) { + this.context = context; + this.logger = context.getLogger(); + this.config = config; + + this._exporter = exporter; + this._permanentURLs = permanentURLs; + } + + /** + * _processRaw - Dispatch raw events to hooks that expect raw data. + * @param {object} hook - The hook to which the event should be dispatched + * @param {object} rawEvent - A raw event to be dispatched. + * @returns {Promise} - A promise that resolves when all hooks have been notified. + * @private + */ + _processRaw(hook, rawEvent) { + if (hook == null || !hook?.payload?.getRaw) return Promise.resolve(); + + this.logger.info('dispatching raw event to hook', { callbackURL: hook.payload.callbackURL }); + + return this.dispatch(rawEvent, hook).catch((error) => { + this.logger.error('failed to enqueue', { calbackURL: hook.payload.callbackURL, error: error.stack }); + }); + } + + /** + * _extractIntMeetingID - Extract the internal meeting ID from mapped or raw events. + * @param {object} message - A mapped or raw event object. + * @returns {string} - The internal meeting ID. + * @private + */ + _extractIntMeetingID(message) { + // Webhooks events + return message?.data?.attributes?.meeting["internal-meeting-id"] + // Raw messages from BBB + || message?.envelope?.routing?.meetingId + || message?.header?.body?.meetingId + || message?.core?.body?.props?.meetingProp?.intId + || message?.core?.body?.meetingId; + } + + /** + * _extractExternalMeetingID - Extract the external meeting ID from a mapped event. + * @param {object} message - A mapped event object. + * @returns {string} - The external meeting ID. + * @private + */ + _extractExternalMeetingID(message) { + return message?.data?.attributes?.meeting["external-meeting-id"]; + } + + /** + * _isHookPermanent - Check if a hook is permanent. + * @param {string} callbackURL - The callback URL of the hook. + * @returns {boolean} - Whether the hook is permanent or not. + * @private + */ + _isHookPermanent(callbackURL) { + return this._permanentURLs.some(obj => { + return obj.url === callbackURL + }); + } + + /** + * _shouldIgnoreEvent - Check if an event should be ignored according to + * the includeEvents/excludeEvents configurations. + * @param {object} event - The event to be checked. + * @returns {boolean} - Whether the event should be ignored or not. + * @private + */ + _shouldIgnoreEvent(event) { + const eventId = event?.data?.id; + const filterIn = this.config.includeEvents || []; + const filterOut = this.config.excludeEvents || []; + + if (filterIn.length > 0 && !filterIn.includes(eventId)) { + this.logger.debug('event not included in the list of events to be sent', { eventId }); + return true; + } + + if (filterOut.length > 0 && filterOut.includes(eventId)) { + this.logger.debug('event included in the list of events to be ignored', { eventId }); + return true; + } + + return false; + } + + /** + * createPermanentHooks - Create permanent hooks. + * @returns {Promise} - A promise that resolves when all permanent hooks have been created. + * @public + * @async + */ + async createPermanentHooks() { + for (let i = 0; i < this._permanentURLs.length; i++) { + try { + const { url: callbackURL, getRaw } = this._permanentURLs[i]; + const { hook, duplicated } = await HookCompartment.get().addSubscription({ + callbackURL, + permanent: this._isHookPermanent(callbackURL), + getRaw, + }); + + if (duplicated) { + this.logger.info(`permanent hook already set ${hook.id}`, { hook: hook.payload }); + } else if (hook != null) { + this.logger.info('permanent hook created successfully'); + } else { + this.logger.error('error creating permanent hook'); + } + } catch (error) { + this.logger.error(`error creating permanent hook ${error}`); + } + } + } + + /** + * dispatch - Dispatch an event to the target hook. + * @param {object} event - The event to be dispatched (raw or mapped) + * @param {StorageItem} hook - The hook to which the event should be dispatched + * (as a StorageItem object). + * The event will *not* be dispatched if the hook is invalid, + * or if the event is not in the list of events to be sent + * for that hook. + * @returns {Promise} - A promise that resolves when the event has been dispatched. + * @public + * @async + */ + dispatch(event, hook) { + return new Promise((resolve, reject) => { + // Check for an invalid event - skip if that's the case + if (event == null) return; + const mappedEventId = event?.data?.id; + const eventId = mappedEventId + || event?.envelope?.name + || 'unknownEvent'; + + // CHeck if the event is in the list of events to be sent (if list was specified) + if (hook.payload.eventID != null + && (mappedEventId == null + || (!hook.payload.eventID.some((ev) => ev == mappedEventId.toLowerCase()))) + ) { + this.logger.info(`${hook.payload.callbackURL} skipping event because not in event list`, { eventID: eventId }); + return; + } + + const emitter = new CallbackEmitter( + hook.payload.callbackURL, + event, + hook.payload.permanent, + this.config.server.domain, { + permanentIntervalReset: this.config.permanentIntervalReset, + secret: this.config.server.secret, + auth2_0: this.config.server.auth2_0, + requestTimeout: this.config.requestTimeout, + retryIntervals: this.config.retryIntervals, + checksumAlgorithm: this.config.hookChecksumAlgorithm, + logger: this.logger, + } + ); + + emitter.start(); + emitter.on(CallbackEmitter.EVENTS.SUCCESS, () => { + this.logger.info(`successfully dispatched to ${hook.payload.callbackURL}`); + emitter.stop(); + this._exporter.agent.increment(METRIC_NAMES.PROCESSED_EVENTS, { + callbackURL: hook.payload.callbackURL, + eventId, + }); + return resolve(); + }); + + emitter.on(CallbackEmitter.EVENTS.FAILURE, (error) => { + this._exporter.agent.increment(METRIC_NAMES.HOOK_FAILURES, { + callbackURL: hook.payload.callbackURL, + reason: error.code || error.name || 'unknown', + eventId, + }); + }); + + emitter.once(CallbackEmitter.EVENTS.STOPPED, () => { + this.logger.warn(`too many failed attempts to perform a callback call, removing the hook for: ${hook.payload.callbackURL}`); + emitter.stop(); + this._exporter.agent.increment(METRIC_NAMES.HOOK_FAILURES, { + callbackURL: hook.payload.callbackURL, + reason: 'too many failed attempts', + eventId, + }); + // TODO just disable + return hook.destroy().then(resolve).catch(reject); + }); + }); + } + + + /** + * onEvent - Handles incoming events received by the main application (relayed + * from this module's entrypoint, OutWebHooks). + * @param {object} event - A mapped webhook event object. + * @param {object} raw - A raw webhook event object. + * @returns {Promise} - A promise that resolves when all hooks have been notified. + * @public + * @async + */ + onEvent(event, raw) { + if (this._shouldIgnoreEvent(event)) return Promise.resolve(); + + const meetingID = this._extractIntMeetingID(event); + let hooks = HookCompartment.get().getAllGlobalHooks(); + + // filter the hooks that need to receive this event + // add hooks that are registered for this specific meeting + if (meetingID != null) { + const eMeetingID = this._extractExternalMeetingID(event); + hooks = hooks.concat(HookCompartment.get().findByExternalMeetingID(eMeetingID)); + } + + if (hooks == null || hooks.length === 0) { + this.logger.info('no hooks registered for this event'); + return Promise.resolve(); + } + + return Promise.all(hooks.map((hook) => { + if (hook == null) return Promise.resolve(); + + if (!hook.payload.getRaw) { + this.logger.info('dispatching event to hook', { callbackURL: hook.payload.callbackURL }); + return this.dispatch(event, hook).catch((error) => { + this.logger.error('failed to enqueue', { + calbackURL: hook.payload.callbackURL, error: error.stack + }); + }); + } else { + return this._processRaw(hook, raw); + } + })); + } +} +export default WebHooks; diff --git a/src/out/xapi/README.md b/src/out/xapi/README.md new file mode 100644 index 0000000..6572c67 --- /dev/null +++ b/src/out/xapi/README.md @@ -0,0 +1,98 @@ +# xAPI output module +This is a bbb-webhooks output module responsible for exporting events from BigBlueButton (BBB) to a Learning Record Store (LRS) using the xAPI pattern. + +# YAML configuration +This module is set and configured in the `default.yml` file using the following YAML structure: + +```yml +modules: + ../out/xapi/index.js: + enabled: true + type: out + config: + lrs: + lrs_endpoint: https://your_lrs.endpoint + lrs_username: user + lrs_password: pass + uuid_namespace: 01234567-89ab-cdef-0123-456789abcdef + redis: + keys: + meetings: bigbluebutton:webhooks:xapi:meetings + meetingPrefix: bigbluebutton:webhooks:xapi:meeting + users: bigbluebutton:webhooks:xapi:users + userPrefix: bigbluebutton:webhooks:xapi:user + polls: bigbluebutton:webhooks:xapi:polls + pollPrefix: bigbluebutton:webhooks:xapi:poll +``` +## LRS Configuration +The LRS configuration section specifies the details required to connect to the Learning Record Store (LRS) where the xAPI statements will be sent. Here are the configuration parameters: + +- **lrs_endpoint**: The URL of the LRS where xAPI statements will be sent. +- **lrs_username**: The username or API key used to authenticate with the LRS. +- **lrs_password**: The password or API key secret used for authentication with the LRS. + +This is the standalone strategy to connect to the LRS, because it does not support multi-tenancy. Connection to the LRS with multi-tenancy could be achieved by sending relevant metadata (explained below), and if that method is used, these parameters are ignored, and thus are not required. + +## UUID Namespace +The **uuid_namespace** parameter is used to define a unique identifier namespace for generating UUIDs. This namespace helps ensure uniqueness when generating identifiers for xAPI statements, and should be kept safe. + +## Redis Configuration +The Redis configuration section defines the keys and key prefixes used for storing and retrieving data related to BBB events. These keys and prefixes are associated with different types of events within the application: + +- **meetings**: Key for storing information about meetings. +- **meetingPrefix**: Prefix for keys related to specific meeting events. +- **users**: Key for storing information about users. +- **userPrefix**: Prefix for keys related to specific user events. +- **polls**: Key for storing information about polls or surveys. +- **pollPrefix**: Prefix for keys related to specific poll events. + +# BBB event metadata +You have the option to set relevant metadata when creating a meeting in Big Blue Button (BBB). This metadata allows you to control the generation and sending of xAPI events to the Learning Record Store (LRS). + +## Supported Metadata Parameters +### meta_xapi-enabled +- **Description**: This parameter controls whether xAPI events are generated and sent to the LRS for a specific meeting. +- **Values**: true or false +- **Default Value**: true (xAPI events are enabled by default) + +If you set `meta_xapi-enabled` to false, no xAPI events will be generated or sent to the LRS for that particular meeting. This provides the flexibility to choose which meetings should be tracked using xAPI. + +### meta_xapi-create-end-actor-name +- **Description**: This parameter specifies the actor name to be used in the meeting-created/ended (Initialized/Terminated) statements +- **Value Format**: string +- **Default Value**: `` + +### meta_secret-lrs-payload +- **Description**: This parameter allows you to specify the credentials and endpoint of the Learning Record Store (LRS) where the xAPI events will be sent. The payload is a Base64-encoded string representing a JSON object encrypted (AES 256/PBKDF2) using the **server secret** as the **passphrase**. +- **Value Format**: Base64-encoded JSON object encrypted with AES 256/PBKDF2 encryption +- **JSON Payload Structure**: +```json +{ + "lrs_endpoint": "https://lrs1.example.com", + "lrs_token": "AAF32423SDF5345" +} +``` +- **Encrypting the Payload**: The Payload should be encrypted with the server secret using the following bash command (provided the lrs credential are in the `lrs.conf` file and server secret is `bab3fd92bcd7d464`): +```bash +cat ./lrs.conf | openssl aes-256-cbc -pass "pass:bab3fd92bcd7d464" -pbkdf2 -a -A +``` +- **Decrypting the Payload**: The Payload can be decrypted with the server secret using the following bash command: +```bash +echo -n U2FsdGVkX18fLg33ChrHbHyIvbcdDwU6+4yX2yTb4gbDKOKSG3hhsd2+TS0ZK15fZlo4G1SQqaxm1OGo1fIsoji82T4SD4y5p1G2g9E9gAKzZC2Z5R454rw7/xGvX7uYGd/fbJcZraMYmafX1Zg3qA== | openssl aes-256-cbc -d -pass "pass:bab3fd92bcd7d464" -pbkdf2 -a -A +`````` +- **Example**: +``` +meta_secret-lrs-payload: U2FsdGVkX18fLg33ChrHbHyIvbcdDwU6+4yX2yTb4gbDKOKSG3hhsd2+TS0ZK15fZlo4G1SQqaxm1OGo1fIsoji82T4SD4y5p1G2g9E9gAKzZC2Z5R454rw7/xGvX7uYGd/fbJcZraMYmafX1Zg3qA== +``` + +The `meta_secret-lrs-payload` parameter allows you to securely define the LRS endpoint and authentication token for each meeting. It ensures that xAPI events generated during the meeting are sent to the correct LRS. + +This strategy to connect to the LRS supports multi-tenancy, as each meeting could point to a different LRS endpoint. If this parameter is present in the metadata, the endpoint and credentials contained in the YML file (lrs_* parameters) are ignored and thus not required. + +# Testing xAPI statements +A test suite for the xAPI statements is provided and can be run using the following command: +``` +npm run test:xapi +``` + +The test suite uses the mapped events file `(example/mapped-events)` and test each event separately to ensure the xAPI module will generate correct statements for each event. diff --git a/src/out/xapi/compartment.js b/src/out/xapi/compartment.js new file mode 100644 index 0000000..0aa4186 --- /dev/null +++ b/src/out/xapi/compartment.js @@ -0,0 +1,107 @@ +import { StorageCompartmentKV } from '../../db/redis/base-storage.js'; + +export class meetingCompartment extends StorageCompartmentKV { + constructor(client, prefix, setId, options = {}) { + super(client, prefix, setId, options); + } + + async addOrUpdateMeetingData(meeting_data) { + const { internal_meeting_id, context_registration, planned_duration, + create_time, meeting_name, xapi_enabled, create_end_actor_name, + lrs_endpoint, lrs_token } = meeting_data; + + const payload = { + internal_meeting_id, + context_registration, + planned_duration, + create_time, + meeting_name, + xapi_enabled, + create_end_actor_name, + lrs_endpoint, + lrs_token, + }; + + const mapping = await this.save(payload, { + alias: internal_meeting_id, + }); + this.logger.info(`added meeting data to the list ${internal_meeting_id}: ${mapping.print()}`); + + return mapping; + } + + async getMeetingData(internal_meeting_id) { + const meeting_data = this.findByField('internal_meeting_id', internal_meeting_id); + return (meeting_data != null ? meeting_data.payload : undefined); + } + + // Initializes global methods for this model. + initialize() { + return; + } +} + +export class userCompartment extends StorageCompartmentKV { + constructor(client, prefix, setId, options = {}) { + super(client, prefix, setId, options); + } + + async addOrUpdateUserData(user_data) { + const { external_user_id, name } = user_data; + + const payload = { + external_user_id, + name, + }; + + const mapping = await this.save(payload, { + alias: external_user_id, + }); + this.logger.info(`added user data to the list ${external_user_id}: ${mapping.print()}`); + + return mapping; + } + + async getUserData(external_user_id) { + const user_data = this.findByField('external_user_id', external_user_id); + return (user_data != null ? user_data.payload : undefined); + } + + // Initializes global methods for this model. + initialize() { + return; + } +} + +export class pollCompartment extends StorageCompartmentKV { + constructor(client, prefix, setId, options = {}) { + super(client, prefix, setId, options); + } + + async addOrUpdatePollData(poll_data) { + const { object_id, question, choices } = poll_data; + + const payload = { + object_id, + question, + choices, + }; + + const mapping = await this.save(payload, { + alias: object_id, + }); + this.logger.info(`added poll data to the list ${object_id}: ${mapping.print()}`); + + return mapping; + } + + async getPollData(object_id) { + const poll_data = this.findByField('object_id', object_id); + return (poll_data != null ? poll_data.payload : undefined); + } + + // Initializes global methods for this model. + initialize() { + return; + } +} diff --git a/src/out/xapi/decrypt.js b/src/out/xapi/decrypt.js new file mode 100644 index 0000000..91d3f87 --- /dev/null +++ b/src/out/xapi/decrypt.js @@ -0,0 +1,30 @@ +import crypto from 'crypto' + +/** + * + * @param encryptedObj + * @param secret + */ +export default function decryptStr(encryptedObj, secret) { + // Decode the base64-encoded text + const encryptedText = Buffer.from(encryptedObj, 'base64'); + + // Extract salt (bytes 8 to 15) and ciphertext (the rest) + // the first 8 bytes are reserved for OpenSSL's 'Salted__' magic prefix + const salt = encryptedText.subarray(8, 16); + const ciphertext = encryptedText.subarray(16); + + // Derive the key and IV using PBKDF2 + const keyIVBuffer = crypto.pbkdf2Sync(secret, salt, 10000, 48, 'sha256'); + const key = keyIVBuffer.subarray(0, 32); + const iv = keyIVBuffer.subarray(32); + + // Create a decipher object with IV + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); + + // Update the decipher with the ciphertext + let decrypted = decipher.update(ciphertext, 'binary', 'utf8'); + decrypted += decipher.final('utf8'); + + return decrypted; +} diff --git a/src/out/xapi/index.js b/src/out/xapi/index.js new file mode 100644 index 0000000..7a683b3 --- /dev/null +++ b/src/out/xapi/index.js @@ -0,0 +1,100 @@ +import XAPI from './xapi.js'; +import { meetingCompartment, userCompartment, pollCompartment } from './compartment.js'; +import { createClient } from 'redis'; +/* + * [MODULE_TYPES.OUTPUT]: { + * load: 'function', + * unload: 'function', + * setContext: 'function', + * onEvent: 'function', + * }, + */ + +export default class OutXAPI { + static type = "out"; + + static _defaultCollector() { + throw new Error('Collector not set'); + } + + constructor(context, config = {}) { + this.type = OutXAPI.type; + this.config = config; + this.setContext(context); + this.loaded = false; + } + + _validateConfig() { + if (this.config == null) { + throw new Error("config not set"); + } + + // TODO + + return true; + } + + _onRedisError(error) { + this.logger.error("Redis client failure", error); + } + + async load() { + if (this._validateConfig()) { + const { url, password, host, port } = this.config.redis || this.config; + const redisUrl = url || `redis://${password ? `:${password}@` : ''}${host}:${port}`; + this.redisClient = createClient({ + url: redisUrl, + }); + this.redisClient.on('error', this._onRedisError.bind(this)); + this.redisClient.on('ready', () => this.logger.info('Redis client is ready')); + + await this.redisClient.connect(); + + this.meetingStorage = new meetingCompartment( + this.redisClient, + this.config.redis.keys.meetingPrefix, + this.config.redis.keys.meetings + ); + + this.userStorage = new userCompartment( + this.redisClient, + this.config.redis.keys.userPrefix, + this.config.redis.keys.users + ); + + this.pollStorage = new pollCompartment( + this.redisClient, + this.config.redis.keys.pollPrefix, + this.config.redis.keys.polls + ); + + this.xAPI = new XAPI(this.context, this.config, this.meetingStorage, this.userStorage, this.pollStorage); + } + this.loaded = true; + } + + async unload() { + if (this.redisClient != null) { + await this.redisClient.disconnect(); + this.redisClient = null; + } + + this.setCollector(OutXAPI._defaultCollector); + this.loaded = false; + } + + setContext(context) { + this.context = context; + this.logger = context.getLogger(); + + return context; + } + + async onEvent(event, raw) { + if (!this.loaded) { + throw new Error("OutXAPI not loaded"); + } + + return this.xAPI.onEvent(event, raw); + } +} diff --git a/src/out/xapi/templates.js b/src/out/xapi/templates.js new file mode 100644 index 0000000..9666570 --- /dev/null +++ b/src/out/xapi/templates.js @@ -0,0 +1,208 @@ +import { DateTime, Duration } from 'luxon'; + +/** + * + * @param event + * @param meeting_data + * @param user_data + * @param poll_data + */ +export default function getXAPIStatement(event, meeting_data, user_data = null, poll_data = null) { + const { server_domain, + object_id, + meeting_name, + context_registration, + session_id, + planned_duration, + create_time, + create_end_actor_name } = meeting_data; + + const planned_duration_ISO = Duration.fromObject({ minutes: planned_duration }).toISO(); + const create_time_ISO = DateTime.fromMillis(create_time).toUTC().toISO(); + + const eventId = event.data.id; + const eventTs = event.data.event.ts; + + const session_parent = [ + { + "id": `https://${server_domain}/xapi/activities/${object_id}`, + "definition": { + "type": "https://w3id.org/xapi/virtual-classroom/activity-types/virtual-classroom" + } + } + ] + + if (eventId == 'meeting-created' + || eventId == 'meeting-ended' + || eventId == 'user-joined' + || eventId == 'user-left' + || eventId == 'user-audio-voice-enabled' + || eventId == 'user-audio-voice-disabled' + || eventId == "user-audio-muted" + || eventId == "user-audio-unmuted" + || eventId == 'user-cam-broadcast-start' + || eventId == 'user-cam-broadcast-end' + || eventId == 'meeting-screenshare-started' + || eventId == 'meeting-screenshare-stopped' + || eventId == 'chat-group-message-sent' + || eventId == 'poll-started' + || eventId == 'poll-responded' + || eventId == 'user-raise-hand-changed') { + const verbMappings = { + 'meeting-created': 'http://adlnet.gov/expapi/verbs/initialized', + 'meeting-ended': 'http://adlnet.gov/expapi/verbs/terminated', + 'user-joined': 'http://activitystrea.ms/join', + 'user-left': 'http://activitystrea.ms/leave', + 'user-audio-voice-enabled': 'http://activitystrea.ms/start', + 'user-audio-voice-disabled': 'https://w3id.org/xapi/virtual-classroom/verbs/stopped', + 'user-audio-muted': 'https://w3id.org/xapi/virtual-classroom/verbs/stopped', + 'user-audio-unmuted': 'http://activitystrea.ms/start', + 'user-cam-broadcast-start': 'http://activitystrea.ms/start', + 'user-cam-broadcast-end': 'https://w3id.org/xapi/virtual-classroom/verbs/stopped', + 'meeting-screenshare-started': 'http://activitystrea.ms/share', + 'meeting-screenshare-stopped': 'http://activitystrea.ms/unshare', + 'chat-group-message-sent': 'https://w3id.org/xapi/acrossx/verbs/posted', + 'poll-started': 'http://adlnet.gov/expapi/verbs/asked', + 'poll-responded': 'http://adlnet.gov/expapi/verbs/answered', + } + + // TODO check for data integrity + const statement = { + "actor": { + "account": { + "name": user_data?.name || "", + "homePage": `https://${server_domain}` + } + }, + "verb": { + "id": Object.prototype.hasOwnProperty.call(verbMappings, eventId) ? verbMappings[eventId] : null + }, + "object": { + "id": `https://${server_domain}/xapi/activities/${object_id}`, + "definition": { + "type": "https://w3id.org/xapi/virtual-classroom/activity-types/virtual-classroom", + "name": { + "en": meeting_name + } + }, + }, + "context": { + "registration": context_registration, + "contextActivities": { + "category": [ + { + "id": "https://w3id.org/xapi/virtual-classroom", + "definition": { + "type": "http://adlnet.gov/expapi/activities/profile" + } + } + ] + }, + "extensions": { + "https://w3id.org/xapi/cmi5/context/extensions/sessionid": session_id + } + }, + "timestamp": DateTime.fromMillis(eventTs).toUTC().toISO() + } + + // Custom 'meeting-created' attributes + if (eventId == 'meeting-created') { + statement.actor.account.name = create_end_actor_name; + statement.context.extensions["http://id.tincanapi.com/extension/planned-duration"] = planned_duration_ISO + statement.timestamp = create_time_ISO; + } + + // Custom 'meeting-ended' attributes + else if (eventId == 'meeting-ended') { + statement.actor.account.name = create_end_actor_name; + statement.context.extensions["http://id.tincanapi.com/extension/planned-duration"] = planned_duration_ISO + statement.result = { + "duration": Duration.fromMillis(eventTs - create_time).toISO() + } + } + + // Custom attributes for multiple interactions + else if (eventId == 'user-audio-voice-enabled' + || eventId == 'user-audio-voice-disabled' + || eventId == "user-audio-muted" + || eventId == "user-audio-unmuted" + || eventId == 'user-cam-broadcast-start' + || eventId == 'user-cam-broadcast-end' + || eventId == 'meeting-screenshare-started' + || eventId == 'meeting-screenshare-stopped') { + + const media = { + "user-audio-voice-enabled": "micro", + "user-audio-voice-disabled": "micro", + "user-audio-muted": "micro", + "user-audio-unmuted": "micro", + "user-cam-broadcast-start": "camera", + "user-cam-broadcast-end": "camera", + "meeting-screenshare-started": "screen", + "meeting-screenshare-stopped": "screen", + }[eventId] + + statement.object = { + "id": `https://${server_domain}/xapi/activities/${user_data?.[`user_${media}_object_id`]}`, + "definition": { + "type": `https://w3id.org/xapi/virtual-classroom/activity-types/${media}`, + "name": { + "en": `${user_data?.name}'s ${media}` + } + } + }; + statement.context.contextActivities.parent = session_parent; + } + + // Custom 'user-raise-hand-changed' attributes + else if (eventId == 'user-raise-hand-changed') { + const raisedHandVerb = "https://w3id.org/xapi/virtual-classroom/verbs/reacted"; + const loweredHandVerb = "https://w3id.org/xapi/virtual-classroom/verbs/unreacted"; + const isRaiseHand = event.data.attributes.user["raise-hand"]; + statement.verb.id = isRaiseHand ? raisedHandVerb : loweredHandVerb; + statement.result = { + "extensions": { + "https://w3id.org/xapi/virtual-classroom/extensions/emoji": "U+1F590" + } + } + } + + // Custom 'chat-group-message-sent' attributes + else if (eventId == 'chat-group-message-sent') { + statement.object = { + "id": `https://${server_domain}/xapi/activities/${user_data?.msg_object_id}`, + "definition": { + "type": "https://w3id.org/xapi/acrossx/activities/message" + } + } + + statement.context.contextActivities.parent = session_parent; + if (user_data?.time !== undefined) statement.timestamp = DateTime.fromMillis(user_data.time).toUTC().toISO(); + } + + // Custom 'poll-started' and 'poll-responded' attributes + else if (eventId == 'poll-started' || eventId == 'poll-responded') { + statement.object = { + "id": `https://${server_domain}/xapi/activities/${poll_data?.object_id}`, + "definition": { + "description": { + "en": poll_data?.question, + }, + "type": "http://adlnet.gov/expapi/activities/cmi.interaction", + "interactionType": "choice", + "choices": poll_data?.choices, + } + } + + statement.context.contextActivities.parent = session_parent; + if (eventId == 'poll-responded') { + statement.result = { + "response": event.data.attributes.poll.answerIds.join(','), + } + } + } + + return statement + } + else return null; +} diff --git a/src/out/xapi/xapi.js b/src/out/xapi/xapi.js new file mode 100644 index 0000000..ee161d0 --- /dev/null +++ b/src/out/xapi/xapi.js @@ -0,0 +1,283 @@ +import getXAPIStatement from "./templates.js"; +import decryptStr from "./decrypt.js" +import { v5 as uuidv5 } from "uuid"; +import { DateTime } from "luxon"; +import fetch from "node-fetch"; + +// XAPI will listen for events on redis coming from BigBlueButton, +// generate xAPI statements and send to a LRS +export default class XAPI { + constructor(context, config, meetingStorage, userStorage, pollStorage) { + this.context = context; + this.logger = context.getLogger(); + this.config = config; + this.meetingStorage = meetingStorage; + this.userStorage = userStorage; + this.pollStorage = pollStorage; + this.validEvents = [ + 'chat-group-message-sent', + 'meeting-created', + 'meeting-ended', + 'meeting-screenshare-started', + 'meeting-screenshare-stopped', + 'poll-started', + 'poll-responded', + 'user-audio-muted', + 'user-audio-unmuted', + 'user-audio-voice-disabled', + 'user-audio-voice-enabled', + 'user-joined', + 'user-left', + 'user-cam-broadcast-end', + 'user-cam-broadcast-start', + 'user-raise-hand-changed' + ] + } + + _uuid(payload) { + return uuidv5( payload, this.config.uuid_namespace ); + } + + async postToLRS(statement, meeting_data) { + let { lrs_endpoint, lrs_username, lrs_password } = this.config.lrs; + if (meeting_data.lrs_endpoint !== ''){ + lrs_endpoint = meeting_data.lrs_endpoint; + } + const lrs_token = meeting_data.lrs_token; + const headers = { + Authorization: `Basic ${Buffer.from( + lrs_username + ":" + lrs_password + ).toString("base64")}`, + "Content-Type": "application/json", + "X-Experience-API-Version": "1.0.0", + }; + + if (lrs_token !== ''){ + headers.Authorization = `Bearer ${lrs_token}` + } + + const requestOptions = { + method: "POST", + body: JSON.stringify(statement), + headers, + }; + + // Remove /xapi(/)(statements)(/) from the end of the lrs_endpoint to ensure the correct endpoint is used + const fixed_lrs_endpoint = lrs_endpoint.replace(/\/xapi\/?(statements)?\/?$/i, ''); + const xAPIEndpoint = new URL("xAPI/statements", fixed_lrs_endpoint); + + try { + const response = await fetch(xAPIEndpoint, requestOptions); + const { status } = response; + const data = await response.json(); + this.logger.debug("OutXAPI.res.status:", { status, data }); + if (status < 200 || status >= 400){ + this.logger.debug("OutXAPI.res.post_fail:", { statement }); + } + } catch (err) { + this.logger.error("OutXAPI.res.err:", err); + } + } + + async onEvent(event) { + const eventId = event.data.id; + + if (this.validEvents.indexOf(eventId) <= -1) return Promise.resolve(); + + const meeting_data = { + internal_meeting_id: event.data.attributes.meeting["internal-meeting-id"], + external_meeting_id: event.data.attributes.meeting["external-meeting-id"], + server_domain: this.config.server.domain, + }; + + meeting_data.session_id = this._uuid(meeting_data.internal_meeting_id); + meeting_data.object_id = this._uuid(meeting_data.external_meeting_id); + + let XAPIStatement = null; + + return new Promise(async (resolve, reject) => { + // if meeting-created event, set meeting_data on redis + if (eventId == "meeting-created") { + meeting_data.planned_duration = event.data.attributes.meeting.duration; + meeting_data.create_time = event.data.attributes.meeting["create-time"]; + meeting_data.meeting_name = event.data.attributes.meeting.name; + meeting_data.xapi_enabled = event.data.attributes.meeting.metadata?.["xapi-enabled"] !== 'false' ? 'true' : 'false'; + meeting_data.create_end_actor_name = event.data.attributes.meeting.metadata?.["xapi-create-end-actor-name"] || ""; + + const lrs_payload = event.data.attributes.meeting.metadata?.["secret-lrs-payload"]; + let lrs_endpoint = ''; + let lrs_token = ''; + + // if lrs_payload exists, decrypts with the server secret and extracts lrs_endpoint and lrs_token from it + if (lrs_payload !== undefined){ + const payload_text = decryptStr(lrs_payload, this.config.server.secret); + ({lrs_endpoint, lrs_token} = JSON.parse(payload_text)); + } + + meeting_data.lrs_endpoint = lrs_endpoint; + meeting_data.lrs_token = lrs_token; + + const meeting_create_day = DateTime.fromMillis( + meeting_data.create_time + ).toFormat("yyyyMMdd"); + const external_key = `${meeting_data.external_meeting_id}_${meeting_create_day}`; + + meeting_data.context_registration = this._uuid(external_key); + //set meeting_data on redis + try { + await this.meetingStorage.addOrUpdateMeetingData(meeting_data); + resolve(); + } catch (error) { + return reject(error); + } + + // Do not proceed if xapi_enabled === 'false' was passed in the metadata + if (meeting_data.xapi_enabled === 'false') { + return resolve(); + } + + XAPIStatement = getXAPIStatement(event, meeting_data); + } + // if not meeting-created event, read meeting_data from redis + else { + const meeting_data_storage = await this.meetingStorage.getMeetingData( + meeting_data.internal_meeting_id + ); + // Do not proceed if meeting_data is not found on the storage + if (meeting_data_storage === undefined) { + return reject(new Error('meeting data not found')); + } + Object.assign(meeting_data, meeting_data_storage); + + // Do not proceed if xapi_enabled === 'false' was passed in the metadata + if (meeting_data.xapi_enabled === 'false') { + return resolve(); + } + + if (eventId == "meeting-ended") { + resolve(); + XAPIStatement = getXAPIStatement(event, meeting_data); + } + // if user-joined event, set user_data on redis + else if (eventId == "user-joined") { + const external_user_id = event.data.attributes.user["internal-user-id"]; + const user_data = { + external_user_id, + name: event.data.attributes.user.name, + }; + try { + await this.userStorage.addOrUpdateUserData(user_data); + resolve(); + } catch (error) { + return reject(error); + } + XAPIStatement = getXAPIStatement(event, meeting_data, user_data); + } + // if not user-joined user event, read user_data on redis + else if ( + eventId == "user-left" || + eventId == "user-audio-voice-enabled" || + eventId == "user-audio-voice-disabled" || + eventId == "user-audio-muted" || + eventId == "user-audio-unmuted" || + eventId == "user-cam-broadcast-start" || + eventId == "user-cam-broadcast-end" || + eventId == "meeting-screenshare-started" || + eventId == "meeting-screenshare-stopped" || + eventId == "user-raise-hand-changed" + ) { + resolve(); + // If mic is not enabled in "user-audio-voice-enabled" event, do not send statement + if (eventId == "user-audio-voice-enabled" && + (event.data.attributes.user["listening-only"] == true || + event.data.attributes.user.muted == true || + event.data.attributes.user["sharing-mic"] == false)) { + return; + } + const external_user_id = event.data.attributes.user?.["internal-user-id"]; + + const user_data = external_user_id + ? await this.userStorage.getUserData(external_user_id) + : undefined; + // Do not proceed if user_data is requested but not found on the storage + if (user_data === undefined) { + return; + } + const media = { + "user-audio-voice-enabled": "micro", + "user-audio-voice-disabled": "micro", + "user-audio-muted": "micro", + "user-audio-unmuted": "micro", + "user-cam-broadcast-start": "camera", + "user-cam-broadcast-end": "camera", + "meeting-screenshare-started": "screen", + "meeting-screenshare-stopped": "screen", + }[eventId] + + if (media !== undefined){ + user_data[`user_${media}_object_id`] = this._uuid(`${external_user_id}_${media}`); + } + + XAPIStatement = getXAPIStatement(event, meeting_data, user_data); + // Chat message + } else if (eventId == "chat-group-message-sent") { + resolve(); + const user_data = event.data.attributes["chat-message"]?.sender; + const msg_key = `${user_data?.external_user_id}_${user_data?.time}`; + user_data.msg_object_id = this._uuid(msg_key); + XAPIStatement = getXAPIStatement(event, meeting_data, user_data); + // Poll events + } else if ( + eventId == "poll-started" || + eventId == "poll-responded" + ) { + if (eventId == "poll-responded") { + resolve(); + } + const external_user_id = + event.data.attributes.user?.["internal-user-id"]; + const user_data = external_user_id + ? await this.userStorage.getUserData(external_user_id) + : null; + const object_id = this._uuid(event.data.attributes.poll.id); + let poll_data; + + if (eventId == "poll-started") { + var choices = event.data.attributes.poll.answers.map((a) => { + return { id: a.id.toString(), description: { en: a.key } }; + }); + poll_data = { + object_id, + question: event.data.attributes.poll.question, + choices, + }; + //set poll_data on redis + try { + await this.pollStorage.addOrUpdatePollData(poll_data); + resolve(); + } catch (error) { + return reject(error); + } + } else if (eventId == "poll-responded") { + poll_data = object_id + ? await this.pollStorage.getPollData(object_id) + : undefined; + // Do not proceed if poll_data is requested but not found on the storage + if (poll_data === undefined) { + return; + } + } + XAPIStatement = getXAPIStatement( + event, + meeting_data, + user_data, + poll_data + ); + } + } + if (XAPIStatement !== null && meeting_data.xapi_enabled === 'true') { + await this.postToLRS(XAPIStatement, meeting_data); + } + }); + } +} diff --git a/src/process/event-processor.js b/src/process/event-processor.js new file mode 100644 index 0000000..e8d08b9 --- /dev/null +++ b/src/process/event-processor.js @@ -0,0 +1,336 @@ +import IDMapping from '../db/redis/id-mapping.js'; +import { newLogger } from '../common/logger.js'; +import WebhooksEvent from '../process/event.js'; +import UserMapping from '../db/redis/user-mapping.js'; +import Utils from '../common/utils.js'; +import Metrics from '../metrics/index.js'; +import config from 'config'; + +const Logger = newLogger('event-processor'); + +export default class EventProcessor { + static _defaultCollector () { + throw new Error('Collector not set'); + } + + constructor( + inputs, + outputs, + ) { + this.inputs = inputs; + this.outputs = outputs; + + this._exporter = Metrics.agent; + } + + _trackModuleEvents() { + this.outputs.forEach((output) => { + output.on('eventDispatchFailed', ({ event, raw, error }) => { + Logger.error('error notifying output module', { + error: error.stack, + event, + raw, + }); + this._exporter.increment(Metrics.METRIC_NAMES.EVENT_DISPATCH_FAILURES, { + outputEventId: event?.data?.id || 'unknown', + module: output.name, + }); + }); + }); + } + + start() { + this.inputs.forEach((input) => { + input.setCollector(this.processInputEvent.bind(this)); + }); + + return Promise.resolve(); + } + + stop() { + this.inputs.forEach((input) => { + input.setCollector(EventProcessor._defaultCollector); + }); + } + + _parseEvent(event) { + let parsedEvent = event; + + if (typeof event === 'string') parsedEvent = JSON.parse(event); + + return parsedEvent; + } + + // TODO move this to an event factory + // Spoofs a user left event for a given user when a meeting ends (so that the user + // is removed from the user mapping AND the user left event is sent to output modules). + // This is necessary because the user left event is not sent by BBB when a meeting ends. + _spoofUserLeftEvent(internalMeetingID, externalMeetingID, userData) { + if (userData == null || userData.user == null) { + Logger.warn('cannot spoof user left event, user is null'); + return; + } + + const spoofedUserLeft = { + data: { + "type": "event", + "id": "user-left", + "attributes":{ + "meeting":{ + "internal-meeting-id": internalMeetingID, + "external-meeting-id": externalMeetingID, + }, + "user": userData.user, + }, + "event":{ + "ts": Date.now() + } + } + }; + + this.processInputEvent(spoofedUserLeft); + } + + async _handleMeetingEndedEvent(event) { + const internalMeetingId = event.data.attributes.meeting["internal-meeting-id"]; + const externalMeetingId = event.data.attributes.meeting["external-meeting-id"]; + + try { + await IDMapping.get().removeMapping(internalMeetingId) + } catch (error) { + Logger.error(`error removing meeting mapping: ${error}`, { + error: error.stack, + event, + }); + } + + try { + const users = await UserMapping.get().getUsersFromMeeting(internalMeetingId); + users.forEach(user => this._spoofUserLeftEvent(internalMeetingId, externalMeetingId, user)); + await UserMapping.get().removeMappingWithMeetingId(internalMeetingId); + } catch (error) { + Logger.error(`error removing user mappings: ${error}`, { + error: error.stack, + event, + }); + } + } + + _handleUserEmojiChangedEvent(outputEvent, rawEvent) { + const internalUserId = outputEvent.data.attributes.user["internal-user-id"]; + const emoji = outputEvent.data.attributes.user.emoji; + + this._notifyOutputModules(outputEvent, rawEvent); + + // If the emoji changed to raiseHand, we're dealing with BBB < 2.7 + // where the events weren't separated yet. In this case, we'll + // spoof a raiseHand event and store the state so we can + // spoof a raiseHand: false event when user-emoji-changed is + // called again with a different emoji. + if (emoji === 'raiseHand') { + const spoofedEvent = config.util.cloneDeep(outputEvent); + spoofedEvent.data.id = 'user-raise-hand-changed'; + delete spoofedEvent.data.attributes.user.emoji; + spoofedEvent.data.attributes.user.raiseHand = true; + + return UserMapping.get().updateWithField( + 'internalUserID', + internalUserId, { + user: { + raiseHand: true, + }, + } + ).catch((error) => { + Logger.error('error updating user mapping', error);} + ).finally(() => { + this._notifyOutputModules(spoofedEvent, rawEvent); + }); + } + + const userInfo = UserMapping.get().getUser(internalUserId); + + // Emoji changed and raiseHand was true, so we'll spoof a raiseHand: false + if (userInfo != null && userInfo.raiseHand === true) { + const spoofedEvent = config.util.cloneDeep(outputEvent); + spoofedEvent.data.id = 'user-raise-hand-changed'; + delete spoofedEvent.data.attributes.user.emoji; + spoofedEvent.data.attributes.user.raiseHand = false; + + return UserMapping.get().updateWithField( + 'internalUserID', + internalUserId, { + user: { + raiseHand: false, + }, + } + ).catch((error) => { + Logger.error('error updating user mapping', error); + }).finally(() => { + this._notifyOutputModules(spoofedEvent, rawEvent); + }); + } + + return Promise.resolve(); + } + + processInputEvent(event) { + try { + const rawEvent = this._parseEvent(event); + const eventInstance = new WebhooksEvent(rawEvent); + const outputEvent = eventInstance.outputEvent; + + if (!Utils.isEmpty(outputEvent)) { + Logger.trace('raw event succesfully parsed', { rawEvent }); + const internalMeetingId = outputEvent.data.attributes.meeting["internal-meeting-id"]; + IDMapping.get().reportActivity(internalMeetingId); + + // Any kind of retrocompatibility logic, output event post-processing, + // data storage et al. should be done here. + switch (outputEvent.data.id) { + case "meeting-created": + IDMapping.get().addOrUpdateMapping(internalMeetingId, + outputEvent.data.attributes.meeting["external-meeting-id"] + ).catch((error) => { + Logger.error(`error adding meeting mapping: ${error}`, { + error: error.stack, + event, + }); + }).finally(() => { + // has to be here, after the meeting was created, otherwise create calls won't generate + // callback calls for meeting hooks + this._notifyOutputModules(outputEvent, rawEvent); + }); + break; + case "user-joined": + UserMapping.get().addOrUpdateMapping( + outputEvent.data.attributes.user["internal-user-id"], + outputEvent.data.attributes.user["external-user-id"], + internalMeetingId, + outputEvent.data.attributes.user + ).catch((error) => { + Logger.error(`error adding user mapping: ${error}`, { + error: error.stack, + event, + }) + }).finally(() => { + this._notifyOutputModules(outputEvent, rawEvent); + }); + break; + case "user-left": + UserMapping.get().removeMapping( + outputEvent.data.attributes.user["internal-user-id"] + ).catch((error) => { + Logger.error(`error removing user mapping: ${error}`, { + error: error.stack, + event, + }); + }).finally(() => { + this._notifyOutputModules(outputEvent, rawEvent); + }); + break; + case "user-presenter-assigned": + UserMapping.get().updateWithField( + 'internalUserID', + outputEvent.data.attributes.user["internal-user-id"], { + user: { + presenter: true, + }, + } + ).catch((error) => { + Logger.error('error updating user mapping', error); + }).finally(() => { + this._notifyOutputModules(outputEvent, rawEvent); + }); + break; + case "user-presenter-unassigned": + UserMapping.get().updateWithField( + 'internalUserID', + outputEvent.data.attributes.user["internal-user-id"], { + user: { + presenter: false, + }, + } + ).catch((error) => { + Logger.error('error updating user mapping', error); + }).finally(() => { + this._notifyOutputModules(outputEvent, rawEvent); + }); + break; + case "meeting-screenshare-started": + UserMapping.get().updateWithField( + 'internalUserID', + outputEvent.data.attributes.user["internal-user-id"], { + user: { + screenshare: true, + }, + } + ).catch((error) => { + Logger.error('error updating user mapping', error); + }).finally(() => { + this._notifyOutputModules(outputEvent, rawEvent); + }); + break; + case "meeting-screenshare-stopped": + UserMapping.get().updateWithField( + 'internalUserID', + outputEvent.data.attributes.user["internal-user-id"], { + user: { + screenshare: false, + }, + } + ).catch((error) => { + Logger.error('error updating user mapping', error); + }).finally(() => { + this._notifyOutputModules(outputEvent, rawEvent); + }); + break; + case "user-emoji-changed": + this._handleUserEmojiChangedEvent(outputEvent, rawEvent); + break; + case "meeting-ended": + this._handleMeetingEndedEvent(outputEvent).finally(() => { + this._notifyOutputModules(outputEvent, rawEvent); + }); + break; + default: + this._notifyOutputModules(outputEvent, rawEvent); + } + } + } catch (error) { + Logger.error('error processing event', { + error: error.stack, + event, + }); + this._exporter.increment(Metrics.METRIC_NAMES.EVENT_PROCESS_FAILURES); + } + } + + // Processes an event received from redis. Will get all hook URLs that + // should receive this event and start the process to perform the callback. + _notifyOutputModules(message, raw) { + if (this.outputs == null || this.outputs.length === 0) { + Logger.warn('no output modules registered'); + return; + } + + Logger.info('notifying output modules', { + event: message, + }); + + this.outputs.forEach((output) => { + output.onEvent(message, raw).catch((error) => { + Logger.error('error notifying output module', { + module: output.name, + error: error?.stack, + errorMessage: error?.message, + event: message, + raw, + }); + this._exporter.increment(Metrics.METRIC_NAMES.EVENT_DISPATCH_FAILURES, { + outputEventId: message?.data?.id || 'unknown', + module: output.name, + }); + }); + }); + } +} diff --git a/src/process/event.js b/src/process/event.js new file mode 100644 index 0000000..24b9155 --- /dev/null +++ b/src/process/event.js @@ -0,0 +1,568 @@ +import { newLogger } from '../common/logger.js'; +import IDMapping from '../db/redis/id-mapping.js'; +import UserMapping from '../db/redis/user-mapping.js'; + +const logger = newLogger('webhook-event'); + +export default class WebhooksEvent { + static OUTPUT_EVENTS = [ + "meeting-created", + "meeting-ended", + "meeting-recording-started", + "meeting-recording-stopped", + "meeting-recording-unhandled", + "meeting-screenshare-started", + "meeting-screenshare-stopped", + "meeting-presentation-changed", + "user-joined", + "user-left", + "user-audio-voice-enabled", + "user-audio-voice-disabled", + "user-audio-muted", + "user-audio-unmuted", + "user-audio-unhandled", + "user-cam-broadcast-start", + "user-cam-broadcast-end", + "user-presenter-assigned", + "user-presenter-unassigned", + "user-emoji-changed", + "user-raise-hand-changed", + "chat-group-message-sent", + "rap-published", + "rap-unpublished", + "rap-deleted", + "pad-content", + "rap-archive-started", + "rap-archive-ended", + "rap-sanity-started", + "rap-sanity-ended", + "rap-post-archive-started", + "rap-post-archive-ended", + "rap-process-started", + "rap-process-ended", + "rap-post-process-started", + "rap-post-process-ended", + "rap-publish-started", + "rap-publish-ended", + "rap-post-publish-started", + "rap-post-publish-ended", + "poll-started", + "poll-responded", + ]; + + static RAW = { + MEETING_EVENTS: [ + "MeetingCreatedEvtMsg", + "MeetingDestroyedEvtMsg", + "ScreenshareRtmpBroadcastStartedEvtMsg", + "ScreenshareRtmpBroadcastStoppedEvtMsg", + "SetCurrentPresentationEvtMsg", + "RecordingStatusChangedEvtMsg", + ], + USER_EVENTS: [ + "UserJoinedMeetingEvtMsg", + "UserLeftMeetingEvtMsg", + "UserMutedVoiceEvtMsg", + "UserJoinedVoiceConfToClientEvtMsg", + "UserLeftVoiceConfToClientEvtMsg", + "PresenterAssignedEvtMsg", + "PresenterUnassignedEvtMsg", + "UserBroadcastCamStartedEvtMsg", + "UserBroadcastCamStoppedEvtMsg", + "UserEmojiChangedEvtMsg", + "UserReactionEmojiChangedEvtMsg", + // 2.7+ + "UserRaiseHandChangedEvtMsg", + ], + CHAT_EVENTS: [ + "GroupChatMessageBroadcastEvtMsg", + ], + RAP_EVENTS: [ + "PublishedRecordingSysMsg", + "UnpublishedRecordingSysMsg", + "DeletedRecordingSysMsg", + ], + COMP_RAP_EVENTS: [ + "archive_started", + "archive_ended", + "sanity_started", + "sanity_ended", + "post_archive_started", + "post_archive_ended", + "process_started", + "process_ended", + "post_process_started", + "post_process_ended", + "publish_started", + "publish_ended", + "post_publish_started", + "post_publish_ended", + "published", + "unpublished", + "deleted", + ], + PAD_EVENTS: [ + "PadContentEvtMsg" + ], + POLL_EVENTS: [ + "PollStartedEvtMsg", + "UserRespondedToPollRespMsg", + ], + } + + constructor(inputEvent) { + this.inputEvent = inputEvent; + this.outputEvent = this.map(); + } + + // Map internal message based on it's type + map() { + if (this.inputEvent) { + if (this.mappedEvent(this.inputEvent, WebhooksEvent.RAW.MEETING_EVENTS)) { + this.meetingTemplate(this.inputEvent); + } else if (this.mappedEvent(this.inputEvent, WebhooksEvent.RAW.USER_EVENTS)) { + this.userTemplate(this.inputEvent); + } else if (this.mappedEvent(this.inputEvent, WebhooksEvent.RAW.CHAT_EVENTS)) { + this.chatTemplate(this.inputEvent); + } else if (this.mappedEvent(this.inputEvent, WebhooksEvent.RAW.RAP_EVENTS)) { + this.rapTemplate(this.inputEvent); + } else if (this.mappedEvent(this.inputEvent, WebhooksEvent.RAW.COMP_RAP_EVENTS)) { + this.compRapTemplate(this.inputEvent); + } else if (this.mappedEvent(this.inputEvent, WebhooksEvent.RAW.PAD_EVENTS)) { + this.padTemplate(this.inputEvent); + } else if (this.mappedEvent(this.inputEvent, WebhooksEvent.RAW.POLL_EVENTS)) { + this.pollTemplate(this.inputEvent); + } else if (this.mappedEvent(this.inputEvent, WebhooksEvent.OUTPUT_EVENTS)) { + // Check if input is already a mapped event and return it + this.outputEvent = this.inputEvent; + } else { + this.outputEvent = null; + } + + if (this.outputEvent) { + logger.debug('output event mapped', { event: this.outputEvent }); + } + + return this.outputEvent; + } + + logger.warn('invalid input event', { event: this.inputEvent }); + + return null; + } + + mappedEvent(messageObj, events) { + return events.some(event => { + if (messageObj?.header?.name === event) { + return true; + } + + if (messageObj?.envelope?.name === event) { + return true; + } + + if (messageObj?.data?.id === event) { + return true; + } + + return false; + }); + } + + // Map internal to external message for meeting information + meetingTemplate(messageObj) { + const props = messageObj.core.body.props; + const meetingId = messageObj.core.body.meetingId || messageObj.core.header.meetingId; + this.outputEvent = { + data: { + "type": "event", + "id": this.mapInternalMessage(messageObj), + "attributes":{ + "meeting":{ + "internal-meeting-id": meetingId, + "external-meeting-id": IDMapping.get().getExternalMeetingID(meetingId) + } + }, + "event":{ + "ts": Date.now() + } + } + } + + switch (messageObj.envelope.name) { + case "MeetingCreatedEvtMsg": + this.outputEvent.data.attributes = { + "meeting":{ + "internal-meeting-id": props.meetingProp.intId, + "external-meeting-id": props.meetingProp.extId, + "name": props.meetingProp.name, + "is-breakout": props.meetingProp.isBreakout, + "parent-id": props.breakoutProps.parentId, + "duration": props.durationProps.duration, + "create-time": props.durationProps.createdTime, + "create-date": props.durationProps.createdDate, + "moderator-pass": props.password.moderatorPass, + "viewer-pass": props.password.viewerPass, + "record": props.recordProp.record, + "voice-conf": props.voiceProp.voiceConf, + "dial-number": props.voiceProp.dialNumber, + "max-users": props.usersProp.maxUsers, + "metadata": props.metadataProp.metadata + } + }; + break; + + case "SetCurrentPresentationEvtMsg": + this.outputEvent.data.attributes = { + "meeting":{ + "internal-meeting-id": meetingId, + "external-meeting-id": IDMapping.get().getExternalMeetingID(meetingId), + "presentation-id": messageObj.core.body.presentationId + } + }; + break; + + case "ScreenshareRtmpBroadcastStartedEvtMsg": { + const presenter = UserMapping.get().getMeetingPresenter(meetingId); + this.outputEvent.data.attributes = { + ...this.outputEvent.data.attributes, + user:{ + "internal-user-id": presenter.internalUserID, + "external-user-id": presenter.externalUserID, + } + }; + break; + } + case "ScreenshareRtmpBroadcastStoppedEvtMsg": { + const owner = UserMapping.get().getMeetingScreenShareOwner(meetingId); + this.outputEvent.data.attributes = { + ...this.outputEvent.data.attributes, + user:{ + "internal-user-id": owner.internalUserID, + "external-user-id": owner.externalUserID, + } + }; + + break; + } + + default: return; + } + } + + handleUserMutedVoice(message) { + try { + const { body } = message.core; + const muted = body.muted; + + if (muted === true) return "user-audio-muted"; + if (muted === false) return "user-audio-unmuted"; + return "user-audio-unhandled"; + } catch (error) { + logger.error('error handling user muted voice', error); + return "user-audio-unhandled"; + } + } + + // Map internal to external message for user information + userTemplate(messageObj) { + const msgBody = messageObj.core.body; + const msgHeader = messageObj.core.header; + const extId = UserMapping.get().getExternalUserID(msgHeader.userId) || msgBody.extId || ""; + this.outputEvent = { + data: { + "type": "event", + "id": this.mapInternalMessage(messageObj), + "attributes":{ + "meeting":{ + "internal-meeting-id": messageObj.envelope.routing.meetingId, + "external-meeting-id": IDMapping.get().getExternalMeetingID(messageObj.envelope.routing.meetingId) + }, + "user":{ + "internal-user-id": msgHeader.userId, + "external-user-id": extId, + "name": msgBody.name, + "role": msgBody.role, + "presenter": msgBody.presenter, + "userdata": msgBody.userdata, + "stream": msgBody.stream + } + }, + "event":{ + "ts": Date.now() + } + } + }; + + switch (this.outputEvent.data.id) { + case "user-audio-voice-enabled": + this.outputEvent.data["attributes"]["user"]["listening-only"] = msgBody.listenOnly; + this.outputEvent.data["attributes"]["user"]["sharing-mic"] = !msgBody.listenOnly; + this.outputEvent.data["attributes"]["user"]["muted"] = msgBody.muted; + break; + case "user-audio-voice-disabled": + this.outputEvent.data["attributes"]["user"]["listening-only"] = false; + this.outputEvent.data["attributes"]["user"]["sharing-mic"] = false; + this.outputEvent.data["attributes"]["user"]["muted"] = true; + break; + case "user-audio-muted": + case "user-audio-unmuted": + this.outputEvent.data["attributes"]["user"]["muted"] = msgBody.muted; + break; + case "user-emoji-changed": { + const emoji = msgBody.emoji || msgBody.reactionEmoji || "none"; + this.outputEvent.data["attributes"]["user"]["emoji"] = emoji; + break; + } + case "user-raise-hand-changed": { + this.outputEvent.data["attributes"]["user"]["raise-hand"] = msgBody.raiseHand; + break; + } + default: + break; + } + } + + // Map internal to external message for chat information + chatTemplate(messageObj) { + const { body } = messageObj.core; + // Ignore private chats + if (body.chatId !== 'MAIN-PUBLIC-GROUP-CHAT') return; + + this.outputEvent = { + data: { + "type": "event", + "id": this.mapInternalMessage(messageObj), + "attributes":{ + "meeting":{ + "internal-meeting-id": messageObj.envelope.routing.meetingId, + "external-meeting-id": IDMapping.get().getExternalMeetingID(messageObj.envelope.routing.meetingId) + }, + "chat-message":{ + "id": body.msg.id, + "message": body.msg.message, + "sender":{ + "internal-user-id": body.msg.sender.id, + "name": body.msg.sender.name, + "time": body.msg.timestamp + } + }, + "chat-id": body.chatId + }, + "event":{ + "ts": Date.now() + } + } + }; + } + + rapTemplate(messageObj) { + const data = messageObj.core.body; + this.outputEvent = { + data: { + "type": "event", + "id": this.mapInternalMessage(messageObj), + "attributes": { + "meeting": { + "internal-meeting-id": data.internalMeetingId, + "external-meeting-id": IDMapping.get().getExternalMeetingID(data.recordId) + }, + "record-id": data.recordId + }, + "event": { + "ts": Date.now() + } + } + }; + } + + compRapTemplate(messageObj) { + const data = messageObj.payload; + this.outputEvent = { + data: { + "type": "event", + "id": this.mapInternalMessage(messageObj), + "attributes": { + "meeting": { + "internal-meeting-id": data.meeting_id, + "external-meeting-id": data.external_meeting_id || IDMapping.get().getExternalMeetingID(data.meeting_id) + } + }, + "event": { + "ts": messageObj.header.current_time + } + }, + }; + + if (this.outputEvent.data.id === "published" || + this.outputEvent.data.id === "unpublished" || + this.outputEvent.data.id === "deleted") { + this.outputEvent.data.attributes["record-id"] = data.meeting_id; + this.outputEvent.data.attributes["format"] = data.format; + } else { + this.outputEvent.data.attributes["record-id"] = data.record_id; + this.outputEvent.data.attributes["success"] = data.success; + this.outputEvent.data.attributes["step-time"] = data.step_time; + } + + if (this.outputEvent.data.id === "rap-archive-ended") { + this.outputEvent.data.attributes["recorded"] = data.recorded || false; + this.outputEvent.data.attributes["duration"] = data.duration || 0; + } + + if (data.workflow) { + this.outputEvent.data.attributes.workflow = data.workflow; + } + + if (this.outputEvent.data.id === "rap-publish-ended") { + this.outputEvent.data.attributes.recording = { + "name": data.metadata.meetingName, + "is-breakout": data.metadata.isBreakout, + "start-time": data.start_time, + "end-time": data.end_time, + "size": data.playback.size, + "raw-size": data.raw_size, + "metadata": data.metadata, + "playback": data.playback, + "download": data.download + } + } + } + + handleRecordingStatusChanged(message) { + const event = "meeting-recording"; + const { core } = message; + if (core && core.body) { + const { recording } = core.body; + if (typeof recording === 'boolean') { + if (recording) return `${event}-started`; + return `${event}-stopped`; + } + } + return `${event}-unhandled`; + } + + padTemplate(messageObj) { + const { + body, + header, + } = messageObj.core; + this.outputEvent = { + data: { + "type": "event", + "id": this.mapInternalMessage(messageObj), + "attributes":{ + "meeting":{ + "internal-meeting-id": header.meetingId, + "external-meeting-id": IDMapping.get().getExternalMeetingID(header.meetingId) + }, + "pad":{ + "id": body.padId, + "external-pad-id": body.externalId, + "rev": body.rev, + "start": body.start, + "end": body.end, + "text": body.text + } + }, + "event":{ + "ts": Date.now() + } + } + }; + } + + pollTemplate(messageObj) { + const { + body, + header, + } = messageObj.core; + const extId = UserMapping.get().getExternalUserID(header.userId) || body.extId || ""; + const pollId = body.pollId || body.poll?.id; + + this.outputEvent = { + data: { + type: "event", + id: this.mapInternalMessage(messageObj), + attributes:{ + meeting:{ + "internal-meeting-id": messageObj.envelope.routing.meetingId, + "external-meeting-id": IDMapping.get().getExternalMeetingID(messageObj.envelope.routing.meetingId) + }, + user:{ + "internal-user-id": header.userId, + "external-user-id": extId, + }, + poll: { + "id": pollId, + } + }, + event: { + "ts": Date.now() + } + } + }; + + if (this.outputEvent.data.id === "poll-started") { + this.outputEvent.data.attributes.poll = { + ...this.outputEvent.data.attributes.poll, + question: body.question, + answers: body.poll.answers, + }; + } else if (this.outputEvent.data.id === "poll-responded") { + this.outputEvent.data.attributes.poll.answerIds = body.answerIds; + } + } + + mapInternalMessage(message) { + const name = message?.envelope?.name || message?.header?.name; + + const mappedMsg = (() => { switch (name) { + case "MeetingCreatedEvtMsg": return "meeting-created"; + case "MeetingDestroyedEvtMsg": return "meeting-ended"; + case "RecordingStatusChangedEvtMsg": return this.handleRecordingStatusChanged(message); + case "ScreenshareRtmpBroadcastStartedEvtMsg": return "meeting-screenshare-started"; + case "ScreenshareRtmpBroadcastStoppedEvtMsg": return "meeting-screenshare-stopped"; + case "SetCurrentPresentationEvtMsg": return "meeting-presentation-changed"; + case "UserJoinedMeetingEvtMsg": return "user-joined"; + case "UserLeftMeetingEvtMsg": return "user-left"; + case "UserJoinedVoiceConfToClientEvtMsg": return "user-audio-voice-enabled"; + case "UserLeftVoiceConfToClientEvtMsg": return "user-audio-voice-disabled"; + case "UserMutedVoiceEvtMsg": return this.handleUserMutedVoice(message); + case "UserBroadcastCamStartedEvtMsg": return "user-cam-broadcast-start"; + case "UserBroadcastCamStoppedEvtMsg": return "user-cam-broadcast-end"; + case "PresenterAssignedEvtMsg": return "user-presenter-assigned"; + case "PresenterUnassignedEvtMsg": return "user-presenter-unassigned"; + case "UserEmojiChangedEvtMsg": + case "UserReactionEmojiChangedEvtMsg": return 'user-emoji-changed'; + case "UserRaiseHandChangedEvtMsg": return "user-raise-hand-changed"; + case "GroupChatMessageBroadcastEvtMsg": return "chat-group-message-sent"; + case "PublishedRecordingSysMsg": return "rap-published"; + case "UnpublishedRecordingSysMsg": return "rap-unpublished"; + case "DeletedRecordingSysMsg": return "rap-deleted"; + case "PadContentEvtMsg": return "pad-content"; + case "PollStartedEvtMsg": return "poll-started"; + case "UserRespondedToPollRespMsg": return "poll-responded"; + // RAP + case "archive_started": return "rap-archive-started"; + case "archive_ended": return "rap-archive-ended"; + case "sanity_started": return "rap-sanity-started"; + case "sanity_ended": return "rap-sanity-ended"; + case "post_archive_started": return "rap-post-archive-started"; + case "post_archive_ended": return "rap-post-archive-ended"; + case "process_started": return "rap-process-started"; + case "process_ended": return "rap-process-ended"; + case "post_process_started": return "rap-post-process-started"; + case "post_process_ended": return "rap-post-process-ended"; + case "publish_started": return "rap-publish-started"; + case "publish_ended": return "rap-publish-ended"; + case "published": return "rap-published"; + case "unpublished": return "rap-unpublished"; + case "deleted": return "rap-deleted"; + case "post_publish_started": return "rap-post-publish-started"; + case "post_publish_ended": return "rap-post-publish-ended"; + } })(); + + return mappedMsg; + } +} diff --git a/test/.mocharc.yml b/test/.mocharc.yml new file mode 100644 index 0000000..c82ed8e --- /dev/null +++ b/test/.mocharc.yml @@ -0,0 +1 @@ +timeout: '5000' diff --git a/test/helpers.js b/test/helpers.js deleted file mode 100644 index d4eb17e..0000000 --- a/test/helpers.js +++ /dev/null @@ -1,46 +0,0 @@ - -const helpers = {}; - -helpers.url = 'http://10.0.3.179'; //serverUrl -helpers.port = ':3005' -helpers.callback = 'http://we2bh.requestcatcher.com' -helpers.callbackURL = '?callbackURL=' + helpers.callback -helpers.apiPath = '/bigbluebutton/api/hooks/' -helpers.createUrl = helpers.port + helpers.apiPath + 'create/' + helpers.callbackURL -helpers.destroyUrl = (id) => { return helpers.port + helpers.apiPath + 'destroy/' + '?hookID=' + id } -helpers.destroyPermanent = helpers.port + helpers.apiPath + 'destroy/' + '?hookID=1' -helpers.createRaw = '&getRaw=true' -helpers.listUrl = 'list/' -helpers.rawMessage = { - envelope: { - name: 'PresenterAssignedEvtMsg', - routing: { - msgType: 'BROADCAST_TO_MEETING', - meetingId: 'a674bb9c6ff92bfa6d5a0a1e530fabb56023932e-1509387833678', - userId: 'w_ysgy0erqgayc' - } - }, - core: { - header: { - name: 'PresenterAssignedEvtMsg', - meetingId: 'a674bb9c6ff92bfa6d5a0a1e530fabb56023932e-1509387833678', - userId: 'w_ysgy0erqgayc' - }, - body: { - presenterId: 'w_ysgy0erqgayc', - presenterName: 'User 4125097', - assignedBy: 'w_vlnwu1wkhena' - } - } -}; - -helpers.flushall = (rClient) => { - let client = rClient; - client.flushdb() -} - -helpers.flushredis = (hook) => { - hook.redisClient.flushdb(); -} - -module.exports = helpers; diff --git a/test/index.js b/test/index.js new file mode 100644 index 0000000..1f5e5e5 --- /dev/null +++ b/test/index.js @@ -0,0 +1,69 @@ +import { describe, before, after, beforeEach } from 'mocha'; +import redis from 'redis'; +import config from 'config'; +import Application from '../application.js'; +import WebhooksSuite, { MOD_CONFIG as WH_CONFIG } from './webhooks/index.js'; +import XAPISuite, { MOD_CONFIG as XAPI_CONFIG } from './xapi/index.js'; + +let MODULES = config.get('modules'); +MODULES = config.util.extendDeep(MODULES, WH_CONFIG, XAPI_CONFIG); + +const IN_REDIS_CONFIG = MODULES['../in/redis/index.js'].config.redis; +const SHARED_SECRET = process.env.SHARED_SECRET + || config.has('bbb.sharedSecret') ? config.get('bbb.sharedSecret') : false + || function () { throw new Error('SHARED_SECRET not set'); }(); +const ALL_TESTS = process.env.ALL_TESTS ? process.env.ALL_TESTS === 'true' : true; +const TEST_CHANNEL = 'test-channel'; + +describe('bbb-webhooks test suite', () => { + const application = new Application(); + const { host, port, password } = config.get('redis'); + const redisUrl = `redis://${password ? `:${password}@` : ''}${host}:${port}`; + const redisClient = redis.createClient({ + url: redisUrl, + }); + + before((done) => { + IN_REDIS_CONFIG.inboundChannels = [...IN_REDIS_CONFIG.inboundChannels, TEST_CHANNEL]; + application.start() + .then(redisClient.connect()) + .then(() => { done(); }) + .catch(done); + }); + + beforeEach((done) => { + redisClient.flushDb(); + done(); + }) + + after((done) => { + redisClient.flushDb(); + done(); + }); + + // Add tests for each module here + + describe('out/webhooks tests', () => { + const context = { + application, + redisClient, + sharedSecret: SHARED_SECRET, + testChannel: TEST_CHANNEL, + force: ALL_TESTS, + }; + + WebhooksSuite(context); + }); + + describe('out/xapi tests', () => { + const context = { + application, + redisClient, + sharedSecret: SHARED_SECRET, + testChannel: TEST_CHANNEL, + force: ALL_TESTS, + }; + + XAPISuite(context); + }); +}); diff --git a/test/mocha.opts b/test/mocha.opts deleted file mode 100644 index cf80ee7..0000000 --- a/test/mocha.opts +++ /dev/null @@ -1 +0,0 @@ ---timeout 5000 diff --git a/test/test.js b/test/test.js deleted file mode 100644 index 03299e7..0000000 --- a/test/test.js +++ /dev/null @@ -1,307 +0,0 @@ -const request = require('supertest'); -const nock = require("nock"); -const Logger = require('../logger.js'); -const utils = require('../utils.js'); -const config = require('config'); -const Hook = require('../hook.js'); -const Helpers = require('./helpers.js') -const sinon = require('sinon'); -const winston = require('winston'); - -Application = require('../application.js'); - -const sharedSecret = process.env.SHARED_SECRET || sharedSecret; - -let globalHooks = config.get('hooks'); - -// Block winston from logging -Logger.remove(winston.transports.Console); -describe('bbb-webhooks tests', () => { - before( (done) => { - globalHooks.queueSize = 10; - globalHooks.permanentURLs = [ { url: "http://wh.requestcatcher.com", getRaw: true } ]; - application = new Application(); - application.start( () => { - done(); - }); - }); - beforeEach( (done) => { - const hooks = Hook.allGlobalSync(); - Helpers.flushall(Application.redisClient()); - hooks.forEach( hook => { - Helpers.flushredis(hook); - }) - done(); - }) - after( () => { - const hooks = Hook.allGlobalSync(); - Helpers.flushall(Application.redisClient()); - hooks.forEach( hook => { - Helpers.flushredis(hook); - }) - }); - - describe('GET /hooks/list permanent', () => { - it('should list permanent hook', (done) => { - let getUrl = utils.checksumAPI(Helpers.url + Helpers.listUrl, sharedSecret); - getUrl = Helpers.listUrl + '?checksum=' + getUrl - - request(Helpers.url) - .get(getUrl) - .expect('Content-Type', /text\/xml/) - .expect(200, (res) => { - const hooks = Hook.allGlobalSync(); - if (hooks && hooks.some( hook => { return hook.permanent }) ) { - done(); - } - else { - done(new Error ("permanent hook was not created")); - } - }) - }) - }); - - describe('GET /hooks/create', () => { - after( (done) => { - const hooks = Hook.allGlobalSync(); - Hook.removeSubscription(hooks[hooks.length-1].id, () => { done(); }); - }); - it('should create a hook', (done) => { - let getUrl = utils.checksumAPI(Helpers.url + Helpers.createUrl, sharedSecret); - getUrl = Helpers.createUrl + '&checksum=' + getUrl - - request(Helpers.url) - .get(getUrl) - .expect('Content-Type', /text\/xml/) - .expect(200, (res) => { - const hooks = Hook.allGlobalSync(); - if (hooks && hooks.some( hook => { return !hook.permanent }) ) { - done(); - } - else { - done(new Error ("hook was not created")); - } - }) - }) - }); - - describe('GET /hooks/destroy', () => { - before( (done) => { - Hook.addSubscription(Helpers.callback,null,null,false,() => { done(); }); - }); - it('should destroy a hook', (done) => { - const hooks = Hook.allGlobalSync(); - const hook = hooks[hooks.length-1].id; - let getUrl = utils.checksumAPI(Helpers.url + Helpers.destroyUrl(hook), sharedSecret); - getUrl = Helpers.destroyUrl(hook) + '&checksum=' + getUrl - - request(Helpers.url) - .get(getUrl) - .expect('Content-Type', /text\/xml/) - .expect(200, (res) => { - const hooks = Hook.allGlobalSync(); - if(hooks && hooks.every( hook => { return hook.callbackURL != Helpers.callback })) - done(); - }) - }) - }); - - describe('GET /hooks/destroy permanent hook', () => { - it('should not destroy the permanent hook', (done) => { - let getUrl = utils.checksumAPI(Helpers.url + Helpers.destroyPermanent, sharedSecret); - getUrl = Helpers.destroyPermanent + '&checksum=' + getUrl - request(Helpers.url) - .get(getUrl) - .expect('Content-Type', /text\/xml/) - .expect(200, (res) => { - const hooks = Hook.allGlobalSync(); - if (hooks && hooks[0].callbackURL == globalHooks.permanentURLs[0].url) { - done(); - } - else { - done(new Error("should not delete permanent")); - } - }) - }) - }); - - describe('GET /hooks/create getRaw hook', () => { - after( (done) => { - const hooks = Hook.allGlobalSync(); - Hook.removeSubscription(hooks[hooks.length-1].id, () => { done(); }); - }); - it('should create a hook with getRaw=true', (done) => { - let getUrl = utils.checksumAPI(Helpers.url + Helpers.createUrl + Helpers.createRaw, sharedSecret); - getUrl = Helpers.createUrl + '&checksum=' + getUrl + Helpers.createRaw - - request(Helpers.url) - .get(getUrl) - .expect('Content-Type', /text\/xml/) - .expect(200, (res) => { - const hooks = Hook.allGlobalSync(); - if (hooks && hooks.some( (hook) => { return hook.getRaw })) { - done(); - } - else { - done(new Error("getRaw hook was not created")) - } - }) - }) - }); - - describe('Hook queues', () => { - before( () => { - Application.redisPubSubClient().psubscribe("test-channel"); - Hook.addSubscription(Helpers.callback,null,null,false, (err,reply) => { - const hooks = Hook.allGlobalSync(); - const hook = hooks[0]; - const hook2 = hooks[hooks.length -1]; - sinon.stub(hook, '_processQueue'); - sinon.stub(hook2, '_processQueue'); - }); - }); - after( () => { - const hooks = Hook.allGlobalSync(); - const hook = hooks[0]; - const hook2 = hooks[hooks.length -1]; - - hook._processQueue.restore(); - hook2._processQueue.restore(); - Hook.removeSubscription(hooks[hooks.length-1].id); - Application.redisPubSubClient().unsubscribe("test-channel"); - }); - it('should have different queues for each hook', (done) => { - Application.redisClient().publish("test-channel", JSON.stringify(Helpers.rawMessage)); - const hooks = Hook.allGlobalSync(); - - if (hooks && hooks[0].queue != hooks[hooks.length-1].queue) { - done(); - } - else { - done(new Error("hooks using same queue")) - } - }) - }); - // reduce queue size, fill queue with requests, try to add another one, if queue does not exceed, OK - describe('Hook queues', () => { - before( () => { - const hooks = Hook.allGlobalSync(); - const hook = hooks[0]; - sinon.stub(hook, '_processQueue'); - }); - after( () => { - const hooks = Hook.allGlobalSync(); - const hook = hooks[0]; - hook._processQueue.restore(); - Helpers.flushredis(hook); - }) - it('should limit queue size to defined in config', (done) => { - let hook = Hook.allGlobalSync(); - hook = hook[0]; - for(i=0;i<=9;i++) { hook.enqueue("message" + i); } - - if (hook && hook.queue.length <= globalHooks.queueSize) { - done(); - } - else { - done(new Error("hooks exceeded max queue size")) - } - }) - }); - - describe('/POST mapped message', () => { - before( () => { - Application.redisPubSubClient().psubscribe("test-channel"); - const hooks = Hook.allGlobalSync(); - const hook = hooks[0]; - hook.queue = []; - Helpers.flushredis(hook); - }); - after( () => { - const hooks = Hook.allGlobalSync(); - const hook = hooks[0]; - Helpers.flushredis(hook); - Application.redisPubSubClient().unsubscribe("test-channel"); - }) - it('should post mapped message ', (done) => { - const hooks = Hook.allGlobalSync(); - const hook = hooks[0]; - - const getpost = nock(globalHooks.permanentURLs[0].url) - .filteringRequestBody( (body) => { - let parsed = JSON.parse(body) - return parsed[0].data.id ? "mapped" : "not mapped"; - }) - .post("/", "mapped") - .reply(200, (res) => { - done(); - }); - Application.redisClient().publish("test-channel", JSON.stringify(Helpers.rawMessage)); - }) - }); - - describe('/POST raw message', () => { - before( () => { - Application.redisPubSubClient().psubscribe("test-channel"); - Hook.addSubscription(Helpers.callback,null,null,true, (err,hook) => { - Helpers.flushredis(hook); - }) - }); - after( () => { - const hooks = Hook.allGlobalSync(); - Hook.removeSubscription(hooks[hooks.length-1].id); - Helpers.flushredis(hooks[hooks.length-1]); - Application.redisPubSubClient().unsubscribe("test-channel"); - }); - it('should post raw message ', (done) => { - const hooks = Hook.allGlobalSync(); - const hook = hooks[0]; - - const getpost = nock(Helpers.callback) - .filteringRequestBody( (body) => { - if (body.indexOf("PresenterAssignedEvtMsg")) { - return "raw message"; - } - else { return "not raw"; } - }) - .post("/", "raw message") - .reply(200, () => { - done(); - }); - const permanent = nock(globalHooks.permanentURLs[0].url) - .post("/") - .reply(200) - Application.redisClient().publish("test-channel", JSON.stringify(Helpers.rawMessage)); - }) - }); - - describe('/POST multi message', () => { - before( () =>{ - const hooks = Hook.allGlobalSync(); - const hook = hooks[0]; - Helpers.flushredis(hook); - hook.queue = ["multiMessage1"]; - }); - it('should post multi message ', (done) => { - const hooks = Hook.allGlobalSync(); - const hook = hooks[0]; - hook.enqueue("multiMessage2") - const getpost = nock(globalHooks.permanentURLs[0].url) - .filteringPath( (path) => { - return path.split('?')[0]; - }) - .filteringRequestBody( (body) => { - if (body.indexOf("multiMessage1") != -1 && body.indexOf("multiMessage2") != -1) { - return "multiMess" - } - else { - return "not multi" - } - }) - .post("/", "multiMess") - .reply(200, (res) => { - done(); - }); - }) - }); -}); diff --git a/test/utils/post-catcher.js b/test/utils/post-catcher.js new file mode 100644 index 0000000..5b72cf8 --- /dev/null +++ b/test/utils/post-catcher.js @@ -0,0 +1,75 @@ +/* eslint no-console: "off" */ +import express from "express"; +import bodyParser from 'body-parser'; +import EventEmitter from 'events'; + +const encodeForUrl = (value) => { + return encodeURIComponent(value) + .replace(/%20/g, '+') + .replace(/[!'()]/g, escape) + .replace(/\*/g, "%2A") +}; + +class PostCatcher extends EventEmitter { + static encodeForUrl = encodeForUrl; + + constructor (url, { + useLogger = false, + path = "/callback", + } = {}) { + super(); + this.url = url; + this.started = false; + this.path = path; + this._parsedUrl = new URL(url); + this.port = this._parsedUrl.port; + this.logger = useLogger ? console : { log: () => {}, error: () => {} }; + if (!this.port) throw new Error("Port not specified in URL"); + } + + start () { + return new Promise((resolve, reject) => { + this.app = express(); + this.app.use(bodyParser.json()); + this.app.use(bodyParser.urlencoded({ + extended: true + })); + this.app.post(this.path, (req, res) => { + try { + this.logger.log("-------------------------------------"); + this.logger.log("* Received:", req.url); + this.logger.log("* Body:", req.body); + this.logger.log("-------------------------------------\n"); + res.statusCode = 200; + res.send(JSON.stringify({ status: "OK" })); + this.emit("callback", req.body); + } catch (error) { + this.logger.error("Error processing callback:", error); + res.statusCode = 500; + res.send(); + this.emit("error", error); + } + }); + + this.server = this.app.listen(this.port, (error) => { + if (error) { + this.logger.error("Error starting server:", error); + reject(error); + return; + } + + this.logger.log("Server listening on", this.url); + this.started = true; + resolve(); + }); + }); + } + + stop () { + this.server.close(); + this.started = false; + this.removeAllListeners(); + } +} + +export default PostCatcher; diff --git a/test/webhooks/helpers.js b/test/webhooks/helpers.js new file mode 100644 index 0000000..7d2c953 --- /dev/null +++ b/test/webhooks/helpers.js @@ -0,0 +1,47 @@ +const helpers = {}; + +helpers.url = 'http://127.0.0.1'; +helpers.port = ':3005'; +helpers.callback = 'http://127.0.0.1:3008/callback'; +helpers.rawCatcherURL = 'http://127.0.0.1:3006/callback'; +helpers.mappedCatcherURL = 'http://127.0.0.1:3007/callback'; +helpers.callbackURL = '?callbackURL=' + helpers.callback +helpers.apiPath = '/bigbluebutton/api/hooks/' +helpers.createUrl = helpers.port + helpers.apiPath + 'create/' + helpers.callbackURL +helpers.destroyUrl = (id) => { return helpers.port + helpers.apiPath + 'destroy/' + '?hookID=' + id } +helpers.destroyPermanent = helpers.port + helpers.apiPath + 'destroy/' + '?hookID=1' +helpers.createRaw = '&getRaw=true' +helpers.createPermanent = '&permanent=true' +helpers.listUrl = 'list/' +helpers.rawMessage = { + envelope: { + name: 'PresenterAssignedEvtMsg', + routing: { + msgType: 'BROADCAST_TO_MEETING', + meetingId: 'a674bb9c6ff92bfa6d5a0a1e530fabb56023932e-1509387833678', + userId: 'w_ysgy0erqgayc' + } + }, + core: { + header: { + name: 'PresenterAssignedEvtMsg', + meetingId: 'a674bb9c6ff92bfa6d5a0a1e530fabb56023932e-1509387833678', + userId: 'w_ysgy0erqgayc' + }, + body: { + presenterId: 'w_ysgy0erqgayc', + presenterName: 'User 4125097', + assignedBy: 'w_vlnwu1wkhena' + } + } +}; + +helpers.flushall = (rClient) => { + rClient.flushDb() +} + +helpers.flushredis = (hook) => { + if (hook?.client) hook.client.flushDb(); +} + +export default helpers; diff --git a/test/webhooks/hooks-post-catcher.js b/test/webhooks/hooks-post-catcher.js new file mode 100644 index 0000000..4b63096 --- /dev/null +++ b/test/webhooks/hooks-post-catcher.js @@ -0,0 +1,49 @@ +/* eslint no-console: "off" */ +import fetch from "node-fetch"; +import crypto from "crypto"; +import PostCatcher from '../utils/post-catcher.js'; + +class HooksPostCatcher extends PostCatcher { + constructor (url, options) { + super(url, options); + } + + async createHook (bbbDomain, sharedSecret, { + getRaw = false, + eventId = null, + meetingId = null, + } = {}) { + if (!this.started) this.start(); + let params = `callbackURL=${HooksPostCatcher.encodeForUrl(this.url)}&getRaw=${getRaw}`; + if (eventId) params += "&eventID=" + eventId; + if (meetingId) params += "&meetingID=" + meetingId; + const checksum = crypto + .createHash('sha1') + .update("hooks/create" + params + sharedSecret).digest('hex'); + const fullUrl = `http://${bbbDomain}/bigbluebutton/api/hooks/create?` + + params + + "&checksum=" + + checksum; + this.logger.log("Registering a hook with", fullUrl); + + const controller = new AbortController(); + const abortTimeout = setTimeout(controller.abort, 2500); + + try { + const response = await fetch(fullUrl, { signal: controller.signal }); + const text = await response.text(); + if (response.ok) { + this.logger.debug("Hook registered - response from hook/create:", text); + } else { + throw new Error(text); + } + } catch (error) { + this.logger.error("Hook registration failed - response from hook/create:", error); + throw error; + } finally { + clearTimeout(abortTimeout); + } + } +} + +export default HooksPostCatcher; diff --git a/test/webhooks/index.js b/test/webhooks/index.js new file mode 100644 index 0000000..e1aaa13 --- /dev/null +++ b/test/webhooks/index.js @@ -0,0 +1,241 @@ +import { describe, it, before, after, beforeEach } from 'mocha'; +import request from 'supertest'; +import config from 'config'; +import Utils from '../../src/out/webhooks/utils.js'; +import Hook from '../../src/db/redis/hooks.js'; +import Helpers from './helpers.js' +import HooksPostCatcher from './hooks-post-catcher.js'; + +const MODULES = config.get('modules'); +const WH_CONFIG = MODULES['../out/webhooks/index.js']?.config; +const CHECKSUM_ALGORITHM = 'sha1'; +const WEBHOOKS_SUITE = process.env.WEBHOOKS_SUITE ? process.env.WEBHOOKS_SUITE === 'true' : false; +const ALL_TESTS = process.env.ALL_TESTS ? process.env.ALL_TESTS === 'true' : true; + +export default function suite({ + redisClient, + sharedSecret, + testChannel, + force, +}) { + if (!WEBHOOKS_SUITE && !force) return; + + before((done) => { + done(); + }); + + beforeEach((done) => { + const hooks = Hook.get().getAllGlobalHooks(); + + hooks.forEach((hook) => { + Helpers.flushredis(hook); + }); + + done(); + }) + + after((done) => { + const hooks = Hook.get().getAllGlobalHooks(); + + hooks.forEach((hook) => { + Helpers.flushredis(hook); + }); + + done(); + }); + + describe('GET /hooks/list permanent', () => { + it('should list permanent hook', (done) => { + let getUrl = Utils.checksumAPI( + Helpers.url + Helpers.listUrl, + sharedSecret, CHECKSUM_ALGORITHM + ); + getUrl = Helpers.listUrl + '?checksum=' + getUrl + + request(Helpers.url) + .get(getUrl) + .expect('Content-Type', /text\/xml/) + .expect(200, () => { + const hooks = Hook.get().getAllGlobalHooks(); + if (hooks && hooks.some(hook => hook.payload.permanent)) { + done(); + } else { + done(new Error ("permanent hook was not created")); + } + }) + }) + }); + + describe('GET /hooks/destroy', () => { + before((done) => { + Hook.get().addSubscription({ + callbackURL: Helpers.callback, + permanent: false, + getRaw: false, + }).then(() => { done(); }).catch(done); + }); + + it('should destroy a hook', (done) => { + const hooks = Hook.get().getAllGlobalHooks(); + const hook = hooks[hooks.length-1].id; + let getUrl = Utils.checksumAPI( + Helpers.url + Helpers.destroyUrl(hook), + sharedSecret, + CHECKSUM_ALGORITHM, + ); + getUrl = Helpers.destroyUrl(hook) + '&checksum=' + getUrl + + request(Helpers.url) + .get(getUrl) + .expect('Content-Type', /text\/xml/) + .expect(200, () => { + const hooks = Hook.get().getAllGlobalHooks(); + if (hooks && hooks.every(hook => hook.payload.callbackURL != Helpers.callback)) done(); + }) + }) + }); + + describe('GET /hooks/destroy permanent hook', () => { + it('should not destroy the permanent hook', (done) => { + let getUrl = Utils.checksumAPI( + Helpers.url + Helpers.destroyPermanent, + sharedSecret, + CHECKSUM_ALGORITHM, + ); getUrl = Helpers.destroyPermanent + '&checksum=' + getUrl + request(Helpers.url) + .get(getUrl) + .expect('Content-Type', /text\/xml/) + .expect(200, () => { + const hooks = Hook.get().getAllGlobalHooks(); + if (hooks && hooks[0].payload.callbackURL == WH_CONFIG.permanentURLs[0].url) { + done(); + } else { + done(new Error("should not delete permanent")); + } + }) + }) + }); + + describe('GET /hooks/create getRaw hook', () => { + after( (done) => { + const hooks = Hook.get().getAllGlobalHooks(); + Hook.get().removeSubscription(hooks[hooks.length-1].id) + .then(() => { done(); }) + .catch(done); + }); + + it('should create a hook with getRaw=true', (done) => { + let getUrl = Utils.checksumAPI( + Helpers.url + Helpers.createUrl + Helpers.createRaw, + sharedSecret, + CHECKSUM_ALGORITHM, + ); + getUrl = Helpers.createUrl + '&checksum=' + getUrl + Helpers.createRaw + + request(Helpers.url) + .get(getUrl) + .expect('Content-Type', /text\/xml/) + .expect(200, () => { + const hooks = Hook.get().getAllGlobalHooks(); + if (hooks && hooks.some((hook) => { return hook.payload.getRaw })) { + done(); + } else { + done(new Error("getRaw hook was not created")) + } + }) + }) + }); + + describe('/POST mapped message', () => { + let catcher; + + before((done) => { + catcher = new HooksPostCatcher(WH_CONFIG.permanentURLs[1].url); + const hooks = Hook.get().getAllGlobalHooks(); + const hook = hooks[0]; + Helpers.flushredis(hook); + catcher.start().then(() => { + done(); + }); + }); + + after((done) => { + const hooks = Hook.get().getAllGlobalHooks(); + const hook = hooks[0]; + Helpers.flushredis(hook); + catcher.stop(); + done(); + }) + + it('should post mapped message ', (done) => { + catcher.once('callback', (body) => { + try { + let parsed = JSON.parse(body?.event); + if (parsed[0].data?.id) { + done(); + } else { + done(new Error("unmapped message")); + } + } catch (error) { + done(error); + } + }); + + redisClient.publish(testChannel, JSON.stringify(Helpers.rawMessage)); + }) + }); + + describe('/POST raw message', () => { + let catcher; + + before((done) => { + catcher = new HooksPostCatcher(WH_CONFIG.permanentURLs[0].url); + const hooks = Hook.get().getAllGlobalHooks(); + const hook = hooks[0]; + Helpers.flushredis(hook); + catcher.start().then(() => { + done(); + }); + }); + + after((done) => { + catcher.stop(); + const hooks = Hook.get().getAllGlobalHooks(); + Hook.get().removeSubscription(hooks[hooks.length-1].id) + .then(() => { done(); }) + .catch(done); + Helpers.flushredis(hooks[hooks.length-1]); + }); + + it('should post raw message ', (done) => { + catcher.once('callback', (body) => { + try { + let parsed = JSON.parse(body?.event); + if (parsed[0]?.envelope?.name == Helpers.rawMessage.envelope.name) { + done(); + } else { + done(new Error("message is not raw")); + } + } catch (error) { + done(error); + } + }); + + redisClient.publish(testChannel, JSON.stringify(Helpers.rawMessage)); + }) + }); +} + +export const MOD_CONFIG = { + '../out/webhooks/index.js': { + enabled: WEBHOOKS_SUITE || ALL_TESTS, + config: { + queueSize: 10, + permanentURLs: [ + { url: Helpers.rawCatcherURL, getRaw: true }, + { url: Helpers.mappedCatcherURL, getRaw: false }, + ], + }, + }, +}; + diff --git a/test/xapi/events.js b/test/xapi/events.js new file mode 100644 index 0000000..21c1b35 --- /dev/null +++ b/test/xapi/events.js @@ -0,0 +1,162 @@ +import { open } from 'node:fs/promises'; +import { fileURLToPath } from 'url'; +import { validateVerb, validateDefinitionType, validateCommonProperties, + validatePlannedDuration, validateResultDuration, validateVirtualClassroomParent, + validatePoll, validatePollResponse, validateRaiseHandEmoji } from './validateFunctions.js'; +const MAPPED_EVENTS_PATH = fileURLToPath( + new URL('../../example/events/mapped-events.json', import.meta.url) +); + +const validEvents = [ + 'chat-group-message-sent', + 'meeting-created', + 'meeting-ended', + 'meeting-screenshare-started', + 'meeting-screenshare-stopped', + 'poll-started', + 'poll-responded', + 'user-audio-muted', + 'user-audio-unmuted', + 'user-audio-voice-disabled', + 'user-audio-voice-enabled', + 'user-joined', + 'user-left', + 'user-cam-broadcast-end', + 'user-cam-broadcast-start', + 'user-raise-hand-changed' +] + +const mapSamplesToEvents = async () => { + const eventList = []; + const mHandle = await open(MAPPED_EVENTS_PATH, 'r'); + + for await (const line of mHandle.readLines()) { + const event = JSON.parse(line) + if (validEvents.includes(event.data.id)){ + eventList.push(event); + } + } + + await mHandle.close(); + + return eventList; +} + +const validators = { + 'meeting-created': (event, statement) => { + return validateVerb(statement, 'http://adlnet.gov/expapi/verbs/initialized') + && validateDefinitionType(statement, 'https://w3id.org/xapi/virtual-classroom/activity-types/virtual-classroom') + && validatePlannedDuration(statement) + && validateCommonProperties(statement); + }, + 'meeting-ended': (event, statement) => { + return validateVerb(statement, 'http://adlnet.gov/expapi/verbs/terminated') + && validateDefinitionType(statement, 'https://w3id.org/xapi/virtual-classroom/activity-types/virtual-classroom') + && validateResultDuration(statement) + && validatePlannedDuration(statement) + && validateCommonProperties(statement); + }, + 'user-joined': (event, statement) => { + return validateVerb(statement, 'http://activitystrea.ms/join') + && validateDefinitionType(statement, 'https://w3id.org/xapi/virtual-classroom/activity-types/virtual-classroom') + && validateCommonProperties(statement); + }, + 'user-left': (event, statement) => { + return validateVerb(statement, 'http://activitystrea.ms/leave') + && validateDefinitionType(statement, 'https://w3id.org/xapi/virtual-classroom/activity-types/virtual-classroom') + && validateCommonProperties(statement); + }, + 'user-audio-voice-enabled': (event, statement) => { + return validateVerb(statement, 'http://activitystrea.ms/start') + && validateDefinitionType(statement, 'https://w3id.org/xapi/virtual-classroom/activity-types/micro') + && validateCommonProperties(statement) + && validateVirtualClassroomParent(statement); + }, + 'user-audio-voice-disabled': (event, statement) => { + return validateVerb(statement, 'https://w3id.org/xapi/virtual-classroom/verbs/stopped') + && validateDefinitionType(statement, 'https://w3id.org/xapi/virtual-classroom/activity-types/micro') + && validateCommonProperties(statement) + && validateVirtualClassroomParent(statement); + }, + 'user-audio-muted': (event, statement) => { + return validateVerb(statement, 'https://w3id.org/xapi/virtual-classroom/verbs/stopped') + && validateDefinitionType(statement, 'https://w3id.org/xapi/virtual-classroom/activity-types/micro') + && validateCommonProperties(statement) + && validateVirtualClassroomParent(statement); + }, + 'user-audio-unmuted': (event, statement) => { + return validateVerb(statement, 'http://activitystrea.ms/start') + && validateDefinitionType(statement, 'https://w3id.org/xapi/virtual-classroom/activity-types/micro') + && validateCommonProperties(statement) + && validateVirtualClassroomParent(statement); + }, + 'user-cam-broadcast-start': (event, statement) => { + return validateVerb(statement, 'http://activitystrea.ms/start') + && validateDefinitionType(statement, 'https://w3id.org/xapi/virtual-classroom/activity-types/camera') + && validateCommonProperties(statement) + && validateVirtualClassroomParent(statement); + }, + 'user-cam-broadcast-end': (event, statement) => { + return validateVerb(statement, 'https://w3id.org/xapi/virtual-classroom/verbs/stopped') + && validateDefinitionType(statement, 'https://w3id.org/xapi/virtual-classroom/activity-types/camera') + && validateCommonProperties(statement) + && validateVirtualClassroomParent(statement); + }, + 'meeting-screenshare-started': (event, statement) => { + return validateVerb(statement, 'http://activitystrea.ms/share') + && validateDefinitionType(statement, 'https://w3id.org/xapi/virtual-classroom/activity-types/screen') + && validateCommonProperties(statement) + && validateVirtualClassroomParent(statement); + }, + 'meeting-screenshare-stopped': (event, statement) => { + return validateVerb(statement, 'http://activitystrea.ms/unshare') + && validateDefinitionType(statement, 'https://w3id.org/xapi/virtual-classroom/activity-types/screen') + && validateCommonProperties(statement) + && validateVirtualClassroomParent(statement); + }, + 'chat-group-message-sent': (event, statement) => { + return validateVerb(statement, 'https://w3id.org/xapi/acrossx/verbs/posted') + && validateDefinitionType(statement, 'https://w3id.org/xapi/acrossx/activities/message') + && validateCommonProperties(statement) + && validateVirtualClassroomParent(statement); + }, + 'poll-started': (event, statement) => { + return validateVerb(statement, 'http://adlnet.gov/expapi/verbs/asked') + && validateDefinitionType(statement, 'http://adlnet.gov/expapi/activities/cmi.interaction') + && validatePoll(statement) + && validateCommonProperties(statement) + && validateVirtualClassroomParent(statement); + }, + 'poll-responded': (event, statement) => { + return validateVerb(statement, 'http://adlnet.gov/expapi/verbs/answered') + && validateDefinitionType(statement, 'http://adlnet.gov/expapi/activities/cmi.interaction') + && validatePoll(statement) + && validateCommonProperties(statement) + && validateVirtualClassroomParent(statement) + && validatePollResponse(statement); + }, + 'user-raise-hand-changed': (event, statement) => { + const raisedHandVerb = 'https://w3id.org/xapi/virtual-classroom/verbs/reacted'; + const loweredHandVerb = 'https://w3id.org/xapi/virtual-classroom/verbs/unreacted'; + const isRaiseHand = event.data.attributes.user['raise-hand']; + return validateVerb(statement, isRaiseHand ? raisedHandVerb : loweredHandVerb) + && validateDefinitionType(statement, 'https://w3id.org/xapi/virtual-classroom/activity-types/virtual-classroom') + && validateCommonProperties(statement) + && validateRaiseHandEmoji(statement); + } +} + +const validate = (event, statement) => { + const eventId = event.data.id; + const validator = validators[eventId]; + + if (!validator) throw new Error(`No validator for eventId '${eventId}'`); + + return validator(event, statement); +} + +export { + mapSamplesToEvents, + validators, + validate, +}; diff --git a/test/xapi/index.js b/test/xapi/index.js new file mode 100644 index 0000000..40e145f --- /dev/null +++ b/test/xapi/index.js @@ -0,0 +1,95 @@ +import { describe, it, before, after } from 'mocha'; +import { mapSamplesToEvents, validate } from './events.js'; +import PostCatcher from '../utils/post-catcher.js'; + +const XAPI_SUITE = process.env.XAPI_SUITE ? process.env.XAPI_SUITE === 'true' : false; +const ALL_TESTS = process.env.ALL_TESTS ? process.env.ALL_TESTS === 'true' : true; +const MOCK_LRS_URL = 'http://127.0.0.1:9009'; + +const generateTestCase = (event, redisClient, channel) => { + const eventId = event.data.id; + + return () => { + let lrsCatcher = new PostCatcher(MOCK_LRS_URL, { + useLogger: false, + path: '/xAPI/statements', + }); + + before((done) => { + lrsCatcher.start().then(() => { + done(); + }).catch((err) => { + done(err); + }); + }); + + after((done) => { + lrsCatcher.stop(); + done(); + }); + + it(`should validate ${eventId}`, (done) => { + lrsCatcher.once('callback', (statement) => { + try { + // Uncomment to debug + //console.debug("Statement received", statement); + const valid = validate(event, statement); + if (!valid) { + done(new Error(`Event ${eventId} is not valid.\n\nStatement: ${JSON.stringify(statement)}`)); + } else { + done(); + } + } catch (error) { + error.message += `\n\nStatement: ${JSON.stringify(statement)}`; + done(error); + } + }); + + redisClient.publish(channel, JSON.stringify(event)); + }); + }; +}; + +export default function suite({ + redisClient, + testChannel, + force, +}) { + if (!XAPI_SUITE && !force) return; + let events = []; + + describe('xapi test generation', () => { + before((done) => { + mapSamplesToEvents().then((mappedEvents) => { + events = mappedEvents; + events.forEach((event) => { + describe(`xapi: ${event.data.id}`, generateTestCase(event, redisClient, testChannel)); + }); + + done(); + }).catch((err) => { + done(err); + }); + }); + + after((done) => { + done(); + }); + + it('should generate xAPI tests', () => {}); + }); +} + +export const MOD_CONFIG = { + '../out/xapi/index.js': { + enabled: XAPI_SUITE || ALL_TESTS, + config: { + lrs: { + lrs_endpoint: MOCK_LRS_URL, + lrs_username: 'admin', + lrs_password: 'admin', + }, + uuid_namespace: '22946e5b-1860-4436-a025-cb133ca4c1d3', + } + } +}; diff --git a/test/xapi/validateFunctions.js b/test/xapi/validateFunctions.js new file mode 100644 index 0000000..478c018 --- /dev/null +++ b/test/xapi/validateFunctions.js @@ -0,0 +1,89 @@ +import { validate as validateUUID } from 'uuid'; +import { DateTime, Duration } from 'luxon'; + +const isValidISODate = (dateString) => DateTime.fromISO(dateString, { zone: 'utc', setZone: true }).isValid; +const isValidISODuration = (durationString) => Duration.fromISO(durationString).isValid; + +const checkCondition = (condition, errorMsg) => { + if (condition === true) return true + else throw new Error(errorMsg) +}; + +const validateVerb = (statement, verbId) => checkCondition( + statement.verb.id === verbId, + `verb.id '${statement.verb.id}' should be '${verbId}'` +); + +const validateDefinitionType = (statement, definitionType) => checkCondition( + statement.object.definition.type === definitionType, + `object.definition.type '${statement.object.definition.type}' should be '${definitionType}'` +); + +const validateCommonProperties = statement => checkCondition( + statement.context.contextActivities.category[0].id == 'https://w3id.org/xapi/virtual-classroom', + `context.contextActivities.category[0].id '${statement.context.contextActivities.category[0].id}' \ +should be 'https://w3id.org/xapi/virtual-classroom'` +) && checkCondition(statement.context.contextActivities.category[0].definition.type == 'http://adlnet.gov/expapi/activities/profile', + `context.contextActivities.category[0].definition.type '${statement.context.contextActivities.category[0].definition.type}' \ +should be 'http://adlnet.gov/expapi/activities/profile'` +) && checkCondition(validateUUID(statement.object.id.substring(statement.object.id.length - 36)), + `object.id '${statement.object.id}' should end in an UUID` +) +&& checkCondition(validateUUID(statement.context.registration), + `context.registration '${statement.context.registration}' should be an UUID`) +&& checkCondition(validateUUID(statement.context.extensions['https://w3id.org/xapi/cmi5/context/extensions/sessionid']), + `context.extensions['https://w3id.org/xapi/cmi5/context/extensions/sessionid' \ +'${statement.context.extensions['https://w3id.org/xapi/cmi5/context/extensions/sessionid']}' should be an UUID`) +&& checkCondition(Object.prototype.hasOwnProperty.call(statement, 'actor'), `actor should be present`) +&& checkCondition(isValidISODate(statement.timestamp), 'timestamp should be a valid ISO date'); + +const validatePlannedDuration = statement => checkCondition( + isValidISODuration(statement.context.extensions['http://id.tincanapi.com/extension/planned-duration']), + `context.extensions['http://id.tincanapi.com/extension/planned-duration'] \ +'${statement.context.extensions['http://id.tincanapi.com/extension/planned-duration']}' \ +should be a valid ISO duration` +); + +const validateResultDuration = statement => checkCondition( + isValidISODuration(statement.result.duration), + `result.duration '${statement.result.duration}' should be a valid ISO duration` +); + +const validateVirtualClassroomParent = statement => checkCondition( + statement.context.contextActivities.parent[0].definition.type === 'https://w3id.org/xapi/virtual-classroom/activity-types/virtual-classroom', + `context.contextActivities.parent[0].definition.type '${statement.context.contextActivities.parent[0].definition.type}' \ +should be 'https://w3id.org/xapi/virtual-classroom/activity-types/virtual-classroom'`) +&& checkCondition( + validateUUID(statement.context.contextActivities.parent[0].id.substring(statement.context.contextActivities.parent[0].id.length - 36)), + `context.contextActivities.parent[0].id '${statement.context.contextActivities.parent[0].id}' should end in an UUID`) + +const validatePoll = statement => checkCondition(statement.object.definition.interactionType === 'choice', + `object.definition.interactionType '${statement.object.definition.interactionType}' should be 'choice'`) + && checkCondition(Array.isArray(statement.object.definition.choices), + `object.definition.choices '${statement.object.definition.choices}' should be an Array`) + +const validatePollResponse = statement => checkCondition( + Object.prototype.hasOwnProperty.call(statement, 'result'), + `statement should have a 'result' property` +) && checkCondition( + Object.prototype.hasOwnProperty.call(statement.result, 'response'), + `result should have a 'response'` +) + +const validateRaiseHandEmoji = statement => checkCondition( + statement.result.extensions['https://w3id.org/xapi/virtual-classroom/extensions/emoji'] === 'U+1F590', + `result.extensions['https://w3id.org/xapi/virtual-classroom/extensions/emoji'] \ +'${statement.result.extensions['https://w3id.org/xapi/virtual-classroom/extensions/emoji']}' should be 'U+1F590'` +); + +export { + validateVerb, + validateDefinitionType, + validateCommonProperties, + validatePlannedDuration, + validateResultDuration, + validateVirtualClassroomParent, + validatePoll, + validatePollResponse, + validateRaiseHandEmoji, +}; diff --git a/userMapping.js b/userMapping.js deleted file mode 100644 index b003d38..0000000 --- a/userMapping.js +++ /dev/null @@ -1,200 +0,0 @@ -const _ = require("lodash"); -const async = require("async"); -const redis = require("redis"); - -const config = require("config"); -const Logger = require("./logger.js"); - -// The database of mappings. Uses the internal ID as key because it is unique -// unlike the external ID. -// Used always from memory, but saved to redis for persistence. -// -// Format: -// { -// internalMeetingID: { -// id: @id -// externalMeetingID: @externalMeetingID -// internalMeetingID: @internalMeetingID -// lastActivity: @lastActivity -// } -// } -// Format on redis: -// * a SET "...:mappings" with all ids (not meeting ids, the object id) -// * a HASH "...:mapping:" for each mapping with all its attributes -const db = {}; -let nextID = 1; - -// A simple model to store mappings for user extIDs. -module.exports = class UserMapping { - - constructor() { - this.id = null; - this.externalUserID = null; - this.internalUserID = null; - this.meetingId = null; - this.user = null; - this.redisClient = Application.redisClient(); - } - - save(callback) { - db[this.internalUserID] = this; - - this.redisClient.hmset(config.get("redis.keys.userMapPrefix") + ":" + this.id, this.toRedis(), (error, reply) => { - if (error != null) { Logger.error(`[UserMapping] error saving mapping to redis: ${error} ${reply}`); } - this.redisClient.sadd(config.get("redis.keys.userMaps"), this.id, (error, reply) => { - if (error != null) { Logger.error(`[UserMapping] error saving mapping ID to the list of mappings: ${error} ${reply}`); } - - (typeof callback === 'function' ? callback(error, db[this.internalUserID]) : undefined); - }); - }); - } - - destroy(callback) { - this.redisClient.srem(config.get("redis.keys.userMaps"), this.id, (error, reply) => { - if (error != null) { Logger.error(`[UserMapping] error removing mapping ID from the list of mappings: ${error} ${reply}`); } - this.redisClient.del(config.get("redis.keys.userMapPrefix") + ":" + this.id, error => { - if (error != null) { Logger.error(`[UserMapping] error removing mapping from redis: ${error}`); } - - if (db[this.internalUserID]) { - delete db[this.internalUserID]; - (typeof callback === 'function' ? callback(error, true) : undefined); - } else { - (typeof callback === 'function' ? callback(error, false) : undefined); - } - }); - }); - } - - toRedis() { - const r = { - "id": this.id, - "internalUserID": this.internalUserID, - "externalUserID": this.externalUserID, - "meetingId": this.meetingId, - "user": this.user - }; - return r; - } - - fromRedis(redisData) { - this.id = parseInt(redisData.id); - this.externalUserID = redisData.externalUserID; - this.internalUserID = redisData.internalUserID; - this.meetingId = redisData.meetingId; - this.user = redisData.user; - } - - print() { - return JSON.stringify(this.toRedis()); - } - - static addOrUpdateMapping(internalUserID, externalUserID, meetingId, user, callback) { - let mapping = new UserMapping(); - mapping.id = nextID++; - mapping.internalUserID = internalUserID; - mapping.externalUserID = externalUserID; - mapping.meetingId = meetingId; - mapping.user = user; - mapping.save(function(error, result) { - Logger.info(`[UserMapping] added user mapping to the list ${internalUserID}: ${mapping.print()}`); - (typeof callback === 'function' ? callback(error, result) : undefined); - }); - } - - static removeMapping(internalUserID, callback) { - return (() => { - let result = []; - for (let internal in db) { - var mapping = db[internal]; - if (mapping.internalUserID === internalUserID) { - result.push(mapping.destroy( (error, result) => { - Logger.info(`[UserMapping] removing user mapping from the list ${internalUserID}: ${mapping.print()}`); - return (typeof callback === 'function' ? callback(error, result) : undefined); - })); - } else { - result.push(undefined); - } - } - return result; - })(); - } - - static removeMappingMeetingId(meetingId, callback) { - return (() => { - let result = []; - for (let internal in db) { - var mapping = db[internal]; - if (mapping.meetingId === meetingId) { - result.push(mapping.destroy( (error, result) => { - Logger.info(`[UserMapping] removing user mapping from the list ${mapping.internalUserID}: ${mapping.print()}`); - })); - } else { - result.push(undefined); - } - } - return (typeof callback === 'function' ? callback() : undefined); - })(); - } - - static getUser(internalUserID) { - if (db[internalUserID]){ - return db[internalUserID].user; - } - } - - static getExternalUserID(internalUserID) { - if (db[internalUserID]){ - return db[internalUserID].externalUserID; - } - } - - static allSync() { - let arr = Object.keys(db).reduce(function(arr, id) { - arr.push(db[id]); - return arr; - } - , []); - return arr; - } - - // Initializes global methods for this model. - static initialize(callback) { - UserMapping.resync(callback); - } - - // Gets all mappings from redis to populate the local database. - // Calls `callback()` when done. - static resync(callback) { - let client = Application.redisClient(); - let tasks = []; - - return client.smembers(config.get("redis.keys.userMaps"), (error, mappings) => { - if (error != null) { Logger.error(`[UserMapping] error getting list of mappings from redis: ${error}`); } - - mappings.forEach(id => { - tasks.push(done => { - client.hgetall(config.get("redis.keys.userMapPrefix") + ":" + id, function(error, mappingData) { - if (error != null) { Logger.error(`[UserMapping] error getting information for a mapping from redis: ${error}`); } - - if (mappingData != null) { - let mapping = new UserMapping(); - mapping.fromRedis(mappingData); - mapping.save(function(error, hook) { - if (mapping.id >= nextID) { nextID = mapping.id + 1; } - done(null, mapping); - }); - } else { - done(null, null); - } - }); - }); - }); - - return async.series(tasks, function(errors, result) { - mappings = _.map(UserMapping.allSync(), m => m.print()); - Logger.info(`[UserMapping] finished resync, mappings registered: ${mappings}`); - return (typeof callback === 'function' ? callback() : undefined); - }); - }); - } -}; diff --git a/utils.js b/utils.js deleted file mode 100644 index 77c8442..0000000 --- a/utils.js +++ /dev/null @@ -1,68 +0,0 @@ -const sha1 = require("sha1"); -const url = require("url"); - -const config = require("config"); - -const Utils = exports; - -// Calculates the checksum given a url `fullUrl` and a `salt`, as calculate by bbb-web. -Utils.checksumAPI = function(fullUrl, salt) { - const query = Utils.queryFromUrl(fullUrl); - const method = Utils.methodFromUrl(fullUrl); - return Utils.checksum(method + query + salt); -}; - -// Calculates the checksum for a string. -// Just a wrapper for the method that actually does it. -Utils.checksum = string => sha1(string); - -// Get the query of an API call from the url object (from url.parse()) -// Example: -// -// * `fullUrl` = `http://bigbluebutton.org/bigbluebutton/api/create?name=Demo+Meeting&meetingID=Demo` -// * returns: `name=Demo+Meeting&meetingID=Demo` -Utils.queryFromUrl = function(fullUrl) { - - // Returns the query without the checksum. - // We can't use url.parse() because it would change the encoding - // and the checksum wouldn't match. We need the url exactly as - // the client sent us. - let query = fullUrl.replace(/&checksum=[^&]*/, ''); - query = query.replace(/checksum=[^&]*&/, ''); - query = query.replace(/checksum=[^&]*$/, ''); - const matched = query.match(/\?(.*)/); - if (matched != null) { - return matched[1]; - } else { - return ''; - } -}; - -// Get the method name of an API call from the url object (from url.parse()) -// Example: -// -// * `fullUrl` = `http://mconf.org/bigbluebutton/api/create?name=Demo+Meeting&meetingID=Demo` -// * returns: `create` -Utils.methodFromUrl = function(fullUrl) { - const urlObj = url.parse(fullUrl, true); - return urlObj.pathname.substr((config.get("bbb.apiPath") + "/").length); -}; - -// Returns the IP address of the client that made a request `req`. -// If can not determine the IP, returns `127.0.0.1`. -Utils.ipFromRequest = function(req) { - - // the first ip in the list if the ip of the client - // the others are proxys between him and us - let ipAddress; - if ((req.headers != null ? req.headers["x-forwarded-for"] : undefined) != null) { - let ips = req.headers["x-forwarded-for"].split(","); - ipAddress = ips[0] != null ? ips[0].trim() : undefined; - } - - // fallbacks - if (!ipAddress) { ipAddress = req.headers != null ? req.headers["x-real-ip"] : undefined; } // when behind nginx - if (!ipAddress) { ipAddress = req.connection != null ? req.connection.remoteAddress : undefined; } - if (!ipAddress) { ipAddress = "127.0.0.1"; } - return ipAddress; -}; diff --git a/web_hooks.js b/web_hooks.js deleted file mode 100644 index ca55409..0000000 --- a/web_hooks.js +++ /dev/null @@ -1,149 +0,0 @@ -const _ = require("lodash"); -const async = require("async"); -const redis = require("redis"); -const request = require("request"); -const config = require("config"); -const Hook = require("./hook.js"); -const IDMapping = require("./id_mapping.js"); -const Logger = require("./logger.js"); -const MessageMapping = require("./messageMapping.js"); -const UserMapping = require("./userMapping.js"); - -// Web hooks will listen for events on redis coming from BigBlueButton and -// perform HTTP calls with them to all registered hooks. -module.exports = class WebHooks { - - constructor() { - this.subscriberEvents = Application.redisPubSubClient(); - } - - start(callback) { - this._subscribeToEvents(); - typeof callback === 'function' ? callback(null,"w") : undefined; - } - - // Subscribe to the events on pubsub that might need to be sent in callback calls. - _subscribeToEvents() { - this.subscriberEvents.on("psubscribe", (channel, count) => Logger.info(`[WebHooks] subscribed to: ${channel}`)); - - this.subscriberEvents.on("pmessage", (pattern, channel, message) => { - - let raw; - const processMessage = () => { - Logger.info(`[WebHooks] processing message on [${channel}]: ${JSON.stringify(message)}`); - this._processEvent(message, raw); - }; - - try { - raw = JSON.parse(message); - let messageMapped = new MessageMapping(); - messageMapped.mapMessage(JSON.parse(message)); - message = messageMapped.mappedObject; - if (!_.isEmpty(message)) { - const intId = message.data.attributes.meeting["internal-meeting-id"]; - IDMapping.reportActivity(intId); - - // First treat meeting events to add/remove ID mappings - switch (message.data.id) { - case "meeting-created": - Logger.info(`[WebHooks] got create message on meetings channel [${channel}]: ${message}`); - IDMapping.addOrUpdateMapping(intId, message.data.attributes.meeting["external-meeting-id"], (error, result) => { - // has to be here, after the meeting was created, otherwise create calls won't generate - // callback calls for meeting hooks - processMessage(); - }); - break; - case "user-joined": - UserMapping.addOrUpdateMapping(message.data.attributes.user["internal-user-id"],message.data.attributes.user["external-user-id"], intId, message.data.attributes.user, () => { - processMessage(); - }); - break; - case "user-left": - UserMapping.removeMapping(message.data.attributes.user["internal-user-id"], () => { processMessage(); }); - break; - case "meeting-ended": - UserMapping.removeMappingMeetingId(intId, () => { processMessage(); }); - break; - default: - processMessage(); - } - } - } catch (e) { - Logger.error(`[WebHooks] error processing the message ${JSON.stringify(raw)}: ${e.message}`); - } - }); - - config.get("hooks.channels").forEach((channel) => { - this.subscriberEvents.psubscribe(channel); - }); - - } - - // Send raw data to hooks that are not expecting mapped messages - _processRaw(message) { - let idFromMessage; - let hooks = Hook.allGlobalSync(); - - // Add hooks for the specific meeting that expect raw data - // Get meetingId for a raw message that was previously mapped by another webhook application or if it's straight from redis - idFromMessage = this._findMeetingID(message); - if (idFromMessage != null) { - const eMeetingID = IDMapping.getExternalMeetingID(idFromMessage); - hooks = hooks.concat(Hook.findByExternalMeetingIDSync(eMeetingID)); - // Notify the hooks that expect raw data - async.forEach(hooks, (hook) => { - if (hook.getRaw) { - Logger.info(`[WebHooks] enqueueing a raw message in the hook: ${hook.callbackURL}`); - hook.enqueue(message); - } - }); - } // Put foreach inside the if to avoid pingpong events - } - - _findMeetingID(message) { - if (message.data) { - return message.data.attributes.meeting["internal-meeting-id"]; - } - if (message.payload) { - return message.payload.meeting_id; - } - if (message.envelope && message.envelope.routing && message.envelope.routing.meetingId) { - return message.envelope.routing.meetingId; - } - if (message.header && message.header.body && message.header.body.meetingId) { - return message.header.body.meetingId; - } - if (message.core && message.core.body) { - return message.core.body.props ? message.core.body.props.meetingProp.intId : message.core.body.meetingId; - } - return undefined; - } - - // Processes an event received from redis. Will get all hook URLs that - // should receive this event and start the process to perform the callback. - _processEvent(message, raw) { - // Get all global hooks - let hooks = Hook.allGlobalSync(); - - // filter the hooks that need to receive this event - // add hooks that are registered for this specific meeting - const idFromMessage = message.data != null ? message.data.attributes.meeting["internal-meeting-id"] : undefined; - if (idFromMessage != null) { - const eMeetingID = IDMapping.getExternalMeetingID(idFromMessage); - hooks = hooks.concat(Hook.findByExternalMeetingIDSync(eMeetingID)); - } - - // Notify every hook asynchronously, if hook N fails, it won't block hook N+k from receiving its message - async.forEach(hooks, (hook) => { - if (!hook.getRaw) { - Logger.info(`[WebHooks] enqueueing a message in the hook: ${hook.callbackURL}`); - hook.enqueue(message); - } - }); - - const sendRaw = hooks.some(hook => { return hook.getRaw }); - if (sendRaw && config.get("hooks.getRaw")) { - this._processRaw(raw); - } - } -}; diff --git a/web_server.js b/web_server.js deleted file mode 100644 index 5807c77..0000000 --- a/web_server.js +++ /dev/null @@ -1,177 +0,0 @@ -const _ = require("lodash"); -const express = require("express"); -const url = require("url"); - -const config = require("config"); -const Hook = require("./hook.js"); -const Logger = require("./logger.js"); -const Utils = require("./utils.js"); -const responses = require("./responses.js") - -// Web server that listens for API calls and process them. -module.exports = class WebServer { - - constructor() { - this._validateChecksum = this._validateChecksum.bind(this); - this.app = express(); - this._registerRoutes(); - } - - start(port, bind, callback) { - this.server = this.app.listen(port, bind, () => { - if (this.server.address() == null) { - Logger.error(`[WebServer] aborting, could not bind to port ${port} ${process.exit(1)}`); - } - Logger.info(`[WebServer] listening on port ${port} in ${this.app.settings.env.toUpperCase()} mode`); - typeof callback === 'function' ? callback(null,"k") : undefined; - }); - } - - _registerRoutes() { - // Request logger - this.app.all("*", function(req, res, next) { - if (!fromMonit(req)) { - Logger.info(`[WebServer] ${req.method} request to ${req.url} from: ${clientDataSimple(req)}`); - } - next(); - }); - - this.app.get("/bigbluebutton/api/hooks/create", this._validateChecksum, this._create); - this.app.get("/bigbluebutton/api/hooks/destroy", this._validateChecksum, this._destroy); - this.app.get("/bigbluebutton/api/hooks/list", this._validateChecksum, this._list); - this.app.get("/bigbluebutton/api/hooks/ping", function(req, res) { - res.write("bbb-webhooks up!"); - res.end(); - }); - } - - _create(req, res, next) { - const urlObj = url.parse(req.url, true); - const callbackURL = urlObj.query["callbackURL"]; - const meetingID = urlObj.query["meetingID"]; - const eventID = urlObj.query["eventID"]; - let getRaw = urlObj.query["getRaw"]; - if (getRaw){ - getRaw = JSON.parse(getRaw.toLowerCase()); - } else { - getRaw = false; - } - - if (callbackURL == null) { - respondWithXML(res, responses.missingParamCallbackURL); - } else { - Hook.addSubscription(callbackURL, meetingID, eventID, getRaw, function(error, hook) { - let msg; - if (error != null) { // the only error for now is for duplicated callbackURL - msg = responses.createDuplicated(hook.id); - } else if (hook != null) { - msg = responses.createSuccess(hook.id, hook.permanent, hook.getRaw); - } else { - msg = responses.createFailure; - } - respondWithXML(res, msg); - }); - } - } - // Create a permanent hook. Permanent hooks can't be deleted via API and will try to emit a message until it succeed - createPermanents(callback) { - for (let i = 0; i < config.get("hooks.permanentURLs").length; i++) { - Hook.addSubscription(config.get("hooks.permanentURLs")[i].url, null, null, config.get("hooks.permanentURLs")[i].getRaw, function(error, hook) { - if (error != null) { // there probably won't be any errors here - Logger.info(`[WebServer] duplicated permanent hook ${error}`); - } else if (hook != null) { - Logger.info('[WebServer] permanent hook created successfully'); - } else { - Logger.info('[WebServer] error creating permanent hook'); - } - }); - } - typeof callback === 'function' ? callback(null,"p") : undefined; - } - - _destroy(req, res, next) { - const urlObj = url.parse(req.url, true); - const hookID = urlObj.query["hookID"]; - - if (hookID == null) { - respondWithXML(res, responses.missingParamHookID); - } else { - Hook.removeSubscription(hookID, function(error, result) { - let msg; - if (error != null) { - msg = responses.destroyFailure; - } else if (!result) { - msg = responses.destroyNoHook; - } else { - msg = responses.destroySuccess; - } - respondWithXML(res, msg); - }); - } - } - - _list(req, res, next) { - let hooks; - const urlObj = url.parse(req.url, true); - const meetingID = urlObj.query["meetingID"]; - - if (meetingID != null) { - // all the hooks that receive events from this meeting - hooks = Hook.allGlobalSync(); - hooks = hooks.concat(Hook.findByExternalMeetingIDSync(meetingID)); - hooks = _.sortBy(hooks, hook => hook.id); - } else { - // no meetingID, return all hooks - hooks = Hook.allSync(); - } - - let msg = "SUCCESS"; - hooks.forEach(function(hook) { - msg += ""; - msg += `${hook.id}`; - msg += ``; - if (!hook.isGlobal()) { msg += ``; } - if (hook.eventID != null) { msg += `${hook.eventID.join()}`; } - msg += `${hook.permanent}`; - msg += `${hook.getRaw}`; - msg += ""; - }); - msg += ""; - - respondWithXML(res, msg); - } - - // Validates the checksum in the request `req`. - // If it doesn't match BigBlueButton's shared secret, will send an XML response - // with an error code just like BBB does. - _validateChecksum(req, res, next) { - const urlObj = url.parse(req.url, true); - const checksum = urlObj.query["checksum"]; - const sharedSecret = config.get("bbb.sharedSecret"); - - if (checksum === Utils.checksumAPI(req.url, sharedSecret)) { - next(); - } else { - Logger.info('[WebServer] checksum check failed, sending a checksumError response'); - res.setHeader("Content-Type", "text/xml"); - res.send(cleanupXML(responses.checksumError)); - } - } -}; - -var respondWithXML = function(res, msg) { - msg = cleanupXML(msg); - Logger.info(`[WebServer] respond with: ${msg}`); - res.setHeader("Content-Type", "text/xml"); - res.send(msg); -}; - -// Returns a simple string with a description of the client that made -// the request. It includes the IP address and the user agent. -var clientDataSimple = req => `ip ${Utils.ipFromRequest(req)}, using ${req.headers["user-agent"]}`; - -// Cleans up a string with an XML in it removing spaces and new lines from between the tags. -var cleanupXML = string => string.trim().replace(/>\s*/g, '>'); - -// Was this request made by monit? -var fromMonit = req => (req.headers["user-agent"] != null) && req.headers["user-agent"].match(/^monit/);