Outbound Webhooks (#945)

* Initial support for outbound webhooks.

* Refactor outbound into it's own connection type.

* Add support for media / encrypted media.

* Ensure we configure a sensible User Agent

* Add a test for outbound webhooks

* Checkpoint for feature completeness.

* Lint tidy

* Finish up media tests.

* changelog

* Add outbound documentation

* update default config

* fix tests
This commit is contained in:
Will Hunt 2024-06-19 18:28:00 +01:00 committed by GitHub
parent 60ccc041b3
commit 7573d37527
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 840 additions and 42 deletions

3
changelog.d/945.feature Normal file
View File

@ -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.

View File

@ -100,6 +100,7 @@ listeners:
# #'allowJsTransformationFunctions' will allow users to write short transformation snippets in code, and thus is unsafe in untrusted environments # #'allowJsTransformationFunctions' will allow users to write short transformation snippets in code, and thus is unsafe in untrusted environments
# enabled: false # enabled: false
# outbound: false
# enableHttpGet: false # enableHttpGet: false
# urlPrefix: https://example.com/webhook/ # urlPrefix: https://example.com/webhook/
# userIdPrefix: _webhooks_ # userIdPrefix: _webhooks_

View File

@ -1,7 +1,7 @@
# Webhooks # Webhooks
Hookshot supports generic webhook support so that services can send messages into Matrix rooms without being aware of the Matrix protocol. This works Hookshot supports two kinds of webhooks, inbound (previously known as Generic Webhooks) and outbound.
by having services hit a unique URL that then transforms a HTTP payload into a Matrix message.
## Configuration ## Configuration
@ -10,6 +10,7 @@ You will need to add the following configuration to the config file.
```yaml ```yaml
generic: generic:
enabled: true enabled: true
outbound: true # For outbound webhook support
urlPrefix: https://example.com/mywebhookspath/ urlPrefix: https://example.com/mywebhookspath/
allowJsTransformationFunctions: false allowJsTransformationFunctions: false
waitForComplete: false waitForComplete: false
@ -17,6 +18,11 @@ generic:
# userIdPrefix: webhook_ # 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.
<section class="notice"> <section class="notice">
Previous versions of the bridge listened for requests on `/` rather than `/webhook`. While this behaviour will continue to work, 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`. administators are advised to use `/webhook`.
@ -50,7 +56,7 @@ namespaces:
exclusive: true exclusive: true
``` ```
## Adding a webhook ### Adding a webhook
To add a webhook to your room: To add a webhook to your room:
- Invite the bot user to the 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. - 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. - 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. 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**. 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: 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 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. 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 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. 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 <strong>not applied</st
variable, so it will contain proper float values. variable, so it will contain proper float values.
</section> </section>
### 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 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 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 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. immeditately respond with it's default response values.
## JavaScript Transformations ### JavaScript Transformations
<section class="notice"> <section class="notice">
Although every effort has been made to securely sandbox scripts, running untrusted code from users is always risky. Ensure safe permissions 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. 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 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`. 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 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. for a more precise error.
### V2 API #### V2 API
The `v2` api expects an object to be returned from the `result` variable. 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 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`. 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}` 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)

View File

@ -48,8 +48,8 @@
"@octokit/rest": "^20.0.2", "@octokit/rest": "^20.0.2",
"@octokit/webhooks": "^12.0.10", "@octokit/webhooks": "^12.0.10",
"@sentry/node": "^7.52.1", "@sentry/node": "^7.52.1",
"@vector-im/compound-design-tokens": "^0.1.0", "@vector-im/compound-design-tokens": "^1.3.0",
"@vector-im/compound-web": "^0.9.4", "@vector-im/compound-web": "^4.8.0",
"ajv": "^8.11.0", "ajv": "^8.11.0",
"axios": "^1.6.3", "axios": "^1.6.3",
"cors": "^2.8.5", "cors": "^2.8.5",
@ -86,6 +86,7 @@
"@rollup/plugin-alias": "^5.1.0", "@rollup/plugin-alias": "^5.1.0",
"@tsconfig/node18": "^18.2.2", "@tsconfig/node18": "^18.2.2",
"@types/ajv": "^1.0.0", "@types/ajv": "^1.0.0",
"@types/busboy": "^1.5.4",
"@types/chai": "^4.2.22", "@types/chai": "^4.2.22",
"@types/cors": "^2.8.12", "@types/cors": "^2.8.12",
"@types/express": "^4.17.14", "@types/express": "^4.17.14",
@ -100,6 +101,7 @@
"@typescript-eslint/eslint-plugin": "^6.17.0", "@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.17.0", "@typescript-eslint/parser": "^6.17.0",
"@uiw/react-codemirror": "^4.12.3", "@uiw/react-codemirror": "^4.12.3",
"busboy": "^1.6.0",
"chai": "^4.3.4", "chai": "^4.3.4",
"eslint": "^8.49.0", "eslint": "^8.49.0",
"eslint-config-preact": "^1.3.0", "eslint-config-preact": "^1.3.0",
@ -116,5 +118,6 @@
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vite": "^5.0.13" "vite": "^5.0.13"
} },
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
} }

1
spec/util/fixtures.ts Normal file

File diff suppressed because one or more lines are too long

201
spec/webhooks.spec.ts Normal file
View File

@ -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>(.+)<\/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);
// });
});

View File

@ -1164,11 +1164,15 @@ export class Bridge {
} }
if (!existingConnections.length) { if (!existingConnections.length) {
// Is anyone interested in this state? // Is anyone interested in this state?
try {
const connection = await this.connectionManager.createConnectionForState(roomId, new StateEvent(event), true); const connection = await this.connectionManager.createConnectionForState(roomId, new StateEvent(event), true);
if (connection) { if (connection) {
log.info(`New connected added to ${roomId}: ${connection.toString()}`); log.info(`New connected added to ${roomId}: ${connection.toString()}`);
this.connectionManager.push(connection); this.connectionManager.push(connection);
} }
} catch (ex) {
log.error(`Failed to handle connection for state ${event.type} in ${roomId}`, ex);
}
} }
const botUsersInRoom = this.botUsersManager.getBotUsersInRoom(roomId); const botUsersInRoom = this.botUsersManager.getBotUsersInRoom(roomId);

View File

@ -467,7 +467,6 @@ export class GenericHookConnection extends BaseConnection implements IConnection
successful, successful,
response: webhookResponse, response: webhookResponse,
}; };
} }
public static getProvisionerDetails(botUserId: string) { public static getProvisionerDetails(botUserId: string) {
@ -492,7 +491,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection
...(showSecrets ? { secrets: { ...(showSecrets ? { secrets: {
url: new URL(this.hookId, this.config.parsedUrlPrefix), url: new URL(this.hookId, this.config.parsedUrlPrefix),
hookId: this.hookId, hookId: this.hookId,
} as GenericHookSecrets} : undefined) } satisfies GenericHookSecrets} : undefined)
} }
} }

View File

@ -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<OutboundHookConnectionState, OutboundHookSecrets>;
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<string, unknown>): 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<Record<string, unknown>>, {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<string, unknown> = {}, {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<unknown>): Promise<{blob: Blob, event: MatrixEvent<unknown>}|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<unknown>): Promise<void> {
// 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<string, unknown>) {
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}`;
}
}

View File

@ -1,6 +1,6 @@
import { BotCommands, botCommand, compileBotCommands, HelpFunction } from "../BotCommands"; import { BotCommands, botCommand, compileBotCommands, HelpFunction } from "../BotCommands";
import { CommandConnection } from "./CommandConnection"; import { CommandConnection } from "./CommandConnection";
import { GenericHookConnection, GenericHookConnectionState, GitHubRepoConnection, JiraProjectConnection, JiraProjectConnectionState } from "."; import { GenericHookConnection, GenericHookConnectionState, GitHubRepoConnection, JiraProjectConnection, JiraProjectConnectionState, OutboundHookConnection } from ".";
import { CommandError } from "../errors"; import { CommandError } from "../errors";
import { BridgePermissionLevel } from "../config/Config"; import { BridgePermissionLevel } from "../config/Config";
import markdown from "markdown-it"; import markdown from "markdown-it";
@ -18,6 +18,8 @@ import { HoundConnection } from "./HoundConnection";
const md = new markdown(); const md = new markdown();
const log = new Logger("SetupConnection"); 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 * 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. * 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}\``)); 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}) @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) { public async onFigma(userId: string, url: string) {
if (!this.config.figma) { if (!this.config.figma) {

View File

@ -11,3 +11,4 @@ export * from "./IConnection";
export * from "./JiraProject"; export * from "./JiraProject";
export * from "./FigmaFileConnection"; export * from "./FigmaFileConnection";
export * from "./FeedConnection"; export * from "./FeedConnection";
export * from "./OutboundHook";

View File

@ -100,7 +100,8 @@ export class BridgeWidgetApi extends ProvisioningApi {
general: true, general: true,
github: !!this.config.github, github: !!this.config.github,
gitlab: !!this.config.gitlab, gitlab: !!this.config.gitlab,
generic: !!this.config.generic, generic: !!this.config.generic?.enabled,
genericOutbound: !!this.config.generic?.outbound,
jira: !!this.config.jira, jira: !!this.config.jira,
figma: !!this.config.figma, figma: !!this.config.figma,
feeds: !!this.config.feeds?.enabled, feeds: !!this.config.feeds?.enabled,

View File

@ -9,10 +9,9 @@ import { BridgeConfigActorPermission, BridgePermissions } from "../libRs";
import { ConfigError } from "../errors"; import { ConfigError } from "../errors";
import { ApiError, ErrCode } from "../api"; import { ApiError, ErrCode } from "../api";
import { GithubInstance, GITHUB_CLOUD_URL } from "../github/GithubInstance"; 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 { BridgeConfigCache } from "./sections/cache";
import { BridgeConfigQueue } from "./sections"; import { BridgeConfigQueue } from "./sections";
import { DefaultConfigRoot } from "./Defaults";
const log = new Logger("Config"); const log = new Logger("Config");
@ -296,10 +295,13 @@ export interface BridgeGenericWebhooksConfigYAML {
allowJsTransformationFunctions?: boolean; allowJsTransformationFunctions?: boolean;
waitForComplete?: boolean; waitForComplete?: boolean;
enableHttpGet?: boolean; enableHttpGet?: boolean;
outbound?: boolean;
disallowedIpRanges?: string[];
} }
export class BridgeConfigGenericWebhooks { export class BridgeConfigGenericWebhooks {
public readonly enabled: boolean; public readonly enabled: boolean;
public readonly outbound: boolean;
@hideKey() @hideKey()
public readonly parsedUrlPrefix: URL; public readonly parsedUrlPrefix: URL;
@ -311,6 +313,7 @@ export class BridgeConfigGenericWebhooks {
public readonly enableHttpGet: boolean; public readonly enableHttpGet: boolean;
constructor(yaml: BridgeGenericWebhooksConfigYAML) { constructor(yaml: BridgeGenericWebhooksConfigYAML) {
this.enabled = yaml.enabled || false; this.enabled = yaml.enabled || false;
this.outbound = yaml.outbound || false;
this.enableHttpGet = yaml.enableHttpGet || false; this.enableHttpGet = yaml.enableHttpGet || false;
try { try {
this.parsedUrlPrefix = makePrefixedUrl(yaml.urlPrefix); this.parsedUrlPrefix = makePrefixedUrl(yaml.urlPrefix);
@ -757,6 +760,9 @@ remove "useLegacySledStore" from your configuration file, and restart Hookshot.
} }
if (this.generic && this.generic.enabled) { if (this.generic && this.generic.enabled) {
services.push("generic"); services.push("generic");
if (this.generic.outbound) {
services.push("genericOutbound");
}
} }
if (this.github) { if (this.github) {
services.push("github"); services.push("github");
@ -788,6 +794,7 @@ remove "useLegacySledStore" from your configuration file, and restart Hookshot.
case "gitlab": case "gitlab":
config = this.gitlab?.publicConfig; config = this.gitlab?.publicConfig;
break; break;
case "genericOutbound":
case "jira": case "jira":
config = {}; config = {};
break; break;

View File

@ -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 LEGACY_ACCOUNT_DATA_GITLAB_TYPE = "uk.half-shot.matrix-github.gitlab.password-store:";
const log = new Logger("UserTokenStore"); const log = new Logger("UserTokenStore");
export type TokenType = "github"|"gitlab"|"jira"; export type TokenType = "github"|"gitlab"|"jira"|"generic";
export const AllowedTokenTypes = ["github", "gitlab", "jira"]; export const AllowedTokenTypes = ["github", "gitlab", "jira", "generic"];
interface StoredTokenData { interface StoredTokenData {
encrypted: string|string[]; encrypted: string|string[];
@ -165,6 +165,37 @@ export class UserTokenStore extends TypedEmitter<Emitter> {
return null; 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<string|null> {
const finalTokenKey = `generic:${namespace}:${key}`
const obj = await this.intent.underlyingClient.getSafeAccountData<StoredTokenData|DeletedTokenData>(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 { public static parseGitHubToken(token: string): GitHubOAuthToken {
if (!token.startsWith('{')) { if (!token.startsWith('{')) {
// Old style token // Old style token

View File

@ -4,6 +4,7 @@ import style from "./RoomConfigView.module.scss";
import { ConnectionCard } from "./ConnectionCard"; import { ConnectionCard } from "./ConnectionCard";
import { FeedsConfig } from "./roomConfig/FeedsConfig"; import { FeedsConfig } from "./roomConfig/FeedsConfig";
import { GenericWebhookConfig } from "./roomConfig/GenericWebhookConfig"; import { GenericWebhookConfig } from "./roomConfig/GenericWebhookConfig";
import { OutboundWebhookConfig } from "./roomConfig/OutboundWebhookConfig";
import { GithubRepoConfig } from "./roomConfig/GithubRepoConfig"; import { GithubRepoConfig } from "./roomConfig/GithubRepoConfig";
import { GitlabRepoConfig } from "./roomConfig/GitlabRepoConfig"; import { GitlabRepoConfig } from "./roomConfig/GitlabRepoConfig";
import { JiraProjectConfig } from "./roomConfig/JiraProjectConfig"; import { JiraProjectConfig } from "./roomConfig/JiraProjectConfig";
@ -25,6 +26,7 @@ interface IProps {
enum ConnectionType { enum ConnectionType {
Feeds = "feeds", Feeds = "feeds",
Generic = "generic", Generic = "generic",
GenericOutbound = "genericOutbound",
Github = "github", Github = "github",
Gitlab = "gitlab", Gitlab = "gitlab",
Jira = "jira", Jira = "jira",
@ -65,12 +67,19 @@ const connections: Record<ConnectionType, IConnectionProps> = {
component: JiraProjectConfig, component: JiraProjectConfig,
}, },
[ConnectionType.Generic]: { [ConnectionType.Generic]: {
displayName: 'Generic Webhook', displayName: 'Inbound (Generic) Webhook',
description: "Create a webhook which can be used to connect any service to Matrix", description: "Create a webhook which can be used to connect any service to Matrix",
icon: WebhookIcon, icon: WebhookIcon,
darkIcon: true, darkIcon: true,
component: GenericWebhookConfig, 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) { export default function RoomConfigView(props: IProps) {

View File

@ -103,7 +103,7 @@ interface ServiceConfig {
} }
const RoomConfigText = { const RoomConfigText = {
header: 'Generic Webhooks', header: 'Inbound (Generic) Webhooks',
createNew: 'Create new webhook', createNew: 'Create new webhook',
listCanEdit: 'Your webhooks', listCanEdit: 'Your webhooks',
listCantEdit: 'Configured webhooks', listCantEdit: 'Configured webhooks',

View File

@ -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<ConnectionConfigurationProps<ServiceConfig, OutboundHookResponseItem, OutboundHookConnectionState>> = ({existingConnection, onSave, onRemove, isUpdating}) => {
const [outboundUrl, setOutboundUrl] = useState<string>(existingConnection?.config.url ?? '');
const nameRef = createRef<HTMLInputElement>();
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<boolean>(false);
const revealToken = useCallback((evt: any) => {
evt.preventDefault();
setTokenRevealed(true);
}, [setTokenRevealed]);
return <form onSubmit={handleSave}>
<InputField visible={!existingConnection} label="Friendly name" noPadding={true}>
<input ref={nameRef} disabled={!canEdit} placeholder="My webhook" type="text" value={existingConnection?.config.name} />
</InputField>
<InputField label="URL" noPadding={true}>
<input onChange={onUrlChange} placeholder="https://example.org/my-webhook" type="text" value={outboundUrl} />
</InputField>
<InputField visible={!!existingConnection} label="Token" noPadding={true}>
<input onClick={revealToken} readOnly={true} type="text" value={tokenRevealed ? existingConnection?.secrets?.token : "Click to reveal"} />
</InputField>
<ButtonSet>
{ canEdit && <Button disabled={isUpdating} type="submit">{ existingConnection ? "Save" : "Add webhook" }</Button>}
{ canEdit && existingConnection && <Button disabled={isUpdating} intent="remove" onClick={onRemove}>Remove webhook</Button>}
</ButtonSet>
</form>;
};
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 <RoomConfig<ServiceConfig, OutboundHookResponseItem, OutboundHookConnectionState>
headerImg={WebhookIcon}
darkHeaderImg={true}
showHeader={showHeader}
roomId={roomId}
type="genericOutbound"
connectionEventType="uk.half-shot.matrix-hookshot.outbound-hook"
text={RoomConfigText}
listItemName={RoomConfigListItemFunc}
connectionConfigComponent={ConnectionConfiguration}
/>;
};

127
yarn.lock
View File

@ -610,6 +610,13 @@
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.56.0.tgz#ef20350fec605a7f7035a01764731b2de0f3782b" resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.56.0.tgz#ef20350fec605a7f7035a01764731b2de0f3782b"
integrity sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A== 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": "@floating-ui/core@^1.4.2":
version "1.5.2" version "1.5.2"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.5.2.tgz#53a0f7a98c550e63134d504f26804f6b83dbc071" resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.5.2.tgz#53a0f7a98c550e63134d504f26804f6b83dbc071"
@ -617,6 +624,14 @@
dependencies: dependencies:
"@floating-ui/utils" "^0.1.3" "@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": "@floating-ui/dom@^1.5.1":
version "1.5.3" version "1.5.3"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.5.3.tgz#54e50efcb432c06c23cd33de2b575102005436fa" resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.5.3.tgz#54e50efcb432c06c23cd33de2b575102005436fa"
@ -632,11 +647,32 @@
dependencies: dependencies:
"@floating-ui/dom" "^1.5.1" "@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": "@floating-ui/utils@^0.1.3":
version "0.1.6" version "0.1.6"
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.6.tgz#22958c042e10b67463997bd6ea7115fe28cbcaf9" resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.6.tgz#22958c042e10b67463997bd6ea7115fe28cbcaf9"
integrity sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A== 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": "@humanwhocodes/config-array@^0.11.13":
version "0.11.13" version "0.11.13"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.13.tgz#075dc9684f40a531d9b26b0822153c1e832ee297" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.13.tgz#075dc9684f40a531d9b26b0822153c1e832ee297"
@ -1325,6 +1361,19 @@
dependencies: dependencies:
"@babel/runtime" "^7.13.10" "@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": "@radix-ui/react-context@1.0.1":
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.0.1.tgz#fe46e67c96b240de59187dcb7a1a50ce3e2ec00c" 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" "@babel/runtime" "^7.13.10"
"@radix-ui/react-primitive" "1.0.3" "@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" version "1.0.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz#a9ff4423eade67f501ffb32ec22064bc9d3099ab" resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz#a9ff4423eade67f501ffb32ec22064bc9d3099ab"
integrity sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg== integrity sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==
@ -1859,6 +1908,13 @@
resolved "https://registry.yarnpkg.com/@types/btoa-lite/-/btoa-lite-1.0.2.tgz#82bb6aab00abf7cff3ca2825abe010c0cd536ae5" resolved "https://registry.yarnpkg.com/@types/btoa-lite/-/btoa-lite-1.0.2.tgz#82bb6aab00abf7cff3ca2825abe010c0cd536ae5"
integrity sha512-ZYbcE2x7yrvNFJiU7xJGrpF/ihpkM7zKgw8bha3LNJSesvTtUNxbpzaT7WXBIryf6jovisrxTBvymxMeLLj1Mg== 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@*": "@types/caseless@*":
version "0.12.5" version "0.12.5"
resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.5.tgz#db9468cb1b1b5a925b8f34822f1669df0c5472f5" 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" resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406"
integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==
"@vector-im/compound-design-tokens@^0.1.0": "@vector-im/compound-design-tokens@^1.3.0":
version "0.1.0" version "1.3.0"
resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-0.1.0.tgz#1a574fba872ff93b1de8490f475e30b922cd02a2" resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-1.3.0.tgz#1d04f006a9e56b920432095d08d7c84c0933ebc7"
integrity sha512-vnDrd1CPPR7CwQLss/JnIE1ga6QwmCkhgBvXm1huMhCs7nIiqf90Sbgc0WugbHNaRXGEEhMVGrE69DaQIUcqOA== integrity sha512-RXcyEAdxNzekMhVuvxtLPt9zb6yT2N+5cnb2Hul9zwRiF7+XEHpD36+IF6V0QOXk2pkN0wOr3jCvc9eOWOq9SQ==
dependencies: dependencies:
svg2vectordrawable "^2.9.1" svg2vectordrawable "^2.9.1"
"@vector-im/compound-web@^0.9.4": "@vector-im/compound-web@^4.8.0":
version "0.9.4" version "4.8.0"
resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-0.9.4.tgz#89ac6d136c5f9d553de0e8540398a8a4b6cdeb6f" resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-4.8.0.tgz#1fe11d78549694f8d91b40065994bad19a7cebf2"
integrity sha512-L1N0xe3G7k35b3i+5teYg1nplsbz8p+VOxIGWIPU4H7D4PBCxhf9i7ft8aJjLsIdIaInJkqjvKwPU+Yb/yvgUQ== integrity sha512-kyB8wQPbdTUFWIzAbb4HcZ4iisUUpbm0xwmEjV9ZNN1/EIodidW6nLeYATh3Vc1fBvTGTgbFiPc1DiAcBuudiw==
dependencies: 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-dropdown-menu" "^2.0.6"
"@radix-ui/react-form" "^0.0.3" "@radix-ui/react-form" "^0.0.3"
"@radix-ui/react-separator" "^1.0.3" "@radix-ui/react-separator" "^1.0.3"
"@radix-ui/react-slot" "^1.0.2"
"@radix-ui/react-tooltip" "^1.0.6" "@radix-ui/react-tooltip" "^1.0.6"
classnames "^2.3.2" classnames "^2.3.2"
graphemer "^1.4.0"
vaul "^0.7.0" vaul "^0.7.0"
abbrev@1: 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" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== 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: bytes@3.1.2:
version "3.1.2" version "3.1.2"
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
@ -7429,6 +7495,11 @@ stream-length@^1.0.2:
dependencies: dependencies:
bluebird "^2.6.2" 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: string-argv@^0.3.1:
version "0.3.2" version "0.3.2"
resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" 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" char-regex "^1.0.2"
strip-ansi "^6.0.0" 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" version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -7509,7 +7589,14 @@ string_decoder@^1.1.1:
dependencies: dependencies:
safe-buffer "~5.2.0" 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" version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -7626,6 +7713,11 @@ svgpath@^2.5.0:
resolved "https://registry.yarnpkg.com/svgpath/-/svgpath-2.6.0.tgz#5b160ef3d742b7dfd2d721bf90588d3450d7a90d" resolved "https://registry.yarnpkg.com/svgpath/-/svgpath-2.6.0.tgz#5b160ef3d742b7dfd2d721bf90588d3450d7a90d"
integrity sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg== 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: tdigest@^0.1.1:
version "0.1.2" version "0.1.2"
resolved "https://registry.yarnpkg.com/tdigest/-/tdigest-0.1.2.tgz#96c64bac4ff10746b910b0e23b515794e12faced" 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" resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343"
integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw== 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" version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@ -8144,6 +8236,15 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0" string-width "^4.1.0"
strip-ansi "^6.0.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: wrap-ansi@^8.1.0:
version "8.1.0" version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"