diff --git a/.envrc b/.envrc new file mode 100644 index 000000000..5bf8fc159 --- /dev/null +++ b/.envrc @@ -0,0 +1,3 @@ +source_url "https://raw.githubusercontent.com/cachix/devenv/95f329d49a8a5289d31e0982652f7058a189bfca/direnvrc" "sha256-d+8cBpDfDBj41inrADaJt+bDWhOktwslgoP5YiGJ1v0=" + +use devenv \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index c4fad7c14..000000000 --- a/.eslintrc.js +++ /dev/null @@ -1,60 +0,0 @@ -module.exports = { - root: true, - parser: '@typescript-eslint/parser', - plugins: [ - '@typescript-eslint' - ], - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - ], - // eslint-config-preact needs a Jest version to be happy, even if Jest isn't used. - // See https://github.com/preactjs/eslint-config-preact/issues/19#issuecomment-997924892 - settings: { - jest: { "version": 27 }, - }, - rules: { - "@typescript-eslint/explicit-module-boundary-types": "off", - "@typescript-eslint/no-explicit-any": "warn", - "@typescript-eslint/no-unused-vars": "warn", - "camelcase": ["error", { "properties": "never", "ignoreDestructuring": true }], - "no-console": "error" - }, - env: { - node: true, - es6: true, - }, - overrides: [ - { - files: ["test/**/*.ts"], - parser: '@typescript-eslint/parser', - plugins: [ - '@typescript-eslint', - 'mocha', - ], - }, - { - files: ["web/**/*.ts", "web/**/*.tsx"], - parser: '@typescript-eslint/parser', - env: { - browser: true, - node: false, - }, - extends: [ - 'plugin:@typescript-eslint/recommended', - 'preact', - ], - plugins: [ - '@typescript-eslint', - ], - rules: { - "no-console": "off", - "no-unused-vars": "off", - "@typescript-eslint/no-explicit-any": "warn", - "@typescript-eslint/no-unused-vars": ["error"], - "no-useless-constructor": "off", - "@typescript-eslint/no-useless-constructor": ["error"], - }, - } - ] -}; \ No newline at end of file diff --git a/.github/workflows/devenv.yml b/.github/workflows/devenv.yml new file mode 100644 index 000000000..439ba8702 --- /dev/null +++ b/.github/workflows/devenv.yml @@ -0,0 +1,25 @@ +name: Devenv + +on: + push: + branches: [ main ] + paths: + - "/devenv*" + pull_request: + branches: [ main ] + paths: + - "/devenv*" + +jobs: + test-nix-env: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: cachix/install-nix-action@v26 + - uses: cachix/cachix-action@v14 + with: + name: devenv + - name: Install devenv.sh + run: nix profile install nixpkgs#devenv + - name: Build the devenv shell and run any git hooks + run: devenv test \ No newline at end of file diff --git a/.github/workflows/docker-hub-latest.yml b/.github/workflows/docker-hub-latest.yml deleted file mode 100644 index 99fdb1196..000000000 --- a/.github/workflows/docker-hub-latest.yml +++ /dev/null @@ -1,56 +0,0 @@ -# Based on https://github.com/matrix-org/dendrite/blob/master/.github/workflows/docker-hub.yml - -name: "Docker Hub - Latest" - -on: - push: - paths-ignore: - - changelog.d/**' - pull_request: - branches: [ main ] - paths-ignore: - - changelog.d/**' - merge_group: - - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - -env: - DOCKER_NAMESPACE: halfshot - PLATFORMS: linux/amd64 - PLATFORMS_PUSH: linux/amd64,linux/arm64 - # Only push if this is main, otherwise we just want to build - PUSH: ${{ github.ref == 'refs/heads/main' }} - -jobs: - docker-latest: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v3 - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - name: Log in to Docker Hub - if: github.ref == 'refs/heads/main' - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_TOKEN }} - - name: Build image - uses: docker/build-push-action@v3 - with: - context: . - file: ./Dockerfile - platforms: ${{ (env.PUSH == 'true' && env.PLATFORMS_PUSH) || env.PLATFORMS }} - push: ${{ env.PUSH }} - tags: | - ${{ env.DOCKER_NAMESPACE }}/matrix-hookshot:latest - - # arm64 builds OOM without the git fetch setting. c.f. - # https://github.com/rust-lang/cargo/issues/10583 - build-args: | - CARGO_NET_GIT_FETCH_WITH_CLI=true diff --git a/.github/workflows/docker-hub-release.yml b/.github/workflows/docker-hub-release.yml deleted file mode 100644 index acf58f1d9..000000000 --- a/.github/workflows/docker-hub-release.yml +++ /dev/null @@ -1,47 +0,0 @@ -# Based on https://github.com/matrix-org/dendrite/blob/master/.github/workflows/docker-hub.yml - -name: "Docker Hub - Release" - -on: - release: - types: [published] - -env: - DOCKER_NAMESPACE: halfshot - PLATFORMS: linux/amd64,linux/arm64 - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - docker-release: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v3 - - name: Get release tag - run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - name: Log in to Docker Hub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_TOKEN }} - - name: Build image - uses: docker/build-push-action@v3 - with: - context: . - file: ./Dockerfile - platforms: ${{ env.PLATFORMS }} - push: true - tags: | - ${{ env.DOCKER_NAMESPACE }}/matrix-hookshot:${{ env.RELEASE_VERSION }} - - # arm64 builds OOM without the git fetch setting. c.f. - # https://github.com/rust-lang/cargo/issues/10583 - build-args: | - CARGO_NET_GIT_FETCH_WITH_CLI=true \ No newline at end of file diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 000000000..7de620230 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,84 @@ +# Based on https://github.com/matrix-org/dendrite/blob/master/.github/workflows/docker-hub.yml + +name: "Docker" + +on: + push: + paths-ignore: + - changelog.d/**' + pull_request: + branches: [ main ] + paths-ignore: + - changelog.d/**' + release: + types: [published] + + merge_group: + + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +env: + DOCKER_NAMESPACE: halfshot + PLATFORMS: linux/amd64 + PLATFORMS_PUSH: linux/amd64,linux/arm64 + # Only push if this is main, otherwise we just want to build + BUILD_FOR_ALL_PLATFORMS: ${{ github.ref == 'refs/heads/main' }} + +jobs: + docker-latest: + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + attestations: write + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + - name: Log in to the GitHub Container registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + tags: | + type=semver,pattern={{version}} + type=ref,event=branch + type=ref,event=pr + type=raw,value=latest,enable={{is_default_branch}} + images: | + ${{ env.DOCKER_NAMESPACE }}/matrix-hookshot + ghcr.io/matrix-org/matrix-hookshot + + - name: Build and push Docker images + id: push + uses: docker/build-push-action@v6 + with: + context: . + # arm64 builds OOM without the git fetch setting. c.f. + # https://github.com/rust-lang/cargo/issues/10583 + build-args: | + CARGO_NET_GIT_FETCH_WITH_CLI=true + platforms: ${{ (env.BUILD_FOR_ALL_PLATFORMS == 'true' && env.PLATFORMS_PUSH) || env.PLATFORMS }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 978e61b8d..fadfacb89 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -34,10 +34,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - uses: actions-rs/toolchain@v1 + - uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: stable - profile: minimal components: rustfmt - run: cargo fmt --all -- --check - run: cargo clippy -- -Dwarnings @@ -66,23 +65,22 @@ jobs: test: # Test on LTS-1 - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: - node_version: [20, 21] + node_version: [22, 23] steps: - uses: actions/checkout@v3 - name: Use Node.js ${{ matrix.node_version }} uses: actions/setup-node@v3 with: node-version: ${{ matrix.node_version }} - - uses: actions-rs/toolchain@v1 + - uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: stable - profile: minimal - uses: Swatinem/rust-cache@v2 with: - shared-key: rust-cache + shared-key: ubuntu-2204-rust-cache - run: yarn - run: yarn test:cover @@ -92,7 +90,7 @@ jobs: homerunnersha: ${{ steps.gitsha.outputs.sha }} steps: - name: Checkout matrix-org/complement - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: matrix-org/complement - name: Get complement git sha @@ -100,7 +98,7 @@ jobs: run: echo sha=`git rev-parse --short HEAD` >> "$GITHUB_OUTPUT" - name: Cache homerunner id: cached - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: homerunner key: ${{ runner.os }}-homerunner-${{ steps.gitsha.outputs.sha }} @@ -125,23 +123,28 @@ jobs: needs: - test - build-homerunner + services: + redis: + image: redis + ports: + - 6379:6379 steps: - name: Install Complement Dependencies run: | sudo apt-get update && sudo apt-get install -y libolm3 - name: Load cached homerunner bin - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: homerunner key: ${{ runner.os }}-homerunner-${{ needs.build-synapse.outputs.homerunnersha }} fail-on-cache-miss: true # Shouldn't happen, we build this in the needs step. - name: Checkout matrix-hookshot - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: path: matrix-hookshot # Setup node & run tests - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version-file: matrix-hookshot/.node-version - uses: Swatinem/rust-cache@v2 @@ -152,8 +155,9 @@ jobs: timeout-minutes: 10 env: HOMERUNNER_SPAWN_HS_TIMEOUT_SECS: 100 - HOMERUNNER_IMAGE: ghcr.io/element-hq/synapse/complement-synapse:latest + HOMERUNNER_IMAGE: ghcr.io/element-hq/synapse/complement-synapse:nightly NODE_OPTIONS: --dns-result-order ipv4first + REDIS_DATABASE_URI: "redis://localhost:6379" run: | docker pull $HOMERUNNER_IMAGE cd matrix-hookshot diff --git a/.github/workflows/newsfile.yml b/.github/workflows/newsfile.yml index ce32d6398..bd1a21906 100644 --- a/.github/workflows/newsfile.yml +++ b/.github/workflows/newsfile.yml @@ -8,6 +8,8 @@ on: jobs: changelog: runs-on: ubuntu-latest + # No newsfile required for dependabot + if: ${{ github.actor != 'dependabot[bot]' }} steps: - uses: actions/checkout@v3 with: # Needed for comparison diff --git a/.gitignore b/.gitignore index 6eeecd5db..0db7bb407 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,10 @@ public/ # Generated during build /src/libRs.d.ts +# Generated during test run +/spec-lib +/hookshot-int-* + book *.cer *.pem @@ -20,4 +24,11 @@ book # Coverage coverage -.nyc_output \ No newline at end of file +.nyc_output + +# Devenv +.devenv* +devenv.local.nix + +# direnv +.direnv \ No newline at end of file diff --git a/.node-version b/.node-version index 209e3ef4b..2bd5a0a98 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -20 +22 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bda7153d..7478d48e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,97 @@ +# 6.0.0 (2024-11-29) + +### Features + +- Add support for setting an expiry time on a webhook. See the documentation on [Generic Webhooks](https://matrix-org.github.io/matrix-hookshot/latest/setup/webhooks.html) for more information. ([\#984](https://github.com/matrix-org/matrix-hookshot/issues/984)) +- Support for E2E Encrypted rooms is now considered stable and can be enabled in production. Please see the [documentation](https://matrix-org.github.io/matrix-hookshot/latest/advanced/encryption.html) + on the requirements for enabling support. ([\#989](https://github.com/matrix-org/matrix-hookshot/issues/989)) + +### Bugfixes + +- Fix Challenge Hound activities being duplicated if the cache layer (e.g Redis) goes away. ([\#982](https://github.com/matrix-org/matrix-hookshot/issues/982)) + +### Deprecations and Removals + +- Drop support for Node 20 and start supporting Node 22, 23. ([\#990](https://github.com/matrix-org/matrix-hookshot/issues/990)) + +### Internal Changes + +- Reduce bundle size of widget. ([\#985](https://github.com/matrix-org/matrix-hookshot/issues/985)) +- Don't invoke newsfile CI check for dependabot. ([\#987](https://github.com/matrix-org/matrix-hookshot/issues/987)) +- Add devenv development files. ([\#993](https://github.com/matrix-org/matrix-hookshot/issues/993)) +- Push Docker images to ghcr.io, and and build an image on each commit. ([\#994](https://github.com/matrix-org/matrix-hookshot/issues/994)) +- Retry e2e tests in CI due to container creation flakiness. ([\#995](https://github.com/matrix-org/matrix-hookshot/issues/995)) +- Update matrix-bot-sdk to pull in new matrix-rust-sdk. ([\#996](https://github.com/matrix-org/matrix-hookshot/issues/996)) + + +5.4.1 (2024-06-21) +================== + +Internal Changes +---------------- + +- Pin the minor version of Node for Docker builds to avoid a startup crash on arm64. ([\#949](https://github.com/matrix-org/matrix-hookshot/issues/949)) + + +5.4.0 (2024-06-20) +================== + +Features +-------- + +- Add support for reopened GitLab MR. ([\#935](https://github.com/matrix-org/matrix-hookshot/issues/935)) +- Add support for new connection type "Outgoing Webhooks". This feature allows you to send outgoing HTTP requests to other services + when a message appears in a Matrix room. See [the documentation](https://matrix-org.github.io/matrix-hookshot/latest/setup/webhooks.html) + for help with this feature. ([\#945](https://github.com/matrix-org/matrix-hookshot/issues/945)) + + +Bugfixes +-------- + +- Fix GitLab's ready for review hook. ([\#936](https://github.com/matrix-org/matrix-hookshot/issues/936)) +- Fix rendering of comments of GitLab merge requests. ([\#937](https://github.com/matrix-org/matrix-hookshot/issues/937)) +- Fix the symbol used to prefix GitLab merge requests. ([\#938](https://github.com/matrix-org/matrix-hookshot/issues/938)) + + +5.3.0 (2024-04-17) +================== + +Features +-------- + +- Add support for Challenge Hound. ([\#924](https://github.com/matrix-org/matrix-hookshot/issues/924)) + + +Bugfixes +-------- + +- Ensure generic webhooks have appropriate Content-Security-Policy headers. ([\#926](https://github.com/matrix-org/matrix-hookshot/issues/926)) +- Fix a few bugs introduced in challenge hound support. ([\#927](https://github.com/matrix-org/matrix-hookshot/issues/927)) +- Track which key was used to encrypt secrets in storage, and encrypt/decrypt secrets in Rust. ([\#929](https://github.com/matrix-org/matrix-hookshot/issues/929), [\#930](https://github.com/matrix-org/matrix-hookshot/issues/930)) + + +Improved Documentation +---------------------- + +- Fixes the OpenID Connect call back URI in the config defaults and docs. ([\#899](https://github.com/matrix-org/matrix-hookshot/issues/899)) +- Clarify permissions system documentation. ([\#925](https://github.com/matrix-org/matrix-hookshot/issues/925)) + + +Deprecations and Removals +------------------------- + +- The cache/queue configuration has been changed in this release. The `queue.monolithic` option has been deprecated, in place of a dedicated `cache` + config section. Check the ["Cache configuration" section](https://matrix-org.github.io/matrix-hookshot/latest/setup.html#cache-configuration) for + more information on how to configure Hookshot caches. ([\#902](https://github.com/matrix-org/matrix-hookshot/issues/902)) + + +Internal Changes +---------------- + +- Switch expressjs to production mode for improved performance. ([\#904](https://github.com/matrix-org/matrix-hookshot/issues/904)) +- Track which key was used to encrypt secrets in storage, and encrypt/decrypt secrets in Rust. ([\#915](https://github.com/matrix-org/matrix-hookshot/issues/915)) + + 5.2.1 (2024-02-21) ================== diff --git a/Cargo.lock b/Cargo.lock index 247829c0f..1f735a7f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -51,6 +51,12 @@ dependencies = [ "quick-xml", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.1.0" @@ -78,6 +84,18 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + [[package]] name = "bitflags" version = "1.3.2" @@ -112,10 +130,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2ef034f05691a48569bd920a96c81b9d91bbad1ab5ac7c4616c1f6ef36cb79f" [[package]] -name = "bytes" +name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" [[package]] name = "cc" @@ -138,6 +162,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "contrast" version = "0.1.0" @@ -173,6 +203,15 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +[[package]] +name = "cpufeatures" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +dependencies = [ + "libc", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -228,6 +267,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "der" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fffa369a668c8af7dbf8b5e56c9f744fbd399949ed171606040001947de40b1c" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "derive_builder" version = "0.12.0" @@ -266,6 +316,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", ] @@ -417,15 +468,15 @@ checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" [[package]] name = "h2" -version = "0.3.24" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb2c4422095b67ee78da96fbb51a4cc413b3b25883c7717ff7ca1ab31022c9c9" +checksum = "ccae279728d634d083c00f6099cb58f01cc99c145b84b8be2f6c74618d79922e" dependencies = [ + "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "futures-util", "http", "indexmap", "slab", @@ -468,9 +519,9 @@ dependencies = [ [[package]] name = "http" -version = "0.2.11" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8947b1a6fad4393052c7ba1f4cd97bed3e953a95c79c92ad9b051a04611d9fbb" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" dependencies = [ "bytes", "fnv", @@ -479,12 +530,24 @@ dependencies = [ [[package]] name = "http-body" -version = "0.4.6" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" dependencies = [ "bytes", + "futures-util", "http", + "http-body", "pin-project-lite", ] @@ -494,47 +557,76 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - [[package]] name = "hyper" -version = "0.14.28" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf96e135eb83a2a8ddf766e426a841d8ddd7449d5f00d34ea02b41d2f19eef80" +checksum = "97818827ef4f364230e16705d4706e2897df2bb60617d6ca15d598025a3c481f" dependencies = [ "bytes", "futures-channel", - "futures-core", "futures-util", "h2", "http", "http-body", "httparse", - "httpdate", "itoa", "pin-project-lite", - "socket2", + "smallvec", "tokio", - "tower-service", - "tracing", "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" +dependencies = [ + "futures-util", + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-tls" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", + "http-body-util", "hyper", + "hyper-util", "native-tls", "tokio", "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", ] [[package]] @@ -608,6 +700,9 @@ name = "lazy_static" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +dependencies = [ + "spin 0.5.2", +] [[package]] name = "libc" @@ -625,6 +720,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "libm" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" + [[package]] name = "linux-raw-sys" version = "0.4.13" @@ -669,9 +770,10 @@ dependencies = [ [[package]] name = "matrix-hookshot" -version = "5.2.1" +version = "5.4.1" dependencies = [ "atom_syndication", + "base64ct", "contrast", "hex", "md-5", @@ -681,11 +783,13 @@ dependencies = [ "rand", "reqwest", "rgb", + "rsa", "rss", "ruma", "serde", "serde_derive", "serde_json", + "sha1", "url", ] @@ -722,9 +826,9 @@ dependencies = [ [[package]] name = "mio" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", "wasi", @@ -821,6 +925,43 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54" +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d869c01cc0c455284163fd0092f1f93835385ccab5a98a0dcc497b2f8bf055a9" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.18" @@ -828,6 +969,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -857,9 +999,9 @@ checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] name = "openssl" -version = "0.10.64" +version = "0.10.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f" +checksum = "9529f4786b70a3e8c61e11179af17ab6188ad8d0ded78c5529441ed39d4bd9c1" dependencies = [ "bitflags 2.4.2", "cfg-if", @@ -889,9 +1031,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-sys" -version = "0.9.100" +version = "0.9.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae94056a791d0e1217d18b6cbdccb02c61e3054fc69893607f4067e3bb0b1fd1" +checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6" dependencies = [ "cc", "libc", @@ -922,6 +1064,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -1020,6 +1171,27 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.30" @@ -1146,11 +1318,11 @@ checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" [[package]] name = "reqwest" -version = "0.11.24" +version = "0.12.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6920094eb85afde5e4a138be3f2de8bbdf28000f0029e72c45025a56b042251" +checksum = "a77c62af46e79de0a562e1a9849205ffcb7fc1238876e9bd743357570e04046f" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -1158,8 +1330,11 @@ dependencies = [ "h2", "http", "http-body", + "http-body-util", "hyper", + "hyper-rustls", "hyper-tls", + "hyper-util", "ipnet", "js-sys", "log", @@ -1181,7 +1356,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "winreg", + "windows-registry", ] [[package]] @@ -1193,6 +1368,42 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "spin 0.9.8", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rss" version = "2.0.7" @@ -1226,7 +1437,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3bca4c33c50e47b4cdceeac71bdef0c04153b0e29aa992d9030ec14a62323e85" dependencies = [ "as_variant", - "base64", + "base64 0.21.7", "bytes", "form_urlencoded", "indexmap", @@ -1326,13 +1537,43 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustls" +version = "0.23.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9cc1d47e243d655ace55ed38201c19ae02c148ae56412ab8750e8f0166ab7f" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + [[package]] name = "rustls-pemfile" -version = "1.0.4" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b" + +[[package]] +name = "rustls-webpki" +version = "0.102.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" dependencies = [ - "base64", + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -1450,6 +1691,38 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + [[package]] name = "siphasher" version = "0.3.11" @@ -1481,6 +1754,28 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "string_cache" version = "0.8.7" @@ -1513,6 +1808,12 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + [[package]] name = "syn" version = "1.0.109" @@ -1537,26 +1838,29 @@ dependencies = [ [[package]] name = "sync_wrapper" -version = "0.1.2" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "system-configuration" -version = "0.5.1" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.4.2", "core-foundation", "system-configuration-sys", ] [[package]] name = "system-configuration-sys" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" dependencies = [ "core-foundation-sys", "libc", @@ -1646,6 +1950,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +dependencies = [ + "rustls", + "rustls-pki-types", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.10" @@ -1770,6 +2085,12 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.0" @@ -1896,6 +2217,36 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "495ec47bf3c1345005f40724f0269362c8556cbc43aed0526ed44cae1d35fceb" +[[package]] +name = "windows-registry" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" +dependencies = [ + "windows-result", + "windows-strings", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -1911,7 +2262,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.0", + "windows-targets 0.52.6", ] [[package]] @@ -1931,17 +2282,18 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.0", - "windows_aarch64_msvc 0.52.0", - "windows_i686_gnu 0.52.0", - "windows_i686_msvc 0.52.0", - "windows_x86_64_gnu 0.52.0", - "windows_x86_64_gnullvm 0.52.0", - "windows_x86_64_msvc 0.52.0", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -1952,9 +2304,9 @@ checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" @@ -1964,9 +2316,9 @@ checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" @@ -1976,9 +2328,15 @@ checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" @@ -1988,9 +2346,9 @@ checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" @@ -2000,9 +2358,9 @@ checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" @@ -2012,9 +2370,9 @@ checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" @@ -2024,9 +2382,9 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" -version = "0.52.0" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" @@ -2038,11 +2396,7 @@ dependencies = [ ] [[package]] -name = "winreg" -version = "0.50.0" +name = "zeroize" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] +checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" diff --git a/Cargo.toml b/Cargo.toml index 5475bb53a..4ef9d6cd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "matrix-hookshot" -version = "5.2.1" +version = "5.4.1" edition = "2021" [lib] @@ -20,8 +20,10 @@ hex = "0.4" rss = "2.0" atom_syndication = "0.12" ruma = { version = "0.9", features = ["events", "html"] } -reqwest = "0.11" +reqwest = "0.12.9" rand = "0.8.5" - +rsa = { version = "0.9.6", features = ["sha2"] } +base64ct = { version = "1.6.0", features = ["alloc"] } +sha1 = "0.10.6" [build-dependencies] napi-build = "2" diff --git a/Dockerfile b/Dockerfile index fa087a3bb..ed423069c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # Stage 0: Build the thing # Need debian based image to build the native rust module # as musl doesn't support cdylib -FROM node:20-slim AS builder +FROM node:22-slim AS builder # Needed in order to build rust FFI bindings. RUN apt-get update && apt-get install -y build-essential cmake curl pkg-config pkg-config libssl-dev @@ -29,7 +29,7 @@ RUN yarn build # Stage 1: The actual container -FROM node:20-slim +FROM node:22-slim WORKDIR /bin/matrix-hookshot @@ -45,6 +45,8 @@ COPY --from=builder /src/lib ./ COPY --from=builder /src/public ./public COPY --from=builder /src/assets ./assets +ENV NODE_ENV="production" + VOLUME /data EXPOSE 9993 EXPOSE 7775 diff --git a/README.md b/README.md index f84622ff8..9fa854d35 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ A Matrix bot for connecting to external services like GitHub, GitLab, JIRA, and We richly support the following integrations: +- [Challenge Hound](https://matrix-org.github.io/matrix-hookshot/latest/setup/challengehound.html) - [Figma](https://matrix-org.github.io/matrix-hookshot/latest/setup/figma.html) - [Generic Webhooks](https://matrix-org.github.io/matrix-hookshot/latest/setup/webhooks.html) - [GitHub](https://matrix-org.github.io/matrix-hookshot/latest/setup/github.html) diff --git a/config.sample.yml b/config.sample.yml index 7f8055f45..c2fafe5c2 100644 --- a/config.sample.yml +++ b/config.sample.yml @@ -7,16 +7,16 @@ bridge: mediaUrl: https://example.com port: 9993 bindAddress: 127.0.0.1 -passFile: - # A passkey used to encrypt tokens stored inside the bridge. - # Run openssl genpkey -out passkey.pem -outform PEM -algorithm RSA -pkeyopt rsa_keygen_bits:4096 to generate - passkey.pem logging: # Logging settings. You can have a severity debug,info,warn,error level: info colorize: true json: false timestampFormat: HH:mm:ss:SSS +passFile: + # A passkey used to encrypt tokens stored inside the bridge. + # Run openssl genpkey -out passkey.pem -outform PEM -algorithm RSA -pkeyopt rsa_keygen_bits:4096 to generate + ./passkey.pem listeners: # HTTP Listener configuration. # Bind resource endpoints to ports and addresses. @@ -37,6 +37,25 @@ listeners: resources: - widgets +#cache: +# # (Optional) Cache options for large scale deployments. +# # For encryption to work, this must be configured. +# redisUri: redis://localhost:6379 + +#encryption: +# # (Optional) Configuration for encryption support in the bridge. +# # If omitted, encryption support will be disabled. +# storagePath: +# # Path to the directory used to store encryption files. These files must be persist between restarts of the service. +# ./cryptostore + +#permissions: +# # (Optional) Permissions for using the bridge. See docs/setup.md#permissions for help +# - actor: example.com +# services: +# - service: "*" +# level: admin + #github: # # (Optional) Configure this to enable GitHub support # auth: @@ -50,7 +69,7 @@ listeners: # # (Optional) Settings for allowing users to sign in via OAuth. # client_id: foo # client_secret: bar -# redirect_uri: https://example.com/bridge_oauth/ +# redirect_uri: https://example.com/oauth/ # defaultOptions: # # (Optional) Default options for GitHub connections. # showIssueRoomLink: false @@ -75,15 +94,6 @@ listeners: # # (Optional) Aggregate comments by waiting this many miliseconds before posting them to Matrix. Defaults to 5000 (5 seconds) # 5000 -#figma: -# # (Optional) Configure this to enable Figma support -# publicUrl: https://example.com/hookshot/ -# instances: -# your-instance: -# teamId: your-team-id -# accessToken: your-personal-access-token -# passcode: your-webhook-passcode - #jira: # # (Optional) Configure this to enable Jira support. Only specify `url` if you are using a On Premise install (i.e. not atlassian.com) # webhook: @@ -93,29 +103,38 @@ listeners: # # (Optional) OAuth settings for connecting users to JIRA. See documentation for more information # client_id: foo # client_secret: bar -# redirect_uri: https://example.com/bridge_oauth/ +# redirect_uri: https://example.com/oauth/ #generic: # # (Optional) Support for generic webhook events. # #'allowJsTransformationFunctions' will allow users to write short transformation snippets in code, and thus is unsafe in untrusted environments # enabled: false -# enableHttpGet: false +# outbound: false # urlPrefix: https://example.com/webhook/ # userIdPrefix: _webhooks_ # allowJsTransformationFunctions: false # waitForComplete: false +# enableHttpGet: false +# sendExpiryNotice: false +# requireExpiryTime: false +# maxExpiryTime: 30d + +#figma: +# # (Optional) Configure this to enable Figma support +# publicUrl: https://example.com/hookshot/ +# instances: +# your-instance: +# teamId: your-team-id +# accessToken: your-personal-access-token +# passcode: your-webhook-passcode #feeds: # # (Optional) Configure this to enable RSS/Atom feed support # enabled: false -# pollConcurrency: 4 # pollIntervalSeconds: 600 # pollTimeoutSeconds: 30 - -#provisioning: -# # (Optional) Provisioning API for integration managers -# secret: "!secretToken" +# pollConcurrency: 4 #bot: # # (Optional) Define profile information for the bot user @@ -130,20 +149,12 @@ listeners: # prefix: "!feeds" # service: feeds -#metrics: -# # (Optional) Prometheus metrics support -# enabled: true - -#queue: -# # (Optional) Message queue / cache configuration options for large scale deployments. -# # For encryption to work, must be set to monolithic mode and have a host & port specified. -# monolithic: true -# port: 6379 -# host: localhost - #widgets: # # (Optional) EXPERIMENTAL support for complimentary widgets # addToAdminRooms: false +# publicUrl: https://example.com/widgetapi/v1/static/ +# roomSetupWidget: +# addOnInvite: false # disallowedIpRanges: # - 127.0.0.0/8 # - 10.0.0.0/8 @@ -164,23 +175,21 @@ listeners: # - 2001:db8::/32 # - ff00::/8 # - fec0::/10 -# roomSetupWidget: -# addOnInvite: false -# publicUrl: https://example.com/widgetapi/v1/static/ # branding: # widgetTitle: Hookshot Configuration +#provisioning: +# # (Optional) Provisioning API for integration managers +# secret: "!secretToken" + +#metrics: +# # (Optional) Prometheus metrics support +# enabled: true + #sentry: # # (Optional) Configure Sentry error reporting # dsn: https://examplePublicKey@o0.ingest.sentry.io/0 # environment: production -#permissions: -# # (Optional) Permissions for using the bridge. See docs/setup.md#permissions for help -# - actor: example.com -# services: -# - service: "*" -# level: admin - diff --git a/devenv.lock b/devenv.lock new file mode 100644 index 000000000..32571958a --- /dev/null +++ b/devenv.lock @@ -0,0 +1,175 @@ +{ + "nodes": { + "devenv": { + "locked": { + "dir": "src/modules", + "lastModified": 1732585607, + "owner": "cachix", + "repo": "devenv", + "rev": "a520f05c40ebecaf5e17064b27e28ba8e70c49fb", + "treeHash": "18c0c037ca6ab98acc83bf17a1abefac00d77e65", + "type": "github" + }, + "original": { + "dir": "src/modules", + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "fenix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "rust-analyzer-src": "rust-analyzer-src" + }, + "locked": { + "lastModified": 1732689334, + "owner": "nix-community", + "repo": "fenix", + "rev": "a8a983027ca02b363dfc82fbe3f7d9548a8d3dce", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "fenix", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1732722421, + "owner": "edolstra", + "repo": "flake-compat", + "rev": "9ed2ac151eada2306ca8c418ebd97807bb08f6ac", + "treeHash": "89dbb8f9bb36708ea4455ee2225a9c60c950f2f7", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "pre-commit-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "treeHash": "ca14199cabdfe1a06a7b1654c76ed49100a689f9", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1716977621, + "owner": "cachix", + "repo": "devenv-nixpkgs", + "rev": "4267e705586473d3e5c8d50299e71503f16a6fb6", + "treeHash": "6d9f1f7ca0faf1bc2eeb397c78a49623260d3412", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "rolling", + "repo": "devenv-nixpkgs", + "type": "github" + } + }, + "nixpkgs-stable": { + "locked": { + "lastModified": 1732632634, + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "6f6076c37180ea3a916f84928cf3a714c5207a30", + "treeHash": "1e15b0cb2baeffc40a2c957508052031b879c401", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-24.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-upstream": { + "locked": { + "lastModified": 1732350895, + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "0c582677378f2d9ffcb01490af2f2c678dcb29d3", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-24.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "pre-commit-hooks": { + "inputs": { + "flake-compat": "flake-compat", + "gitignore": "gitignore", + "nixpkgs": [ + "nixpkgs" + ], + "nixpkgs-stable": "nixpkgs-stable" + }, + "locked": { + "lastModified": 1732021966, + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "rev": "3308484d1a443fc5bc92012435d79e80458fe43c", + "treeHash": "434cf02841a1faf9e53437604ffa4b9a92669bff", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "fenix": "fenix", + "nixpkgs": "nixpkgs", + "nixpkgs-upstream": "nixpkgs-upstream", + "pre-commit-hooks": "pre-commit-hooks" + } + }, + "rust-analyzer-src": { + "flake": false, + "locked": { + "lastModified": 1732633904, + "owner": "rust-lang", + "repo": "rust-analyzer", + "rev": "8d5e91c94f80c257ce6dbdfba7bd63a5e8a03fa6", + "type": "github" + }, + "original": { + "owner": "rust-lang", + "ref": "nightly", + "repo": "rust-analyzer", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/devenv.nix b/devenv.nix new file mode 100644 index 000000000..ca0089cef --- /dev/null +++ b/devenv.nix @@ -0,0 +1,24 @@ +{ pkgs, lib, config, inputs, ... }: +let + pkgs-upstream = import inputs.nixpkgs-upstream { system = pkgs.stdenv.system; }; +in +{ + packages = [ pkgs.git pkgs.gcc pkgs.pkg-config pkgs.openssl ]; + + # https://devenv.sh/tests/ + enterTest = '' + echo "Running tests" + yarn + ''; + + # https://devenv.sh/services/ + services.redis.enable = true; + + # https://devenv.sh/languages/ + languages.typescript.enable = true; + languages.javascript.yarn.enable = true; + languages.javascript.enable = true; + languages.javascript.package = pkgs-upstream.nodejs_22; + languages.rust.enable = true; + languages.rust.channel = "stable"; +} diff --git a/devenv.yaml b/devenv.yaml new file mode 100644 index 000000000..c53502e25 --- /dev/null +++ b/devenv.yaml @@ -0,0 +1,10 @@ +inputs: + nixpkgs: + url: github:cachix/devenv-nixpkgs/rolling + fenix: + url: github:nix-community/fenix + inputs: + nixpkgs: + follows: nixpkgs + nixpkgs-upstream: + url: github:NixOS/nixpkgs/nixos-24.11 \ No newline at end of file diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 19f934a5a..836b8617c 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -9,6 +9,7 @@ - [GitLab](./setup/gitlab.md) - [JIRA](./setup/jira.md) - [Webhooks](./setup/webhooks.md) + - [ChallengeHound](./setup/challengehound.md) - [👤 Usage](./usage.md) - [Dynamic Rooms](./usage/dynamic_rooms.md) - [Authenticating](./usage/auth.md) diff --git a/docs/icons/feeds.png b/docs/_site/icons/feeds.png similarity index 100% rename from docs/icons/feeds.png rename to docs/_site/icons/feeds.png diff --git a/docs/icons/figma.png b/docs/_site/icons/figma.png similarity index 100% rename from docs/icons/figma.png rename to docs/_site/icons/figma.png diff --git a/docs/icons/github.png b/docs/_site/icons/github.png similarity index 100% rename from docs/icons/github.png rename to docs/_site/icons/github.png diff --git a/docs/icons/gitlab.png b/docs/_site/icons/gitlab.png similarity index 100% rename from docs/icons/gitlab.png rename to docs/_site/icons/gitlab.png diff --git a/docs/_site/icons/hound.png b/docs/_site/icons/hound.png new file mode 100644 index 000000000..4d72dbdb5 Binary files /dev/null and b/docs/_site/icons/hound.png differ diff --git a/docs/icons/jira.png b/docs/_site/icons/jira.png similarity index 100% rename from docs/icons/jira.png rename to docs/_site/icons/jira.png diff --git a/docs/icons/sentry.png b/docs/_site/icons/sentry.png similarity index 100% rename from docs/icons/sentry.png rename to docs/_site/icons/sentry.png diff --git a/docs/icons/webhooks.png b/docs/_site/icons/webhooks.png similarity index 100% rename from docs/icons/webhooks.png rename to docs/_site/icons/webhooks.png diff --git a/docs/_site/style.css b/docs/_site/style.css index c63806baf..eee11b93f 100644 --- a/docs/_site/style.css +++ b/docs/_site/style.css @@ -26,33 +26,39 @@ font-weight: 700; } -/* icons for headers */ +/* icons for headers */ +/* We use base64 to avoid having to deal with pathing issues. */ .chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(2) strong:after { - content: ' ' url('/matrix-hookshot/latest/icons/feeds.png'); + content: ' ' url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAABGlBMVEXqeBnqeBnqeBnqdxfqdhXvl030tYLxpWbuj0DrfCDqdhbqeBj0uYj////99Oz51rnxpGTrfSHwnlr40bD64cz+9/H//Pr40K/tjDvqeRvqehzrfiLuk0f2w5n99e375dLuk0j40rP0uIbukUPvmlL75NHtizr0uYf1wJTrgCftjj775dP4z63qdxjukEH0toP52Lz++/j40rLvm1T+9e/xomDpdBLqeh3yqW398un1wJP2xp3407Xqex7rfSLxpGXxo2Pyqm7++/n98+vvlUv++PPtjTzxp2j52b70t4TrfyT75ND//v7xomHxqGv//fz//Pvxpmf0uIX40bH41LXzs33rfiPyqWzukUT0u4vxp2nwoV/1vI3vl04j+OXeAAAAAnRSTlNd4NmMDFkAAAABYktHRA32tGH1AAAAB3RJTUUH5gQWESsnQqV9+QAAAM1JREFUGNNjYGBkQgKMDEA+MwsLM5IIEzMrGzsHJxc3VISBiYWHl5ePX0CQGSbALCQsIsorJi7BDBVgkpSSlpGV45VXYIaqYFdUUpZUkedVVWOGmKEONENDU0ubV4eTGySgq6dvYMhrpGlsYmjKBTaD2czcwpLXStKa18YWJMBtZ+9g6+jkrOfiaunGDBTgchczFLD14PX08vbxZQEJ+PkHBJoF8Qabh/CGggS4w8L97LgiIqMko2NigVoYmbi5gB7R5WJmYgZisHdRvA8A4+sWdRXffIcAAAAldEVYdGRhdGU6Y3JlYXRlADIwMjItMDQtMjJUMTc6NDI6MDcrMDA6MDAJz20rAAAAJXRFWHRkYXRlOm1vZGlmeQAyMDIyLTA0LTIyVDE3OjQyOjA3KzAwOjAweJLVlwAAAABJRU5ErkJggg=='); } .chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(3) strong:after { - content: ' ' url('/matrix-hookshot/latest/icons/figma.png'); + content: ' ' url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABcWlDQ1BpY2MAACiRdZE9S8NQFIbftkpFKx3qIOKQoYpDC0VFOmoduhQptYJVl+Q2aYUkDTcpUlwFF4eCg+ji1+A/0FVwVRAERRBx8gf4tUiJ5zaFFmlPuDkP7z3v4d5zAX9GZ4bdlwAM0+G5dEpaLaxJwXf4EEIEs0jKzLYWstkMesbPI1VTPMRFr951XWOoqNoM8A0QzzGLO8TzxJktxxK8RzzCynKR+IQ4xumAxLdCVzx+E1zy+Eswz+cWAb/oKZU6WOlgVuYG8RRx1NCrrHUecZOQaq4sUx6jNQ4bOaSRggQFVWxCh4M4ZZNm1t2XaPqWUCEPo7+FGjg5SiiTN0ZqlbqqlDXSVfp01MTc/8/T1mamve6hFND/6rqfE0BwH2jUXff31HUbZ0DgBbg22/4KzSn5TXq9rUWPgfAOcHnT1pQD4GoXGH22ZC43pQAtv6YBHxfAcAGI3AOD696sWvs4fwLy2/REd8DhETBJ9eGNPzxOaCbSjI5SAAAACXBIWXMAAAsSAAALEgHS3X78AAABAUlEQVQ4T6WSPRIBQRCFu3sTCSVUiswF3GBUCclFbDmCXIDYGTYQkG0gQ5W9AXcQCGUEmDb+p836qfJF+95uv9l51QB/gq/GppJTHlFoHtNnnSyUny+RI+wHpacBQLbYVPO+GZ7DbdiBUXGrybYlAjzEwNa/IAKAORL6B0RAarwS94tF644tnRIHNVaaIES+9lAfImame0Xohebyy3WZ3pcY1NhnhPl9+ExmcvDN8KVYc5rKzvSnEsEpkYgcz0YEMENk64sHrmcjAhojdEp8vTPw8XOJiUVPmdTHJm6LbecbG/EHiUXXN8b7TYxBBBD8uYnfCotDBOyKbVGYBrl1cZwAOfxEBEfXmiUAAAAASUVORK5CYII='); } .chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(4) strong:after { - content: ' ' url('/matrix-hookshot/latest/icons/github.png'); + content: ' ' url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABcWlDQ1BpY2MAACiRdZE9S8NQFIbftkpFKx3qIOKQoYpDC8UvHLUOXYqUWsGqS3KbtEKShpsUKa6Ci0PBQXTxa/Af6Cq4KgiCIog4+QP8WqTEc5tCi7Qn3JyH9573cO+5gD+tM8PuSQCG6fBsKimt5tek4Dt8CCGCGUzLzLYWMpk0usbPI1VTPMRFr+51HWOgoNoM8PURzzKLO8TzxOktxxK8RzzESnKB+IQ4xumAxLdCVzx+E1z0+Eswz2UXAb/oKRXbWGljVuIG8QRx1NArrHkecZOQaq4sUx6hNQobWaSQhAQFFWxCh4M4ZZNm1tmXaPiWUCYPo7+FKjg5iiiRN0ZqhbqqlDXSVfp0VMXc/8/T1qYmve6hJND76rqfY0BwH6jXXPf31HXrZ0DgBbg2W/4yzWnum/RaS4seA+Ed4PKmpSkHwNUuMPxsyVxuSAFafk0DPi6AwTwQuQf6171ZNfdx/gTktumJ7oDDI2Cc6sMbfzXVaCPmlJtVAAAACXBIWXMAAC4jAAAuIwF4pT92AAABl0lEQVQ4T5WTvU4CQRDHd5dTEqvDGMHuiBAhBF0KE0veAEo7fQOxsvYJxCdQ38BHwM7ChAU1nB8EOj9ilARjAh433lyYc0HQ+Csu/5mbmZ3NzHI2ASml2f2EA85Ykblgcs4Vd3n51lYn47FcNzDxvee+6b5xuMHKd5f13cAmkcxkJLihKtm/AYy3m41aHLXAD54cJHM41WJHEbzjX4eBtZxea6HL7yCRXgWKuW/UfV9KSstWqk1+KfOmUpVOSm5YTu/DTzbCIi5kPm9SkI6ejGCyLxzHIp/Th6roPr2WySFAbJOehn11UQkMAFNwwQtkTxrTJLwcRVrgnPWf/wU7CKolstmgm98AFyRpb4zuYfDH4dNHOGQlLXd0Oxij18r+AGALZxxionTTUN+FPZLJdclm+0f66QDiOIQiEo1GPGtvJixygwErAoPNxdjS2cvzY5uC56MLD94KxshGmnYtZ/jiul7yNqvg9NxWc7hIf2GE5/xVHgnG9cQroKaNJPRtxWRbnbdR+2+BwAdClX/gvQO8MxamZOQLp/igo1dFFCgAAAAASUVORK5CYII='); } .chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(5) strong:after { - content: ' ' url('/matrix-hookshot/latest/icons/gitlab.png'); + content: ' ' url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABbmlDQ1BpY2MAACiRdZE9S0JhFMd/alGU4WBDRIODRYOCFIRj2eAiIWaQ1aLXt0Cvl3uVkNagpUFoiFp6G/oGtQatBUFQBBFNfYDelpDbuSoooefy3PPj/5z/4XnOA/ZIQSkafQEoqmU9Fg55VhNrnoF3bDhxE8SWVAxtIRqN0DN+HqVa4sFv9epd1zWG0xlDAdug8Jyi6WXheeHIVlmzeE94VMkn08Inwj5dDih8a+mpJr9ZnGvyl8V6PLYIdqunJ9fBqQ5W8npReFrYWyxUlNZ5rJs4M+rKsuRxWRMYxAgTwkOKCpsUKOOXrMrMuvsCDd8SJfEo8teooosjR168PlEr0jUjOSt6Rr4CVWvu/+dpZGdnmt2dIeh/Nc3PSRjYh3rNNH9PTbN+Bo4XuFbb/pLMKfgteq2teY/BtQOXN20tdQBXuzD2rCX1ZENyyLJns/BxASMJcN/D0HpzVq19zp8gvi1PdAeHRzAl9a6NP84cZ/OkIlzqAAAACXBIWXMAAAsSAAALEgHS3X78AAABr0lEQVQ4T6VSzUrDQBCebNI2lqCXUqhSsBWaUjxVFASPngWPioJP4ANIH0DER/DoD4iP4FmLiBdB8NKDerEFD1JKbJus32x+mjbUix/szs7km5lvd6JJKTX6B0Tced+steP+NIDnheeoAIInMLnQnwbwjgOrcjW+AhwDZ4f8govF25e3WE6EgNcP3Bx4X6oKDkMaqbkObAIBL8QDbyoJlRuxD2ux8xjAO4i5Zd60z+2qrrF8T12B4Q1+tEN3QN0YWcG05A1MFBd5qqk3cK9sj+4QCQY6cKjXaenZkKggiQo1l7Rw6HNoaNOjcM/tLXIRWKXofimTxpOBjCWdKJmHWCET+wa/waUKCjKomJQdIlfyOMFXuR5YQOh7rxbst/IKZOGDGlN+yev5FB+SVTJWoFRG4TLPlcE/0C7WGWaQpnsiIyOzLFlKJb+LwhaVcG+hpBMaj36koIgC3sRTw20SfTzrQ6GTUaji8VLoXOcMOkXyUchPFGCgyBN6VftNTXRaIr2w7PIjk77vd40jEWCgQx1Cy+a8FDOz0sG12uicnuQpsIK/Vrdh7wwvKvpkPFy/C9SuZFOE3X8AAAAASUVORK5CYII='); } .chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(6) strong:after { - content: ' ' url('/matrix-hookshot/latest/icons/jira.png'); + content: ' ' url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABcmlDQ1BpY2MAACiRdZE9S8NQFIbftkpFKx3qIOKQoYpDC0VBxUnr0KVIqRWsuiS3SSskabhJkeIquDgUHEQXvwb/ga6Cq4IgKIKIkz/Ar0VKPLcptEh7ws15eO95D/eeC/jTOjPsngRgmA7PppLSan5NCr7DhxAimMOMzGxrIZNJo2v8PFI1xUNc9Ope1zEGCqrNAF8f8TSzuEM8T5zecizBe8RDrCQXiE+IY5wOSHwrdMXjN8FFj78E81x2EfCLnlKxjZU2ZiVuEE8QRw29wprnETcJqebKMuURWqOwkUUKSUhQUMEmdDiIUzZpZp19iYZvCWXyMPpbqIKTo4gSeWOkVqirSlkjXaVPR1XM/f88bW1q0useSgK9r677OQYE94F6zXV/T123fgYEXoBrs+Uv05xmv0mvtbToMRDeAS5vWppyAFztAsPPlszlhhSg5dc04OMCGMwDkXugf92bVXMf509Abpue6A44PALGqT688QdHJ2grp2cfigAAAAlwSFlzAAALEgAACxIB0t1+/AAAAhJJREFUOE91Uz1vE0EQfbPe3fuIERFKUAgfQobYFBFKzY+ggiBRUqSgpAABLigACapIdFFEQe0iPwBRpEpBgSiQE4ToUIRcIGPsu/XdLbPnj5zP8HRzuzc783bf7BxZawn/QeMFNi3wruyfgBPbsuwsokLYTywSDgzLa2NcFmVPEV+e4JgDtnhqymsTUFHC2hto/MJVNzd/ejDGwPR6UFWvplR4Uwjh+wthDUQ3xindGQnqN84kAp9hrZCeRpqlEDz2u33Egw6GcZxdbDRafrgwzZmRMD7yeyJCRSl4QVCwEMrzhU2zqJgzVwOu+j1+H0xJwnBsQW7GxC0Om5JQ/bm9k6WpNlGEaBDBjcM4EkrrDapgdXH5bIcg7seDAQxbmsXrS+cu1Tl3l01K3rFJQqxLraDSFDZzliDus+5oAKS0t3ThfC7DVTsZKhw+xd6VV/ggDDaksNi2RLsVycf1C0LyxyJD+iOXI2VOIrw4j/j2CF0e9kW7ibcc94CDOk6z9n22ADrwmTBwlf/kAh2JYBKtqpNdcuRFPGxi+9QyVhNBKyT19RHJyJQXZpnALe6WFpMkM9mMaR983IJb/Fl7yU5SIyfL4ObB18fo8Nfmtdeonl48uQGHuX/h+xo69SM6lkqtcEOB5MlNtx+iVwjNMd8Ht5Fx3jOnWWoN359W9p+YI3A4amKHT3+XSQ6oovrl9SL+AhmcsPdAgrGeAAAAAElFTkSuQmCC'); } .chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(7) strong:after { - content: ' ' url('/matrix-hookshot/latest/icons/webhooks.png'); + content: ' ' url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABbmlDQ1BpY2MAACiRdZE9S0JhFMd/amGU0ZBDRIODRYOCFERj2eAiIWaQ1XK9+RJc7XKvEtIatDQIDVFLb0PfoNagtSAIiiCiqQ/Q2xJxO48GSti5PPf8+D/nfzj3XHDHDb1od0SgWCpbyVg0sJBeDHhfcOHDD/g13TanE4k4/8bnnVRL3IZVr//r2kbPStbWwdUlPKGbVll4Sji+XjYVb6sZ9IK2InwoHLJkQOErpWca/Kw43+B3xVYqOQNu1TOQb+FMC+sFqyg8KhwsGhX9dx71Jb5saX5O8qCcIWySxIgSIEOFVQzKhCWXZGftfZG6b5Y18ejyNqliiSNPQbwhUSvSNSs5J3pWHoOq2vvffdq58bFGd18UOp8c520YvDvwXXOcryPH+T4GzyNclJr+NdnT5IfotaYWPIC+TTi7bGqZXTjfgoEHU7O0uuSR487l4PUUetPQfwPdS41d/d5zcg+pDflF17C3DyNS37f8A3/sZ8/TN03XAAAACXBIWXMAAAsSAAALEgHS3X78AAAAMnRFWHRSYXcgcHJvZmlsZSB0eXBlIGV4aWYACmV4aWYKICAgICAgIDYKNDU3ODY5NjYwMDAwCvm8oQ4AAABadEVYdFJhdyBwcm9maWxlIHR5cGUgaXB0YwAKaXB0YwogICAgICAyNgo1MDY4NmY3NDZmNzM2ODZmNzAyMDMzMmUzMDAwMzg0MjQ5NGQwNDA0MDAwMDAwMDAwMDAwCsfRadwAAAAjdEVYdFJhdyBwcm9maWxlIHR5cGUgeG1wAAp4bXAKICAgICAgIDAKTRhOWwAAAitJREFUOE+VU71L8lEUfvwqJXFQIRVEQvEPEIdQaBFpEBSXhDYRbBP/h0hIEEQXwaEclIjctMmPUVwEQTAdolREDBTNBDE173mpl37yDu8z3XvPuc99znPO5W22wD/AQjwej3v8C0LuAcN8Psfd3R3a7TYRmEwmuN1uCIW76Tyugs/PT4TDYchkMpyenmK1WiGbzUKpVMLv9+8o4v/abdFoNLBYLHB2doabmxs8PDzQRaam1+tx03cJ+v0+9Ho9arUaRCIR3t/f6eLh4SGGwyE3fZfg6OgIT09PMJvN2NvbI0VyuRwulwsGg4GbTk7/wnq93sRisU0kEqF9Op3exONxTtZf/CjodDp4eXkhkzQaDUnfxuF0Oqn+29tbpFIplMtlMvobRFCtVnF1dYXpdIrRaIRSqYSLiwsik0qlOD8/x2QyoU5UKhWEQiFqNRHMZjNkMhkEAgEMBgMEg0GcnJxApVL9vHJ8fAyHw0Fxr9dLLc7lchQTdrtdYmaO5/N5aLVaWjebTdzf35ORbBbq9TqsVisSiQQ8Hg/FiODg4ABMBWPd398nqUajEcViEWq1mqZQIBCAz+fj+fkZFosF4/EYEomECHhb9s319TX12W63k0Gvr68k8fLykhJbrRbEYjGWyyX5Eo1GqRRGTqPMzEsmkzQobN7Ziz6fDzqdjl55fHxEoVAAU/vx8UF+2Gy2PwoYAa22eHt7IwVMOhesTFaeQqEgNd/Y+Uz/iy/JFyvRO9cdAwAAAABJRU5ErkJggg=='); } +.chapter > li:nth-child(3) > ol:nth-child(1) > li:nth-child(8) strong:after { + content: ' ' url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAOwAAADsAEnxA+tAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAk9JREFUOI1tk09IVGEUxX/3vvdmlMYCkTQjJUVBsBJat2tVERZRDa2SClpIBIIKhUggQpCtolULNxHtsqKWGQS10aQggkgkRZM2ko5v5nvfbfEcGxnv+pxzz7l/xMzYtR42tfk4Oo3RiVgAwbIGxWnWl6cZMV+GSZXAWFsjsj5htu+iWCFADDAMBVHEuznCUj+Dv6cBdAf5fke7182PkM2LbAaIggmmhhiIOVyw56gl4QNEBCDcJo/uz1km+1rxrVgI6koee26Re0uxGIvW9KB2NbS4wQm3wy3r2wI+GwzgfSeWRbSwOrO4ce/bfDyfn9qcApjsjf7U56JPZ7rrDoTDK+/LvDTCqIQQXXcRiMQGQd/sj42WwBXvlIEuCfqXCskFhlYeVaZOBWoauzRJmjMlMHVfGV585cKIorI94TAxNKleVhohDlsIUqzT2pkIM8gh2JEnZzOzaSc7jJeXuwuEhSKWAZTIl2pSggdYA955kECkrrp/WSC777sVYw+mGCdk4lAteEAW+l4UbwFMnsq2eqvOkM5g4OcC5uYwAwmbiHVQJBFXcSViSCZRIN3/TgEzE3XjQgQIRnL3/PGGTFft3stlYPta5lpvT8Mbxg9OpVsrC2+fsogfb36KySUAtQSvmRX1/gPChpWSY0RhNxojFt1k6Nfj/w62bGhMn2LPIAER1JJGxM6BXLEw7EY38ZbFLNtXPuWdvzCyuMHwUl41ziPy2VvgTTwmDgs85mU1SNyY5PQkW9arv7GyxlvbkL8dIDWYLlC/+oUbVqqE/AMToAVJZCYDcQAAAABJRU5ErkJggg=='); +} + + .chapter li:nth-child(7) > a:nth-child(1) > strong:after { - content: ' ' url('/matrix-hookshot/latest/icons/sentry.png'); + content: ' ' url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAPCAYAAADtc08vAAAB5klEQVQoU4WSQU7bUBCGZ6wkStogwglwdkikqpEI6/gEhF3SVXoCzA3CCWpOUO9sVvgGeB0jYSlGYodvAFKgrmLk6cwztlKUgHfWe++f///mR/jgO9wfW4g5hfHlxaZruOngqDeaEoIl50hgz2Jvuu7uWgFDH3bqW60HADorHuGvbJF2o8R/ei+yVqD/beQAoR7G7kAe9HvjgG0k4dybfCrQ3/9hoEa3BGCHc1c5OOyNBhridU5k3sResCqy1oGCp5FkvuKpP5UL5Qp2w9gzNwrwpQkD22Vg58beSG/UUKbdzmL3pODSTPjfYlGnFKkcrIBDzv+QPadm/esXHbQ8oBynN3euXWwGT1eBVgJyyLQny+fUaLSbATE1EWlstQbM43eW0UF07yVHvTG7IKdcayVQkIbvkKOZvfxJGu1WVF6URyzi81asEugyo64IKgHJKz8KFOCxWKy1mwYi+Nnir87Zh9IF3spOtVYgbqhnYqW4SHekKBwlYvuBTJPJObfw9SV1OMpjuUYFuI5cNDjB95lkE+U0jmUzio4USN3jas/mrv/mwkakYyXA+R4571tNscPWjSU7YpiWUGe3ESAaSHQh8BQvgG0W76JqHuac8f9v+QpOrQa6BjAoT3KAQJpYbIxHkub/A+GHBVloER6qAAAAAElFTkSuQmCC'); } diff --git a/docs/advanced/encryption.md b/docs/advanced/encryption.md index 21c334cce..6d74c989e 100644 --- a/docs/advanced/encryption.md +++ b/docs/advanced/encryption.md @@ -1,27 +1,32 @@ Encryption ========== -
-Encryption support is HIGHLY EXPERIMENTAL AND SUBJECT TO CHANGE. It should not be enabled for production workloads. -For more details, see issue 594. +
+Support for encryption is considered stable, but the underlying specification changes are not yet. + +Hookshot supports end-to-bridge encryption via [MSC3202](https://github.com/matrix-org/matrix-spec-proposals/pull/3202), and [MSC4203](https://github.com/matrix-org/matrix-spec-proposals/pull/4203). Hookshot needs to be configured against a a homeserver that supports these features, such as [Synapse](#running-with-synapse). + +Please check with your homeserver implementation before reporting bugs against matrix-hookshot.
-Hookshot supports end-to-bridge encryption via [MSC3202](https://github.com/matrix-org/matrix-spec-proposals/pull/3202). As such, encryption requires Hookshot to be connected to a homeserver that supports that MSC, such as [Synapse](#running-with-synapse). + ## Enabling encryption in Hookshot In order for Hookshot to use encryption, it must be configured as follows: -- The `experimentalEncryption.storagePath` setting must point to a directory that Hookshot has permissions to write files into. If running with Docker, this path should be within a volume (for persistency). Hookshot uses this directory for its crypto store (i.e. long-lived state relating to its encryption keys). +- The `encryption.storagePath` setting must point to a directory that Hookshot has permissions to write files into. If running with Docker, this path should be within a volume (for persistency). Hookshot uses this directory for its crypto store (i.e. long-lived state relating to its encryption keys). - Once a crypto store has been initialized, its files must not be modified, and Hookshot cannot be configured to use another crypto store of the same type as one it has used before. If a crypto store's files get lost or corrupted, Hookshot may fail to start up, or may be unable to decrypt command messages. To fix such issues, stop Hookshot, then reset its crypto store by running `yarn start:resetcrypto`. -- [Redis](./workers.md) must be enabled. Note that worker mode is not yet supported with encryption, so `queue.monolithic` must be set to `true`. +- [Redis](./workers.md) must be enabled. Note that worker mode is not yet supported with encryption, so `queue` MUST **NOT be configured**. -If you ever reset your homeserver's state, ensure you also reset Hookshot's encryption state. This includes clearing the `experimentalEncryption.storagePath` directory and all worker state stored in your redis instance. Otherwise, Hookshot may fail on start up with registration errors. +If you ever reset your homeserver's state, ensure you also reset Hookshot's encryption state. This includes clearing the `storagePath` directory and all worker state stored in your redis instance. Otherwise, Hookshot may fail on start up with registration errors. Also ensure that Hookshot's appservice registration file contains every line from `registration.sample.yml` that appears after the `If enabling encryption` comment. Note that changing the registration file may require restarting the homeserver that Hookshot is connected to. ## Running with Synapse -[Synapse](https://github.com/matrix-org/synapse/) has functional support for MSC3202 as of [v1.63.0](https://github.com/matrix-org/synapse/releases/tag/v1.63.0). To enable it, add the following section to Synapse's configuration file (typically named `homeserver.yaml`): +[Synapse](https://github.com/matrix-org/synapse/) has functional support for MSC3202 and MSC4203 as of [v1.63.0](https://github.com/matrix-org/synapse/releases/tag/v1.63.0). To enable it, add the following section to Synapse's configuration file (typically named `homeserver.yaml`): + +You may notice that MSC2409 is not listed above. Due to the changes being split out from MSC2409, `msc2409_to_device_messages_enabled` refers to MSC4203. ```yaml experimental_features: @@ -29,3 +34,4 @@ experimental_features: msc3202_transaction_extensions: true msc2409_to_device_messages_enabled: true ``` + diff --git a/docs/advanced/workers.md b/docs/advanced/workers.md index c4b59e072..1decc97ce 100644 --- a/docs/advanced/workers.md +++ b/docs/advanced/workers.md @@ -11,18 +11,19 @@ This feature is experimental and should only be used when you are reachin You must first have a working Redis instance somewhere which can talk between processes. For example, in Docker you can run: -`docker run --name github-bridge-redis -p 6379:6379 -d redis`. +`docker run --name redis-host -p 6379:6379 -d redis`. The processes should all share the same config, which should contain the correct config to enable Redis: ```yaml queue: - monolithic: false - port: 6379 - host: github-bridge-redis + redisUri: "redis://redis-host:6379" +cache: + redisUri: "redis://redis-host:6379" ``` -Note that if [encryption](./encryption.md) is enabled, `queue.monolithic` must be set to `true`, as worker mode is not yet supported with encryption. +Note that if [encryption](./encryption.md) is enabled, you MUST enable the `cache` config but NOT the `queue` config. Workers require persistent +storage in Redis, but cannot make use of worker-mode queues. Once that is done, you can simply start the processes by name using yarn: ``` diff --git a/docs/setup.md b/docs/setup.md index 8b930192e..15a0953cd 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -13,7 +13,7 @@ Hookshot requires the homeserver to be configured with its appservice registrati ## Local installation -This bridge requires at least Node 16 and Rust installed. +This bridge requires at least Node 22 and Rust installed. To install Node.JS, [nvm](https://github.com/nvm-sh/nvm) is a good option. @@ -27,7 +27,12 @@ cd matrix-hookshot yarn # or npm i ``` -Starting the bridge (after configuring it), is a matter of running `yarn start`. +Starting the bridge (after configuring it), is a matter of setting the `NODE_ENV` environment variable to `production` or `development`, depending if you want [better performance or more verbose logging](https://expressjs.com/en/advanced/best-practice-performance.html#set-node_env-to-production), and then running it: + + +```bash +NODE_ENV=production yarn start +``` ## Installation via Docker @@ -115,19 +120,37 @@ Each permission set can have a service. The `service` field can be: - `feed` - `figma` - `webhooks` +- `challengehound` - `*`, for any service. +The `level` determines what permissions a user has access to on the named service(s). They are +additive, one level grants all previous levels in addition to previous levels. + The `level` can be: - `commands` Can run commands within connected rooms, but NOT log in to the bridge. -- `login` All the above, and can also log in to the bridge. -- `notifications` All the above, and can also bridge their notifications. +- `login` All the above, and can also log in to supported networks (such as GitHub, GitLab). This is the minimum level required to invite the bridge to rooms. +- `notifications` All the above, and can also bridge their own notifications. Only supported on GitHub. - `manageConnections` All the above, and can create and delete connections (either via the provisioner, setup commands, or state events). - `admin` All permissions. This allows you to perform administrative tasks like deleting connections from all rooms. -When permissions are checked, if a user matches any of the permissions set and one -of those grants the right level for a service, they are allowed access. If none of the -definitions match, they are denied. +If any of the permissions matches positively for a user, they are granted access. For example: + +```yaml +permissions: + - actor: example.com + services: + - service: GitHub + level: manageConnections + - actor: "@badapple:example.com" + services: + - service: GitHub + level: login +``` + +would grant `@badapple:example.com` the right to `manageConnections` for GitHub, even though they +were explicitly named for a lower permission. + #### Example @@ -222,6 +245,20 @@ Please note that the appservice HTTP listener is configured separatelythis issue for details.
+### Cache configuration + +You can optionally enable a Redis-backed cache for Hookshot. This is generally a good thing to enable if you can +afford to, as it will generally improve startup times. Some features such as resuming RSS/Atom feeds between restarts +is also only possible with a external cache. + +To enable, simply set: + +```yaml +cache: + redisUri: "redis://redis-host:3679" +``` + + ### Services configuration You will need to configure some services. Each service has its own documentation file inside the setup subdirectory. diff --git a/docs/setup/challengehound.md b/docs/setup/challengehound.md new file mode 100644 index 000000000..ab5c46a7f --- /dev/null +++ b/docs/setup/challengehound.md @@ -0,0 +1,33 @@ +# ChallengeHound + +You can configure Hookshot to bridge [ChallengeHound](https://www.challengehound.com/) activites +into Matrix. + +### Getting the API secret. + +You will need to email ChallengeHound support for an API token. They seem happy to provide one +as long as you are an admin of a challenge. See [this support article](https://support.challengehound.com/article/69-does-challenge-hound-have-an-api) + +```yaml +challengeHound: + token: +``` + +## Usage + +You can add a new challenge hound challenge by command: + +``` +challengehound add https://www.challengehound.com/challenge/abc-def +``` + +and remove it with the same command + +``` +challengehound remove https://www.challengehound.com/challenge/abc-def +``` + +Hookshot will periodically refetch activities from the challenge and send a notice when a new +one is completed. Note that Hookshot uses your configured cache to store seen activities. If +you have not configured Redis caching, it will default to in-memory storage which means activites +**will** repeat on restart. diff --git a/docs/setup/github.md b/docs/setup/github.md index 6f0f423ef..d80a9674a 100644 --- a/docs/setup/github.md +++ b/docs/setup/github.md @@ -63,7 +63,7 @@ github: oauth: client_id: foo client_secret: bar - redirect_uri: https://example.com/bridge_oauth/ + redirect_uri: https://example.com/oauth/ defaultOptions: showIssueRoomLink: false ``` diff --git a/docs/setup/webhooks.md b/docs/setup/webhooks.md index 57938164f..afcbcfb5f 100644 --- a/docs/setup/webhooks.md +++ b/docs/setup/webhooks.md @@ -1,7 +1,7 @@ # Webhooks -Hookshot supports generic webhook support so that services can send messages into Matrix rooms without being aware of the Matrix protocol. This works -by having services hit a unique URL that then transforms a HTTP payload into a Matrix message. +Hookshot supports two kinds of webhooks, inbound (previously known as Generic Webhooks) and outbound. + ## Configuration @@ -10,13 +10,21 @@ You will need to add the following configuration to the config file. ```yaml generic: enabled: true + outbound: true # For outbound webhook support urlPrefix: https://example.com/mywebhookspath/ allowJsTransformationFunctions: false waitForComplete: false enableHttpGet: false + # maxExpiryTime: 30d + # sendExpiryNotice: false # userIdPrefix: webhook_ ``` +## Inbound Webhooks + +Hookshot supports generic webhook support so that services can send messages into Matrix rooms without being aware of the Matrix protocol. This works +by having services hit a unique URL that then transforms a HTTP payload into a Matrix message. +
Previous versions of the bridge listened for requests on `/` rather than `/webhook`. While this behaviour will continue to work, administators are advised to use `/webhook`. @@ -37,6 +45,13 @@ has been sent (`true`). By default this is `false`. `enableHttpGet` means that webhooks can be triggered by `GET` requests, in addition to `POST` and `PUT`. This was previously on by default, but is now disabled due to concerns mentioned below. +`maxExpiryTime` sets an upper limit on how long a webhook can be valid for before the bridge expires it. By default this is unlimited. This +takes a duration represented by a string. E.g. "30d" is 30 days. See [this page](https://github.com/jkroso/parse-duration?tab=readme-ov-file#available-unit-types-are) +for available units. Additionally: + + - `sendExpiryNotice` configures whether a message is sent into a room when the connection is close to expiring. + - `requireExpiryTime` forbids creating a webhook without a expiry time. This does not apply to existing webhooks. + You may set a `userIdPrefix` to create a specific user for each new webhook connection in a room. For example, a connection with a name like `example` for a prefix of `webhook_` will create a user called `@webhook_example:example.com`. If you enable this option, you need to configure the user to be part of your registration file e.g.: @@ -50,7 +65,7 @@ namespaces: exclusive: true ``` -## Adding a webhook +### Adding a webhook To add a webhook to your room: - Invite the bot user to the room. @@ -58,7 +73,7 @@ To add a webhook to your room: - Say `!hookshot webhook example` where `example` is a name for your hook. - The bot will respond with the webhook URL to be sent to services. -## Webhook Handling +### Webhook Handling Hookshot handles `POST` and `PUT` HTTP requests by default. @@ -76,7 +91,7 @@ If the body *also* contains a `username` key, then the message will be prepended If the body does NOT contain a `text` field, the full payload will be sent to the room. This can be adapted into a message by creating a **JavaScript transformation function**. -### Payload formats +#### Payload formats If the request is a `POST`/`PUT`, the body of the request will be decoded and stored inside the event. Currently, Hookshot supports: @@ -88,7 +103,7 @@ If the request is a `POST`/`PUT`, the body of the request will be decoded and st Decoding is done in the order given above. E.g. `text/xml` would be parsed as XML. Any formats not described above are not decoded. -### GET requests +#### GET requests In previous versions of hookshot, it would also handle the `GET` HTTP method. This was disabled due to concerns that it was too easy for the webhook to be inadvertently triggered by URL preview features in clients and servers. If you still need this functionality, you can enable it in the config. @@ -102,7 +117,7 @@ to a string representation of that value. This change is not applied -### Wait for complete +#### Wait for complete It is possible to choose whether a webhook response should be instant, or after hookshot has handled the message. The reason for this is that some services expect a quick response time (like Slack) whereas others will wait for the request to complete. You @@ -111,7 +126,14 @@ can specify this either globally in your config, or on the widget with `waitForC If you make use of the `webhookResponse` feature, you will need to enable `waitForComplete` as otherwise hookshot will immeditately respond with it's default response values. -## JavaScript Transformations + +#### Expiring webhooks + +Webhooks can be configured to expire, such that beyond a certain date they will fail any incoming requests. Currently this expiry time +is mutable, so anybody able to configure connections will be able to change the expiry date. Hookshot will send a notice to the room +at large when the webhook has less than 3 days until it's due to expire (if `sendExpiryNotice` is set). + +### JavaScript Transformations
Although every effort has been made to securely sandbox scripts, running untrusted code from users is always risky. Ensure safe permissions @@ -130,7 +152,7 @@ Please seek out documentation from your client on how to achieve this. The script string should be set within the state event under the `transformationFunction` key. -### Script API +#### Script API Transformation scripts have a versioned API. You can check the version of the API that the hookshot instance supports at runtime by checking the `HookshotApiVersion` variable. If the variable is undefined, it should be considered `v1`. @@ -141,7 +163,7 @@ Scripts are executed synchronously and expect the `result` variable to be set. If the script contains errors or is otherwise unable to work, the bridge will send an error to the room. You can check the logs of the bridge for a more precise error. -### V2 API +#### V2 API The `v2` api expects an object to be returned from the `result` variable. @@ -176,7 +198,7 @@ if (data.counter === undefined) { ``` -### V1 API +#### V1 API The v1 API expects `result` to be a string. The string will be automatically interpreted as Markdown and transformed into HTML. All webhook messages will be prefixed with `Received webhook:`. If `result` is falsey (undefined, false or null) then the message will be `No content`. @@ -192,3 +214,36 @@ if (data.counter > data.maxValue) { result = `*Everything is fine*, the counter is under by ${data.maxValue - data.counter}` } ``` + +## Outbound webhooks + +You can also configure Hookshot to send outgoing requests to other services when a message appears +on Matrix. To do so, you need to configure hookshot to enable outgoing messages with: + +```yaml +generic: + outbound: true +``` + +### Request format + +Requests can be sent to any service that accepts HTTP requests. You may configure Hookshot to either use the HTTP `PUT` (default) +or `POST` methods. + +Each request will contain 3 headers which you may use to authenticate and direct traffic: + + - 'X-Matrix-Hookshot-EventId' contains the event's ID. + - 'X-Matrix-Hookshot-RoomId' contains the room ID where the message was sent. + - 'X-Matrix-Hookshot-Token' is the unique authentication token provided when you created the webhook. Use this + to verify that the message came from Hookshot. + +The payloads are formatted as `multipart/form-data`. + +The first file contains the event JSON data, proviced as the `event` file. This is a raw representation of the Matrix event data. If the +event was encrypted, this will be the **decrypted** body. + +If any media is linked to in the event, then a second file will be present named `media` which will contain the media referenced in +the event. + +All events that occur in the room will be sent to the outbound URL, so be careful to ensure your remote service can filter the +traffic appropriately (e.g. check the `type` in the event JSON) diff --git a/docs/usage/room_configuration/gitlab_project.md b/docs/usage/room_configuration/gitlab_project.md index 0201ab3b1..92d678ce1 100644 --- a/docs/usage/room_configuration/gitlab_project.md +++ b/docs/usage/room_configuration/gitlab_project.md @@ -46,6 +46,7 @@ the events marked as default below will be enabled. Otherwise, this is ignored. - merge_request.close * - merge_request.merge * - merge_request.open * + - merge_request.reopen * - merge_request.review.comments * - merge_request.review * - merge_request.review.individual diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 000000000..c5d20da64 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,82 @@ +import mocha from "eslint-plugin-mocha"; +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import react from "eslint-plugin-react"; +import chai from "eslint-plugin-chai-expect"; + +export default [ + { + ignores: ["lib/**/*", "spec-lib/**/*", "contrib/**/*"], + }, + ...tseslint.config( + { + files: ["src/**/*.ts", "scripts/*.ts"], + extends: [ + eslint.configs.recommended, + ...tseslint.configs.recommended, + ], + rules: { + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": "warn", + camelcase: ["error", { + properties: "never", + ignoreDestructuring: true, + }], + "no-console": "error", + }, + }, + ), + ...tseslint.config( + { + files: ["tests/**/*.ts", "tests/**/*.spec.ts"], + extends: [ + eslint.configs.recommended, + ...tseslint.configs.recommended, + mocha.configs.flat.recommended, + chai.configs["recommended-flat"], + ], + rules: { + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": "warn", + // Chai assertions don't call functions + "@typescript-eslint/no-unused-expressions": "off", + camelcase: ["error", { + properties: "never", + ignoreDestructuring: true, + }], + "no-console": "error", + // Needs a refactor + "mocha/no-mocha-arrows": "off", + }, + }, + ), + ...tseslint.config( + { + settings: { + react: { + pragma: "Preact", + version: "17", + } + }, + files: ["web/**/*.ts", "web/**/*.tsx"], + extends: [ + eslint.configs.recommended, + ...tseslint.configs.recommended, + react.configs.flat.recommended, + react.configs.flat['jsx-runtime'], + ], + rules: { + "no-console": "off", + "no-unused-vars": "off", + "no-useless-constructor": "off", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": "error", + "@typescript-eslint/no-useless-constructor": "error", + "react/react-in-jsx-scope": "off", + "react/prop-types": "off", + }, + }, + ), +]; \ No newline at end of file diff --git a/helm/hookshot/values.yaml b/helm/hookshot/values.yaml index a4b1302b7..6a8e0c21b 100644 --- a/helm/hookshot/values.yaml +++ b/helm/hookshot/values.yaml @@ -3,7 +3,6 @@ # # -- Number of replicas to deploy. Consequences of using multiple Hookshot replicas currently unknown. replicaCount: 1 - image: # -- Repository to pull hookshot image from repository: halfshot/matrix-hookshot @@ -11,16 +10,12 @@ image: pullPolicy: IfNotPresent # -- Image tag to pull. Defaults to chart's appVersion value as set in Chart.yaml tag: - # -- List of names of k8s secrets to be used as ImagePullSecrets for the pod imagePullSecrets: [] - # -- Name override for helm chart nameOverride: "" - # -- Full name override for helm chart fullnameOverride: "" - serviceAccount: # -- Specifies whether a service account should be created create: true @@ -28,10 +23,8 @@ serviceAccount: annotations: {} # -- The name of the service account to use. If not set and create is true, a name is generated using the fullname template name: "" - # -- Extra annotations for Hookshot pod podAnnotations: {} - # -- Pod security context settings podSecurityContext: {} # fsGroup: 2000 @@ -54,7 +47,6 @@ service: annotations: {} # -- Extra labels for service labels: {} - webhook: # -- Webhook port as configured in container port: 9000 @@ -64,7 +56,6 @@ service: appservice: # -- Appservice port as configured in container port: 9002 - ingress: webhook: # -- Enable ingress for webhook @@ -77,7 +68,6 @@ ingress: hosts: [] # -- TLS configuration for webhook ingress tls: [] - appservice: # -- Enable ingress for appservice enabled: false @@ -89,7 +79,6 @@ ingress: hosts: [] # -- TLS configuration for appservice ingress tls: [] - # -- Pod resource requests / limits resources: {} # We usually recommend not to specify default resources and to leave this as a conscious @@ -105,189 +94,40 @@ resources: {} autoscaling: enabled: false - # -- Node selector parameters nodeSelector: {} # -- Tolerations for deployment tolerations: [] - # -- Affinity settings for deployment affinity: {} - hookshot: # -- Name of existing ConfigMap with valid Hookshot configuration existingConfigMap: - # -- Raw Hookshot configuration. Gets templated into a YAML file and then loaded unless an existingConfigMap is specified. config: + # This is an example configuration file bridge: # Basic homeserver configuration - # domain: example.com url: http://localhost:8008 mediaUrl: https://example.com port: 9993 bindAddress: 127.0.0.1 - # github: - # (Optional) Configure this to enable GitHub support - # - # auth: - # Authentication for the GitHub App. - # - # id: 123 - # privateKeyFile: github-key.pem - # webhook: - # Webhook settings for the GitHub app. - # - # secret: secrettoken - # oauth: - # (Optional) Settings for allowing users to sign in via OAuth. - # - # client_id: foo - # client_secret: bar - # redirect_uri: https://example.com/bridge_oauth/ - # defaultOptions: - # (Optional) Default options for GitHub connections. - # - # showIssueRoomLink: false - # hotlinkIssues: - # prefix: "#" - # userIdPrefix: _github_ - # (Optional) Prefix used when creating ghost users for GitHub accounts. - # - # gitlab: - # (Optional) Configure this to enable GitLab support - # - # instances: - # gitlab.com: - # url: https://gitlab.com - # webhook: - # secret: secrettoken - # publicUrl: https://example.com/hookshot/ - # userIdPrefix: _gitlab_ - # (Optional) Prefix used when creating ghost users for GitLab accounts. - # - # figma: - # (Optional) Configure this to enable Figma support - # - # publicUrl: https://example.com/hookshot/ - # instances: - # your-instance: - # teamId: your-team-id - # accessToken: your-personal-access-token - # passcode: your-webhook-passcode - # jira: - # (Optional) Configure this to enable Jira support. Only specify `url` if you are using a On Premise install (i.e. not atlassian.com) - # - # webhook: - # Webhook settings for JIRA - # - # secret: secrettoken - # oauth: - # (Optional) OAuth settings for connecting users to JIRA. See documentation for more information - # - # client_id: foo - # client_secret: bar - # redirect_uri: https://example.com/bridge_oauth/ - generic: - # (Optional) Support for generic webhook events. - #'allowJsTransformationFunctions' will allow users to write short transformation snippets in code, and thus is unsafe in untrusted environments - # - # - enabled: false - enableHttpGet: false - urlPrefix: https://example.com/webhook/ - userIdPrefix: _webhooks_ - allowJsTransformationFunctions: false - waitForComplete: false - feeds: - # (Optional) Configure this to enable RSS/Atom feed support - # - enabled: false - pollIntervalSeconds: 600 - pollTimeoutSeconds: 30 - # provisioning: - # (Optional) Provisioning API for integration managers - # - # secret: "!secretToken" passFile: passkey.pem # A passkey used to encrypt tokens stored inside the bridge. # Run openssl genpkey -out passkey.pem -outform PEM -algorithm RSA -pkeyopt rsa_keygen_bits:4096 to generate - # - # bot: - # (Optional) Define profile information for the bot user - # - # displayname: Hookshot Bot - # avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d - # serviceBots: - # (Optional) Define additional bot users for specific services - # - # - localpart: feeds - # displayname: Feeds - # avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d - # prefix: "!feeds" - # service: feeds - metrics: - # (Optional) Prometheus metrics support - # - enabled: true - # queue: - # (Optional) Message queue / cache configuration options for large scale deployments. - # For encryption to work, must be set to monolithic mode and have a host & port specified. - # - # monolithic: true - # port: 6379 - # host: localhost logging: - # (Optional) Logging settings. You can have a severity debug,info,warn,error - # + # Logging settings. You can have a severity debug,info,warn,error level: info colorize: true json: false timestampFormat: HH:mm:ss:SSS - # widgets: - # (Optional) EXPERIMENTAL support for complimentary widgets - # - # addToAdminRooms: false - # disallowedIpRanges: - # - 127.0.0.0/8 - # - 10.0.0.0/8 - # - 172.16.0.0/12 - # - 192.168.0.0/16 - # - 100.64.0.0/10 - # - 192.0.0.0/24 - # - 169.254.0.0/16 - # - 192.88.99.0/24 - # - 198.18.0.0/15 - # - 192.0.2.0/24 - # - 198.51.100.0/24 - # - 203.0.113.0/24 - # - 224.0.0.0/4 - # - ::1/128 - # - fe80::/10 - # - fc00::/7 - # - 2001:db8::/32 - # - ff00::/8 - # - fec0::/10 - # roomSetupWidget: - # addOnInvite: false - # publicUrl: https://example.com/widgetapi/v1/static/ - # branding: - # widgetTitle: Hookshot Configuration - # permissions: - # (Optional) Permissions for using the bridge. See docs/setup.md#permissions for help - # - # - actor: example.com - # services: - # - service: "*" - # level: admin listeners: - # (Optional) HTTP Listener configuration. + # HTTP Listener configuration. # Bind resource endpoints to ports and addresses. # 'port' must be specified. Each listener must listen on a unique port. # 'bindAddress' will default to '127.0.0.1' if not specified, which may not be suited to Docker environments. # 'resources' may be any of webhooks, widgets, metrics, provisioning - # - port: 9000 bindAddress: 0.0.0.0 resources: @@ -302,6 +142,153 @@ hookshot: resources: - widgets registration: + #github: + # # (Optional) Configure this to enable GitHub support + # auth: + # # Authentication for the GitHub App. + # id: 123 + # privateKeyFile: github-key.pem + # webhook: + # # Webhook settings for the GitHub app. + # secret: secrettoken + # oauth: + # # (Optional) Settings for allowing users to sign in via OAuth. + # client_id: foo + # client_secret: bar + # redirect_uri: https://example.com/oauth/ + # defaultOptions: + # # (Optional) Default options for GitHub connections. + # showIssueRoomLink: false + # hotlinkIssues: + # prefix: "#" + # userIdPrefix: + # # (Optional) Prefix used when creating ghost users for GitHub accounts. + # _github_ + + #gitlab: + # # (Optional) Configure this to enable GitLab support + # instances: + # gitlab.com: + # url: https://gitlab.com + # webhook: + # secret: secrettoken + # publicUrl: https://example.com/hookshot/ + # userIdPrefix: + # # (Optional) Prefix used when creating ghost users for GitLab accounts. + # _gitlab_ + # commentDebounceMs: + # # (Optional) Aggregate comments by waiting this many miliseconds before posting them to Matrix. Defaults to 5000 (5 seconds) + # 5000 + + #figma: + # # (Optional) Configure this to enable Figma support + # publicUrl: https://example.com/hookshot/ + # instances: + # your-instance: + # teamId: your-team-id + # accessToken: your-personal-access-token + # passcode: your-webhook-passcode + + #jira: + # # (Optional) Configure this to enable Jira support. Only specify `url` if you are using a On Premise install (i.e. not atlassian.com) + # webhook: + # # Webhook settings for JIRA + # secret: secrettoken + # oauth: + # # (Optional) OAuth settings for connecting users to JIRA. See documentation for more information + # client_id: foo + # client_secret: bar + # redirect_uri: https://example.com/oauth/ + + #generic: + # # (Optional) Support for generic webhook events. + # #'allowJsTransformationFunctions' will allow users to write short transformation snippets in code, and thus is unsafe in untrusted environments + + # enabled: false + # enableHttpGet: false + # urlPrefix: https://example.com/webhook/ + # userIdPrefix: _webhooks_ + # allowJsTransformationFunctions: false + # waitForComplete: false + + #feeds: + # # (Optional) Configure this to enable RSS/Atom feed support + # enabled: false + # pollConcurrency: 4 + # pollIntervalSeconds: 600 + # pollTimeoutSeconds: 30 + + #provisioning: + # # (Optional) Provisioning API for integration managers + # secret: "!secretToken" + + #bot: + # # (Optional) Define profile information for the bot user + # displayname: Hookshot Bot + # avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d + + #serviceBots: + # # (Optional) Define additional bot users for specific services + # - localpart: feeds + # displayname: Feeds + # avatar: ./assets/feeds_avatar.png + # prefix: "!feeds" + # service: feeds + + #metrics: + # # (Optional) Prometheus metrics support + # enabled: true + + #cache: + # # (Optional) Cache options for large scale deployments. + # # For encryption to work, this must be configured. + # redisUri: redis://localhost:6379 + + #queue: + # # (Optional) Message queue configuration options for large scale deployments. + # # For encryption to work, this must not be configured. + # redisUri: redis://localhost:6379 + + #widgets: + # # (Optional) EXPERIMENTAL support for complimentary widgets + # addToAdminRooms: false + # disallowedIpRanges: + # - 127.0.0.0/8 + # - 10.0.0.0/8 + # - 172.16.0.0/12 + # - 192.168.0.0/16 + # - 100.64.0.0/10 + # - 192.0.0.0/24 + # - 169.254.0.0/16 + # - 192.88.99.0/24 + # - 198.18.0.0/15 + # - 192.0.2.0/24 + # - 198.51.100.0/24 + # - 203.0.113.0/24 + # - 224.0.0.0/4 + # - ::1/128 + # - fe80::/10 + # - fc00::/7 + # - 2001:db8::/32 + # - ff00::/8 + # - fec0::/10 + # roomSetupWidget: + # addOnInvite: false + # publicUrl: https://example.com/widgetapi/v1/static/ + # branding: + # widgetTitle: Hookshot Configuration + + #sentry: + # # (Optional) Configure Sentry error reporting + # dsn: https://examplePublicKey@o0.ingest.sentry.io/0 + # environment: production + + #permissions: + # # (Optional) Permissions for using the bridge. See docs/setup.md#permissions for help + # - actor: example.com + # services: + # - service: "*" + # level: admin id: matrix-hookshot as_token: "" hs_token: "" diff --git a/jest.config.ts b/jest.config.ts index 371b39f86..21432ec32 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -7,15 +7,9 @@ import type {Config} from 'jest'; const config: Config = { // The root directory that Jest should scan for tests and modules within - rootDir: "spec", + rootDir: "spec-lib", testTimeout: 60000, - transform: { - '^.+\\.tsx?$': [ - 'ts-jest', - { - }, - ], - }, + setupFiles: ["/spec/setup-jest.js"], }; export default config; diff --git a/package.json b/package.json index 5b65de21b..69899375e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-hookshot", - "version": "5.2.1", + "version": "6.0.0", "description": "A bridge between Matrix and multiple project management services, such as GitHub, GitLab and JIRA.", "main": "lib/app.js", "repository": "https://github.com/matrix-org/matrix-hookshot", @@ -10,7 +10,7 @@ "name": "matrix-hookshot-rs" }, "engines": { - "node": ">=20" + "node": ">=22" }, "scripts": { "build:web": "vite build", @@ -31,11 +31,11 @@ "start:webhooks": "node --require source-map-support/register lib/App/GithubWebhookApp.js", "start:matrixsender": "node --require source-map-support/register lib/App/MatrixSenderApp.js", "start:resetcrypto": "node --require source-map-support/register lib/App/ResetCryptoStore.js", - "test": "mocha -r ts-node/register tests/init.ts tests/*.ts tests/**/*.ts", - "test:e2e": "yarn node --experimental-vm-modules $(yarn bin jest)", + "test": "mocha -r ts-node/register tests/init.ts 'tests/*.ts' 'tests/**/*.ts'", + "test:e2e": "tsc --p tsconfig.spec.json && cp ./lib/libRs.js ./lib/matrix-hookshot-rs.node ./spec-lib/src && yarn node --experimental-vm-modules $(yarn bin jest)", "test:cover": "nyc --reporter=lcov --reporter=text yarn test", "lint": "yarn run lint:js && yarn run lint:rs", - "lint:js": "eslint -c .eslintrc.js 'src/**/*.ts' 'tests/**/*.ts' 'web/**/*.ts' 'web/**/*.tsx'", + "lint:js": "eslint", "lint:rs": "cargo fmt --all -- --check && cargo clippy -- -Dwarnings", "lint:rs:apply": "cargo clippy --fix && cargo fmt --all", "generate-default-config": "ts-node src/config/Defaults.ts --config > config.sample.yml", @@ -48,29 +48,32 @@ "@octokit/rest": "^20.0.2", "@octokit/webhooks": "^12.0.10", "@sentry/node": "^7.52.1", - "@vector-im/compound-design-tokens": "^0.1.0", - "@vector-im/compound-web": "^0.9.4", + "@vector-im/compound-design-tokens": "^2.0.1", + "@vector-im/compound-web": "^7.3.0", "ajv": "^8.11.0", - "axios": "^1.6.3", + "axios": "^1.7.5", + "clsx": "^2.1.1", "cors": "^2.8.5", - "express": "^4.18.2", + "date-fns": "^4.1.0", + "express": "^4.20.0", "figma-js": "^1.14.0", + "helmet": "^7.1.0", "http-status-codes": "^2.2.0", "iconv-lite": "^0.6.3", "ioredis": "^5.2.3", "jira-client": "^8.2.2", "markdown-it": "^14.0.0", "matrix-appservice-bridge": "^9.0.1", - "matrix-bot-sdk": "npm:@vector-im/matrix-bot-sdk@^0.7.0-specific-device-2", - "matrix-widget-api": "^1.6.0", - "micromatch": "^4.0.5", - "mime": "^4.0.1", + "matrix-bot-sdk": "npm:@vector-im/matrix-bot-sdk@0.7.1-element.7", + "matrix-widget-api": "^1.10.0", + "micromatch": "^4.0.8", + "mime": "^4.0.4", "node-emoji": "^2.1.3", "node-html-markdown": "^1.3.0", - "p-queue": "^6.6.2", + "parse-duration": "^1.1.0", "preact-render-to-string": "^6.3.1", "prom-client": "^15.1.0", - "quickjs-emscripten": "^0.26.0", + "quickjs-emscripten": "^0.31.0", "reflect-metadata": "^0.2.1", "source-map-support": "^0.5.21", "string-argv": "^0.3.1", @@ -82,11 +85,16 @@ }, "devDependencies": { "@codemirror/lang-javascript": "^6.0.2", + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.15.0", + "@fontsource/inter": "^5.1.0", "@napi-rs/cli": "^2.13.2", - "@preact/preset-vite": "^2.2.0", + "@octokit/webhooks-types": "^7.6.1", + "@preact/preset-vite": "^2.9.1", "@rollup/plugin-alias": "^5.1.0", - "@tsconfig/node18": "^18.2.2", + "@tsconfig/node22": "^22", "@types/ajv": "^1.0.0", + "@types/busboy": "^1.5.4", "@types/chai": "^4.2.22", "@types/cors": "^2.8.12", "@types/express": "^4.17.14", @@ -96,26 +104,28 @@ "@types/micromatch": "^4.0.1", "@types/mime": "^3.0.4", "@types/mocha": "^10.0.6", - "@types/node": "20.10.6", + "@types/node": "^22", "@types/xml2js": "^0.4.11", - "@typescript-eslint/eslint-plugin": "^6.17.0", - "@typescript-eslint/parser": "^6.17.0", "@uiw/react-codemirror": "^4.12.3", - "chai": "^4.3.4", - "eslint": "^8.49.0", - "eslint-config-preact": "^1.3.0", - "eslint-plugin-mocha": "^10.1.0", - "homerunner-client": "^1.0.0", + "babel-cli": "^6.26.0", + "babel-jest": "^29.7.0", + "busboy": "^1.6.0", + "chai": "^4", + "eslint": "^9.15.0", + "eslint-plugin-chai-expect": "^3.1.0", + "eslint-plugin-mocha": "^10.5.0", + "eslint-plugin-react": "^7.37.2", + "homerunner-client": "^1.1.0", "jest": "^29.7.0", - "mini.css": "^3.0.1", - "mocha": "^10.2.0", - "nyc": "^15.1.0", - "preact": "^10.5.15", - "rimraf": "^5.0.5", - "sass": "^1.69.6", - "ts-jest": "^29.1.1", - "ts-node": "^10.9.1", - "typescript": "^5.3.3", - "vite": "^5.0.12" - } + "mocha": "^10.8.2", + "nyc": "^17.1.0", + "preact": "^10.24.3", + "rimraf": "6.0.1", + "sass": "^1.81.0", + "ts-node": "10.9.2", + "typescript": "^5.7.2", + "typescript-eslint": "^8.16.0", + "vite": "^5.4.11" + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 6a8fdce48..9b25f7201 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # exit when any command fails set -e diff --git a/spec/basic.spec.ts b/spec/basic.spec.ts index ec36ddf08..2400487bb 100644 --- a/spec/basic.spec.ts +++ b/spec/basic.spec.ts @@ -19,44 +19,11 @@ describe('Basic test setup', () => { const user = testEnv.getUser('user'); const roomId = await user.createRoom({ name: 'Test room', invite:[testEnv.botMxid] }); await user.waitForRoomJoin({sender: testEnv.botMxid, roomId }); - await user.sendText(roomId, "!hookshot help"); - const msg = await user.waitForRoomEvent({ + const msg = user.waitForRoomEvent({ eventType: 'm.room.message', sender: testEnv.botMxid, roomId }); + await user.sendText(roomId, "!hookshot help"); // Expect help text. - expect(msg.data.content.body).to.include('!hookshot help` - This help text\n'); - }); - - // TODO: Move test to it's own generic connections file. - it('should be able to setup a webhook', async () => { - const user = testEnv.getUser('user'); - const testRoomId = await user.createRoom({ name: 'Test room', invite:[testEnv.botMxid] }); - await user.waitForRoomJoin({sender: testEnv.botMxid, roomId: testRoomId }); - await user.setUserPowerLevel(testEnv.botMxid, testRoomId, 50); - await user.sendText(testRoomId, "!hookshot webhook test-webhook"); - const inviteResponse = await user.waitForRoomInvite({sender: testEnv.botMxid}); - await user.waitForRoomEvent({ - eventType: 'm.room.message', sender: testEnv.botMxid, roomId: testRoomId, - body: 'Room configured to bridge webhooks. See admin room for secret url.' - }); - const webhookUrlMessage = user.waitForRoomEvent({ - eventType: 'm.room.message', sender: testEnv.botMxid, roomId: inviteResponse.roomId - }); - await user.joinRoom(inviteResponse.roomId); - const msgData = (await webhookUrlMessage).data.content.body; - const webhookUrl = msgData.split('\n')[2]; - const webhookNotice = user.waitForRoomEvent({ - eventType: 'm.room.message', sender: testEnv.botMxid, roomId: testRoomId, body: 'Hello world!' - }); - - // Send a webhook - await fetch(webhookUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({text: 'Hello world!'}) - }); - - // And await the notice. - await webhookNotice; + expect((await msg).data.content.body).to.include('!hookshot help` - This help text\n'); }); }); diff --git a/spec/e2ee.spec.ts b/spec/e2ee.spec.ts new file mode 100644 index 000000000..a5c1d8219 --- /dev/null +++ b/spec/e2ee.spec.ts @@ -0,0 +1,66 @@ +import { MessageEventContent } from "matrix-bot-sdk"; +import { E2ESetupTestTimeout, E2ETestEnv } from "./util/e2e-test"; +import { describe, it, beforeEach, afterEach } from "@jest/globals"; + +const CryptoRoomState = [{ + content: { + "algorithm": "m.megolm.v1.aes-sha2" + }, + state_key: "", + type: "m.room.encryption" +}]; + +describe('End-2-End Encryption support', () => { + let testEnv: E2ETestEnv; + + beforeEach(async () => { + testEnv = await E2ETestEnv.createTestEnv({ matrixLocalparts: ['user'], enableE2EE: true }); + await testEnv.setUp(); + }, E2ESetupTestTimeout); + + afterEach(() => { + return testEnv?.tearDown(); + }); + + it('should be able to send the help command', async () => { + const user = testEnv.getUser('user'); + const testRoomId = await user.createRoom({ name: 'Test room', invite:[testEnv.botMxid], initial_state: CryptoRoomState}); + await user.setUserPowerLevel(testEnv.botMxid, testRoomId, 50); + await user.waitForRoomJoin({sender: testEnv.botMxid, roomId: testRoomId }); + await user.sendText(testRoomId, "!hookshot help"); + await user.waitForRoomEvent({ + eventType: 'm.room.message', sender: testEnv.botMxid, roomId: testRoomId, + }); + }); + it('should send notices in an encrypted format', async () => { + const user = testEnv.getUser('user'); + const testRoomId = await user.createRoom({ name: 'Test room', invite:[testEnv.botMxid], initial_state: CryptoRoomState}); + await user.setUserPowerLevel(testEnv.botMxid, testRoomId, 50); + await user.waitForRoomJoin({sender: testEnv.botMxid, roomId: testRoomId }); + await user.sendText(testRoomId, "!hookshot webhook test-webhook"); + const inviteResponse = await user.waitForRoomInvite({sender: testEnv.botMxid}); + await user.waitForEncryptedEvent({ + eventType: 'm.room.message', sender: testEnv.botMxid, roomId: testRoomId, + body: 'Room configured to bridge webhooks. See admin room for secret url.' + }); + const webhookUrlMessage = user.waitForEncryptedEvent({ + eventType: 'm.room.message', sender: testEnv.botMxid, roomId: inviteResponse.roomId + }); + await user.joinRoom(inviteResponse.roomId); + const msgData = (await webhookUrlMessage).data.content.body; + const webhookUrl = msgData.split('\n')[2]; + const webhookNotice = user.waitForEncryptedEvent({ + eventType: 'm.room.message', sender: testEnv.botMxid, roomId: testRoomId, body: 'Hello world!' + }); + + // Send a webhook + await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({text: 'Hello world!'}) + }); + + // And await the notice. + await webhookNotice; + }); +}); diff --git a/spec/generic-hooks.spec.ts b/spec/generic-hooks.spec.ts new file mode 100644 index 000000000..8b1f266b4 --- /dev/null +++ b/spec/generic-hooks.spec.ts @@ -0,0 +1,119 @@ +import { E2ESetupTestTimeout, E2ETestEnv, E2ETestMatrixClient } from "./util/e2e-test"; +import { describe, it } from "@jest/globals"; +import { GenericHookConnection } from "../src/Connections"; +import { TextualMessageEventContent } from "matrix-bot-sdk"; +import { add } from "date-fns/add"; + +async function createInboundConnection(user: E2ETestMatrixClient, botMxid: string, roomId: string, duration?: string) { + const join = user.waitForRoomJoin({ sender: botMxid, roomId }); + const connectionEvent = user.waitForRoomEvent({ + eventType: GenericHookConnection.CanonicalEventType, + stateKey: 'test', + sender: botMxid + }); + await user.inviteUser(botMxid, roomId); + await user.setUserPowerLevel(botMxid, roomId, 50); + await join; + + // Note: Here we create the DM proactively so this works across multiple + // tests. + // Get the DM room so we can get the token. + const dmRoomId = await user.dms.getOrCreateDm(botMxid); + + await user.sendText(roomId, '!hookshot webhook test' + (duration ? ` ${duration}` : "")); + // Test the contents of this. + await connectionEvent; + + const msgPromise = user.waitForRoomEvent({ sender: botMxid, eventType: "m.room.message", roomId: dmRoomId }); + const { data: msgData } = await msgPromise; + const msgContent = msgData.content as unknown as TextualMessageEventContent; + const [_unused1, _unused2, url] = msgContent.body.split('\n'); + return url; +} + +describe('Inbound (Generic) Webhooks', () => { + let testEnv: E2ETestEnv; + + beforeAll(async () => { + const webhooksPort = 9500 + E2ETestEnv.workerId; + testEnv = await E2ETestEnv.createTestEnv({ + matrixLocalparts: ['user'], + config: { + generic: { + enabled: true, + // Prefer to wait for complete as it reduces the concurrency of the test. + waitForComplete: true, + urlPrefix: `http://localhost:${webhooksPort}` + }, + listeners: [{ + port: webhooksPort, + bindAddress: '0.0.0.0', + // Bind to the SAME listener to ensure we don't have conflicts. + resources: ['webhooks'], + }], + } + }); + await testEnv.setUp(); + }, E2ESetupTestTimeout); + + afterAll(() => { + return testEnv?.tearDown(); + }); + + it('should be able to create a new webhook and handle an incoming request.', async () => { + const user = testEnv.getUser('user'); + const roomId = await user.createRoom({ name: 'My Test Webhooks room'}); + const okMsg = user.waitForRoomEvent({ eventType: "m.room.message", sender: testEnv.botMxid, roomId }); + const url = await createInboundConnection(user, testEnv.botMxid, roomId); + expect((await okMsg).data.content.body).toEqual('Room configured to bridge webhooks. See admin room for secret url.'); + + const expectedMsg = user.waitForRoomEvent({ eventType: "m.room.message", sender: testEnv.botMxid, roomId }); + const req = await fetch(url, { + method: "PUT", + body: "Hello world" + }); + expect(req.status).toEqual(200); + expect(await req.json()).toEqual({ ok: true }); + expect((await expectedMsg).data.content).toEqual({ + msgtype: 'm.notice', + body: 'Received webhook data: Hello world', + formatted_body: '

Received webhook data: Hello world

', + format: 'org.matrix.custom.html', + 'uk.half-shot.hookshot.webhook_data': 'Hello world' + }); + }); + + it('should be able to create a new expiring webhook and handle valid requests.', async () => { + jest.useFakeTimers(); + const user = testEnv.getUser('user'); + const roomId = await user.createRoom({ name: 'My Test Webhooks room'}); + const okMsg = user.waitForRoomEvent({ eventType: "m.room.message", sender: testEnv.botMxid, roomId }); + const url = await createInboundConnection(user, testEnv.botMxid, roomId, '2h'); + expect((await okMsg).data.content.body).toEqual('Room configured to bridge webhooks. See admin room for secret url.'); + + const expectedMsg = user.waitForRoomEvent({ eventType: "m.room.message", sender: testEnv.botMxid, roomId }); + const req = await fetch(url, { + method: "PUT", + body: "Hello world" + }); + expect(req.status).toEqual(200); + expect(await req.json()).toEqual({ ok: true }); + expect((await expectedMsg).data.content).toEqual({ + msgtype: 'm.notice', + body: 'Received webhook data: Hello world', + formatted_body: '

Received webhook data: Hello world

', + format: 'org.matrix.custom.html', + 'uk.half-shot.hookshot.webhook_data': 'Hello world' + }); + jest.setSystemTime(add(new Date(), { hours: 3 })); + const expiredReq = await fetch(url, { + method: "PUT", + body: "Hello world" + }); + expect(expiredReq.status).toEqual(404); + expect(await expiredReq.json()).toEqual({ + ok: false, + error: "This hook has expired", + }); + }); +}); diff --git a/spec/setup-jest.ts b/spec/setup-jest.ts new file mode 100644 index 000000000..a5481961e --- /dev/null +++ b/spec/setup-jest.ts @@ -0,0 +1,2 @@ +// In CI, the network creation for the homerunner containers can race (https://github.com/matrix-org/complement/issues/720). +jest.retryTimes(process.env.CI ? 3 : 1); \ No newline at end of file diff --git a/spec/util/e2e-test.ts b/spec/util/e2e-test.ts index 20cf38c25..7d842c7b3 100644 --- a/spec/util/e2e-test.ts +++ b/spec/util/e2e-test.ts @@ -5,13 +5,24 @@ import { BridgeConfig, BridgeConfigRoot } from "../../src/config/Config"; import { start } from "../../src/App/BridgeApp"; import { RSAKeyPairOptions, generateKeyPair } from "node:crypto"; import path from "node:path"; +import Redis from "ioredis"; -const WAIT_EVENT_TIMEOUT = 10000; +const WAIT_EVENT_TIMEOUT = 20000; export const E2ESetupTestTimeout = 60000; +const REDIS_DATABASE_URI = process.env.HOOKSHOT_E2E_REDIS_DB_URI ?? "redis://localhost:6379"; interface Opts { matrixLocalparts?: string[]; config?: Partial, + enableE2EE?: boolean, + useRedis?: boolean, +} + +interface WaitForEventResponse> { + roomId: string, + data: { + sender: string, type: string, state_key?: string, content: T, event_id: string, + } } export class E2ETestMatrixClient extends MatrixClient { @@ -55,13 +66,10 @@ export class E2ETestMatrixClient extends MatrixClient { }, `Timed out waiting for powerlevel from in ${roomId}`) } - public async waitForRoomEvent>( - opts: {eventType: string, sender: string, roomId?: string, stateKey?: string, body?: string} - ): Promise<{roomId: string, data: { - sender: string, type: string, state_key?: string, content: T, event_id: string, - }}> { - const {eventType, sender, roomId, stateKey} = opts; - return this.waitForEvent('room.event', (eventRoomId: string, eventData: { + private async innerWaitForRoomEvent>( + {eventType, sender, roomId, stateKey, eventId, body}: {eventType: string, sender: string, roomId?: string, stateKey?: string, body?: string, eventId?: string}, expectEncrypted: boolean, + ): Promise> { + return this.waitForEvent(expectEncrypted ? 'room.decrypted_event' : 'room.event', (eventRoomId: string, eventData: { sender: string, type: string, state_key?: string, content: T, event_id: string, }) => { if (eventData.sender !== sender) { @@ -73,21 +81,36 @@ export class E2ETestMatrixClient extends MatrixClient { if (roomId && eventRoomId !== roomId) { return undefined; } + if (eventId && eventData.event_id !== eventId) { + return undefined; + } if (stateKey !== undefined && eventData.state_key !== stateKey) { return undefined; } - const body = 'body' in eventData.content && eventData.content.body; - if (opts.body && body !== opts.body) { + const evtBody = 'body' in eventData.content && eventData.content.body; + if (body && body !== evtBody) { return undefined; } console.info( // eslint-disable-next-line max-len - `${eventRoomId} ${eventData.event_id} ${eventData.type} ${eventData.sender} ${eventData.state_key ?? body ?? ''}` + `${eventRoomId} ${eventData.event_id} ${eventData.type} ${eventData.sender} ${eventData.state_key ?? evtBody ?? ''}` ); return {roomId: eventRoomId, data: eventData}; }, `Timed out waiting for ${eventType} from ${sender} in ${roomId || "any room"}`) } + public async waitForRoomEvent>( + opts: Parameters[0] + ): Promise> { + return this.innerWaitForRoomEvent(opts, false); + } + + public async waitForEncryptedEvent>( + opts: Parameters[0] + ): Promise> { + return this.innerWaitForRoomEvent(opts, true); + } + public async waitForRoomJoin( opts: {sender: string, roomId?: string} ): Promise<{roomId: string, data: unknown}> { @@ -173,10 +196,11 @@ export class E2ETestEnv { if (err) { reject(err) } else { resolve(privateKey) } })); + const dir = await mkdtemp('hookshot-int-test'); + // Configure homeserver and bots - const [homeserver, dir, privateKey] = await Promise.all([ - createHS([...matrixLocalparts || []], workerID), - mkdtemp('hookshot-int-test'), + const [homeserver, privateKey] = await Promise.all([ + createHS([...matrixLocalparts || []], workerID, opts.enableE2EE ? path.join(dir, 'client-crypto') : undefined), keyPromise, ]); const keyPath = path.join(dir, 'key.pem'); @@ -193,6 +217,15 @@ export class E2ETestEnv { providedConfig.github.auth.privateKeyFile = keyPath; } + opts.useRedis = opts.enableE2EE || opts.useRedis; + + let cacheConfig: BridgeConfigRoot["cache"]|undefined; + if (opts.useRedis) { + cacheConfig = { + redisUri: `${REDIS_DATABASE_URI}/${workerID}`, + } + } + const config = new BridgeConfig({ bridge: { domain: homeserver.domain, @@ -201,10 +234,7 @@ export class E2ETestEnv { bindAddress: '0.0.0.0', }, logging: { - level: 'info', - }, - queue: { - monolithic: true, + level: 'debug', }, // Always enable webhooks so that hookshot starts. generic: { @@ -217,6 +247,12 @@ export class E2ETestEnv { resources: ['webhooks'], }], passFile: keyPath, + ...(opts.enableE2EE ? { + encryption: { + storagePath: path.join(dir, 'crypto-store'), + } + } : undefined), + cache: cacheConfig, ...providedConfig, }); const registration: IAppserviceRegistration = { @@ -230,7 +266,8 @@ export class E2ETestEnv { }], rooms: [], aliases: [], - } + }, + "de.sorunome.msc2409.push_ephemeral": true }; const app = await start(config, registration); app.listener.finaliseListeners(); @@ -258,6 +295,12 @@ export class E2ETestEnv { await this.app.bridgeApp.stop(); await this.app.listener.stop(); await this.app.storage.disconnect?.(); + + // Clear the redis DB. + if (this.config.cache?.redisUri) { + await new Redis(this.config.cache.redisUri).flushdb(); + } + this.homeserver.users.forEach(u => u.client.stop()); await destroyHS(this.homeserver.id); await rm(this.dir, { recursive: true }); diff --git a/spec/util/fixtures.ts b/spec/util/fixtures.ts new file mode 100644 index 000000000..ae6de768c --- /dev/null +++ b/spec/util/fixtures.ts @@ -0,0 +1 @@ +export const TEST_FILE = Buffer.from(`PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxMy4wLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDE0NTc2KSAgLS0+DQo8IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPg0KPHN2ZyB2ZXJzaW9uPSIxLjEiIGlkPSJMYXllcl8xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4PSIwcHgiIHk9IjBweCINCgkgd2lkdGg9Ijc5My4zMjJweCIgaGVpZ2h0PSIzNDAuODA5cHgiIHZpZXdCb3g9IjAgMCA3OTMuMzIyIDM0MC44MDkiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDc5My4zMjIgMzQwLjgwOSINCgkgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+DQo8cGF0aCBvcGFjaXR5PSIwLjUiIGZpbGw9IiNGRkZGRkYiIGQ9Ik0zNC4wMDQsMzQwLjgwOUgyYy0xLjEwNCwwLTItMC44OTYtMi0yVjJjMC0xLjEwNCwwLjg5Ni0yLDItMmgzMi4wMDRjMS4xMDQsMCwyLDAuODk2LDIsMg0KCXY3LjcxYzAsMS4xMDQtMC44OTYsMi0yLDJoLTIxLjEzdjMxNy4zODZoMjEuMTNjMS4xMDQsMCwyLDAuODk2LDIsMi4wMDF2Ny43MTJDMzYuMDA0LDMzOS45MTMsMzUuMTA4LDM0MC44MDksMzQuMDA0LDM0MC44MDkNCglMMzQuMDA0LDM0MC44MDl6Ii8+DQo8cGF0aCBvcGFjaXR5PSIwLjUiIGZpbGw9IiNGRkZGRkYiIGQ9Ik0xMC44NzUsOS43MTF2MzIxLjM4NmgyMy4xM3Y3LjcxMUgxLjk5OVYyLjAwMWgzMi4wMDZ2Ny43MUgxMC44NzV6Ii8+DQo8cGF0aCBvcGFjaXR5PSIwLjUiIGZpbGw9IiNGRkZGRkYiIGQ9Ik0yNTIuNDAyLDIzMy43MTFoLTMyLjk5M2MtMS4xMDQsMC0yLTAuODk2LTItMnYtNjguMDczYzAtMy45NDktMC4xNTQtNy43MjItMC40NTctMTEuMjEzDQoJYy0wLjI4OS0zLjI4Mi0xLjA3NC02LjE1My0yLjMzMi04LjUzYy0xLjIwNC0yLjI3Ni0zLjAxNy00LjExOS01LjM4NC01LjQ3NmMtMi4zOTMtMS4zNjItNS43NzUtMi4wNTYtMTAuMDQyLTIuMDU2DQoJYy00LjIzOCwwLTcuNjc0LDAuNzk4LTEwLjIxMywyLjM3MWMtMi41NjUsMS41OTYtNC42MDQsMy43MDEtNi4wNTMsNi4yNThjLTEuNDk4LDIuNjQzLTIuNTEsNS42OTQtMy4wMTMsOS4wNjcNCgljLTAuNTI2LDMuNTEzLTAuNzkzLDcuMTI1LTAuNzkzLDEwLjc0MXY2Ni45MWMwLDEuMTA0LTAuODk2LDItMiwyaC0zMi45OTFjLTEuMTA0LDAtMi0wLjg5Ni0yLTJ2LTY3LjM3Mw0KCWMwLTMuNDM1LTAuMDc4LTYuOTY0LTAuMjI4LTEwLjQ4NWMtMC4xNDgtMy4yNTEtMC43NjctNi4yNzgtMS44NDEtOC45OTVjLTEuMDE4LTIuNTcxLTIuNjY3LTQuNTg0LTUuMDQ3LTYuMTUzDQoJYy0yLjM3Mi0xLjU1Mi02LjAyOS0yLjM0MS0xMC44NjUtMi4zNDFjLTEuMzcyLDAtMy4yNjUsMC4zMjgtNS42MjksMC45NzZjLTIuMjgsMC42MjQtNC41MzYsMS44MjYtNi43MDUsMy41NzcNCgljLTIuMTUyLDEuNzMyLTQuMDM2LDQuMzA2LTUuNjA1LDcuNjU1Yy0xLjU2OSwzLjM1Ni0yLjM2Nyw3Ljg3Ny0yLjM2NywxMy40Mzh2NjkuNzAxYzAsMS4xMDQtMC44OTUsMi0yLDJINjguODU3DQoJYy0xLjEwNCwwLTItMC44OTYtMi0yVjExMS41OTRjMC0xLjEwNCwwLjg5Ni0xLjk5OSwyLTEuOTk5aDMxLjEzYzEuMTA0LDAsMiwwLjg5NiwyLDEuOTk5djExLjAwNw0KCWMzLjgzNC00LjQ5OSw4LjI0OC04LjE1MiwxMy4xNzMtMTAuODk2YzYuMzk2LTMuNTU5LDEzLjc5OS01LjM2MiwyMi4wMDItNS4zNjJjNy44NDYsMCwxNS4xMjcsMS41NDgsMjEuNjQyLDQuNjA0DQoJYzUuNzk0LDIuNzIyLDEwLjQyNCw3LjI2LDEzLjc5MSwxMy41MmMzLjQ0OS00LjM2Miw3LjgzMy04LjMwNiwxMy4wNzEtMTEuNzUyYzYuNDIyLTQuMjI4LDE0LjEwMi02LjM3MSwyMi44MjQtNi4zNzENCgljNi40OTksMCwxMi42MjUsMC44MDcsMTguMjA5LDIuMzk5YzUuNjg2LDEuNjI4LDEwLjYzNSw0LjI3MSwxNC43MTIsNy44NTdjNC4wODgsMy42MDUsNy4zMTgsOC4zNTcsOS42MDEsMTQuMTIzDQoJYzIuMjUsNS43MTksMy4zOTEsMTIuNjQ5LDMuMzkxLDIwLjYwNHY4MC4zODRDMjU0LjQwMiwyMzIuODE1LDI1My41MDcsMjMzLjcxMSwyNTIuNDAyLDIzMy43MTFMMjUyLjQwMiwyMzMuNzExeiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNOTkuOTg4LDExMS41OTV2MTYuMjY0aDAuNDYzYzQuMzM4LTYuMTkxLDkuNTYzLTEwLjk5OCwxNS42ODQtMTQuNDA2DQoJYzYuMTE3LTMuNDAyLDEzLjEyOS01LjExLDIxLjAyNy01LjExYzcuNTg4LDAsMTQuNTIxLDEuNDc1LDIwLjc5Myw0LjQxNWM2LjI3NCwyLjk0NSwxMS4wMzgsOC4xMzEsMTQuMjkxLDE1LjU2Nw0KCWMzLjU2LTUuMjY1LDguNC05LjkxMywxNC41MjEtMTMuOTRjNi4xMTctNC4wMjUsMTMuMzU4LTYuMDQyLDIxLjcyNC02LjA0MmM2LjM1MSwwLDEyLjIzNCwwLjc3NiwxNy42NiwyLjMyNQ0KCWM1LjQxOCwxLjU0OSwxMC4wNjUsNC4wMjcsMTMuOTM4LDcuNDM0YzMuODY5LDMuNDEsNi44ODksNy44NjMsOS4wNjIsMTMuMzU3YzIuMTY3LDUuNTA0LDMuMjUzLDEyLjEyMiwzLjI1MywxOS44Njl2ODAuMzg1SDIxOS40MQ0KCXYtNjguMDc0YzAtNC4wMjUtMC4xNTQtNy44Mi0wLjQ2NS0xMS4zODVjLTAuMzEzLTMuNTYtMS4xNjEtNi42NTYtMi41NTUtOS4yOTNjLTEuMzk1LTIuNjMxLTMuNDUtNC43MjQtNi4xNTctNi4yNzQNCgljLTIuNzExLTEuNTQzLTYuMzkxLTIuMzIyLTExLjAzNy0yLjMyMnMtOC40MDMsMC44OTYtMTEuMjY5LDIuNjcxYy0yLjg2OCwxLjc4NC01LjExMiw0LjEwOS02LjczNyw2Ljk3MQ0KCWMtMS42MjYsMi44NjktMi43MTEsNi4xMi0zLjI1Miw5Ljc2MmMtMC41NDUsMy42MzgtMC44MTQsNy4zMTgtMC44MTQsMTEuMDM1djY2LjkxaC0zMi45OTF2LTY3LjM3NWMwLTMuNTYyLTAuMDgxLTcuMDg3LTAuMjMtMTAuNTcNCgljLTAuMTU4LTMuNDg3LTAuODE0LTYuNy0xLjk3OC05LjY0NWMtMS4xNjItMi45NC0zLjA5OS01LjMwNC01LjgwOS03LjA4OGMtMi43MTEtMS43NzUtNi42OTktMi42NzEtMTEuOTY1LTIuNjcxDQoJYy0xLjU1MSwwLTMuNjAzLDAuMzQ5LTYuMTU2LDEuMDQ4Yy0yLjU1NiwwLjY5Ny01LjAzNiwyLjAxNi03LjQzNSwzLjk0OWMtMi40MDQsMS45MzgtNC40NTQsNC43MjYtNi4xNTgsOC4zNjMNCgljLTEuNzA1LDMuNjQyLTIuNTU2LDguNDAyLTIuNTU2LDE0LjI4N3Y2OS43MDFoLTMyLjk5VjExMS41OTVIOTkuOTg4eiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNMzA0LjkwOSwyMzYuNzMzYy01Ljg4MywwLTExLjQ2LTAuNzI5LTE2LjU3NC0yLjE2M2MtNS4xOTItMS40NjQtOS44MDYtMy43NzQtMTMuNzEzLTYuODcxDQoJYy0zLjk0NC0zLjExNy03LjA2OC03LjExMS05LjI4Mi0xMS44NzFjLTIuMjA1LTQuNzMzLTMuMzI0LTEwLjQxMi0zLjMyNC0xNi44NzZjMC03LjEzLDEuMjkzLTEzLjExNywzLjg0Ni0xNy43OTcNCgljMi41NDItNC42NzQsNS44NzctOC40NjQsOS45MTItMTEuMjYzYzMuOTctMi43NTIsOC41NTYtNC44NDIsMTMuNjMtNi4yMDljNC45MDEtMS4zMjIsOS45MzctMi4zOTQsMTQuOTYxLTMuMTg0DQoJYzQuOTg2LTAuNzc1LDkuOTQ5LTEuNDA0LDE0Ljc1NC0xLjg3MmM0LjY3OS0wLjQ1Miw4Ljg4LTEuMTM5LDEyLjQ4OS0yLjAzOWMzLjQxMi0wLjg1NCw2LjExOC0yLjA5LDguMDQyLTMuNjcyDQoJYzEuNjY2LTEuMzcsMi40MTYtMy4zODQsMi4yOTItNi4xNTFjLTAuMDAyLTMuMjg5LTAuNTAyLTUuODE2LTEuNDkyLTcuNTk1Yy0wLjk5OC0xLjc5OC0yLjI4My0zLjE1LTMuOTI3LTQuMTM4DQoJYy0xLjcwMy0xLjAyLTMuNzI1LTEuNzEzLTYuMDEyLTIuMDYyYy0yLjQ3LTAuMzctNS4xNDYtMC41NTctNy45NDctMC41NTdjLTYuMDM0LDAtMTAuNzg5LDEuMjcxLTE0LjEzNSwzLjc4Mw0KCWMtMy4yMzMsMi40MjQtNS4xNTUsNi42NC01LjcxNCwxMi41MjdjLTAuMDk4LDEuMDI2LTAuOTYxLDEuODEyLTEuOTkyLDEuODEyaC0zMi45OTJjLTAuNTUyLDAtMS4wNzktMC4yMjktMS40NTctMC42MjkNCgljLTAuMzc2LTAuNDAyLTAuNTcyLTAuOTQxLTAuNTQtMS40OTFjMC40ODUtOC4wNzMsMi41NS0xNC44OTQsNi4xNDItMjAuMjcyYzMuNTQ4LTUuMzMxLDguMTQ3LTkuNjgyLDEzLjY2MS0xMi45MzENCgljNS40MjQtMy4xOTEsMTEuNjEyLTUuNDk4LDE4LjM5Mi02Ljg1N2M2LjY4NC0xLjMzNSwxMy41LTIuMDEzLDIwLjI2LTIuMDEzYzYuMDk2LDAsMTIuMzY1LDAuNDM3LDE4LjYyNiwxLjI5Ng0KCWM2LjM3NywwLjg4LDEyLjI4NSwyLjYyMiwxNy41NjIsNS4xNzdjNS4zNzYsMi42MDQsOS44NDUsNi4yOSwxMy4yODIsMTAuOTUxYzMuNDk4LDQuNzQ0LDUuMjcxLDExLjA0OCw1LjI3MSwxOC43MzF2NjIuNDk0DQoJYzAsNS4zMDcsMC4zMDYsMTAuNDYyLDAuOTE1LDE1LjMxOWMwLjU3Niw0LjY0LDEuNTcyLDguMTE2LDIuOTYzLDEwLjMzOGMwLjM4NSwwLjYxNiwwLjQwNywxLjM5NSwwLjA1NSwyLjAzMQ0KCWMtMC4zNTMsMC42MzUtMS4wMjIsMS4wMy0xLjc1LDEuMDNoLTMzLjQ1N2MtMC44NjEsMC0xLjYyNC0wLjU1LTEuODk4LTEuMzY3Yy0wLjY0Ni0xLjk0MS0xLjE3Ni0zLjkzOS0xLjU3Mi01LjkzNg0KCWMtMC4xNDEtMC42OTYtMC4yNjctMS40MDItMC4zOC0yLjEyYy00LjgyNSw0LjE4NC0xMC4zNDksNy4yNC0xNi40NzQsOS4xMDVDMzIwLjAzMywyMzUuNjA5LDMxMi40ODksMjM2LjczMywzMDQuOTA5LDIzNi43MzMNCglMMzA0LjkwOSwyMzYuNzMzeiBNMzQxLjk0MSwxNzYuNjYxYy0wLjgwOSwwLjQwOS0xLjY3NiwwLjc2OC0yLjU5NiwxLjA3NGMtMi4xNjEsMC43Mi00LjUxMSwxLjMyNi02Ljk4OCwxLjgwNw0KCWMtMi40NDIsMC40NzUtNS4wMzMsMC44NzItNy42OTksMS4xODZjLTIuNjMxLDAuMzExLTUuMjUxLDAuNjk3LTcuNzg0LDEuMTQ2Yy0yLjMyOSwwLjQzMy00LjcwNSwxLjAzNS03LjA1MSwxLjc5Mg0KCWMtMi4xOTQsMC43MTEtNC4xMTQsMS42NjctNS42OTksMi44NDJjLTEuNTMxLDEuMTI4LTIuNzg1LDIuNTg3LTMuNzMxLDQuMzM1Yy0wLjkxNywxLjcwOS0xLjM4NSwzLjk3LTEuMzg1LDYuNzE5DQoJYzAsMi41OTgsMC40NjUsNC43NzgsMS4zODUsNi40ODFjMC45MjgsMS43MjIsMi4xNDIsMy4wMzUsMy43MTYsNC4wMThjMS42NDQsMS4wMjYsMy42MDEsMS43NTcsNS44MTYsMi4xNw0KCWMyLjM0NCwwLjQzOSw0Ljc5OSwwLjY2Myw3LjI5NywwLjY2M2M2LjEwNSwwLDEwLjgzNi0wLjk5NiwxNC4wNjMtMi45NjFjMy4yNDQtMS45NzMsNS42NjYtNC4zNDksNy4xOTktNy4wNjINCgljMS41NjgtMi43OCwyLjU0Mi01LjYyLDIuODkyLTguNDM2YzAuMzc2LTMuMDE5LDAuNTY1LTUuNDM2LDAuNTY1LTcuMTg3VjE3Ni42NjFMMzQxLjk0MSwxNzYuNjYxeiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNMjczLjU0NCwxMjkuMjU1YzMuNDA1LTUuMTEzLDcuNzQ0LTkuMjE1LDEzLjAxMi0xMi4zMTYNCgljNS4yNjQtMy4wOTcsMTEuMTg2LTUuMzAzLDE3Ljc3MS02LjYyMWM2LjU4Mi0xLjMxNSwxMy4yMDUtMS45NzYsMTkuODY1LTEuOTc2YzYuMDQyLDAsMTIuMTU4LDAuNDI4LDE4LjM1NCwxLjI3Nw0KCWM2LjE5NSwwLjg1NSwxMS44NSwyLjUyMiwxNi45NjIsNC45OTdjNS4xMTEsMi40NzcsOS4yOTIsNS45MjYsMTIuNTQ2LDEwLjMzOGMzLjI1Myw0LjQxNCw0Ljg3OSwxMC4yNjIsNC44NzksMTcuNTQzdjYyLjQ5NA0KCWMwLDUuNDI4LDAuMzEsMTAuNjExLDAuOTMxLDE1LjU2N2MwLjYxNSw0Ljk1OSwxLjcwMSw4LjY3NiwzLjI1MSwxMS4xNTNIMzQ3LjY2Yy0wLjYyMS0xLjg2LTEuMTI2LTMuNzU1LTEuNTExLTUuNjkzDQoJYy0wLjM5LTEuOTMzLTAuNjYxLTMuOTA4LTAuODEzLTUuOTIzYy01LjI2Nyw1LjQyMi0xMS40NjUsOS4yMTctMTguNTg1LDExLjM4NmMtNy4xMjcsMi4xNjMtMTQuNDA3LDMuMjUxLTIxLjg0MiwzLjI1MQ0KCWMtNS43MzMsMC0xMS4wNzctMC42OTgtMTYuMDMzLTIuMDljLTQuOTU4LTEuMzk1LTkuMjkzLTMuNTYyLTEzLjAxLTYuNTFjLTMuNzE4LTIuOTM4LTYuNjIyLTYuNjU2LTguNzEzLTExLjE0Nw0KCXMtMy4xMzgtOS44NC0zLjEzOC0xNi4wMzNjMC02LjgxMywxLjE5OS0xMi40MywzLjYwNC0xNi44NGMyLjM5OS00LjQxNyw1LjQ5NS03LjkzOSw5LjI5NS0xMC41NzUNCgljMy43OTMtMi42MzIsOC4xMjktNC42MDcsMTMuMDEtNS45MjNjNC44NzgtMS4zMTUsOS43OTUtMi4zNTgsMTQuNzUyLTMuMTM3YzQuOTU3LTAuNzcyLDkuODM1LTEuMzkzLDE0LjYzOC0xLjg1Nw0KCWM0LjgwMS0wLjQ2Niw5LjA2Mi0xLjE2NCwxMi43NzktMi4wOTNjMy43MTgtMC45MjksNi42NTgtMi4yODIsOC44MjktNC4wNjVjMi4xNjUtMS43ODEsMy4xNzItNC4zNzUsMy4wMi03Ljc4NQ0KCWMwLTMuNTYtMC41OC02LjM4OS0xLjc0Mi04LjQ3OWMtMS4xNjEtMi4wOS0yLjcxMS0zLjcxOS00LjY0Ni00Ljg4Yy0xLjkzNy0xLjE2MS00LjE4My0xLjkzNi02LjczNy0yLjMyNQ0KCWMtMi41NTctMC4zODItNS4zMDktMC41OC04LjI0OC0wLjU4Yy02LjUwNiwwLTExLjYxNywxLjM5NS0xNS4zMzUsNC4xODNjLTMuNzE2LDIuNzg4LTUuODg5LDcuNDM3LTYuNTA2LDEzLjk0aC0zMi45OTENCglDMjY4LjE5OSwxNDAuNzk0LDI3MC4xMzIsMTM0LjM2MywyNzMuNTQ0LDEyOS4yNTV6IE0zMzguNzEzLDE3NS44MzhjLTIuMDksMC42OTYtNC4zMzcsMS4yNzUtNi43MzYsMS43NDENCgljLTIuNDAyLDAuNDY1LTQuOTE4LDAuODUzLTcuNTUxLDEuMTYxYy0yLjYzNSwwLjMxMy01LjI2OCwwLjY5OC03Ljg5OSwxLjE2M2MtMi40OCwwLjQ2MS00LjkxOSwxLjA4Ni03LjMxNywxLjg1Nw0KCWMtMi40MDQsMC43NzktNC40OTUsMS44MjItNi4yNzQsMy4xMzhjLTEuNzg0LDEuMzE3LTMuMjE2LDIuOTg1LTQuMyw0Ljk5NGMtMS4wODUsMi4wMTQtMS42MjYsNC41NzEtMS42MjYsNy42NjgNCgljMCwyLjk0LDAuNTQxLDUuNDIyLDEuNjI2LDcuNDMxYzEuMDg0LDIuMDE3LDIuNTU4LDMuNjA0LDQuNDE2LDQuNzY1czQuMDI1LDEuOTc2LDYuNTA3LDIuNDM4YzIuNDc1LDAuNDY2LDUuMDMxLDAuNjk4LDcuNjY1LDAuNjk4DQoJYzYuNTA1LDAsMTEuNTM3LTEuMDgyLDE1LjEwMy0zLjI1M2MzLjU2MS0yLjE2Niw2LjE5Mi00Ljc2Miw3Ljg5OS03Ljc4NWMxLjcwMi0zLjAxOSwyLjc0OS02LjA3MiwzLjEzNy05LjE3NA0KCWMwLjM4NC0zLjA5NywwLjU4LTUuNTc2LDAuNTgtNy40MzR2LTEyLjMxNkMzNDIuNTQ3LDE3NC4xNzMsMzQwLjgwNSwxNzUuMTQsMzM4LjcxMywxNzUuODM4eiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNNDQ0LjU0MiwyMzQuODc0Yy01LjE4NywwLTEwLjE3My0wLjM2MS0xNC44MjMtMS4wNjljLTQuODAyLTAuNzMyLTkuMTA0LTIuMTgzLTEyLjc3OS00LjMxMw0KCWMtMy43ODktMi4xODUtNi44MjEtNS4zNDEtOS4wMDYtOS4zNzVjLTIuMTYzLTMuOTg2LTMuMjYtOS4yMzItMy4yNi0xNS41OXYtNjguODU5aC0xNy45ODFjLTEuMTA0LDAtMi0wLjg5Ni0yLTEuOTk5di0yMi4wNzMNCgljMC0xLjEwNCwwLjg5Ni0xLjk5OSwyLTEuOTk5aDE3Ljk4MVY3NS41ODJjMC0xLjEwNCwwLjg5Ni0yLDItMmgzMi45OTJjMS4xMDQsMCwyLDAuODk2LDIsMnYzNC4wMTRoMjIuMTYyYzEuMTA0LDAsMiwwLjg5NiwyLDEuOTk5DQoJdjIyLjA3M2MwLDEuMTA0LTAuODk2LDEuOTk5LTIsMS45OTloLTIyLjE2MnY1Ny40NzljMCw2LjIyOSwxLjE5OCw4LjczMSwyLjIwMiw5LjczM2MxLjAwNCwxLjAwNywzLjUwNiwyLjIwNSw5LjczOCwyLjIwNQ0KCWMxLjgwNCwwLDMuNTQyLTAuMDc2LDUuMTYxLTAuMjI1YzEuNjA0LTAuMTQ0LDMuMTc0LTAuMzY3LDQuNjY5LTAuNjY1YzAuMTMtMC4wMjYsMC4yNjEtMC4wMzksMC4zOTEtMC4wMzkNCgljMC40NTgsMCwwLjkwNywwLjE1OSwxLjI3LDAuNDU0YzAuNDYzLDAuMzc5LDAuNzMsMC45NDYsMC43MywxLjU0NnYyNS41NTVjMCwwLjk3OS0wLjcwNywxLjgxMy0xLjY3MiwxLjk3NA0KCWMtMi44MzQsMC40NzItNi4wNDEsMC43OTQtOS41MjcsMC45NTdDNDUxLjAxNSwyMzQuNzk4LDQ0Ny43MTgsMjM0Ljg3NCw0NDQuNTQyLDIzNC44NzRMNDQ0LjU0MiwyMzQuODc0eiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNNDYzLjgyNSwxMTEuNTk1djIyLjA3MmgtMjQuMTYxdjU5LjQ3OWMwLDUuNTczLDAuOTI4LDkuMjkyLDIuNzg4LDExLjE0OQ0KCWMxLjg1NiwxLjg1OSw1LjU3NiwyLjc4OCwxMS4xNTIsMi43ODhjMS44NTksMCwzLjYzOC0wLjA3Niw1LjM0My0wLjIzMmMxLjcwMy0wLjE1MiwzLjMzLTAuMzg4LDQuODc4LTAuNjk2djI1LjU1Nw0KCWMtMi43ODgsMC40NjUtNS44ODcsMC43NzMtOS4yOTMsMC45MzFjLTMuNDA3LDAuMTQ5LTYuNzM3LDAuMjMtOS45OSwwLjIzYy01LjExMSwwLTkuOTUzLTAuMzUtMTQuNTIxLTEuMDQ4DQoJYy00LjU3MS0wLjY5NS04LjU5Ny0yLjA0Ny0xMi4wODEtNC4wNjNjLTMuNDg2LTIuMDExLTYuMjM2LTQuODgtOC4yNDgtOC41OTdjLTIuMDE2LTMuNzE0LTMuMDIxLTguNTk1LTMuMDIxLTE0LjYzOXYtNzAuODU5aC0xOS45OA0KCXYtMjIuMDcyaDE5Ljk4Vjc1LjU4M2gzMi45OTJ2MzYuMDEySDQ2My44MjV6Ii8+DQo8cGF0aCBvcGFjaXR5PSIwLjUiIGZpbGw9IiNGRkZGRkYiIGQ9Ik01MTIuNjEzLDIzMy43MTFoLTMyLjk5MWMtMS4xMDQsMC0yLTAuODk2LTItMlYxMTEuNTk0YzAtMS4xMDQsMC44OTYtMS45OTksMi0xLjk5OWgzMS4zNjYNCgljMS4xMDQsMCwyLDAuODk2LDIsMS45OTl2MTUuMDY5YzAuOTY3LTEuNTE2LDIuMDM0LTIuOTc4LDMuMTk5LTQuMzgyYzIuNzU0LTMuMzEyLDUuOTQ5LTYuMTgyLDkuNDk2LTguNTIyDQoJYzMuNTQ1LTIuMzMyLDcuMzg1LTQuMTY5LDExLjQxNS01LjQ2MmM0LjA1Ni0xLjI5OCw4LjMyNy0xLjk1NCwxMi42OTEtMS45NTRjMi4zNDEsMCw0Ljk1MywwLjQxOCw3Ljc2NiwxLjI0Mw0KCWMwLjg1MiwwLjI1LDEuNDM3LDEuMDMyLDEuNDM3LDEuOTJ2MzAuNjdjMCwwLjYtMC4yNjksMS4xNjctMC43MzIsMS41NDdjLTAuMzYxLDAuMjk2LTAuODA4LDAuNDUyLTEuMjY1LDAuNDUyDQoJYy0wLjEzMywwLTAuMjY1LTAuMDEzLTAuMzk4LTAuMDM5Yy0xLjQ4NC0wLjMtMy4yOTktMC41NjUtNS4zOTItMC43ODdjLTIuMDk4LTAuMjI0LTQuMTM2LTAuMzM5LTYuMDYyLTAuMzM5DQoJYy01LjcwNiwwLTEwLjU3MiwwLjk1LTE0LjQ2NywyLjgyM2MtMy44NjIsMS44Ni03LjAxMiw0LjQyOC05LjM2MSw3LjYyOWMtMi4zODksMy4yNjMtNC4xMTUsNy4xMi01LjEyNywxMS40Nw0KCWMtMS4wNDMsNC40NzktMS41NzQsOS40MDktMS41NzQsMTQuNjQ3djU0LjEzMkM1MTQuNjEzLDIzMi44MTUsNTEzLjcxNywyMzMuNzExLDUxMi42MTMsMjMzLjcxMUw1MTIuNjEzLDIzMy43MTF6Ii8+DQo8cGF0aCBvcGFjaXR5PSIwLjUiIGZpbGw9IiNGRkZGRkYiIGQ9Ik01MTAuOTg4LDExMS41OTVWMTMzLjloMC40NjVjMS41NDYtMy43MiwzLjYzNi03LjE2Myw2LjI3Mi0xMC4zNDENCgljMi42MzQtMy4xNzIsNS42NTItNS44ODUsOS4wNi04LjEzMWMzLjQwNS0yLjI0Miw3LjA0Ny0zLjk4NSwxMC45MjMtNS4yMjhjMy44NjgtMS4yMzcsNy44OTgtMS44NTksMTIuMDgxLTEuODU5DQoJYzIuMTY4LDAsNC41NjYsMC4zOSw3LjIwMiwxLjE2M3YzMC42N2MtMS41NTEtMC4zMTItMy40MS0wLjU4NC01LjU3Ni0wLjgxNGMtMi4xNy0wLjIzMy00LjI2LTAuMzUtNi4yNzQtMC4zNQ0KCWMtNi4wNDEsMC0xMS4xNTIsMS4wMS0xNS4zMzIsMy4wMjFjLTQuMTgyLDIuMDE0LTcuNTUsNC43NjEtMTAuMTA3LDguMjQ3Yy0yLjU1NSwzLjQ4Ny00LjM3OSw3LjU1LTUuNDYyLDEyLjE5OA0KCWMtMS4wODMsNC42NDUtMS42MjUsOS42ODItMS42MjUsMTUuMTAydjU0LjEzM2gtMzIuOTkxVjExMS41OTVINTEwLjk4OHoiLz4NCjxwYXRoIG9wYWNpdHk9IjAuNSIgZmlsbD0iI0ZGRkZGRiIgZD0iTTYwMy45MjMsMjMzLjcxMUg1NzAuOTNjLTEuMTA0LDAtMi0wLjg5Ni0yLTJWMTExLjU5NGMwLTEuMTA0LDAuODk2LTEuOTk5LDItMS45OTloMzIuOTk0DQoJYzEuMTA0LDAsMiwwLjg5NiwyLDEuOTk5djEyMC4xMTdDNjA1LjkyMywyMzIuODE1LDYwNS4wMjcsMjMzLjcxMSw2MDMuOTIzLDIzMy43MTFMNjAzLjkyMywyMzMuNzExeiBNNjAzLjkyMyw5NS4wMDZINTcwLjkzDQoJYy0xLjEwNCwwLTItMC44OTYtMi0xLjk5OVY2NS44MjVjMC0xLjEwNCwwLjg5Ni0yLDItMmgzMi45OTRjMS4xMDQsMCwyLDAuODk2LDIsMnYyNy4xODINCglDNjA1LjkyMyw5NC4xMSw2MDUuMDI3LDk1LjAwNiw2MDMuOTIzLDk1LjAwNkw2MDMuOTIzLDk1LjAwNnoiLz4NCjxwYXRoIG9wYWNpdHk9IjAuNSIgZmlsbD0iI0ZGRkZGRiIgZD0iTTU3MC45Myw5My4wMDdWNjUuODI0aDMyLjk5NHYyNy4xODNINTcwLjkzeiBNNjAzLjkyNCwxMTEuNTk1djEyMC4xMTdINTcwLjkzVjExMS41OTUNCglINjAzLjkyNHoiLz4NCjxwYXRoIG9wYWNpdHk9IjAuNSIgZmlsbD0iI0ZGRkZGRiIgZD0iTTc0Mi4xNjMsMjMzLjcxMWgtMzcuNjRjLTAuNjcxLDAtMS4yOTctMC4zMzUtMS42NjctMC44OTZsLTIzLjQyNi0zNS4zNTJsLTIzLjQyNiwzNS4zNTINCgljLTAuMzY5LDAuNTYxLTAuOTk1LDAuODk2LTEuNjY3LDAuODk2aC0zNi45MzhjLTAuNzQxLDAtMS40MjQtMC40MTEtMS43Ny0xLjA2N2MtMC4zNDUtMC42NTQtMC4zLTEuNDQ5LDAuMTE4LTIuMDYxbDQyLjQzNS02Mi4wNTUNCglsLTM4LjcxLTU1Ljc5M2MtMC40MjQtMC42MTMtMC40NzQtMS40MDgtMC4xMjgtMi4wNjljMC4zNDMtMC42NTgsMS4wMjgtMS4wNzEsMS43NzEtMS4wNzFoMzcuNjM2YzAuNjY1LDAsMS4yODcsMC4zMywxLjY1OCwwLjg4Mg0KCWwxOS40NzcsMjguODkzbDE5LjI1NS0yOC44ODRjMC4zNzItMC41NTYsMC45OTYtMC44OTEsMS42NjUtMC44OTFoMzYuNDc1YzAuNzQ2LDAsMS40MywwLjQxNSwxLjc3NiwxLjA3OA0KCWMwLjM0MywwLjY2LDAuMjg5LDEuNDYtMC4xMzksMi4wNzFsLTM4LjY5LDU1LjA4Mmw0My41NzgsNjIuNzQ0YzAuNDI0LDAuNjEsMC40NzQsMS40MDgsMC4xMjgsMi4wNjYNCglDNzQzLjU5MSwyMzMuMjk4LDc0Mi45MDgsMjMzLjcxMSw3NDIuMTYzLDIzMy43MTFMNzQyLjE2MywyMzMuNzExeiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNNjIxLjExNSwxMTEuNTk1aDM3LjYzN2wyMS4xNDQsMzEuMzY1bDIwLjkxMS0zMS4zNjVoMzYuNDc2bC0zOS40OTYsNTYuMjI2bDQ0LjM3Nyw2My44OTINCgloLTM3LjY0bC0yNS4wOTMtMzcuODdsLTI1LjA5NCwzNy44N2gtMzYuOTM4bDQzLjIxMy02My4xOTNMNjIxLjExNSwxMTEuNTk1eiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNNzkxLjMyMiwzNDAuODA5aC0zMi4wMDhjLTEuMTA1LDAtMi0wLjg5Ni0yLTJ2LTcuNzEyYzAtMS4xMDUsMC44OTYtMi4wMDEsMi0yLjAwMWgyMS4xMw0KCVYxMS43MWgtMjEuMTNjLTEuMTA1LDAtMi0wLjg5Ni0yLTJWMmMwLTEuMTA0LDAuODk2LTIsMi0yaDMyLjAwOGMxLjEwNCwwLDIsMC44OTYsMiwydjMzNi44MDkNCglDNzkzLjMyMiwzMzkuOTEzLDc5Mi40MjYsMzQwLjgwOSw3OTEuMzIyLDM0MC44MDlMNzkxLjMyMiwzNDAuODA5eiIvPg0KPHBhdGggb3BhY2l0eT0iMC41IiBmaWxsPSIjRkZGRkZGIiBkPSJNNzgyLjQ0MywzMzEuMDk3VjkuNzExaC0yMy4xM3YtNy43MWgzMi4wMDh2MzM2LjgwN2gtMzIuMDA4di03LjcxMUg3ODIuNDQzeiIvPg0KPHBhdGggZD0iTTEwLjg3NSw5LjcxMXYzMjEuMzg2aDIzLjEzdjcuNzExSDEuOTk5VjIuMDAxaDMyLjAwNnY3LjcxSDEwLjg3NXoiLz4NCjxwYXRoIGQ9Ik05OS45ODgsMTExLjU5NXYxNi4yNjRoMC40NjNjNC4zMzgtNi4xOTEsOS41NjMtMTAuOTk4LDE1LjY4NC0xNC40MDZjNi4xMTctMy40MDIsMTMuMTI5LTUuMTEsMjEuMDI3LTUuMTENCgljNy41ODgsMCwxNC41MjEsMS40NzUsMjAuNzkzLDQuNDE1YzYuMjc0LDIuOTQ1LDExLjAzOCw4LjEzMSwxNC4yOTEsMTUuNTY3YzMuNTYtNS4yNjUsOC40LTkuOTEzLDE0LjUyMS0xMy45NA0KCWM2LjExNy00LjAyNSwxMy4zNTgtNi4wNDIsMjEuNzI0LTYuMDQyYzYuMzUxLDAsMTIuMjM0LDAuNzc2LDE3LjY2LDIuMzI1YzUuNDE4LDEuNTQ5LDEwLjA2NSw0LjAyNywxMy45MzgsNy40MzQNCgljMy44NjksMy40MSw2Ljg4OSw3Ljg2Myw5LjA2MiwxMy4zNTdjMi4xNjcsNS41MDQsMy4yNTMsMTIuMTIyLDMuMjUzLDE5Ljg2OXY4MC4zODVIMjE5LjQxdi02OC4wNzQNCgljMC00LjAyNS0wLjE1NC03LjgyLTAuNDY1LTExLjM4NWMtMC4zMTMtMy41Ni0xLjE2MS02LjY1Ni0yLjU1NS05LjI5M2MtMS4zOTUtMi42MzEtMy40NS00LjcyNC02LjE1Ny02LjI3NA0KCWMtMi43MTEtMS41NDMtNi4zOTEtMi4zMjItMTEuMDM3LTIuMzIycy04LjQwMywwLjg5Ni0xMS4yNjksMi42NzFjLTIuODY4LDEuNzg0LTUuMTEyLDQuMTA5LTYuNzM3LDYuOTcxDQoJYy0xLjYyNiwyLjg2OS0yLjcxMSw2LjEyLTMuMjUyLDkuNzYyYy0wLjU0NSwzLjYzOC0wLjgxNCw3LjMxOC0wLjgxNCwxMS4wMzV2NjYuOTFoLTMyLjk5MXYtNjcuMzc1YzAtMy41NjItMC4wODEtNy4wODctMC4yMy0xMC41Nw0KCWMtMC4xNTgtMy40ODctMC44MTQtNi43LTEuOTc4LTkuNjQ1Yy0xLjE2Mi0yLjk0LTMuMDk5LTUuMzA0LTUuODA5LTcuMDg4Yy0yLjcxMS0xLjc3NS02LjY5OS0yLjY3MS0xMS45NjUtMi42NzENCgljLTEuNTUxLDAtMy42MDMsMC4zNDktNi4xNTYsMS4wNDhjLTIuNTU2LDAuNjk3LTUuMDM2LDIuMDE2LTcuNDM1LDMuOTQ5Yy0yLjQwNCwxLjkzOC00LjQ1NCw0LjcyNi02LjE1OCw4LjM2Mw0KCWMtMS43MDUsMy42NDItMi41NTYsOC40MDItMi41NTYsMTQuMjg3djY5LjcwMWgtMzIuOTlWMTExLjU5NUg5OS45ODh6Ii8+DQo8cGF0aCBkPSJNMjczLjU0NCwxMjkuMjU1YzMuNDA1LTUuMTEzLDcuNzQ0LTkuMjE1LDEzLjAxMi0xMi4zMTZjNS4yNjQtMy4wOTcsMTEuMTg2LTUuMzAzLDE3Ljc3MS02LjYyMQ0KCWM2LjU4Mi0xLjMxNSwxMy4yMDUtMS45NzYsMTkuODY1LTEuOTc2YzYuMDQyLDAsMTIuMTU4LDAuNDI4LDE4LjM1NCwxLjI3N2M2LjE5NSwwLjg1NSwxMS44NSwyLjUyMiwxNi45NjIsNC45OTcNCgljNS4xMTEsMi40NzcsOS4yOTIsNS45MjYsMTIuNTQ2LDEwLjMzOGMzLjI1Myw0LjQxNCw0Ljg3OSwxMC4yNjIsNC44NzksMTcuNTQzdjYyLjQ5NGMwLDUuNDI4LDAuMzEsMTAuNjExLDAuOTMxLDE1LjU2Nw0KCWMwLjYxNSw0Ljk1OSwxLjcwMSw4LjY3NiwzLjI1MSwxMS4xNTNIMzQ3LjY2Yy0wLjYyMS0xLjg2LTEuMTI2LTMuNzU1LTEuNTExLTUuNjkzYy0wLjM5LTEuOTMzLTAuNjYxLTMuOTA4LTAuODEzLTUuOTIzDQoJYy01LjI2Nyw1LjQyMi0xMS40NjUsOS4yMTctMTguNTg1LDExLjM4NmMtNy4xMjcsMi4xNjMtMTQuNDA3LDMuMjUxLTIxLjg0MiwzLjI1MWMtNS43MzMsMC0xMS4wNzctMC42OTgtMTYuMDMzLTIuMDkNCgljLTQuOTU4LTEuMzk1LTkuMjkzLTMuNTYyLTEzLjAxLTYuNTFjLTMuNzE4LTIuOTM4LTYuNjIyLTYuNjU2LTguNzEzLTExLjE0N3MtMy4xMzgtOS44NC0zLjEzOC0xNi4wMzMNCgljMC02LjgxMywxLjE5OS0xMi40MywzLjYwNC0xNi44NGMyLjM5OS00LjQxNyw1LjQ5NS03LjkzOSw5LjI5NS0xMC41NzVjMy43OTMtMi42MzIsOC4xMjktNC42MDcsMTMuMDEtNS45MjMNCgljNC44NzgtMS4zMTUsOS43OTUtMi4zNTgsMTQuNzUyLTMuMTM3YzQuOTU3LTAuNzcyLDkuODM1LTEuMzkzLDE0LjYzOC0xLjg1N2M0LjgwMS0wLjQ2Niw5LjA2Mi0xLjE2NCwxMi43NzktMi4wOTMNCgljMy43MTgtMC45MjksNi42NTgtMi4yODIsOC44MjktNC4wNjVjMi4xNjUtMS43ODEsMy4xNzItNC4zNzUsMy4wMi03Ljc4NWMwLTMuNTYtMC41OC02LjM4OS0xLjc0Mi04LjQ3OQ0KCWMtMS4xNjEtMi4wOS0yLjcxMS0zLjcxOS00LjY0Ni00Ljg4Yy0xLjkzNy0xLjE2MS00LjE4My0xLjkzNi02LjczNy0yLjMyNWMtMi41NTctMC4zODItNS4zMDktMC41OC04LjI0OC0wLjU4DQoJYy02LjUwNiwwLTExLjYxNywxLjM5NS0xNS4zMzUsNC4xODNjLTMuNzE2LDIuNzg4LTUuODg5LDcuNDM3LTYuNTA2LDEzLjk0aC0zMi45OTENCglDMjY4LjE5OSwxNDAuNzk0LDI3MC4xMzIsMTM0LjM2MywyNzMuNTQ0LDEyOS4yNTV6IE0zMzguNzEzLDE3NS44MzhjLTIuMDksMC42OTYtNC4zMzcsMS4yNzUtNi43MzYsMS43NDENCgljLTIuNDAyLDAuNDY1LTQuOTE4LDAuODUzLTcuNTUxLDEuMTYxYy0yLjYzNSwwLjMxMy01LjI2OCwwLjY5OC03Ljg5OSwxLjE2M2MtMi40OCwwLjQ2MS00LjkxOSwxLjA4Ni03LjMxNywxLjg1Nw0KCWMtMi40MDQsMC43NzktNC40OTUsMS44MjItNi4yNzQsMy4xMzhjLTEuNzg0LDEuMzE3LTMuMjE2LDIuOTg1LTQuMyw0Ljk5NGMtMS4wODUsMi4wMTQtMS42MjYsNC41NzEtMS42MjYsNy42NjgNCgljMCwyLjk0LDAuNTQxLDUuNDIyLDEuNjI2LDcuNDMxYzEuMDg0LDIuMDE3LDIuNTU4LDMuNjA0LDQuNDE2LDQuNzY1czQuMDI1LDEuOTc2LDYuNTA3LDIuNDM4YzIuNDc1LDAuNDY2LDUuMDMxLDAuNjk4LDcuNjY1LDAuNjk4DQoJYzYuNTA1LDAsMTEuNTM3LTEuMDgyLDE1LjEwMy0zLjI1M2MzLjU2MS0yLjE2Niw2LjE5Mi00Ljc2Miw3Ljg5OS03Ljc4NWMxLjcwMi0zLjAxOSwyLjc0OS02LjA3MiwzLjEzNy05LjE3NA0KCWMwLjM4NC0zLjA5NywwLjU4LTUuNTc2LDAuNTgtNy40MzR2LTEyLjMxNkMzNDIuNTQ3LDE3NC4xNzMsMzQwLjgwNSwxNzUuMTQsMzM4LjcxMywxNzUuODM4eiIvPg0KPHBhdGggZD0iTTQ2My44MjUsMTExLjU5NXYyMi4wNzJoLTI0LjE2MXY1OS40NzljMCw1LjU3MywwLjkyOCw5LjI5MiwyLjc4OCwxMS4xNDljMS44NTYsMS44NTksNS41NzYsMi43ODgsMTEuMTUyLDIuNzg4DQoJYzEuODU5LDAsMy42MzgtMC4wNzYsNS4zNDMtMC4yMzJjMS43MDMtMC4xNTIsMy4zMy0wLjM4OCw0Ljg3OC0wLjY5NnYyNS41NTdjLTIuNzg4LDAuNDY1LTUuODg3LDAuNzczLTkuMjkzLDAuOTMxDQoJYy0zLjQwNywwLjE0OS02LjczNywwLjIzLTkuOTksMC4yM2MtNS4xMTEsMC05Ljk1My0wLjM1LTE0LjUyMS0xLjA0OGMtNC41NzEtMC42OTUtOC41OTctMi4wNDctMTIuMDgxLTQuMDYzDQoJYy0zLjQ4Ni0yLjAxMS02LjIzNi00Ljg4LTguMjQ4LTguNTk3Yy0yLjAxNi0zLjcxNC0zLjAyMS04LjU5NS0zLjAyMS0xNC42Mzl2LTcwLjg1OWgtMTkuOTh2LTIyLjA3MmgxOS45OFY3NS41ODNoMzIuOTkydjM2LjAxMg0KCUg0NjMuODI1eiIvPg0KPHBhdGggZD0iTTUxMC45ODgsMTExLjU5NVYxMzMuOWgwLjQ2NWMxLjU0Ni0zLjcyLDMuNjM2LTcuMTYzLDYuMjcyLTEwLjM0MWMyLjYzNC0zLjE3Miw1LjY1Mi01Ljg4NSw5LjA2LTguMTMxDQoJYzMuNDA1LTIuMjQyLDcuMDQ3LTMuOTg1LDEwLjkyMy01LjIyOGMzLjg2OC0xLjIzNyw3Ljg5OC0xLjg1OSwxMi4wODEtMS44NTljMi4xNjgsMCw0LjU2NiwwLjM5LDcuMjAyLDEuMTYzdjMwLjY3DQoJYy0xLjU1MS0wLjMxMi0zLjQxLTAuNTg0LTUuNTc2LTAuODE0Yy0yLjE3LTAuMjMzLTQuMjYtMC4zNS02LjI3NC0wLjM1Yy02LjA0MSwwLTExLjE1MiwxLjAxLTE1LjMzMiwzLjAyMQ0KCWMtNC4xODIsMi4wMTQtNy41NSw0Ljc2MS0xMC4xMDcsOC4yNDdjLTIuNTU1LDMuNDg3LTQuMzc5LDcuNTUtNS40NjIsMTIuMTk4Yy0xLjA4Myw0LjY0NS0xLjYyNSw5LjY4Mi0xLjYyNSwxNS4xMDJ2NTQuMTMzaC0zMi45OTENCglWMTExLjU5NUg1MTAuOTg4eiIvPg0KPHBhdGggZD0iTTU3MC45Myw5My4wMDdWNjUuODI0aDMyLjk5NHYyNy4xODNINTcwLjkzeiBNNjAzLjkyNCwxMTEuNTk1djEyMC4xMTdINTcwLjkzVjExMS41OTVINjAzLjkyNHoiLz4NCjxwYXRoIGQ9Ik02MjEuMTE1LDExMS41OTVoMzcuNjM3bDIxLjE0NCwzMS4zNjVsMjAuOTExLTMxLjM2NWgzNi40NzZsLTM5LjQ5Niw1Ni4yMjZsNDQuMzc3LDYzLjg5MmgtMzcuNjRsLTI1LjA5My0zNy44Nw0KCWwtMjUuMDk0LDM3Ljg3aC0zNi45MzhsNDMuMjEzLTYzLjE5M0w2MjEuMTE1LDExMS41OTV6Ii8+DQo8cGF0aCBkPSJNNzgyLjQ0MywzMzEuMDk3VjkuNzExaC0yMy4xM3YtNy43MWgzMi4wMDh2MzM2LjgwN2gtMzIuMDA4di03LjcxMUg3ODIuNDQzeiIvPg0KPC9zdmc+DQo=`, "base64"); \ No newline at end of file diff --git a/spec/util/homerunner.ts b/spec/util/homerunner.ts index a33bbc96a..ac7ada3bb 100644 --- a/spec/util/homerunner.ts +++ b/spec/util/homerunner.ts @@ -1,9 +1,10 @@ -import { MatrixClient } from "matrix-bot-sdk"; +import { MatrixClient, MemoryStorageProvider, RustSdkCryptoStorageProvider, RustSdkCryptoStoreType } from "matrix-bot-sdk"; import { createHash, createHmac, randomUUID } from "crypto"; import { Homerunner } from "homerunner-client"; import { E2ETestMatrixClient } from "./e2e-test"; +import path from "node:path"; -const HOMERUNNER_IMAGE = process.env.HOMERUNNER_IMAGE || 'ghcr.io/element-hq/synapse/complement-synapse:latest'; +const HOMERUNNER_IMAGE = process.env.HOMERUNNER_IMAGE || 'ghcr.io/element-hq/synapse/complement-synapse:nightly'; export const DEFAULT_REGISTRATION_SHARED_SECRET = ( process.env.REGISTRATION_SHARED_SECRET || 'complement' ); @@ -41,7 +42,7 @@ async function waitForHomerunner() { } } -export async function createHS(localparts: string[] = [], workerId: number): Promise { +export async function createHS(localparts: string[] = [], workerId: number, cryptoRootPath?: string): Promise { await waitForHomerunner(); const appPort = 49600 + workerId; @@ -60,26 +61,36 @@ export async function createHS(localparts: string[] = [], workerId: number): Pro URL: `http://${COMPLEMENT_HOSTNAME_RUNNING_COMPLEMENT}:${appPort}`, SenderLocalpart: 'hookshot', RateLimited: false, - ...{ASToken: asToken, - HSToken: hsToken}, + ASToken: asToken, + HSToken: hsToken, + SendEphemeral: true, + EnableEncryption: true, }] }], } }); const [homeserverName, homeserver] = Object.entries(blueprintResponse.homeservers)[0]; // Skip AS user. - const users = Object.entries(homeserver.AccessTokens) + const users = await Promise.all(Object.entries(homeserver.AccessTokens) .filter(([_uId, accessToken]) => accessToken !== asToken) - .map(([userId, accessToken]) => ({ - userId: userId, - accessToken, - deviceId: homeserver.DeviceIDs[userId], - client: new E2ETestMatrixClient(homeserver.BaseURL, accessToken), - }) - ); + .map(async ([userId, accessToken]) => { + const cryptoStore = cryptoRootPath ? new RustSdkCryptoStorageProvider(path.join(cryptoRootPath, userId), RustSdkCryptoStoreType.Sqlite) : undefined; + const client = new E2ETestMatrixClient(homeserver.BaseURL, accessToken, new MemoryStorageProvider(), cryptoStore); + if (cryptoStore) { + await client.crypto.prepare(); + } + // Start syncing proactively. + await client.start(); + return { + userId: userId, + accessToken, + deviceId: homeserver.DeviceIDs[userId], + client, + } + } + )); + - // Start syncing proactively. - await Promise.all(users.map(u => u.client.start())); return { users, id: blueprint, @@ -119,7 +130,7 @@ export async function registerUser( .update(password).update("\x00") .update(user.admin ? 'admin' : 'notadmin') .digest('hex'); - return await fetch(registerUrl, { method: "POST", body: JSON.stringify( + const req = await fetch(registerUrl, { method: "POST", body: JSON.stringify( { nonce, username: user.username, @@ -127,8 +138,10 @@ export async function registerUser( admin: user.admin, mac: hmac, } - )}).then(res => res.json()).then(res => ({ - mxid: (res as {user_id: string}).user_id, - client: new MatrixClient(homeserverUrl, (res as {access_token: string}).access_token), - })).catch(err => { console.log(err.response.body); throw new Error(`Failed to register user: ${err}`); }); + )}); + const res = await req.json() as {user_id: string, access_token: string}; + return { + mxid: res.user_id, + client: new MatrixClient(homeserverUrl, res.access_token), + }; } diff --git a/spec/webhooks.spec.ts b/spec/webhooks.spec.ts new file mode 100644 index 000000000..9238caa41 --- /dev/null +++ b/spec/webhooks.spec.ts @@ -0,0 +1,201 @@ +import { E2ESetupTestTimeout, E2ETestEnv, E2ETestMatrixClient } from "./util/e2e-test"; +import { describe, it, beforeEach, afterEach } from "@jest/globals"; +import { OutboundHookConnection } from "../src/Connections"; +import { TextualMessageEventContent } from "matrix-bot-sdk"; +import { IncomingHttpHeaders, createServer } from "http"; +import busboy, { FileInfo } from "busboy"; +import { TEST_FILE } from "./util/fixtures"; + +async function createOutboundConnection(user: E2ETestMatrixClient, botMxid: string, roomId: string) { + const join = user.waitForRoomJoin({ sender: botMxid, roomId }); + const connectionEvent = user.waitForRoomEvent({ + eventType: OutboundHookConnection.CanonicalEventType, + stateKey: 'test', + sender: botMxid + }); + await user.inviteUser(botMxid, roomId); + await user.setUserPowerLevel(botMxid, roomId, 50); + await join; + + // Note: Here we create the DM proactively so this works across multiple + // tests. + // Get the DM room so we can get the token. + const dmRoomId = await user.dms.getOrCreateDm(botMxid); + + await user.sendText(roomId, '!hookshot outbound-hook test http://localhost:8111/test-path'); + // Test the contents of this. + await connectionEvent; + + const msgPromise = user.waitForRoomEvent({ sender: botMxid, eventType: "m.room.message", roomId: dmRoomId }); + const { data: msgData } = await msgPromise; + + const [_match, token ] = /(.+)<\/code>/.exec((msgData.content as unknown as TextualMessageEventContent).formatted_body ?? "") ?? []; + return token; +} + +/** + * + * @returns + */ +function awaitOutboundWebhook() { + return new Promise<{headers: IncomingHttpHeaders, files: {name: string, file: Buffer, info: FileInfo}[]}>((resolve, reject) => { + const server = createServer((req, res) => { + const bb = busboy({headers: req.headers}); + const files: {name: string, file: Buffer, info: FileInfo}[] = []; + bb.on('file', (name, stream, info) => { + const buffers: Buffer[] = []; + stream.on('data', d => { + buffers.push(d) + }); + stream.once('close', () => { + files.push({name, info, file: Buffer.concat(buffers)}) + }); + }); + + bb.once('close', () => { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('OK'); + resolve({ + headers: req.headers, + files, + }); + clearTimeout(timer); + server.close(); + }); + + req.pipe(bb); + }); + server.listen(8111); + let timer: NodeJS.Timeout; + timer = setTimeout(() => { + reject(new Error("Request did not arrive")); + server.close(); + }, 10000); + + }); +} + +describe('OutboundHooks', () => { + let testEnv: E2ETestEnv; + + beforeAll(async () => { + const webhooksPort = 9500 + E2ETestEnv.workerId; + testEnv = await E2ETestEnv.createTestEnv({ + matrixLocalparts: ['user'], + config: { + generic: { + enabled: true, + outbound: true, + urlPrefix: `http://localhost:${webhooksPort}` + }, + listeners: [{ + port: webhooksPort, + bindAddress: '0.0.0.0', + // Bind to the SAME listener to ensure we don't have conflicts. + resources: ['webhooks'], + }], + } + }); + await testEnv.setUp(); + }, E2ESetupTestTimeout); + + afterAll(() => { + return testEnv?.tearDown(); + }); + + it('should be able to create a new webhook and push an event.', async () => { + const user = testEnv.getUser('user'); + const roomId = await user.createRoom({ name: 'My Test Webhooks room'}); + const token = await createOutboundConnection(user, testEnv.botMxid, roomId); + const gotWebhookRequest = awaitOutboundWebhook(); + + const eventId = await user.sendText(roomId, 'hello!'); + const { headers, files } = await gotWebhookRequest; + expect(headers['x-matrix-hookshot-roomid']).toEqual(roomId); + expect(headers['x-matrix-hookshot-eventid']).toEqual(eventId); + expect(headers['x-matrix-hookshot-token']).toEqual(token); + + // And check the JSON payload + const [event, media] = files; + expect(event.name).toEqual('event'); + expect(event.info.mimeType).toEqual('application/json'); + expect(event.info.filename).toEqual('event_data.json'); + const eventJson = JSON.parse(event.file.toString('utf-8')); + + // Check that the content looks sane. + expect(eventJson.room_id).toEqual(roomId); + expect(eventJson.event_id).toEqual(eventId); + expect(eventJson.sender).toEqual(await user.getUserId()); + expect(eventJson.content.body).toEqual('hello!'); + + // No media should be present. + expect(media).toBeUndefined(); + }); + + it('should be able to create a new webhook and push a media attachment.', async () => { + const user = testEnv.getUser('user'); + const roomId = await user.createRoom({ name: 'My Test Webhooks room'}); + await createOutboundConnection(user, testEnv.botMxid, roomId); + const gotWebhookRequest = awaitOutboundWebhook(); + + const mxcUrl = await user.uploadContent(TEST_FILE, 'image/svg+xml', "matrix.svg"); + await user.sendMessage(roomId, { + url: mxcUrl, + msgtype: "m.file", + body: "matrix.svg", + }) + const { files } = await gotWebhookRequest; + const [event, media] = files; + expect(event.info.mimeType).toEqual('application/json'); + expect(event.info.filename).toEqual('event_data.json'); + const eventJson = JSON.parse(event.file.toString('utf-8')); + expect(eventJson.content.body).toEqual('matrix.svg'); + + + expect(media.info.mimeType).toEqual('image/svg+xml'); + expect(media.info.filename).toEqual('matrix.svg'); + expect(media.file).toEqual(TEST_FILE); + }); + + // TODO: This requires us to support Redis in test conditions, as encryption is not possible + // in hookshot without it at the moment. + + // it.only('should be able to create a new webhook and push an encrypted media attachment.', async () => { + // const user = testEnv.getUser('user'); + // const roomId = await user.createRoom({ name: 'My Test Webhooks room', initial_state: [{ + // content: { + // "algorithm": "m.megolm.v1.aes-sha2" + // }, + // state_key: "", + // type: "m.room.encryption" + // }]}); + // await createOutboundConnection(user, testEnv.botMxid, roomId); + // const gotWebhookRequest = awaitOutboundWebhook(); + + // const encrypted = await user.crypto.encryptMedia(Buffer.from(TEST_FILE)); + // const mxc = await user.uploadContent(TEST_FILE); + // await user.sendMessage(roomId, { + // msgtype: "m.image", + // body: "matrix.svg", + // info: { + // mimetype: "image/svg+xml", + // }, + // file: { + // url: mxc, + // ...encrypted.file, + // }, + // }); + + // const { headers, files } = await gotWebhookRequest; + // const [event, media] = files; + // expect(event.info.mimeType).toEqual('application/json'); + // expect(event.info.filename).toEqual('event_data.json'); + // const eventJson = JSON.parse(event.file.toString('utf-8')); + // expect(eventJson.content.body).toEqual('matrix.svg'); + + + // expect(media.info.mimeType).toEqual('image/svg+xml'); + // expect(media.info.filename).toEqual('matrix.svg'); + // expect(media.file).toEqual(TEST_FILE); + // }); +}); diff --git a/src/AdminRoom.ts b/src/AdminRoom.ts index efa2c130c..9c9b85553 100644 --- a/src/AdminRoom.ts +++ b/src/AdminRoom.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ import "reflect-metadata"; import { AdminAccountData, AdminRoomCommandHandler, Category } from "./AdminRoomCommandHandler"; import { botCommand, compileBotCommands, handleCommand, BotCommands, HelpFunction } from "./BotCommands"; @@ -16,7 +15,7 @@ import { Intent } from "matrix-bot-sdk"; import { JiraBotCommands } from "./jira/AdminCommands"; import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters"; import { ProjectsListResponseData } from "./github/Types"; -import { UserTokenStore } from "./UserTokenStore"; +import { UserTokenStore } from "./tokens/UserTokenStore"; import { Logger } from "matrix-appservice-bridge"; import markdown from "markdown-it"; type ProjectsListForRepoResponseData = Endpoints["GET /repos/{owner}/{repo}/projects"]["response"]; diff --git a/src/AdminRoomCommandHandler.ts b/src/AdminRoomCommandHandler.ts index 10102e7e8..e11a081f1 100644 --- a/src/AdminRoomCommandHandler.ts +++ b/src/AdminRoomCommandHandler.ts @@ -1,7 +1,7 @@ import EventEmitter from "events"; import { Intent } from "matrix-bot-sdk"; import { BridgeConfig } from "./config/Config"; -import { UserTokenStore } from "./UserTokenStore"; +import { UserTokenStore } from "./tokens/UserTokenStore"; export enum Category { @@ -13,7 +13,6 @@ export enum Category { export interface AdminAccountData { - // eslint-disable-next-line camelcase admin_user: string; github?: { notifications?: { diff --git a/src/App/BridgeApp.ts b/src/App/BridgeApp.ts index a21049e48..83f90957d 100644 --- a/src/App/BridgeApp.ts +++ b/src/App/BridgeApp.ts @@ -1,6 +1,5 @@ import { Bridge } from "../Bridge"; import { BridgeConfig, parseRegistrationFile } from "../config/Config"; -import { Webhooks } from "../Webhooks"; import { MatrixSender } from "../MatrixSender"; import { UserNotificationWatcher } from "../Notifications/UserNotificationWatcher"; import { ListenerService } from "../ListenerService"; @@ -10,6 +9,7 @@ import { getAppservice } from "../appservice"; import BotUsersManager from "../Managers/BotUsersManager"; import * as Sentry from '@sentry/node'; import { GenericHookConnection } from "../Connections"; +import { UserTokenStore } from "../tokens/UserTokenStore"; Logger.configure({console: "info"}); const log = new Logger("App"); @@ -27,7 +27,7 @@ export async function start(config: BridgeConfig, registration: IAppserviceRegis const {appservice, storage} = getAppservice(config, registration); - if (config.queue.monolithic) { + if (!config.queue) { const matrixSender = new MatrixSender(config, appservice); matrixSender.listen(); const userNotificationWatcher = new UserNotificationWatcher(config); @@ -51,7 +51,8 @@ export async function start(config: BridgeConfig, registration: IAppserviceRegis const botUsersManager = new BotUsersManager(config, appservice); - const bridgeApp = new Bridge(config, listener, appservice, storage, botUsersManager); + const tokenStore = await UserTokenStore.fromKeyPath(config.passFile , appservice.botIntent, config); + const bridgeApp = new Bridge(config, tokenStore, listener, appservice, storage, botUsersManager); process.once("SIGTERM", () => { log.error("Got SIGTERM"); diff --git a/src/Bridge.ts b/src/Bridge.ts index c92517458..36bd695e7 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -10,7 +10,7 @@ import { GetIssueResponse, GetIssueOpts } from "./Gitlab/Types" import { GithubInstance } from "./github/GithubInstance"; import { IBridgeStorageProvider } from "./Stores/StorageProvider"; import { IConnection, GitHubDiscussionSpace, GitHubDiscussionConnection, GitHubUserSpace, JiraProjectConnection, GitLabRepoConnection, - GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitLabIssueConnection, FigmaFileConnection, FeedConnection, GenericHookConnection, WebhookResponse } from "./Connections"; + GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitLabIssueConnection, FigmaFileConnection, FeedConnection, GenericHookConnection } from "./Connections"; import { IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent, IGitLabWebhookPushEvent, IGitLabWebhookReleaseEvent, IGitLabWebhookTagPushEvent, IGitLabWebhookWikiPageEvent } from "./Gitlab/WebhookTypes"; import { JiraIssueEvent, JiraIssueUpdatedEvent, JiraVersionEvent } from "./jira/WebhookTypes"; import { JiraOAuthResult } from "./jira/Types"; @@ -23,7 +23,7 @@ import { NotificationsEnableEvent, NotificationsDisableEvent, Webhooks } from ". import { GitHubOAuthToken, GitHubOAuthTokenResponse, ProjectsGetResponseData } from "./github/Types"; import { retry } from "./PromiseUtil"; import { UserNotificationsEvent } from "./Notifications/UserNotificationWatcher"; -import { UserTokenStore } from "./UserTokenStore"; +import { UserTokenStore } from "./tokens/UserTokenStore"; import * as GitHubWebhookTypes from "@octokit/webhooks-types"; import { Logger } from "matrix-appservice-bridge"; import { Provisioner } from "./provisioning/provisioner"; @@ -39,8 +39,9 @@ import { JiraOAuthRequestCloud, JiraOAuthRequestOnPrem, JiraOAuthRequestResult } import { GenericWebhookEvent, GenericWebhookEventResult } from "./generic/types"; import { SetupWidget } from "./Widgets/SetupWidget"; import { FeedEntry, FeedError, FeedReader, FeedSuccess } from "./feeds/FeedReader"; -import PQueue from "p-queue"; import * as Sentry from '@sentry/node'; +import { HoundConnection, HoundPayload } from "./Connections/HoundConnection"; +import { HoundReader } from "./hound/reader"; const log = new Logger("Bridge"); @@ -49,12 +50,11 @@ export class Bridge { private readonly queue: MessageQueue; private readonly commentProcessor: CommentProcessor; private readonly notifProcessor: NotificationProcessor; - private readonly tokenStore: UserTokenStore; private connectionManager?: ConnectionManager; private github?: GithubInstance; private adminRooms: Map = new Map(); - private widgetApi?: BridgeWidgetApi; private feedReader?: FeedReader; + private houndReader?: HoundReader; private provisioningApi?: Provisioner; private replyProcessor = new RichRepliesPreprocessor(true); @@ -62,6 +62,7 @@ export class Bridge { constructor( private config: BridgeConfig, + private readonly tokenStore: UserTokenStore, private readonly listener: ListenerService, private readonly as: Appservice, private readonly storage: IBridgeStorageProvider, @@ -71,8 +72,6 @@ export class Bridge { this.messageClient = new MessageSenderClient(this.queue); this.commentProcessor = new CommentProcessor(this.as, this.config.bridge.mediaUrl || this.config.bridge.url); this.notifProcessor = new NotificationProcessor(this.storage, this.messageClient); - this.tokenStore = new UserTokenStore(this.config.passFile || "./passkey.pem", this.as.botIntent, this.config); - this.tokenStore.on("onNewToken", this.onTokenUpdated.bind(this)); // Legacy routes, to be removed. this.as.expressAppInstance.get("/live", (_, res) => res.send({ok: true})); @@ -81,14 +80,15 @@ export class Bridge { public stop() { this.feedReader?.stop(); + this.houndReader?.stop(); this.tokenStore.stop(); this.as.stop(); if (this.queue.stop) this.queue.stop(); } public async start() { + this.tokenStore.on("onNewToken", this.onTokenUpdated.bind(this)); log.info('Starting up'); - await this.tokenStore.load(); await this.storage.connect?.(); await this.queue.connect?.(); @@ -97,7 +97,7 @@ export class Bridge { while (!reached) { try { // Make a request to determine if we can reach the homeserver - await this.as.botIntent.getJoinedRooms(); + await this.as.botIntent.underlyingClient.getWhoAmI(); reached = true; } catch (e) { log.warn("Failed to connect to homeserver, retrying in 5s", e); @@ -343,6 +343,12 @@ export class Bridge { (c, data) => c.onMergeRequestOpened(data), ); + this.bindHandlerToQueue( + "gitlab.merge_request.reopen", + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (c, data) => c.onMergeRequestReopened(data), + ); + this.bindHandlerToQueue( "gitlab.merge_request.close", (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), @@ -599,7 +605,7 @@ export class Bridge { if (!connections.length) { await this.queue.push({ - data: {notFound: true}, + data: {successful: true, notFound: true}, sender: "Bridge", messageId: messageId, eventName: "response.generic-webhook.event", @@ -614,21 +620,19 @@ export class Bridge { await c.onGenericHook(data.hookData); return; } - let successful: boolean|null = null; - let response: WebhookResponse|undefined; if (this.config.generic?.waitForComplete || c.waitForComplete) { const result = await c.onGenericHook(data.hookData); - successful = result.successful; - response = result.response; await this.queue.push({ - data: {successful, response}, + data: result, sender: "Bridge", messageId, eventName: "response.generic-webhook.event", }); } else { await this.queue.push({ - data: {}, + data: { + successful: null, + }, sender: "Bridge", messageId, eventName: "response.generic-webhook.event", @@ -681,67 +685,76 @@ export class Bridge { (c, data) => c.handleFeedError(data), ); - const queue = new PQueue({ - concurrency: 2, - }); - // Set up already joined rooms - await queue.addAll(this.botUsersManager.joinedRooms.map((roomId) => async () => { - log.debug("Fetching state for " + roomId); + this.bindHandlerToQueue( + "hound.activity", + (data) => connManager.getConnectionsForHoundChallengeId(data.challengeId), + (c, data) => c.handleNewActivity(data.activity) + ); - try { - await connManager.createConnectionsForRoomId(roomId, false); - } catch (ex) { - log.error(`Unable to create connection for ${roomId}`, ex); - return; - } + const allRooms = this.botUsersManager.joinedRooms; - const botUser = this.botUsersManager.getBotUserInRoom(roomId); - if (!botUser) { - log.error(`Failed to find a bot in room '${roomId}' when setting up admin room`); - return; - } + const processRooms = async () => { + for (let roomId = allRooms.pop(); roomId !== undefined; roomId = allRooms.pop()) { + log.debug("Fetching state for " + roomId); - // TODO: Refactor this to be a connection - try { - let accountData = await botUser.intent.underlyingClient.getSafeRoomAccountData( - BRIDGE_ROOM_TYPE, roomId, - ); - if (!accountData) { - accountData = await botUser.intent.underlyingClient.getSafeRoomAccountData( - LEGACY_BRIDGE_ROOM_TYPE, roomId, - ); - if (!accountData) { - log.debug(`Room ${roomId} has no connections and is not an admin room`); - return; - } else { - // Upgrade the room - await botUser.intent.underlyingClient.setRoomAccountData(BRIDGE_ROOM_TYPE, roomId, accountData); - } + try { + await connManager.createConnectionsForRoomId(roomId, false); + } catch (ex) { + log.error(`Unable to create connection for ${roomId}`, ex); + continue; } - - let notifContent; + + const botUser = this.botUsersManager.getBotUserInRoom(roomId); + if (!botUser) { + log.error(`Failed to find a bot in room '${roomId}' when setting up admin room`); + continue; + } + + // TODO: Refactor this to be a connection try { - notifContent = await botUser.intent.underlyingClient.getRoomStateEvent( - roomId, NotifFilter.StateType, "", + let accountData = await botUser.intent.underlyingClient.getSafeRoomAccountData( + BRIDGE_ROOM_TYPE, roomId, ); - } catch (ex) { + if (!accountData) { + accountData = await botUser.intent.underlyingClient.getSafeRoomAccountData( + LEGACY_BRIDGE_ROOM_TYPE, roomId, + ); + if (!accountData) { + log.debug(`Room ${roomId} has no connections and is not an admin room`); + continue; + } else { + // Upgrade the room + await botUser.intent.underlyingClient.setRoomAccountData(BRIDGE_ROOM_TYPE, roomId, accountData); + } + } + + let notifContent; try { notifContent = await botUser.intent.underlyingClient.getRoomStateEvent( - roomId, NotifFilter.LegacyStateType, "", + roomId, NotifFilter.StateType, "", ); + } catch (ex) { + try { + notifContent = await botUser.intent.underlyingClient.getRoomStateEvent( + roomId, NotifFilter.LegacyStateType, "", + ); + } + catch (ex) { + // No state yet + } } - catch (ex) { - // No state yet - } - } - const adminRoom = await this.setUpAdminRoom(botUser.intent, roomId, accountData, notifContent || NotifFilter.getDefaultContent()); - // Call this on startup to set the state - await this.onAdminRoomSettingsChanged(adminRoom, accountData, { admin_user: accountData.admin_user }); - log.debug(`Room ${roomId} is connected to: ${adminRoom.toString()}`); - } catch (ex) { - log.error(`Failed to set up admin room ${roomId}:`, ex); + const adminRoom = await this.setUpAdminRoom(botUser.intent, roomId, accountData, notifContent || NotifFilter.getDefaultContent()); + // Call this on startup to set the state + await this.onAdminRoomSettingsChanged(adminRoom, accountData, { admin_user: accountData.admin_user }); + log.debug(`Room ${roomId} is connected to: ${adminRoom.toString()}`); + } catch (ex) { + log.error(`Failed to set up admin room ${roomId}:`, ex); + } } - })); + } + + // Concurrency of two. + const roomQueue = await Promise.all([processRooms(), processRooms()]) // Handle spaces for (const discussion of connManager.getAllConnectionsOfType(GitHubDiscussionSpace)) { @@ -755,7 +768,7 @@ export class Bridge { if (apps.length > 1) { throw Error('You may only bind `widgets` to one listener.'); } - this.widgetApi = new BridgeWidgetApi( + new BridgeWidgetApi( this.adminRooms, this.config, this.storage, @@ -774,7 +787,7 @@ export class Bridge { if (this.config.metrics?.enabled) { this.listener.bindResource('metrics', Metrics.expressRouter); } - await queue.onIdle(); + await roomQueue; log.info(`All connections loaded`); // Load feeds after connections, to limit the chances of us double @@ -788,6 +801,15 @@ export class Bridge { ); } + if (this.config.challengeHound?.token) { + this.houndReader = new HoundReader( + this.config.challengeHound, + this.connectionManager, + this.queue, + this.storage, + ); + } + const webhookHandler = new Webhooks(this.config); this.listener.bindResource('webhooks', webhookHandler.expressRouter); @@ -1142,10 +1164,14 @@ export class Bridge { } if (!existingConnections.length) { // Is anyone interested in this state? - const connection = await this.connectionManager.createConnectionForState(roomId, new StateEvent(event), true); - if (connection) { - log.info(`New connected added to ${roomId}: ${connection.toString()}`); - this.connectionManager.push(connection); + try { + const connection = await this.connectionManager.createConnectionForState(roomId, new StateEvent(event), true); + if (connection) { + log.info(`New connected added to ${roomId}: ${connection.toString()}`); + this.connectionManager.push(connection); + } + } catch (ex) { + log.error(`Failed to handle connection for state ${event.type} in ${roomId}`, ex); } } diff --git a/src/CommentProcessor.ts b/src/CommentProcessor.ts index 7f1bf2f7c..b2d0fb6e8 100644 --- a/src/CommentProcessor.ts +++ b/src/CommentProcessor.ts @@ -16,7 +16,6 @@ const log = new Logger("CommentProcessor"); const mime = import('mime'); interface IMatrixCommentEvent extends MatrixMessageContent { - // eslint-disable-next-line camelcase external_url: string; "uk.half-shot.matrix-hookshot.github.comment": { id: number; @@ -158,7 +157,7 @@ export class CommentProcessor { body = body.replace(rawUrl, url); } catch (ex) { - log.warn("Failed to upload file"); + log.warn("Failed to upload file", ex); } } return body; diff --git a/src/ConnectionManager.ts b/src/ConnectionManager.ts index c475aa778..5f8508c82 100644 --- a/src/ConnectionManager.ts +++ b/src/ConnectionManager.ts @@ -8,7 +8,8 @@ import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { ApiError, ErrCode } from "./api"; import { BridgeConfig, BridgePermissionLevel, GitLabInstance } from "./config/Config"; import { CommentProcessor } from "./CommentProcessor"; -import { ConnectionDeclaration, ConnectionDeclarations, GenericHookConnection, GitHubDiscussionConnection, GitHubDiscussionSpace, GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitHubUserSpace, GitLabIssueConnection, GitLabRepoConnection, IConnection, IConnectionState, JiraProjectConnection } from "./Connections"; +import { ConnectionDeclaration, ConnectionDeclarations, GenericHookConnection, GitHubDiscussionConnection, GitHubDiscussionSpace, GitHubIssueConnection, + GitHubProjectConnection, GitHubRepoConnection, GitHubUserSpace, GitLabIssueConnection, GitLabRepoConnection, IConnection, IConnectionState, JiraProjectConnection } from "./Connections"; import { FigmaFileConnection, FeedConnection } from "./Connections"; import { GetConnectionTypeResponseItem } from "./provisioning/api"; import { GitLabClient } from "./Gitlab/Client"; @@ -17,11 +18,12 @@ import { IBridgeStorageProvider } from "./Stores/StorageProvider"; import { JiraProject, JiraVersion } from "./jira/Types"; import { Logger } from "matrix-appservice-bridge"; import { MessageSenderClient } from "./MatrixSender"; -import { UserTokenStore } from "./UserTokenStore"; +import { UserTokenStore } from "./tokens/UserTokenStore"; import BotUsersManager from "./Managers/BotUsersManager"; import { retry, retryMatrixErrorFilter } from "./PromiseUtil"; import Metrics from "./Metrics"; import EventEmitter from "events"; +import { HoundConnection } from "./Connections/HoundConnection"; const log = new Logger("ConnectionManager"); @@ -341,6 +343,10 @@ export class ConnectionManager extends EventEmitter { return this.connections.filter(c => c instanceof FeedConnection && c.feedUrl === url) as FeedConnection[]; } + public getConnectionsForHoundChallengeId(challengeId: string): HoundConnection[] { + return this.connections.filter(c => c instanceof HoundConnection && c.challengeId === challengeId) as HoundConnection[]; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any public getAllConnectionsOfType(typeT: new (...params : any[]) => T): T[] { return this.connections.filter((c) => (c instanceof typeT)) as T[]; diff --git a/src/Connections/FigmaFileConnection.ts b/src/Connections/FigmaFileConnection.ts index 1a662dede..918fc7360 100644 --- a/src/Connections/FigmaFileConnection.ts +++ b/src/Connections/FigmaFileConnection.ts @@ -65,7 +65,7 @@ export class FigmaFileConnection extends BaseConnection implements IConnection { } } - private readonly grantChecker: GrantChecker<{fileId: string, instanceName: string}> = new ConfigGrantChecker("figma", this.as, this.config); + private readonly grantChecker: GrantChecker<{fileId: string, instanceName: string}>; constructor( roomId: string, @@ -76,6 +76,7 @@ export class FigmaFileConnection extends BaseConnection implements IConnection { private readonly intent: Intent, private readonly storage: IBridgeStorageProvider) { super(roomId, stateKey, FigmaFileConnection.CanonicalEventType) + this.grantChecker = new ConfigGrantChecker("figma", this.as, this.config); } public isInterestedInStateEvent() { diff --git a/src/Connections/GenericHook.ts b/src/Connections/GenericHook.ts index 5aa87de04..2f0e831f7 100644 --- a/src/Connections/GenericHook.ts +++ b/src/Connections/GenericHook.ts @@ -8,9 +8,13 @@ import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { ApiError, ErrCode } from "../api"; import { BaseConnection } from "./BaseConnection"; import { GetConnectionsResponseItem } from "../provisioning/api"; -import { BridgeConfigGenericWebhooks } from "../config/Config"; +import { BridgeConfigGenericWebhooks } from "../config/sections"; import { ensureUserIsInRoom } from "../IntentUtils"; import { randomUUID } from 'node:crypto'; +import { GenericWebhookEventResult } from "../generic/types"; +import { StatusCodes } from "http-status-codes"; +import { IBridgeStorageProvider } from "../Stores/StorageProvider"; +import { formatDuration, isMatch, millisecondsToHours } from "date-fns"; export interface GenericHookConnectionState extends IConnectionState { /** @@ -21,11 +25,17 @@ export interface GenericHookConnectionState extends IConnectionState { * The name given in the provisioning UI and displaynames. */ name: string; - transformationFunction: string|undefined; + transformationFunction?: string; /** * Should the webhook only respond on completion. */ - waitForComplete: boolean|undefined; + waitForComplete?: boolean|undefined; + + /** + * If the webhook has an expriation date, then the date at which the webhook is no longer value + * (in UTC) time. + */ + expirationDate?: string; } export interface GenericHookSecrets { @@ -37,6 +47,10 @@ export interface GenericHookSecrets { * The hookId of the webhook. */ hookId: string; + /** + * How long remains until the webhook expires. + */ + timeRemainingMs?: number } export type GenericHookResponseItem = GetConnectionsResponseItem; @@ -64,6 +78,14 @@ interface WebhookTransformationResult { webhookResponse?: WebhookResponse; } +export interface GenericHookServiceConfig { + userIdPrefix?: string; + allowJsTransformationFunctions?: boolean, + waitForComplete?: boolean, + maxExpiryTime?: number, + requireExpiryTime: boolean, +} + const log = new Logger("GenericHookConnection"); const md = new markdownit(); @@ -71,6 +93,12 @@ const TRANSFORMATION_TIMEOUT_MS = 500; const SANITIZE_MAX_DEPTH = 10; const SANITIZE_MAX_BREADTH = 50; +const WARN_AT_EXPIRY_MS = 3 * 24 * 60 * 60 * 1000; +const MIN_EXPIRY_MS = 60 * 60 * 1000; +const CHECK_EXPIRY_MS = 15 * 60 * 1000; + +const EXPIRY_NOTICE_MESSAGE = "The webhook **%NAME** will be expiring in %TIME." + /** * Handles rooms connected to a generic webhook. */ @@ -123,8 +151,8 @@ export class GenericHookConnection extends BaseConnection implements IConnection return obj; } - static validateState(state: Record): GenericHookConnectionState { - const {name, transformationFunction, waitForComplete} = state; + static validateState(state: Partial>): GenericHookConnectionState { + const {name, transformationFunction, waitForComplete, expirationDate: expirationDateStr} = state; if (!name) { throw new ApiError('Missing name', ErrCode.BadValue); } @@ -143,14 +171,26 @@ export class GenericHookConnection extends BaseConnection implements IConnection throw new ApiError('Transformation functions must be a string', ErrCode.BadValue); } } + let expirationDate: string|undefined; + if (expirationDateStr != undefined) { + if (typeof expirationDateStr !== "string" || !expirationDateStr) { + throw new ApiError("'expirationDate' must be a non-empty string", ErrCode.BadValue); + } + if (!isMatch(expirationDateStr, "yyyy-MM-dd'T'HH:mm:ss.SSSXX")) { + throw new ApiError("'expirationDate' must be a valid date", ErrCode.BadValue); + } + expirationDate = expirationDateStr; + } + return { name, transformationFunction: transformationFunction || undefined, waitForComplete, + expirationDate, }; } - static async createConnectionForState(roomId: string, event: StateEvent>, {as, intent, config, messageClient}: InstantiateConnectionOpts) { + static async createConnectionForState(roomId: string, event: StateEvent>, {as, intent, config, messageClient, storage}: InstantiateConnectionOpts) { if (!config.generic) { throw Error('Generic webhooks are not configured'); } @@ -162,6 +202,10 @@ export class GenericHookConnection extends BaseConnection implements IConnection if (!hookId) { hookId = randomUUID(); log.warn(`hookId for ${roomId} not set in accountData, setting to ${hookId}`); + // If this is a new hook... + if (config.generic.requireExpiryTime && !state.expirationDate) { + throw new Error('Expiration date must be set'); + } await GenericHookConnection.ensureRoomAccountData(roomId, intent, hookId, event.stateKey); } @@ -174,18 +218,41 @@ export class GenericHookConnection extends BaseConnection implements IConnection config.generic, as, intent, + storage, ); } - static async provisionConnection(roomId: string, userId: string, data: Record = {}, {as, intent, config, messageClient}: ProvisionConnectionOpts) { + static async provisionConnection(roomId: string, userId: string, data: Partial> = {}, {as, intent, config, messageClient, storage}: ProvisionConnectionOpts) { if (!config.generic) { throw Error('Generic Webhooks are not configured'); } const hookId = randomUUID(); const validState = GenericHookConnection.validateState(data); + if (validState.expirationDate) { + const durationRemaining = new Date(validState.expirationDate).getTime() - Date.now(); + if (config.generic.maxExpiryTimeMs) { + if (durationRemaining > config.generic.maxExpiryTimeMs) { + throw new ApiError('Expiration date cannot exceed the configured max expiry time', ErrCode.BadValue); + } + } + if (durationRemaining < MIN_EXPIRY_MS) { + // If the webhook is actually created with a shorter expiry time than + // our warning period, then just mark it as warned. + throw new ApiError('Expiration date must at least be a hour in the future', ErrCode.BadValue); + } + if (durationRemaining < WARN_AT_EXPIRY_MS) { + // If the webhook is actually created with a shorter expiry time than + // our warning period, then just mark it as warned. + await storage.setHasGenericHookWarnedExpiry(hookId, true); + } + } else if (config.generic.requireExpiryTime) { + throw new ApiError('Expiration date must be set', ErrCode.BadValue); + } + + await GenericHookConnection.ensureRoomAccountData(roomId, intent, hookId, validState.name); await intent.underlyingClient.sendStateEvent(roomId, this.CanonicalEventType, validState.name, validState); - const connection = new GenericHookConnection(roomId, validState, hookId, validState.name, messageClient, config.generic, as, intent); + const connection = new GenericHookConnection(roomId, validState, hookId, validState.name, messageClient, config.generic, as, intent, storage); return { connection, stateEventContent: validState, @@ -218,6 +285,8 @@ export class GenericHookConnection extends BaseConnection implements IConnection private transformationFunction?: string; private cachedDisplayname?: string; + private warnOnExpiryInterval?: NodeJS.Timeout; + /** * @param state Should be a pre-validated state object returned by {@link validateState} */ @@ -230,11 +299,19 @@ export class GenericHookConnection extends BaseConnection implements IConnection private readonly config: BridgeConfigGenericWebhooks, private readonly as: Appservice, private readonly intent: Intent, + private readonly storage: IBridgeStorageProvider, ) { super(roomId, stateKey, GenericHookConnection.CanonicalEventType); if (state.transformationFunction && GenericHookConnection.quickModule) { this.transformationFunction = state.transformationFunction; } + this.handleExpiryTimeUpdate(false).catch(ex => { + log.warn("Failed to configure expiry time warning for hook", ex); + }); + } + + public get expiresAt(): Date|undefined { + return this.state.expirationDate ? new Date(this.state.expirationDate) : undefined; } /** @@ -313,7 +390,49 @@ export class GenericHookConnection extends BaseConnection implements IConnection } else { this.transformationFunction = undefined; } + + const prevDate = this.state.expirationDate; this.state = validatedConfig; + if (prevDate !== validatedConfig.expirationDate) { + await this.handleExpiryTimeUpdate(true); + } + } + + /** + * Called when the expiry time has been updated for the connection. If the connection + * no longer has an expiry time. This voids the interval. + * @returns + */ + private async handleExpiryTimeUpdate(shouldWrite: boolean) { + if (!this.config.sendExpiryNotice) { + return; + } + if (this.warnOnExpiryInterval) { + clearInterval(this.warnOnExpiryInterval); + this.warnOnExpiryInterval = undefined; + } + if (!this.state.expirationDate) { + return; + } + + const durationRemaining = new Date(this.state.expirationDate).getTime() - Date.now(); + if (durationRemaining < WARN_AT_EXPIRY_MS) { + // If the webhook is actually created with a shorter expiry time than + // our warning period, then just mark it as warned. + if (shouldWrite) { + await this.storage.setHasGenericHookWarnedExpiry(this.hookId, true); + } + } else { + const fuzzCheckTimeMs = Math.round((Math.random() * CHECK_EXPIRY_MS)); + this.warnOnExpiryInterval = setInterval(() => { + this.checkAndWarnExpiry().catch(ex => { + log.warn("Failed to check expiry time for hook", ex); + }) + }, CHECK_EXPIRY_MS + fuzzCheckTimeMs); + if (shouldWrite) { + await this.storage.setHasGenericHookWarnedExpiry(this.hookId, false); + } + } } public transformHookData(data: unknown): {plain: string, html?: string} { @@ -424,8 +543,18 @@ export class GenericHookConnection extends BaseConnection implements IConnection * @param data Structured data. This may either be a string, or an object. * @returns `true` if the webhook completed, or `false` if it failed to complete */ - public async onGenericHook(data: unknown): Promise<{successful: boolean, response?: WebhookResponse}> { + public async onGenericHook(data: unknown): Promise { log.info(`onGenericHook ${this.roomId} ${this.hookId}`); + + if (this.expiresAt && new Date() >= this.expiresAt) { + log.warn("Ignoring incoming webhook. This hook has expired"); + return { + successful: false, + statusCode: StatusCodes.NOT_FOUND, + error: 'This hook has expired', + }; + } + let content: {plain: string, html?: string, msgtype?: string}|undefined; let webhookResponse: WebhookResponse|undefined; let successful = true; @@ -467,7 +596,6 @@ export class GenericHookConnection extends BaseConnection implements IConnection successful, response: webhookResponse, }; - } public static getProvisionerDetails(botUserId: string) { @@ -488,16 +616,19 @@ export class GenericHookConnection extends BaseConnection implements IConnection transformationFunction: this.state.transformationFunction, waitForComplete: this.waitForComplete, name: this.state.name, + expirationDate: this.state.expirationDate, }, ...(showSecrets ? { secrets: { url: new URL(this.hookId, this.config.parsedUrlPrefix), hookId: this.hookId, - } as GenericHookSecrets} : undefined) + timeRemainingMs: this.expiresAt ? this.expiresAt.getTime() - Date.now() : undefined, + } satisfies GenericHookSecrets} : undefined) } } public async onRemove() { log.info(`Removing ${this.toString()} for ${this.roomId}`); + clearInterval(this.warnOnExpiryInterval); // Do a sanity check that the event exists. try { await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey); @@ -509,8 +640,9 @@ export class GenericHookConnection extends BaseConnection implements IConnection await GenericHookConnection.ensureRoomAccountData(this.roomId, this.intent, this.hookId, this.stateKey, true); } - public async provisionerUpdateConfig(userId: string, config: Record) { + public async provisionerUpdateConfig(_userId: string, config: Record) { // Apply previous state to the current config, as provisioners might not return "unknown" keys. + config.expirationDate = config.expirationDate ?? undefined; config = { ...this.state, ...config }; const validatedConfig = GenericHookConnection.validateState(config); await this.intent.underlyingClient.sendStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey, @@ -522,6 +654,35 @@ export class GenericHookConnection extends BaseConnection implements IConnection this.state = validatedConfig; } + private async checkAndWarnExpiry() { + const remainingMs = this.expiresAt ? this.expiresAt.getTime() - Date.now() : undefined; + if (!remainingMs) { + return; + } + if (remainingMs < CHECK_EXPIRY_MS) { + // Nearly expired + return; + } + if (remainingMs > WARN_AT_EXPIRY_MS) { + return; + } + if (await this.storage.getHasGenericHookWarnedExpiry(this.hookId)) { + return; + } + // Warn + const markdownStr = EXPIRY_NOTICE_MESSAGE.replace('%NAME', this.state.name).replace('%TIME', formatDuration({ + hours: millisecondsToHours(remainingMs) + })); + await this.messageClient.sendMatrixMessage(this.roomId, { + msgtype: "m.notice", + body: markdownStr, + // render can output redundant trailing newlines, so trim it. + formatted_body: md.render(markdownStr).trim(), + format: "org.matrix.custom.html", + }, 'm.room.message', this.getUserId()); + await this.storage.setHasGenericHookWarnedExpiry(this.hookId, true); + } + public toString() { return `GenericHookConnection ${this.hookId}`; } diff --git a/src/Connections/GithubDiscussion.ts b/src/Connections/GithubDiscussion.ts index 5529118ca..d3787d1d0 100644 --- a/src/Connections/GithubDiscussion.ts +++ b/src/Connections/GithubDiscussion.ts @@ -1,6 +1,6 @@ import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection"; import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; -import { UserTokenStore } from "../UserTokenStore"; +import { UserTokenStore } from "../tokens/UserTokenStore"; import { CommentProcessor } from "../CommentProcessor"; import { MessageSenderClient } from "../MatrixSender"; import { ensureUserIsInRoom, getIntentForUser } from "../IntentUtils"; diff --git a/src/Connections/GithubIssue.ts b/src/Connections/GithubIssue.ts index 449349d4d..f5656e49a 100644 --- a/src/Connections/GithubIssue.ts +++ b/src/Connections/GithubIssue.ts @@ -2,7 +2,7 @@ import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnectio import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent"; import markdown from "markdown-it"; -import { UserTokenStore } from "../UserTokenStore"; +import { UserTokenStore } from "../tokens/UserTokenStore"; import { Logger } from "matrix-appservice-bridge"; import { CommentProcessor } from "../CommentProcessor"; import { MessageSenderClient } from "../MatrixSender"; @@ -20,7 +20,6 @@ export interface GitHubIssueConnectionState { repo: string; state: string; issues: string[]; - // eslint-disable-next-line camelcase comments_processed: number; } diff --git a/src/Connections/GithubProject.ts b/src/Connections/GithubProject.ts index f9e99fe25..6d21db51b 100644 --- a/src/Connections/GithubProject.ts +++ b/src/Connections/GithubProject.ts @@ -7,7 +7,6 @@ import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck"; import { BridgeConfig } from "../config/Config"; export interface GitHubProjectConnectionState { - // eslint-disable-next-line camelcase project_id: number; state: "open"|"closed"; } diff --git a/src/Connections/GithubRepo.ts b/src/Connections/GithubRepo.ts index 6e77191db..bdd863b77 100644 --- a/src/Connections/GithubRepo.ts +++ b/src/Connections/GithubRepo.ts @@ -13,7 +13,7 @@ import { MatrixMessageContent, MatrixEvent, MatrixReactionContent } from "../Mat import { MessageSenderClient } from "../MatrixSender"; import { CommandError, NotLoggedInError } from "../errors"; import { ReposGetResponseData } from "../github/Types"; -import { UserTokenStore } from "../UserTokenStore"; +import { UserTokenStore } from "../tokens/UserTokenStore"; import axios, { AxiosError } from "axios"; import { emojify } from "node-emoji"; import { Logger } from "matrix-appservice-bridge"; @@ -549,7 +549,7 @@ export class GitHubRepoConnection extends CommandConnection, timeout: NodeJS.Timeout}>(); - private readonly grantChecker = new GitHubGrantChecker(this.as, this.tokenStore); + private readonly grantChecker; constructor( roomId: string, @@ -576,6 +576,7 @@ export class GitHubRepoConnection extends CommandConnection "); + formatted = md.render(content); + } else { + formatted = md.renderInline(content); } const eventPromise = this.intent.sendEvent(this.roomId, { msgtype: "m.notice", body: content, - formatted_body: md.renderInline(content), + formatted_body: formatted, format: "org.matrix.custom.html", ...relation, }).catch(ex => { @@ -845,7 +863,7 @@ ${data.description}`; if (this.hookFilter.shouldSkip('merge_request', 'merge_request.review', `merge_request.${event.object_attributes.action}`) || !this.matchesLabelFilter(event)) { return; } - log.info(`onMergeRequestReviewed ${this.roomId} ${this.instance}/${this.path} ${event.object_attributes.iid}`); + log.info(`onMergeRequestReviewed ${this.roomId} ${this.instance}/${this.path} !${event.object_attributes.iid}`); this.validateMREvent(event); this.debounceMergeRequestReview( event.user, @@ -865,7 +883,7 @@ ${data.description}`; return; } - log.info(`onMergeRequestReviewed ${this.roomId} ${this.instance}/${this.path} ${event.object_attributes.iid}`); + log.info(`onMergeRequestReviewed ${this.roomId} ${this.instance}/${this.path} !${event.object_attributes.iid}`); this.validateMREvent(event); this.debounceMergeRequestReview( event.user, @@ -883,7 +901,7 @@ ${data.description}`; if (this.hookFilter.shouldSkip('merge_request', 'merge_request.review')) { return; } - log.info(`onCommentCreated ${this.roomId} ${this.toString()} ${event.merge_request?.iid} ${event.object_attributes.id}`); + log.info(`onCommentCreated ${this.roomId} ${this.toString()} !${event.merge_request?.iid} ${event.object_attributes.id}`); if (!event.merge_request || event.object_attributes.noteable_type !== "MergeRequest") { // Not a MR comment return; diff --git a/src/Connections/HoundConnection.ts b/src/Connections/HoundConnection.ts new file mode 100644 index 000000000..a70c2e444 --- /dev/null +++ b/src/Connections/HoundConnection.ts @@ -0,0 +1,226 @@ +import { Intent, StateEvent } from "matrix-bot-sdk"; +import markdownit from "markdown-it"; +import { BaseConnection } from "./BaseConnection"; +import { IConnection, IConnectionState } from "."; +import { Connection, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection"; +import { CommandError } from "../errors"; +import { IBridgeStorageProvider } from "../Stores/StorageProvider"; +import { Logger } from "matrix-appservice-bridge"; +export interface HoundConnectionState extends IConnectionState { + challengeId: string; +} + +export interface HoundPayload { + activity: HoundActivity, + challengeId: string, +} + +/** + * @url https://documenter.getpostman.com/view/22349866/UzXLzJUV#0913e0b9-9cb5-440e-9d8d-bf6430285ee9 + */ +export interface HoundActivity { + userId: string, + activityId: string, + participant: string, + /** + * @example "07/26/2022" + */ + date: string, + /** + * @example "2022-07-26T13:49:22Z" + */ + datetime: string, + name: string, + type: string, + /** + * @example strava + */ + app: string, + durationSeconds: number, + /** + * @example "1.39" + */ + distanceKilometers: string, + /** + * @example "0.86" + */ + distanceMiles: string, + /** + * @example "0.86" + */ + elevationMeters: string, + /** + * @example "0.86" + */ + elevationFeet: string, +} + +export interface IChallenge { + id: string; + distance: number; + duration: number; + elevaion: number; +} + +export interface ILeader { + id: string; + fullname: string; + duration: number; + distance: number; + elevation: number; +} + +function getEmojiForType(type: string) { + switch (type) { + case "run": + return "🏃"; + case "virtualrun": + return "👨‍💻🏃"; + case "ride": + case "cycle": + case "cycling": + return "🚴"; + case "mountainbikeride": + return "⛰️🚴"; + case "virtualride": + return "👨‍💻🚴"; + case "walk": + case "hike": + return "🚶"; + case "skateboard": + return "🛹"; + case "virtualwalk": + case "virtualhike": + return "👨‍💻🚶"; + case "alpineski": + return "⛷️"; + case "swim": + return "🏊"; + default: + return "🕴️"; + } +} + +const log = new Logger("HoundConnection"); +const md = markdownit(); +@Connection +export class HoundConnection extends BaseConnection implements IConnection { + static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.challengehound.activity"; + static readonly LegacyEventType = "uk.half-shot.matrix-challenger.activity"; // Magically import from matrix-challenger + + static readonly EventTypes = [ + HoundConnection.CanonicalEventType, + HoundConnection.LegacyEventType, + ]; + static readonly ServiceCategory = "challengehound"; + + public static getIdFromURL(url: string): string { + const parts = new URL(url).pathname.split('/'); + return parts[parts.length-1]; + } + + public static validateState(data: Record): HoundConnectionState { + // Convert URL to ID. + if (!data.challengeId && data.url && typeof data.url === "string") { + data.challengeId = this.getIdFromURL(data.url); + } + + // Test for v1 uuid. + if (!data.challengeId || typeof data.challengeId !== "string" || !/^\w{8}(?:-\w{4}){3}-\w{12}$/.test(data.challengeId)) { + throw Error('Missing or invalid id'); + } + + return { + challengeId: data.challengeId + } + } + + public static createConnectionForState(roomId: string, event: StateEvent>, {config, intent, storage}: InstantiateConnectionOpts) { + if (!config.challengeHound) { + throw Error('Challenge hound is not configured'); + } + return new HoundConnection(roomId, event.stateKey, this.validateState(event.content), intent, storage); + } + + static async provisionConnection(roomId: string, _userId: string, data: Record = {}, {intent, config, storage}: ProvisionConnectionOpts) { + if (!config.challengeHound) { + throw Error('Challenge hound is not configured'); + } + const validState = this.validateState(data); + // Check the event actually exists. + const statusDataRequest = await fetch(`https://api.challengehound.com/challenges/${validState.challengeId}/status`); + if (!statusDataRequest.ok) { + throw new CommandError(`Fetch failed, status ${statusDataRequest.status}`, "Challenge could not be found. Is it active?"); + } + const { challengeName } = await statusDataRequest.json() as {challengeName: string}; + const connection = new HoundConnection(roomId, validState.challengeId, validState, intent, storage); + await intent.underlyingClient.sendStateEvent(roomId, HoundConnection.CanonicalEventType, validState.challengeId, validState); + return { + connection, + stateEventContent: validState, + challengeName, + }; + } + + constructor( + roomId: string, + stateKey: string, + private state: HoundConnectionState, + private readonly intent: Intent, + private readonly storage: IBridgeStorageProvider) { + super(roomId, stateKey, HoundConnection.CanonicalEventType) + } + + public isInterestedInStateEvent() { + return false; // We don't support state-updates...yet. + } + + public get challengeId() { + return this.state.challengeId; + } + + public get priority(): number { + return this.state.priority || super.priority; + } + + public async handleNewActivity(activity: HoundActivity) { + log.info(`New activity recorded ${activity.activityId}`); + const existingActivityEventId = await this.storage.getHoundActivity(this.challengeId, activity.activityId); + const distance = parseFloat(activity.distanceKilometers); + const distanceUnits = `${(distance).toFixed(2)}km`; + const emoji = getEmojiForType(activity.type); + const body = `🎉 **${activity.participant}** completed a ${distanceUnits} ${emoji} ${activity.type} (${activity.name})`; + let content: any = { + body, + format: "org.matrix.custom.html", + formatted_body: md.renderInline(body), + }; + content["msgtype"] = "m.notice"; + content["uk.half-shot.matrix-challenger.activity.id"] = activity.activityId; + content["uk.half-shot.matrix-challenger.activity.distance"] = Math.round(distance * 1000); + content["uk.half-shot.matrix-challenger.activity.elevation"] = Math.round(parseFloat(activity.elevationMeters)); + content["uk.half-shot.matrix-challenger.activity.duration"] = Math.round(activity.durationSeconds); + content["uk.half-shot.matrix-challenger.activity.user"] = { + "name": activity.participant, + id: activity.userId, + }; + if (existingActivityEventId) { + log.debug(`Updating existing activity ${activity.activityId} ${existingActivityEventId}`); + content = { + body: `* ${content.body}`, + msgtype: "m.notice", + "m.new_content": content, + "m.relates_to": { + "event_id": existingActivityEventId, + "rel_type": "m.replace" + }, + }; + } + const eventId = await this.intent.underlyingClient.sendMessage(this.roomId, content); + await this.storage.storeHoundActivityEvent(this.challengeId, activity.activityId, eventId); + } + + public toString() { + return `HoundConnection ${this.challengeId}`; + } +} diff --git a/src/Connections/IConnection.ts b/src/Connections/IConnection.ts index 9fe63a9a3..c01c8878d 100644 --- a/src/Connections/IConnection.ts +++ b/src/Connections/IConnection.ts @@ -3,7 +3,7 @@ import { IssuesOpenedEvent, IssuesEditedEvent } from "@octokit/webhooks-types"; import { ConnectionWarning, GetConnectionsResponseItem } from "../provisioning/api"; import { Appservice, Intent, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk"; import { BridgeConfig, BridgePermissionLevel } from "../config/Config"; -import { UserTokenStore } from "../UserTokenStore"; +import { UserTokenStore } from "../tokens/UserTokenStore"; import { CommentProcessor } from "../CommentProcessor"; import { MessageSenderClient } from "../MatrixSender"; import { IBridgeStorageProvider } from "../Stores/StorageProvider"; diff --git a/src/Connections/JiraProject.ts b/src/Connections/JiraProject.ts index a1aa2acd3..05aabec1d 100644 --- a/src/Connections/JiraProject.ts +++ b/src/Connections/JiraProject.ts @@ -9,7 +9,7 @@ import { JiraProject, JiraVersion } from "../jira/Types"; import { botCommand, BotCommands, compileBotCommands } from "../BotCommands"; import { MatrixMessageContent } from "../MatrixEvent"; import { CommandConnection } from "./CommandConnection"; -import { UserTokenStore } from "../UserTokenStore"; +import { UserTokenStore } from "../tokens/UserTokenStore"; import { CommandError, NotLoggedInError } from "../errors"; import { ApiError, ErrCode } from "../api"; import JiraApi from "jira-client"; diff --git a/src/Connections/OutboundHook.ts b/src/Connections/OutboundHook.ts new file mode 100644 index 000000000..a6c4301b2 --- /dev/null +++ b/src/Connections/OutboundHook.ts @@ -0,0 +1,281 @@ +import axios, { isAxiosError } from "axios"; +import { BaseConnection } from "./BaseConnection"; +import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection"; +import { ApiError, ErrCode, Logger } from "matrix-appservice-bridge"; +import { MatrixEvent } from "../MatrixEvent"; +import { FileMessageEventContent, Intent, StateEvent } from "matrix-bot-sdk"; +import { randomUUID } from "crypto"; +import UserAgent from "../UserAgent"; +import { hashId } from "../libRs"; +import { GetConnectionsResponseItem } from "../provisioning/api"; + +export interface OutboundHookConnectionState extends IConnectionState { + name: string, + url: string; + method?: "PUT"|"POST"; +} + +export interface OutboundHookSecrets { + token: string; +} + +export type OutboundHookResponseItem = GetConnectionsResponseItem; + + +const log = new Logger("OutboundHookConnection"); + +/** + * Handles rooms connected to an outbound generic service. + */ +@Connection +export class OutboundHookConnection extends BaseConnection implements IConnection { + static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.outbound-hook"; + static readonly ServiceCategory = "genericOutbound"; + + static readonly EventTypes = [ + OutboundHookConnection.CanonicalEventType, + ]; + + private static getAccountDataKey(stateKey: string) { + return `${OutboundHookConnection.CanonicalEventType}:${stateKey}`; + } + + static validateState(state: Record): OutboundHookConnectionState { + const {url, method, name} = state; + if (typeof url !== "string") { + throw new ApiError('Outbound URL must be a string', ErrCode.BadValue); + } + + if (typeof name !== "string") { + throw new ApiError("A webhook name must be a string.", ErrCode.BadValue); + } + + try { + const validatedUrl = new URL(url); + if (validatedUrl.protocol !== "http:" && validatedUrl.protocol !== "https:") { + throw new ApiError('Outbound URL protocol must be http or https', ErrCode.BadValue); + } + } catch (ex) { + if (ex instanceof ApiError) { + throw ex; + } + throw new ApiError('Outbound URL is invalid', ErrCode.BadValue); + } + + if (method === "PUT" || method === "POST" || method === undefined) { + return { + name, + url, + method: method ?? 'PUT', + }; + } + throw new ApiError('Outbound Method must be one of PUT,POST', ErrCode.BadValue); + } + + static async createConnectionForState(roomId: string, event: StateEvent>, {intent, config, tokenStore}: InstantiateConnectionOpts) { + if (!config.generic) { + throw Error('Generic webhooks are not configured'); + } + // Generic hooks store the hookId in the account data + const state = this.validateState(event.content); + const token = await tokenStore.getGenericToken("outboundHookToken", hashId(`${roomId}:${event.stateKey}`)); + + if (!token) { + throw new Error(`Missing stored token for connection`); + } + + return new OutboundHookConnection( + roomId, + state, + token, + event.stateKey, + intent, + ); + } + + static async provisionConnection(roomId: string, userId: string, data: Record = {}, {intent, config, tokenStore}: ProvisionConnectionOpts) { + if (!config.generic) { + throw Error('Generic Webhooks are not configured'); + } + if (!config.generic.outbound) { + throw Error('Outbound support for Generic Webhooks is not configured'); + } + + const token = `hs-ob-${randomUUID()}`; + + if (typeof data.name !== "string" || data.name.length < 3 || data.name.length > 64) { + throw new ApiError("A webhook name must be between 3-64 characters.", ErrCode.BadValue); + } + + const validState = OutboundHookConnection.validateState(data); + + const stateKey = data.name; + const tokenKey = hashId(`${roomId}:${stateKey}`); + await tokenStore.storeGenericToken("outboundHookToken", tokenKey, token); + + await intent.underlyingClient.sendStateEvent(roomId, this.CanonicalEventType, stateKey, validState); + const connection = new OutboundHookConnection(roomId, validState, token, stateKey, intent); + return { + connection, + stateEventContent: validState, + } + } + + /** + * @param state Should be a pre-validated state object returned by {@link validateState} + */ + constructor( + roomId: string, + private state: OutboundHookConnectionState, + public readonly outboundToken: string, + stateKey: string, + private readonly intent: Intent, + ) { + super(roomId, stateKey, OutboundHookConnection.CanonicalEventType); + } + + public isInterestedInStateEvent(eventType: string, stateKey: string) { + return OutboundHookConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; + } + + /** + * Check for any embedded media in the event, and if present then extract it as a blob. This + * function also returns event content with the encryption details stripped from the event contents. + * @param ev The Matrix event to inspect for embedded media. + * @returns A blob and event object if media is found, otherwise null. + * @throws If media was expected (due to the msgtype) but not provided, or if the media could not + * be found or decrypted. + */ + private async extractMedia(ev: MatrixEvent): Promise<{blob: Blob, event: MatrixEvent}|null> { + // Check for non-extendable event types first. + const content = ev.content as FileMessageEventContent; + + if (!["m.image", "m.audio", "m.file", "m.video"].includes(content.msgtype)) { + return null; + } + + const client = this.intent.underlyingClient; + let data: { data: Buffer, contentType?: string}; + if (client.crypto && content.file) { + data = { + data: await client.crypto.decryptMedia(content.file), + contentType: content.info?.mimetype + }; + const strippedContent = {...ev, content: { + ...content, + file: null, + }}; + return { + blob: new File([await client.crypto.decryptMedia(content.file)], content.body, { type: data.contentType }), + event: strippedContent + } + } else if (content.url) { + data = await this.intent.underlyingClient.downloadContent(content.url); + return { + blob: new File([data.data], content.body, { type: data.contentType }), + event: ev, + }; + } + + throw Error('Missing file or url key on event, not handling media'); + } + + + public async onEvent(ev: MatrixEvent): Promise { + // The event content first. + const multipartBlob = new FormData(); + try { + const mediaResult = await this.extractMedia(ev); + if (mediaResult) { + multipartBlob.set('event', new Blob([JSON.stringify(mediaResult?.event)], { + type: 'application/json', + }), "event_data.json"); + multipartBlob.set('media', mediaResult.blob); + } + } catch (ex) { + log.warn(`Failed to get media for ${ev.event_id} in ${this.roomId}`, ex); + } + + if (!multipartBlob.has('event')) { + multipartBlob.set('event', new Blob([JSON.stringify(ev)], { + type: 'application/json', + }), "event_data.json"); + } + + try { + await axios.request({ + url: this.state.url, + data: multipartBlob, + method: this.state.method, + responseType: 'text', + validateStatus: (status) => status >= 200 && status <= 299, + headers: { + 'User-Agent': UserAgent, + 'X-Matrix-Hookshot-RoomId': this.roomId, + 'X-Matrix-Hookshot-EventId': ev.event_id, + 'X-Matrix-Hookshot-Token': this.outboundToken, + }, + }); + log.info(`Sent webhook for ${ev.event_id}`); + } catch (ex) { + if (!isAxiosError(ex)) { + log.error(`Failed to send outbound webhook`, ex); + throw ex; + } + if (ex.status) { + log.error(`Failed to send outbound webhook: HTTP ${ex.status}`); + } else { + log.error(`Failed to send outbound webhook: ${ex.code}`); + } + log.debug("Response from server", ex.response?.data); + } + } + + public static getProvisionerDetails(botUserId: string) { + return { + service: "genericOutbound", + eventType: OutboundHookConnection.CanonicalEventType, + type: "Webhook", + botUserId: botUserId, + } + } + + public getProvisionerDetails(showSecrets = false): OutboundHookResponseItem { + return { + ...OutboundHookConnection.getProvisionerDetails(this.intent.userId), + id: this.connectionId, + config: { + url: this.state.url, + method: this.state.method, + name: this.state.name, + }, + ...(showSecrets ? { secrets: { + token: this.outboundToken, + } satisfies OutboundHookSecrets} : undefined) + } + } + + public async onRemove() { + log.info(`Removing ${this.toString()} for ${this.roomId}`); + // Do a sanity check that the event exists. + await this.intent.underlyingClient.getRoomStateEvent(this.roomId, OutboundHookConnection.CanonicalEventType, this.stateKey); + await this.intent.underlyingClient.sendStateEvent(this.roomId, OutboundHookConnection.CanonicalEventType, this.stateKey, { disabled: true }); + // TODO: Remove token + + } + + public async provisionerUpdateConfig(userId: string, config: Record) { + config = { ...this.state, ...config }; + const validatedConfig = OutboundHookConnection.validateState(config); + await this.intent.underlyingClient.sendStateEvent(this.roomId, OutboundHookConnection.CanonicalEventType, this.stateKey, + { + ...validatedConfig, + } + ); + this.state = validatedConfig; + } + + public toString() { + return `OutboundHookConnection ${this.roomId}`; + } +} \ No newline at end of file diff --git a/src/Connections/SetupConnection.ts b/src/Connections/SetupConnection.ts index 9f4893f3a..176a11b79 100644 --- a/src/Connections/SetupConnection.ts +++ b/src/Connections/SetupConnection.ts @@ -1,7 +1,6 @@ -// We need to instantiate some functions which are not directly called, which confuses typescript. import { BotCommands, botCommand, compileBotCommands, HelpFunction } from "../BotCommands"; import { CommandConnection } from "./CommandConnection"; -import { GenericHookConnection, GenericHookConnectionState, GitHubRepoConnection, JiraProjectConnection, JiraProjectConnectionState } from "."; +import { GenericHookConnection, GenericHookConnectionState, GitHubRepoConnection, JiraProjectConnection, JiraProjectConnectionState, OutboundHookConnection } from "."; import { CommandError } from "../errors"; import { BridgePermissionLevel } from "../config/Config"; import markdown from "markdown-it"; @@ -15,9 +14,13 @@ import { IConnection, IConnectionState, ProvisionConnectionOpts } from "./IConne import { ApiError, Logger } from "matrix-appservice-bridge"; import { Intent } from "matrix-bot-sdk"; import YAML from 'yaml'; +import parseDuration from 'parse-duration'; +import { HoundConnection } from "./HoundConnection"; const md = new markdown(); const log = new Logger("SetupConnection"); +const OUTBOUND_DOCS_LINK = "https://matrix-org.github.io/matrix-hookshot/latest/setup/webhooks.html"; + /** * Handles setting up a room with connections. This connection is "virtual" in that it has * no state, and is only invoked when messages from other clients fall through. @@ -72,13 +75,13 @@ export class SetupConnection extends CommandConnection { this.includeTitlesInHelp = false; } - @botCommand("github repo", { help: "Create a connection for a GitHub repository. (You must be logged in with GitHub to do this.)", requiredArgs: ["url"], includeUserId: true, category: "github"}) + @botCommand("github repo", { help: "Create a connection for a GitHub repository. (You must be logged in with GitHub to do this.)", requiredArgs: ["url"], includeUserId: true, category: GitHubRepoConnection.ServiceCategory}) public async onGitHubRepo(userId: string, url: string) { if (!this.provisionOpts.github || !this.config.github) { throw new CommandError("not-configured", "The bridge is not configured to support GitHub."); } - await this.checkUserPermissions(userId, "github", GitHubRepoConnection.CanonicalEventType); + await this.checkUserPermissions(userId, GitHubRepoConnection.ServiceCategory, GitHubRepoConnection.CanonicalEventType); const octokit = await this.provisionOpts.tokenStore.getOctokitForUser(userId); if (!octokit) { throw new CommandError("User not logged in", "You are not logged into GitHub. Start a DM with this bot and use the command `github login`."); @@ -93,13 +96,13 @@ export class SetupConnection extends CommandConnection { await this.client.sendNotice(this.roomId, `Room configured to bridge ${connection.org}/${connection.repo}`); } - @botCommand("gitlab project", { help: "Create a connection for a GitHub project. (You must be logged in with GitLab to do this.)", requiredArgs: ["url"], includeUserId: true, category: "gitlab"}) + @botCommand("gitlab project", { help: "Create a connection for a GitHub project. (You must be logged in with GitLab to do this.)", requiredArgs: ["url"], includeUserId: true, category: GitLabRepoConnection.ServiceCategory}) public async onGitLabRepo(userId: string, url: string) { if (!this.config.gitlab) { throw new CommandError("not-configured", "The bridge is not configured to support GitLab."); } - await this.checkUserPermissions(userId, "gitlab", GitLabRepoConnection.CanonicalEventType); + await this.checkUserPermissions(userId, GitLabRepoConnection.ServiceCategory, GitLabRepoConnection.CanonicalEventType); const {name, instance} = this.config.gitlab.getInstanceByProjectUrl(url) || {}; if (!instance || !name) { @@ -126,7 +129,7 @@ export class SetupConnection extends CommandConnection { } } - private async getJiraProjectSafeUrl(userId: string, urlStr: string) { + private async getJiraProjectSafeUrl(urlStr: string) { const url = new URL(urlStr); const urlParts = /\/projects\/(\w+)\/?(\w+\/?)*$/.exec(url.pathname); const projectKey = urlParts?.[1] || url.searchParams.get('projectKey'); @@ -136,22 +139,22 @@ export class SetupConnection extends CommandConnection { return `https://${url.host}/projects/${projectKey}`; } - @botCommand("jira project", { help: "Create a connection for a JIRA project. (You must be logged in with JIRA to do this.)", requiredArgs: ["url"], includeUserId: true, category: "jira"}) + @botCommand("jira project", { help: "Create a connection for a JIRA project. (You must be logged in with JIRA to do this.)", requiredArgs: ["url"], includeUserId: true, category: JiraProjectConnection.ServiceCategory}) public async onJiraProject(userId: string, urlStr: string) { if (!this.config.jira) { throw new CommandError("not-configured", "The bridge is not configured to support Jira."); } - await this.checkUserPermissions(userId, "jira", JiraProjectConnection.CanonicalEventType); + await this.checkUserPermissions(userId, JiraProjectConnection.ServiceCategory, JiraProjectConnection.CanonicalEventType); await this.checkJiraLogin(userId, urlStr); - const safeUrl = await this.getJiraProjectSafeUrl(userId, urlStr); + const safeUrl = await this.getJiraProjectSafeUrl(urlStr); const res = await JiraProjectConnection.provisionConnection(this.roomId, userId, { url: safeUrl }, this.provisionOpts); this.pushConnections(res.connection); await this.client.sendNotice(this.roomId, `Room configured to bridge Jira project ${res.connection.projectKey}.`); } - @botCommand("jira list project", { help: "Show JIRA projects currently connected to.", category: "jira"}) + @botCommand("jira list project", { help: "Show JIRA projects currently connected to.", category: JiraProjectConnection.ServiceCategory}) public async onJiraListProject() { const projects: JiraProjectConnectionState[] = await this.client.getRoomState(this.roomId).catch((err: any) => { if (err.body.errcode === 'M_NOT_FOUND') { @@ -177,11 +180,11 @@ export class SetupConnection extends CommandConnection { } } - @botCommand("jira remove project", { help: "Remove a connection for a JIRA project.", requiredArgs: ["url"], includeUserId: true, category: "jira"}) + @botCommand("jira remove project", { help: "Remove a connection for a JIRA project.", requiredArgs: ["url"], includeUserId: true, category: JiraProjectConnection.ServiceCategory}) public async onJiraRemoveProject(userId: string, urlStr: string) { - await this.checkUserPermissions(userId, "jira", JiraProjectConnection.CanonicalEventType); + await this.checkUserPermissions(userId, JiraProjectConnection.ServiceCategory, JiraProjectConnection.CanonicalEventType); await this.checkJiraLogin(userId, urlStr); - const safeUrl = await this.getJiraProjectSafeUrl(userId, urlStr); + const safeUrl = await this.getJiraProjectSafeUrl(urlStr); const eventTypes = [ JiraProjectConnection.CanonicalEventType, @@ -207,18 +210,27 @@ export class SetupConnection extends CommandConnection { return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room no longer bridged to Jira project \`${safeUrl}\`.`)); } - @botCommand("webhook", { help: "Create an inbound webhook.", requiredArgs: ["name"], includeUserId: true, category: "generic"}) - public async onWebhook(userId: string, name: string) { + @botCommand("webhook", { help: "Create an inbound webhook. The liveDuration must be specified as a duration string (e.g. 30d).", requiredArgs: ["name"], includeUserId: true, optionalArgs: ['liveDuration'], category: GenericHookConnection.ServiceCategory}) + public async onWebhook(userId: string, name: string, liveDuration?: string) { if (!this.config.generic?.enabled) { throw new CommandError("not-configured", "The bridge is not configured to support webhooks."); } + let expirationDate: string|undefined = undefined; + if (liveDuration) { + const expirationDuration = parseDuration(liveDuration); + if (!expirationDuration) { + throw new CommandError("Bad webhook duration", "A webhook name must be between 3-64 characters."); + } + expirationDate = new Date(expirationDuration + Date.now()).toISOString(); + } + await this.checkUserPermissions(userId, "webhooks", GitHubRepoConnection.CanonicalEventType); if (!name || name.length < 3 || name.length > 64) { throw new CommandError("Bad webhook name", "A webhook name must be between 3-64 characters."); } - const c = await GenericHookConnection.provisionConnection(this.roomId, userId, {name}, this.provisionOpts); + const c = await GenericHookConnection.provisionConnection(this.roomId, userId, {name, expirationDate}, this.provisionOpts); this.pushConnections(c.connection); const url = new URL(c.connection.hookId, this.config.generic.parsedUrlPrefix); const adminRoom = await this.getOrCreateAdminRoom(this.intent, userId); @@ -234,7 +246,7 @@ export class SetupConnection extends CommandConnection { - @botCommand("webhook list", { help: "Show webhooks currently configured.", category: "generic"}) + @botCommand("webhook list", { help: "Show webhooks currently configured.", category: GenericHookConnection.ServiceCategory}) public async onWebhookList() { const webhooks: GenericHookConnectionState[] = await this.client.getRoomState(this.roomId).catch((err: any) => { if (err.body.errcode === 'M_NOT_FOUND') { @@ -263,9 +275,9 @@ export class SetupConnection extends CommandConnection { } } - @botCommand("webhook remove", { help: "Remove a webhook from the room.", requiredArgs: ["name"], includeUserId: true, category: "generic"}) + @botCommand("webhook remove", { help: "Remove a webhook from the room.", requiredArgs: ["name"], includeUserId: true, category: GenericHookConnection.ServiceCategory}) public async onWebhookRemove(userId: string, name: string) { - await this.checkUserPermissions(userId, "generic", GenericHookConnection.CanonicalEventType); + await this.checkUserPermissions(userId, GenericHookConnection.ServiceCategory, GenericHookConnection.CanonicalEventType); const event = await this.client.getRoomStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, name).catch((err: any) => { if (err.body.errcode === 'M_NOT_FOUND') { @@ -284,13 +296,42 @@ export class SetupConnection extends CommandConnection { return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Removed webhook \`${name}\``)); } - @botCommand("figma file", { help: "Bridge a Figma file to the room.", requiredArgs: ["url"], includeUserId: true, category: "figma"}) + + + @botCommand("outbound-hook", { help: "Create an outbound webhook.", requiredArgs: ["name", "url"], includeUserId: true, category: GenericHookConnection.ServiceCategory}) + public async onOutboundHook(userId: string, name: string, url: string) { + if (!this.config.generic?.outbound) { + throw new CommandError("not-configured", "The bridge is not configured to support webhooks."); + } + + await this.checkUserPermissions(userId, "webhooks", GitHubRepoConnection.CanonicalEventType); + + const { connection }= await OutboundHookConnection.provisionConnection(this.roomId, userId, {name, url}, this.provisionOpts); + this.pushConnections(connection); + + const adminRoom = await this.getOrCreateAdminRoom(this.intent, userId); + const safeRoomId = encodeURIComponent(this.roomId); + + await this.client.sendHtmlNotice( + adminRoom.roomId, + md.renderInline( + `You have bridged the webhook "${name}" in https://matrix.to/#/${safeRoomId} .\n` + + // Line break before and no full stop after URL is intentional. + // This makes copying and pasting the URL much easier. + `Please use the secret token \`${connection.outboundToken}\` when validating the request.\n` + + `See the [documentation](${OUTBOUND_DOCS_LINK}) for more information`, + )); + return this.client.sendNotice(this.roomId, `Room configured to bridge outbound webhooks. See admin room for the secret token.`); + } + + + @botCommand("figma file", { help: "Bridge a Figma file to the room.", requiredArgs: ["url"], includeUserId: true, category: FigmaFileConnection.ServiceCategory}) public async onFigma(userId: string, url: string) { if (!this.config.figma) { throw new CommandError("not-configured", "The bridge is not configured to support Figma."); } - await this.checkUserPermissions(userId, "figma", FigmaFileConnection.CanonicalEventType); + await this.checkUserPermissions(userId, FigmaFileConnection.ServiceCategory, FigmaFileConnection.CanonicalEventType); const res = /https:\/\/www\.figma\.com\/file\/(\w+).+/.exec(url); if (!res) { @@ -302,13 +343,13 @@ export class SetupConnection extends CommandConnection { return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge Figma file.`)); } - @botCommand("feed", { help: "Bridge an RSS/Atom feed to the room.", requiredArgs: ["url"], optionalArgs: ["label"], includeUserId: true, category: "feeds"}) + @botCommand("feed", { help: "Bridge an RSS/Atom feed to the room.", requiredArgs: ["url"], optionalArgs: ["label"], includeUserId: true, category: FeedConnection.ServiceCategory}) public async onFeed(userId: string, url: string, label?: string) { if (!this.config.feeds?.enabled) { throw new CommandError("not-configured", "The bridge is not configured to support feeds."); } - await this.checkUserPermissions(userId, "feed", FeedConnection.CanonicalEventType); + await this.checkUserPermissions(userId,FeedConnection.ServiceCategory, FeedConnection.CanonicalEventType); // provisionConnection will check it again, but won't give us a nice CommandError on failure try { @@ -327,7 +368,7 @@ export class SetupConnection extends CommandConnection { return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge \`${url}\``)); } - @botCommand("feed list", { help: "Show feeds currently subscribed to. Supported formats `json` and `yaml`.", optionalArgs: ["format"], category: "feeds"}) + @botCommand("feed list", { help: "Show feeds currently subscribed to. Supported formats `json` and `yaml`.", optionalArgs: ["format"], category: FeedConnection.ServiceCategory}) public async onFeedList(format?: string) { const useJsonFormat = format?.toLowerCase() === 'json'; const useYamlFormat = format?.toLowerCase() === 'yaml'; @@ -373,7 +414,7 @@ export class SetupConnection extends CommandConnection { @botCommand("feed remove", { help: "Unsubscribe from an RSS/Atom feed.", requiredArgs: ["url"], includeUserId: true, category: "feeds"}) public async onFeedRemove(userId: string, url: string) { - await this.checkUserPermissions(userId, "feed", FeedConnection.CanonicalEventType); + await this.checkUserPermissions(userId, FeedConnection.ServiceCategory, FeedConnection.CanonicalEventType); const event = await this.client.getRoomStateEvent(this.roomId, FeedConnection.CanonicalEventType, url).catch((err: any) => { if (err.body.errcode === 'M_NOT_FOUND') { @@ -389,6 +430,36 @@ export class SetupConnection extends CommandConnection { return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Unsubscribed from \`${url}\``)); } + @botCommand("challenghound add", { help: "Bridge a ChallengeHound challenge to the room.", requiredArgs: ["url"], includeUserId: true, category: "challengehound"}) + public async onChallengeHoundAdd(userId: string, url: string) { + if (!this.config.challengeHound) { + throw new CommandError("not-configured", "The bridge is not configured to support challengeHound."); + } + + await this.checkUserPermissions(userId, HoundConnection.ServiceCategory, HoundConnection.CanonicalEventType); + const {connection, challengeName} = await HoundConnection.provisionConnection(this.roomId, userId, { url }, this.provisionOpts); + this.pushConnections(connection); + return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge ${challengeName}. Good luck!`)); + } + + @botCommand("challenghound remove", { help: "Unbridge a ChallengeHound challenge.", requiredArgs: ["urlOrId"], includeUserId: true, category: HoundConnection.ServiceCategory}) + public async onChallengeHoundRemove(userId: string, urlOrId: string) { + await this.checkUserPermissions(userId, HoundConnection.ServiceCategory, HoundConnection.CanonicalEventType); + const id = urlOrId.startsWith('http') ? HoundConnection.getIdFromURL(urlOrId) : urlOrId; + const event = await this.client.getRoomStateEvent(this.roomId, HoundConnection.CanonicalEventType, id).catch((err: any) => { + if (err.body.errcode === 'M_NOT_FOUND') { + return null; // not an error to us + } + throw err; + }); + if (!event || Object.keys(event).length === 0) { + throw new CommandError("Invalid feed URL", `Challenge "${id}" is not currently bridged to this room`); + } + + await this.client.sendStateEvent(this.roomId, FeedConnection.CanonicalEventType, id, {}); + return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Unsubscribed from challenge`)); + } + @botCommand("setup-widget", {category: "widget", help: "Open the setup widget in the room"}) public async onSetupWidget() { if (this.config.widgets?.roomSetupWidget === undefined) { diff --git a/src/Connections/index.ts b/src/Connections/index.ts index d56143c98..c06b97cbe 100644 --- a/src/Connections/index.ts +++ b/src/Connections/index.ts @@ -10,4 +10,5 @@ export * from "./GitlabRepo"; export * from "./IConnection"; export * from "./JiraProject"; export * from "./FigmaFileConnection"; -export * from "./FeedConnection"; \ No newline at end of file +export * from "./FeedConnection"; +export * from "./OutboundHook"; \ No newline at end of file diff --git a/src/Gitlab/GrantChecker.ts b/src/Gitlab/GrantChecker.ts index 66bd7bea7..de759fc6e 100644 --- a/src/Gitlab/GrantChecker.ts +++ b/src/Gitlab/GrantChecker.ts @@ -3,7 +3,7 @@ import { Appservice } from "matrix-bot-sdk"; import { BridgeConfigGitLab } from "../config/Config"; import { GitLabRepoConnection } from "../Connections"; import { GrantChecker } from "../grants/GrantCheck"; -import { UserTokenStore } from "../UserTokenStore"; +import { UserTokenStore } from "../tokens/UserTokenStore"; const log = new Logger('GitLabGrantChecker'); diff --git a/src/Gitlab/Types.ts b/src/Gitlab/Types.ts index b7a29198b..956b8a823 100644 --- a/src/Gitlab/Types.ts +++ b/src/Gitlab/Types.ts @@ -1,4 +1,3 @@ -/* eslint-disable camelcase */ export interface GitLabAuthor { id: number; name: string; diff --git a/src/Gitlab/WebhookTypes.ts b/src/Gitlab/WebhookTypes.ts index c721367a6..c331df03d 100644 --- a/src/Gitlab/WebhookTypes.ts +++ b/src/Gitlab/WebhookTypes.ts @@ -1,5 +1,3 @@ -/* eslint-disable camelcase */ - export interface IGitLabWebhookEvent { object_kind: string; } @@ -64,9 +62,9 @@ export interface IGitLabWebhookMREvent { object_attributes: IGitLabMergeRequestObjectAttributes; labels: IGitLabLabel[]; changes: { - [key: string]: { - before: string; - after: string; + draft?: { + previous: boolean; + current: boolean; } } } diff --git a/src/ListenerService.ts b/src/ListenerService.ts index 32ef379f6..62f2168ec 100644 --- a/src/ListenerService.ts +++ b/src/ListenerService.ts @@ -30,6 +30,7 @@ export class ListenerService { } for (const listenerConfig of config) { const app = expressApp(); + app.set('x-powered-by', false); app.use(Handlers.requestHandler()); this.listeners.push({ config: listenerConfig, diff --git a/src/Managers/BotUsersManager.ts b/src/Managers/BotUsersManager.ts index ab7ab871c..5927cd117 100644 --- a/src/Managers/BotUsersManager.ts +++ b/src/Managers/BotUsersManager.ts @@ -171,13 +171,10 @@ export default class BotUsersManager { // Determine if an avatar update is needed if (profile.avatar_url) { try { - const res = await axios.get( - botUser.intent.underlyingClient.mxcToHttp(profile.avatar_url), - { responseType: "arraybuffer" }, - ); + const res = await botUser.intent.underlyingClient.downloadContent(profile.avatar_url); const currentAvatarImage = { - image: Buffer.from(res.data), - contentType: res.headers["content-type"], + image: res.data, + contentType: res.contentType, }; if ( currentAvatarImage.image.equals(avatarImage.image) diff --git a/src/MatrixEvent.ts b/src/MatrixEvent.ts index 5fa42599a..67bb5e90b 100644 --- a/src/MatrixEvent.ts +++ b/src/MatrixEvent.ts @@ -1,4 +1,3 @@ -/* eslint-disable camelcase */ export interface MatrixEvent { content: T; event_id: string; @@ -8,10 +7,7 @@ export interface MatrixEvent { type: string; } -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface MatrixEventContent { - -} +type MatrixEventContent = object; export interface MatrixMemberContent extends MatrixEventContent { avatar_url: string|null; diff --git a/src/MatrixSender.ts b/src/MatrixSender.ts index 924d1f9e8..36b59e127 100644 --- a/src/MatrixSender.ts +++ b/src/MatrixSender.ts @@ -34,7 +34,7 @@ export class MatrixSender { try { await this.sendMatrixMessage(msg.messageId || randomUUID(), msg.data); } catch (ex) { - log.error(`Failed to send message (${msg.data.roomId}, ${msg.data.sender}, ${msg.data.type})`); + log.error(`Failed to send message (${msg.data.roomId}, ${msg.data.sender}, ${msg.data.type})`, ex); } }); } diff --git a/src/MessageQueue/MessageQueue.ts b/src/MessageQueue/MessageQueue.ts index 476e6d862..03dc1d17f 100644 --- a/src/MessageQueue/MessageQueue.ts +++ b/src/MessageQueue/MessageQueue.ts @@ -1,4 +1,4 @@ -import { BridgeConfigQueue } from "../config/Config"; +import { BridgeConfigQueue } from "../config/sections"; import { LocalMQ } from "./LocalMQ"; import { RedisMQ } from "./RedisQueue"; import { MessageQueue } from "./Types"; @@ -6,8 +6,8 @@ import { MessageQueue } from "./Types"; const staticLocalMq = new LocalMQ(); let staticRedisMq: RedisMQ|null = null; -export function createMessageQueue(config: BridgeConfigQueue): MessageQueue { - if (config.monolithic) { +export function createMessageQueue(config?: BridgeConfigQueue): MessageQueue { + if (!config) { return staticLocalMq; } if (staticRedisMq === null) { diff --git a/src/MessageQueue/RedisQueue.ts b/src/MessageQueue/RedisQueue.ts index 2fbda2f3a..0800da894 100644 --- a/src/MessageQueue/RedisQueue.ts +++ b/src/MessageQueue/RedisQueue.ts @@ -1,7 +1,7 @@ import { MessageQueue, MessageQueueMessage, DEFAULT_RES_TIMEOUT, MessageQueueMessageOut } from "./Types"; import { Redis, default as redis } from "ioredis"; -import { BridgeConfigQueue } from "../config/Config"; +import { BridgeConfigQueue } from "../config/sections/queue"; import { EventEmitter } from "events"; import { Logger } from "matrix-appservice-bridge"; import { randomUUID } from 'node:crypto'; @@ -22,9 +22,10 @@ export class RedisMQ extends EventEmitter implements MessageQueue { private myUuid: string; constructor(config: BridgeConfigQueue) { super(); - this.redisSub = new redis(config.port ?? 6379, config.host ?? "localhost"); - this.redisPub = new redis(config.port ?? 6379, config.host ?? "localhost"); - this.redis = new redis(config.port ?? 6379, config.host ?? "localhost"); + const uri = 'redisUri' in config ? config.redisUri : `redis://${config.host ?? 'localhost'}:${config.port ?? 6379}`; + this.redisSub = new redis(uri); + this.redisPub = new redis(uri); + this.redis = new redis(uri); this.myUuid = randomUUID(); this.redisSub.on("pmessage", (_: string, channel: string, message: string) => { const msg = JSON.parse(message) as MessageQueueMessageOut; diff --git a/src/Notifications/UserNotificationWatcher.ts b/src/Notifications/UserNotificationWatcher.ts index 4531c7b96..fbd47315e 100644 --- a/src/Notifications/UserNotificationWatcher.ts +++ b/src/Notifications/UserNotificationWatcher.ts @@ -48,7 +48,7 @@ export class UserNotificationWatcher { [...this.userIntervals.values()].forEach((v) => { v.stop(); }); - this.queue.stop ? this.queue.stop() : undefined; + this.queue.stop?.(); } public removeUser(userId: string, type: "github"|"gitlab", instanceUrl?: string) { diff --git a/src/NotificationsProcessor.ts b/src/NotificationsProcessor.ts index 8b4ea8013..640e85cb8 100644 --- a/src/NotificationsProcessor.ts +++ b/src/NotificationsProcessor.ts @@ -10,7 +10,6 @@ import { GitHubUserNotification } from "./github/Types"; import { components } from "@octokit/openapi-types/types"; import { NotifFilter } from "./NotificationFilters"; - const log = new Logger("NotificationProcessor"); const md = new markdown(); @@ -21,18 +20,15 @@ export interface IssueDiff { merged: boolean; mergedBy: null|{ login: string; - // eslint-disable-next-line camelcase html_url: string; }; user: { login: string; - // eslint-disable-next-line camelcase html_url: string; }; } export interface CachedReviewData { - // eslint-disable-next-line camelcase requested_reviewers: PullsListRequestedReviewersResponseData; reviews: PullsListReviewsResponseData; } @@ -40,8 +36,6 @@ export interface CachedReviewData { type PROrIssue = IssuesGetResponseData|PullGetResponseData; export class NotificationProcessor { - - // eslint-disable-next-line camelcase private static formatUser(user: {login: string, html_url: string}) { return `**[${user.login}](${user.html_url})**`; } diff --git a/src/Stores/MemoryStorageProvider.ts b/src/Stores/MemoryStorageProvider.ts index 52b5bf545..89f3bc58c 100644 --- a/src/Stores/MemoryStorageProvider.ts +++ b/src/Stores/MemoryStorageProvider.ts @@ -14,6 +14,9 @@ export class MemoryStorageProvider extends MSP implements IBridgeStorageProvider private storedFiles = new QuickLRU({ maxSize: 128 }); private gitlabDiscussionThreads = new Map(); private feedGuids = new Map>(); + private houndActivityIds = new Map>(); + private houndActivityIdToEvent = new Map(); + private hasGenericHookWarnedExpiry = new Set(); constructor() { super(); @@ -108,4 +111,41 @@ export class MemoryStorageProvider extends MSP implements IBridgeStorageProvider public async setGitlabDiscussionThreads(connectionId: string, value: SerializedGitlabDiscussionThreads): Promise { this.gitlabDiscussionThreads.set(connectionId, value); } + + async storeHoundActivity(challengeId: string, ...activityIds: string[]): Promise { + let set = this.houndActivityIds.get(challengeId); + if (!set) { + set = [] + this.houndActivityIds.set(challengeId, set); + } + set.unshift(...activityIds); + while (set.length > MAX_FEED_ITEMS) { + set.pop(); + } + } + + async hasSeenHoundActivity(challengeId: string, ...activityIds: string[]): Promise { + const existing = this.houndActivityIds.get(challengeId); + return existing ? activityIds.filter((existingGuid) => existing.includes(existingGuid)) : []; + } + + public async hasSeenHoundChallenge(challengeId: string): Promise { + return this.houndActivityIds.has(challengeId); + } + + public async storeHoundActivityEvent(challengeId: string, activityId: string, eventId: string): Promise { + this.houndActivityIdToEvent.set(`${challengeId}.${activityId}`, eventId); + } + + public async getHoundActivity(challengeId: string, activityId: string): Promise { + return this.houndActivityIdToEvent.get(`${challengeId}.${activityId}`) ?? null; + } + + public async getHasGenericHookWarnedExpiry(hookId: string): Promise { + return this.hasGenericHookWarnedExpiry.has(hookId); + } + + public async setHasGenericHookWarnedExpiry(hookId: string, hasWarned: boolean): Promise { + this.hasGenericHookWarnedExpiry[hasWarned ? "add" : "delete"](hookId); + } } diff --git a/src/Stores/RedisStorageProvider.ts b/src/Stores/RedisStorageProvider.ts index 12c42a798..54be0a1d6 100644 --- a/src/Stores/RedisStorageProvider.ts +++ b/src/Stores/RedisStorageProvider.ts @@ -6,6 +6,7 @@ import { IBridgeStorageProvider, MAX_FEED_ITEMS } from "./StorageProvider"; import { IFilterInfo, IStorageProvider } from "matrix-bot-sdk"; import { ProvisionSession } from "matrix-appservice-bridge"; import { SerializedGitlabDiscussionThreads } from "../Gitlab/Types"; +import { BridgeConfigCache } from "../config/sections"; const BOT_SYNC_TOKEN_KEY = "bot.sync_token."; const BOT_FILTER_KEY = "bot.filter."; @@ -22,12 +23,17 @@ const STORED_FILES_EXPIRE_AFTER = 24 * 60 * 60; // 24 hours const COMPLETED_TRANSACTIONS_EXPIRE_AFTER = 24 * 60 * 60; // 24 hours const ISSUES_EXPIRE_AFTER = 7 * 24 * 60 * 60; // 7 days const ISSUES_LAST_COMMENT_EXPIRE_AFTER = 14 * 24 * 60 * 60; // 7 days +const HOUND_EVENT_CACHE = 90 * 24 * 60 * 60; // 90 days const WIDGET_TOKENS = "widgets.tokens."; const WIDGET_USER_TOKENS = "widgets.user-tokens."; const FEED_GUIDS = "feeds.guids."; +const HOUND_GUIDS = "hound.guids."; +const HOUND_EVENTS = "hound.events."; + +const GENERIC_HOOK_HAS_WARNED = "generichook.haswarned"; const log = new Logger("RedisASProvider"); @@ -68,8 +74,8 @@ export class RedisStorageContextualProvider implements IStorageProvider { export class RedisStorageProvider extends RedisStorageContextualProvider implements IBridgeStorageProvider { - constructor(host: string, port: number, contextSuffix = '') { - super(new redis(port, host), contextSuffix); + constructor(cacheConfig: BridgeConfigCache, contextSuffix = '') { + super(new redis(cacheConfig.redisUri), contextSuffix); this.redis.expire(COMPLETED_TRANSACTIONS_KEY, COMPLETED_TRANSACTIONS_EXPIRE_AFTER).catch((ex) => { log.warn("Failed to set expiry time on as.completed_transactions", ex); }); @@ -98,7 +104,7 @@ export class RedisStorageProvider extends RedisStorageContextualProvider impleme } public async addRegisteredUser(userId: string) { - this.redis.sadd(REGISTERED_USERS_KEY, [userId]); + await this.redis.sadd(REGISTERED_USERS_KEY, [userId]); } public async isUserRegistered(userId: string): Promise { @@ -106,7 +112,7 @@ export class RedisStorageProvider extends RedisStorageContextualProvider impleme } public async setTransactionCompleted(transactionId: string) { - this.redis.sadd(COMPLETED_TRANSACTIONS_KEY, [transactionId]); + await this.redis.sadd(COMPLETED_TRANSACTIONS_KEY, [transactionId]); } public async isTransactionCompleted(transactionId: string): Promise { @@ -239,4 +245,53 @@ export class RedisStorageProvider extends RedisStorageContextualProvider impleme } return guids.filter((_guid, index) => res[index][1] !== null); } + + public async storeHoundActivity(challengeId: string, ...activityHashes: string[]): Promise { + if (activityHashes.length === 0) { + return; + } + const key = `${HOUND_GUIDS}${challengeId}`; + await this.redis.lpush(key, ...activityHashes); + await this.redis.ltrim(key, 0, MAX_FEED_ITEMS); + } + + public async hasSeenHoundChallenge(challengeId: string): Promise { + const key = `${HOUND_GUIDS}${challengeId}`; + return (await this.redis.exists(key)) === 1; + } + + public async hasSeenHoundActivity(challengeId: string, ...activityHashes: string[]): Promise { + let multi = this.redis.multi(); + const key = `${HOUND_GUIDS}${challengeId}`; + + for (const guid of activityHashes) { + multi = multi.lpos(key, guid); + } + const res = await multi.exec(); + if (res === null) { + // Just assume we've seen none. + return []; + } + return activityHashes.filter((_guid, index) => res[index][1] !== null); + } + + public async storeHoundActivityEvent(challengeId: string, activityId: string, eventId: string): Promise { + const key = `${HOUND_EVENTS}${challengeId}.${activityId}`; + await this.redis.set(key, eventId); + this.redis.expire(key, HOUND_EVENT_CACHE).catch((ex) => { + log.warn(`Failed to set expiry time on ${key}`, ex); + }); + } + + public async getHoundActivity(challengeId: string, activityId: string): Promise { + return this.redis.get(`${HOUND_EVENTS}${challengeId}.${activityId}`); + } + + public async getHasGenericHookWarnedExpiry(hookId: string): Promise { + return await this.redis.sismember(GENERIC_HOOK_HAS_WARNED, hookId) === 1; + } + + public async setHasGenericHookWarnedExpiry(hookId: string, hasWarned: boolean): Promise { + await this.redis[hasWarned ? "sadd" : "srem"](GENERIC_HOOK_HAS_WARNED, hookId); + } } diff --git a/src/Stores/StorageProvider.ts b/src/Stores/StorageProvider.ts index 50175d75e..0ec10dddd 100644 --- a/src/Stores/StorageProvider.ts +++ b/src/Stores/StorageProvider.ts @@ -9,6 +9,8 @@ import { SerializedGitlabDiscussionThreads } from "../Gitlab/Types"; // seen from this feed, up to a max of 10,000. // Adopted from https://github.com/matrix-org/go-neb/blob/babb74fa729882d7265ff507b09080e732d060ae/services/rssbot/rssbot.go#L304 export const MAX_FEED_ITEMS = 10_000; +export const MAX_HOUND_ITEMS = 100; + export interface IBridgeStorageProvider extends IAppserviceStorageProvider, IStorageProvider, ProvisioningStore { connect?(): Promise; @@ -28,4 +30,13 @@ export interface IBridgeStorageProvider extends IAppserviceStorageProvider, ISto storeFeedGuids(url: string, ...guids: string[]): Promise; hasSeenFeed(url: string): Promise; hasSeenFeedGuids(url: string, ...guids: string[]): Promise; + + storeHoundActivity(challengeId: string, ...activityHashes: string[]): Promise; + hasSeenHoundChallenge(challengeId: string): Promise; + hasSeenHoundActivity(challengeId: string, ...activityHashes: string[]): Promise; + storeHoundActivityEvent(challengeId: string, activityId: string, eventId: string): Promise; + getHoundActivity(challengeId: string, activityId: string): Promise; + + getHasGenericHookWarnedExpiry(hookId: string): Promise; + setHasGenericHookWarnedExpiry(hookId: string, hasWarned: boolean): Promise; } \ No newline at end of file diff --git a/src/Webhooks.ts b/src/Webhooks.ts index 4597f7ca0..863d5ed12 100644 --- a/src/Webhooks.ts +++ b/src/Webhooks.ts @@ -8,7 +8,7 @@ import { ApiError, ErrCode, Logger } from "matrix-appservice-bridge"; import qs from "querystring"; import axios from "axios"; import { IGitLabWebhookEvent, IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookReleaseEvent } from "./Gitlab/WebhookTypes"; -import { EmitterWebhookEvent, EmitterWebhookEventName, Webhooks as OctokitWebhooks } from "@octokit/webhooks" +import { EmitterWebhookEvent, Webhooks as OctokitWebhooks } from "@octokit/webhooks" import { IJiraWebhookEvent } from "./jira/WebhookTypes"; import { JiraWebhooksRouter } from "./jira/Router"; import { OAuthRequest } from "./WebhookTypes"; @@ -18,6 +18,7 @@ import { FigmaWebhooksRouter } from "./figma/router"; import { GenericWebhooksRouter } from "./generic/Router"; import { GithubInstance } from "./github/GithubInstance"; import QuickLRU from "@alloc/quick-lru"; +import type { WebhookEventName } from "@octokit/webhooks-types"; const log = new Logger("Webhooks"); @@ -178,7 +179,7 @@ export class Webhooks extends EventEmitter { } this.ghWebhooks.verifyAndReceive({ id: githubGuid as string, - name: req.headers["x-github-event"] as EmitterWebhookEventName, + name: req.headers["x-github-event"] as WebhookEventName, payload: githubData.payload, signature: githubData.signature, }).catch((err) => { diff --git a/src/Widgets/BridgeWidgetApi.ts b/src/Widgets/BridgeWidgetApi.ts index 385557890..f17d62903 100644 --- a/src/Widgets/BridgeWidgetApi.ts +++ b/src/Widgets/BridgeWidgetApi.ts @@ -11,7 +11,7 @@ import BotUsersManager, {BotUser} from "../Managers/BotUsersManager"; import { assertUserPermissionsInRoom, GetConnectionsResponseItem } from "../provisioning/api"; import { Appservice, PowerLevelsEvent } from "matrix-bot-sdk"; import { GithubInstance } from '../github/GithubInstance'; -import { AllowedTokenTypes, TokenType, UserTokenStore } from '../UserTokenStore'; +import { AllowedTokenTypes, TokenType, UserTokenStore } from '../tokens/UserTokenStore'; const log = new Logger("BridgeWidgetApi"); @@ -100,14 +100,15 @@ export class BridgeWidgetApi extends ProvisioningApi { general: true, github: !!this.config.github, gitlab: !!this.config.gitlab, - generic: !!this.config.generic, + generic: !!this.config.generic?.enabled, + genericOutbound: !!this.config.generic?.outbound, jira: !!this.config.jira, figma: !!this.config.figma, feeds: !!this.config.feeds?.enabled, }); } - private async getServiceConfig(req: ProvisioningRequest, res: Response>) { + private async getServiceConfig(req: ProvisioningRequest, res: Response) { // GitHub is a special case because it depends on live config. if (req.params.service === 'github') { res.send(this.config.github?.publicConfig(this.github)); diff --git a/src/appservice.ts b/src/appservice.ts index 2bd97742d..7d380b1a2 100644 --- a/src/appservice.ts +++ b/src/appservice.ts @@ -9,9 +9,9 @@ const log = new Logger("Appservice"); export function getAppservice(config: BridgeConfig, registration: IAppserviceRegistration) { let storage: IBridgeStorageProvider; - if (config.queue.host && config.queue.port) { - log.info(`Initialising Redis storage (on ${config.queue.host}:${config.queue.port})`); - storage = new RedisStorageProvider(config.queue.host, config.queue.port); + if (config.cache) { + log.info(`Initialising Redis storage`); + storage = new RedisStorageProvider(config.cache); } else { log.info('Initialising memory storage'); storage = new MemoryStorageProvider(); @@ -45,7 +45,7 @@ export function getAppservice(config: BridgeConfig, registration: IAppserviceReg }, storage: storage, intentOptions: { - encryption: !!config.encryption, + encryption: !!cryptoStorage, }, cryptoStorage: cryptoStorage, }); diff --git a/src/config/Config.ts b/src/config/Config.ts index 374c295ab..c2aa5abdc 100644 --- a/src/config/Config.ts +++ b/src/config/Config.ts @@ -1,3 +1,5 @@ +/* eslint-disable no-console */ + import YAML from "yaml"; import { promises as fs } from "fs"; import { IAppserviceRegistration, LogLevel, MatrixClient } from "matrix-bot-sdk"; @@ -10,9 +12,14 @@ import { ConfigError } from "../errors"; import { ApiError, ErrCode } from "../api"; import { GithubInstance, GITHUB_CLOUD_URL } from "../github/GithubInstance"; import { Logger } from "matrix-appservice-bridge"; +import { BridgeConfigCache } from "./sections/cache"; +import { BridgeConfigGenericWebhooks, BridgeConfigQueue, BridgeGenericWebhooksConfigYAML } from "./sections"; +import { GenericHookServiceConfig } from "../Connections"; +import { BridgeConfigEncryption } from "./sections/encryption"; const log = new Logger("Config"); + function makePrefixedUrl(urlString: string): URL { return new URL(urlString.endsWith("/") ? urlString : urlString + "/"); } @@ -44,11 +51,8 @@ interface BridgeConfigGitHubYAML { secret: string; }; oauth?: { - // eslint-disable-next-line camelcase client_id: string; - // eslint-disable-next-line camelcase client_secret: string; - // eslint-disable-next-line camelcase redirect_uri: string; }; defaultOptions?: GitHubRepoConnectionOptions; @@ -67,11 +71,8 @@ export class BridgeConfigGitHub { }; @configKey("Settings for allowing users to sign in via OAuth.", true) readonly oauth?: { - // eslint-disable-next-line camelcase client_id: string; - // eslint-disable-next-line camelcase client_secret: string; - // eslint-disable-next-line camelcase redirect_uri: string; }; @configKey("Default options for GitHub connections.", true) @@ -104,18 +105,14 @@ export class BridgeConfigGitHub { } export interface BridgeConfigJiraCloudOAuth { - // eslint-disable-next-line camelcase client_id: string; - // eslint-disable-next-line camelcase client_secret: string; - // eslint-disable-next-line camelcase redirect_uri: string; } export interface BridgeConfigJiraOnPremOAuth { consumerKey: string; privateKey: string; - // eslint-disable-next-line camelcase redirect_uri: string; } @@ -179,11 +176,6 @@ export class BridgeConfigJira implements BridgeConfigJiraYAML { export interface GitLabInstance { url: string; - // oauth: { - // client_id: string; - // client_secret: string; - // redirect_uri: string; - // }; } export interface BridgeConfigGitLabYAML { @@ -286,52 +278,6 @@ export interface BridgeConfigFigma { }}; } -export interface BridgeGenericWebhooksConfigYAML { - enabled: boolean; - urlPrefix: string; - userIdPrefix?: string; - allowJsTransformationFunctions?: boolean; - waitForComplete?: boolean; - enableHttpGet?: boolean; -} - -export class BridgeConfigGenericWebhooks { - public readonly enabled: boolean; - - @hideKey() - public readonly parsedUrlPrefix: URL; - public readonly urlPrefix: () => string; - - public readonly userIdPrefix?: string; - public readonly allowJsTransformationFunctions?: boolean; - public readonly waitForComplete?: boolean; - public readonly enableHttpGet: boolean; - constructor(yaml: BridgeGenericWebhooksConfigYAML) { - this.enabled = yaml.enabled || false; - this.enableHttpGet = yaml.enableHttpGet || false; - try { - this.parsedUrlPrefix = makePrefixedUrl(yaml.urlPrefix); - this.urlPrefix = () => { return this.parsedUrlPrefix.href; } - } catch (err) { - throw new ConfigError("generic.urlPrefix", "is not defined or not a valid URL"); - } - this.userIdPrefix = yaml.userIdPrefix; - this.allowJsTransformationFunctions = yaml.allowJsTransformationFunctions; - this.waitForComplete = yaml.waitForComplete; - } - - @hideKey() - public get publicConfig() { - return { - userIdPrefix: this.userIdPrefix, - allowJsTransformationFunctions: this.allowJsTransformationFunctions, - waitForComplete: this.waitForComplete, - } - } - -} - - interface BridgeWidgetConfigYAML { publicUrl: string; /** @@ -398,8 +344,6 @@ interface BridgeConfigBridge { mediaUrl?: string; port: number; bindAddress: string; - // Removed - pantalaimon?: never; } interface BridgeConfigWebhook { @@ -407,12 +351,6 @@ interface BridgeConfigWebhook { bindAddress?: string; } -export interface BridgeConfigQueue { - monolithic: boolean; - port?: number; - host?: string; -} - export interface BridgeConfigLogging { level: "debug"|"info"|"warn"|"error"|"trace"; json?: boolean; @@ -424,10 +362,7 @@ interface BridgeConfigBot { displayname?: string; avatar?: string; } -interface BridgeConfigEncryption { - storagePath: string; - useLegacySledStore: boolean; -} + export interface BridgeConfigServiceBot { localpart: string; @@ -454,40 +389,52 @@ export interface BridgeConfigSentry { environment?: string; } +export interface BridgeConfigChallengeHound { + token?: string; +} + + export interface BridgeConfigRoot { bot?: BridgeConfigBot; - serviceBots?: BridgeConfigServiceBot[]; bridge: BridgeConfigBridge; - experimentalEncryption?: BridgeConfigEncryption; - figma?: BridgeConfigFigma; + cache?: BridgeConfigCache; + /** + * @deprecated Old, unsupported encryption propety. + */ + experimentalEncryption?: never; + encryption?: BridgeConfigEncryption; feeds?: BridgeConfigFeedsYAML; + figma?: BridgeConfigFigma; generic?: BridgeGenericWebhooksConfigYAML; github?: BridgeConfigGitHubYAML; gitlab?: BridgeConfigGitLabYAML; - permissions?: BridgeConfigActorPermission[]; - provisioning?: BridgeConfigProvisioning; jira?: BridgeConfigJiraYAML; + listeners?: BridgeConfigListener[]; logging: BridgeConfigLogging; + metrics?: BridgeConfigMetrics; passFile: string; - queue: BridgeConfigQueue; + permissions?: BridgeConfigActorPermission[]; + provisioning?: BridgeConfigProvisioning; + queue?: BridgeConfigQueue; + sentry?: BridgeConfigSentry; + serviceBots?: BridgeConfigServiceBot[]; webhook?: BridgeConfigWebhook; widgets?: BridgeWidgetConfigYAML; - metrics?: BridgeConfigMetrics; - listeners?: BridgeConfigListener[]; - sentry?: BridgeConfigSentry; + challengeHound?: BridgeConfigChallengeHound; } export class BridgeConfig { @configKey("Basic homeserver configuration") public readonly bridge: BridgeConfigBridge; + @configKey(`Cache options for large scale deployments. + For encryption to work, this must be configured.`, true) + public readonly cache?: BridgeConfigCache; @configKey(`Configuration for encryption support in the bridge. - If omitted, encryption support will be disabled. - This feature is HIGHLY EXPERIMENTAL AND SUBJECT TO CHANGE. - For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594.`, true) + If omitted, encryption support will be disabled.`, true) public readonly encryption?: BridgeConfigEncryption; - @configKey(`Message queue / cache configuration options for large scale deployments. - For encryption to work, must be set to monolithic mode and have a host & port specified.`, true) - public readonly queue: BridgeConfigQueue; + @configKey(`Message queue configuration options for large scale deployments. + For encryption to work, this must not be configured.`, true) + public readonly queue?: Omit; @configKey("Logging settings. You can have a severity debug,info,warn,error") public readonly logging: BridgeConfigLogging; @configKey(`Permissions for using the bridge. See docs/setup.md#permissions for help`, true) @@ -509,6 +456,8 @@ export class BridgeConfig { public readonly figma?: BridgeConfigFigma; @configKey("Configure this to enable RSS/Atom feed support", true) public readonly feeds?: BridgeConfigFeeds; + @configKey("Configure Challenge Hound support", true) + public readonly challengeHound?: BridgeConfigChallengeHound; @configKey("Define profile information for the bot user", true) public readonly bot?: BridgeConfigBot; @configKey("Define additional bot users for specific services", true) @@ -533,7 +482,12 @@ export class BridgeConfig { @hideKey() private readonly bridgePermissions: BridgePermissions; + + constructor(configData: BridgeConfigRoot, env?: {[key: string]: string|undefined}) { + this.logging = configData.logging || { + level: "info", + } this.bridge = configData.bridge; assert.ok(this.bridge); this.github = configData.github && new BridgeConfigGitHub(configData.github); @@ -549,20 +503,44 @@ export class BridgeConfig { this.generic = configData.generic && new BridgeConfigGenericWebhooks(configData.generic); this.feeds = configData.feeds && new BridgeConfigFeeds(configData.feeds); this.provisioning = configData.provisioning; - this.passFile = configData.passFile; + this.passFile = configData.passFile ?? "./passkey.pem"; this.bot = configData.bot; this.serviceBots = configData.serviceBots; this.metrics = configData.metrics; - this.queue = configData.queue || { - monolithic: true, - }; - this.encryption = configData.experimentalEncryption; + this.challengeHound = configData.challengeHound; + + // TODO: Formalize env support + if (env?.CFG_QUEUE_MONOLITHIC && ["false", "off", "no"].includes(env.CFG_QUEUE_MONOLITHIC)) { + if (!env?.CFG_QUEUE_HOST) { + throw new ConfigError("env:CFG_QUEUE_HOST", "CFG_QUEUE_MONOLITHIC was defined but host was not"); + } + configData.queue = { + monolithic: false, + host: env?.CFG_QUEUE_HOST, + port: env?.CFG_QUEUE_POST ? parseInt(env?.CFG_QUEUE_POST, 10) : undefined, + } + } + this.cache = configData.cache; + this.queue = configData.queue; - this.logging = configData.logging || { - level: "info", + if (configData.queue?.monolithic !== undefined) { + log.warn("The `queue.monolithic` config option is deprecated. Instead, configure the `cache` section."); + this.cache = { + redisUri: 'redisUri' in configData.queue ? configData.queue.redisUri + : `redis://${configData.queue.host ?? 'localhost'}:${configData.queue.port ?? 6379}` + }; + // If monolithic, disable the redis queue. + if (configData.queue.monolithic === true) { + this.queue = undefined; + } + } + + if (configData.experimentalEncryption) { + throw new ConfigError("experimentalEncryption", `This key is now called 'encryption'. Please adjust your config file.`) } + this.encryption = configData.encryption && new BridgeConfigEncryption(configData.encryption, this.cache, this.queue); this.widgets = configData.widgets && new BridgeWidgetConfig(configData.widgets); this.sentry = configData.sentry; @@ -589,13 +567,6 @@ export class BridgeConfig { throw Error("Config is not valid: At least one of GitHub, GitLab, JIRA, Figma, feeds or generic hooks must be configured"); } - // TODO: Formalize env support - if (env?.CFG_QUEUE_MONOLITHIC && ["false", "off", "no"].includes(env.CFG_QUEUE_MONOLITHIC)) { - this.queue.monolithic = false; - this.queue.host = env?.CFG_QUEUE_HOST; - this.queue.port = env?.CFG_QUEUE_POST ? parseInt(env?.CFG_QUEUE_POST, 10) : undefined; - } - if ('goNebMigrator' in configData) { log.warn(`The GoNEB migrator has been removed from this release. You should remove the 'goNebMigrator' from your config.`); } @@ -655,37 +626,6 @@ export class BridgeConfig { log.warn("The `widgets.openIdOverrides` config value SHOULD NOT be used in a production environment.") } - if (this.bridge.pantalaimon) { - throw new ConfigError("bridge.pantalaimon", "Pantalaimon support has been removed. Encrypted bridges should now use the `experimentalEncryption` config option"); - } - - if (this.encryption) { - log.warn(` -You have enabled encryption support in the bridge. This feature is HIGHLY EXPERIMENTAL AND SUBJECT TO CHANGE. -For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594. - `); - - if (!this.encryption.storagePath) { - throw new ConfigError("experimentalEncryption.storagePath", "The crypto storage path must not be empty."); - } - - if (this.encryption.useLegacySledStore) { - throw new ConfigError( - "experimentalEncryption.useLegacySledStore", ` -The Sled crypto store format is no longer supported. -Please back up your crypto store at ${this.encryption.storagePath}, -remove "useLegacySledStore" from your configuration file, and restart Hookshot. - `); - } - if (!this.queue.monolithic) { - throw new ConfigError("queue.monolithic", "Encryption is not supported in worker mode yet."); - } - - if (!this.queue.port) { - throw new ConfigError("queue.port", "You must enable redis support for encryption to work."); - } - } - if (this.figma?.overrideUserId) { log.warn("The `figma.overrideUserId` config value is deprecated. A service bot should be configured instead."); } @@ -727,6 +667,9 @@ remove "useLegacySledStore" from your configuration file, and restart Hookshot. } if (this.generic && this.generic.enabled) { services.push("generic"); + if (this.generic.outbound) { + services.push("genericOutbound"); + } } if (this.github) { services.push("github"); @@ -737,11 +680,14 @@ remove "useLegacySledStore" from your configuration file, and restart Hookshot. if (this.jira) { services.push("jira"); } + if (this.challengeHound) { + services.push("challengehound"); + } return services; } - public getPublicConfigForService(serviceName: string): Record { - let config: undefined|Record; + public getPublicConfigForService(serviceName: string): Record|GenericHookServiceConfig { + let config: undefined|Record|GenericHookServiceConfig; switch (serviceName) { case "feeds": config = this.feeds?.publicConfig; @@ -755,6 +701,7 @@ remove "useLegacySledStore" from your configuration file, and restart Hookshot. case "gitlab": config = this.gitlab?.publicConfig; break; + case "genericOutbound": case "jira": config = {}; break; @@ -784,11 +731,9 @@ export async function parseRegistrationFile(filename: string) { if (require.main === module) { Logger.configure({console: "info"}); BridgeConfig.parseConfig(process.argv[2] || "config.yml", process.env).then(() => { - // eslint-disable-next-line no-console console.log('Config successfully validated.'); process.exit(0); }).catch(ex => { - // eslint-disable-next-line no-console console.error('Error in config:', ex); process.exit(1); }); diff --git a/src/config/Defaults.ts b/src/config/Defaults.ts index 6ce83664b..fa511f953 100644 --- a/src/config/Defaults.ts +++ b/src/config/Defaults.ts @@ -1,3 +1,5 @@ +/* eslint-disable no-console */ + import { BridgeConfig, BridgeConfigRoot } from "./Config"; import { getConfigKeyMetadata, keyIsHidden } from "./Decorators"; import { Node, YAMLSeq, default as YAML } from "yaml"; @@ -15,10 +17,8 @@ export const DefaultConfigRoot: BridgeConfigRoot = { port: 9993, bindAddress: "127.0.0.1", }, - queue: { - monolithic: true, - port: 6379, - host: "localhost", + cache: { + redisUri: "redis://localhost:6379", }, logging: { level: "info", @@ -33,7 +33,7 @@ export const DefaultConfigRoot: BridgeConfigRoot = { level: "admin" }], }], - passFile: "passkey.pem", + passFile: "./passkey.pem", widgets: { publicUrl: `${hookshotWebhooksUrl}/widgetapi/v1/static`, addToAdminRooms: false, @@ -66,7 +66,7 @@ export const DefaultConfigRoot: BridgeConfigRoot = { oauth: { client_id: "foo", client_secret: "bar", - redirect_uri: `${hookshotWebhooksUrl}/bridge_oauth/`, + redirect_uri: `${hookshotWebhooksUrl}/oauth/`, }, webhook: { secret: "secrettoken", @@ -98,7 +98,7 @@ export const DefaultConfigRoot: BridgeConfigRoot = { oauth: { client_id: "foo", client_secret: "bar", - redirect_uri: `${hookshotWebhooksUrl}/bridge_oauth/`, + redirect_uri: `${hookshotWebhooksUrl}/oauth/`, }, }, generic: { @@ -108,6 +108,8 @@ export const DefaultConfigRoot: BridgeConfigRoot = { urlPrefix: `${hookshotWebhooksUrl}/webhook/`, userIdPrefix: "_webhooks_", waitForComplete: false, + maxExpiryTime: "30d", + sendExpiryNotice: false, }, figma: { publicUrl: `${hookshotWebhooksUrl}/hookshot/`, @@ -151,6 +153,9 @@ export const DefaultConfigRoot: BridgeConfigRoot = { sentry: { dsn: "https://examplePublicKey@o0.ingest.sentry.io/0", environment: "production" + }, + encryption: { + storagePath: "./cryptostore" } }; @@ -243,19 +248,16 @@ async function renderRegistrationFile(configPath?: string) { rooms: [], }, }; - // eslint-disable-next-line no-console + console.log(YAML.stringify(obj)); } - // Can be called directly if (require.main === module) { if (process.argv[2] === '--config') { - // eslint-disable-next-line no-console console.log(renderDefaultConfig()); } else if (process.argv[2] === '--registration') { renderRegistrationFile(process.argv[3]).catch(ex => { - // eslint-disable-next-line no-console console.error(ex); process.exit(1); }); diff --git a/src/config/permissions.rs b/src/config/permissions.rs index 94cfa9873..63997a191 100644 --- a/src/config/permissions.rs +++ b/src/config/permissions.rs @@ -113,13 +113,10 @@ impl BridgePermissions { continue; } for actor_service in actor_permission.services.iter() { - match &actor_service.service { - Some(actor_service_service) => { - if actor_service_service != &service && actor_service_service != "*" { - continue; - } + if let Some(actor_service_service) = &actor_service.service { + if actor_service_service != &service && actor_service_service != "*" { + continue; } - None => {} } if permission_level_to_int(actor_service.level.clone())? >= permission_int { return Ok(true); diff --git a/src/config/sections/cache.ts b/src/config/sections/cache.ts new file mode 100644 index 000000000..2da69c735 --- /dev/null +++ b/src/config/sections/cache.ts @@ -0,0 +1,7 @@ +export interface BridgeConfigCache { + /** + * A redis URI string + * @example `redis://user:password@host:port/dbnum` + */ + redisUri: string; +} \ No newline at end of file diff --git a/src/config/sections/encryption.ts b/src/config/sections/encryption.ts new file mode 100644 index 000000000..1dc96d167 --- /dev/null +++ b/src/config/sections/encryption.ts @@ -0,0 +1,27 @@ +import { ConfigError } from "../../errors"; +import { configKey } from "../Decorators"; +import { BridgeConfigCache } from "./cache"; +import { BridgeConfigQueue } from "./queue"; + +interface BridgeConfigEncryptionYAML { + storagePath: string; +} + +export class BridgeConfigEncryption { + @configKey("Path to the directory used to store encryption files. These files must be persist between restarts of the service.") + public readonly storagePath: string; + + constructor(config: BridgeConfigEncryptionYAML, cache: unknown|undefined, queue: unknown|undefined) { + if (typeof config.storagePath !== "string" || !config.storagePath) { + throw new ConfigError("encryption.storagePath", "The crypto storage path must not be empty."); + } + this.storagePath = config.storagePath; + + if (!cache) { + throw new ConfigError("cache", "Encryption requires the Redis cache to be enabled."); + } + if (queue) { + throw new ConfigError("queue", "Encryption does not support message queues."); + } + } +} diff --git a/src/config/sections/generichooks.ts b/src/config/sections/generichooks.ts new file mode 100644 index 000000000..f78d62ce3 --- /dev/null +++ b/src/config/sections/generichooks.ts @@ -0,0 +1,74 @@ +import { GenericHookServiceConfig } from "../../Connections"; +import { ConfigError } from "../../errors"; +import { hideKey } from "../Decorators"; +import parseDuration from "parse-duration"; + +function makePrefixedUrl(urlString: string): URL { + return new URL(urlString.endsWith("/") ? urlString : urlString + "/"); +} + +export interface BridgeGenericWebhooksConfigYAML { + enabled: boolean; + urlPrefix: string; + userIdPrefix?: string; + allowJsTransformationFunctions?: boolean; + waitForComplete?: boolean; + enableHttpGet?: boolean; + outbound?: boolean; + disallowedIpRanges?: string[]; + maxExpiryTime?: string; + sendExpiryNotice?: boolean; + requireExpiryTime?: boolean; +} + +export class BridgeConfigGenericWebhooks { + public readonly enabled: boolean; + public readonly outbound: boolean; + + @hideKey() + public readonly parsedUrlPrefix: URL; + public readonly urlPrefix: () => string; + + public readonly userIdPrefix?: string; + public readonly allowJsTransformationFunctions?: boolean; + public readonly waitForComplete?: boolean; + public readonly enableHttpGet: boolean; + + @hideKey() + public readonly maxExpiryTimeMs?: number; + public readonly sendExpiryNotice: boolean; + public readonly requireExpiryTime: boolean; + // Public facing value for config generator + public readonly maxExpiryTime?: string; + + constructor(yaml: BridgeGenericWebhooksConfigYAML) { + this.enabled = yaml.enabled || false; + this.outbound = yaml.outbound || false; + this.enableHttpGet = yaml.enableHttpGet || false; + this.sendExpiryNotice = yaml.sendExpiryNotice || false; + this.requireExpiryTime = yaml.requireExpiryTime || false; + try { + this.parsedUrlPrefix = makePrefixedUrl(yaml.urlPrefix); + this.urlPrefix = () => { return this.parsedUrlPrefix.href; } + } catch (err) { + throw new ConfigError("generic.urlPrefix", "is not defined or not a valid URL"); + } + this.userIdPrefix = yaml.userIdPrefix; + this.allowJsTransformationFunctions = yaml.allowJsTransformationFunctions; + this.waitForComplete = yaml.waitForComplete; + this.maxExpiryTimeMs = yaml.maxExpiryTime ? parseDuration(yaml.maxExpiryTime) : undefined; + this.maxExpiryTime = yaml.maxExpiryTime; + } + + @hideKey() + public get publicConfig(): GenericHookServiceConfig { + return { + userIdPrefix: this.userIdPrefix, + allowJsTransformationFunctions: this.allowJsTransformationFunctions, + waitForComplete: this.waitForComplete, + maxExpiryTime: this.maxExpiryTimeMs, + requireExpiryTime: this.requireExpiryTime, + } + } + +} diff --git a/src/config/sections/index.ts b/src/config/sections/index.ts new file mode 100644 index 000000000..8a6b91e70 --- /dev/null +++ b/src/config/sections/index.ts @@ -0,0 +1,3 @@ +export * from "./cache"; +export * from "./queue"; +export * from "./generichooks"; \ No newline at end of file diff --git a/src/config/sections/queue.ts b/src/config/sections/queue.ts new file mode 100644 index 000000000..605450e7f --- /dev/null +++ b/src/config/sections/queue.ts @@ -0,0 +1,22 @@ +/** + * Configuration for the message queue. + */ +interface BridgeConfigQueueBase { + /** + * Controls whether the queue config is used just for the cache (monolithic), + * or the message queue as well. + * @deprecated Use the `cache` config instead to control this seperately. + */ + monolithic?: boolean; +} + +interface BridgeConfigQueueUri extends BridgeConfigQueueBase { + redisUri: string; +} + +interface BridgeConfigQueueLegacyOptions extends BridgeConfigQueueBase { + port?: number; + host?: string; +} + +export type BridgeConfigQueue = BridgeConfigQueueUri|BridgeConfigQueueLegacyOptions \ No newline at end of file diff --git a/src/feeds/parser.rs b/src/feeds/parser.rs index fe2f7b024..00520beb4 100644 --- a/src/feeds/parser.rs +++ b/src/feeds/parser.rs @@ -62,7 +62,7 @@ fn parse_channel_to_js_result(channel: &Channel) -> JsRssChannel { .and_then(|i| i.permalink.then(|| i.value.to_string())) }), id: item.guid().map(|f| f.value().to_string()), - id_is_permalink: item.guid().map_or(false, |f| f.is_permalink()), + id_is_permalink: item.guid().is_some_and(|f| f.is_permalink()), pubdate: item.pub_date().map(String::from), summary: item.description().map(String::from), author: item.author().map(String::from), @@ -118,7 +118,7 @@ fn parse_feed_to_js_result(feed: &Feed) -> JsRssChannel { link: item .links() .iter() - .find(|l| l.mime_type.as_ref().map_or(false, |t| t == "text/html")) + .find(|l| l.mime_type.as_ref().is_some_and(|t| t == "text/html")) .or_else(|| item.links().first()) .map(|f| f.href.clone()), id: Some(item.id.clone()), diff --git a/src/format_util.rs b/src/format_util.rs index 2146381c5..0c6123970 100644 --- a/src/format_util.rs +++ b/src/format_util.rs @@ -1,7 +1,7 @@ use crate::github::types::*; use crate::jira; use crate::jira::types::{JiraIssue, JiraIssueLight, JiraIssueMessageBody, JiraIssueSimpleItem}; -use contrast; +use contrast::contrast; use md5::{Digest, Md5}; use napi::bindgen_prelude::*; use napi_derive::napi; @@ -87,12 +87,11 @@ pub fn format_labels(array: Vec) -> Result(color_rgb, RGB::new(0, 0, 0)) > 4.5 { - "#000000" - } else { - "#FFFFFF" - }; + let contrast_color = if contrast::(color_rgb, RGB::new(0, 0, 0)) > 4.5 { + "#000000" + } else { + "#FFFFFF" + }; write!(html, " data-mx-color=\"{}\"", contrast_color).unwrap(); } if let Some(description) = label.description { @@ -175,9 +174,9 @@ pub fn hash_id(id: String) -> Result { #[napi(js_name = "sanitizeHtml")] pub fn hookshot_sanitize_html(html: String) -> String { - return sanitize_html( + sanitize_html( html.as_str(), HtmlSanitizerMode::Compat, RemoveReplyFallback::No, - ); + ) } diff --git a/src/generic/Router.ts b/src/generic/Router.ts index ebec626f0..6645afbdb 100644 --- a/src/generic/Router.ts +++ b/src/generic/Router.ts @@ -4,6 +4,8 @@ import { Logger } from "matrix-appservice-bridge"; import { ApiError, ErrCode } from "../api"; import { GenericWebhookEvent, GenericWebhookEventResult } from "./types"; import * as xml from "xml2js"; +import helmet from "helmet"; +import { StatusCodes } from "http-status-codes"; const WEBHOOK_RESPONSE_TIMEOUT = 5000; @@ -41,21 +43,21 @@ export class GenericWebhooksRouter { next(); return; } - res.status(404).send({ok: false, error: "Webhook not found"}); + res.status(StatusCodes.NOT_FOUND).send({ok: false, error: "Webhook not found"}); } else if (response.successful) { const body = response.response?.body ?? {ok: true}; if (response.response?.contentType) { res.contentType(response.response.contentType); } - res.status(response.response?.statusCode ?? 200).send(body); + res.status(response.response?.statusCode ?? StatusCodes.OK).send(body); } else if (response.successful === false) { - res.status(500).send({ok: false, error: "Failed to process webhook"}); + res.status(response.statusCode ?? StatusCodes.INTERNAL_SERVER_ERROR).send({ok: false, error: response.error || "Failed to process webhook"}); } else { - res.status(202).send({ok: true}); + res.status(StatusCodes.ACCEPTED).send({ok: true}); } }).catch((err) => { log.error(`Failed to emit payload: ${err}`); - res.status(500).send({ok: false, error: "Failed to handle webhook"}); + res.status(StatusCodes.INTERNAL_SERVER_ERROR).send({ok: false, error: "Failed to handle webhook"}); }); } @@ -83,6 +85,17 @@ export class GenericWebhooksRouter { const router = Router(); router.all( '/:hookId', + helmet({ + contentSecurityPolicy: { + useDefaults: true, + directives: { + defaultSrc: "'self'", + sandbox: '' + } + }, + xFrameOptions: { action: 'deny'}, + crossOriginResourcePolicy: { policy: 'same-site'} , + }), GenericWebhooksRouter.xmlHandler, express.urlencoded({ extended: false }), express.json(), diff --git a/src/generic/types.ts b/src/generic/types.ts index f028a0465..4011936de 100644 --- a/src/generic/types.ts +++ b/src/generic/types.ts @@ -5,8 +5,16 @@ export interface GenericWebhookEvent { hookId: string; } -export interface GenericWebhookEventResult { - successful?: boolean|null; +export type GenericWebhookEventResult = GenericWebhookEventResultSuccess | GenericWebhookEventResultFailure; + +export interface GenericWebhookEventResultSuccess { + successful: true|null; response?: WebhookResponse, notFound?: boolean; +} +export interface GenericWebhookEventResultFailure { + successful: false; + statusCode?: number; + error?: string; + notFound?: boolean; } \ No newline at end of file diff --git a/src/github/GithubInstance.ts b/src/github/GithubInstance.ts index e86c4322f..4a532750e 100644 --- a/src/github/GithubInstance.ts +++ b/src/github/GithubInstance.ts @@ -46,14 +46,14 @@ interface OAuthUrlParameters { export class GithubInstance { private internalOctokit!: Octokit; private readonly installationsCache = new Map(); - private internalAppSlug?: string; + private internalAppUrl?: string; constructor (private readonly appId: number|string, private readonly privateKey: string, private readonly baseUrl: URL) { this.appId = parseInt(appId as string, 10); } - public get appSlug() { - return this.internalAppSlug; + public get appUrl() { + return this.internalAppUrl; } public get appOctokit() { @@ -143,7 +143,10 @@ export class GithubInstance { }); const appDetails = await this.internalOctokit.apps.getAuthenticated(); - this.internalAppSlug = appDetails.data.slug; + if (!appDetails.data) { + throw Error("No information returned about GitHub App. Is your GitHub App configured correctly?"); + } + this.internalAppUrl = appDetails.data.html_url; let installPageSize = 100; let page = 1; @@ -186,12 +189,10 @@ export class GithubInstance { } public get newInstallationUrl() { - if (this.baseUrl.hostname === GITHUB_CLOUD_URL.hostname) { - // Cloud - return new URL(`/apps/${this.appSlug}/installations/new`, GITHUB_CLOUD_PUBLIC_URL); + if (!this.appUrl) { + throw Error('No configured app url, cannot get installation url'); } - // Enterprise (yes, i know right) - return new URL(`/github-apps/${this.appSlug}/installations/new`, this.baseUrl); + return new URL(this.appUrl); } public static generateOAuthUrl(baseUrl: URL, action: "authorize"|"access_token", params: OAuthUrlParameters) { diff --git a/src/github/GrantChecker.ts b/src/github/GrantChecker.ts index a1497f305..e3eb27eef 100644 --- a/src/github/GrantChecker.ts +++ b/src/github/GrantChecker.ts @@ -1,7 +1,7 @@ import { Appservice } from "matrix-bot-sdk"; import { GitHubRepoConnection } from "../Connections"; import { GrantChecker } from "../grants/GrantCheck"; -import { UserTokenStore } from "../UserTokenStore"; +import { UserTokenStore } from "../tokens/UserTokenStore"; import { Logger } from 'matrix-appservice-bridge'; const log = new Logger('GitHubGrantChecker'); diff --git a/src/github/Router.ts b/src/github/Router.ts index 94ba2ce9f..fa05c57be 100644 --- a/src/github/Router.ts +++ b/src/github/Router.ts @@ -1,7 +1,7 @@ import { Router, Request, Response, NextFunction } from "express"; import { BridgeConfigGitHub } from "../config/Config"; import { ApiError, ErrCode } from "../api"; -import { UserTokenStore } from "../UserTokenStore"; +import { UserTokenStore } from "../tokens/UserTokenStore"; import { Logger } from "matrix-appservice-bridge"; import { GithubInstance } from "./GithubInstance"; import { NAMELESS_ORG_PLACEHOLDER } from "./Types"; diff --git a/src/github/Types.ts b/src/github/Types.ts index 7f4b62fc2..f51dc2843 100644 --- a/src/github/Types.ts +++ b/src/github/Types.ts @@ -19,7 +19,6 @@ export type CreateInstallationAccessTokenDataType = Endpoints["POST /app/install export const NAMELESS_ORG_PLACEHOLDER = "No name"; -/* eslint-disable camelcase */ export interface GitHubUserNotification { id: string; reason: "assign"|"author"|"comment"|"invitation"|"manual"|"mention"|"review_requested"| diff --git a/src/hound/reader.ts b/src/hound/reader.ts new file mode 100644 index 000000000..378ce0763 --- /dev/null +++ b/src/hound/reader.ts @@ -0,0 +1,142 @@ +import axios from "axios"; +import { ConnectionManager } from "../ConnectionManager"; +import { HoundConnection, HoundPayload, HoundActivity } from "../Connections/HoundConnection"; +import { MessageQueue } from "../MessageQueue"; +import { IBridgeStorageProvider } from "../Stores/StorageProvider"; +import { BridgeConfigChallengeHound } from "../config/Config"; +import { Logger } from "matrix-appservice-bridge"; +import { hashId } from "../libRs"; + +const log = new Logger("HoundReader"); + +export class HoundReader { + private connections: HoundConnection[]; + private challengeIds: string[]; + private timeout?: NodeJS.Timeout; + private shouldRun = true; + private readonly houndClient: axios.AxiosInstance; + + get sleepingInterval() { + return 60000 / (this.challengeIds.length || 1); + } + + constructor( + config: BridgeConfigChallengeHound, + private readonly connectionManager: ConnectionManager, + private readonly queue: MessageQueue, + private readonly storage: IBridgeStorageProvider, + ) { + this.connections = this.connectionManager.getAllConnectionsOfType(HoundConnection); + this.challengeIds = this.connections.map(c => c.challengeId); + this.houndClient = axios.create({ + headers: { + 'Authorization': config.token, + } + }); + + connectionManager.on('new-connection', newConnection => { + if (!(newConnection instanceof HoundConnection)) { + return; + } + if (!this.challengeIds.includes(newConnection.challengeId)) { + log.info(`Connection added, adding "${newConnection.challengeId}" to queue`); + this.challengeIds.push(newConnection.challengeId); + } + }); + connectionManager.on('connection-removed', removed => { + if (!(removed instanceof HoundConnection)) { + return; + } + let shouldKeepUrl = false; + this.connections = this.connections.filter(c => { + // Cheeky reuse of iteration to determine if we should remove this URL. + if (c.connectionId !== removed.connectionId) { + shouldKeepUrl = shouldKeepUrl || c.challengeId === removed.challengeId; + return true; + } + return false; + }); + if (shouldKeepUrl) { + log.info(`Connection removed, but not removing "${removed.challengeId}" as it is still in use`); + return; + } + log.info(`Connection removed, removing "${removed.challengeId}" from queue`); + this.challengeIds = this.challengeIds.filter(u => u !== removed.challengeId) + }); + + log.debug('Loaded challenge IDs:', [...this.challengeIds].join(', ')); + void this.pollChallenges(); + } + + public stop() { + this.shouldRun = false; + if (this.timeout) { + clearTimeout(this.timeout); + } + } + + private static hashActivity(activity: HoundActivity) { + return hashId(activity.activityId + activity.name + activity.distanceKilometers + activity.durationSeconds + activity.elevationMeters); + } + + public async poll(challengeId: string) { + const resAct = await this.houndClient.get(`https://api.challengehound.com/v1/activities?challengeId=${challengeId}&size=10`); + const activites = (resAct.data["results"] as HoundActivity[]).map(a => ({...a, hash: HoundReader.hashActivity(a)})); + const seen = await this.storage.hasSeenHoundActivity(challengeId, ...activites.map(a => a.hash)); + + // Don't emit anything if our cache is empty, as we'll probably create duplicates. + const hasSeenChallenge = await this.storage.hasSeenHoundChallenge(challengeId); + if (hasSeenChallenge) { + for (const activity of activites) { + if (seen.includes(activity.hash)) { + continue; + } + this.queue.push({ + eventName: "hound.activity", + sender: "HoundReader", + data: { + challengeId, + activity: activity, + } + }); + } + } + // Ensure we don't add duplicates to the storage. + await this.storage.storeHoundActivity(challengeId, ...activites.filter(s => !seen.includes(s.hash)).map(a => a.hash)) + } + + public async pollChallenges(): Promise { + log.debug(`Checking for updates`); + + const fetchingStarted = Date.now(); + + const challengeId = this.challengeIds.pop(); + let sleepFor = this.sleepingInterval; + + if (challengeId) { + try { + await this.poll(challengeId); + const elapsed = Date.now() - fetchingStarted; + sleepFor = Math.max(this.sleepingInterval - elapsed, 0); + log.debug(`Activity fetching took ${elapsed / 1000}s, sleeping for ${sleepFor / 1000}s`); + + if (elapsed > this.sleepingInterval) { + log.warn(`It took us longer to update the activities than the expected interval`); + } + } catch (ex) { + log.warn("Failed to poll for challenge", ex); + } finally { + this.challengeIds.splice(0, 0, challengeId); + } + } else { + log.debug(`No activites available to poll`); + } + + this.timeout = setTimeout(() => { + if (!this.shouldRun) { + return; + } + void this.pollChallenges(); + }, sleepFor); + } +} \ No newline at end of file diff --git a/src/jira/GrantChecker.ts b/src/jira/GrantChecker.ts index 211fc8210..d2a4e2457 100644 --- a/src/jira/GrantChecker.ts +++ b/src/jira/GrantChecker.ts @@ -1,7 +1,7 @@ import { Appservice } from "matrix-bot-sdk"; import { JiraProjectConnection } from "../Connections"; import { GrantChecker } from "../grants/GrantCheck"; -import { UserTokenStore } from "../UserTokenStore"; +import { UserTokenStore } from "../tokens/UserTokenStore"; interface JiraGrantConnectionId{ url: string; @@ -26,7 +26,7 @@ export class JiraGrantChecker extends GrantChecker { try { await JiraProjectConnection.assertUserHasAccessToProject(this.tokenStore, sender, connectionId.url); return true; - } catch (ex) { + } catch { return false; } } diff --git a/src/jira/Router.ts b/src/jira/Router.ts index 2bd6815b9..7771e5f79 100644 --- a/src/jira/Router.ts +++ b/src/jira/Router.ts @@ -1,7 +1,7 @@ import { BridgeConfigJira } from "../config/Config"; import { MessageQueue } from "../MessageQueue"; import { Router, Request, Response, NextFunction, json } from "express"; -import { UserTokenStore } from "../UserTokenStore"; +import { UserTokenStore } from "../tokens/UserTokenStore"; import { Logger } from "matrix-appservice-bridge"; import { ApiError, ErrCode } from "../api"; import { JiraOAuthRequestCloud, JiraOAuthRequestOnPrem, JiraOAuthRequestResult } from "./OAuth"; diff --git a/src/jira/client/CloudClient.ts b/src/jira/client/CloudClient.ts index 4b8d86d2d..383cb825d 100644 --- a/src/jira/client/CloudClient.ts +++ b/src/jira/client/CloudClient.ts @@ -14,11 +14,6 @@ const ACCESSIBLE_RESOURCE_CACHE_TTL_MS = 60000; export class HookshotCloudJiraApi extends HookshotJiraApi { - constructor(options: JiraApi.JiraApiOptions, res: JiraAPIAccessibleResource) { - super(options, res); - } - - async getIssue(issueIdOrKey: string): Promise { return this.apiRequest(`/rest/api/3/issue/${issueIdOrKey}`); } @@ -90,7 +85,7 @@ export class JiraCloudClient implements JiraClient { if (existingPromise) { return await existingPromise; } - } catch (ex) { + } catch { // Existing failed promise, break out and try again. JiraCloudClient.resourceCache.delete(this.bearer); } diff --git a/src/lib.rs b/src/lib.rs index 3a15bd657..4e4e1bfe8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod feeds; pub mod format_util; pub mod github; pub mod jira; +pub mod tokens; pub mod util; #[macro_use] diff --git a/src/libRs.js b/src/libRs.js index 819a9c4eb..d473596fc 100644 --- a/src/libRs.js +++ b/src/libRs.js @@ -2,6 +2,11 @@ try { // In production, we expect it co-located module.exports = require('./matrix-hookshot-rs.node'); } catch (ex) { - // When running under ts-node, it may not be co-located. - module.exports = require('../lib/matrix-hookshot-rs.node'); + try { + // When running under ts-node, it may not be co-located. + module.exports = require('../lib/matrix-hookshot-rs.node'); + } catch (ex) { + // Or in a test environment. + module.exports = require('../../lib/matrix-hookshot-rs.node'); + } } diff --git a/src/UserTokenStore.ts b/src/tokens/UserTokenStore.ts similarity index 75% rename from src/UserTokenStore.ts rename to src/tokens/UserTokenStore.ts index afd6520d9..c02d590a9 100644 --- a/src/UserTokenStore.ts +++ b/src/tokens/UserTokenStore.ts @@ -1,22 +1,22 @@ -import { GithubInstance } from "./github/GithubInstance"; -import { GitLabClient } from "./Gitlab/Client"; +import { GithubInstance } from "../github/GithubInstance"; +import { GitLabClient } from "../Gitlab/Client"; import { Intent } from "matrix-bot-sdk"; import { promises as fs } from "fs"; -import { publicEncrypt, privateDecrypt } from "crypto"; import { Logger } from "matrix-appservice-bridge"; -import { isJiraCloudInstance, JiraClient } from "./jira/Client"; -import { JiraStoredToken } from "./jira/Types"; -import { BridgeConfig, BridgeConfigJira, BridgeConfigJiraOnPremOAuth, BridgePermissionLevel } from "./config/Config"; +import { isJiraCloudInstance, JiraClient } from "../jira/Client"; +import { JiraStoredToken } from "../jira/Types"; +import { BridgeConfig, BridgeConfigJira, BridgeConfigJiraOnPremOAuth, BridgePermissionLevel } from "../config/Config"; import { randomUUID } from 'node:crypto'; -import { GitHubOAuthToken } from "./github/Types"; -import { ApiError, ErrCode } from "./api"; -import { JiraOAuth } from "./jira/OAuth"; -import { JiraCloudOAuth } from "./jira/oauth/CloudOAuth"; -import { JiraOnPremOAuth } from "./jira/oauth/OnPremOAuth"; -import { JiraOnPremClient } from "./jira/client/OnPremClient"; -import { JiraCloudClient } from "./jira/client/CloudClient"; -import { TokenError, TokenErrorCode } from "./errors"; +import { GitHubOAuthToken } from "../github/Types"; +import { ApiError, ErrCode } from "../api"; +import { JiraOAuth } from "../jira/OAuth"; +import { JiraCloudOAuth } from "../jira/oauth/CloudOAuth"; +import { JiraOnPremOAuth } from "../jira/oauth/OnPremOAuth"; +import { JiraOnPremClient } from "../jira/client/OnPremClient"; +import { JiraCloudClient } from "../jira/client/CloudClient"; +import { TokenError, TokenErrorCode } from "../errors"; import { TypedEmitter } from "tiny-typed-emitter"; +import { hashId, TokenEncryption, stringToAlgo } from "../libRs"; const ACCOUNT_DATA_TYPE = "uk.half-shot.matrix-hookshot.github.password-store:"; const ACCOUNT_DATA_GITLAB_TYPE = "uk.half-shot.matrix-hookshot.gitlab.password-store:"; @@ -26,11 +26,13 @@ const LEGACY_ACCOUNT_DATA_TYPE = "uk.half-shot.matrix-github.password-store:"; const LEGACY_ACCOUNT_DATA_GITLAB_TYPE = "uk.half-shot.matrix-github.gitlab.password-store:"; const log = new Logger("UserTokenStore"); -export type TokenType = "github"|"gitlab"|"jira"; -export const AllowedTokenTypes = ["github", "gitlab", "jira"]; +export type TokenType = "github"|"gitlab"|"jira"|"generic"; +export const AllowedTokenTypes = ["github", "gitlab", "jira", "generic"]; interface StoredTokenData { encrypted: string|string[]; + keyId: string; + algorithm: 'rsa'|'rsa-pkcs1v15'; instance?: string; } @@ -51,20 +53,29 @@ function tokenKey(type: TokenType, userId: string, legacy = false, instanceUrl?: return `${legacy ? LEGACY_ACCOUNT_DATA_GITLAB_TYPE : ACCOUNT_DATA_GITLAB_TYPE}${instanceUrl}${userId}`; } -const MAX_TOKEN_PART_SIZE = 128; const OAUTH_TIMEOUT_MS = 1000 * 60 * 30; interface Emitter { onNewToken: (type: TokenType, userId: string, token: string, instanceUrl?: string) => void, } export class UserTokenStore extends TypedEmitter { - private key!: Buffer; + + public static async fromKeyPath(keyPath: string, intent: Intent, config: BridgeConfig) { + log.info(`Loading token key file ${keyPath}`); + const key = await fs.readFile(keyPath); + return new UserTokenStore(key, intent, config); + } + private oauthSessionStore: Map = new Map(); private userTokens: Map; public readonly jiraOAuth?: JiraOAuth; - constructor(private keyPath: string, private intent: Intent, private config: BridgeConfig) { + private tokenEncryption: TokenEncryption; + private readonly keyId: string; + constructor(key: Buffer, private readonly intent: Intent, private readonly config: BridgeConfig) { super(); + this.tokenEncryption = new TokenEncryption(key); this.userTokens = new Map(); + this.keyId = hashId(key.toString('utf-8')); if (config.jira?.oauth) { if ("client_id" in config.jira.oauth) { this.jiraOAuth = new JiraCloudOAuth(config.jira.oauth); @@ -76,11 +87,6 @@ export class UserTokenStore extends TypedEmitter { } } - public async load() { - log.info(`Loading token key file ${this.keyPath}`); - this.key = await fs.readFile(this.keyPath); - } - public stop() { for (const session of this.oauthSessionStore.values()) { clearTimeout(session.timeout); @@ -92,21 +98,16 @@ export class UserTokenStore extends TypedEmitter { throw new ApiError('User does not have permission to log in to service', ErrCode.ForbiddenUser); } const key = tokenKey(type, userId, false, instanceUrl); - const tokenParts: string[] = []; - let tokenSource = token; - while (tokenSource && tokenSource.length > 0) { - const part = tokenSource.slice(0, MAX_TOKEN_PART_SIZE); - tokenSource = tokenSource.substring(MAX_TOKEN_PART_SIZE); - tokenParts.push(publicEncrypt(this.key, Buffer.from(part)).toString("base64")); - } + const tokenParts: string[] = this.tokenEncryption.encrypt(token); const data: StoredTokenData = { encrypted: tokenParts, + keyId: this.keyId, + algorithm: "rsa-pkcs1v15", instance: instanceUrl, }; await this.intent.underlyingClient.setAccountData(key, data); this.userTokens.set(key, token); log.info(`Stored new ${type} token for ${userId}`); - log.debug(`Stored`, data); this.emit("onNewToken", type, userId, token, instanceUrl); } @@ -146,8 +147,16 @@ export class UserTokenStore extends TypedEmitter { if (!obj || "deleted" in obj) { return null; } + // For legacy we just assume it's the current configured key. + const algorithm = stringToAlgo(obj.algorithm ?? "rsa"); + const keyId = obj.keyId ?? this.keyId; + + if (keyId !== this.keyId) { + throw new Error(`Stored data was encrypted with a different key to the one currently configured`); + } + const encryptedParts = typeof obj.encrypted === "string" ? [obj.encrypted] : obj.encrypted; - const token = encryptedParts.map((t) => privateDecrypt(this.key, Buffer.from(t, "base64")).toString("utf-8")).join(""); + const token = this.tokenEncryption.decrypt(encryptedParts, algorithm); this.userTokens.set(key, token); return token; } catch (ex) { @@ -156,6 +165,37 @@ export class UserTokenStore extends TypedEmitter { return null; } + public async storeGenericToken(namespace: string, key: string, token: string) { + const finalTokenKey = `generic:${namespace}:${key}` + const tokenParts: string[] = this.tokenEncryption.encrypt(token); + const data: StoredTokenData = { + encrypted: tokenParts, + keyId: this.keyId, + algorithm: "rsa-pkcs1v15", + }; + await this.intent.underlyingClient.setAccountData(finalTokenKey, data); + log.debug(`Stored token ${namespace}`); + } + + public async getGenericToken(namespace: string, key: string): Promise { + const finalTokenKey = `generic:${namespace}:${key}` + const obj = await this.intent.underlyingClient.getSafeAccountData(finalTokenKey); + if (!obj || "deleted" in obj) { + return null; + } + // For legacy we just assume it's the current configured key. + const algorithm = stringToAlgo(obj.algorithm ?? "rsa"); + const keyId = obj.keyId ?? this.keyId; + + if (keyId !== this.keyId) { + throw new Error(`Stored data was encrypted with a different key to the one currently configured`); + } + + const encryptedParts = typeof obj.encrypted === "string" ? [obj.encrypted] : obj.encrypted; + const token = this.tokenEncryption.decrypt(encryptedParts, algorithm); + return token; + } + public static parseGitHubToken(token: string): GitHubOAuthToken { if (!token.startsWith('{')) { // Old style token diff --git a/src/tokens/mod.rs b/src/tokens/mod.rs new file mode 100644 index 000000000..56a59a4bf --- /dev/null +++ b/src/tokens/mod.rs @@ -0,0 +1,155 @@ +use std::string::FromUtf8Error; + +use base64ct::{Base64, Encoding}; +use napi::bindgen_prelude::Buffer; +use napi::Error; +use rsa::pkcs1::DecodeRsaPrivateKey; +use rsa::pkcs8::DecodePrivateKey; +use rsa::{Oaep, Pkcs1v15Encrypt, RsaPrivateKey, RsaPublicKey}; +use sha1::Sha1; + +static MAX_TOKEN_PART_SIZE: usize = 128; + +struct TokenEncryption { + pub private_key: RsaPrivateKey, + pub public_key: RsaPublicKey, +} + +#[derive(Debug)] +#[allow(dead_code)] +enum TokenEncryptionError { + FromUtf8(FromUtf8Error), + UnknownFormat, + PrivateKey8(rsa::pkcs8::Error), + PrivateKey1(rsa::pkcs1::Error), +} + +#[derive(Debug)] +#[allow(dead_code)] +enum DecryptError { + Base64(base64ct::Error), + Decryption(rsa::Error), + FromUtf8(FromUtf8Error), +} + +#[napi] +pub enum Algo { + RSAOAEP, + RSAPKCS1v15, +} + +#[napi] +pub fn string_to_algo(algo_str: String) -> Result { + match algo_str.as_str() { + "rsa" => Ok(Algo::RSAOAEP), + "rsa-pkcs1v15" => Ok(Algo::RSAPKCS1v15), + _ => Err(Error::new( + napi::Status::GenericFailure, + "Unknown algorithm", + )), + } +} + +impl TokenEncryption { + pub fn new(private_key_data: Vec) -> Result { + let data = String::from_utf8(private_key_data).map_err(TokenEncryptionError::FromUtf8)?; + let private_key: RsaPrivateKey; + if data.starts_with("-----BEGIN PRIVATE KEY-----") { + private_key = RsaPrivateKey::from_pkcs8_pem(data.as_str()) + .map_err(TokenEncryptionError::PrivateKey8)?; + } else if data.starts_with("-----BEGIN RSA PRIVATE KEY-----") { + private_key = RsaPrivateKey::from_pkcs1_pem(data.as_str()) + .map_err(TokenEncryptionError::PrivateKey1)?; + } else { + return Err(TokenEncryptionError::UnknownFormat); + } + let public_key = private_key.to_public_key(); + Ok(TokenEncryption { + private_key, + public_key, + }) + } +} + +#[napi(js_name = "TokenEncryption")] +pub struct JsTokenEncryption { + inner: TokenEncryption, +} + +#[napi] +impl JsTokenEncryption { + #[napi(constructor)] + pub fn new(private_key_data: Buffer) -> Result { + let buf: Vec = private_key_data.into(); + match TokenEncryption::new(buf) { + Ok(inner) => Ok(JsTokenEncryption { inner }), + Err(err) => Err(Error::new( + napi::Status::GenericFailure, + format!("Error reading private key: {:?}", err).to_string(), + )), + } + } + + #[napi] + pub fn decrypt(&self, parts: Vec, algo: Algo) -> Result { + let mut result = String::new(); + + for v in parts { + match self.decrypt_value(v, algo) { + Ok(new_value) => { + result += &new_value; + Ok(()) + } + Err(err) => Err(Error::new( + napi::Status::GenericFailure, + format!("Could not decrypt string: {:?}", err).to_string(), + )), + }? + } + Ok(result) + } + + fn decrypt_value(&self, value: String, algo: Algo) -> Result { + let raw_value = Base64::decode_vec(&value).map_err(DecryptError::Base64)?; + let decrypted_value = match algo { + Algo::RSAOAEP => { + let padding = Oaep::new::(); + self.inner + .private_key + .decrypt(padding, &raw_value) + .map_err(DecryptError::Decryption) + } + Algo::RSAPKCS1v15 => self + .inner + .private_key + .decrypt(Pkcs1v15Encrypt, &raw_value) + .map_err(DecryptError::Decryption), + }?; + let utf8_value = String::from_utf8(decrypted_value).map_err(DecryptError::FromUtf8)?; + Ok(utf8_value) + } + + #[napi] + pub fn encrypt(&self, input: String) -> Result, Error> { + let mut rng = rand::thread_rng(); + let mut parts: Vec = Vec::new(); + for part in input.into_bytes().chunks(MAX_TOKEN_PART_SIZE) { + match self + .inner + .public_key + .encrypt(&mut rng, Pkcs1v15Encrypt, part) + { + Ok(encrypted) => { + let b64 = Base64::encode_string(encrypted.as_slice()); + parts.push(b64); + Ok(()) + } + Err(err) => Err(Error::new( + napi::Status::GenericFailure, + format!("Could not encrypt string: {:?}", err).to_string(), + )), + }? + } + Ok(parts) + } +} diff --git a/tests/AdminRoomTest.ts b/tests/AdminRoomTest.ts index ee171e174..658c0cfcb 100644 --- a/tests/AdminRoomTest.ts +++ b/tests/AdminRoomTest.ts @@ -4,7 +4,7 @@ import { AdminRoom } from "../src/AdminRoom"; import { DefaultConfig } from "../src/config/Defaults"; import { ConnectionManager } from "../src/ConnectionManager"; import { NotifFilter } from "../src/NotificationFilters"; -import { UserTokenStore } from "../src/UserTokenStore"; +import { UserTokenStore } from "../src/tokens/UserTokenStore"; import { IntentMock } from "./utils/IntentMock"; const ROOM_ID = "!foo:bar"; @@ -14,9 +14,8 @@ function createAdminRoom(data: any = {admin_user: "@admin:bar"}): [AdminRoom, In if (!data.admin_user) { data.admin_user = "@admin:bar"; } - const tokenStore = new UserTokenStore("notapath", intent, DefaultConfig); - return [new AdminRoom(ROOM_ID, data, NotifFilter.getDefaultContent(), intent, tokenStore, DefaultConfig, {} as ConnectionManager), intent]; -} + return [new AdminRoom(ROOM_ID, data, NotifFilter.getDefaultContent(), intent, {} as UserTokenStore, DefaultConfig, {} as ConnectionManager), intent]; +} describe("AdminRoom", () => { it("will present help text", async () => { diff --git a/tests/FeedReader.spec.ts b/tests/FeedReader.spec.ts index 00f397450..cf8670cc7 100644 --- a/tests/FeedReader.spec.ts +++ b/tests/FeedReader.spec.ts @@ -71,6 +71,7 @@ async function constructFeedReader(feedResponse: () => {headers: Record httpServer.close()); return {config, cm, events, feedReader, feedUrl, httpServer, storage}; } @@ -100,6 +101,7 @@ describe("FeedReader", () => { expect(events[0].data.feed.title).to.equal(null); expect(events[0].data.title).to.equal(null); }); + it("should handle RSS 2.0 feeds", async () => { const { events, feedReader, feedUrl } = await constructFeedReader(() => ({ headers: {}, data: ` @@ -137,6 +139,7 @@ describe("FeedReader", () => { expect(events[0].data.link).to.equal('http://www.example.com/blog/post/1'); expect(events[0].data.pubdate).to.equal('Sun, 6 Sep 2009 16:20:00 +0000'); }); + it("should handle RSS feeds with a permalink url", async () => { const { events, feedReader, feedUrl } = await constructFeedReader(() => ({ headers: {}, data: ` @@ -173,6 +176,7 @@ describe("FeedReader", () => { expect(events[0].data.link).to.equal('http://www.example.com/blog/post/1'); expect(events[0].data.pubdate).to.equal('Sun, 6 Sep 2009 16:20:00 +0000'); }); + it("should handle Atom feeds", async () => { const { events, feedReader, feedUrl } = await constructFeedReader(() => ({ headers: {}, data: ` @@ -213,6 +217,7 @@ describe("FeedReader", () => { expect(events[0].data.link).to.equal('http://example.org/2003/12/13/atom03'); expect(events[0].data.pubdate).to.equal('Sat, 13 Dec 2003 18:30:02 +0000'); }); + it("should not duplicate feed entries", async () => { const { events, feedReader, feedUrl } = await constructFeedReader(() => ({ headers: {}, data: ` @@ -238,6 +243,7 @@ describe("FeedReader", () => { feedReader.stop(); expect(events).to.have.lengthOf(1); }); + it("should always hash to the same value for Atom feeds", async () => { const expectedHash = ['md5:d41d8cd98f00b204e9800998ecf8427e']; const { feedReader, feedUrl, storage } = await constructFeedReader(() => ({ @@ -258,6 +264,7 @@ describe("FeedReader", () => { const items = await storage.hasSeenFeedGuids(feedUrl, ...expectedHash); expect(items).to.deep.equal(expectedHash); }); + it("should always hash to the same value for RSS feeds", async () => { const expectedHash = [ 'md5:98bafde155b931e656ad7c137cd7711e', // guid diff --git a/tests/FormatUtilTest.ts b/tests/FormatUtilTest.ts index b8c35dc20..315f2ad3c 100644 --- a/tests/FormatUtilTest.ts +++ b/tests/FormatUtilTest.ts @@ -48,11 +48,13 @@ describe("FormatUtilTest", () => { "evilcorp/lab: A simple description", ); }); + it("should correctly formats a issue room name", () => { expect(FormatUtil.formatIssueRoomName(SIMPLE_ISSUE, SIMPLE_REPO)).to.equal( "evilcorp/lab#123: A simple title", ); }); + it("should correctly generate a partial body for a Github repo", () => { expect(FormatUtil.getPartialBodyForGithubRepo(SIMPLE_REPO)).to.deep.equal({ "external_url": "https://github.com/evilcorp/lab", @@ -63,6 +65,7 @@ describe("FormatUtilTest", () => { }, }); }); + it("should correctly generate a partial body for a Github issue", () => { expect(FormatUtil.getPartialBodyForGithubIssue(SIMPLE_REPO, SIMPLE_ISSUE)).to.deep.equal({ "external_url": "https://github.com/evilcorp/lab/issues/123", @@ -79,29 +82,34 @@ describe("FormatUtilTest", () => { }, }); }); + it("should correctly formats a room topic", () => { expect(FormatUtil.formatRoomTopic(SIMPLE_ISSUE)).to.equal( "Status: open | https://github.com/evilcorp/lab/issues/123", ); }); + it("should correctly format one simple label", () => { expect(FormatUtil.formatLabels([{name: "foo"}])).to.deep.equal({ plain: "foo", html: "foo" }); }); + it("should correctly format many simple labels", () => { expect(FormatUtil.formatLabels([{name: "foo"},{name: "bar"}])).to.deep.equal({ plain: "foo, bar", html: "foo bar" }); }); + it("should correctly format one detailed label", () => { expect(FormatUtil.formatLabels([{name: "foo", color: 'FFFFFF', description: 'My label'}])).to.deep.equal({ plain: "foo", html: "foo" }); }); + it("should correctly format many detailed labels", () => { expect(FormatUtil.formatLabels([ {name: "foo", color: 'FFFFFF', description: 'My label'}, @@ -112,6 +120,7 @@ describe("FormatUtilTest", () => { + "bar" },); }); + it("should correctly format a JIRA issue", () => { expect(FormatUtil.getPartialBodyForJiraIssue(SIMPLE_JIRA_ISSUE)).to.deep.equal({ "external_url": "http://example-api.url.com/browse/TEST-001", @@ -127,6 +136,7 @@ describe("FormatUtilTest", () => { }, }); }); + it("should hash an ID", () => { expect(FormatUtil.hashId("foobar")).to.equal('3858f62230ac3c915f300c664312c63f'); }); diff --git a/tests/HookFilter.ts b/tests/HookFilter.ts index f5e8821d1..16fa1423d 100644 --- a/tests/HookFilter.ts +++ b/tests/HookFilter.ts @@ -6,13 +6,16 @@ const ENABLED_SET = ['enabled-hook', 'enabled-but-ignored']; describe("HookFilter", () => { let filter: HookFilter; + beforeEach(() => { filter = new HookFilter(ENABLED_SET); }); + describe('shouldSkip', () => { it('should allow a hook named in enabled set', () => { expect(filter.shouldSkip('enabled-hook')).to.be.false; }); + it('should not allow a hook not named in enabled set', () => { expect(filter.shouldSkip('not-enabled-hook')).to.be.true; }); diff --git a/tests/IntentUtilsTest.ts b/tests/IntentUtilsTest.ts index 1fcae634f..b611cb240 100644 --- a/tests/IntentUtilsTest.ts +++ b/tests/IntentUtilsTest.ts @@ -25,7 +25,7 @@ describe("IntentUtils", () => { return; } expect(roomId).to.equal(ROOM_ID); - throw new MatrixError({ errcode: "M_FORBIDDEN", error: "Test forced error"}, 401) + throw new MatrixError({ errcode: "M_FORBIDDEN", error: "Test forced error"}, 401, {}) }; // This should invite the puppet user. @@ -40,13 +40,13 @@ describe("IntentUtils", () => { expect(hasInvited).to.be.true; }); - it("invites the user to the room and joins", () => { + it("invites the user to the room and handles the failure", () => { const targetIntent = IntentMock.create(SENDER_USER_ID); const matrixClient = MatrixClientMock.create(); // This should fail the first time, then pass once we've tried to invite the user targetIntent.ensureJoined = () => { - throw new MatrixError({ errcode: "FORCED_FAILURE", error: "Test forced error"}, 500) + throw new MatrixError({ errcode: "FORCED_FAILURE", error: "Test forced error"}, 500, { }) }; try { ensureUserIsInRoom(targetIntent, matrixClient, ROOM_ID); diff --git a/tests/MessageQueueTest.ts b/tests/MessageQueueTest.ts index a3fa0d63f..00974fb5c 100644 --- a/tests/MessageQueueTest.ts +++ b/tests/MessageQueueTest.ts @@ -1,9 +1,7 @@ import { expect } from "chai"; import { createMessageQueue } from "../src/MessageQueue/MessageQueue"; -const mq = createMessageQueue({ - monolithic: true, -}); +const mq = createMessageQueue(); describe("MessageQueueTest", () => { describe("LocalMq", () => { @@ -25,6 +23,7 @@ describe("MessageQueueTest", () => { data: 51, }); }); + it("should be able to push an event, and respond to it", async () => { mq.subscribe("fakeevent2"); mq.subscribe("response.fakeevent2"); diff --git a/tests/config/config.ts b/tests/config/config.ts new file mode 100644 index 000000000..f942edc9f --- /dev/null +++ b/tests/config/config.ts @@ -0,0 +1,90 @@ +import { BridgeConfig } from "../../src/config/Config"; +import { DefaultConfigRoot } from "../../src/config/Defaults"; +import { expect } from "chai"; + + +describe("Config/BridgeConfig", () => { + describe("will handle the legacy queue.monolitihc option", () => { + it("with no parameters", () => { + const config = new BridgeConfig({ ...DefaultConfigRoot, queue: { + monolithic: true + }}); + expect(config.queue).to.be.undefined; + expect(config.cache?.redisUri).to.equal("redis://localhost:6379"); + }); + + it("with a host parameter", () => { + const config = new BridgeConfig({ ...DefaultConfigRoot, queue: { + monolithic: true, + host: 'bark' + }}); + expect(config.queue).to.be.undefined; + expect(config.cache?.redisUri).to.equal("redis://bark:6379"); + }); + + it("with a port parameter", () => { + const config = new BridgeConfig({ ...DefaultConfigRoot, queue: { + monolithic: true, + port: 6379, + }}); + expect(config.queue).to.be.undefined; + expect(config.cache?.redisUri).to.equal("redis://localhost:6379"); + }); + + it("with a host and port parameter", () => { + const config = new BridgeConfig({ ...DefaultConfigRoot, queue: { + monolithic: true, + host: 'bark', + port: 6379, + }}); + expect(config.queue).to.be.undefined; + expect(config.cache?.redisUri).to.equal("redis://bark:6379"); + }); + + it("with monolithic disabled", () => { + const config = new BridgeConfig({ + ...DefaultConfigRoot, + encryption: undefined, + queue: { + monolithic: false + } + }); + expect(config.queue).to.deep.equal({ + monolithic: false, + }); + expect(config.cache?.redisUri).to.equal("redis://localhost:6379"); + }); + }); + + describe("will handle the queue option", () => { + it("with redisUri", () => { + const config = new BridgeConfig({ ...DefaultConfigRoot, + encryption: undefined, + queue: { + redisUri: "redis://localhost:6379" + }, + cache: undefined + }); + expect(config.queue).to.deep.equal({ + redisUri: "redis://localhost:6379" + }); + expect(config.cache).to.be.undefined; + }); + }); + + describe("will handle the cache option", () => { + it("with redisUri", () => { + const config = new BridgeConfig({ + ...DefaultConfigRoot, + cache: { + redisUri: "redis://localhost:6379" + }, + queue: undefined, + }); + expect(config.cache).to.deep.equal({ + redisUri: "redis://localhost:6379" + }); + expect(config.queue).to.be.undefined; + }); + }); +}) \ No newline at end of file diff --git a/tests/config/permissions.ts b/tests/config/permissions.ts index a61254d90..33487469d 100644 --- a/tests/config/permissions.ts +++ b/tests/config/permissions.ts @@ -21,43 +21,53 @@ describe("Config/BridgePermissions", () => { const bridgePermissions = new BridgePermissions([]); expect(bridgePermissions.checkAction("@foo:bar", "empty-service", "commands")).to.be.false; }); + it("will return false for an insufficent level", () => { const bridgePermissions = genBridgePermissions('@foo:bar', 'my-service', 'login'); expect(bridgePermissions.checkAction("@foo:bar", "my-service", "notifications")).to.be.false; }); + it("will return false if the there are no matching services", () => { const bridgePermissions = genBridgePermissions('@foo:bar', 'my-service', 'login'); expect(bridgePermissions.checkAction("@foo:bar", "other-service", "login")).to.be.false; }); + it("will return false if the target does not match", () => { const bridgePermissions = genBridgePermissions('@foo:bar', 'my-service', 'login'); expect(bridgePermissions.checkAction("@foo:baz", "my-service", "login")).to.be.false; }); + it("will return true if there is a matching level and service", () => { const bridgePermissions = genBridgePermissions('@foo:bar', 'my-service', 'login'); expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")).to.be.true; }); + it("will return true for a matching actor domain", () => { const bridgePermissions = genBridgePermissions('bar', 'my-service', 'login'); expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")).to.be.true; }); + it("will return true for a wildcard actor", () => { const bridgePermissions = genBridgePermissions('*', 'my-service', 'login'); expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")).to.be.true; }); + it("will return true for a wildcard service", () => { const bridgePermissions = genBridgePermissions('@foo:bar', '*', 'login'); expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")).to.be.true; }); + it("will return false if a user is not present in a room", () => { const bridgePermissions = genBridgePermissions('!foo:bar', 'my-service', 'login'); expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")).to.be.false; }); + it("will return true if a user is present in a room", () => { const bridgePermissions = genBridgePermissions('!foo:bar', 'my-service', 'login'); bridgePermissions.addMemberToCache('!foo:bar', '@foo:bar'); expect(bridgePermissions.checkAction("@foo:bar", "my-service", "login")).to.be.true; }); + it("will fall through and return true for multiple permission sets", () => { const bridgePermissions = new BridgePermissions([ { @@ -98,6 +108,7 @@ describe("Config/BridgePermissions", () => { const bridgePermissions = new BridgePermissions([]); expect(bridgePermissions.checkActionAny("@foo:bar", "commands")).to.be.false; }); + it(`will return false for a service with an insufficent level`, () => { const bridgePermissions = genBridgePermissions("@foo:bar", "fake-service", "commands"); expect( @@ -107,8 +118,7 @@ describe("Config/BridgePermissions", () => { ) ).to.be.false; }); - const checkActorValues = ["@foo:bar", "bar", "*"]; - checkActorValues.forEach(actor => { + for (const actor of ["@foo:bar", "bar", "*"]) { it(`will return true for a service defintion of '${actor}' that has a sufficent level`, () => { const bridgePermissions = genBridgePermissions("@foo:bar", "fake-service", "commands"); expect( @@ -118,6 +128,6 @@ describe("Config/BridgePermissions", () => { ) ).to.be.true; }); - }); + } }) }) \ No newline at end of file diff --git a/tests/connections/FeedTest.spec.ts b/tests/connections/FeedTest.spec.ts index 6bb268411..7359349ea 100644 --- a/tests/connections/FeedTest.spec.ts +++ b/tests/connections/FeedTest.spec.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { AppserviceMock } from "../utils/AppserviceMock"; import { FeedConnection, FeedConnectionState } from "../../src/Connections"; import { FeedEntry } from "../../src/feeds/FeedReader"; @@ -47,6 +46,7 @@ describe("FeedConnection", () => { expect(matrixEvt.content.external_url).to.equal(FEED_ENTRY_DEFAULTS.link); expect(matrixEvt.content.body).to.equal("New post in Test feed: [Foo](foo/bar)"); }); + it("will handle simple feed message without a title and link ", async () => { const [connection, intent] = createFeed(); await connection.handleFeedEntry({ @@ -60,6 +60,7 @@ describe("FeedConnection", () => { expect(matrixEvt.content.external_url).to.be.undefined; expect(matrixEvt.content.body).to.equal("New post in Test feed"); }); + it("will handle simple feed message with a missing title ", async () => { const [connection, intent] = createFeed(); await connection.handleFeedEntry({ @@ -71,6 +72,7 @@ describe("FeedConnection", () => { expect(matrixEvt.roomId).to.equal(ROOM_ID); expect(matrixEvt.content.body).to.equal("New post in Test feed: [foo/bar](foo/bar)"); }); + it("will handle simple feed message with a missing link ", async () => { const [connection, intent] = createFeed(); await connection.handleFeedEntry({ @@ -82,6 +84,7 @@ describe("FeedConnection", () => { expect(matrixEvt.roomId).to.equal(ROOM_ID); expect(matrixEvt.content.body).to.equal("New post in Test feed: Foo"); }); + it("will handle simple feed message with all the template options possible ", async () => { const [connection, intent] = createFeed({ template: `$FEEDNAME $FEEDURL $FEEDTITLE $TITLE $LINK $AUTHOR $DATE $SUMMARY` @@ -94,6 +97,7 @@ describe("FeedConnection", () => { expect(matrixEvt.roomId).to.equal(ROOM_ID); expect(matrixEvt.content.body).to.equal("Test feed https://example.com/feed.xml Test feed Foo [Foo](foo/bar) Me! today! fibble fobble"); }); + it("will handle html in the feed summary ", async () => { const [connection, intent] = createFeed({ template: `$FEEDNAME $SUMMARY` @@ -107,6 +111,7 @@ describe("FeedConnection", () => { expect(matrixEvt.roomId).to.equal(ROOM_ID); expect(matrixEvt.content.body).to.equal('Test feed

Some HTML with which should be ignored and an

'); }); + it("will handle partial html in the feed summary ", async () => { const [connection, intent] = createFeed({ template: `$FEEDNAME $SUMMARY` diff --git a/tests/connections/GenericHookTest.ts b/tests/connections/GenericHookTest.ts index 007c880d6..975d250d8 100644 --- a/tests/connections/GenericHookTest.ts +++ b/tests/connections/GenericHookTest.ts @@ -1,11 +1,14 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { expect } from "chai"; -import { MatrixError } from "matrix-bot-sdk"; -import { BridgeConfigGenericWebhooks, BridgeGenericWebhooksConfigYAML } from "../../src/config/Config"; +import { assert, expect } from "chai"; +import { Appservice, Intent, MatrixError } from "matrix-bot-sdk"; +import { BridgeConfigGenericWebhooks, BridgeGenericWebhooksConfigYAML } from "../../src/config/sections"; import { GenericHookConnection, GenericHookConnectionState } from "../../src/Connections/GenericHook"; import { MessageSenderClient, IMatrixSendMessage } from "../../src/MatrixSender"; import { LocalMQ } from "../../src/MessageQueue/LocalMQ"; import { AppserviceMock } from "../utils/AppserviceMock"; +import { MemoryStorageProvider } from "../../src/Stores/MemoryStorageProvider"; +import { BridgeConfig } from "../../src/config/Config"; +import { ProvisionConnectionOpts } from "../../src/Connections"; +import { add } from "date-fns"; const ROOM_ID = "!foo:bar"; @@ -31,12 +34,15 @@ async function testSimpleWebhook(connection: GenericHookConnection, mq: LocalMQ, }); } +const ConfigDefaults = {enabled: true, urlPrefix: "https://example.com/webhookurl"}; + function createGenericHook( state: Partial = { }, - config: BridgeGenericWebhooksConfigYAML = { enabled: true, urlPrefix: "https://example.com/webhookurl"} -) { + config: Partial = { } +): [GenericHookConnection, LocalMQ, Appservice, Intent] { const mq = new LocalMQ(); mq.subscribe('*'); + const storage = new MemoryStorageProvider(); const messageClient = new MessageSenderClient(mq); const as = AppserviceMock.create(); const intent = as.getIntentForUserId('@webhooks:example.test'); @@ -45,7 +51,10 @@ function createGenericHook( transformationFunction: undefined, waitForComplete: undefined, ...state, - }, "foobar", "foobar", messageClient, new BridgeConfigGenericWebhooks(config), as, intent); + }, "foobar", "foobar", messageClient, new BridgeConfigGenericWebhooks({ + ...ConfigDefaults, + ...config, + }), as, intent, storage); return [connection, mq, as, intent]; } @@ -65,10 +74,12 @@ describe("GenericHookConnection", () => { before(async () => { await GenericHookConnection.initialiseQuickJS(); }) + it("will handle simple hook events", async () => { const [connection, mq] = createGenericHook(); await testSimpleWebhook(connection, mq, "data"); }); + it("will handle a hook event containing text", async () => { const webhookData = {text: "simple-message"}; const [connection, mq] = createGenericHook(); @@ -87,6 +98,7 @@ describe("GenericHookConnection", () => { type: 'm.room.message', }); }); + it("will handle a hook event containing markdown", async () => { const webhookData = {text: "**bold-message** _italic-message_"}; const [connection, mq] = createGenericHook(); @@ -105,6 +117,7 @@ describe("GenericHookConnection", () => { type: 'm.room.message', }); }); + it("will handle a hook event containing markdown with newlines", async () => { const webhookData = {text: "# Oh wow\n\n`some-code`"}; const [connection, mq] = createGenericHook(); @@ -123,6 +136,7 @@ describe("GenericHookConnection", () => { type: 'm.room.message', }); }); + it("will handle a hook event containing html", async () => { const webhookData = {text: "simple-message", html: "simple-message"}; const [connection, mq] = createGenericHook(); @@ -141,6 +155,7 @@ describe("GenericHookConnection", () => { type: 'm.room.message', }); }); + it("will handle a hook event containing a username", async () => { const webhookData = {username: "Bobs-integration", type: 42}; const [connection, mq] = createGenericHook(); @@ -159,11 +174,10 @@ describe("GenericHookConnection", () => { type: 'm.room.message', }); }); + it("will handle a hook event with a v1 transformation function", async () => { const webhookData = {question: 'What is the meaning of life?', answer: 42}; const [connection, mq] = createGenericHook({name: 'test', transformationFunction: V1TFFunction}, { - enabled: true, - urlPrefix: "https://example.com/webhookurl", allowJsTransformationFunctions: true, } ); @@ -182,11 +196,10 @@ describe("GenericHookConnection", () => { type: 'm.room.message', }); }); + it("will handle a hook event with a v2 transformation function", async () => { const webhookData = {question: 'What is the meaning of life?', answer: 42}; const [connection, mq] = createGenericHook({name: 'test', transformationFunction: V2TFFunction}, { - enabled: true, - urlPrefix: "https://example.com/webhookurl", allowJsTransformationFunctions: true, } ); @@ -205,11 +218,10 @@ describe("GenericHookConnection", () => { type: 'm.room.message', }); }); + it("will handle a hook event with a top-level return", async () => { const webhookData = {question: 'What is the meaning of life?', answer: 42}; const [connection, mq] = createGenericHook({name: 'test', transformationFunction: V2TFFunctionWithReturn}, { - enabled: true, - urlPrefix: "https://example.com/webhookurl", allowJsTransformationFunctions: true, } ); @@ -228,11 +240,10 @@ describe("GenericHookConnection", () => { type: 'm.room.message', }); }); + it("will fail to handle a webhook with an invalid script", async () => { const webhookData = {question: 'What is the meaning of life?', answer: 42}; const [connection, mq] = createGenericHook({name: 'test', transformationFunction: "bibble bobble"}, { - enabled: true, - urlPrefix: "https://example.com/webhookurl", allowJsTransformationFunctions: true, } ); @@ -251,6 +262,7 @@ describe("GenericHookConnection", () => { type: 'm.room.message', }); }); + it("will handle a message containing floats", async () => { const [connection, mq] = createGenericHook(); let messagePromise = handleMessage(mq); @@ -290,8 +302,7 @@ describe("GenericHookConnection", () => { }); it("should handle simple hook events with user Id prefix", async () => { - const config = { enabled: true, urlPrefix: "https://example.com/webhookurl", userIdPrefix: "_webhooks_"}; - const [connection, mq] = createGenericHook(undefined, config); + const [connection, mq] = createGenericHook(undefined, { userIdPrefix: "_webhooks_"}); await testSimpleWebhook(connection, mq, "data1"); // regression test covering https://github.com/matrix-org/matrix-hookshot/issues/625 await testSimpleWebhook(connection, mq, "data2"); @@ -299,22 +310,21 @@ describe("GenericHookConnection", () => { it("should invite a configured puppet to the room if it's unable to join", async () => { const senderUserId = "@_webhooks_some-name:example.test"; - const config = { enabled: true, urlPrefix: "https://example.com/webhookurl", userIdPrefix: "_webhooks_"}; - const [connection, mq, as, botIntent] = createGenericHook(undefined, config); + const [connection, mq, as, botIntent] = createGenericHook(undefined, { userIdPrefix: "_webhooks_"}); const intent = as.getIntentForUserId(senderUserId); let hasInvited = false; // This should fail the first time, then pass once we've tried to invite the user - intent.ensureJoined = (roomId: string) => { + intent.ensureJoined = async (roomId: string) => { if (hasInvited) { - return; + return roomId; } expect(roomId).to.equal(ROOM_ID); - throw new MatrixError({ errcode: "M_FORBIDDEN", error: "Test forced error"}, 401) + throw new MatrixError({ errcode: "M_FORBIDDEN", error: "Test forced error"}, 401, { }) }; // This should invite the puppet user. - botIntent.underlyingClient.inviteUser = (userId: string, roomId: string) => { + botIntent.underlyingClient.inviteUser = async (userId: string, roomId: string) => { expect(userId).to.equal(senderUserId); expect(roomId).to.equal(ROOM_ID); hasInvited = true; @@ -328,13 +338,12 @@ describe("GenericHookConnection", () => { it("should fail a message if a bot cannot join a room", async () => { const senderUserId = "@_webhooks_some-name:example.test"; - const config = { enabled: true, urlPrefix: "https://example.com/webhookurl", userIdPrefix: "_webhooks_"}; - const [connection, mq, as] = createGenericHook(undefined, config); + const [connection, mq, as] = createGenericHook(undefined, { userIdPrefix: "_webhooks_"}); const intent = as.getIntentForUserId(senderUserId); // This should fail the first time, then pass once we've tried to invite the user intent.ensureJoined = () => { - throw new MatrixError({ errcode: "FORCED_FAILURE", error: "Test forced error"}, 500) + throw new MatrixError({ errcode: "FORCED_FAILURE", error: "Test forced error"}, 500, { }) }; try { // regression test covering https://github.com/matrix-org/matrix-hookshot/issues/625 @@ -343,4 +352,100 @@ describe("GenericHookConnection", () => { expect(ex.message).to.contain(`Could not ensure that ${senderUserId} is in ${ROOM_ID}`) } }); + + it('should fail to create a hook with an invalid expiry time', () => { + for (const expirationDate of [0, 1, -1, false, true, {}, [], new Date(), ""]) { + expect(() => GenericHookConnection.validateState({ + name: "beep", + expirationDate, + })).to.throw("'expirationDate' must be a non-empty string"); + } + for (const expirationDate of ["no", "\0", "true", " 2024", "2024-01-01", "15:56", "2024-01-01 15:16"]) { + expect(() => GenericHookConnection.validateState({ + name: "beep", + expirationDate, + })).to.throw("'expirationDate' must be a valid date"); + } + }); + + it('should fail to create a hook with a too short expiry time', async () => { + const as = AppserviceMock.create(); + try { + await GenericHookConnection.provisionConnection(ROOM_ID, "@some:user", { + name: "foo", + expirationDate: new Date().toISOString(), + }, { + as: as, + intent: as.botIntent, + config: { generic: new BridgeConfigGenericWebhooks(ConfigDefaults) } as unknown as BridgeConfig, + messageClient: new MessageSenderClient(new LocalMQ()), + storage: new MemoryStorageProvider(), + } as unknown as ProvisionConnectionOpts); + assert.fail('Expected function to throw'); + } catch (ex) { + expect(ex.message).to.contain('Expiration date must at least be a hour in the future'); + } + }); + + it('should fail to create a hook with a too long expiry time', async () => { + const as = AppserviceMock.create(); + try { + await GenericHookConnection.provisionConnection(ROOM_ID, "@some:user", { + name: "foo", + expirationDate: add(new Date(), { days: 1, seconds: 1}).toISOString(), + }, { + as: as, + intent: as.botIntent, + config: { generic: new BridgeConfigGenericWebhooks({ + ...ConfigDefaults, + maxExpiryTime: '1d' + }) } as unknown as BridgeConfig, + messageClient: new MessageSenderClient(new LocalMQ()), + storage: new MemoryStorageProvider(), + } as unknown as ProvisionConnectionOpts); + assert.fail('Expected function to throw'); + } catch (ex) { + expect(ex.message).to.contain('Expiration date cannot exceed the configured max expiry time'); + } + }); + + it('should fail to create a hook without an expiry time when required by config', async () => { + const as = AppserviceMock.create(); + try { + await GenericHookConnection.provisionConnection(ROOM_ID, "@some:user", { + name: "foo", + }, { + as: as, + intent: as.botIntent, + config: { generic: new BridgeConfigGenericWebhooks({ + ...ConfigDefaults, + maxExpiryTime: '1d', + requireExpiryTime: true, + }) } as unknown as BridgeConfig, + messageClient: new MessageSenderClient(new LocalMQ()), + storage: new MemoryStorageProvider(), + } as unknown as ProvisionConnectionOpts); + assert.fail('Expected function to throw'); + } catch (ex) { + expect(ex.message).to.contain('Expiration date must be set'); + } + }); + + it('should create a hook and handle a request within the expiry time', async () => { + const [connection, mq] = createGenericHook({ + expirationDate: add(new Date(), { seconds: 30 }).toISOString(), + }); + await testSimpleWebhook(connection, mq, "test"); + }); + + it('should reject requests to an expired hook', async () => { + const [connection] = createGenericHook({ + expirationDate: new Date().toISOString(), + }); + expect(await connection.onGenericHook({test: "value"})).to.deep.equal({ + error: "This hook has expired", + statusCode: 404, + successful: false, + }); + }); }) diff --git a/tests/connections/GithubRepoTest.ts b/tests/connections/GithubRepoTest.ts index e686f2a5c..8c8a0e0dd 100644 --- a/tests/connections/GithubRepoTest.ts +++ b/tests/connections/GithubRepoTest.ts @@ -1,7 +1,7 @@ import { GitHubRepoConnection, GitHubRepoConnectionState } from "../../src/Connections/GithubRepo" import { GithubInstance } from "../../src/github/GithubInstance"; import { createMessageQueue } from "../../src/MessageQueue"; -import { UserTokenStore } from "../../src/UserTokenStore"; +import { UserTokenStore } from "../../src/tokens/UserTokenStore"; import { DefaultConfig } from "../../src/config/Defaults"; import { AppserviceMock } from "../utils/AppserviceMock"; import { ApiError, ErrCode, ValidatorApiError } from "../../src/api"; @@ -37,9 +37,7 @@ const GITHUB_ISSUE_CREATED_PAYLOAD = { }; function createConnection(state: Record = {}, isExistingState=false) { - const mq = createMessageQueue({ - monolithic: true - }); + const mq = createMessageQueue(); mq.subscribe('*'); const as = AppserviceMock.create(); const intent = as.getIntentForUserId('@github:example.test'); @@ -57,7 +55,6 @@ function createConnection(state: Record = {}, isExistingState=f "state_key", githubInstance, // Default config always contains GitHub - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion DefaultConfig.github! ); return {connection, intent: intent as IntentMock}; @@ -86,6 +83,7 @@ describe("GitHubRepoConnection", () => { } } as GitHubRepoConnectionState as unknown as Record); }); + it("will convert ignoredHooks for existing state", () => { const state = GitHubRepoConnection.validateState({ org: "foo", @@ -96,6 +94,7 @@ describe("GitHubRepoConnection", () => { } as GitHubRepoConnectionState as unknown as Record, true); expect(state.enableHooks).to.not.contain('issue'); }); + it("will disallow invalid state", () => { try { GitHubRepoConnection.validateState({ @@ -108,6 +107,7 @@ describe("GitHubRepoConnection", () => { } } }); + it("will disallow enabledHooks to contains invalid enums if this is new state", () => { try { GitHubRepoConnection.validateState({ @@ -121,6 +121,7 @@ describe("GitHubRepoConnection", () => { } } }); + it("will allow enabledHooks to contains invalid enums if this is old state", () => { GitHubRepoConnection.validateState({ org: "foo", @@ -129,6 +130,7 @@ describe("GitHubRepoConnection", () => { }, true); }); }); + describe("onIssueCreated", () => { it("will handle a simple issue", async () => { const { connection, intent } = createConnection(); @@ -138,6 +140,7 @@ describe("GitHubRepoConnection", () => { intent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.html_url, 0); intent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.title, 0); }); + it("will handle assignees on issue creation", async () => { const { connection, intent } = createConnection(); await connection.onIssueCreated({ @@ -153,6 +156,7 @@ describe("GitHubRepoConnection", () => { intent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.html_url, 0); intent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.title, 0); }); + it("will filter out issues not matching includingLabels.", async () => { const { connection, intent } = createConnection({ includingLabels: ["include-me"] @@ -170,6 +174,7 @@ describe("GitHubRepoConnection", () => { await connection.onIssueCreated(GITHUB_ISSUE_CREATED_PAYLOAD as never); intent.expectNoEvent(); }); + it("will filter out issues matching excludingLabels.", async () => { const { connection, intent } = createConnection({ excludingLabels: ["exclude-me"] @@ -185,6 +190,7 @@ describe("GitHubRepoConnection", () => { } as never); intent.expectNoEvent(); }); + it("will include issues matching includingLabels.", async () => { const { connection, intent } = createConnection({ includingIssues: ["include-me"] diff --git a/tests/connections/GitlabRepoTest.ts b/tests/connections/GitlabRepoTest.ts index ee87d7658..58315ba27 100644 --- a/tests/connections/GitlabRepoTest.ts +++ b/tests/connections/GitlabRepoTest.ts @@ -1,5 +1,5 @@ import { createMessageQueue } from "../../src/MessageQueue"; -import { UserTokenStore } from "../../src/UserTokenStore"; +import { UserTokenStore } from "../../src/tokens/UserTokenStore"; import { AppserviceMock } from "../utils/AppserviceMock"; import { ApiError, ErrCode, ValidatorApiError } from "../../src/api"; import { GitLabRepoConnection, GitLabRepoConnectionState } from "../../src/Connections"; @@ -56,9 +56,7 @@ const GITLAB_MR_COMMENT = { const COMMENT_DEBOUNCE_MS = 25; function createConnection(state: Record = {}, isExistingState=false): { connection: GitLabRepoConnection, intent: IntentMock } { - const mq = createMessageQueue({ - monolithic: true - }); + const mq = createMessageQueue(); mq.subscribe('*'); const as = AppserviceMock.create(); const intent = as.getIntentForUserId('@gitlab:example.test'); @@ -99,6 +97,7 @@ describe("GitLabRepoConnection", () => { path: "bar/baz", enableHooks: [ "merge_request.open", + "merge_request.reopen", "merge_request.close", "merge_request.merge", "merge_request.review", @@ -116,6 +115,7 @@ describe("GitLabRepoConnection", () => { excludingLabels: ["but-not-me"], } as GitLabRepoConnectionState as unknown as Record); }); + it("will convert ignoredHooks for existing state", () => { const state = GitLabRepoConnection.validateState({ instance: "foo", @@ -127,6 +127,7 @@ describe("GitLabRepoConnection", () => { } as GitLabRepoConnectionState as unknown as Record, true); expect(state.enableHooks).to.not.contain('merge_request'); }); + it("will disallow invalid state", () => { try { GitLabRepoConnection.validateState({ @@ -139,6 +140,7 @@ describe("GitLabRepoConnection", () => { } } }); + it("will disallow enabledHooks to contains invalid enums if this is new state", () => { try { GitLabRepoConnection.validateState({ @@ -152,6 +154,7 @@ describe("GitLabRepoConnection", () => { } } }); + it("will allow enabledHooks to contains invalid enums if this is old state", () => { GitLabRepoConnection.validateState({ instance: "bar", @@ -160,6 +163,7 @@ describe("GitLabRepoConnection", () => { }, true); }); }); + describe("onCommentCreated", () => { it("will handle an MR comment", async () => { const { connection, intent } = createConnection(); @@ -170,6 +174,7 @@ describe("GitLabRepoConnection", () => { 'event body indicates MR comment' ); }); + it("will debounce MR comments", async () => { const { connection, intent } = createConnection(); await connection.onCommentCreated(GITLAB_MR_COMMENT as never); @@ -189,6 +194,7 @@ describe("GitLabRepoConnection", () => { 0, ); }); + it("will add new comments in a Matrix thread", async () => { const { connection, intent } = createConnection(); await connection.onCommentCreated(GITLAB_MR_COMMENT as never); @@ -202,6 +208,7 @@ describe("GitLabRepoConnection", () => { 1, ); }); + it("will correctly map new comments to aggregated discussions", async () => { const { connection, intent } = createConnection(); await connection.onCommentCreated({ @@ -252,6 +259,7 @@ describe("GitLabRepoConnection", () => { ); }); }); + describe("onIssueCreated", () => { it("will handle a simple issue", async () => { const { connection, intent } = createConnection(); @@ -261,6 +269,7 @@ describe("GitLabRepoConnection", () => { intent.expectEventBodyContains(GITLAB_ISSUE_CREATED_PAYLOAD.object_attributes.url, 0); intent.expectEventBodyContains(GITLAB_ISSUE_CREATED_PAYLOAD.object_attributes.title, 0); }); + it("will filter out issues not matching includingLabels.", async () => { const { connection, intent } = createConnection({ includingLabels: ["include-me"] @@ -275,6 +284,7 @@ describe("GitLabRepoConnection", () => { await connection.onMergeRequestOpened(GITLAB_ISSUE_CREATED_PAYLOAD as never); intent.expectNoEvent(); }); + it("will filter out issues matching excludingLabels.", async () => { const { connection, intent } = createConnection({ excludingLabels: ["exclude-me"] @@ -287,6 +297,7 @@ describe("GitLabRepoConnection", () => { } as never); intent.expectNoEvent(); }); + it("will include issues matching includingLabels.", async () => { const { connection, intent } = createConnection({ includingIssues: ["include-me"] diff --git a/tests/github/AdminCommands.ts b/tests/github/AdminCommands.ts index 5bc0aa5e7..f614b82e3 100644 --- a/tests/github/AdminCommands.ts +++ b/tests/github/AdminCommands.ts @@ -13,6 +13,7 @@ describe("GitHub", () => { }) ).equals('https://github.com/login/oauth/authorize?state=my_state&client_id=123&redirect_uri=456'); }); + it("can generate an authorize URL for enterprise URLs", () => { expect( GithubInstance.generateOAuthUrl(new URL("https://mygithuburl.com/foo/bar"), "authorize", { @@ -22,6 +23,7 @@ describe("GitHub", () => { }) ).equals('https://mygithuburl.com/foo/bar/login/oauth/authorize?state=my_state&client_id=123&redirect_uri=456'); }); + it("can generate an access_token URL for the cloud URL", () => { expect( GithubInstance.generateOAuthUrl(GITHUB_CLOUD_URL, "access_token", { @@ -33,6 +35,7 @@ describe("GitHub", () => { }) ).equals('https://github.com/login/oauth/access_token?client_id=123&client_secret=the-secret&code=the-code&redirect_uri=456&state=my_state'); }); + it("can generate an access_token URL for enterprise URLs", () => { expect( GithubInstance.generateOAuthUrl(new URL("https://mygithuburl.com/foo/bar"), "access_token", { diff --git a/tests/grants/GrantChecker.spec.ts b/tests/grants/GrantChecker.spec.ts index caa6e59df..5e81e625a 100644 --- a/tests/grants/GrantChecker.spec.ts +++ b/tests/grants/GrantChecker.spec.ts @@ -39,6 +39,7 @@ describe("GrantChecker", () => { let check: GrantChecker; // eslint-disable-next-line @typescript-eslint/no-explicit-any let intent: any; + beforeEach(() => { intent = IntentMock.create('@foo:bar'); check = new TestGrantChecker(intent, GRANT_SERVICE); @@ -95,9 +96,11 @@ describe("GrantChecker", () => { ); }); }); + describe('config fallback', () => { let check: GrantChecker; let as: AppserviceMock; + beforeEach(() => { const mockAs = AppserviceMock.create(); as = mockAs; diff --git a/tests/jira/Utils.ts b/tests/jira/Utils.ts index 3f9050ed4..e9e3fb2f4 100644 --- a/tests/jira/Utils.ts +++ b/tests/jira/Utils.ts @@ -9,13 +9,15 @@ describe("Jira", () => { key: "TEST-111", })).to.equal("https://my-test-jira/browse/TEST-111"); }); + it("processes a jira issue into a URL with a port", () => { expect(generateJiraWebLinkFromIssue({ self: "https://my-test-jira:9995/", key: "TEST-111", })).to.equal("https://my-test-jira:9995/browse/TEST-111"); }); - it("processes a jira issue into a URL with a port", () => { + + it("processes a jira issue into a URL with a port and a version", () => { expect(generateJiraWebLinkFromVersion({ self: "https://my-test-jira:9995/", description: "foo", diff --git a/tests/tokens/tokenencryption.spec.ts b/tests/tokens/tokenencryption.spec.ts new file mode 100644 index 000000000..d79ccea85 --- /dev/null +++ b/tests/tokens/tokenencryption.spec.ts @@ -0,0 +1,91 @@ +import { Algo, TokenEncryption } from "../../src/libRs"; +import { RSAKeyPairOptions, generateKeyPair, publicEncrypt } from "node:crypto"; +import { expect } from "chai"; + +describe("TokenEncryption", () => { + let keyPromise: Promise; + let keyPromisePKCS1: Promise; + async function createTokenEncryption() { + return new TokenEncryption(await keyPromise); + } + + async function legacyEncryptFunction(token: string) { + const MAX_TOKEN_PART_SIZE = 128; + const tokenParts: string[] = []; + let tokenSource = token; + while (tokenSource && tokenSource.length > 0) { + const part = tokenSource.slice(0, MAX_TOKEN_PART_SIZE); + tokenSource = tokenSource.substring(MAX_TOKEN_PART_SIZE); + tokenParts.push(publicEncrypt(await keyPromise, Buffer.from(part)).toString("base64")); + } + return tokenParts; + } + + before('generate RSA key', () => { + // Generate this once since it will take an age. + keyPromise = new Promise((resolve, reject) => generateKeyPair("rsa", { + // Deliberately shorter length to speed up test + modulusLength: 2048, + privateKeyEncoding: { + type: "pkcs8", + format: "pem", + }, + publicKeyEncoding: { + format: "pem", + type: "pkcs1", + } + } satisfies RSAKeyPairOptions<"pem", "pem">, (err, _, privateKey) => { + if (err) { reject(err) } else { resolve(Buffer.from(privateKey)) } + })); + keyPromisePKCS1 = new Promise((resolve, reject) => generateKeyPair("rsa", { + // Deliberately shorter length to speed up test + modulusLength: 2048, + privateKeyEncoding: { + type: "pkcs1", + format: "pem", + }, + publicKeyEncoding: { + format: "pem", + type: "pkcs1", + } + } satisfies RSAKeyPairOptions<"pem", "pem">, (err, _, privateKey) => { + if (err) { reject(err) } else { resolve(Buffer.from(privateKey)) } + })); + + }, ); + + it('should be able to encrypt a string into a single part', async() => { + const tokenEncryption = await createTokenEncryption(); + const result = tokenEncryption.encrypt('hello world'); + expect(result).to.have.lengthOf(1); + }); + + it('should be able to decrypt from a single part into a string', async() => { + const tokenEncryption = await createTokenEncryption(); + const value = tokenEncryption.encrypt('hello world'); + const result = tokenEncryption.decrypt(value, Algo.RSAPKCS1v15); + expect(result).to.equal('hello world'); + }); + + it('should be able to decrypt from many parts into string', async() => { + const plaintext = 'This is a very long string that needs to be encoded into multiple parts in order for us to store it properly. This ' + + ' should end up as multiple encrypted values in base64.'; + const tokenEncryption = await createTokenEncryption(); + const value = tokenEncryption.encrypt(plaintext); + expect(value).to.have.lengthOf(2); + const result = tokenEncryption.decrypt(value, Algo.RSAPKCS1v15); + expect(result).to.equal(plaintext); + }); + + it('should support pkcs1 format keys', async() => { + const tokenEncryption = new TokenEncryption(await keyPromisePKCS1); + const result = tokenEncryption.encrypt('hello world'); + expect(result).to.have.lengthOf(1); + }); + + it('should be to decrypt a string from the old crypto implementation', async() => { + const legacyString = await legacyEncryptFunction('hello world'); + const tokenEncryption = await createTokenEncryption(); + expect(tokenEncryption.decrypt(legacyString, Algo.RSAOAEP)).to.equal('hello world'); + }); +}); diff --git a/tests/utils/IntentMock.ts b/tests/utils/IntentMock.ts index 5877055b0..078091b17 100644 --- a/tests/utils/IntentMock.ts +++ b/tests/utils/IntentMock.ts @@ -39,7 +39,7 @@ export class MatrixClientMock { throw new MatrixError({ errcode: 'M_NOT_FOUND', error: 'Test error: No account data', - }, 404); + }, 404, { }); } async setRoomAccountData(key: string, roomId: string, value: string): Promise { diff --git a/tsconfig.json b/tsconfig.json index 7d9a92ac3..7a2f088c1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@tsconfig/node18/tsconfig.json", + "extends": "@tsconfig/node22/tsconfig.json", "compilerOptions": { "incremental": true, "declaration": false, @@ -17,16 +17,13 @@ "noUnusedParameters": false, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, - "esModuleInterop": true, "inlineSourceMap": true, "inlineSources": true, "allowJs": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, // TODO: Type errors - "useUnknownInCatchVariables": false, - // ES2022+ is currently broken https://github.com/matrix-org/matrix-hookshot/issues/729 - "target": "es2021" + "useUnknownInCatchVariables": false }, "include": [ "src/**/*" diff --git a/tsconfig.spec.json b/tsconfig.spec.json new file mode 100644 index 000000000..fb6a30d7f --- /dev/null +++ b/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "incremental": false, + "declaration": false, + "outDir": "./spec-lib", + "rootDir": "./", + "allowJs": true + }, + "include": [ + "spec/**/*" + ], + "exclude": [ + "tests/**/*", + "web/**/*" + ] +} diff --git a/vite.config.mjs b/vite.config.mjs index 010694409..7789d7aea 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -31,4 +31,11 @@ export default defineConfig({ }, emptyOutDir: true, }, + css: { + preprocessorOptions: { + scss: { + api: 'modern' + } + } + } }) diff --git a/web/App.tsx b/web/App.tsx index bac2078e9..e19581c5b 100644 --- a/web/App.tsx +++ b/web/App.tsx @@ -1,4 +1,3 @@ -/* eslint-disable no-console */ import { Component } from 'preact'; import WA, { MatrixCapabilities } from 'matrix-widget-api'; import { BridgeAPI, BridgeAPIError, EmbedType, embedTypeParameter } from './BridgeAPI'; @@ -84,8 +83,6 @@ export default class App extends Component { const roomState = widgetKind === "admin" ? await bridgeApi.state() : undefined; const supportedServices = await bridgeApi.getEnabledConfigSections(); await widgetReady; - // Calling setState is ok because we've awaited a network request. - // eslint-disable-next-line react/no-did-mount-set-state this.setState({ userId, roomState, @@ -106,8 +103,6 @@ export default class App extends Component { error = "Could not contact your homeserver. Your instance may be misconfigured."; } } - // Calling setState is ok because we've awaited a network request. - // eslint-disable-next-line react/no-did-mount-set-state this.setState({ error, busy: false, diff --git a/web/BridgeAPI.ts b/web/BridgeAPI.ts index e6b5791e3..80fbcd81c 100644 --- a/web/BridgeAPI.ts +++ b/web/BridgeAPI.ts @@ -46,7 +46,6 @@ export class BridgeAPI { const creds = await widgetApi.requestOpenIDConnectToken(); const { matrix_server_name, access_token } = creds; - // eslint-disable-next-line camelcase if (!matrix_server_name || !access_token) { throw Error('Server OpenID response missing values'); } @@ -55,9 +54,7 @@ export class BridgeAPI { cache: 'no-cache', method: 'POST', body: JSON.stringify({ - // eslint-disable-next-line camelcase matrixServer: matrix_server_name, - // eslint-disable-next-line camelcase openIdToken: access_token, } as ExchangeOpenAPIRequestBody), headers: { diff --git a/web/components/AdminSettings.tsx b/web/components/AdminSettings.tsx index c9a7d99e7..f192c7e58 100644 --- a/web/components/AdminSettings.tsx +++ b/web/components/AdminSettings.tsx @@ -36,23 +36,23 @@ export default function AdminSettings(props: IProps) { [setCurrentTab] ); if (busy) { - return
+ return
; } - return
-

Hookshot Bridge settings

-
-