diff --git a/changelog.d/945.feature b/changelog.d/945.feature new file mode 100644 index 00000000..bf4e2350 --- /dev/null +++ b/changelog.d/945.feature @@ -0,0 +1,3 @@ +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. diff --git a/config.sample.yml b/config.sample.yml index 00201678..6360b22b 100644 --- a/config.sample.yml +++ b/config.sample.yml @@ -100,6 +100,7 @@ listeners: # #'allowJsTransformationFunctions' will allow users to write short transformation snippets in code, and thus is unsafe in untrusted environments # enabled: false +# outbound: false # enableHttpGet: false # urlPrefix: https://example.com/webhook/ # userIdPrefix: _webhooks_ diff --git a/docs/setup/webhooks.md b/docs/setup/webhooks.md index 57938164..d3cc096d 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,6 +10,7 @@ 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 @@ -17,6 +18,11 @@ generic: # 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`. @@ -50,7 +56,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 +64,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 +82,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 +94,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 +108,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 +117,7 @@ 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 +### 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 +136,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 +147,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 +182,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 +198,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/package.json b/package.json index 0fe78286..b017eccc 100644 --- a/package.json +++ b/package.json @@ -48,8 +48,8 @@ "@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": "^1.3.0", + "@vector-im/compound-web": "^4.8.0", "ajv": "^8.11.0", "axios": "^1.6.3", "cors": "^2.8.5", @@ -86,6 +86,7 @@ "@rollup/plugin-alias": "^5.1.0", "@tsconfig/node18": "^18.2.2", "@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", @@ -100,6 +101,7 @@ "@typescript-eslint/eslint-plugin": "^6.17.0", "@typescript-eslint/parser": "^6.17.0", "@uiw/react-codemirror": "^4.12.3", + "busboy": "^1.6.0", "chai": "^4.3.4", "eslint": "^8.49.0", "eslint-config-preact": "^1.3.0", @@ -116,5 +118,6 @@ "ts-node": "^10.9.1", "typescript": "^5.3.3", "vite": "^5.0.13" - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/spec/util/fixtures.ts b/spec/util/fixtures.ts new file mode 100644 index 00000000..ae6de768 --- /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/webhooks.spec.ts b/spec/webhooks.spec.ts new file mode 100644 index 00000000..9238caa4 --- /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/Bridge.ts b/src/Bridge.ts index af0b336e..f968c804 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -1164,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/Connections/GenericHook.ts b/src/Connections/GenericHook.ts index 5aa87de0..80cee3ad 100644 --- a/src/Connections/GenericHook.ts +++ b/src/Connections/GenericHook.ts @@ -467,7 +467,6 @@ export class GenericHookConnection extends BaseConnection implements IConnection successful, response: webhookResponse, }; - } public static getProvisionerDetails(botUserId: string) { @@ -492,7 +491,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection ...(showSecrets ? { secrets: { url: new URL(this.hookId, this.config.parsedUrlPrefix), hookId: this.hookId, - } as GenericHookSecrets} : undefined) + } satisfies GenericHookSecrets} : undefined) } } diff --git a/src/Connections/OutboundHook.ts b/src/Connections/OutboundHook.ts new file mode 100644 index 00000000..a6c4301b --- /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 59877fb0..c642b3e4 100644 --- a/src/Connections/SetupConnection.ts +++ b/src/Connections/SetupConnection.ts @@ -1,6 +1,6 @@ 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"; @@ -18,6 +18,8 @@ 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. @@ -284,6 +286,35 @@ export class SetupConnection extends CommandConnection { return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Removed webhook \`${name}\``)); } + + + @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) { diff --git a/src/Connections/index.ts b/src/Connections/index.ts index d56143c9..c06b97cb 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/Widgets/BridgeWidgetApi.ts b/src/Widgets/BridgeWidgetApi.ts index 0962d5d0..16ff84fd 100644 --- a/src/Widgets/BridgeWidgetApi.ts +++ b/src/Widgets/BridgeWidgetApi.ts @@ -100,7 +100,8 @@ 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, diff --git a/src/config/Config.ts b/src/config/Config.ts index 83288f97..3884a0c7 100644 --- a/src/config/Config.ts +++ b/src/config/Config.ts @@ -9,10 +9,9 @@ import { BridgeConfigActorPermission, BridgePermissions } from "../libRs"; import { ConfigError } from "../errors"; import { ApiError, ErrCode } from "../api"; import { GithubInstance, GITHUB_CLOUD_URL } from "../github/GithubInstance"; -import { Logger } from "matrix-appservice-bridge"; +import { DefaultDisallowedIpRanges, Logger } from "matrix-appservice-bridge"; import { BridgeConfigCache } from "./sections/cache"; import { BridgeConfigQueue } from "./sections"; -import { DefaultConfigRoot } from "./Defaults"; const log = new Logger("Config"); @@ -296,10 +295,13 @@ export interface BridgeGenericWebhooksConfigYAML { allowJsTransformationFunctions?: boolean; waitForComplete?: boolean; enableHttpGet?: boolean; + outbound?: boolean; + disallowedIpRanges?: string[]; } export class BridgeConfigGenericWebhooks { public readonly enabled: boolean; + public readonly outbound: boolean; @hideKey() public readonly parsedUrlPrefix: URL; @@ -311,6 +313,7 @@ export class BridgeConfigGenericWebhooks { public readonly enableHttpGet: boolean; constructor(yaml: BridgeGenericWebhooksConfigYAML) { this.enabled = yaml.enabled || false; + this.outbound = yaml.outbound || false; this.enableHttpGet = yaml.enableHttpGet || false; try { this.parsedUrlPrefix = makePrefixedUrl(yaml.urlPrefix); @@ -757,6 +760,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"); @@ -788,6 +794,7 @@ remove "useLegacySledStore" from your configuration file, and restart Hookshot. case "gitlab": config = this.gitlab?.publicConfig; break; + case "genericOutbound": case "jira": config = {}; break; diff --git a/src/tokens/UserTokenStore.ts b/src/tokens/UserTokenStore.ts index 9a30df73..c02d590a 100644 --- a/src/tokens/UserTokenStore.ts +++ b/src/tokens/UserTokenStore.ts @@ -26,8 +26,8 @@ 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[]; @@ -165,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/web/components/RoomConfigView.tsx b/web/components/RoomConfigView.tsx index 4a8c23b6..3ce4af35 100644 --- a/web/components/RoomConfigView.tsx +++ b/web/components/RoomConfigView.tsx @@ -4,6 +4,7 @@ import style from "./RoomConfigView.module.scss"; import { ConnectionCard } from "./ConnectionCard"; import { FeedsConfig } from "./roomConfig/FeedsConfig"; import { GenericWebhookConfig } from "./roomConfig/GenericWebhookConfig"; +import { OutboundWebhookConfig } from "./roomConfig/OutboundWebhookConfig"; import { GithubRepoConfig } from "./roomConfig/GithubRepoConfig"; import { GitlabRepoConfig } from "./roomConfig/GitlabRepoConfig"; import { JiraProjectConfig } from "./roomConfig/JiraProjectConfig"; @@ -25,6 +26,7 @@ interface IProps { enum ConnectionType { Feeds = "feeds", Generic = "generic", + GenericOutbound = "genericOutbound", Github = "github", Gitlab = "gitlab", Jira = "jira", @@ -65,12 +67,19 @@ const connections: Record = { component: JiraProjectConfig, }, [ConnectionType.Generic]: { - displayName: 'Generic Webhook', + displayName: 'Inbound (Generic) Webhook', description: "Create a webhook which can be used to connect any service to Matrix", icon: WebhookIcon, darkIcon: true, component: GenericWebhookConfig, }, + [ConnectionType.GenericOutbound]: { + displayName: 'Outbound Webhook', + description: "Create a webhook which can be used to connect any service to Matrix", + icon: WebhookIcon, + darkIcon: true, + component: OutboundWebhookConfig, + }, }; export default function RoomConfigView(props: IProps) { diff --git a/web/components/roomConfig/GenericWebhookConfig.tsx b/web/components/roomConfig/GenericWebhookConfig.tsx index 6a5ae3aa..ec583f41 100644 --- a/web/components/roomConfig/GenericWebhookConfig.tsx +++ b/web/components/roomConfig/GenericWebhookConfig.tsx @@ -103,7 +103,7 @@ interface ServiceConfig { } const RoomConfigText = { - header: 'Generic Webhooks', + header: 'Inbound (Generic) Webhooks', createNew: 'Create new webhook', listCanEdit: 'Your webhooks', listCantEdit: 'Configured webhooks', diff --git a/web/components/roomConfig/OutboundWebhookConfig.tsx b/web/components/roomConfig/OutboundWebhookConfig.tsx new file mode 100644 index 00000000..d64b6517 --- /dev/null +++ b/web/components/roomConfig/OutboundWebhookConfig.tsx @@ -0,0 +1,85 @@ +import { FunctionComponent, createRef } from "preact"; +import { useCallback, useState } from "preact/hooks" +import { BridgeConfig } from "../../BridgeAPI"; +import type { OutboundHookConnectionState, OutboundHookResponseItem } from "../../../src/Connections/OutboundHook"; +import { ConnectionConfigurationProps, RoomConfig } from "./RoomConfig"; +import { InputField, ButtonSet, Button } from "../elements"; +import WebhookIcon from "../../icons/webhook.png"; + +const ConnectionConfiguration: FunctionComponent> = ({existingConnection, onSave, onRemove, isUpdating}) => { + const [outboundUrl, setOutboundUrl] = useState(existingConnection?.config.url ?? ''); + + const nameRef = createRef(); + + const canEdit = !existingConnection || (existingConnection?.canEdit ?? false); + const handleSave = useCallback((evt: Event) => { + evt.preventDefault(); + if (!canEdit) { + return; + } + onSave({ + name: nameRef?.current?.value || existingConnection?.config.name || "Generic Webhook", + url: outboundUrl, + }); + }, [canEdit, onSave, nameRef, outboundUrl, existingConnection]); + + const onUrlChange = useCallback((evt: any) => { + setOutboundUrl(evt.target?.value); + }, [setOutboundUrl]); + + const [tokenRevealed, setTokenRevealed] = useState(false); + + const revealToken = useCallback((evt: any) => { + evt.preventDefault(); + setTokenRevealed(true); + }, [setTokenRevealed]); + + + + return
+ + + + + + + + + + + + + + { canEdit && } + { canEdit && existingConnection && } + +
; +}; + +interface ServiceConfig { + allowJsTransformationFunctions: boolean, + waitForComplete: boolean, +} + +const RoomConfigText = { + header: 'Outbound Webhooks', + createNew: 'Create new webhook', + listCanEdit: 'Your webhooks', + listCantEdit: 'Configured webhooks', +}; + +const RoomConfigListItemFunc = (c: OutboundHookResponseItem) => c.config.name; + +export const OutboundWebhookConfig: BridgeConfig = ({ roomId, showHeader }) => { + return + headerImg={WebhookIcon} + darkHeaderImg={true} + showHeader={showHeader} + roomId={roomId} + type="genericOutbound" + connectionEventType="uk.half-shot.matrix-hookshot.outbound-hook" + text={RoomConfigText} + listItemName={RoomConfigListItemFunc} + connectionConfigComponent={ConnectionConfiguration} + />; +}; diff --git a/yarn.lock b/yarn.lock index 9f45fb74..8c9071cd9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -610,6 +610,13 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.56.0.tgz#ef20350fec605a7f7035a01764731b2de0f3782b" integrity sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A== +"@floating-ui/core@^1.0.0": + version "1.6.2" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.2.tgz#d37f3e0ac1f1c756c7de45db13303a266226851a" + integrity sha512-+2XpQV9LLZeanU4ZevzRnGFg2neDeKHgFLjP6YLW+tly0IvrhqT4u8enLGjLH3qeh85g19xY5rsAusfwTdn5lg== + dependencies: + "@floating-ui/utils" "^0.2.0" + "@floating-ui/core@^1.4.2": version "1.5.2" resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.5.2.tgz#53a0f7a98c550e63134d504f26804f6b83dbc071" @@ -617,6 +624,14 @@ dependencies: "@floating-ui/utils" "^0.1.3" +"@floating-ui/dom@^1.0.0": + version "1.6.5" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.5.tgz#323f065c003f1d3ecf0ff16d2c2c4d38979f4cb9" + integrity sha512-Nsdud2X65Dz+1RHjAIP0t8z5e2ff/IRbei6BqFrl1urT8sDVzM1HMQ+R0XcU5ceRfyO3I6ayeqIfh+6Wb8LGTw== + dependencies: + "@floating-ui/core" "^1.0.0" + "@floating-ui/utils" "^0.2.0" + "@floating-ui/dom@^1.5.1": version "1.5.3" resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.5.3.tgz#54e50efcb432c06c23cd33de2b575102005436fa" @@ -632,11 +647,32 @@ dependencies: "@floating-ui/dom" "^1.5.1" +"@floating-ui/react-dom@^2.0.8", "@floating-ui/react-dom@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.0.tgz#4f0e5e9920137874b2405f7d6c862873baf4beff" + integrity sha512-lNzj5EQmEKn5FFKc04+zasr09h/uX8RtJRNj5gUXsSQIXHVWTVh+hVAg1vOMCexkX8EgvemMvIFpQfkosnVNyA== + dependencies: + "@floating-ui/dom" "^1.0.0" + +"@floating-ui/react@^0.26.9": + version "0.26.17" + resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.26.17.tgz#efa2e1a0dea3d9d308965c5ccd49756bb64a883d" + integrity sha512-ESD+jYWwqwVzaIgIhExrArdsCL1rOAzryG/Sjlu8yaD3Mtqi3uVyhbE2V7jD58Mo52qbzKz2eUY/Xgh5I86FCQ== + dependencies: + "@floating-ui/react-dom" "^2.1.0" + "@floating-ui/utils" "^0.2.0" + tabbable "^6.0.0" + "@floating-ui/utils@^0.1.3": version "0.1.6" resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.6.tgz#22958c042e10b67463997bd6ea7115fe28cbcaf9" integrity sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A== +"@floating-ui/utils@^0.2.0": + version "0.2.2" + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.2.tgz#d8bae93ac8b815b2bd7a98078cf91e2724ef11e5" + integrity sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw== + "@humanwhocodes/config-array@^0.11.13": version "0.11.13" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.13.tgz#075dc9684f40a531d9b26b0822153c1e832ee297" @@ -1325,6 +1361,19 @@ dependencies: "@babel/runtime" "^7.13.10" +"@radix-ui/react-context-menu@^2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@radix-ui/react-context-menu/-/react-context-menu-2.1.5.tgz#1bdbd72761439f9166f75dc4598f276265785c83" + integrity sha512-R5XaDj06Xul1KGb+WP8qiOh7tKJNz2durpLBXAGZjSVtctcRFCuEvy2gtMwRJGePwQQE5nV77gs4FwRi8T+r2g== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-menu" "2.0.6" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-use-callback-ref" "1.0.1" + "@radix-ui/react-use-controllable-state" "1.0.1" + "@radix-ui/react-context@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.0.1.tgz#fe46e67c96b240de59187dcb7a1a50ce3e2ec00c" @@ -1523,7 +1572,7 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-primitive" "1.0.3" -"@radix-ui/react-slot@1.0.2": +"@radix-ui/react-slot@1.0.2", "@radix-ui/react-slot@^1.0.2": version "1.0.2" resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz#a9ff4423eade67f501ffb32ec22064bc9d3099ab" integrity sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg== @@ -1859,6 +1908,13 @@ resolved "https://registry.yarnpkg.com/@types/btoa-lite/-/btoa-lite-1.0.2.tgz#82bb6aab00abf7cff3ca2825abe010c0cd536ae5" integrity sha512-ZYbcE2x7yrvNFJiU7xJGrpF/ihpkM7zKgw8bha3LNJSesvTtUNxbpzaT7WXBIryf6jovisrxTBvymxMeLLj1Mg== +"@types/busboy@^1.5.4": + version "1.5.4" + resolved "https://registry.yarnpkg.com/@types/busboy/-/busboy-1.5.4.tgz#0038c31102ca90f2a7f0d8bc27ee5ebf1088e230" + integrity sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw== + dependencies: + "@types/node" "*" + "@types/caseless@*": version "0.12.5" resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.5.tgz#db9468cb1b1b5a925b8f34822f1669df0c5472f5" @@ -2273,24 +2329,27 @@ resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== -"@vector-im/compound-design-tokens@^0.1.0": - version "0.1.0" - resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-0.1.0.tgz#1a574fba872ff93b1de8490f475e30b922cd02a2" - integrity sha512-vnDrd1CPPR7CwQLss/JnIE1ga6QwmCkhgBvXm1huMhCs7nIiqf90Sbgc0WugbHNaRXGEEhMVGrE69DaQIUcqOA== +"@vector-im/compound-design-tokens@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-1.3.0.tgz#1d04f006a9e56b920432095d08d7c84c0933ebc7" + integrity sha512-RXcyEAdxNzekMhVuvxtLPt9zb6yT2N+5cnb2Hul9zwRiF7+XEHpD36+IF6V0QOXk2pkN0wOr3jCvc9eOWOq9SQ== dependencies: svg2vectordrawable "^2.9.1" -"@vector-im/compound-web@^0.9.4": - version "0.9.4" - resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-0.9.4.tgz#89ac6d136c5f9d553de0e8540398a8a4b6cdeb6f" - integrity sha512-L1N0xe3G7k35b3i+5teYg1nplsbz8p+VOxIGWIPU4H7D4PBCxhf9i7ft8aJjLsIdIaInJkqjvKwPU+Yb/yvgUQ== +"@vector-im/compound-web@^4.8.0": + version "4.8.0" + resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-4.8.0.tgz#1fe11d78549694f8d91b40065994bad19a7cebf2" + integrity sha512-kyB8wQPbdTUFWIzAbb4HcZ4iisUUpbm0xwmEjV9ZNN1/EIodidW6nLeYATh3Vc1fBvTGTgbFiPc1DiAcBuudiw== dependencies: + "@floating-ui/react" "^0.26.9" + "@floating-ui/react-dom" "^2.0.8" + "@radix-ui/react-context-menu" "^2.1.5" "@radix-ui/react-dropdown-menu" "^2.0.6" "@radix-ui/react-form" "^0.0.3" "@radix-ui/react-separator" "^1.0.3" + "@radix-ui/react-slot" "^1.0.2" "@radix-ui/react-tooltip" "^1.0.6" classnames "^2.3.2" - graphemer "^1.4.0" vaul "^0.7.0" abbrev@1: @@ -2859,6 +2918,13 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== +busboy@^1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" + integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== + dependencies: + streamsearch "^1.1.0" + bytes@3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" @@ -7429,6 +7495,11 @@ stream-length@^1.0.2: dependencies: bluebird "^2.6.2" +streamsearch@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" + integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== + string-argv@^0.3.1: version "0.3.2" resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" @@ -7442,7 +7513,16 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -7509,7 +7589,14 @@ string_decoder@^1.1.1: dependencies: safe-buffer "~5.2.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -7626,6 +7713,11 @@ svgpath@^2.5.0: resolved "https://registry.yarnpkg.com/svgpath/-/svgpath-2.6.0.tgz#5b160ef3d742b7dfd2d721bf90588d3450d7a90d" integrity sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg== +tabbable@^6.0.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.2.0.tgz#732fb62bc0175cfcec257330be187dcfba1f3b97" + integrity sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew== + tdigest@^0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/tdigest/-/tdigest-0.1.2.tgz#96c64bac4ff10746b910b0e23b515794e12faced" @@ -8126,7 +8218,7 @@ workerpool@6.2.1: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343" integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -8144,6 +8236,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"