diff --git a/.github/workflows/docker-hub-latest.yml b/.github/workflows/docker-hub-latest.yml index dfcbf3cc..e7c3544f 100644 --- a/.github/workflows/docker-hub-latest.yml +++ b/.github/workflows/docker-hub-latest.yml @@ -4,6 +4,8 @@ name: "Docker Hub - Latest" on: push: + pull_request: + branches: [ main ] env: DOCKER_NAMESPACE: halfshot @@ -23,6 +25,7 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 - name: Log in to Docker Hub + if: github.ref == 'refs/heads/main' uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} diff --git a/CHANGELOG.md b/CHANGELOG.md index f7a364c9..d29cec60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,131 @@ +3.0.0 (2023-03-17) +================== + +This release includes some new landmark improvements to support **public Hookshots**. + +One key feature is the new Go-NEB migrator. If you run a Go-NEB instance currently and are looking for a way to migrate GitHub and RSS feeds +over to Hookshot, there is a nice fancy widget feature for this. + +The other feature is we have now implemented a "Grant" system for authorising new connections in rooms. Simply put when you create a new connection +in a room to a remote service like GitHub, we now store the validity of that authorisation in the bridge. This is a new change where previously we +would not persist this authorization between sessions, so it was possible for users (who were permitted in the config to `manageConnections`) to create +connections to anywhere Hookshot was already configured to talk to. This piece of extra security means we can now be more confident about allowing Hookshot +to be used in public spaces. + +Upgrading to 3.0.0 **is breaking**, as the new grant system will run against any of your previous connections. It is imperative that where you have +created or edited a connection manually in the room state, that you are still authenticated to the service it is connected to. For instance, ensure +you are logged into GitHub if you have created manual GitHub connections. You can check the logs for any information on which connections have not +been granted. + +For any users who are not able to immediately update, but are nontheless worried about the consequeneces for this change: Do not panic. You can always +update the permissions in your config to only allow `manageConnections` to users you trust. + +If you have any questions about this change, do not hesistate to reach out to `#hookshot:half-shot.uk`. + +Features +-------- + +- Add support from migrating go-neb services to Hookshot ([\#647](https://github.com/matrix-org/matrix-hookshot/issues/647)) +- Implement grant system to internally record all approved connections in hookshot. ([\#655](https://github.com/matrix-org/matrix-hookshot/issues/655)) + + +Bugfixes +-------- + +- `roomSetupWidget` in widget config does now allow an empty value ([\#657](https://github.com/matrix-org/matrix-hookshot/issues/657)) +- Fix service bots not being able to reject invites with a reason. ([\#659](https://github.com/matrix-org/matrix-hookshot/issues/659)) +- Fix Hookshot presenting room connections as editable if the user has a default-or-greater power levels. This was only a presentation bug, power levels were and are proeprly checked at creation/edit time. ([\#660](https://github.com/matrix-org/matrix-hookshot/issues/660)) +- Add support for logging into GitHub via OAuth from bridge widgets. ([\#661](https://github.com/matrix-org/matrix-hookshot/issues/661)) + + +Improved Documentation +---------------------- + +- Update docs and sample config for serviceBots. Thanks to @HarHarLinks. ([\#643](https://github.com/matrix-org/matrix-hookshot/issues/643)) + + +Internal Changes +---------------- + +- Replace `uuid` package with `crypto.randomUUID` function. ([\#640](https://github.com/matrix-org/matrix-hookshot/issues/640)) +- Minor improvements to widget UI styles. ([\#652](https://github.com/matrix-org/matrix-hookshot/issues/652)) +- Run docker-latest CI for incoming pull requests. ([\#662](https://github.com/matrix-org/matrix-hookshot/issues/662)) + + +2.7.0 (2023-01-20) +================== + +Features +-------- + +- The room configuration widget now features an improved project search component, which now shows project avatars and descriptions. ([\#624](https://github.com/matrix-org/matrix-hookshot/issues/624)) + + +2.6.1 (2023-01-16) +================== + +Features +-------- + +- The message in the admin room when creating a webhook now also shows the name and links to the room. ([\#620](https://github.com/matrix-org/matrix-hookshot/issues/620)) + + +Bugfixes +-------- + +- Fixed generic webhook 'user is already in the room' error ([\#627](https://github.com/matrix-org/matrix-hookshot/issues/627)) +- Hookshot now handles `uk.half-shot.matrix-hookshot.generic.hook` state event updates ([\#628](https://github.com/matrix-org/matrix-hookshot/issues/628)) + + +2.6.0 (2023-01-13) +================== + +Features +-------- + +- Add support for end-to-bridge encryption via MSC3202. ([\#299](https://github.com/matrix-org/matrix-hookshot/issues/299)) +- Add support for additional bot users called "service bots" which handle a particular connection type, so that different services can be used through different bot users. ([\#573](https://github.com/matrix-org/matrix-hookshot/issues/573)) +- Add new GitHubRepo connection config setting `workflowRun.workflows` to filter run reports by workflow name. ([\#588](https://github.com/matrix-org/matrix-hookshot/issues/588)) +- The GitHub/GitLab connection state configuration has changed. The configuration option `ignoreHooks` is now deprecated, and new connections may not use this options. + Users should instead explicitly configure all the hooks they want to enable with the `enableHooks` option. Existing connections will continue to work with both options. ([\#592](https://github.com/matrix-org/matrix-hookshot/issues/592)) +- A11y: Add alt tags to all images. ([\#602](https://github.com/matrix-org/matrix-hookshot/issues/602)) + + +Bugfixes +-------- + +- Parent projects are now taken into account when calculating a user's access level to a GitLab project. ([\#539](https://github.com/matrix-org/matrix-hookshot/issues/539)) +- Ensure bridge treats published and drafted GitHub releases as different events. ([\#582](https://github.com/matrix-org/matrix-hookshot/issues/582)) +- Fix a bug where unknown keys in a connections state would be clobbered when updated via widget UI. ([\#587](https://github.com/matrix-org/matrix-hookshot/issues/587)) +- Improve webhook code editor performance. ([\#601](https://github.com/matrix-org/matrix-hookshot/issues/601)) +- Correctly apply CSS for recent RSS feed changes. ([\#604](https://github.com/matrix-org/matrix-hookshot/issues/604)) +- Improve startup stability by not loading all room state at once. ([\#614](https://github.com/matrix-org/matrix-hookshot/issues/614)) +- You can now add multiple GitLab connections to the same room with the same project path, if they are under different instances. ([\#617](https://github.com/matrix-org/matrix-hookshot/issues/617)) + + +Improved Documentation +---------------------- + +- Clarify GitLab setup docs ([\#350](https://github.com/matrix-org/matrix-hookshot/issues/350)) +- Change URL protocol in the ocumentation and sample configs to HTTPS. ([\#623](https://github.com/matrix-org/matrix-hookshot/issues/623)) + + +Deprecations and Removals +------------------------- + +- Remove support for Pantalaimon-based encryption. ([\#299](https://github.com/matrix-org/matrix-hookshot/issues/299)) + + +Internal Changes +---------------- + +- RSS feed polling now uses cache headers sent by servers, which should mean we will be more conservative on resources. ([\#583](https://github.com/matrix-org/matrix-hookshot/issues/583)) +- Only build ARM images when merging or releasing, due to slow ARM build times. ([\#589](https://github.com/matrix-org/matrix-hookshot/issues/589)) +- Increase maximum size of incoming webhook payload from `100kb` to `10mb`. ([\#606](https://github.com/matrix-org/matrix-hookshot/issues/606)) +- Mark encryption feature as experimental (config option is now `experimentalEncryption`). ([\#610](https://github.com/matrix-org/matrix-hookshot/issues/610)) +- Cache yarn dependencies during Docker build. ([\#615](https://github.com/matrix-org/matrix-hookshot/issues/615)) + + 2.5.0 (2022-12-02) ================== diff --git a/changelog.d/299.feature b/changelog.d/299.feature deleted file mode 100644 index 7c50d02d..00000000 --- a/changelog.d/299.feature +++ /dev/null @@ -1 +0,0 @@ -Add support for end-to-bridge encryption via MSC3202. diff --git a/changelog.d/299.removal b/changelog.d/299.removal deleted file mode 100644 index 2b8b2023..00000000 --- a/changelog.d/299.removal +++ /dev/null @@ -1 +0,0 @@ -Remove support for Pantalaimon-based encryption. diff --git a/changelog.d/350.doc b/changelog.d/350.doc deleted file mode 100644 index 74f877b2..00000000 --- a/changelog.d/350.doc +++ /dev/null @@ -1 +0,0 @@ -Clarify GitLab setup docs diff --git a/changelog.d/539.bugfix b/changelog.d/539.bugfix deleted file mode 100644 index dd75d805..00000000 --- a/changelog.d/539.bugfix +++ /dev/null @@ -1 +0,0 @@ -Parent projects are now taken into account when calculating a user's access level to a GitLab project. \ No newline at end of file diff --git a/changelog.d/582.bugfix b/changelog.d/582.bugfix deleted file mode 100644 index 5772615e..00000000 --- a/changelog.d/582.bugfix +++ /dev/null @@ -1 +0,0 @@ -Ensure bridge treats published and drafted GitHub releases as different events. \ No newline at end of file diff --git a/changelog.d/583.misc b/changelog.d/583.misc deleted file mode 100644 index 0b69b9ce..00000000 --- a/changelog.d/583.misc +++ /dev/null @@ -1 +0,0 @@ -RSS feed polling now uses cache headers sent by servers, which should mean we will be more conservative on resources. \ No newline at end of file diff --git a/changelog.d/587.bugfix b/changelog.d/587.bugfix deleted file mode 100644 index dd6f4eff..00000000 --- a/changelog.d/587.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix a bug where unknown keys in a connections state would be clobbered when updated via widget UI. \ No newline at end of file diff --git a/changelog.d/588.feature b/changelog.d/588.feature deleted file mode 100644 index 2a876596..00000000 --- a/changelog.d/588.feature +++ /dev/null @@ -1 +0,0 @@ -Add new GitHubRepo connection config setting `workflowRun.workflows` to filter run reports by workflow name. \ No newline at end of file diff --git a/changelog.d/589.misc b/changelog.d/589.misc deleted file mode 100644 index a0029db7..00000000 --- a/changelog.d/589.misc +++ /dev/null @@ -1 +0,0 @@ -Only build ARM images when merging or releasing, due to slow ARM build times. \ No newline at end of file diff --git a/changelog.d/601.bugfix b/changelog.d/601.bugfix deleted file mode 100644 index 17bd0e0c..00000000 --- a/changelog.d/601.bugfix +++ /dev/null @@ -1 +0,0 @@ -Improve webhook code editor performance. \ No newline at end of file diff --git a/changelog.d/602.feature b/changelog.d/602.feature deleted file mode 100644 index d07f9255..00000000 --- a/changelog.d/602.feature +++ /dev/null @@ -1 +0,0 @@ -A11y: Add alt tags to all images. diff --git a/changelog.d/606.misc b/changelog.d/606.misc deleted file mode 100644 index 061e396f..00000000 --- a/changelog.d/606.misc +++ /dev/null @@ -1 +0,0 @@ -Increase maximum size of incoming webhook payload from `100kb` to `10mb`. diff --git a/changelog.d/610.misc b/changelog.d/610.misc deleted file mode 100644 index ebdcb896..00000000 --- a/changelog.d/610.misc +++ /dev/null @@ -1 +0,0 @@ -Mark encryption feature as experimental (config option is now `experimentalEncryption`). \ No newline at end of file diff --git a/changelog.d/614.bugfix b/changelog.d/614.bugfix deleted file mode 100644 index d9d8a441..00000000 --- a/changelog.d/614.bugfix +++ /dev/null @@ -1 +0,0 @@ -Improve startup stability by not loading all room state at once. \ No newline at end of file diff --git a/changelog.d/615.misc b/changelog.d/615.misc deleted file mode 100644 index ac59177e..00000000 --- a/changelog.d/615.misc +++ /dev/null @@ -1 +0,0 @@ -Cache yarn dependencies during Docker build. \ No newline at end of file diff --git a/changelog.d/617.bugfix b/changelog.d/617.bugfix deleted file mode 100644 index d8d88539..00000000 --- a/changelog.d/617.bugfix +++ /dev/null @@ -1 +0,0 @@ -You can now add multiple GitLab connections to the same room with the same project path, if they are under different instances. \ No newline at end of file diff --git a/config.sample.yml b/config.sample.yml index a16400c6..2cf86367 100644 --- a/config.sample.yml +++ b/config.sample.yml @@ -5,7 +5,7 @@ bridge: # domain: example.com url: http://localhost:8008 - mediaUrl: http://example.com + mediaUrl: https://example.com port: 9993 bindAddress: 127.0.0.1 github: @@ -100,8 +100,16 @@ passFile: bot: # (Optional) Define profile information for the bot user # - displayname: GitHub Bot + displayname: Hookshot Bot avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d +serviceBots: + # (Optional) Define additional bot users for specific services + # + - localpart: feeds + displayname: Feeds + avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d + prefix: "!feeds" + service: feeds metrics: # (Optional) Prometheus metrics support # @@ -146,7 +154,7 @@ widgets: - fec0::/10 roomSetupWidget: addOnInvite: false - publicUrl: http://example.com/widgetapi/v1/static/ + publicUrl: https://example.com/widgetapi/v1/static/ branding: widgetTitle: Hookshot Configuration permissions: diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index e2837e20..9f689f09 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -27,3 +27,4 @@ - [Workers](./advanced/workers.md) - [🔒 Encryption](./advanced/encryption.md) - [🪀 Widgets](./advanced/widgets.md) +- [Service Bots](./advanced/service_bots.md) diff --git a/docs/advanced/service_bots.md b/docs/advanced/service_bots.md new file mode 100644 index 00000000..6336c648 --- /dev/null +++ b/docs/advanced/service_bots.md @@ -0,0 +1,36 @@ +# Service Bots + +Hookshot supports additional bot users called "service bots" which handle a particular connection type +(in addition to the default bot user which can handle any connection type). +These bots can coexist in a room, each handling a different service. + +## Configuration + +Service bots can be given a different localpart, display name, avatar, and command prefix. +They will only handle connections for the specified service, which can be one of: +* `feeds` - [Feeds](../setup/feeds.md) +* `figma` - [Figma](../setup/figma.md) +* `generic` - [Webhooks](../setup/webhooks.md) +* `github` - [GitHub](../setup/github.md) +* `gitlab` - [GitLab](../setup/gitlab.md) +* `jira` - [Jira](../setup/jira.md) + +For example with this configuration: +```yaml +serviceBots: + - localpart: feeds + displayname: Feeds + avatar: mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d + prefix: "!feeds" + service: feeds +``` + +There will be a bot user `@feeds:example.com` which responds to commands prefixed with `!feeds`, and only handles feeds connections. + +For the homeserver to allow hookshot control over users, they need to be added to the list of user namespaces in the `registration.yml` file provided to the homeserver. + +In the example above, you would need to add these lines: +```yaml + - regex: "@feeds:example.com" # Where example.com is your homeserver's domain + exclusive: true +``` diff --git a/docs/advanced/widgets.md b/docs/advanced/widgets.md index ec3da2ff..73575b80 100644 --- a/docs/advanced/widgets.md +++ b/docs/advanced/widgets.md @@ -39,7 +39,7 @@ widgets: # - 2001:db8::/32 # - ff00::/8 # - fec0::/10 - publicUrl: http://example.com/widgetapi/v1/static + publicUrl: https://example.com/widgetapi/v1/static branding: widgetTitle: Hookshot Configuration openIdOverrides: diff --git a/docs/setup/jira.md b/docs/setup/jira.md index ceb91f1c..57f83e65 100644 --- a/docs/setup/jira.md +++ b/docs/setup/jira.md @@ -74,7 +74,7 @@ To begin, configure your `config.yml`: ```yaml jira: - url: http://yourjirainstance.com # The location of your jira instance. + url: https://yourjirainstance.com # The location of your jira instance. webhook: # A secret string generated by you. secret: Ieph7iecheiThoo1othaineewieSh1koh2chainohtooyoh4waht1oetoaSoh6oh @@ -98,7 +98,7 @@ To start with, set up your JIRA instance to support OAuth. 1. The Application Name can be anything, but for simplicty we usually use `matrix-hookshot` 2. The Application Type should be **Generic Application** 3. The Consumer key, and shared secret can be any string, they are not used. - 4. The URLs can be any URL, they are not used (e.g. `http://example.com`) + 4. The URLs can be any URL, they are not used (e.g. `https://example.com`) 5. Ensure you enable **Create incoming link** 6. Click **Continue** 6. On the next step: diff --git a/docs/usage/room_configuration/github_repo.md b/docs/usage/room_configuration/github_repo.md index 53968ec1..8d2aa20d 100644 --- a/docs/usage/room_configuration/github_repo.md +++ b/docs/usage/room_configuration/github_repo.md @@ -27,8 +27,8 @@ This connection supports a few options which can be defined in the room state: | Option | Description | Allowed values | Default | |--------|-------------|----------------|---------| -|enableHooks [^1]|Enable notifications for some event types|Array of: [Supported event types](#supported-event-types) |*empty*| -|ignoreHooks [^1]|Choose to exclude notifications for some event types|Array of: [Supported event types](#supported-event-types) |*empty*| +|enableHooks [^1]|Enable notifications for some event types|Array of: [Supported event types](#supported-event-types) |If not defined, defaults are mentioned below| +|ignoreHooks [^1]|**deprecated** Choose to exclude notifications for some event types|Array of: [Supported event types](#supported-event-types) |*empty*| |commandPrefix|Choose the prefix to use when sending commands to the bot|A string, ideally starts with "!"|`!gh`| |showIssueRoomLink|When new issues are created, provide a Matrix alias link to the issue room|`true/false`|`false`| |prDiff|Show a diff in the room when a PR is created, subject to limits|`{enabled: boolean, maxLines: number}`|`{enabled: false}`| @@ -43,14 +43,16 @@ This connection supports a few options which can be defined in the room state: |workflowRun.excludingWorkflows|Never report workflow runs with a matching workflow name.|Array of: String matching a workflow name|*empty*| -[^1]: `ignoreHooks` takes precedence over `enableHooks`. +[^1]: `ignoreHooks` is no longer accepted for new state events. Use `enableHooks` to explicitly state all events you want to see. + ### Supported event types This connection supports sending messages when the following actions happen on the repository. -Note: Some of these event types are enabled by default (marked with a `*`) +Note: Some of these event types are enabled by default (marked with a `*`). When `ignoreHooks` *is* defined, +the events marked as default below will be enabled. Otherwise, this is ignored. - issue * - issue.created * diff --git a/docs/usage/room_configuration/gitlab_project.md b/docs/usage/room_configuration/gitlab_project.md index c8f91a65..c7612a9d 100644 --- a/docs/usage/room_configuration/gitlab_project.md +++ b/docs/usage/room_configuration/gitlab_project.md @@ -23,26 +23,33 @@ This connection supports a few options which can be defined in the room state: | Option | Description | Allowed values | Default | |--------|-------------|----------------|---------| -|ignoreHooks|Choose to exclude notifications for some event types|Array of: [Supported event types](#supported-event-types) |*empty*| |commandPrefix|Choose the prefix to use when sending commands to the bot|A string, ideally starts with "!"|`!gh`| -|pushTagsRegex|Only mention pushed tags which match this regex|Regex string|*empty*| -|includingLabels|Only notify on issues matching these label names|Array of: String matching a label name|*empty*| +|enableHooks [^1]|Enable notifications for some event types|Array of: [Supported event types](#supported-event-types) |If not defined, defaults are mentioned below| |excludingLabels|Never notify on issues matching these label names|Array of: String matching a label name|*empty*| +|ignoreHooks [^1]|**deprecated** Choose to exclude notifications for some event types|Array of: [Supported event types](#supported-event-types) |*empty*| |includeCommentBody|Include the body of a comment when notifying on merge requests|Boolean|false| +|includingLabels|Only notify on issues matching these label names|Array of: String matching a label name|*empty*| +|pushTagsRegex|Only mention pushed tags which match this regex|Regex string|*empty*| + + +[^1]: `ignoreHooks` is no longer accepted for new state events. Use `enableHooks` to explicitly state all events you want to see. ### Supported event types This connection supports sending messages when the following actions happen on the repository. -- merge_request - - merge_request.close - - merge_request.merge - - merge_request.open - - merge_request.review.comments - - merge_request.review -- push -- release - - release.created -- tag_push -- wiki +Note: Some of these event types are enabled by default (marked with a `*`). When `ignoreHooks` *is* defined, +the events marked as default below will be enabled. Otherwise, this is ignored. + +- merge_request * + - merge_request.close * + - merge_request.merge * + - merge_request.open * + - merge_request.review.comments * + - merge_request.review * +- push * +- release * + - release.created * +- tag_push * +- wiki * diff --git a/package.json b/package.json index bec2298f..8b9701d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-hookshot", - "version": "2.5.0", + "version": "3.0.0", "description": "A bridge between Matrix and multiple project management services, such as GitHub, GitLab and JIRA.", "main": "lib/app.js", "repository": "https://github.com/matrix-org/matrix-hookshot", @@ -67,7 +67,6 @@ "source-map-support": "^0.5.21", "string-argv": "^0.3.1", "tiny-typed-emitter": "^2.1.0", - "uuid": "^8.3.2", "vm2": "^3.9.11", "winston": "^3.3.3", "xml2js": "^0.4.23", @@ -77,6 +76,7 @@ "@codemirror/lang-javascript": "^6.0.2", "@napi-rs/cli": "^2.2.0", "@preact/preset-vite": "^2.2.0", + "@tsconfig/node16": "^1.0.3", "@types/ajv": "^1.0.0", "@types/chai": "^4.2.22", "@types/cors": "^2.8.12", @@ -104,7 +104,7 @@ "sass": "^1.51.0", "ts-node": "^10.4.0", "typescript": "^4.5.2", - "vite": "^2.9.13", - "vite-svg-loader": "^3.4.0" + "vite": "^4.1.4", + "vite-svg-loader": "^4.0.0" } } diff --git a/registration.sample.yml b/registration.sample.yml index d7a89d62..e22fcf89 100644 --- a/registration.sample.yml +++ b/registration.sample.yml @@ -12,6 +12,8 @@ namespaces: exclusive: true - regex: "@_webhooks_.*:foobar" # Where _webhooks_ is set by userIdPrefix in config.yml exclusive: true + - regex: "@feeds:foobar" # Matches the localpart of all serviceBots in config.yml + exclusive: true aliases: - regex: "#github_.+:foobar" # Where foobar is your homeserver's domain exclusive: true @@ -23,4 +25,4 @@ rate_limited: false # If enabling encryption de.sorunome.msc2409.push_ephemeral: true push_ephemeral: true -org.matrix.msc3202: true \ No newline at end of file +org.matrix.msc3202: true diff --git a/src/App/BridgeApp.ts b/src/App/BridgeApp.ts index 703fcde1..750e4b9b 100644 --- a/src/App/BridgeApp.ts +++ b/src/App/BridgeApp.ts @@ -8,6 +8,7 @@ import { ListenerService } from "../ListenerService"; import { Logger } from "matrix-appservice-bridge"; import { LogService } from "matrix-bot-sdk"; import { getAppservice } from "../appservice"; +import BotUsersManager from "../Managers/BotUsersManager"; Logger.configure({console: "info"}); const log = new Logger("App"); @@ -35,7 +36,9 @@ async function start() { userNotificationWatcher.start(); } - const bridgeApp = new Bridge(config, listener, appservice, storage); + const botUsersManager = new BotUsersManager(config, appservice); + + const bridgeApp = new Bridge(config, listener, appservice, storage, botUsersManager); process.once("SIGTERM", () => { log.error("Got SIGTERM"); diff --git a/src/Bridge.ts b/src/Bridge.ts index 97cf7077..7d544e56 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -1,6 +1,7 @@ import { AdminAccountData } from "./AdminRoomCommandHandler"; import { AdminRoom, BRIDGE_ROOM_TYPE, LEGACY_BRIDGE_ROOM_TYPE } from "./AdminRoom"; -import { Appservice, RichRepliesPreprocessor, IRichReplyMetadata, StateEvent, EventKind, PowerLevelsEvent } from "matrix-bot-sdk"; +import { Appservice, RichRepliesPreprocessor, IRichReplyMetadata, StateEvent, EventKind, PowerLevelsEvent, Intent } from "matrix-bot-sdk"; +import BotUsersManager from "./Managers/BotUsersManager"; import { BridgeConfig, BridgePermissionLevel, GitLabInstance } from "./Config/Config"; import { BridgeWidgetApi } from "./Widgets/BridgeWidgetApi"; import { CommentProcessor } from "./CommentProcessor"; @@ -50,6 +51,7 @@ export class Bridge { private connectionManager?: ConnectionManager; private github?: GithubInstance; private adminRooms: Map = new Map(); + private widgetApi?: BridgeWidgetApi; private provisioningApi?: Provisioner; private replyProcessor = new RichRepliesPreprocessor(true); @@ -60,6 +62,7 @@ export class Bridge { private readonly listener: ListenerService, private readonly as: Appservice, private readonly storage: IBridgeStorageProvider, + private readonly botUsersManager: BotUsersManager, ) { this.queue = createMessageQueue(this.config.queue); this.messageClient = new MessageSenderClient(this.queue); @@ -67,6 +70,7 @@ export class Bridge { this.notifProcessor = new NotificationProcessor(this.storage, this.messageClient); this.tokenStore = new UserTokenStore(this.config.passFile || "./passkey.pem", this.as.botIntent, this.config); this.tokenStore.on("onNewToken", this.onTokenUpdated.bind(this)); + this.as.expressAppInstance.get("/live", (_, res) => res.send({ok: true})); this.as.expressAppInstance.get("/ready", (_, res) => res.status(this.ready ? 200 : 500).send({ready: this.ready})); } @@ -82,20 +86,21 @@ export class Bridge { await this.storage.connect?.(); await this.queue.connect?.(); - // Fetch all room state - let joinedRooms: string[]|undefined; - while(joinedRooms === undefined) { + log.info("Ensuring homeserver can be reached..."); + let reached = false; + while (!reached) { try { - log.info("Connecting to homeserver and fetching joined rooms.."); - joinedRooms = await this.as.botIntent.getJoinedRooms(); - log.debug(`Bridge bot is joined to ${joinedRooms.length} rooms`); - } catch (ex) { - // This is our first interaction with the homeserver, so wait if it's not ready yet. - log.warn("Failed to connect to homeserver, retrying in 5s", ex); + // Make a request to determine if we can reach the homeserver + await this.as.botIntent.getJoinedRooms(); + reached = true; + } catch (e) { + log.warn("Failed to connect to homeserver, retrying in 5s", e); await new Promise((r) => setTimeout(r, 5000)); } } - + + await this.botUsersManager.start(); + await this.config.prefillMembershipCache(this.as.botClient); if (this.config.github) { @@ -112,20 +117,28 @@ export class Bridge { await ensureFigmaWebhooks(this.config.figma, this.as.botClient); } - - const connManager = this.connectionManager = new ConnectionManager(this.as, - this.config, this.tokenStore, this.commentProcessor, this.messageClient, this.storage, this.github); + const connManager = this.connectionManager = new ConnectionManager( + this.as, + this.config, + this.tokenStore, + this.commentProcessor, + this.messageClient, + this.storage, + this.botUsersManager, + this.github, + ); if (this.config.feeds?.enabled) { new FeedReader( this.config.feeds, this.connectionManager, this.queue, + // Use default bot when storing account data this.as.botClient, ); } - + if (this.config.provisioning) { const routers = []; if (this.config.jira) { @@ -145,7 +158,13 @@ export class Bridge { if (this.config.generic) { this.connectionManager.registerProvisioningConnection(GenericHookConnection); } - this.provisioningApi = new Provisioner(this.config.provisioning, this.connectionManager, this.as.botIntent, routers); + this.provisioningApi = new Provisioner( + this.config.provisioning, + this.connectionManager, + this.botUsersManager, + this.as, + routers, + ); } this.as.on("query.room", async (roomAlias, cb) => { @@ -266,114 +285,114 @@ export class Bridge { this.bindHandlerToQueue( "github.issues.unlabeled", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), + (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), (c, data) => c.onIssueUnlabeled(data), ); this.bindHandlerToQueue( "github.issues.labeled", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), + (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), (c, data) => c.onIssueLabeled(data), ); this.bindHandlerToQueue( "github.pull_request.opened", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), + (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), (c, data) => c.onPROpened(data), ); this.bindHandlerToQueue( "github.pull_request.closed", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), + (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), (c, data) => c.onPRClosed(data), ); this.bindHandlerToQueue( "github.pull_request.ready_for_review", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), + (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), (c, data) => c.onPRReadyForReview(data), ); this.bindHandlerToQueue( "github.pull_request_review.submitted", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), + (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), (c, data) => c.onPRReviewed(data), ); this.bindHandlerToQueue( "github.workflow_run.completed", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), + (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), (c, data) => c.onWorkflowCompleted(data), ); this.bindHandlerToQueue( "github.release.published", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), + (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), (c, data) => c.onReleaseCreated(data), ); this.bindHandlerToQueue( "github.release.created", - (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), + (data) => connManager.getConnectionsForGithubRepo(data.repository.owner.login, data.repository.name), (c, data) => c.onReleaseDrafted(data), ); this.bindHandlerToQueue( "gitlab.merge_request.open", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), (c, data) => c.onMergeRequestOpened(data), ); this.bindHandlerToQueue( "gitlab.merge_request.close", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), (c, data) => c.onMergeRequestClosed(data), ); this.bindHandlerToQueue( "gitlab.merge_request.merge", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), (c, data) => c.onMergeRequestMerged(data), ); this.bindHandlerToQueue( "gitlab.merge_request.approved", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), (c, data) => c.onMergeRequestReviewed(data), ); this.bindHandlerToQueue( "gitlab.merge_request.unapproved", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), (c, data) => c.onMergeRequestReviewed(data), ); this.bindHandlerToQueue( "gitlab.merge_request.update", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), (c, data) => c.onMergeRequestUpdate(data), ); this.bindHandlerToQueue( "gitlab.release.create", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), (c, data) => c.onRelease(data), ); this.bindHandlerToQueue( "gitlab.tag_push", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), (c, data) => c.onGitLabTagPush(data), ); this.bindHandlerToQueue( "gitlab.push", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), (c, data) => c.onGitLabPush(data), ); this.bindHandlerToQueue( "gitlab.wiki_page", - (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), + (data) => connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), (c, data) => c.onWikiPageEvent(data), ); @@ -419,10 +438,10 @@ export class Bridge { this.bindHandlerToQueue( "gitlab.note.created", - (data) => { + (data) => { const iid = data.issue?.iid || data.merge_request?.iid; return [ - ...( iid ? connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, iid) : []), + ...( iid ? connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, iid) : []), ...connManager.getConnectionsForGitLabRepo(data.project.path_with_namespace), ]}, (c, data) => c.onCommentCreated(data), @@ -430,19 +449,19 @@ export class Bridge { this.bindHandlerToQueue( "gitlab.issue.reopen", - (data) => connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, data.object_attributes.iid), + (data) => connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, data.object_attributes.iid), (c) => c.onIssueReopened(), ); this.bindHandlerToQueue( "gitlab.issue.close", - (data) => connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, data.object_attributes.iid), + (data) => connManager.getConnectionsForGitLabIssueWebhook(data.repository.homepage, data.object_attributes.iid), (c) => c.onIssueClosed(), ); this.bindHandlerToQueue( "github.discussion_comment.created", - (data) => connManager.getConnectionsForGithubDiscussion(data.repository.owner.login, data.repository.name, data.discussion.number), + (data) => connManager.getConnectionsForGithubDiscussion(data.repository.owner.login, data.repository.name, data.discussion.number), (c, data) => c.onDiscussionCommentCreated(data), ); @@ -458,10 +477,16 @@ export class Bridge { } let [discussionConnection] = connManager.getConnectionsForGithubDiscussion(data.repository.owner.login, data.repository.name, data.discussion.id); if (!discussionConnection) { + const botUser = this.botUsersManager.getBotUserForService(GitHubDiscussionConnection.ServiceCategory); + if (!botUser) { + throw Error('Could not find a bot to handle this connection'); + } + try { // If we don't have an existing connection for this discussion (likely), then create one. discussionConnection = await GitHubDiscussionConnection.createDiscussionRoom( this.as, + botUser.intent, null, data.repository.owner.login, data.repository.name, @@ -469,7 +494,7 @@ export class Bridge { this.tokenStore, this.commentProcessor, this.messageClient, - this.config.github, + this.config, ); connManager.push(discussionConnection); } catch (ex) { @@ -486,7 +511,7 @@ export class Bridge { } }) }); - + this.bindHandlerToQueue( "jira.issue_created", (data) => connManager.getConnectionsForJiraProject(data.issue.fields.project), @@ -553,7 +578,7 @@ export class Bridge { }); }); - + this.queue.on("generic-webhook.event", async (msg) => { const { data, messageId } = msg; const connections = connManager.getConnectionsForGenericWebhook(data.hookId); @@ -635,30 +660,13 @@ export class Bridge { (c, data) => c.handleFeedError(data), ); - // Set the name and avatar of the bot - if (this.config.bot) { - // Ensure we are registered before we set a profile - await this.as.botIntent.ensureRegistered(); - let profile; - try { - profile = await this.as.botClient.getUserProfile(this.as.botUserId); - } catch { - profile = {} - } - if (this.config.bot.avatar && profile.avatar_url !== this.config.bot.avatar) { - log.info(`Setting avatar to ${this.config.bot.avatar}`); - await this.as.botClient.setAvatarUrl(this.config.bot.avatar); - } - if (this.config.bot.displayname && profile.displayname !== this.config.bot.displayname) { - log.info(`Setting displayname to ${this.config.bot.displayname}`); - await this.as.botClient.setDisplayName(this.config.bot.displayname); - } - } const queue = new PQueue({ concurrency: 2, }); - queue.addAll(joinedRooms.map(((roomId) => async () => { + // Set up already joined rooms + await queue.addAll(this.botUsersManager.joinedRooms.map((roomId) => async () => { log.debug("Fetching state for " + roomId); + try { await connManager.createConnectionsForRoomId(roomId, false); } catch (ex) { @@ -666,13 +674,19 @@ export class Bridge { return; } + const botUser = this.botUsersManager.getBotUserInRoom(roomId); + if (!botUser) { + log.error(`Failed to find a bot in room '${roomId}' when setting up admin room`); + return; + } + // TODO: Refactor this to be a connection try { - let accountData = await this.as.botClient.getSafeRoomAccountData( + let accountData = await botUser.intent.underlyingClient.getSafeRoomAccountData( BRIDGE_ROOM_TYPE, roomId, ); if (!accountData) { - accountData = await this.as.botClient.getSafeRoomAccountData( + accountData = await botUser.intent.underlyingClient.getSafeRoomAccountData( LEGACY_BRIDGE_ROOM_TYPE, roomId, ); if (!accountData) { @@ -680,18 +694,18 @@ export class Bridge { return; } else { // Upgrade the room - await this.as.botClient.setRoomAccountData(BRIDGE_ROOM_TYPE, roomId, accountData); + await botUser.intent.underlyingClient.setRoomAccountData(BRIDGE_ROOM_TYPE, roomId, accountData); } } let notifContent; try { - notifContent = await this.as.botClient.getRoomStateEvent( + notifContent = await botUser.intent.underlyingClient.getRoomStateEvent( roomId, NotifFilter.StateType, "", ); } catch (ex) { try { - notifContent = await this.as.botClient.getRoomStateEvent( + notifContent = await botUser.intent.underlyingClient.getRoomStateEvent( roomId, NotifFilter.LegacyStateType, "", ); } @@ -699,14 +713,14 @@ export class Bridge { // No state yet } } - const adminRoom = await this.setUpAdminRoom(roomId, accountData, notifContent || NotifFilter.getDefaultContent()); + const adminRoom = await this.setUpAdminRoom(botUser.intent, roomId, accountData, notifContent || NotifFilter.getDefaultContent()); // Call this on startup to set the state await this.onAdminRoomSettingsChanged(adminRoom, accountData, { admin_user: accountData.admin_user }); log.debug(`Room ${roomId} is connected to: ${adminRoom.toString()}`); } catch (ex) { log.error(`Failed to set up admin room ${roomId}:`, ex); } - }))); + })); // Handle spaces for (const discussion of connManager.getAllConnectionsOfType(GitHubDiscussionSpace)) { @@ -719,16 +733,19 @@ export class Bridge { const apps = this.listener.getApplicationsForResource('widgets'); if (apps.length > 1) { throw Error('You may only bind `widgets` to one listener.'); - } - new BridgeWidgetApi( + } + this.widgetApi = new BridgeWidgetApi( this.adminRooms, this.config, this.storage, apps[0], this.connectionManager, - this.as.botIntent, + this.botUsersManager, + this.as, + this.tokenStore, + this.github, ); - + } if (this.provisioningApi) { this.listener.bindResource('provisioning', this.provisioningApi.expressRouter); @@ -763,36 +780,69 @@ export class Bridge { /* Do not handle invites from our users */ return; } - log.info(`Got invite roomId=${roomId} from=${event.sender} to=${event.state_key}`); - // Room joins can fail over federation - if (event.state_key !== this.as.botUserId) { - return this.as.botClient.kickUser(event.state_key, roomId, "Bridge does not support DMing ghosts"); + const invitedUserId = event.state_key; + if (!invitedUserId) { + return; + } + log.info(`Got invite roomId=${roomId} from=${event.sender} to=${invitedUserId}`); + + const botUser = this.botUsersManager.getBotUser(invitedUserId); + if (!botUser) { + // We got an invite but it's not a configured bot user, must be for a ghost user + log.debug(`Rejecting invite to room ${roomId} for ghost user ${invitedUserId}`); + const client = this.as.getIntentForUserId(invitedUserId).underlyingClient; + return client.doRequest("POST", "/_matrix/client/v3/rooms/" + encodeURIComponent(roomId) + "/leave", null, { + reason: "Bridge does not support DMing ghosts" + }); } // Don't accept invites from people who can't do anything if (!this.config.checkPermissionAny(event.sender, BridgePermissionLevel.login)) { - return this.as.botClient.kickUser(this.as.botUserId, roomId, "You do not have permission to invite this bot."); + return botUser.intent.underlyingClient.doRequest("POST", "/_matrix/client/v3/rooms/" + encodeURIComponent(roomId) + "/leave", null, { + reason: "You do not have permission to invite this bot." + }); } - await retry(() => this.as.botIntent.joinRoom(roomId), 5); + if (event.content.is_direct && botUser.userId !== this.as.botUserId) { + // Service bots do not support direct messages (admin rooms) + log.debug(`Rejecting direct message (admin room) invite to room ${roomId} for service bot ${botUser.userId}`); + return botUser.intent.underlyingClient.doRequest("POST", "/_matrix/client/v3/rooms/" + encodeURIComponent(roomId) + "/leave", null, { + reason: "This bot does not support admin rooms." + }); + } + + // Accept the invite + await retry(() => botUser.intent.joinRoom(roomId), 5); if (event.content.is_direct) { - await this.as.botClient.setRoomAccountData( + await botUser.intent.underlyingClient.setRoomAccountData( BRIDGE_ROOM_TYPE, roomId, {admin_user: event.sender}, ); } } - private async onRoomLeave(roomId: string, event: MatrixEvent) { - if (event.state_key !== this.as.botUserId) { - // Only interested in bot leaves. + private async onRoomLeave(roomId: string, matrixEvent: MatrixEvent) { + const userId = matrixEvent.state_key; + if (!userId) { return; } - // If the bot has left the room, we want to vape all connections for that room. - try { - await this.connectionManager?.removeConnectionsForRoom(roomId); - } catch (ex) { - log.warn(`Failed to remove connections on leave for ${roomId}`); + + const botUser = this.botUsersManager.getBotUser(userId); + if (!botUser) { + // Not for one of our bots + return; + } + this.botUsersManager.onRoomLeave(botUser, roomId); + + if (!this.connectionManager) { + return; + } + + // Remove all the connections for this room + await this.connectionManager.removeConnectionsForRoom(roomId); + if (this.botUsersManager.getBotUsersInRoom(roomId).length > 0) { + // If there are still bots in the room, recreate connections + await this.connectionManager.createConnectionsForRoomId(roomId, true); } } @@ -838,13 +888,23 @@ export class Bridge { } if (!handled && this.config.checkPermissionAny(event.sender, BridgePermissionLevel.manageConnections)) { // Divert to the setup room code if we didn't match any of these - try { - await ( - new SetupConnection( + + const botUsersInRoom = this.botUsersManager.getBotUsersInRoom(roomId); + // Try each bot in the room until one handles the command + for (const botUser of botUsersInRoom) { + try { + const setupConnection = new SetupConnection( roomId, + botUser.prefix, + botUser.services, + [ + ...botUser.services, + this.config.widgets?.roomSetupWidget ? "widget" : "", + ], { config: this.config, as: this.as, + intent: botUser.intent, tokenStore: this.tokenStore, commentProcessor: this.commentProcessor, messageClient: this.messageClient, @@ -852,12 +912,16 @@ export class Bridge { github: this.github, getAllConnectionsOfType: this.connectionManager.getAllConnectionsOfType.bind(this.connectionManager), }, - this.getOrCreateAdminRoomForUser.bind(this), + this.getOrCreateAdminRoom.bind(this), this.connectionManager.push.bind(this.connectionManager), - ) - ).onMessageEvent(event, checkPermission); - } catch (ex) { - log.warn(`Setup connection failed to handle:`, ex); + ); + const handled = await setupConnection.onMessageEvent(event, checkPermission); + if (handled) { + break; + } + } catch (ex) { + log.warn(`Setup connection failed to handle:`, ex); + } } } return; @@ -900,23 +964,30 @@ export class Bridge { } private async onRoomJoin(roomId: string, matrixEvent: MatrixEvent) { - if (this.as.botUserId !== matrixEvent.sender) { - // Only act on bot joins + const userId = matrixEvent.state_key; + if (!userId) { return; } + const botUser = this.botUsersManager.getBotUser(userId); + if (!botUser) { + // Not for one of our bots + return; + } + this.botUsersManager.onRoomJoin(botUser, roomId); + if (this.config.encryption) { // Ensure crypto is aware of all members of this room before posting any messages, // so that the bot can share room keys to all recipients first. - await this.as.botClient.crypto.onRoomJoin(roomId); + await botUser.intent.underlyingClient.crypto.onRoomJoin(roomId); } - const adminAccountData = await this.as.botIntent.underlyingClient.getSafeRoomAccountData( + const adminAccountData = await botUser.intent.underlyingClient.getSafeRoomAccountData( BRIDGE_ROOM_TYPE, roomId, ); if (adminAccountData) { - const room = await this.setUpAdminRoom(roomId, adminAccountData, NotifFilter.getDefaultContent()); - await this.as.botClient.setRoomAccountData( + const room = await this.setUpAdminRoom(botUser.intent, roomId, adminAccountData, NotifFilter.getDefaultContent()); + await botUser.intent.underlyingClient.setRoomAccountData( BRIDGE_ROOM_TYPE, roomId, room.accountData, ); } @@ -926,20 +997,28 @@ export class Bridge { return; } - // Only fetch rooms we have no connections in yet. - const roomHasConnection = - this.connectionManager.isRoomConnected(roomId) || - await this.connectionManager.createConnectionsForRoomId(roomId, true); + // Recreate connections for the room + await this.connectionManager.removeConnectionsForRoom(roomId); + await this.connectionManager.createConnectionsForRoomId(roomId, true); - // If room has connections or is an admin room, don't setup a wizard. + // Only fetch rooms we have no connections in yet. + const roomHasConnection = this.connectionManager.isRoomConnected(roomId); + + // If room has connections or is an admin room, don't set up a wizard. // Otherwise it's a new room if (!roomHasConnection && !adminAccountData && this.config.widgets?.roomSetupWidget?.addOnInvite) { try { - if (await this.as.botClient.userHasPowerLevelFor(this.as.botUserId, roomId, "im.vector.modular.widgets", true) === false) { - await this.as.botIntent.sendText(roomId, "Hello! To setup new integrations in this room, please promote me to a Moderator/Admin"); + const hasPowerlevel = await botUser.intent.underlyingClient.userHasPowerLevelFor( + botUser.intent.userId, + roomId, + "im.vector.modular.widgets", + true, + ); + if (!hasPowerlevel) { + await botUser.intent.sendText(roomId, "Hello! To set up new integrations in this room, please promote me to a Moderator/Admin."); } else { - // Setup the widget - await SetupWidget.SetupRoomConfigWidget(roomId, this.as.botIntent, this.config.widgets); + // Set up the widget + await SetupWidget.SetupRoomConfigWidget(roomId, botUser.intent, this.config.widgets, botUser.services); } } catch (ex) { log.error(`Failed to setup new widget for room`, ex); @@ -988,28 +1067,31 @@ export class Bridge { } } - // If it's a power level event for a new room, we might want to create the setup widget. - if (this.config.widgets?.roomSetupWidget?.addOnInvite && event.type === "m.room.power_levels" && event.state_key === "" && !this.connectionManager.isRoomConnected(roomId)) { - log.debug(`${roomId} got a new powerlevel change and isn't connected to any connections, testing to see if we should create a setup widget`) - const plEvent = new PowerLevelsEvent(event); - const currentPl = plEvent.content.users?.[this.as.botUserId] || plEvent.defaultUserLevel; - const previousPl = plEvent.previousContent?.users?.[this.as.botUserId] || plEvent.previousContent?.users_default; - const requiredPl = plEvent.content.events?.["im.vector.modular.widgets"] || plEvent.defaultStateEventLevel; - if (currentPl !== previousPl && currentPl >= requiredPl) { - // PL changed for bot user, check to see if the widget can be created. - try { - log.info(`Bot has powerlevel required to create a setup widget, attempting`); - await SetupWidget.SetupRoomConfigWidget(roomId, this.as.botIntent, this.config.widgets); - } catch (ex) { - log.error(`Failed to create setup widget for ${roomId}`, ex); + const botUsersInRoom = this.botUsersManager.getBotUsersInRoom(roomId); + for (const botUser of botUsersInRoom) { + // If it's a power level event for a new room, we might want to create the setup widget. + if (this.config.widgets?.roomSetupWidget?.addOnInvite && event.type === "m.room.power_levels" && event.state_key === "" && !this.connectionManager.isRoomConnected(roomId)) { + log.debug(`${roomId} got a new powerlevel change and isn't connected to any connections, testing to see if we should create a setup widget`) + const plEvent = new PowerLevelsEvent(event); + const currentPl = plEvent.content.users?.[botUser.userId] || plEvent.defaultUserLevel; + const previousPl = plEvent.previousContent?.users?.[botUser.userId] || plEvent.previousContent?.users_default; + const requiredPl = plEvent.content.events?.["im.vector.modular.widgets"] || plEvent.defaultStateEventLevel; + if (currentPl !== previousPl && currentPl >= requiredPl) { + // PL changed for bot user, check to see if the widget can be created. + try { + log.info(`Bot has powerlevel required to create a setup widget, attempting`); + await SetupWidget.SetupRoomConfigWidget(roomId, botUser.intent, this.config.widgets, botUser.services); + } catch (ex) { + log.error(`Failed to create setup widget for ${roomId}`, ex); + } } } - } + } return; } // We still want to react to our own state events. - if (event.sender === this.as.botUserId) { + if (this.botUsersManager.isBotUser(event.sender)) { // It's us return; } @@ -1169,16 +1251,16 @@ export class Bridge { }); } } - + } - private async getOrCreateAdminRoomForUser(userId: string): Promise { + private async getOrCreateAdminRoom(intent: Intent, userId: string): Promise { const existingRoom = this.getAdminRoomForUser(userId); if (existingRoom) { return existingRoom; } - const roomId = await this.as.botClient.dms.getOrCreateDm(userId); - const room = await this.setUpAdminRoom(roomId, {admin_user: userId}, NotifFilter.getDefaultContent()); + const roomId = await intent.underlyingClient.dms.getOrCreateDm(userId); + const room = await this.setUpAdminRoom(intent, roomId, {admin_user: userId}, NotifFilter.getDefaultContent()); await this.as.botClient.setRoomAccountData( BRIDGE_ROOM_TYPE, roomId, room.accountData, ); @@ -1194,23 +1276,28 @@ export class Bridge { return null; } - private async setUpAdminRoom(roomId: string, accountData: AdminAccountData, notifContent: NotificationFilterStateContent) { + private async setUpAdminRoom( + intent: Intent, + roomId: string, + accountData: AdminAccountData, + notifContent: NotificationFilterStateContent, + ) { if (!this.connectionManager) { throw Error('setUpAdminRoom() called before connectionManager was ready'); } const adminRoom = new AdminRoom( - roomId, accountData, notifContent, this.as.botIntent, this.tokenStore, this.config, this.connectionManager, + roomId, accountData, notifContent, intent, this.tokenStore, this.config, this.connectionManager, ); adminRoom.on("settings.changed", this.onAdminRoomSettingsChanged.bind(this)); adminRoom.on("open.project", async (project: ProjectsGetResponseData) => { const [connection] = this.connectionManager?.getForGitHubProject(project.id) || []; if (!connection) { - const connection = await GitHubProjectConnection.onOpenProject(project, this.as, adminRoom.userId); + const connection = await GitHubProjectConnection.onOpenProject(project, this.as, intent, this.config, adminRoom.userId); this.connectionManager?.push(connection); } else { - await this.as.botClient.inviteUser(adminRoom.userId, connection.roomId); + await intent.underlyingClient.inviteUser(adminRoom.userId, connection.roomId); } }); adminRoom.on("open.gitlab-issue", async (issueInfo: GetIssueOpts, res: GetIssueResponse, instanceName: string, instance: GitLabInstance) => { @@ -1219,25 +1306,26 @@ export class Bridge { } const [ connection ] = this.connectionManager?.getConnectionsForGitLabIssue(instance, issueInfo.projects, issueInfo.issue) || []; if (connection) { - return this.as.botClient.inviteUser(adminRoom.userId, connection.roomId); - } + return intent.underlyingClient.inviteUser(adminRoom.userId, connection.roomId); + } const newConnection = await GitLabIssueConnection.createRoomForIssue( instanceName, instance, res, issueInfo.projects, this.as, - this.tokenStore, + intent, + this.tokenStore, this.commentProcessor, this.messageClient, - this.config.gitlab, + this.config, ); this.connectionManager?.push(newConnection); - return this.as.botClient.inviteUser(adminRoom.userId, newConnection.roomId); + return intent.underlyingClient.inviteUser(adminRoom.userId, newConnection.roomId); }); this.adminRooms.set(roomId, adminRoom); if (this.config.widgets?.addToAdminRooms) { - await SetupWidget.SetupAdminRoomConfigWidget(roomId, this.as.botIntent, this.config.widgets); + await SetupWidget.SetupAdminRoomConfigWidget(roomId, intent, this.config.widgets); } log.debug(`Set up ${roomId} as an admin room for ${adminRoom.userId}`); return adminRoom; diff --git a/src/Config/Config.ts b/src/Config/Config.ts index bd9aa4de..3d269b8f 100644 --- a/src/Config/Config.ts +++ b/src/Config/Config.ts @@ -8,7 +8,7 @@ import { GitHubRepoConnectionOptions } from "../Connections/GithubRepo"; import { BridgeConfigActorPermission, BridgePermissions } from "../libRs"; import { ConfigError } from "../errors"; import { ApiError, ErrCode } from "../api"; -import { GITHUB_CLOUD_URL } from "../Github/GithubInstance"; +import { GithubInstance, GITHUB_CLOUD_URL } from "../Github/GithubInstance"; import { Logger } from "matrix-appservice-bridge"; const log = new Logger("Config"); @@ -79,7 +79,7 @@ export class BridgeConfigGitHub { @configKey("Prefix used when creating ghost users for GitHub accounts.", true) readonly userIdPrefix: string; - + @configKey("URL for enterprise deployments. Does not include /api/v3", true) private enterpriseUrl?: string; @@ -95,10 +95,10 @@ export class BridgeConfigGitHub { this.baseUrl = yaml.enterpriseUrl ? new URL(yaml.enterpriseUrl) : GITHUB_CLOUD_URL; } - @hideKey() - public get publicConfig() { + public publicConfig(githubInstance?: GithubInstance) { return { userIdPrefix: this.userIdPrefix, + newInstallationUrl: githubInstance?.newInstallationUrl?.toString(), } } } @@ -129,12 +129,12 @@ export interface BridgeConfigJiraYAML { } export class BridgeConfigJira implements BridgeConfigJiraYAML { static CLOUD_INSTANCE_NAME = "api.atlassian.com"; - + @configKey("Webhook settings for JIRA") readonly webhook: { secret: string; }; - + // To hide the undefined for now @hideKey() @configKey("URL for the instance if using on prem. Ignore if targetting cloud (atlassian.net)", true) @@ -411,6 +411,14 @@ interface BridgeConfigEncryption { storagePath: string; } +export interface BridgeConfigServiceBot { + localpart: string; + displayname?: string; + avatar?: string; + prefix: string; + service: string; +} + export interface BridgeConfigProvisioning { bindAddress?: string; port?: number; @@ -423,8 +431,14 @@ export interface BridgeConfigMetrics { port?: number; } +export interface BridgeConfigGoNebMigrator { + apiUrl: string; + serviceIds?: string[]; +} + export interface BridgeConfigRoot { bot?: BridgeConfigBot; + serviceBots?: BridgeConfigServiceBot[]; bridge: BridgeConfigBridge; experimentalEncryption?: BridgeConfigEncryption; figma?: BridgeConfigFigma; @@ -442,6 +456,7 @@ export interface BridgeConfigRoot { widgets?: BridgeWidgetConfigYAML; metrics?: BridgeConfigMetrics; listeners?: BridgeConfigListener[]; + goNebMigrator?: BridgeConfigGoNebMigrator; } export class BridgeConfig { @@ -478,6 +493,8 @@ export class BridgeConfig { public readonly feeds?: BridgeConfigFeeds; @configKey("Define profile information for the bot user", true) public readonly bot?: BridgeConfigBot; + @configKey("Define additional bot users for specific services", true) + public readonly serviceBots?: BridgeConfigServiceBot[]; @configKey("EXPERIMENTAL support for complimentary widgets", true) public readonly widgets?: BridgeWidgetConfig; @configKey("Provisioning API for integration managers", true) @@ -492,18 +509,21 @@ export class BridgeConfig { 'resources' may be any of ${ResourceTypeArray.join(', ')}`, true) public readonly listeners: BridgeConfigListener[]; + @configKey("go-neb migrator configuration", true) + public readonly goNebMigrator?: BridgeConfigGoNebMigrator; + @hideKey() private readonly bridgePermissions: BridgePermissions; - constructor(configData: BridgeConfigRoot, env: {[key: string]: string|undefined}) { + constructor(configData: BridgeConfigRoot, env?: {[key: string]: string|undefined}) { this.bridge = configData.bridge; assert.ok(this.bridge); this.github = configData.github && new BridgeConfigGitHub(configData.github); - if (this.github?.auth && env["GITHUB_PRIVATE_KEY_FILE"]) { - this.github.auth.privateKeyFile = env["GITHUB_PRIVATE_KEY_FILE"]; + if (this.github?.auth && env?.["GITHUB_PRIVATE_KEY_FILE"]) { + this.github.auth.privateKeyFile = env?.["GITHUB_PRIVATE_KEY_FILE"]; } - if (this.github?.oauth && env["GITHUB_OAUTH_REDIRECT_URI"]) { - this.github.oauth.redirect_uri = env["GITHUB_OAUTH_REDIRECT_URI"]; + if (this.github?.oauth && env?.["GITHUB_OAUTH_REDIRECT_URI"]) { + this.github.oauth.redirect_uri = env?.["GITHUB_OAUTH_REDIRECT_URI"]; } this.gitlab = configData.gitlab && new BridgeConfigGitLab(configData.gitlab); this.figma = configData.figma; @@ -513,6 +533,7 @@ export class BridgeConfig { this.provisioning = configData.provisioning; this.passFile = configData.passFile; this.bot = configData.bot; + this.serviceBots = configData.serviceBots; this.metrics = configData.metrics; this.queue = configData.queue || { monolithic: true, @@ -525,7 +546,7 @@ export class BridgeConfig { } this.widgets = configData.widgets && new BridgeWidgetConfig(configData.widgets); - + // To allow DEBUG as well as debug this.logging.level = this.logging.level.toLowerCase() as "debug"|"info"|"warn"|"error"|"trace"; if (!ValidLogLevelStrings.includes(this.logging.level)) { @@ -547,7 +568,7 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594. }]; this.bridgePermissions = new BridgePermissions(this.permissions); - if (!configData.permissions) { + if (!configData.permissions) { log.warn(`You have not configured any permissions for the bridge, which by default means all users on ${this.bridge.domain} have admin levels of control. Please adjust your config.`); } @@ -556,12 +577,14 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594. } // TODO: Formalize env support - if (env.CFG_QUEUE_MONOLITHIC && ["false", "off", "no"].includes(env.CFG_QUEUE_MONOLITHIC)) { + if (env?.CFG_QUEUE_MONOLITHIC && ["false", "off", "no"].includes(env.CFG_QUEUE_MONOLITHIC)) { this.queue.monolithic = false; - this.queue.host = env.CFG_QUEUE_HOST; - this.queue.port = env.CFG_QUEUE_POST ? parseInt(env.CFG_QUEUE_POST, 10) : undefined; + this.queue.host = env?.CFG_QUEUE_HOST; + this.queue.port = env?.CFG_QUEUE_POST ? parseInt(env?.CFG_QUEUE_POST, 10) : undefined; } + this.goNebMigrator = configData.goNebMigrator; + // Listeners is a bit special this.listeners = configData.listeners || []; @@ -574,7 +597,7 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594. }); log.warn("The `webhook` configuration still specifies a port/bindAddress. This should be moved to the `listeners` config."); } - + if (configData.widgets?.port) { this.listeners.push({ resources: ['widgets'], @@ -590,7 +613,7 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594. }) log.warn("The `provisioning` configuration still specifies a port/bindAddress. This should be moved to the `listeners` config."); } - + if (this.metrics?.port) { this.listeners.push({ resources: ['metrics'], @@ -599,7 +622,7 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594. }) log.warn("The `metrics` configuration still specifies a port/bindAddress. This should be moved to the `listeners` config."); } - + if (configData.widgets?.port) { this.listeners.push({ resources: ['widgets'], @@ -628,6 +651,10 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594. if (this.encryption && !this.queue.port) { throw new ConfigError("queue.port", "You must enable redis support for encryption to work."); } + + if (this.figma?.overrideUserId) { + log.warn("The `figma.overrideUserId` config value is deprecated. A service bot should be configured instead."); + } } public async prefillMembershipCache(client: MatrixClient) { @@ -637,7 +664,7 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594. const membership = await client.getJoinedRoomMembers(await client.resolveRoom(roomEntry)); membership.forEach(userId => this.bridgePermissions.addMemberToCache(roomEntry, userId)); log.debug(`Found ${membership.length} users for ${roomEntry}`); - } + } } public addMemberToCache(roomId: string, userId: string) { @@ -656,6 +683,29 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594. return this.bridgePermissions.checkAction(mxid, service, BridgePermissionLevel[permission]); } + public get enabledServices(): string[] { + const services = []; + if (this.feeds && this.feeds.enabled) { + services.push("feeds"); + } + if (this.figma) { + services.push("figma"); + } + if (this.generic && this.generic.enabled) { + services.push("generic"); + } + if (this.github) { + services.push("github"); + } + if (this.gitlab) { + services.push("gitlab"); + } + if (this.jira) { + services.push("jira"); + } + return services; + } + public getPublicConfigForService(serviceName: string): Record { let config: undefined|Record; switch (serviceName) { @@ -666,7 +716,7 @@ For more details, see https://github.com/matrix-org/matrix-hookshot/issues/594. config = this.generic?.publicConfig; break; case "github": - config = this.github?.publicConfig; + config = this.github?.publicConfig(); break; case "gitlab": config = this.gitlab?.publicConfig; diff --git a/src/Config/Defaults.ts b/src/Config/Defaults.ts index e4498329..5e166f31 100644 --- a/src/Config/Defaults.ts +++ b/src/Config/Defaults.ts @@ -1,17 +1,20 @@ -import { BridgeConfig } from "./Config"; +import { BridgeConfig, BridgeConfigRoot } from "./Config"; import YAML from "yaml"; import { getConfigKeyMetadata, keyIsHidden } from "./Decorators"; import { Node, YAMLSeq } from "yaml/types"; import { randomBytes } from "crypto"; import { DefaultDisallowedIpRanges } from "matrix-appservice-bridge"; -export const DefaultConfig = new BridgeConfig({ +const serverName = "example.com"; +const hookshotWebhooksUrl = "https://example.com"; + +export const DefaultConfigRoot: BridgeConfigRoot = { bridge: { - domain: "example.com", + domain: serverName, url: "http://localhost:8008", - mediaUrl: "http://example.com", + mediaUrl: "https://example.com", port: 9993, - bindAddress: "127.0.0.1", + bindAddress: "127.0.0.1", }, queue: { monolithic: true, @@ -25,7 +28,7 @@ export const DefaultConfig = new BridgeConfig({ timestampFormat: "HH:mm:ss:SSS", }, permissions: [{ - actor: "example.com", + actor: serverName, services: [{ service: "*", level: "admin" @@ -33,7 +36,7 @@ export const DefaultConfig = new BridgeConfig({ }], passFile: "passkey.pem", widgets: { - publicUrl: "http://example.com/widgetapi/v1/static", + publicUrl: `${hookshotWebhooksUrl}/widgetapi/v1/static`, addToAdminRooms: false, roomSetupWidget: { addOnInvite: false, @@ -44,9 +47,18 @@ export const DefaultConfig = new BridgeConfig({ }, }, bot: { - displayname: "GitHub Bot", + displayname: "Hookshot Bot", avatar: "mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d" }, + serviceBots: [ + { + localpart: "feeds", + displayname: "Feeds", + avatar: "mxc://half-shot.uk/2876e89ccade4cb615e210c458e2a7a6883fe17d", + prefix: "!feeds", + service: "feeds", + }, + ], github: { auth: { id: 123, @@ -55,7 +67,7 @@ export const DefaultConfig = new BridgeConfig({ oauth: { client_id: "foo", client_secret: "bar", - redirect_uri: "https://example.com/bridge_oauth/", + redirect_uri: `${hookshotWebhooksUrl}/bridge_oauth/`, }, webhook: { secret: "secrettoken", @@ -76,7 +88,7 @@ export const DefaultConfig = new BridgeConfig({ }, webhook: { secret: "secrettoken", - publicUrl: "https://example.com/hookshot/" + publicUrl: `${hookshotWebhooksUrl}/hookshot/`, }, userIdPrefix: "_gitlab_", }, @@ -87,19 +99,19 @@ export const DefaultConfig = new BridgeConfig({ oauth: { client_id: "foo", client_secret: "bar", - redirect_uri: "https://example.com/bridge_oauth/", + redirect_uri: `${hookshotWebhooksUrl}/bridge_oauth/`, }, }, generic: { allowJsTransformationFunctions: false, enabled: false, enableHttpGet: false, - urlPrefix: "https://example.com/webhook/", + urlPrefix: `${hookshotWebhooksUrl}/webhook/`, userIdPrefix: "_webhooks_", waitForComplete: false, }, figma: { - publicUrl: "https://example.com/hookshot/", + publicUrl: `${hookshotWebhooksUrl}/hookshot/`, instances: { "your-instance": { teamId: "your-team-id", @@ -135,7 +147,9 @@ export const DefaultConfig = new BridgeConfig({ resources: ['widgets'], } ] -}, {}); +}; + +export const DefaultConfig = new BridgeConfig(DefaultConfigRoot); function renderSection(doc: YAML.Document, obj: Record, parentNode?: YAMLSeq) { const entries = Object.entries(obj); diff --git a/src/ConnectionManager.ts b/src/ConnectionManager.ts index b83b9990..b509c7e1 100644 --- a/src/ConnectionManager.ts +++ b/src/ConnectionManager.ts @@ -4,8 +4,8 @@ * Manages connections between Matrix rooms and the remote side. */ +import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { ApiError, ErrCode } from "./api"; -import { Appservice, StateEvent } from "matrix-bot-sdk"; import { BridgeConfig, BridgePermissionLevel, GitLabInstance } from "./Config/Config"; import { CommentProcessor } from "./CommentProcessor"; import { ConnectionDeclaration, ConnectionDeclarations, GenericHookConnection, GitHubDiscussionConnection, GitHubDiscussionSpace, GitHubIssueConnection, GitHubProjectConnection, GitHubRepoConnection, GitHubUserSpace, GitLabIssueConnection, GitLabRepoConnection, IConnection, IConnectionState, JiraProjectConnection } from "./Connections"; @@ -18,6 +18,7 @@ import { JiraProject, JiraVersion } from "./Jira/Types"; import { Logger } from "matrix-appservice-bridge"; import { MessageSenderClient } from "./MatrixSender"; import { UserTokenStore } from "./UserTokenStore"; +import BotUsersManager from "./Managers/BotUsersManager"; import { retry, retryMatrixErrorFilter } from "./PromiseUtil"; import Metrics from "./Metrics"; import EventEmitter from "events"; @@ -42,6 +43,7 @@ export class ConnectionManager extends EventEmitter { private readonly commentProcessor: CommentProcessor, private readonly messageClient: MessageSenderClient, private readonly storage: IBridgeStorageProvider, + private readonly botUsersManager: BotUsersManager, private readonly github?: GithubInstance ) { super(); @@ -66,21 +68,29 @@ export class ConnectionManager extends EventEmitter { /** * Used by the provisioner API to create new connections on behalf of users. + * * @param roomId The target Matrix room. + * @param intent Bot user intent to create the connection with. * @param userId The requesting Matrix user. - * @param type The type of room (corresponds to the event type of the connection) + * @param connectionType The connection declaration to provision. * @param data The data corresponding to the connection state. This will be validated. * @returns The resulting connection. */ - public async provisionConnection(roomId: string, userId: string, type: string, data: Record) { - log.info(`Looking to provision connection for ${roomId} ${type} for ${userId} with data ${JSON.stringify(data)}`); - const connectionType = ConnectionDeclarations.find(c => c.EventTypes.includes(type)); + public async provisionConnection( + roomId: string, + intent: Intent, + userId: string, + connectionType: ConnectionDeclaration, + data: Record, + ) { + log.info(`Looking to provision connection for ${roomId} ${connectionType.ServiceCategory} for ${userId} with data ${JSON.stringify(data)}`); if (connectionType?.provisionConnection) { if (!this.config.checkPermission(userId, connectionType.ServiceCategory, BridgePermissionLevel.manageConnections)) { throw new ApiError(`User is not permitted to provision connections for this type of service.`, ErrCode.ForbiddenUser); } const result = await connectionType.provisionConnection(roomId, userId, data, { as: this.as, + intent: intent, config: this.config, tokenStore: this.tokenStore, commentProcessor: this.commentProcessor, @@ -99,14 +109,15 @@ export class ConnectionManager extends EventEmitter { * Check if a state event is sent by a user who is allowed to configure the type of connection the state event covers. * If it isn't, optionally revert the state to the last-known valid value, or redact it if that isn't possible. * @param roomId The target Matrix room. + * @param intent The bot intent to use. * @param state The state event for altering a connection in the room. * @param serviceType The type of connection the state event is altering. * @returns Whether the state event was allowed to be set. If not, the state will be reverted asynchronously. */ - public verifyStateEvent(roomId: string, state: StateEvent, serviceType: string, rollbackBadState: boolean) { + public verifyStateEvent(roomId: string, intent: Intent, state: StateEvent, serviceType: string, rollbackBadState: boolean) { if (!this.isStateAllowed(roomId, state, serviceType)) { if (rollbackBadState) { - void this.tryRestoreState(roomId, state, serviceType); + void this.tryRestoreState(roomId, intent, state, serviceType); } log.error(`User ${state.sender} is disallowed to manage state for ${serviceType} in ${roomId}`); return false; @@ -121,50 +132,72 @@ export class ConnectionManager extends EventEmitter { * @param state The state event for altering a connection in the room targeted by {@link connection}. * @returns Whether the state event was allowed to be set. If not, the state will be reverted asynchronously. */ - public verifyStateEventForConnection(connection: IConnection, state: StateEvent, rollbackBadState: boolean) { + public verifyStateEventForConnection(connection: IConnection, state: StateEvent, rollbackBadState: boolean): boolean { const cd: ConnectionDeclaration = Object.getPrototypeOf(connection).constructor; - return !this.verifyStateEvent(connection.roomId, state, cd.ServiceCategory, rollbackBadState); + const botUser = this.botUsersManager.getBotUserInRoom(connection.roomId, cd.ServiceCategory); + if (!botUser) { + log.error(`Failed to find a bot in room '${connection.roomId}' for service type '${cd.ServiceCategory}' when verifying state for connection`); + throw Error('Could not find a bot to handle this connection'); + } + return this.verifyStateEvent(connection.roomId, botUser.intent, state, cd.ServiceCategory, rollbackBadState); } private isStateAllowed(roomId: string, state: StateEvent, serviceType: string) { - return state.sender === this.as.botUserId + return this.botUsersManager.isBotUser(state.sender) || this.config.checkPermission(state.sender, serviceType, BridgePermissionLevel.manageConnections); } - private async tryRestoreState(roomId: string, originalState: StateEvent, serviceType: string) { + private async tryRestoreState(roomId: string, intent: Intent, originalState: StateEvent, serviceType: string) { let state = originalState; let attemptsRemaining = 5; try { do { if (state.unsigned.replaces_state) { - state = new StateEvent(await this.as.botClient.getEvent(roomId, state.unsigned.replaces_state)); + state = new StateEvent(await intent.underlyingClient.getEvent(roomId, state.unsigned.replaces_state)); } else { - await this.as.botClient.redactEvent(roomId, originalState.eventId, + await intent.underlyingClient.redactEvent(roomId, originalState.eventId, `User ${originalState.sender} is disallowed to manage state for ${serviceType} in ${roomId}`); return; } } while (--attemptsRemaining > 0 && !this.isStateAllowed(roomId, state, serviceType)); - await this.as.botClient.sendStateEvent(roomId, state.type, state.stateKey, state.content); + await intent.underlyingClient.sendStateEvent(roomId, state.type, state.stateKey, state.content); } catch (ex) { log.warn(`Unable to undo state event from ${state.sender} for disallowed ${serviceType} connection management in ${roomId}`); } } - public createConnectionForState(roomId: string, state: StateEvent, rollbackBadState: boolean) { + /** + * This is called ONLY when we spot new state in a room and want to create a connection for it. + * @param roomId + * @param state + * @param rollbackBadState + * @returns + */ + public async createConnectionForState(roomId: string, state: StateEvent, rollbackBadState: boolean) { // Empty object == redacted if (state.content.disabled === true || Object.keys(state.content).length === 0) { log.debug(`${roomId} has disabled state for ${state.type}`); return; } - const connectionType = ConnectionDeclarations.find(c => c.EventTypes.includes(state.type)); + const connectionType = this.getConnectionTypeForEventType(state.type); if (!connectionType) { return; } - if (!this.verifyStateEvent(roomId, state, connectionType.ServiceCategory, rollbackBadState)) { + + // Get a bot user for the connection type + const botUser = this.botUsersManager.getBotUserInRoom(roomId, connectionType.ServiceCategory); + if (!botUser) { + log.error(`Failed to find a bot in room '${roomId}' for service type '${connectionType.ServiceCategory}' when creating connection for state`); + throw Error('Could not find a bot to handle this connection'); + } + + if (!this.verifyStateEvent(roomId, botUser.intent, state, connectionType.ServiceCategory, rollbackBadState)) { return; } - return connectionType.createConnectionForState(roomId, state, { + + const connection = await connectionType.createConnectionForState(roomId, state, { as: this.as, + intent: botUser.intent, config: this.config, tokenStore: this.tokenStore, commentProcessor: this.commentProcessor, @@ -172,13 +205,29 @@ export class ConnectionManager extends EventEmitter { storage: this.storage, github: this.github, }); + + // Finally, ensure the connection is allowed by us. + await connection.ensureGrant?.(state.sender); + return connection; } + /** + * This is called when hookshot starts up, or a hookshot service bot has left + * and we need to recalculate the right bots for all the connections in a room. + * @param roomId + * @param rollbackBadState + * @returns + */ public async createConnectionsForRoomId(roomId: string, rollbackBadState: boolean) { - let connectionCreated = false; + const botUser = this.botUsersManager.getBotUserInRoom(roomId); + if (!botUser) { + log.error(`Failed to find a bot in room '${roomId}' when creating connections`); + return; + } + // This endpoint can be heavy, wrap it in pillows. const state = await retry( - () => this.as.botClient.getRoomState(roomId), + () => botUser.intent.underlyingClient.getRoomState(roomId), GET_STATE_ATTEMPTS, GET_STATE_TIMEOUT_MS, retryMatrixErrorFilter @@ -190,13 +239,11 @@ export class ConnectionManager extends EventEmitter { if (conn) { log.debug(`Room ${roomId} is connected to: ${conn}`); this.push(conn); - connectionCreated = true; } } catch (ex) { log.error(`Failed to create connection for ${roomId}:`, ex); } } - return connectionCreated; } public getConnectionsForGithubIssue(org: string, repo: string, issueNumber: number): (GitHubIssueConnection|GitHubRepoConnection)[] { @@ -289,12 +336,16 @@ export class ConnectionManager extends EventEmitter { public getConnectionsForFeedUrl(url: string): FeedConnection[] { return this.connections.filter(c => c instanceof FeedConnection && c.feedUrl === url) as FeedConnection[]; } - + // eslint-disable-next-line @typescript-eslint/no-explicit-any public getAllConnectionsOfType(typeT: new (...params : any[]) => T): T[] { return this.connections.filter((c) => (c instanceof typeT)) as T[]; } + public getConnectionTypeForEventType(eventType: string): ConnectionDeclaration | undefined { + return ConnectionDeclarations.find(c => c.EventTypes.includes(eventType)); + } + public isRoomConnected(roomId: string): boolean { return !!this.connections.find(c => c.roomId === roomId); } @@ -344,7 +395,7 @@ export class ConnectionManager extends EventEmitter { /** * Removes connections for a room from memory. This does NOT remove the state * event from the room. - * @param roomId + * @param roomId */ public async removeConnectionsForRoom(roomId: string) { log.info(`Removing all connections from ${roomId}`); @@ -363,14 +414,14 @@ export class ConnectionManager extends EventEmitter { /** * Get a list of possible targets for a given connection type when provisioning - * @param userId - * @param type + * @param userId + * @param type */ async getConnectionTargets(userId: string, type: string, filters: Record = {}): Promise { switch (type) { case GitLabRepoConnection.CanonicalEventType: { const configObject = this.validateConnectionTarget(userId, this.config.gitlab, "GitLab", "gitlab"); - return await GitLabRepoConnection.getConnectionTargets(userId, this.tokenStore, configObject, filters); + return await GitLabRepoConnection.getConnectionTargets(userId, configObject, filters, this.tokenStore, this.storage); } case GitHubRepoConnection.CanonicalEventType: { this.validateConnectionTarget(userId, this.config.github, "GitHub", "github"); diff --git a/src/Connections/CommandConnection.ts b/src/Connections/CommandConnection.ts index 7b511a76..4d31d046 100644 --- a/src/Connections/CommandConnection.ts +++ b/src/Connections/CommandConnection.ts @@ -10,17 +10,17 @@ const log = new Logger("CommandConnection"); * Connection class that handles commands for a given connection. Should be used * by connections expecting to handle user input. */ -export abstract class CommandConnection extends BaseConnection { - protected enabledHelpCategories?: string[]; +export abstract class CommandConnection extends BaseConnection { protected includeTitlesInHelp?: boolean; constructor( roomId: string, stateKey: string, canonicalStateType: string, - protected state: StateType, + protected state: ValidatedStateType, private readonly botClient: MatrixClient, private readonly botCommands: BotCommands, private readonly helpMessage: HelpFunction, + protected readonly helpCategories: string[], protected readonly defaultCommandPrefix: string, protected readonly serviceName?: string, ) { @@ -36,10 +36,10 @@ export abstract class CommandConnection) { - this.state = this.validateConnectionState(stateEv.content); + this.state = await this.validateConnectionState(stateEv.content); } - protected abstract validateConnectionState(content: unknown): StateType; + protected abstract validateConnectionState(content: unknown): Promise|ValidatedStateType; public async onMessageEvent(ev: MatrixEvent, checkPermission: PermissionCheckFn) { const commandResult = await handleCommand( @@ -80,6 +80,6 @@ export abstract class CommandConnection, {config, as, storage}: InstantiateConnectionOpts) { + public static createConnectionForState(roomId: string, event: StateEvent, {config, as, intent, storage}: InstantiateConnectionOpts) { if (!config.feeds?.enabled) { throw Error('RSS/Atom feeds are not configured'); } - return new FeedConnection(roomId, event.stateKey, event.content, config.feeds, as, storage); + return new FeedConnection(roomId, event.stateKey, event.content, config.feeds, as, intent, storage); } static async validateUrl(url: string): Promise { @@ -74,7 +74,7 @@ export class FeedConnection extends BaseConnection implements IConnection { } } - static async provisionConnection(roomId: string, _userId: string, data: Record = {}, {as, config, storage}: ProvisionConnectionOpts) { + static async provisionConnection(roomId: string, _userId: string, data: Record = {}, {as, intent, config, storage}: ProvisionConnectionOpts) { if (!config.feeds?.enabled) { throw new ApiError('RSS/Atom feeds are not configured', ErrCode.DisabledFeature); } @@ -90,8 +90,8 @@ export class FeedConnection extends BaseConnection implements IConnection { const state = { url, label: data.label }; - const connection = new FeedConnection(roomId, url, state, config.feeds, as, storage); - await as.botClient.sendStateEvent(roomId, FeedConnection.CanonicalEventType, url, state); + const connection = new FeedConnection(roomId, url, state, config.feeds, as, intent, storage); + await intent.underlyingClient.sendStateEvent(roomId, FeedConnection.CanonicalEventType, url, state); return { connection, @@ -104,14 +104,13 @@ export class FeedConnection extends BaseConnection implements IConnection { service: "feeds", eventType: FeedConnection.CanonicalEventType, type: "Feed", - // TODO: Add ability to configure the bot per connnection type. botUserId: botUserId, } } public getProvisionerDetails(): FeedResponseItem { return { - ...FeedConnection.getProvisionerDetails(this.as.botUserId), + ...FeedConnection.getProvisionerDetails(this.intent.userId), id: this.connectionId, config: { url: this.feedUrl, @@ -136,6 +135,7 @@ export class FeedConnection extends BaseConnection implements IConnection { private state: FeedConnectionState, private readonly config: BridgeConfigFeeds, private readonly as: Appservice, + private readonly intent: Intent, private readonly storage: IBridgeStorageProvider ) { super(roomId, stateKey, FeedConnection.CanonicalEventType) @@ -160,7 +160,7 @@ export class FeedConnection extends BaseConnection implements IConnection { message += `: ${entryDetails}`; } - await this.as.botIntent.sendEvent(this.roomId, { + await this.intent.sendEvent(this.roomId, { msgtype: 'm.notice', format: "org.matrix.custom.html", formatted_body: md.renderInline(message), @@ -190,7 +190,7 @@ export class FeedConnection extends BaseConnection implements IConnection { return; } if (!this.hasError) { - await this.as.botIntent.sendEvent(this.roomId, { + await this.intent.sendEvent(this.roomId, { msgtype: 'm.notice', format: 'm.text', body: `Error fetching ${this.feedUrl}: ${error.cause.message}` @@ -202,7 +202,7 @@ export class FeedConnection extends BaseConnection implements IConnection { // needed to ensure that the connection is removable public async onRemove(): Promise { log.info(`Removing connection ${this.connectionId}`); - await this.as.botClient.sendStateEvent(this.roomId, FeedConnection.CanonicalEventType, this.feedUrl, {}); + await this.intent.underlyingClient.sendStateEvent(this.roomId, FeedConnection.CanonicalEventType, this.feedUrl, {}); } toString(): string { diff --git a/src/Connections/FigmaFileConnection.ts b/src/Connections/FigmaFileConnection.ts index c42f4bfd..f1e1fc21 100644 --- a/src/Connections/FigmaFileConnection.ts +++ b/src/Connections/FigmaFileConnection.ts @@ -1,12 +1,13 @@ -import { Appservice, StateEvent } from "matrix-bot-sdk"; +import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import markdownit from "markdown-it"; import { FigmaPayload } from "../figma/types"; import { BaseConnection } from "./BaseConnection"; import { IConnection, IConnectionState } from "."; import { Logger } from "matrix-appservice-bridge"; import { IBridgeStorageProvider } from "../Stores/StorageProvider"; -import { BridgeConfigFigma } from "../Config/Config"; +import { BridgeConfig } from "../Config/Config"; import { Connection, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection"; +import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck"; const log = new Logger("FigmaFileConnection"); @@ -29,7 +30,7 @@ export class FigmaFileConnection extends BaseConnection implements IConnection { ]; static readonly ServiceCategory = "figma"; - + public static validateState(data: Record): FigmaFileConnectionState { if (!data.fileId || typeof data.fileId !== "string") { throw Error('Missing or invalid fileId'); @@ -43,33 +44,36 @@ export class FigmaFileConnection extends BaseConnection implements IConnection { } } - public static createConnectionForState(roomId: string, event: StateEvent, {config, as, storage}: InstantiateConnectionOpts) { + public static createConnectionForState(roomId: string, event: StateEvent, {config, as, intent, storage}: InstantiateConnectionOpts) { if (!config.figma) { throw Error('Figma is not configured'); } - return new FigmaFileConnection(roomId, event.stateKey, event.content, config.figma, as, storage); + return new FigmaFileConnection(roomId, event.stateKey, event.content, config, as, intent, storage); } - static async provisionConnection(roomId: string, userId: string, data: Record = {}, {as, config, storage}: ProvisionConnectionOpts) { + static async provisionConnection(roomId: string, userId: string, data: Record = {}, {as, intent, config, storage}: ProvisionConnectionOpts) { if (!config.figma) { throw Error('Figma is not configured'); } const validState = this.validateState(data); - const connection = new FigmaFileConnection(roomId, validState.fileId, validState, config.figma, as, storage); - await as.botClient.sendStateEvent(roomId, FigmaFileConnection.CanonicalEventType, validState.fileId, validState); + const connection = new FigmaFileConnection(roomId, validState.fileId, validState, config, as, intent, storage); + await new GrantChecker(as.botIntent, "figma").grantConnection(roomId, { fileId: validState.fileId, instanceName: validState.instanceName || "none"}); + await intent.underlyingClient.sendStateEvent(roomId, FigmaFileConnection.CanonicalEventType, validState.fileId, validState); return { connection, stateEventContent: validState, } } + private readonly grantChecker: GrantChecker<{fileId: string, instanceName: string}> = new ConfigGrantChecker("figma", this.as, this.config); constructor( roomId: string, stateKey: string, private state: FigmaFileConnectionState, - private readonly config: BridgeConfigFigma, + private readonly config: BridgeConfig, private readonly as: Appservice, + private readonly intent: Intent, private readonly storage: IBridgeStorageProvider) { super(roomId, stateKey, FigmaFileConnection.CanonicalEventType) } @@ -90,6 +94,14 @@ export class FigmaFileConnection extends BaseConnection implements IConnection { return this.state.priority || super.priority; } + public async ensureGrant(sender?: string) { + return this.grantChecker.assertConnectionGranted(this.roomId, { fileId: this.state.fileId, instanceName: this.state.instanceName || "none"}, sender); + } + + public async onRemove() { + return this.grantChecker.ungrantConnection(this.roomId, { fileId: this.state.fileId, instanceName: this.state.instanceName || "none"}); + } + public async handleNewComment(payload: FigmaPayload) { // We need to check if the comment was actually new. // There isn't a way to tell how the comment has changed, so for now check the timestamps @@ -100,8 +112,13 @@ export class FigmaFileConnection extends BaseConnection implements IConnection { return; } - const intent = this.as.getIntentForUserId(this.config.overrideUserId || this.as.botUserId); - + let intent; + if (this.config.figma?.overrideUserId) { + intent = this.as.getIntentForUserId(this.config.figma.overrideUserId); + } else { + intent = this.intent; + } + const permalink = `https://www.figma.com/file/${payload.file_key}#${payload.comment_id}`; const comment = payload.comment.map(({text}) => text).join("\n"); const empty = "‎"; // This contains an empty character to thwart the notification matcher. @@ -109,7 +126,7 @@ export class FigmaFileConnection extends BaseConnection implements IConnection { let content: Record|undefined = undefined; const parentEventId = payload.parent_id && await this.storage.getFigmaCommentEventId(this.roomId, payload.parent_id); if (parentEventId) { - content = { + content = { "m.relates_to": { rel_type: THREAD_RELATION_TYPE, event_id: parentEventId, diff --git a/src/Connections/GenericHook.ts b/src/Connections/GenericHook.ts index 211dcf87..173e805d 100644 --- a/src/Connections/GenericHook.ts +++ b/src/Connections/GenericHook.ts @@ -4,12 +4,13 @@ import { MessageSenderClient } from "../MatrixSender" import markdownit from "markdown-it"; import { VMScript as Script, NodeVM } from "vm2"; import { MatrixEvent } from "../MatrixEvent"; -import { Appservice, StateEvent } from "matrix-bot-sdk"; -import { v4 as uuid} from "uuid"; +import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { ApiError, ErrCode } from "../api"; import { BaseConnection } from "./BaseConnection"; import { GetConnectionsResponseItem } from "../provisioning/api"; import { BridgeConfigGenericWebhooks } from "../Config/Config"; +import { ensureUserIsInRoom } from "../IntentUtils"; +import { randomUUID } from 'node:crypto'; export interface GenericHookConnectionState extends IConnectionState { /** @@ -68,14 +69,14 @@ export class GenericHookConnection extends BaseConnection implements IConnection /** * Ensures a JSON payload is compatible with Matrix JSON requirements, such * as disallowing floating point values. - * + * * If the `depth` exceeds `SANITIZE_MAX_DEPTH`, the value of `data` will be immediately returned. * If the object contains more than `SANITIZE_MAX_BREADTH` entries, the remaining entries will not be checked. - * + * * @param data The data to santise * @param depth The depth of the `data` relative to the root. * @param breadth The breadth of the `data` in the parent object. - * @returns + * @returns */ static sanitiseObjectForMatrixJSON(data: unknown, depth = 0, breadth = 0): unknown { // Floats @@ -91,7 +92,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection if (depth > SANITIZE_MAX_DEPTH || breadth > SANITIZE_MAX_BREADTH) { return JSON.stringify(data); } - + const newDepth = depth + 1; if (Array.isArray(data)) { return data.map((d, innerBreadth) => this.sanitiseObjectForMatrixJSON(d, newDepth, innerBreadth)); @@ -130,19 +131,19 @@ export class GenericHookConnection extends BaseConnection implements IConnection }; } - static async createConnectionForState(roomId: string, event: StateEvent>, {as, config, messageClient}: InstantiateConnectionOpts) { + static async createConnectionForState(roomId: string, event: StateEvent>, {as, intent, config, messageClient}: InstantiateConnectionOpts) { if (!config.generic) { throw Error('Generic webhooks are not configured'); } // Generic hooks store the hookId in the account data - const acctData = await as.botClient.getSafeRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, {}); + const acctData = await intent.underlyingClient.getSafeRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, {}); const state = this.validateState(event.content); // hookId => stateKey let hookId = Object.entries(acctData).find(([, v]) => v === event.stateKey)?.[0]; if (!hookId) { - hookId = uuid(); + hookId = randomUUID(); log.warn(`hookId for ${roomId} not set in accountData, setting to ${hookId}`); - await GenericHookConnection.ensureRoomAccountData(roomId, as, hookId, event.stateKey); + await GenericHookConnection.ensureRoomAccountData(roomId, intent, hookId, event.stateKey); } return new GenericHookConnection( @@ -153,18 +154,19 @@ export class GenericHookConnection extends BaseConnection implements IConnection messageClient, config.generic, as, + intent, ); } - static async provisionConnection(roomId: string, userId: string, data: Record = {}, {as, config, messageClient}: ProvisionConnectionOpts) { + static async provisionConnection(roomId: string, userId: string, data: Record = {}, {as, intent, config, messageClient}: ProvisionConnectionOpts) { if (!config.generic) { throw Error('Generic Webhooks are not configured'); } - const hookId = uuid(); + const hookId = randomUUID(); const validState = GenericHookConnection.validateState(data, config.generic.allowJsTransformationFunctions || false); - await GenericHookConnection.ensureRoomAccountData(roomId, as, hookId, validState.name); - await as.botClient.sendStateEvent(roomId, this.CanonicalEventType, validState.name, validState); - const connection = new GenericHookConnection(roomId, validState, hookId, validState.name, messageClient, config.generic, as); + await GenericHookConnection.ensureRoomAccountData(roomId, intent, hookId, validState.name); + await intent.underlyingClient.sendStateEvent(roomId, this.CanonicalEventType, validState.name, validState); + const connection = new GenericHookConnection(roomId, validState, hookId, validState.name, messageClient, config.generic, as, intent); return { connection, stateEventContent: validState, @@ -173,25 +175,22 @@ export class GenericHookConnection extends BaseConnection implements IConnection /** * This function ensures the account data for a room contains all the hookIds for the various state events. - * @param roomId - * @param as - * @param connection */ - static async ensureRoomAccountData(roomId: string, as: Appservice, hookId: string, stateKey: string, remove = false) { - const data = await as.botClient.getSafeRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, {}); + static async ensureRoomAccountData(roomId: string, intent: Intent, hookId: string, stateKey: string, remove = false) { + const data = await intent.underlyingClient.getSafeRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, {}); if (remove && data[hookId] === stateKey) { delete data[hookId]; - await as.botClient.setRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, data); + await intent.underlyingClient.setRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, data); } if (!remove && data[hookId] !== stateKey) { data[hookId] = stateKey; - await as.botClient.setRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, data); + await intent.underlyingClient.setRoomAccountData(GenericHookConnection.CanonicalEventType, roomId, data); } } static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.generic.hook"; static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.generic.hook"; - static readonly ServiceCategory = "webhooks"; + static readonly ServiceCategory = "generic"; static readonly EventTypes = [ GenericHookConnection.CanonicalEventType, @@ -200,23 +199,25 @@ export class GenericHookConnection extends BaseConnection implements IConnection private transformationFunction?: Script; private cachedDisplayname?: string; - constructor(roomId: string, + constructor( + roomId: string, private state: GenericHookConnectionState, public readonly hookId: string, stateKey: string, private readonly messageClient: MessageSenderClient, private readonly config: BridgeConfigGenericWebhooks, - private readonly as: Appservice) { - super(roomId, stateKey, GenericHookConnection.CanonicalEventType); - if (state.transformationFunction && config.allowJsTransformationFunctions) { - this.transformationFunction = new Script(state.transformationFunction); - } - } - - public get priority(): number { - return this.state.priority || super.priority; + private readonly as: Appservice, + private readonly intent: Intent, + ) { + super(roomId, stateKey, GenericHookConnection.CanonicalEventType); + if (state.transformationFunction && config.allowJsTransformationFunctions) { + this.transformationFunction = new Script(state.transformationFunction); } + } + public get priority(): number { + return this.state.priority || super.priority; + } public isInterestedInStateEvent(eventType: string, stateKey: string) { return GenericHookConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; @@ -224,32 +225,31 @@ export class GenericHookConnection extends BaseConnection implements IConnection public getUserId() { if (!this.config.userIdPrefix) { - return this.as.botUserId; + return this.intent.userId; } - const [, domain] = this.as.botUserId.split(':'); + const [, domain] = this.intent.userId.split(':'); const name = this.state.name && this.state.name.replace(/[A-Z]/g, (s) => s.toLowerCase()).replace(/([^a-z0-9\-.=_]+)/g, ''); return `@${this.config.userIdPrefix}${name || 'bot'}:${domain}`; } - public async ensureDisplayname(sender: string) { + public async ensureDisplayname(intent: Intent) { if (!this.state.name) { return; } - if (sender === this.as.botUserId) { - // Don't set the global displayname for the bot. - return; + if (this.intent.userId === intent.userId) { + // Don't set a displayname on the root bot user. + return; } - const intent = this.as.getIntentForUserId(sender); + await intent.ensureRegistered(); const expectedDisplayname = `${this.state.name} (Webhook)`; try { if (this.cachedDisplayname !== expectedDisplayname) { - this.cachedDisplayname = (await intent.underlyingClient.getUserProfile(sender)).displayname; + this.cachedDisplayname = (await intent.underlyingClient.getUserProfile(this.intent.userId)).displayname; } } catch (ex) { // Couldn't fetch, probably not set. - await intent.ensureRegistered(); this.cachedDisplayname = undefined; } if (this.cachedDisplayname !== expectedDisplayname) { @@ -264,7 +264,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection try { this.transformationFunction = new Script(validatedConfig.transformationFunction); } catch (ex) { - await this.messageClient.sendMatrixText(this.roomId, 'Could not compile transformation function:' + ex); + await this.messageClient.sendMatrixText(this.roomId, 'Could not compile transformation function:' + ex, "m.text", this.intent.userId); } } else { this.transformationFunction = undefined; @@ -352,7 +352,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection /** * Processes an incoming generic hook * @param data Structured data. This may either be a string, or an object. - * @returns `true` if the webhook completed, or `false` if it failed to complete + * @returns `true` if the webhook completed, or `false` if it failed to complete */ public async onGenericHook(data: unknown): Promise { log.info(`onGenericHook ${this.roomId} ${this.hookId}`); @@ -376,11 +376,14 @@ export class GenericHookConnection extends BaseConnection implements IConnection } const sender = this.getUserId(); - await this.ensureDisplayname(sender); + const senderIntent = this.as.getIntentForUserId(sender); + await this.ensureDisplayname(senderIntent); + + await ensureUserIsInRoom(senderIntent, this.intent.underlyingClient, this.roomId); // Matrix cannot handle float data, so make sure we parse out any floats. const safeData = GenericHookConnection.sanitiseObjectForMatrixJSON(data); - + await this.messageClient.sendMatrixMessage(this.roomId, { msgtype: content.msgtype || "m.notice", body: content.plain, @@ -405,7 +408,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection public getProvisionerDetails(showSecrets = false): GenericHookResponseItem { return { - ...GenericHookConnection.getProvisionerDetails(this.as.botUserId), + ...GenericHookConnection.getProvisionerDetails(this.intent.userId), id: this.connectionId, config: { transformationFunction: this.state.transformationFunction, @@ -422,20 +425,20 @@ export class GenericHookConnection extends BaseConnection implements IConnection log.info(`Removing ${this.toString()} for ${this.roomId}`); // Do a sanity check that the event exists. try { - await this.as.botClient.getRoomStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey); - await this.as.botClient.sendStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey, { disabled: true }); + await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey); + await this.intent.underlyingClient.sendStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey, { disabled: true }); } catch (ex) { - await this.as.botClient.getRoomStateEvent(this.roomId, GenericHookConnection.LegacyCanonicalEventType, this.stateKey); - await this.as.botClient.sendStateEvent(this.roomId, GenericHookConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true }); + await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GenericHookConnection.LegacyCanonicalEventType, this.stateKey); + await this.intent.underlyingClient.sendStateEvent(this.roomId, GenericHookConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true }); } - await GenericHookConnection.ensureRoomAccountData(this.roomId, this.as, this.hookId, this.stateKey, true); + await GenericHookConnection.ensureRoomAccountData(this.roomId, this.intent, this.hookId, this.stateKey, true); } public async provisionerUpdateConfig(userId: string, config: Record) { // Apply previous state to the current config, as provisioners might not return "unknown" keys. config = { ...this.state, ...config }; const validatedConfig = GenericHookConnection.validateState(config, this.config.allowJsTransformationFunctions || false); - await this.as.botClient.sendStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey, + await this.intent.underlyingClient.sendStateEvent(this.roomId, GenericHookConnection.CanonicalEventType, this.stateKey, { ...validatedConfig, hookId: this.hookId diff --git a/src/Connections/GithubDiscussion.ts b/src/Connections/GithubDiscussion.ts index 14bbc28d..44407245 100644 --- a/src/Connections/GithubDiscussion.ts +++ b/src/Connections/GithubDiscussion.ts @@ -1,9 +1,9 @@ import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection"; -import { Appservice, StateEvent } from "matrix-bot-sdk"; +import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { UserTokenStore } from "../UserTokenStore"; import { CommentProcessor } from "../CommentProcessor"; import { MessageSenderClient } from "../MatrixSender"; -import { getIntentForUser } from "../IntentUtils"; +import { ensureUserIsInRoom, getIntentForUser } from "../IntentUtils"; import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent"; import { Discussion } from "@octokit/webhooks-types"; import emoji from "node-emoji"; @@ -12,7 +12,9 @@ import { DiscussionCommentCreatedEvent } from "@octokit/webhooks-types"; import { GithubGraphQLClient } from "../Github/GithubInstance"; import { Logger } from "matrix-appservice-bridge"; import { BaseConnection } from "./BaseConnection"; -import { BridgeConfigGitHub } from "../Config/Config"; +import { BridgeConfig, BridgeConfigGitHub } from "../Config/Config"; +import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck"; +import QuickLRU from "@alloc/quick-lru"; export interface GitHubDiscussionConnectionState { owner: string; repo: string; @@ -42,27 +44,26 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne static readonly ServiceCategory = "github"; public static createConnectionForState(roomId: string, event: StateEvent, { - github, config, as, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts) { + github, config, as, intent, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts) { if (!github || !config.github) { throw Error('GitHub is not configured'); } return new GitHubDiscussionConnection( - roomId, as, event.content, event.stateKey, tokenStore, commentProcessor, - messageClient, config.github, + roomId, as, intent, event.content, event.stateKey, tokenStore, commentProcessor, + messageClient, config, ); } - readonly sentEvents = new Set(); //TODO: Set some reasonable limits public static async createDiscussionRoom( - as: Appservice, userId: string|null, owner: string, repo: string, discussion: Discussion, + as: Appservice, intent: Intent, userId: string|null, owner: string, repo: string, discussion: Discussion, tokenStore: UserTokenStore, commentProcessor: CommentProcessor, messageClient: MessageSenderClient, - config: BridgeConfigGitHub, + config: BridgeConfig, ) { const commentIntent = await getIntentForUser({ login: discussion.user.login, avatarUrl: discussion.user.avatar_url, - }, as, config.userIdPrefix); + }, as, config.github?.userIdPrefix); const state: GitHubDiscussionConnectionState = { owner, repo, @@ -71,7 +72,7 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne discussion: discussion.number, category: discussion.category.id, }; - const invite = [as.botUserId]; + const invite = [intent.userId]; if (userId) { invite.push(userId); } @@ -93,20 +94,38 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne formatted_body: md.render(discussion.body), format: 'org.matrix.custom.html', }); - await as.botIntent.ensureJoined(roomId); - return new GitHubDiscussionConnection(roomId, as, state, '', tokenStore, commentProcessor, messageClient, config); + await intent.ensureJoined(roomId); + + return new GitHubDiscussionConnection(roomId, as, intent, state, '', tokenStore, commentProcessor, messageClient, config); } - constructor(roomId: string, + private static grantKey(state: GitHubDiscussionConnectionState) { + return `${this.CanonicalEventType}/${state.owner}/${state.repo}`; + } + + private readonly sentEvents = new QuickLRU({ maxSize: 128 }); + private readonly grantChecker: GrantChecker; + + private readonly config: BridgeConfigGitHub; + + constructor( + roomId: string, private readonly as: Appservice, + private readonly intent: Intent, private readonly state: GitHubDiscussionConnectionState, stateKey: string, private readonly tokenStore: UserTokenStore, private readonly commentProcessor: CommentProcessor, private readonly messageClient: MessageSenderClient, - private readonly config: BridgeConfigGitHub) { - super(roomId, stateKey, GitHubDiscussionConnection.CanonicalEventType); + bridgeConfig: BridgeConfig, + ) { + super(roomId, stateKey, GitHubDiscussionConnection.CanonicalEventType); + if (!bridgeConfig.github) { + throw Error('Expected github to be enabled in config'); } + this.config = bridgeConfig.github; + this.grantChecker = new ConfigGrantChecker("github", this.as, bridgeConfig); + } public isInterestedInStateEvent(eventType: string, stateKey: string) { return GitHubDiscussionConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; @@ -116,13 +135,13 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne const octokit = await this.tokenStore.getOctokitForUser(ev.sender); if (octokit === null) { // TODO: Use Reply - Also mention user. - await this.as.botClient.sendNotice(this.roomId, `${ev.sender}: Cannot send comment, you are not logged into GitHub`); + await this.intent.underlyingClient.sendNotice(this.roomId, `${ev.sender}: Cannot send comment, you are not logged into GitHub`); return true; } const qlClient = new GithubGraphQLClient(octokit); const commentId = await qlClient.addDiscussionComment(this.state.internalId, ev.content.body); log.info(`Sent ${commentId} for ${ev.event_id} (${ev.sender})`); - this.sentEvents.add(commentId); + this.sentEvents.set(commentId, undefined); return true; } @@ -147,6 +166,7 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne return; } const intent = await getIntentForUser(data.comment.user, this.as, this.config.userIdPrefix); + await ensureUserIsInRoom(intent, this.intent.underlyingClient, this.roomId); await this.messageClient.sendMatrixMessage(this.roomId, { body: data.comment.body, formatted_body: md.render(data.comment.body), @@ -158,13 +178,18 @@ export class GitHubDiscussionConnection extends BaseConnection implements IConne public async onRemove() { log.info(`Removing ${this.toString()} for ${this.roomId}`); + await this.grantChecker.ungrantConnection(this.roomId, GitHubDiscussionConnection.grantKey(this.state)); // Do a sanity check that the event exists. try { - await this.as.botClient.getRoomStateEvent(this.roomId, GitHubDiscussionConnection.CanonicalEventType, this.stateKey); - await this.as.botClient.sendStateEvent(this.roomId, GitHubDiscussionConnection.CanonicalEventType, this.stateKey, { disabled: true }); + await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubDiscussionConnection.CanonicalEventType, this.stateKey); + await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubDiscussionConnection.CanonicalEventType, this.stateKey, { disabled: true }); } catch (ex) { - await this.as.botClient.getRoomStateEvent(this.roomId, GitHubDiscussionConnection.LegacyCanonicalEventType, this.stateKey); - await this.as.botClient.sendStateEvent(this.roomId, GitHubDiscussionConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true }); + await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubDiscussionConnection.LegacyCanonicalEventType, this.stateKey); + await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubDiscussionConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true }); } } -} \ No newline at end of file + + public async ensureGrant(sender?: string) { + await this.grantChecker.assertConnectionGranted(this.roomId, GitHubDiscussionConnection.grantKey(this.state), sender); + } +} diff --git a/src/Connections/GithubDiscussionSpace.ts b/src/Connections/GithubDiscussionSpace.ts index 30617ff8..4cc432d1 100644 --- a/src/Connections/GithubDiscussionSpace.ts +++ b/src/Connections/GithubDiscussionSpace.ts @@ -6,6 +6,8 @@ import axios from "axios"; import { GitHubDiscussionConnection } from "./GithubDiscussion"; import { GithubInstance } from "../Github/GithubInstance"; import { BaseConnection } from "./BaseConnection"; +import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck"; +import { BridgeConfig } from "../Config/Config"; const log = new Logger("GitHubDiscussionSpace"); @@ -31,16 +33,17 @@ export class GitHubDiscussionSpace extends BaseConnection implements IConnection static readonly ServiceCategory = "github"; public static async createConnectionForState(roomId: string, event: StateEvent, { - github, config, as}: InstantiateConnectionOpts) { + github, config, as, intent}: InstantiateConnectionOpts) { if (!github || !config.github) { throw Error('GitHub is not configured'); } + await new GrantChecker(as.botIntent, 'github').grantConnection(roomId, this.grantKey(event.content)); return new GitHubDiscussionSpace( - await as.botClient.getSpace(roomId), event.content, event.stateKey + as, config, await intent.underlyingClient.getSpace(roomId), event.content, event.stateKey ); } - static async onQueryRoom(result: RegExpExecArray, opts: {githubInstance: GithubInstance, as: Appservice}): Promise> { + public static async onQueryRoom(result: RegExpExecArray, opts: {githubInstance: GithubInstance, as: Appservice}): Promise> { if (!result || result.length < 2) { log.error(`Invalid alias pattern '${result}'`); throw Error("Could not find issue"); @@ -108,7 +111,7 @@ export class GitHubDiscussionSpace extends BaseConnection implements IConnection preset: 'public_chat', room_alias_name: `github_disc_${owner.toLowerCase()}_${repo.toLowerCase()}`, initial_state: [ - + { type: this.CanonicalEventType, content: state, @@ -141,10 +144,19 @@ export class GitHubDiscussionSpace extends BaseConnection implements IConnection }; } - constructor(public readonly space: Space, + private static grantKey(state: GitHubDiscussionSpaceConnectionState) { + return `${this.CanonicalEventType}/${state.owner}/${state.repo}`; + } + + private readonly grantChecker: GrantChecker; + + constructor(as: Appservice, + config: BridgeConfig, + public readonly space: Space, private state: GitHubDiscussionSpaceConnectionState, stateKey: string) { super(space.roomId, stateKey, GitHubDiscussionSpace.CanonicalEventType) + this.grantChecker = new ConfigGrantChecker("github", as, config); } public isInterestedInStateEvent(eventType: string, stateKey: string) { @@ -167,12 +179,18 @@ export class GitHubDiscussionSpace extends BaseConnection implements IConnection log.info(`Adding connection to ${this.toString()}`); await this.space.addChildRoom(discussion.roomId); } + + + public async ensureGrant(sender?: string) { + await this.grantChecker.assertConnectionGranted(this.roomId, GitHubDiscussionSpace.grantKey(this.state), sender); + } public async onRemove() { log.info(`Removing ${this.toString()} for ${this.roomId}`); + this.grantChecker.ungrantConnection(this.roomId, GitHubDiscussionSpace.grantKey(this.state)); // Do a sanity check that the event exists. try { - + await this.space.client.getRoomStateEvent(this.roomId, GitHubDiscussionSpace.CanonicalEventType, this.stateKey); await this.space.client.sendStateEvent(this.roomId, GitHubDiscussionSpace.CanonicalEventType, this.stateKey, { disabled: true }); } catch (ex) { @@ -180,4 +198,4 @@ export class GitHubDiscussionSpace extends BaseConnection implements IConnection await this.space.client.sendStateEvent(this.roomId, GitHubDiscussionSpace.LegacyCanonicalEventType, this.stateKey, { disabled: true }); } } -} \ No newline at end of file +} diff --git a/src/Connections/GithubIssue.ts b/src/Connections/GithubIssue.ts index 46cafe27..e4ba7f4e 100644 --- a/src/Connections/GithubIssue.ts +++ b/src/Connections/GithubIssue.ts @@ -1,12 +1,12 @@ import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection"; -import { Appservice, StateEvent } from "matrix-bot-sdk"; +import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent"; import markdown from "markdown-it"; import { UserTokenStore } from "../UserTokenStore"; import { Logger } from "matrix-appservice-bridge"; import { CommentProcessor } from "../CommentProcessor"; import { MessageSenderClient } from "../MatrixSender"; -import { getIntentForUser } from "../IntentUtils"; +import { ensureUserIsInRoom, getIntentForUser } from "../IntentUtils"; import { FormatUtil } from "../FormatUtil"; import axios from "axios"; import { GithubInstance } from "../Github/GithubInstance"; @@ -56,12 +56,12 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection } public static async createConnectionForState(roomId: string, event: StateEvent, { - github, config, as, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts) { + github, config, as, intent, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts) { if (!github || !config.github) { throw Error('GitHub is not configured'); } const issue = new GitHubIssueConnection( - roomId, as, event.content, event.stateKey || "", tokenStore, + roomId, as, intent, event.content, event.stateKey || "", tokenStore, commentProcessor, messageClient, github, config.github, ); await issue.syncIssueState(); @@ -154,17 +154,20 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection }; } - constructor(roomId: string, + constructor( + roomId: string, private readonly as: Appservice, + private readonly intent: Intent, private state: GitHubIssueConnectionState, stateKey: string, private tokenStore: UserTokenStore, private commentProcessor: CommentProcessor, private messageClient: MessageSenderClient, private github: GithubInstance, - private config: BridgeConfigGitHub,) { - super(roomId, stateKey, GitHubIssueConnection.CanonicalEventType); - } + private config: BridgeConfigGitHub, + ) { + super(roomId, stateKey, GitHubIssueConnection.CanonicalEventType); + } public isInterestedInStateEvent(eventType: string, stateKey: string) { return GitHubIssueConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; @@ -214,13 +217,14 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection const matrixEvent = await this.commentProcessor.getEventBodyForGitHubComment(comment, event.repository, event.issue); // Comment body may be blank if (matrixEvent) { + await ensureUserIsInRoom(commentIntent, this.intent.underlyingClient, this.roomId); await this.messageClient.sendMatrixMessage(this.roomId, matrixEvent, "m.room.message", commentIntent.userId); } if (!updateState) { return; } this.state.comments_processed++; - await this.as.botIntent.underlyingClient.sendStateEvent( + await this.intent.underlyingClient.sendStateEvent( this.roomId, GitHubIssueConnection.CanonicalEventType, this.stateKey, @@ -245,6 +249,7 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection }, this.as, this.config.userIdPrefix); // We've not sent any messages into the room yet, let's do it! if (issue.data.body) { + await ensureUserIsInRoom(creator, this.intent.underlyingClient, this.roomId); await this.messageClient.sendMatrixMessage(this.roomId, { msgtype: "m.text", external_url: issue.data.html_url, @@ -282,6 +287,11 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection if (issue.data.state === "closed") { // TODO: Fix const closedUserId = this.as.getUserIdForSuffix(issue.data.closed_by?.login as string); + await ensureUserIsInRoom( + this.as.getIntentForUserId(closedUserId), + this.intent.underlyingClient, + this.roomId + ); await this.messageClient.sendMatrixMessage(this.roomId, { msgtype: "m.notice", body: `closed the ${issue.data.pull_request ? "pull request" : "issue"} at ${issue.data.closed_at}`, @@ -289,14 +299,14 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection }, "m.room.message", closedUserId); } - await this.as.botIntent.underlyingClient.sendStateEvent(this.roomId, "m.room.topic", "", { + await this.intent.underlyingClient.sendStateEvent(this.roomId, "m.room.topic", "", { topic: FormatUtil.formatRoomTopic(issue.data), }); this.state.state = issue.data.state; } - await this.as.botIntent.underlyingClient.sendStateEvent( + await this.intent.underlyingClient.sendStateEvent( this.roomId, GitHubIssueConnection.CanonicalEventType, this.stateKey, @@ -308,7 +318,7 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection public async onMatrixIssueComment(event: MatrixEvent, allowEcho = false) { const clientKit = await this.tokenStore.getOctokitForUser(event.sender); if (clientKit === null) { - await this.as.botClient.sendEvent(this.roomId, "m.reaction", { + await this.intent.underlyingClient.sendEvent(this.roomId, "m.reaction", { "m.relates_to": { rel_type: "m.annotation", event_id: event.event_id, @@ -339,7 +349,7 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection // TODO: Fix types if (event.issue && event.changes.title) { - await this.as.botIntent.underlyingClient.sendStateEvent(this.roomId, "m.room.name", "", { + await this.intent.underlyingClient.sendStateEvent(this.roomId, "m.room.name", "", { name: FormatUtil.formatIssueRoomName(event.issue, event.repository), }); } @@ -349,11 +359,11 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection log.info(`Removing ${this.toString()} for ${this.roomId}`); // Do a sanity check that the event exists. try { - await this.as.botClient.getRoomStateEvent(this.roomId, GitHubIssueConnection.CanonicalEventType, this.stateKey); - await this.as.botClient.sendStateEvent(this.roomId, GitHubIssueConnection.CanonicalEventType, this.stateKey, { disabled: true }); + await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubIssueConnection.CanonicalEventType, this.stateKey); + await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubIssueConnection.CanonicalEventType, this.stateKey, { disabled: true }); } catch (ex) { - await this.as.botClient.getRoomStateEvent(this.roomId, GitHubIssueConnection.LegacyCanonicalEventType, this.stateKey); - await this.as.botClient.sendStateEvent(this.roomId, GitHubIssueConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true }); + await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubIssueConnection.LegacyCanonicalEventType, this.stateKey); + await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubIssueConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true }); } } @@ -374,4 +384,4 @@ export class GitHubIssueConnection extends BaseConnection implements IConnection public toString() { return `GitHubIssue ${this.state.org}/${this.state.repo}#${this.state.issues.join(",")}`; } -} \ No newline at end of file +} diff --git a/src/Connections/GithubProject.ts b/src/Connections/GithubProject.ts index 92bee4af..690cc70f 100644 --- a/src/Connections/GithubProject.ts +++ b/src/Connections/GithubProject.ts @@ -1,8 +1,10 @@ import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection"; -import { Appservice, StateEvent } from "matrix-bot-sdk"; +import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { Logger } from "matrix-appservice-bridge"; import { ProjectsGetResponseData } from "../Github/Types"; import { BaseConnection } from "./BaseConnection"; +import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck"; +import { BridgeConfig } from "../Config/Config"; export interface GitHubProjectConnectionState { // eslint-disable-next-line camelcase @@ -24,14 +26,18 @@ export class GitHubProjectConnection extends BaseConnection implements IConnecti GitHubProjectConnection.LegacyCanonicalEventType, ]; - public static createConnectionForState(roomId: string, event: StateEvent, {config, as}: InstantiateConnectionOpts) { + public static createConnectionForState(roomId: string, event: StateEvent, {config, as, intent}: InstantiateConnectionOpts) { if (!config.github) { throw Error('GitHub is not configured'); } - return new GitHubProjectConnection(roomId, as, event.content, event.stateKey); + return new GitHubProjectConnection(roomId, as, intent, config, event.content, event.stateKey); } - static async onOpenProject(project: ProjectsGetResponseData, as: Appservice, inviteUser: string): Promise { + public static getGrantKey(projectId: number) { + return `${this.CanonicalEventType}/${projectId}`; + } + + static async onOpenProject(project: ProjectsGetResponseData, as: Appservice, intent: Intent, config: BridgeConfig, inviteUser: string): Promise { log.info(`Fetching ${project.name} ${project.id}`); // URL hack so we don't need to fetch the repo itself. @@ -41,7 +47,7 @@ export class GitHubProjectConnection extends BaseConnection implements IConnecti state: project.state as "open"|"closed", }; - const roomId = await as.botClient.createRoom({ + const roomId = await intent.underlyingClient.createRoom({ visibility: "private", name: `${project.name}`, topic: project.body || undefined, @@ -55,20 +61,32 @@ export class GitHubProjectConnection extends BaseConnection implements IConnecti }, ], }); - - return new GitHubProjectConnection(roomId, as, state, project.url) + await new GrantChecker(as.botIntent, 'github').grantConnection(roomId, this.getGrantKey(project.id)); + + return new GitHubProjectConnection(roomId, as, intent, config, state, project.url) } get projectId() { return this.state.project_id; } - constructor(public readonly roomId: string, + private readonly grantChecker: GrantChecker; + + constructor( + public readonly roomId: string, as: Appservice, + intent: Intent, + config: BridgeConfig, private state: GitHubProjectConnectionState, - stateKey: string) { - super(roomId, stateKey, GitHubProjectConnection.CanonicalEventType); - } + stateKey: string, + ) { + super(roomId, stateKey, GitHubProjectConnection.CanonicalEventType); + this.grantChecker = new ConfigGrantChecker("github", as, config); + } + + public ensureGrant(sender?: string) { + return this.grantChecker.assertConnectionGranted(this.roomId, GitHubProjectConnection.getGrantKey(this.state.project_id), sender); + } public isInterestedInStateEvent(eventType: string, stateKey: string) { return GitHubProjectConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; @@ -77,4 +95,4 @@ export class GitHubProjectConnection extends BaseConnection implements IConnecti public toString() { return `GitHubProjectConnection ${this.state.project_id}}`; } -} \ No newline at end of file +} diff --git a/src/Connections/GithubRepo.ts b/src/Connections/GithubRepo.ts index d161a4a4..f36707e4 100644 --- a/src/Connections/GithubRepo.ts +++ b/src/Connections/GithubRepo.ts @@ -1,8 +1,9 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ -import { Appservice, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk"; +import { Appservice, Intent, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk"; import { BotCommands, botCommand, compileBotCommands, HelpFunction } from "../BotCommands"; import { CommentProcessor } from "../CommentProcessor"; import { FormatUtil } from "../FormatUtil"; +import { Octokit } from "@octokit/rest"; import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection"; import { GetConnectionsResponseItem } from "../provisioning/api"; import { IssuesOpenedEvent, IssuesReopenedEvent, IssuesEditedEvent, PullRequestOpenedEvent, IssuesClosedEvent, PullRequestClosedEvent, @@ -27,6 +28,8 @@ import { PermissionCheckFn } from "."; import { MinimalGitHubIssue, MinimalGitHubRepo } from "../libRs"; import Ajv, { JSONSchemaType } from "ajv"; import { HookFilter } from "../HookFilter"; +import { GrantChecker } from "../grants/GrantCheck"; +import { GitHubGrantChecker } from "../Github/GrantChecker"; const log = new Logger("GitHubRepoConnection"); const md = new markdown(); @@ -40,8 +43,12 @@ interface IQueryRoomOpts { } export interface GitHubRepoConnectionOptions extends IConnectionState { - enableHooks?: AllowedEventsNames[], + /** + * Do not use. Use `enableHooks`. + * @deprecated + */ ignoreHooks?: AllowedEventsNames[], + enableHooks?: AllowedEventsNames[], showIssueRoomLink?: boolean; prDiff?: { enabled: boolean; @@ -61,11 +68,17 @@ export interface GitHubRepoConnectionOptions extends IConnectionState { excludingWorkflows?: string[]; } } + export interface GitHubRepoConnectionState extends GitHubRepoConnectionOptions { org: string; repo: string; } +interface ConnectionValidatedState extends GitHubRepoConnectionState { + ignoreHooks: undefined, + enableHooks: AllowedEventsNames[], +} + export interface GitHubRepoConnectionOrgTarget { name: string; @@ -73,6 +86,8 @@ export interface GitHubRepoConnectionOrgTarget { export interface GitHubRepoConnectionRepoTarget { state: GitHubRepoConnectionState; name: string; + description?: string; + avatar?: string; } export type GitHubRepoConnectionTarget = GitHubRepoConnectionOrgTarget|GitHubRepoConnectionRepoTarget; @@ -81,12 +96,12 @@ export type GitHubRepoConnectionTarget = GitHubRepoConnectionOrgTarget|GitHubRep export type GitHubRepoResponseItem = GetConnectionsResponseItem; -type AllowedEventsNames = +export type AllowedEventsNames = "issue.changed" | "issue.created" | "issue.edited" | "issue.labeled" | - "issue" | + "issue" | "pull_request.closed" | "pull_request.merged" | "pull_request.opened" | @@ -97,7 +112,7 @@ type AllowedEventsNames = "release.drafted" | "release" | "workflow" | - "workflow.run" | + "workflow.run" | "workflow.run.success" | "workflow.run.failure" | "workflow.run.neutral" | @@ -106,7 +121,7 @@ type AllowedEventsNames = "workflow.run.action_required" | "workflow.run.stale"; -const AllowedEvents: AllowedEventsNames[] = [ +export const AllowedEvents: AllowedEventsNames[] = [ "issue.changed" , "issue.created" , "issue.edited" , @@ -136,8 +151,17 @@ const AllowedEvents: AllowedEventsNames[] = [ * These hooks are enabled by default, unless they are * specifed in the ignoreHooks option. */ -const AllowHookByDefault: AllowedEventsNames[] = [ +const DefaultHooks: AllowedEventsNames[] = [ + "issue.changed", + "issue.created", + "issue.edited", + "issue.labeled", "issue", + "pull_request.closed", + "pull_request.merged", + "pull_request.opened", + "pull_request.ready_for_review", + "pull_request.reviewed", "pull_request", "release.created" ]; @@ -151,6 +175,10 @@ const ConnectionStateSchema = { }, org: {type: "string"}, repo: {type: "string"}, + /** + * Legacy state. + * @deprecated + */ ignoreHooks: { type: "array", items: { @@ -171,7 +199,7 @@ const ConnectionStateSchema = { nullable: true, maxLength: 24, }, - showIssueRoomLink: { + showIssueRoomLink: { type: "boolean", nullable: true, }, @@ -249,7 +277,7 @@ const ConnectionStateSchema = { additionalProperties: true } as JSONSchemaType; -type ReactionOptions = +type ReactionOptions = | "+1" | "-1" | "laugh" @@ -295,43 +323,45 @@ const WORKFLOW_CONCLUSION_TO_NOTICE: Record implements IConnection { - - static validateState(state: unknown, isExistingState = false): GitHubRepoConnectionState { +export class GitHubRepoConnection extends CommandConnection implements IConnection { + static validateState(state: unknown, isExistingState = false): ConnectionValidatedState { const validator = new Ajv({ allowUnionTypes: true }).compile(ConnectionStateSchema); if (validator(state)) { - // Validate ignoreHooks IF this is an incoming update (we can be less strict for existing state) - if (!isExistingState && state.ignoreHooks && !state.ignoreHooks.every(h => AllowedEvents.includes(h))) { - throw new ApiError('`ignoreHooks` must only contain allowed values', ErrCode.BadValue); - } if (!isExistingState && state.enableHooks && !state.enableHooks.every(h => AllowedEvents.includes(h))) { throw new ApiError('`enableHooks` must only contain allowed values', ErrCode.BadValue); } - return state; + if (state.ignoreHooks) { + if (!isExistingState) { + throw new ApiError('`ignoreHooks` cannot be used with new connections', ErrCode.BadValue); + } + log.warn(`Room has old state key 'ignoreHooks'. Converting to compatible enabledHooks filter`); + state.enableHooks = HookFilter.convertIgnoredHooksToEnabledHooks(state.enableHooks, state.ignoreHooks, DefaultHooks); + } + return { + ...state, + ignoreHooks: undefined, + enableHooks: state.enableHooks ?? [...DefaultHooks] + }; } throw new ValidatorApiError(validator.errors); } - static async provisionConnection(roomId: string, userId: string, data: Record, {as, tokenStore, github, config}: ProvisionConnectionOpts) { - if (!github || !config.github) { - throw Error('GitHub is not configured'); - } - const validData = this.validateState(data); + static async assertUserHasAccessToRepo(userId: string, org: string, repo: string, github: GithubInstance, tokenStore: UserTokenStore) { const octokit = await tokenStore.getOctokitForUser(userId); if (!octokit) { throw new ApiError("User is not authenticated with GitHub", ErrCode.ForbiddenUser); @@ -339,8 +369,8 @@ export class GitHubRepoConnection extends CommandConnection, {as, intent, tokenStore, github, config}: ProvisionConnectionOpts) { + if (!github || !config.github) { + throw Error('GitHub is not configured'); + } + const validData = this.validateState(data); + await this.assertUserHasAccessToRepo(userId, validData.org, validData.repo, github, tokenStore); const appOctokit = await github.getSafeOctokitForRepo(validData.org, validData.repo); if (!appOctokit) { throw new ApiError( @@ -361,10 +399,11 @@ export class GitHubRepoConnection extends CommandConnection>, {as, tokenStore, github, config}: InstantiateConnectionOpts) { + static async createConnectionForState(roomId: string, state: StateEvent>, {as, intent, tokenStore, github, config}: InstantiateConnectionOpts) { if (!github || !config.github) { throw Error('GitHub is not configured'); } - return new GitHubRepoConnection(roomId, as, this.validateState(state.content, true), tokenStore, state.stateKey, github, config.github); + + const connectionState = this.validateState(state.content, true); + + return new GitHubRepoConnection(roomId, as, intent, connectionState, tokenStore, state.stateKey, github, config.github); } static async onQueryRoom(result: RegExpExecArray, opts: IQueryRoomOpts): Promise { @@ -469,29 +511,32 @@ export class GitHubRepoConnection extends CommandConnection, timeout: NodeJS.Timeout}>(); - constructor(roomId: string, + private readonly grantChecker = new GitHubGrantChecker(this.as, this.githubInstance, this.tokenStore); + + constructor( + roomId: string, private readonly as: Appservice, - state: GitHubRepoConnectionState, + private readonly intent: Intent, + state: ConnectionValidatedState, private readonly tokenStore: UserTokenStore, stateKey: string, private readonly githubInstance: GithubInstance, private readonly config: BridgeConfigGitHub, - ) { - super( - roomId, - stateKey, - GitHubRepoConnection.CanonicalEventType, - state, - as.botClient, - GitHubRepoConnection.botCommands, - GitHubRepoConnection.helpMessage, - "!gh", - "github", - ); + ) { + super( + roomId, + stateKey, + GitHubRepoConnection.CanonicalEventType, + state, + intent.underlyingClient, + GitHubRepoConnection.botCommands, + GitHubRepoConnection.helpMessage, + ["github"], + "!gh", + "github", + ); this.hookFilter = new HookFilter( - AllowHookByDefault, state.enableHooks, - state.ignoreHooks, ) } @@ -524,14 +569,20 @@ export class GitHubRepoConnection extends CommandConnection) { await super.onStateUpdate(stateEv); - this.hookFilter.enabledHooks = this.state.enableHooks ?? []; - this.hookFilter.ignoredHooks = this.state.ignoreHooks ?? []; + this.hookFilter.enabledHooks = this.state.enableHooks; } public isInterestedInStateEvent(eventType: string, stateKey: string) { @@ -581,7 +632,7 @@ export class GitHubRepoConnection extends CommandConnection w.name.toLowerCase().trim() === name.toLowerCase().trim()); if (!workflow) { const workflowNames = workflows.data.workflows.map(w => w.name).join(', '); - await this.as.botIntent.sendText(this.roomId, `Could not find a workflow by the name of "${name}". The workflows on this repository are ${workflowNames}.`, "m.notice"); + await this.intent.sendText(this.roomId, `Could not find a workflow by the name of "${name}". The workflows on this repository are ${workflowNames}.`, "m.notice"); return; } try { @@ -780,7 +831,7 @@ export class GitHubRepoConnection extends CommandConnection ({ name: l.name, description: l.description || undefined, color: l.color || undefined }))); - await this.as.botIntent.sendEvent(this.roomId, { + const labels = FormatUtil.formatLabels(event.issue.labels?.map(l => ({ name: l.name, description: l.description || undefined, color: l.color || undefined }))); + await this.intent.sendEvent(this.roomId, { msgtype: "m.notice", body: content + (labels.plain.length > 0 ? ` with labels ${labels.plain}`: ""), formatted_body: md.renderInline(content) + (labels.html.length > 0 ? ` with labels ${labels.html}`: ""), @@ -854,7 +905,7 @@ export class GitHubRepoConnection extends CommandConnection { const {labels} = this.debounceOnIssueLabeled.get(event.issue.id) || { labels: [] }; @@ -902,9 +953,9 @@ export class GitHubRepoConnection extends CommandConnection ({ name: l.name, description: l.description || undefined, color: l.color || undefined }))); + const {plain, html} = FormatUtil.formatLabels(event.issue.labels?.map(l => ({ name: l.name, description: l.description || undefined, color: l.color || undefined }))); const content = `**${event.sender.login}** labeled issue [${orgRepoName}#${event.issue.number}](${event.issue.html_url}): "${emoji.emojify(event.issue.title)}"`; - this.as.botIntent.sendEvent(this.roomId, { + this.intent.sendEvent(this.roomId, { msgtype: "m.notice", body: content + (plain.length > 0 ? ` with labels ${plain}`: ""), formatted_body: md.renderInline(content) + (html.length > 0 ? ` with labels ${html}`: ""), @@ -961,8 +1012,8 @@ export class GitHubRepoConnection extends CommandConnection ({ name: l.name, description: l.description || undefined, color: l.color || undefined }))); - await this.as.botIntent.sendEvent(this.roomId, { + const labels = FormatUtil.formatLabels(event.pull_request.labels?.map(l => ({ name: l.name, description: l.description || undefined, color: l.color || undefined }))); + await this.intent.sendEvent(this.roomId, { msgtype: "m.notice", body: content + (labels.plain.length > 0 ? ` with labels ${labels}`: "") + diffContent, formatted_body: md.renderInline(content) + (labels.html.length > 0 ? ` with labels ${labels.html}`: "") + diffContentHtml, @@ -985,7 +1036,7 @@ export class GitHubRepoConnection extends CommandConnection r.full_name); + } + + // Now, find all the repos that we have the ability to install. + const foundRepos = []; + let installationsCount = 0; + let totalCount = 0; + let page = 1; + do { + const { data } = await octokit.apps.listInstallationReposForAuthenticatedUser({ + installation_id: installationId, + page, + per_page: 100, + }); + // No results, so stop trying. + if (data.repositories.length === 0) { + break; + } + page++; + installationsCount += data.repositories.length; + totalCount = data.total_count; + // Find any repos that were in our search results. If a search term isn't defined, just return it. + foundRepos.push(...data.repositories.filter((installRepo) => searchRepos?.includes(installRepo.full_name) ?? true)); + } while ( + installationsCount < totalCount && + foundRepos.length < (searchRepos?.length ?? MAX_RETURNED_TARGETS) + ) + return foundRepos; + } public static async getConnectionTargets(userId: string, tokenStore: UserTokenStore, githubInstance: GithubInstance, filters: GitHubTargetFilter = {}): Promise { // Search for all repos under the user's control. @@ -1262,37 +1355,25 @@ export class GitHubRepoConnection extends CommandConnection ({ state: { org: filters.orgName, repo: r.name, }, name: r.name, + description: r.description, + avatar: r.owner?.avatar_url || r.organization?.avatar_url, })) as GitHubRepoConnectionRepoTarget[]; } catch (ex) { log.warn(`Failed to fetch accessible repos for ${filters.orgName} / ${userId}`, ex); @@ -1304,21 +1385,21 @@ export class GitHubRepoConnection extends CommandConnection, { - github, config, as}: InstantiateConnectionOpts) { + github, config, intent, as}: InstantiateConnectionOpts) { if (!github || !config.github) { throw Error('GitHub is not configured'); } return new GitHubUserSpace( - await as.botClient.getSpace(roomId), event.content, event.stateKey + as, config, await intent.underlyingClient.getSpace(roomId), event.content, event.stateKey ); } @@ -104,7 +110,7 @@ export class GitHubUserSpace extends BaseConnection implements IConnection { preset: 'public_chat', room_alias_name: `github_${state.username.toLowerCase()}`, initial_state: [ - + { type: this.CanonicalEventType, content: state, @@ -137,12 +143,21 @@ export class GitHubUserSpace extends BaseConnection implements IConnection { }; } - constructor(public readonly space: Space, + private readonly grantChecker: GrantChecker; + + constructor(as: Appservice, + config: BridgeConfig, + public readonly space: Space, private state: GitHubUserSpaceConnectionState, stateKey: string) { super(space.roomId, stateKey, GitHubUserSpace.CanonicalEventType); + this.grantChecker = new ConfigGrantChecker("github", as, config); } + public ensureGrant(sender?: string) { + return this.grantChecker.assertConnectionGranted(this.roomId, GitHubUserSpace.grantKey(this.state), sender); + } + public isInterestedInStateEvent(eventType: string, stateKey: string) { return GitHubUserSpace.EventTypes.includes(eventType) && this.stateKey === stateKey; } @@ -167,4 +182,4 @@ export class GitHubUserSpace extends BaseConnection implements IConnection { await this.space.addChildRoom(discussion.roomId); } } -} \ No newline at end of file +} diff --git a/src/Connections/GitlabIssue.ts b/src/Connections/GitlabIssue.ts index f8623493..742a6df8 100644 --- a/src/Connections/GitlabIssue.ts +++ b/src/Connections/GitlabIssue.ts @@ -1,15 +1,16 @@ import { Connection, IConnection, InstantiateConnectionOpts } from "./IConnection"; -import { Appservice, StateEvent } from "matrix-bot-sdk"; +import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent"; import { UserTokenStore } from "../UserTokenStore"; import { Logger } from "matrix-appservice-bridge"; import { CommentProcessor } from "../CommentProcessor"; import { MessageSenderClient } from "../MatrixSender"; -import { BridgeConfigGitLab, GitLabInstance } from "../Config/Config"; +import { BridgeConfig, BridgeConfigGitLab, GitLabInstance } from "../Config/Config"; import { GetIssueResponse } from "../Gitlab/Types"; import { IGitLabWebhookNoteEvent } from "../Gitlab/WebhookTypes"; -import { getIntentForUser } from "../IntentUtils"; +import { ensureUserIsInRoom, getIntentForUser } from "../IntentUtils"; import { BaseConnection } from "./BaseConnection"; +import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck"; export interface GitLabIssueConnectionState { instance: string; @@ -48,8 +49,11 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection return `Author: ${authorName} | State: ${state === "closed" ? "closed" : "open"}` } - public static async createConnectionForState(roomId: string, event: StateEvent, { - config, as, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts) { + public static async createConnectionForState( + roomId: string, + event: StateEvent, + { config, as, intent, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts, + ) { if (!config.gitlab) { throw Error('GitHub is not configured'); } @@ -58,15 +62,31 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection throw Error('Instance name not recognised'); } return new GitLabIssueConnection( - roomId, as, event.content, event.stateKey || "", tokenStore, - commentProcessor, messageClient, instance, config.gitlab, + roomId, + as, + intent, + event.content, + event.stateKey || "", + tokenStore, + commentProcessor, + messageClient, + instance, + config, ); } - public static async createRoomForIssue(instanceName: string, instance: GitLabInstance, - issue: GetIssueResponse, projects: string[], as: Appservice, - tokenStore: UserTokenStore, commentProcessor: CommentProcessor, - messageSender: MessageSenderClient, config: BridgeConfigGitLab) { + public static async createRoomForIssue( + instanceName: string, + instance: GitLabInstance, + issue: GetIssueResponse, + projects: string[], + as: Appservice, + intent: Intent, + tokenStore: UserTokenStore, + commentProcessor: CommentProcessor, + messageSender: MessageSenderClient, + config: BridgeConfig, + ) { const state: GitLabIssueConnectionState = { projects, state: issue.state, @@ -76,7 +96,7 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection authorName: issue.author.name, }; - const roomId = await as.botClient.createRoom({ + const roomId = await intent.underlyingClient.createRoom({ visibility: "private", name: `${issue.references.full}`, topic: GitLabIssueConnection.getTopicString(issue.author.name, issue.state), @@ -90,8 +110,13 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection }, ], }); + await new GrantChecker(as.botIntent, "gitlab").grantConnection(roomId, { + instance: state.instance, + project: state.projects[0].toString(), + issue: state.iid.toString(), + }); - return new GitLabIssueConnection(roomId, as, state, issue.web_url, tokenStore, commentProcessor, messageSender, instance, config); + return new GitLabIssueConnection(roomId, as, intent, state, issue.web_url, tokenStore, commentProcessor, messageSender, instance, config); } public get projectPath() { @@ -102,18 +127,37 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection return this.instance.url; } - constructor(roomId: string, + private readonly grantChecker: GrantChecker<{instance: string, project: string, issue: string}>; + private readonly config: BridgeConfigGitLab; + + constructor( + roomId: string, private readonly as: Appservice, + private readonly intent: Intent, private state: GitLabIssueConnectionState, stateKey: string, private tokenStore: UserTokenStore, private commentProcessor: CommentProcessor, private messageClient: MessageSenderClient, private instance: GitLabInstance, - private config: BridgeConfigGitLab) { - super(roomId, stateKey, GitLabIssueConnection.CanonicalEventType); + config: BridgeConfig, + ) { + super(roomId, stateKey, GitLabIssueConnection.CanonicalEventType); + this.grantChecker = new ConfigGrantChecker("gitlab", as, config); + if (!config.gitlab) { + throw Error('No gitlab config!'); } - + this.config = config.gitlab; + } + + public ensureGrant(sender?: string) { + return this.grantChecker.assertConnectionGranted(this.roomId, { + instance: this.state.instance, + project: this.state.projects[0], + issue: this.state.iid.toString(), + }, sender); + } + public isInterestedInStateEvent(eventType: string, stateKey: string) { return GitLabIssueConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; } @@ -140,14 +184,19 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection avatarUrl: event.user.avatar_url, }, this.as, this.config.userIdPrefix); const matrixEvent = await this.commentProcessor.getEventBodyForGitLabNote(event); - + // Make sure ghost user is invited to the room + await ensureUserIsInRoom( + commentIntent, + this.intent.underlyingClient, + this.roomId + ); await this.messageClient.sendMatrixMessage(this.roomId, matrixEvent, "m.room.message", commentIntent.userId); } public async onMatrixIssueComment(event: MatrixEvent, allowEcho = false) { const clientKit = await this.tokenStore.getGitLabForUser(event.sender, this.instanceUrl); if (clientKit === null) { - await this.as.botClient.sendEvent(this.roomId, "m.reaction", { + await this.intent.underlyingClient.sendEvent(this.roomId, "m.reaction", { "m.relates_to": { rel_type: "m.annotation", event_id: event.event_id, @@ -178,8 +227,8 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection public async onIssueReopened() { // TODO: We don't store the author data. this.state.state = "reopened"; - await this.as.botClient.sendStateEvent(this.roomId, GitLabIssueConnection.CanonicalEventType, this.stateKey, this.state); - return this.as.botClient.sendStateEvent(this.roomId, "m.room.topic", "", { + await this.intent.underlyingClient.sendStateEvent(this.roomId, GitLabIssueConnection.CanonicalEventType, this.stateKey, this.state); + return this.intent.underlyingClient.sendStateEvent(this.roomId, "m.room.topic", "", { topic: GitLabIssueConnection.getTopicString(this.state.authorName, this.state.state), }); } @@ -187,8 +236,8 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection public async onIssueClosed() { // TODO: We don't store the author data. this.state.state = "closed"; - await this.as.botClient.sendStateEvent(this.roomId, GitLabIssueConnection.CanonicalEventType, this.stateKey , this.state); - return this.as.botClient.sendStateEvent(this.roomId, "m.room.topic", "", { + await this.intent.underlyingClient.sendStateEvent(this.roomId, GitLabIssueConnection.CanonicalEventType, this.stateKey , this.state); + return this.intent.underlyingClient.sendStateEvent(this.roomId, "m.room.topic", "", { topic: GitLabIssueConnection.getTopicString(this.state.authorName, this.state.state), }); } @@ -206,4 +255,4 @@ export class GitLabIssueConnection extends BaseConnection implements IConnection public toString() { return `GitLabIssue ${this.instanceUrl}/${this.projectPath}#${this.issueNumber}`; } -} \ No newline at end of file +} diff --git a/src/Connections/GitlabRepo.ts b/src/Connections/GitlabRepo.ts index 09111cd7..9f5c0078 100644 --- a/src/Connections/GitlabRepo.ts +++ b/src/Connections/GitlabRepo.ts @@ -1,7 +1,7 @@ // We need to instantiate some functions which are not directly called, which confuses typescript. /* eslint-disable @typescript-eslint/ban-ts-comment */ import { UserTokenStore } from "../UserTokenStore"; -import { Appservice, StateEvent } from "matrix-bot-sdk"; +import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { BotCommands, botCommand, compileBotCommands } from "../BotCommands"; import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent"; import markdown from "markdown-it"; @@ -17,10 +17,19 @@ import Ajv, { JSONSchemaType } from "ajv"; import { CommandError } from "../errors"; import QuickLRU from "@alloc/quick-lru"; import { HookFilter } from "../HookFilter"; +import { GitLabClient } from "../Gitlab/Client"; +import { IBridgeStorageProvider } from "../Stores/StorageProvider"; +import axios from "axios"; +import { GitLabGrantChecker } from "../Gitlab/GrantChecker"; export interface GitLabRepoConnectionState extends IConnectionState { instance: string; path: string; + enableHooks?: AllowedEventsNames[], + /** + * Do not use. Use `enableHooks` + * @deprecated + */ ignoreHooks?: AllowedEventsNames[], includeCommentBody?: boolean; pushTagsRegex?: string, @@ -28,6 +37,11 @@ export interface GitLabRepoConnectionState extends IConnectionState { excludingLabels?: string[]; } +interface ConnectionStateValidated extends GitLabRepoConnectionState { + ignoreHooks: undefined, + enableHooks: AllowedEventsNames[], +} + export interface GitLabRepoConnectionInstanceTarget { name: string; @@ -35,6 +49,8 @@ export interface GitLabRepoConnectionInstanceTarget { export interface GitLabRepoConnectionProjectTarget { state: GitLabRepoConnectionState; name: string; + avatar_url?: string; + description?: string; } export type GitLabRepoConnectionTarget = GitLabRepoConnectionInstanceTarget|GitLabRepoConnectionProjectTarget; @@ -49,7 +65,7 @@ const MRRCOMMENT_DEBOUNCE_MS = 5000; export type GitLabRepoResponseItem = GetConnectionsResponseItem; -type AllowedEventsNames = +type AllowedEventsNames = "merge_request.open" | "merge_request.close" | "merge_request.merge" | @@ -58,7 +74,7 @@ type AllowedEventsNames = "merge_request.review.comments" | `merge_request.${string}` | "merge_request" | - "tag_push" | + "tag_push" | "push" | "wiki" | `wiki.${string}` | @@ -80,6 +96,8 @@ const AllowedEvents: AllowedEventsNames[] = [ "release.created", ]; +const DefaultHooks = AllowedEvents; + const ConnectionStateSchema = { type: "object", properties: { @@ -89,6 +107,10 @@ const ConnectionStateSchema = { }, instance: { type: "string" }, path: { type: "string" }, + /** + * Do not use. Use `enableHooks` + * @deprecated + */ ignoreHooks: { type: "array", items: { @@ -96,6 +118,13 @@ const ConnectionStateSchema = { }, nullable: true, }, + enableHooks: { + type: "array", + items: { + type: "string", + }, + nullable: true, + }, commandPrefix: { type: "string", minLength: 2, @@ -131,7 +160,6 @@ const ConnectionStateSchema = { export interface GitLabTargetFilter { instance?: string; parent?: string; - after?: string; search?: string; } @@ -139,7 +167,7 @@ export interface GitLabTargetFilter { * Handles rooms connected to a GitLab repo. */ @Connection -export class GitLabRepoConnection extends CommandConnection implements IConnection { +export class GitLabRepoConnection extends CommandConnection implements IConnection { static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.gitlab.repository"; static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.gitlab.repository"; @@ -147,24 +175,35 @@ export class GitLabRepoConnection extends CommandConnection MatrixMessageContent; static ServiceCategory = "gitlab"; - static validateState(state: unknown, isExistingState = false): GitLabRepoConnectionState { + static validateState(state: unknown, isExistingState = false): ConnectionStateValidated { const validator = new Ajv({ strict: false }).compile(ConnectionStateSchema); if (validator(state)) { - // Validate ignoreHooks IF this is an incoming update (we can be less strict for existing state) - if (!isExistingState && state.ignoreHooks && !state.ignoreHooks.every(h => AllowedEvents.includes(h))) { - throw new ApiError('`ignoreHooks` must only contain allowed values', ErrCode.BadValue); + // Validate enableHooks IF this is an incoming update (we can be less strict for existing state) + if (!isExistingState && state.enableHooks && !state.enableHooks.every(h => AllowedEvents.includes(h))) { + throw new ApiError('`enableHooks` must only contain allowed values', ErrCode.BadValue); } - return state; + if (state.ignoreHooks) { + if (!isExistingState) { + throw new ApiError('`ignoreHooks` cannot be used with new connections', ErrCode.BadValue); + } + log.warn(`Room has old state key 'ignoreHooks'. Converting to compatible enabledHooks filter`); + state.enableHooks = HookFilter.convertIgnoredHooksToEnabledHooks(state.enableHooks, state.ignoreHooks, AllowedEvents); + } + return { + ...state, + enableHooks: state.enableHooks ?? AllowedEvents, + ignoreHooks: undefined, + }; } throw new ValidatorApiError(validator.errors); } - static async createConnectionForState(roomId: string, event: StateEvent>, {as, tokenStore, config}: InstantiateConnectionOpts) { + static async createConnectionForState(roomId: string, event: StateEvent>, {as, intent, tokenStore, config}: InstantiateConnectionOpts) { if (!config.gitlab) { throw Error('GitLab is not configured'); } @@ -173,18 +212,16 @@ export class GitLabRepoConnection extends CommandConnection, { config, as, tokenStore, getAllConnectionsOfType }: ProvisionConnectionOpts) { - if (!config.gitlab) { - throw Error('GitLab is not configured'); - } - const gitlabConfig = config.gitlab; - const validData = this.validateState(data); - const instance = gitlabConfig.instances[validData.instance]; + public static async assertUserHasAccessToProject( + instanceName: string, path: string, requester: string, + tokenStore: UserTokenStore, config: BridgeConfigGitLab + ) { + const instance = config.instances[instanceName]; if (!instance) { - throw Error(`provisionConnection provided an instanceName of ${validData.instance} but the instance does not exist`); + throw Error(`provisionConnection provided an instanceName of ${instanceName} but the instance does not exist`); } const client = await tokenStore.getGitLabForUser(requester, instance.url); if (!client) { @@ -192,7 +229,7 @@ export class GitLabRepoConnection extends CommandConnection, { as, config, intent, tokenStore, getAllConnectionsOfType }: ProvisionConnectionOpts) { + if (!config.gitlab) { + throw Error('GitLab is not configured'); + } + const validData = this.validateState(data); + const gitlabConfig = config.gitlab; + const instance = gitlabConfig.instances[validData.instance]; + if (!instance) { + throw Error(`provisionConnection provided an instanceName of ${validData.instance} but the instance does not exist`); + } + const permissionLevel = await this.assertUserHasAccessToProject(validData.instance, validData.path, requester, tokenStore, gitlabConfig); + const client = await tokenStore.getGitLabForUser(requester, instance.url); + if (!client) { + throw new ApiError("User is not authenticated with GitLab", ErrCode.ForbiddenUser); + } + + const project = await client.projects.get(validData.path); const stateEventKey = `${validData.instance}/${validData.path}`; - const connection = new GitLabRepoConnection(roomId, stateEventKey, as, validData, tokenStore, instance); + const connection = new GitLabRepoConnection(roomId, stateEventKey, as, gitlabConfig, intent, validData, tokenStore, instance); const existingConnections = getAllConnectionsOfType(GitLabRepoConnection); const existing = existingConnections.find(c => c.roomId === roomId && c.instance.url === connection.instance.url && c.path === connection.path); @@ -244,7 +298,8 @@ export class GitLabRepoConnection extends CommandConnection { + public static async getBase64Avatar(avatarUrl: string, client: GitLabClient, storage: IBridgeStorageProvider): Promise { + try { + const existingFile = await storage.getStoredTempFile(avatarUrl); + if (existingFile) { + return existingFile; + } + const res = await client.get(avatarUrl); + if (res.status !== 200) { + return null; + } + const contentType = res.headers["content-type"]; + if (!contentType?.startsWith("image/")) { + return null; + } + const data = res.data as Buffer; + const url = `data:${contentType};base64,${data.toString('base64')}`; + await storage.setStoredTempFile(avatarUrl, url); + return url; + } catch (ex) { + if (axios.isAxiosError(ex)) { + if (ex.response?.status === 401) { + // 401 means that the project is Private and GitLab haven't fixed + // the auth issues, just ignore this one. + // https://gitlab.com/gitlab-org/gitlab/-/issues/25498 + return null; + } + } + log.warn(`Could not transform data from ${avatarUrl} into base64`, ex); + return null; + } + } + + public static async getConnectionTargets(userId: string, config: BridgeConfigGitLab, filters: GitLabTargetFilter = {}, tokenStore: UserTokenStore, storage: IBridgeStorageProvider): Promise { // Search for all repos under the user's control. if (!filters.instance) { @@ -278,15 +365,16 @@ export class GitLabRepoConnection extends CommandConnection ({ + const allProjects = await client.projects.list(AccessLevel.Developer, filters.parent, undefined, filters.search); + return await Promise.all(allProjects.map(async p => ({ state: { instance: filters.instance, path: p.path_with_namespace, }, name: p.name, - })) as GitLabRepoConnectionProjectTarget[]; + avatar_url: p.avatar_url && await this.getBase64Avatar(p.avatar_url, client, storage), + description: p.description, + }))) as GitLabRepoConnectionProjectTarget[]; } private readonly debounceMRComments = new Map({ maxSize: 100 }); private readonly hookFilter: HookFilter; - constructor(roomId: string, + private readonly grantChecker; + + constructor( + roomId: string, stateKey: string, - private readonly as: Appservice, - state: GitLabRepoConnectionState, + as: Appservice, + config: BridgeConfigGitLab, + private readonly intent: Intent, + state: ConnectionStateValidated, private readonly tokenStore: UserTokenStore, - private readonly instance: GitLabInstance) { - super( - roomId, - stateKey, - GitLabRepoConnection.CanonicalEventType, - state, - as.botClient, - GitLabRepoConnection.botCommands, - GitLabRepoConnection.helpMessage, - "!gl", - "gitlab", - ) - if (!state.path || !state.instance) { - throw Error('Invalid state, missing `path` or `instance`'); - } - this.hookFilter = new HookFilter( - // GitLab allows all events by default - AllowedEvents, - [], - state.ignoreHooks, - ); - } + private readonly instance: GitLabInstance, + ) { + super( + roomId, + stateKey, + GitLabRepoConnection.CanonicalEventType, + state, + intent.underlyingClient, + GitLabRepoConnection.botCommands, + GitLabRepoConnection.helpMessage, + ["gitlab"], + "!gl", + "gitlab", + ) + this.grantChecker = new GitLabGrantChecker(as, config, tokenStore); + if (!state.path || !state.instance) { + throw Error('Invalid state, missing `path` or `instance`'); + } + this.hookFilter = new HookFilter( + state.enableHooks ?? DefaultHooks, + ); +} public get path() { return this.state.path.toLowerCase(); @@ -360,13 +453,18 @@ export class GitLabRepoConnection extends CommandConnection) { + const validatedState = GitLabRepoConnection.validateState(stateEv.content); + await this.grantChecker.assertConnectionGranted(this.roomId, { + instance: validatedState.instance, + path: validatedState.path, + } , stateEv.sender); await super.onStateUpdate(stateEv); - this.hookFilter.ignoredHooks = this.state.ignoreHooks ?? []; + this.hookFilter.enabledHooks = this.state.enableHooks; } public getProvisionerDetails(): GitLabRepoResponseItem { return { - ...GitLabRepoConnection.getProvisionerDetails(this.as.botUserId), + ...GitLabRepoConnection.getProvisionerDetails(this.intent.userId), id: this.connectionId, config: { ...this.state, @@ -393,7 +491,7 @@ export class GitLabRepoConnection extends CommandConnection PUSH_MAX_COMMITS; const displayedCommits = tooManyCommits ? 1 : Math.min(event.total_commits_count, PUSH_MAX_COMMITS); - + // Take the top 5 commits. The array is ordered in reverse. const commits = event.commits.reverse().slice(0,displayedCommits).map(commit => { return `[\`${commit.id.slice(0,8)}\`](${event.project.homepage}/-/commit/${commit.id}) ${commit.title}${shouldName ? ` by ${commit.author.name}` : ""}`; @@ -571,14 +669,14 @@ export class GitLabRepoConnection extends CommandConnection "); } - this.as.botIntent.sendEvent(this.roomId, { + this.intent.sendEvent(this.roomId, { msgtype: "m.notice", body: content, formatted_body: md.renderInline(content), @@ -714,7 +812,7 @@ ${data.description}`; } this.debounceMergeRequestReview( event.user, - event.object_attributes, + event.object_attributes, event.project, { commentCount: 0, @@ -778,20 +876,21 @@ ${data.description}`; // Apply previous state to the current config, as provisioners might not return "unknown" keys. config = { ...this.state, ...config }; const validatedConfig = GitLabRepoConnection.validateState(config); - await this.as.botClient.sendStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey, validatedConfig); + await this.intent.underlyingClient.sendStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey, validatedConfig); this.state = validatedConfig; - this.hookFilter.ignoredHooks = this.state.ignoreHooks ?? []; + this.hookFilter.enabledHooks = this.state.enableHooks; } public async onRemove() { log.info(`Removing ${this.toString()} for ${this.roomId}`); + await this.grantChecker.ungrantConnection(this.roomId, { instance: this.state.instance, path: this.path }); // Do a sanity check that the event exists. try { - await this.as.botClient.getRoomStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey); - await this.as.botClient.sendStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey, { disabled: true }); + await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey); + await this.intent.underlyingClient.sendStateEvent(this.roomId, GitLabRepoConnection.CanonicalEventType, this.stateKey, { disabled: true }); } catch (ex) { - await this.as.botClient.getRoomStateEvent(this.roomId, GitLabRepoConnection.LegacyCanonicalEventType, this.stateKey); - await this.as.botClient.sendStateEvent(this.roomId, GitLabRepoConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true }); + await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitLabRepoConnection.LegacyCanonicalEventType, this.stateKey); + await this.intent.underlyingClient.sendStateEvent(this.roomId, GitLabRepoConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true }); } // TODO: Clean up webhooks } diff --git a/src/Connections/IConnection.ts b/src/Connections/IConnection.ts index 2905a285..b1f81157 100644 --- a/src/Connections/IConnection.ts +++ b/src/Connections/IConnection.ts @@ -1,7 +1,7 @@ import { MatrixEvent, MatrixMessageContent } from "../MatrixEvent"; import { IssuesOpenedEvent, IssuesEditedEvent } from "@octokit/webhooks-types"; import { ConnectionWarning, GetConnectionsResponseItem } from "../provisioning/api"; -import { Appservice, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk"; +import { Appservice, Intent, IRichReplyMetadata, StateEvent } from "matrix-bot-sdk"; import { BridgeConfig, BridgePermissionLevel } from "../Config/Config"; import { UserTokenStore } from "../UserTokenStore"; import { CommentProcessor } from "../CommentProcessor"; @@ -25,6 +25,19 @@ export interface IConnection { priority: number; + /** + * Ensures that the current state loaded into the connection has been granted by + * the remote service. I.e. If the room is bridged into a GitHub repository, + * check that the *sender* has permission to bridge it. + * + * If a grant cannot be found, it may be determined by doing an API lookup against + * the remote service. + * + * @param sender The matrix ID of the sender of the event. + * @throws If the grant cannot be found, and cannot be detetermined, this will throw. + */ + ensureGrant?: (sender?: string) => void; + /** * The unique connection ID. This is a opaque hash of the roomId, connection type and state key. */ @@ -81,13 +94,14 @@ export interface ConnectionDeclaration { EventTypes: string[]; ServiceCategory: string; provisionConnection?: (roomId: string, userId: string, data: Record, opts: ProvisionConnectionOpts) => Promise<{connection: C, warning?: ConnectionWarning}>; - createConnectionForState: (roomId: string, state: StateEvent>, opts: InstantiateConnectionOpts) => C|Promise + createConnectionForState: (roomId: string, state: StateEvent>, opts: InstantiateConnectionOpts) => C|Promise; } export const ConnectionDeclarations: Array = []; export interface InstantiateConnectionOpts { as: Appservice, + intent: Intent, config: BridgeConfig, tokenStore: UserTokenStore, commentProcessor: CommentProcessor, diff --git a/src/Connections/JiraProject.ts b/src/Connections/JiraProject.ts index 37fe3a23..9d9a68cf 100644 --- a/src/Connections/JiraProject.ts +++ b/src/Connections/JiraProject.ts @@ -1,5 +1,5 @@ import { Connection, IConnection, IConnectionState, InstantiateConnectionOpts, ProvisionConnectionOpts } from "./IConnection"; -import { Appservice, StateEvent } from "matrix-bot-sdk"; +import { Appservice, Intent, StateEvent } from "matrix-bot-sdk"; import { Logger } from "matrix-appservice-bridge"; import { JiraIssueEvent, JiraIssueUpdatedEvent, JiraVersionEvent } from "../Jira/WebhookTypes"; import { FormatUtil } from "../FormatUtil"; @@ -16,6 +16,8 @@ import JiraApi from "jira-client"; import { GetConnectionsResponseItem } from "../provisioning/api"; import { BridgeConfigJira } from "../Config/Config"; import { HookshotJiraApi } from "../Jira/Client"; +import { GrantChecker } from "../grants/GrantCheck"; +import { JiraGrantChecker } from "../Jira/GrantChecker"; type JiraAllowedEventsNames = "issue_created" | @@ -54,6 +56,7 @@ export type JiraProjectConnectionTarget = JiraProjectConnectionInstanceTarget|Ji export interface JiraTargetFilter { instanceName?: string; + search?: string; } @@ -93,8 +96,6 @@ const md = new markdownit(); */ @Connection export class JiraProjectConnection extends CommandConnection implements IConnection { - - static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.jira.project"; static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.jira.project"; @@ -106,49 +107,55 @@ export class JiraProjectConnection extends CommandConnection MatrixMessageContent; - static async provisionConnection(roomId: string, userId: string, data: Record, {getAllConnectionsOfType, as, tokenStore, config}: ProvisionConnectionOpts) { + static async assertUserHasAccessToProject(tokenStore: UserTokenStore, userId: string, urlStr: string) { + const url = new URL(urlStr); + const jiraClient = await tokenStore.getJiraForUser(userId, url.toString()); + if (!jiraClient) { + throw new ApiError("User is not authenticated with JIRA", ErrCode.ForbiddenUser); + } + const jiraResourceClient = await jiraClient.getClientForUrl(url); + if (!jiraResourceClient) { + throw new ApiError("User is not authenticated with this JIRA instance", ErrCode.ForbiddenUser); + } + const projectKey = JiraProjectConnection.getProjectKeyForUrl(url); + if (!projectKey) { + throw new ApiError("URL did not contain a valid project key", ErrCode.BadValue); + } + try { + // Need to check that the user can access this. + const project = await jiraResourceClient.getProject(projectKey); + return project; + } catch (ex) { + throw new ApiError("Requested project was not found", ErrCode.ForbiddenUser); + } + } + + static async provisionConnection(roomId: string, userId: string, data: Record, {as, intent, tokenStore, config}: ProvisionConnectionOpts) { if (!config.jira) { throw new ApiError('JIRA integration is not configured', ErrCode.DisabledFeature); } const validData = validateJiraConnectionState(data); log.info(`Attempting to provisionConnection for ${roomId} ${validData.url} on behalf of ${userId}`); - const jiraClient = await tokenStore.getJiraForUser(userId, validData.url); - if (!jiraClient) { - throw new ApiError("User is not authenticated with JIRA", ErrCode.ForbiddenUser); + const project = await this.assertUserHasAccessToProject(tokenStore, userId, validData.url); + const connection = new JiraProjectConnection(roomId, as, intent, validData, validData.url, tokenStore); + // Fetch the project's id now, to support events that identify projects by id instead of url + if (connection.state.id !== undefined && connection.state.id !== project.id) { + log.warn(`Updating ID of project ${connection.projectKey} from ${connection.state.id} to ${project.id}`); + connection.state.id = project.id; } - const jiraResourceClient = await jiraClient.getClientForUrl(new URL(validData.url)); - if (!jiraResourceClient) { - throw new ApiError("User is not authenticated with this JIRA instance", ErrCode.ForbiddenUser); - } - const connection = new JiraProjectConnection(roomId, as, validData, validData.url, tokenStore); - log.debug(`projectKey for ${validData.url} is ${connection.projectKey}`); - if (!connection.projectKey) { - throw Error('Expected projectKey to be defined'); - } - try { - // Need to check that the user can access this. - const project = await jiraResourceClient.getProject(connection.projectKey); - // Fetch the project's id now, to support events that identify projects by id instead of url - if (connection.state.id !== undefined && connection.state.id !== project.id) { - log.warn(`Updating ID of project ${connection.projectKey} from ${connection.state.id} to ${project.id}`); - connection.state.id = project.id; - } - } catch (ex) { - throw new ApiError("Requested project was not found", ErrCode.ForbiddenUser); - } - await as.botIntent.underlyingClient.sendStateEvent(roomId, JiraProjectConnection.CanonicalEventType, connection.stateKey, validData); + await intent.underlyingClient.sendStateEvent(roomId, JiraProjectConnection.CanonicalEventType, connection.stateKey, validData); log.info(`Created connection via provisionConnection ${connection.toString()}`); return {connection}; } - - static createConnectionForState(roomId: string, state: StateEvent>, {config, as, tokenStore}: InstantiateConnectionOpts) { + + static createConnectionForState(roomId: string, state: StateEvent>, {config, as, intent, tokenStore}: InstantiateConnectionOpts) { if (!config.jira) { throw Error('JIRA is not configured'); } const connectionConfig = validateJiraConnectionState(state.content); - return new JiraProjectConnection(roomId, as, connectionConfig, state.stateKey, tokenStore); + return new JiraProjectConnection(roomId, as, intent, connectionConfig, state.stateKey, tokenStore); } - + public get projectId() { return this.state.id; } @@ -194,36 +201,42 @@ export class JiraProjectConnection extends CommandConnection; + + constructor( + roomId: string, private readonly as: Appservice, + private readonly intent: Intent, state: JiraProjectConnectionState, stateKey: string, - private readonly tokenStore: UserTokenStore,) { - super( - roomId, - stateKey, - JiraProjectConnection.CanonicalEventType, - state, - as.botClient, - JiraProjectConnection.botCommands, - JiraProjectConnection.helpMessage, - "!jira", - "jira" - ); - if (state.url) { - this.projectUrl = new URL(state.url); - } else if (state.id) { - log.warn(`Legacy ID option in use, needs to be switched to 'url'`); - } else { - throw Error('State is missing both id and url, cannot create connection'); - } - + private readonly tokenStore: UserTokenStore + ) { + super( + roomId, + stateKey, + JiraProjectConnection.CanonicalEventType, + state, + intent.underlyingClient, + JiraProjectConnection.botCommands, + JiraProjectConnection.helpMessage, + ["jira"], + "!jira", + "jira" + ); + if (state.url) { + this.projectUrl = new URL(state.url); + } else if (state.id) { + log.warn(`Legacy ID option in use, needs to be switched to 'url'`); + } else { + throw Error('State is missing both id and url, cannot create connection'); } + this.grantChecker = new JiraGrantChecker(as, tokenStore); + } public isInterestedInStateEvent(eventType: string, stateKey: string) { return JiraProjectConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; @@ -233,6 +246,12 @@ export class JiraProjectConnection extends CommandConnection t.name).join(', ')}`; - return this.as.botIntent.sendEvent(this.roomId,{ + return this.intent.sendEvent(this.roomId,{ msgtype: "m.notice", body: content, formatted_body: md.render(content), @@ -496,13 +514,16 @@ export class JiraProjectConnection extends CommandConnection Promise, - private readonly pushConnections: (...connections: IConnection[]) => void) { - super( - roomId, - "", - "", - // TODO Consider storing room-specific config in state. - {}, - provisionOpts.as.botClient, - SetupConnection.botCommands, - SetupConnection.helpMessage, - "!hookshot", - ) - this.enabledHelpCategories = [ - this.config.github ? "github" : "", - this.config.gitlab ? "gitlab": "", - this.config.figma ? "figma": "", - this.config.jira ? "jira": "", - this.config.generic?.enabled ? "webhook": "", - this.config.feeds?.enabled ? "feed" : "", - this.config.widgets?.roomSetupWidget ? "widget" : "", - ]; - this.includeTitlesInHelp = false; + private readonly getOrCreateAdminRoom: (intent: Intent, userId: string) => Promise, + private readonly pushConnections: (...connections: IConnection[]) => void, + ) { + super( + roomId, + "", + "", + // TODO Consider storing room-specific config in state. + {}, + provisionOpts.intent.underlyingClient, + SetupConnection.botCommands, + SetupConnection.helpMessage, + helpCategories, + prefix, + ); + this.includeTitlesInHelp = false; } @botCommand("github repo", { help: "Create a connection for a GitHub repository. (You must be logged in with GitHub to do this.)", requiredArgs: ["url"], includeUserId: true, category: "github"}) @@ -84,7 +89,7 @@ export class SetupConnection extends CommandConnection { const [, org, repo] = urlParts; const {connection} = await GitHubRepoConnection.provisionConnection(this.roomId, userId, {org, repo}, this.provisionOpts); this.pushConnections(connection); - await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge ${connection.org}/${connection.repo}`); + await this.client.sendNotice(this.roomId, `Room configured to bridge ${connection.org}/${connection.repo}`); } @botCommand("gitlab project", { help: "Create a connection for a GitHub project. (You must be logged in with GitLab to do this.)", requiredArgs: ["url"], includeUserId: true, category: "gitlab"}) @@ -110,7 +115,7 @@ export class SetupConnection extends CommandConnection { } const {connection, warning} = await GitLabRepoConnection.provisionConnection(this.roomId, userId, {path, instance: name}, this.provisionOpts); this.pushConnections(connection); - await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge ${connection.prettyPath}` + (warning ? `\n${warning.header}: ${warning.message}` : "")); + await this.client.sendNotice(this.roomId, `Room configured to bridge ${connection.prettyPath}` + (warning ? `\n${warning.header}: ${warning.message}` : "")); } private async checkJiraLogin(userId: string, urlStr: string) { @@ -142,12 +147,12 @@ export class SetupConnection extends CommandConnection { const res = await JiraProjectConnection.provisionConnection(this.roomId, userId, { url: safeUrl }, this.provisionOpts); this.pushConnections(res.connection); - await this.as.botClient.sendNotice(this.roomId, `Room configured to bridge Jira project ${res.connection.projectKey}.`); + await this.client.sendNotice(this.roomId, `Room configured to bridge Jira project ${res.connection.projectKey}.`); } @botCommand("jira list project", { help: "Show JIRA projects currently connected to.", category: "jira"}) public async onJiraListProject() { - const projects: JiraProjectConnectionState[] = await this.as.botClient.getRoomState(this.roomId).catch((err: any) => { + const projects: JiraProjectConnectionState[] = await this.client.getRoomState(this.roomId).catch((err: any) => { if (err.body.errcode === 'M_NOT_FOUND') { return []; // not an error to us } @@ -162,9 +167,9 @@ export class SetupConnection extends CommandConnection { ); if (projects.length === 0) { - return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline('Not connected to any JIRA projects')); + return this.client.sendHtmlNotice(this.roomId, md.renderInline('Not connected to any JIRA projects')); } else { - return this.as.botClient.sendHtmlNotice(this.roomId, md.render( + return this.client.sendHtmlNotice(this.roomId, md.render( 'Currently connected to these JIRA projects:\n\n' + projects.map(project => ` - ${project.url}`).join('\n') )); @@ -185,7 +190,7 @@ export class SetupConnection extends CommandConnection { let eventType = ""; for (eventType of eventTypes) { try { - event = await this.as.botClient.getRoomStateEvent(this.roomId, eventType, safeUrl); + event = await this.client.getRoomStateEvent(this.roomId, eventType, safeUrl); break; } catch (err: any) { if (err.body.errcode !== 'M_NOT_FOUND') { @@ -197,11 +202,11 @@ export class SetupConnection extends CommandConnection { throw new CommandError("Invalid Jira project URL", `Feed "${urlStr}" is not currently bridged to this room`); } - await this.as.botClient.sendStateEvent(this.roomId, eventType, safeUrl, {}); - return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline(`Room no longer bridged to Jira project \`${safeUrl}\`.`)); + await this.client.sendStateEvent(this.roomId, eventType, safeUrl, {}); + return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room no longer bridged to Jira project \`${safeUrl}\`.`)); } - @botCommand("webhook", { help: "Create an inbound webhook.", requiredArgs: ["name"], includeUserId: true, category: "webhook"}) + @botCommand("webhook", { help: "Create an inbound webhook.", requiredArgs: ["name"], includeUserId: true, category: "generic"}) public async onWebhook(userId: string, name: string) { if (!this.config.generic?.enabled) { throw new CommandError("not-configured", "The bridge is not configured to support webhooks."); @@ -215,9 +220,15 @@ export class SetupConnection extends CommandConnection { const c = await GenericHookConnection.provisionConnection(this.roomId, userId, {name}, this.provisionOpts); this.pushConnections(c.connection); const url = new URL(c.connection.hookId, this.config.generic.parsedUrlPrefix); - const adminRoom = await this.getOrCreateAdminRoom(userId); - await adminRoom.sendNotice(`You have bridged a webhook. Please configure your webhook source to use ${url}.`); - return this.as.botClient.sendNotice(this.roomId, `Room configured to bridge webhooks. See admin room for secret url.`); + const adminRoom = await this.getOrCreateAdminRoom(this.intent, userId); + const safeRoomId = encodeURIComponent(this.roomId); + await adminRoom.sendNotice( + `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 configure your webhook source to use\n${url}` + ); + return this.client.sendNotice(this.roomId, `Room configured to bridge webhooks. See admin room for secret url.`); } @botCommand("figma file", { help: "Bridge a Figma file to the room.", requiredArgs: ["url"], includeUserId: true, category: "figma"}) @@ -235,10 +246,10 @@ export class SetupConnection extends CommandConnection { const [, fileId] = res; const {connection} = await FigmaFileConnection.provisionConnection(this.roomId, userId, { fileId }, this.provisionOpts); this.pushConnections(connection); - return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge Figma file.`)); + return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge Figma file.`)); } - @botCommand("feed", { help: "Bridge an RSS/Atom feed to the room.", requiredArgs: ["url"], optionalArgs: ["label"], includeUserId: true, category: "feed"}) + @botCommand("feed", { help: "Bridge an RSS/Atom feed to the room.", requiredArgs: ["url"], optionalArgs: ["label"], includeUserId: true, category: "feeds"}) public async onFeed(userId: string, url: string, label?: string) { if (!this.config.feeds?.enabled) { throw new CommandError("not-configured", "The bridge is not configured to support feeds."); @@ -260,12 +271,12 @@ export class SetupConnection extends CommandConnection { const {connection} = await FeedConnection.provisionConnection(this.roomId, userId, { url, label }, this.provisionOpts); this.pushConnections(connection); - return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge \`${url}\``)); + return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Room configured to bridge \`${url}\``)); } - @botCommand("feed list", { help: "Show feeds currently subscribed to.", category: "feed"}) + @botCommand("feed list", { help: "Show feeds currently subscribed to.", category: "feeds"}) public async onFeedList() { - const feeds: FeedConnectionState[] = await this.as.botClient.getRoomState(this.roomId).catch((err: any) => { + const feeds: FeedConnectionState[] = await this.client.getRoomState(this.roomId).catch((err: any) => { if (err.body.errcode === 'M_NOT_FOUND') { return []; // not an error to us } @@ -277,7 +288,7 @@ export class SetupConnection extends CommandConnection { ); if (feeds.length === 0) { - return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline('Not subscribed to any feeds')); + return this.client.sendHtmlNotice(this.roomId, md.renderInline('Not subscribed to any feeds')); } else { const feedDescriptions = feeds.map(feed => { if (feed.label) { @@ -286,18 +297,18 @@ export class SetupConnection extends CommandConnection { return feed.url; }); - return this.as.botClient.sendHtmlNotice(this.roomId, md.render( + return this.client.sendHtmlNotice(this.roomId, md.render( 'Currently subscribed to these feeds:\n\n' + feedDescriptions.map(desc => ` - ${desc}`).join('\n') )); } } - @botCommand("feed remove", { help: "Unsubscribe from an RSS/Atom feed.", requiredArgs: ["url"], includeUserId: true, category: "feed"}) + @botCommand("feed remove", { help: "Unsubscribe from an RSS/Atom feed.", requiredArgs: ["url"], includeUserId: true, category: "feeds"}) public async onFeedRemove(userId: string, url: string) { await this.checkUserPermissions(userId, "feed", FeedConnection.CanonicalEventType); - const event = await this.as.botClient.getRoomStateEvent(this.roomId, FeedConnection.CanonicalEventType, url).catch((err: any) => { + const event = await this.client.getRoomStateEvent(this.roomId, FeedConnection.CanonicalEventType, url).catch((err: any) => { if (err.body.errcode === 'M_NOT_FOUND') { return null; // not an error to us } @@ -307,17 +318,17 @@ export class SetupConnection extends CommandConnection { throw new CommandError("Invalid feed URL", `Feed "${url}" is not currently bridged to this room`); } - await this.as.botClient.sendStateEvent(this.roomId, FeedConnection.CanonicalEventType, url, {}); - return this.as.botClient.sendHtmlNotice(this.roomId, md.renderInline(`Unsubscribed from \`${url}\``)); + await this.client.sendStateEvent(this.roomId, FeedConnection.CanonicalEventType, url, {}); + return this.client.sendHtmlNotice(this.roomId, md.renderInline(`Unsubscribed from \`${url}\``)); } @botCommand("setup-widget", {category: "widget", help: "Open the setup widget in the room"}) public async onSetupWidget() { - if (!this.config.widgets?.roomSetupWidget) { + if (this.config.widgets?.roomSetupWidget === undefined) { throw new CommandError("Not configured", "The bridge is not configured to support setup widgets"); } - if (!await SetupWidget.SetupRoomConfigWidget(this.roomId, this.as.botIntent, this.config.widgets)) { - await this.as.botClient.sendNotice(this.roomId, `This room already has a setup widget, please open the "Hookshot Configuration" widget.`); + if (!await SetupWidget.SetupRoomConfigWidget(this.roomId, this.intent, this.config.widgets, this.serviceTypes)) { + await this.client.sendNotice(this.roomId, `This room already has a setup widget, please open the "Hookshot Configuration" widget.`); } } @@ -325,10 +336,10 @@ export class SetupConnection extends CommandConnection { if (!this.config.checkPermission(userId, service, BridgePermissionLevel.manageConnections)) { throw new CommandError(`You are not permitted to provision connections for ${service}.`); } - if (!await this.as.botClient.userHasPowerLevelFor(userId, this.roomId, "", true)) { + if (!await this.client.userHasPowerLevelFor(userId, this.roomId, "", true)) { throw new CommandError("not-configured", "You must be able to set state in a room ('Change settings') in order to set up new integrations."); } - if (!await this.as.botClient.userHasPowerLevelFor(this.as.botUserId, this.roomId, stateEventType, true)) { + if (!await this.client.userHasPowerLevelFor(this.intent.userId, this.roomId, stateEventType, true)) { throw new CommandError("Bot lacks power level to set room state", "I do not have permission to set up a bridge in this room. Please promote me to an Admin/Moderator."); } } diff --git a/src/Github/GithubInstance.ts b/src/Github/GithubInstance.ts index f0c981d7..c4c31c48 100644 --- a/src/Github/GithubInstance.ts +++ b/src/Github/GithubInstance.ts @@ -10,6 +10,7 @@ import UserAgent from "../UserAgent"; const log = new Logger("GithubInstance"); export const GITHUB_CLOUD_URL = new URL("https://api.github.com"); +export const GITHUB_CLOUD_PUBLIC_URL = new URL("https://github.com"); export class GitHubOAuthError extends Error { constructor(errorResponse: GitHubOAuthErrorResponse) { @@ -182,7 +183,7 @@ export class GithubInstance { public get newInstallationUrl() { if (this.baseUrl.hostname === GITHUB_CLOUD_URL.hostname) { // Cloud - return new URL(`/apps/${this.appSlug}/installations/new`, this.baseUrl); + return new URL(`/apps/${this.appSlug}/installations/new`, GITHUB_CLOUD_PUBLIC_URL); } // Enterprise (yes, i know right) return new URL(`/github-apps/${this.appSlug}/installations/new`, this.baseUrl); @@ -192,7 +193,7 @@ export class GithubInstance { const q = new URLSearchParams(params as Record); if (baseUrl.hostname === GITHUB_CLOUD_URL.hostname) { // Cloud doesn't use `api.` for oauth. - baseUrl = new URL("https://github.com"); + baseUrl = GITHUB_CLOUD_PUBLIC_URL; } const rawUrl = baseUrl.toString(); return rawUrl + `${rawUrl.endsWith('/') ? '' : '/'}` + `login/oauth/${action}?${q}`; diff --git a/src/Github/GrantChecker.ts b/src/Github/GrantChecker.ts new file mode 100644 index 00000000..e3aa9ace --- /dev/null +++ b/src/Github/GrantChecker.ts @@ -0,0 +1,39 @@ +import { Appservice } from "matrix-bot-sdk"; +import { GitHubRepoConnection } from "../Connections"; +import { GrantChecker } from "../grants/GrantCheck"; +import { UserTokenStore } from "../UserTokenStore"; +import { GithubInstance } from "./GithubInstance"; +import { Logger } from 'matrix-appservice-bridge'; + +const log = new Logger('GitHubGrantChecker'); + +interface GitHubGrantConnectionId { + org: string; + repo: string; +} + + +export class GitHubGrantChecker extends GrantChecker { + constructor(private readonly as: Appservice, private readonly github: GithubInstance, private readonly tokenStore: UserTokenStore) { + super(as.botIntent, "github") + } + + protected async checkFallback(roomId: string, connectionId: GitHubGrantConnectionId, sender?: string) { + if (!sender) { + log.debug(`Tried to check fallback for ${roomId} with a missing sender`); + // Cannot validate without a sender. + return false; + } + if (this.as.isNamespacedUser(sender)) { + // Bridge is always valid. + return true; + } + try { + await GitHubRepoConnection.assertUserHasAccessToRepo(sender, connectionId.org, connectionId.repo, this.github, this.tokenStore); + return true; + } catch (ex) { + log.info(`Tried to check fallback for ${roomId}: ${sender} does not have access to ${connectionId.org}/${connectionId.repo}`, ex); + return false; + } + } +} \ No newline at end of file diff --git a/src/Gitlab/Client.ts b/src/Gitlab/Client.ts index b1673170..2c2cb85b 100644 --- a/src/Gitlab/Client.ts +++ b/src/Gitlab/Client.ts @@ -35,6 +35,10 @@ export class GitLabClient { }; } + async get(path: string) { + return await axios.get(path, { ...this.defaultConfig, responseType: 'arraybuffer'}); + } + async version() { return (await axios.get("api/v4/versions", this.defaultConfig)).data; } @@ -80,7 +84,7 @@ export class GitLabClient { min_access_level: minAccess, simple: true, pagination: "keyset", - per_page: 50, + per_page: 10, order_by: "id", sort: "asc", id_after: idAfter, diff --git a/src/Gitlab/GrantChecker.ts b/src/Gitlab/GrantChecker.ts new file mode 100644 index 00000000..1ce94731 --- /dev/null +++ b/src/Gitlab/GrantChecker.ts @@ -0,0 +1,40 @@ +import { Logger } from "matrix-appservice-bridge"; +import { Appservice } from "matrix-bot-sdk"; +import { BridgeConfigGitLab } from "../Config/Config"; +import { GitLabRepoConnection } from "../Connections"; +import { GrantChecker } from "../grants/GrantCheck"; +import { UserTokenStore } from "../UserTokenStore"; + +const log = new Logger('GitLabGrantChecker'); + +interface GitLabGrantConnectionId{ + instance: string; + path: string; +} + + + +export class GitLabGrantChecker extends GrantChecker { + constructor(private readonly as: Appservice, private readonly config: BridgeConfigGitLab, private readonly tokenStore: UserTokenStore) { + super(as.botIntent, "gitlab") + } + + protected async checkFallback(roomId: string, connectionId: GitLabGrantConnectionId, sender?: string) { + if (!sender) { + log.debug(`Tried to check fallback for ${roomId} with a missing sender`); + // Cannot validate without a sender. + return false; + } + if (this.as.isNamespacedUser(sender)) { + // Bridge is always valid. + return true; + } + try { + await GitLabRepoConnection.assertUserHasAccessToProject(connectionId.instance, connectionId.path, sender, this.tokenStore, this.config); + return true; + } catch (ex) { + log.info(`${sender} does not have access to ${connectionId.instance}/${connectionId.path}`, ex); + return false; + } + } +} \ No newline at end of file diff --git a/src/Gitlab/Types.ts b/src/Gitlab/Types.ts index 224f6047..0ef55965 100644 --- a/src/Gitlab/Types.ts +++ b/src/Gitlab/Types.ts @@ -221,6 +221,8 @@ export interface ProjectHook extends ProjectHookOpts { } export interface SimpleProject { + avatar_url?: string; + description?: string; id: string; name: string; path: string; diff --git a/src/HookFilter.ts b/src/HookFilter.ts index 934e8935..474ddfe4 100644 --- a/src/HookFilter.ts +++ b/src/HookFilter.ts @@ -1,19 +1,32 @@ export class HookFilter { + static convertIgnoredHooksToEnabledHooks(explicitlyEnabledHooks: T[] = [], ignoredHooks: T[], defaultHooks: T[]): T[] { + const resultHookSet = new Set([ + ...explicitlyEnabledHooks, + ...defaultHooks, + ]); + + // For each ignored hook, remove anything that matches. + for (const ignoredHook of ignoredHooks) { + resultHookSet.delete(ignoredHook); + // If the hook is a "root" hook name, remove all children. + for (const enabledHook of resultHookSet) { + if (enabledHook.startsWith(`${ignoredHook}.`)) { + resultHookSet.delete(enabledHook); + } + } + } + + return [...resultHookSet]; + } + constructor( - public readonly defaultHooks: T[], public enabledHooks: T[] = [], - public ignoredHooks: T[] = [] ) { } public shouldSkip(...hookName: T[]) { - if (hookName.some(name => this.ignoredHooks.includes(name))) { - return true; - } - if (hookName.some(name => this.enabledHooks.includes(name))) { - return false; - } - return !hookName.some(h => this.defaultHooks.includes(h)); + // Should skip if all of the hook names are missing + return hookName.every(name => !this.enabledHooks.includes(name)); } } \ No newline at end of file diff --git a/src/IntentUtils.ts b/src/IntentUtils.ts index 415ed633..31d2b5ba 100644 --- a/src/IntentUtils.ts +++ b/src/IntentUtils.ts @@ -1,12 +1,43 @@ import { Logger } from "matrix-appservice-bridge"; -import { Appservice } from "matrix-bot-sdk"; +import { Appservice, Intent, MatrixClient } from "matrix-bot-sdk"; import axios from "axios"; const log = new Logger("IntentUtils"); -export async function getIntentForUser(user: {avatarUrl?: string, login: string}, as: Appservice, prefix: string) { +/** + * Attempt to ensure that a given user is in a room, inviting them + * via the bot user if nessacery. + * + * If the bot user isn't in the room (and the target isn't in the room already), + * this will fail. + * @param targetIntent The intent for the user who should be in the room. + * @param botClient The bot client for the room. + * @param roomId The target room to invite to. + * @throws If it was not possible to invite the user. + */ +export async function ensureUserIsInRoom(targetIntent: Intent, botClient: MatrixClient, roomId: string) { + const senderUserId = targetIntent.userId; + try { + try { + await targetIntent.ensureJoined(roomId); + } catch (ex) { + if ('errcode' in ex && ex.errcode === "M_FORBIDDEN") { + // Make sure ghost user is invited to the room + await botClient.inviteUser(senderUserId, roomId); + await targetIntent.ensureJoined(roomId); + } else { + throw ex; + } + } + } catch (ex) { + log.warn(`Could not ensure that ${senderUserId} is in ${roomId}`, ex); + throw Error(`Could not ensure that ${senderUserId} is in ${roomId}`); + } +} + +export async function getIntentForUser(user: {avatarUrl?: string, login: string}, as: Appservice, prefix?: string) { const domain = as.botUserId.split(":")[1]; - const intent = as.getIntentForUserId(`@${prefix}${user.login}:${domain}`); + const intent = as.getIntentForUserId(`@${prefix ?? ''}${user.login}:${domain}`); const displayName = `${user.login}`; // Verify up-to-date profile let profile; diff --git a/src/Jira/Client.ts b/src/Jira/Client.ts index 73e8194a..52cd375f 100644 --- a/src/Jira/Client.ts +++ b/src/Jira/Client.ts @@ -33,7 +33,7 @@ export abstract class HookshotJiraApi extends JiraApi { return this.res; } - public abstract getAllProjects(): AsyncIterable; + public abstract getAllProjects(query?: string, maxResults?: number): AsyncIterable; protected async apiRequest(path: string, method?: Method, data?: undefined): Promise protected async apiRequest(path: string, method: Method, data?: R): Promise { diff --git a/src/Jira/GrantChecker.ts b/src/Jira/GrantChecker.ts new file mode 100644 index 00000000..211fc821 --- /dev/null +++ b/src/Jira/GrantChecker.ts @@ -0,0 +1,33 @@ +import { Appservice } from "matrix-bot-sdk"; +import { JiraProjectConnection } from "../Connections"; +import { GrantChecker } from "../grants/GrantCheck"; +import { UserTokenStore } from "../UserTokenStore"; + +interface JiraGrantConnectionId{ + url: string; +} + + + +export class JiraGrantChecker extends GrantChecker { + constructor(private readonly as: Appservice, private readonly tokenStore: UserTokenStore) { + super(as.botIntent, "jira") + } + + protected async checkFallback(roomId: string, connectionId: JiraGrantConnectionId, sender?: string) { + if (!sender) { + // Cannot validate without a sender. + return false; + } + if (this.as.isNamespacedUser(sender)) { + // Bridge is always valid. + return true; + } + try { + await JiraProjectConnection.assertUserHasAccessToProject(this.tokenStore, sender, connectionId.url); + return true; + } catch (ex) { + return false; + } + } +} \ No newline at end of file diff --git a/src/Jira/client/CloudClient.ts b/src/Jira/client/CloudClient.ts index d83a4602..7d1e37de 100644 --- a/src/Jira/client/CloudClient.ts +++ b/src/Jira/client/CloudClient.ts @@ -6,6 +6,7 @@ import { BridgeConfigJira, BridgeConfigJiraCloudOAuth } from '../../Config/Confi import { Logger } from "matrix-appservice-bridge"; import { HookshotJiraApi, JiraClient } from '../Client'; import JiraApi from 'jira-client'; +import * as qs from "node:querystring"; const log = new Logger("JiraCloudClient"); const ACCESSIBLE_RESOURCE_CACHE_LIMIT = 100; @@ -44,11 +45,16 @@ export class HookshotCloudJiraApi extends HookshotJiraApi { return super.addNewIssue(issue); } - async * getAllProjects(): AsyncIterable { + async * getAllProjects(query?: string, maxResults = 10): AsyncIterable { let response; let startAt = 0; do { - response = await this.apiRequest(`/rest/api/3/project/search?startAt=${startAt}`); + const params = qs.stringify({ + startAt, + maxResults, + query + }); + response = await this.apiRequest(`/rest/api/3/project/search?${params}`); yield* response.values; startAt += response.maxResults; } while(!response.isLast) diff --git a/src/Jira/client/OnPremClient.ts b/src/Jira/client/OnPremClient.ts index 61f29ca9..22de4c8a 100644 --- a/src/Jira/client/OnPremClient.ts +++ b/src/Jira/client/OnPremClient.ts @@ -6,15 +6,27 @@ import { KeyObject } from 'crypto'; import { HookshotJiraApi, JiraClient } from '../Client'; import JiraApi from 'jira-client'; +function createSearchTerm(name?: string) { + return name?.toLowerCase()?.replaceAll(/[^a-z0-9]/g, '') || ''; +} + export class HookshotOnPremJiraApi extends HookshotJiraApi { constructor(options: JiraApi.JiraApiOptions, res: JiraAPIAccessibleResource) { super(options, res); } - async * getAllProjects(): AsyncIterable { + async * getAllProjects(search?: string): AsyncIterable { // Note, status is ignored. const results = await this.genericGet(`project`) as JiraOnPremProjectSearchResponse; + + // Reasonable search algorithm. + const searchTerm = search && createSearchTerm(search); + if (searchTerm) { + yield *results.filter(p => createSearchTerm(p.name).includes(searchTerm) || createSearchTerm(p.key).includes(searchTerm)); + return; + } + yield *results; } } diff --git a/src/Managers/BotUsersManager.ts b/src/Managers/BotUsersManager.ts new file mode 100644 index 00000000..46b26256 --- /dev/null +++ b/src/Managers/BotUsersManager.ts @@ -0,0 +1,217 @@ +import { Appservice, Intent } from "matrix-bot-sdk"; +import { Logger } from "matrix-appservice-bridge"; + +import { BridgeConfig } from "../Config/Config"; + +const log = new Logger("BotUsersManager"); + +export class BotUser { + constructor( + private readonly as: Appservice, + readonly userId: string, + readonly services: string[], + readonly prefix: string, + // Bots with higher priority should handle a command first + readonly priority: number, + readonly avatar?: string, + readonly displayname?: string, + ) {} + + get intent(): Intent { + return this.as.getIntentForUserId(this.userId); + } +} + +// Sort bot users by highest priority first. +const higherPriority: (a: BotUser, b: BotUser) => number = (a, b) => (b.priority - a.priority); + +export default class BotUsersManager { + // Map of user ID to config for all our configured bot users + private _botUsers = new Map(); + + // Map of room ID to set of bot users in the room + private _botsInRooms = new Map>(); + + constructor( + readonly config: BridgeConfig, + readonly as: Appservice, + ) { + // Default bot user + this._botUsers.set( + this.as.botUserId, + new BotUser( + this.as, + this.as.botUserId, + // Default bot can handle all services + this.config.enabledServices, + "!hookshot", + 0, + this.config.bot?.avatar, + this.config.bot?.displayname, + ) + ); + + // Service bot users + if (this.config.serviceBots) { + this.config.serviceBots.forEach(bot => { + const botUserId = this.as.getUserId(bot.localpart); + this._botUsers.set( + botUserId, + new BotUser( + this.as, + botUserId, + [bot.service], + bot.prefix, + // Service bots should handle commands first + 1, + bot.avatar, + bot.displayname, + ) + ); + }); + } + } + + async start(): Promise { + await this.ensureProfiles(); + await this.getJoinedRooms(); + } + + private async ensureProfiles(): Promise { + log.info("Ensuring bot users are set up..."); + for (const botUser of this.botUsers) { + // Ensure the bot is registered + log.debug(`Ensuring bot user ${botUser.userId} is registered`); + await botUser.intent.ensureRegistered(); + + // Set up the bot profile + let profile; + try { + profile = await botUser.intent.underlyingClient.getUserProfile(botUser.userId); + } catch { + profile = {} + } + if (botUser.avatar && profile.avatar_url !== botUser.avatar) { + log.info(`Setting avatar for "${botUser.userId}" to ${botUser.avatar}`); + await botUser.intent.underlyingClient.setAvatarUrl(botUser.avatar); + } + if (botUser.displayname && profile.displayname !== botUser.displayname) { + log.info(`Setting displayname for "${botUser.userId}" to ${botUser.displayname}`); + await botUser.intent.underlyingClient.setDisplayName(botUser.displayname); + } + } + } + + private async getJoinedRooms(): Promise { + log.info("Getting joined rooms..."); + for (const botUser of this.botUsers) { + const joinedRooms = await botUser.intent.underlyingClient.getJoinedRooms(); + for (const roomId of joinedRooms) { + this.onRoomJoin(botUser, roomId); + } + } + } + + /** + * Records a bot user having joined a room. + * + * @param botUser + * @param roomId + */ + onRoomJoin(botUser: BotUser, roomId: string): void { + log.debug(`Bot user ${botUser.userId} joined room ${roomId}`); + const botUsers = this._botsInRooms.get(roomId) ?? new Set(); + botUsers.add(botUser); + this._botsInRooms.set(roomId, botUsers); + } + + /** + * Records a bot user having left a room. + * + * @param botUser + * @param roomId + */ + onRoomLeave(botUser: BotUser, roomId: string): void { + log.info(`Bot user ${botUser.userId} left room ${roomId}`); + const botUsers = this._botsInRooms.get(roomId) ?? new Set(); + botUsers.delete(botUser); + if (botUsers.size > 0) { + this._botsInRooms.set(roomId, botUsers); + } else { + this._botsInRooms.delete(roomId); + } + } + + /** + * Gets the list of room IDs where at least one bot is a member. + * + * @returns List of room IDs. + */ + get joinedRooms(): string[] { + return Array.from(this._botsInRooms.keys()); + } + + /** + * Gets the configured bot users, ordered by priority. + * + * @returns List of bot users. + */ + get botUsers(): BotUser[] { + return Array.from(this._botUsers.values()) + .sort(higherPriority) + } + + /** + * Gets a configured bot user by user ID. + * + * @param userId User ID to get. + */ + getBotUser(userId: string): BotUser | undefined { + return this._botUsers.get(userId); + } + + /** + * Checks if the given user ID belongs to a configured bot user. + * + * @param userId User ID to check. + * @returns `true` if the user ID belongs to a bot user, otherwise `false`. + */ + isBotUser(userId: string): boolean { + return this._botUsers.has(userId); + } + + /** + * Gets all the bot users in a room, ordered by priority. + * + * @param roomId Room ID to get bots for. + */ + getBotUsersInRoom(roomId: string): BotUser[] { + return Array.from(this._botsInRooms.get(roomId) || new Set()) + .sort(higherPriority); + } + + /** + * Gets a bot user in a room, optionally for a particular service. + * When a service is specified, the bot user with the highest priority which handles that service is returned. + * + * @param roomId Room ID to get a bot user for. + * @param serviceType Optional service type for the bot. + */ + getBotUserInRoom(roomId: string, serviceType?: string): BotUser | undefined { + const botUsersInRoom = this.getBotUsersInRoom(roomId); + if (serviceType) { + return botUsersInRoom.find(b => b.services.includes(serviceType)); + } else { + return botUsersInRoom[0]; + } + } + + /** + * Gets the bot user with the highest priority for a particular service. + * + * @param serviceType Service type for the bot. + */ + getBotUserForService(serviceType: string): BotUser | undefined { + return this.botUsers.find(b => b.services.includes(serviceType)); + } +} diff --git a/src/MatrixSender.ts b/src/MatrixSender.ts index 89db2a49..391cb4c6 100644 --- a/src/MatrixSender.ts +++ b/src/MatrixSender.ts @@ -1,8 +1,8 @@ import { BridgeConfig } from "./Config/Config"; import { MessageQueue, createMessageQueue } from "./MessageQueue"; -import { Appservice, Intent } from "matrix-bot-sdk"; +import { Appservice } from "matrix-bot-sdk"; import { Logger } from "matrix-appservice-bridge"; -import { v4 as uuid } from "uuid"; +import { randomUUID } from 'node:crypto'; export interface IMatrixSendMessage { sender: string|null; @@ -32,7 +32,7 @@ export class MatrixSender { this.mq.subscribe("matrix.message"); this.mq.on("matrix.message", async (msg) => { try { - await this.sendMatrixMessage(msg.messageId || uuid(), msg.data); + await this.sendMatrixMessage(msg.messageId || randomUUID(), msg.data); } catch (ex) { log.error(`Failed to send message (${msg.data.roomId}, ${msg.data.sender}, ${msg.data.type})`); } diff --git a/src/MessageQueue/LocalMQ.ts b/src/MessageQueue/LocalMQ.ts index 31fe57f7..dff8bd78 100644 --- a/src/MessageQueue/LocalMQ.ts +++ b/src/MessageQueue/LocalMQ.ts @@ -1,7 +1,7 @@ import { EventEmitter } from "events"; import { MessageQueue, MessageQueueMessage, DEFAULT_RES_TIMEOUT } from "./Types"; import micromatch from "micromatch"; -import {v4 as uuid} from "uuid"; +import { randomUUID } from 'node:crypto'; import Metrics from "../Metrics"; export class LocalMQ extends EventEmitter implements MessageQueue { @@ -25,7 +25,7 @@ export class LocalMQ extends EventEmitter implements MessageQueue { return; } if (!message.messageId) { - message.messageId = uuid(); + message.messageId = randomUUID(); } this.emit(message.eventName, message); } diff --git a/src/MessageQueue/RedisQueue.ts b/src/MessageQueue/RedisQueue.ts index e328de66..38b2db58 100644 --- a/src/MessageQueue/RedisQueue.ts +++ b/src/MessageQueue/RedisQueue.ts @@ -1,11 +1,10 @@ import { MessageQueue, MessageQueueMessage, DEFAULT_RES_TIMEOUT, MessageQueueMessageOut } from "./Types"; import { Redis, default as redis } from "ioredis"; -import { BridgeConfig, BridgeConfigQueue } from "../Config/Config"; +import { BridgeConfigQueue } from "../Config/Config"; import { EventEmitter } from "events"; import { Logger } from "matrix-appservice-bridge"; - -import {v4 as uuid} from "uuid"; +import { randomUUID } from 'node:crypto'; const log = new Logger("RedisMq"); @@ -26,7 +25,7 @@ export class RedisMQ extends EventEmitter implements MessageQueue { this.redisSub = new redis(config.port ?? 6379, config.host ?? "localhost"); this.redisPub = new redis(config.port ?? 6379, config.host ?? "localhost"); this.redis = new redis(config.port ?? 6379, config.host ?? "localhost"); - this.myUuid = uuid(); + this.myUuid = randomUUID(); this.redisSub.on("pmessage", (_: string, channel: string, message: string) => { const msg = JSON.parse(message) as MessageQueueMessageOut; if (msg.for && msg.for !== this.myUuid) { @@ -63,7 +62,7 @@ export class RedisMQ extends EventEmitter implements MessageQueue { public async push(message: MessageQueueMessage, single = false) { if (!message.messageId) { - message.messageId = uuid(); + message.messageId = randomUUID(); } if (single) { const recipient = await this.getRecipientForEvent(message.eventName); diff --git a/src/Stores/MemoryStorageProvider.ts b/src/Stores/MemoryStorageProvider.ts index 6f4cd7fb..ac29b997 100644 --- a/src/Stores/MemoryStorageProvider.ts +++ b/src/Stores/MemoryStorageProvider.ts @@ -2,6 +2,7 @@ import { MemoryStorageProvider as MSP } from "matrix-bot-sdk"; import { IBridgeStorageProvider } from "./StorageProvider"; import { IssuesGetResponseData } from "../Github/Types"; import { ProvisionSession } from "matrix-appservice-bridge"; +import QuickLRU from "@alloc/quick-lru"; export class MemoryStorageProvider extends MSP implements IBridgeStorageProvider { private issues: Map = new Map(); @@ -9,6 +10,7 @@ export class MemoryStorageProvider extends MSP implements IBridgeStorageProvider private reviewData: Map = new Map(); private figmaCommentIds: Map = new Map(); private widgetSessions: Map = new Map(); + private storedFiles = new QuickLRU({ maxSize: 128 }); constructor() { super(); @@ -66,4 +68,11 @@ export class MemoryStorageProvider extends MSP implements IBridgeStorageProvider .forEach(s => this.widgetSessions.delete(s.token)); } + public async getStoredTempFile(key: string): Promise { + return this.storedFiles.get(key) ?? null; + } + + public async setStoredTempFile(key: string, value: string) { + this.storedFiles.set(key, value); + } } diff --git a/src/Stores/RedisStorageProvider.ts b/src/Stores/RedisStorageProvider.ts index f6821efc..9024519f 100644 --- a/src/Stores/RedisStorageProvider.ts +++ b/src/Stores/RedisStorageProvider.ts @@ -15,6 +15,8 @@ const GH_ISSUES_KEY = "gh.issues"; const GH_ISSUES_LAST_COMMENT_KEY = "gh.issues.last_comment"; const GH_ISSUES_REVIEW_DATA_KEY = "gh.issues.review_data"; const FIGMA_EVENT_COMMENT_ID = "figma.comment_event_id"; +const STORED_FILES_KEY = "storedfiles."; +const STORED_FILES_EXPIRE_AFTER = 24 * 60 * 60; // 24 hours const COMPLETED_TRANSACTIONS_EXPIRE_AFTER = 24 * 60 * 60; // 24 hours const ISSUES_EXPIRE_AFTER = 7 * 24 * 60 * 60; // 7 days const ISSUES_LAST_COMMENT_EXPIRE_AFTER = 14 * 24 * 60 * 60; // 7 days @@ -65,6 +67,9 @@ export class RedisStorageProvider extends RedisStorageContextualProvider impleme this.redis.expire(COMPLETED_TRANSACTIONS_KEY, COMPLETED_TRANSACTIONS_EXPIRE_AFTER).catch((ex) => { log.warn("Failed to set expiry time on as.completed_transactions", ex); }); + this.redis.expire(STORED_FILES_KEY, STORED_FILES_EXPIRE_AFTER).catch((ex) => { + log.warn(`Failed to set expiry time on ${STORED_FILES_KEY}`, ex); + }); } public async connect(): Promise { @@ -181,4 +186,12 @@ export class RedisStorageProvider extends RedisStorageContextualProvider impleme } return new RedisStorageContextualProvider(this.redis, newContext.join(".")); } + + public async getStoredTempFile(key: string) { + return this.redis.get(STORED_FILES_KEY + key); + } + + public async setStoredTempFile(key: string, value: string) { + await this.redis.set(STORED_FILES_KEY + key, value); + } } diff --git a/src/Stores/StorageProvider.ts b/src/Stores/StorageProvider.ts index 75ebf490..09e5c81e 100644 --- a/src/Stores/StorageProvider.ts +++ b/src/Stores/StorageProvider.ts @@ -12,4 +12,6 @@ export interface IBridgeStorageProvider extends IAppserviceStorageProvider, ISto getPRReviewData(repo: string, issueNumber: string, scope?: string): Promise; setFigmaCommentEventId(roomId: string, figmaCommentId: string, eventId: string): Promise; getFigmaCommentEventId(roomId: string, figmaCommentId: string): Promise; + getStoredTempFile(key: string): Promise; + setStoredTempFile(key: string, value: string): Promise; } \ No newline at end of file diff --git a/src/UserTokenStore.ts b/src/UserTokenStore.ts index ff4e8468..f0ca68dd 100644 --- a/src/UserTokenStore.ts +++ b/src/UserTokenStore.ts @@ -7,7 +7,7 @@ import { Logger } from "matrix-appservice-bridge"; import { isJiraCloudInstance, JiraClient } from "./Jira/Client"; import { JiraStoredToken } from "./Jira/Types"; import { BridgeConfig, BridgeConfigJira, BridgeConfigJiraOnPremOAuth, BridgePermissionLevel } from "./Config/Config"; -import { v4 as uuid } from "uuid"; +import { randomUUID } from 'node:crypto'; import { GitHubOAuthToken } from "./Github/Types"; import { ApiError, ErrCode } from "./api"; import { JiraOAuth } from "./Jira/OAuth"; @@ -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"); -type TokenType = "github"|"gitlab"|"jira"; -const AllowedTokenTypes = ["github", "gitlab", "jira"]; +export type TokenType = "github"|"gitlab"|"jira"; +export const AllowedTokenTypes = ["github", "gitlab", "jira"]; interface StoredTokenData { encrypted: string|string[]; @@ -107,6 +107,9 @@ export class UserTokenStore extends TypedEmitter { } public async clearUserToken(type: TokenType, userId: string, instanceUrl?: string): Promise { + if (!AllowedTokenTypes.includes(type)) { + throw Error('Unknown token type'); + } const key = tokenKey(type, userId, false, instanceUrl); const obj = await this.intent.underlyingClient.getSafeAccountData(key); if (!obj || "deleted" in obj) { @@ -254,7 +257,7 @@ export class UserTokenStore extends TypedEmitter { } public createStateForOAuth(userId: string): string { - const state = uuid(); + const state = randomUUID(); this.oauthSessionStore.set(state, { userId, timeout: setTimeout(() => this.oauthSessionStore.delete(state), OAUTH_TIMEOUT_MS), diff --git a/src/Webhooks.ts b/src/Webhooks.ts index a4474c00..612dc836 100644 --- a/src/Webhooks.ts +++ b/src/Webhooks.ts @@ -1,8 +1,9 @@ +/* eslint-disable camelcase */ import { BridgeConfig } from "./Config/Config"; import { Router, default as express, Request, Response } from "express"; import { EventEmitter } from "events"; import { MessageQueue, createMessageQueue } from "./MessageQueue"; -import { Logger } from "matrix-appservice-bridge"; +import { ApiError, ErrCode, Logger } from "matrix-appservice-bridge"; import qs from "querystring"; import axios from "axios"; import { IGitLabWebhookEvent, IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookReleaseEvent } from "./Gitlab/WebhookTypes"; @@ -185,52 +186,77 @@ export class Webhooks extends EventEmitter { } } - public async onGitHubGetOauth(req: Request , res: Response) { - log.info(`Got new oauth request`, { state: req.query.state }); + public async onGitHubGetOauth(req: Request , res: Response) { + const oauthUrl = this.config.widgets && new URL("oauth.html", this.config.widgets.parsedPublicUrl); + if (oauthUrl) { + oauthUrl.searchParams.set('service', 'github'); + oauthUrl?.searchParams.set('oauth-kind', 'account'); + } + const { setup_action, state } = req.query; + log.info("Got new oauth request", { state, setup_action }); try { if (!this.config.github || !this.config.github.oauth) { - return res.status(500).send(`

Bridge is not configured with OAuth support

`); + throw new ApiError('Bridge is not configured with OAuth support', ErrCode.DisabledFeature); } if (req.query.error) { - return res.status(500).send(`

GitHub Error: ${req.query.error} ${req.query.error_description}

`); + throw new ApiError(`GitHub Error: ${req.query.error} ${req.query.error_description}`, ErrCode.Unknown); } - if (!req.query.state) { - return res.status(400).send(`

Missing state

`); + if (setup_action !== 'install') { + if (!state) { + throw new ApiError(`Missing state`, ErrCode.BadValue); + } + if (!req.query.code) { + throw new ApiError(`Missing code`, ErrCode.BadValue); + } + const exists = await this.queue.pushWait({ + eventName: "github.oauth.response", + sender: "GithubWebhooks", + data: { + state, + }, + }); + if (!exists) { + throw new ApiError(`Could not find user which authorised this request. Has it timed out?`, undefined, 404); + } + const accessTokenUrl = GithubInstance.generateOAuthUrl(this.config.github.baseUrl, "access_token", { + client_id: this.config.github.oauth.client_id, + client_secret: this.config.github.oauth.client_secret, + code: req.query.code as string, + redirect_uri: this.config.github.oauth.redirect_uri, + state: req.query.state as string, + }); + const accessTokenRes = await axios.post(accessTokenUrl); + const result = qs.parse(accessTokenRes.data) as GitHubOAuthTokenResponse|{error: string, error_description: string, error_uri: string}; + if ("error" in result) { + throw new ApiError(`GitHub Error: ${result.error} ${result.error_description}`, ErrCode.Unknown); + } + await this.queue.push({ + eventName: "github.oauth.tokens", + sender: "GithubWebhooks", + data: { ...result, state: req.query.state as string }, + }); + } else if (oauthUrl) { + // App install. + oauthUrl.searchParams.set('oauth-kind', 'organisation'); } - if (!req.query.code) { - return res.status(400).send(`

Missing code

`); - } - const exists = await this.queue.pushWait({ - eventName: "github.oauth.response", - sender: "GithubWebhooks", - data: { - state: req.query.state, - }, - }); - if (!exists) { - return res.status(404).send(`

Could not find user which authorised this request. Has it timed out?

`); - } - const accessTokenUrl = GithubInstance.generateOAuthUrl(this.config.github.baseUrl, "access_token", { - client_id: this.config.github.oauth.client_id, - client_secret: this.config.github.oauth.client_secret, - code: req.query.code as string, - redirect_uri: this.config.github.oauth.redirect_uri, - state: req.query.state as string, - }); - const accessTokenRes = await axios.post(accessTokenUrl); - const result = qs.parse(accessTokenRes.data) as GitHubOAuthTokenResponse|{error: string, error_description: string, error_uri: string}; - if ("error" in result) { - return res.status(500).send(`

GitHub Error: ${result.error} ${result.error_description}

`); - } - await this.queue.push({ - eventName: "github.oauth.tokens", - sender: "GithubWebhooks", - data: { ...result, state: req.query.state as string }, - }); - return res.send(`

Your account has been bridged

`); } catch (ex) { - log.error("Failed to handle oauth request:", ex); - return res.status(500).send(`

Encountered an error handing oauth request

`); + if (ex instanceof ApiError) { + if (oauthUrl) { + oauthUrl?.searchParams.set('error', ex.error); + oauthUrl?.searchParams.set('errcode', ex.errcode); + } else { + return res.status(ex.statusCode).send(ex.message); + } + } else { + log.error("Failed to handle oauth request:", ex); + return res.status(500).send('Failed to handle oauth request'); + } + } + if (oauthUrl) { + // If we're serving widgets, do something prettier. + return res.redirect(oauthUrl.toString()); + } else { + return res.send(`

Your account has been bridged

`); } } diff --git a/src/Widgets/BridgeWidgetApi.ts b/src/Widgets/BridgeWidgetApi.ts index 5f7ef991..f29d8530 100644 --- a/src/Widgets/BridgeWidgetApi.ts +++ b/src/Widgets/BridgeWidgetApi.ts @@ -3,24 +3,34 @@ import { AdminRoom } from "../AdminRoom"; import { Logger } from "matrix-appservice-bridge"; import { ApiError, ErrCode } from "../api"; import { BridgeConfig } from "../Config/Config"; -import { GetConnectionsForServiceResponse } from "./BridgeWidgetInterface"; +import { GetAuthPollResponse, GetAuthResponse, GetConnectionsForServiceResponse } from "./BridgeWidgetInterface"; import { ProvisioningApi, ProvisioningRequest } from "matrix-appservice-bridge"; import { IBridgeStorageProvider } from "../Stores/StorageProvider"; import { ConnectionManager } from "../ConnectionManager"; +import BotUsersManager, {BotUser} from "../Managers/BotUsersManager"; import { assertUserPermissionsInRoom, GetConnectionsResponseItem } from "../provisioning/api"; -import { Intent, PowerLevelsEvent } from "matrix-bot-sdk"; +import { Appservice, PowerLevelsEvent } from "matrix-bot-sdk"; +import { GoNebMigrator } from "./GoNebMigrator"; +import { StatusCodes } from "http-status-codes"; +import { GithubInstance } from '../Github/GithubInstance'; +import { AllowedTokenTypes, TokenType, UserTokenStore } from '../UserTokenStore'; const log = new Logger("BridgeWidgetApi"); export class BridgeWidgetApi { private readonly api: ProvisioningApi; + private readonly goNebMigrator?: GoNebMigrator; + constructor( private adminRooms: Map, private readonly config: BridgeConfig, storageProvider: IBridgeStorageProvider, expressApp: Application, private readonly connMan: ConnectionManager, - private readonly intent: Intent, + private readonly botUsersManager: BotUsersManager, + private readonly as: Appservice, + private readonly tokenStore: UserTokenStore, + private readonly github?: GithubInstance, ) { this.api = new ProvisioningApi( storageProvider, @@ -52,6 +62,45 @@ export class BridgeWidgetApi { this.api.addRoute("patch", '/v1/:roomId/connections/:connectionId', wrapHandler(this.updateConnection)); this.api.addRoute("delete", '/v1/:roomId/connections/:connectionId', wrapHandler(this.deleteConnection)); this.api.addRoute("get", '/v1/targets/:type', wrapHandler(this.getConnectionTargets)); + this.api.addRoute('get', '/v1/service/:service/auth', wrapHandler(this.getAuth)); + this.api.addRoute('get', '/v1/service/:service/auth/:state', wrapHandler(this.getAuthPoll)); + this.api.addRoute('post', '/v1/service/:service/auth/logout', wrapHandler(this.postAuthLogout)); + + if (this.config.goNebMigrator) { + this.goNebMigrator = new GoNebMigrator( + this.config.goNebMigrator.apiUrl, + this.config.goNebMigrator.serviceIds, + ); + } + + this.api.addRoute("get", "/v1/:roomId/goNebConnections", wrapHandler(this.getGoNebConnections)); + } + + private async getGoNebConnections(req: ProvisioningRequest, res: Response) { + if (!this.goNebMigrator) { + res.status(StatusCodes.NO_CONTENT).send(); + return; + } + + const roomId = req.params.roomId; + + if (!req.userId) { + throw Error('Cannot get connections without a valid userId'); + } + + const botUser = this.getBotUserInRoom(roomId); + await assertUserPermissionsInRoom(req.userId, roomId, "read", botUser.intent); + const connections = await this.goNebMigrator.getConnectionsForRoom(roomId, req.userId); + + res.send(connections); + } + + private getBotUserInRoom(roomId: string, serviceType?: string): BotUser { + const botUser = this.botUsersManager.getBotUserInRoom(roomId, serviceType); + if (!botUser) { + throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom); + } + return botUser; } private async getRoomFromRequest(req: ProvisioningRequest): Promise { @@ -85,23 +134,31 @@ export class BridgeWidgetApi { } private async getServiceConfig(req: ProvisioningRequest, res: Response>) { - res.send(this.config.getPublicConfigForService(req.params.service)); + // GitHub is a special case because it depends on live config. + if (req.params.service === 'github') { + res.send(this.config.github?.publicConfig(this.github)); + } else { + res.send(this.config.getPublicConfigForService(req.params.service)); + } } private async getConnectionsForRequest(req: ProvisioningRequest) { if (!req.userId) { throw Error('Cannot get connections without a valid userId'); } - await assertUserPermissionsInRoom(req.userId, req.params.roomId as string, "read", this.intent); - const allConnections = this.connMan.getAllConnectionsForRoom(req.params.roomId as string); - const powerlevel = new PowerLevelsEvent({content: await this.intent.underlyingClient.getRoomStateEvent(req.params.roomId, "m.room.power_levels", "")}); + const roomId = req.params.roomId; + const serviceType = req.params.service; + + const botUser = this.getBotUserInRoom(roomId, serviceType); + await assertUserPermissionsInRoom(req.userId, roomId, "read", botUser.intent); + const allConnections = this.connMan.getAllConnectionsForRoom(roomId); + const powerlevel = new PowerLevelsEvent({content: await botUser.intent.underlyingClient.getRoomStateEvent(roomId, "m.room.power_levels", "")}); const serviceFilter = req.params.service; const connections = allConnections.map(c => c.getProvisionerDetails?.(true)) .filter(c => !!c) // If we have a service filter. .filter(c => typeof serviceFilter !== "string" || c?.service === serviceFilter) as GetConnectionsResponseItem[]; const userPl = powerlevel.content.users?.[req.userId] || powerlevel.defaultUserLevel; - for (const c of connections) { const requiredPl = Math.max(powerlevel.content.events?.[c.type] || 0, powerlevel.defaultStateEventLevel); c.canEdit = userPl >= requiredPl; @@ -112,7 +169,7 @@ export class BridgeWidgetApi { return { connections, - canEdit: userPl >= powerlevel.defaultUserLevel + canEdit: userPl >= powerlevel.defaultStateEventLevel, }; } @@ -128,13 +185,22 @@ export class BridgeWidgetApi { if (!req.userId) { throw Error('Cannot get connections without a valid userId'); } - await assertUserPermissionsInRoom(req.userId, req.params.roomId as string, "write", this.intent); + const roomId = req.params.roomId; + const eventType = req.params.type; + const connectionType = this.connMan.getConnectionTypeForEventType(eventType); + if (!connectionType) { + throw new ApiError("Unknown event type", ErrCode.NotFound); + } + const serviceType = connectionType.ServiceCategory; + + const botUser = this.getBotUserInRoom(roomId, serviceType); + await assertUserPermissionsInRoom(req.userId, roomId, "write", botUser.intent); try { if (!req.body || typeof req.body !== "object") { throw new ApiError("A JSON body must be provided", ErrCode.BadValue); } this.connMan.validateCommandPrefix(req.params.roomId, req.body); - const result = await this.connMan.provisionConnection(req.params.roomId, req.userId, req.params.type, req.body); + const result = await this.connMan.provisionConnection(roomId, botUser.intent, req.userId, connectionType, req.body); if (!result.connection.getProvisionerDetails) { throw new Error('Connection supported provisioning but not getProvisionerDetails'); } @@ -152,15 +218,20 @@ export class BridgeWidgetApi { if (!req.userId) { throw Error('Cannot get connections without a valid userId'); } - await assertUserPermissionsInRoom(req.userId, req.params.roomId as string, "write", this.intent); - const connection = this.connMan.getConnectionById(req.params.roomId as string, req.params.connectionId as string); + const roomId = req.params.roomId; + const serviceType = req.params.type; + const connectionId = req.params.connectionId; + + const botUser = this.getBotUserInRoom(roomId, serviceType); + await assertUserPermissionsInRoom(req.userId, roomId, "write", botUser.intent); + const connection = this.connMan.getConnectionById(roomId, connectionId); if (!connection) { throw new ApiError("Connection does not exist", ErrCode.NotFound); } if (!connection.provisionerUpdateConfig || !connection.getProvisionerDetails) { throw new ApiError("Connection type does not support updates", ErrCode.UnsupportedOperation); } - this.connMan.validateCommandPrefix(req.params.roomId, req.body, connection); + this.connMan.validateCommandPrefix(roomId, req.body, connection); await connection.provisionerUpdateConfig(req.userId, req.body); res.send(connection.getProvisionerDetails(true)); } @@ -169,9 +240,12 @@ export class BridgeWidgetApi { if (!req.userId) { throw Error('Cannot get connections without a valid userId'); } - const roomId = req.params.roomId as string; - const connectionId = req.params.connectionId as string; - await assertUserPermissionsInRoom(req.userId, roomId, "write", this.intent); + const roomId = req.params.roomId; + const serviceType = req.params.type; + const connectionId = req.params.connectionId; + + const botUser = this.getBotUserInRoom(roomId, serviceType); + await assertUserPermissionsInRoom(req.userId, roomId, "write", botUser.intent); const connection = this.connMan.getConnectionById(roomId, connectionId); if (!connection) { throw new ApiError("Connection does not exist", ErrCode.NotFound); @@ -191,4 +265,109 @@ export class BridgeWidgetApi { const connections = await this.connMan.getConnectionTargets(req.userId, type, req.query); res.send(connections); } + + + private async getAuth(req: ProvisioningRequest, res: Response) { + if (!req.userId) { + throw Error('Expected userId on request'); + } + const service = req.params.service; + if (!service) { + throw Error('Expected service in parameters'); + } + + // TODO: Should this be part of the GitHub module code. + if (service === 'github') { + if (!this.config.github || !this.config.github.oauth) { + throw new ApiError('GitHub oauth is not configured', ErrCode.DisabledFeature); + } + + let user; + try { + const octokit = await this.tokenStore.getOctokitForUser(req.userId); + if (octokit !== null) { + const me = await octokit.users.getAuthenticated(); + user = { + name: me.data.login, + }; + } + } catch (e) { + // Need to authenticate + } + + if (user) { + return res.json({ + authenticated: true, + user + }); + } else { + const state = this.tokenStore.createStateForOAuth(req.userId); + const authUrl = GithubInstance.generateOAuthUrl( + this.config.github.baseUrl, + 'authorize', + { + state, + client_id: this.config.github.oauth.client_id, + redirect_uri: this.config.github.oauth.redirect_uri, + } + ); + return res.json({ + authenticated: false, + stateId: state, + authUrl + }); + } + } else { + throw new ApiError('Service not found', ErrCode.NotFound); + } + } + + private async getAuthPoll(req: ProvisioningRequest, res: Response) { + if (!req.userId) { + throw Error('Expected userId on request'); + } + const { service, state } = req.params; + + if (!service) { + throw Error('Expected service in parameters'); + } + + // N.B. Service isn't really used. + const stateUserId = this.tokenStore.getUserIdForOAuthState(state); + + if (!stateUserId || req.userId !== stateUserId) { + // If the state isn't found then either the state has been completed or the key is wrong. + // We don't actually know, so we assume the sender knows what they are doing. + res.send({ + state: 'complete', + }); + return; + } + res.send({ + state: 'waiting', + }); + return; + } + + private async postAuthLogout(req: ProvisioningRequest, res: Response<{ok: true}>) { + if (!req.userId) { + throw Error('Expected userId on request'); + } + const { service } = req.params; + + if (!service) { + throw Error('Expected service in parameters'); + } + + if (AllowedTokenTypes.includes(service)) { + const result = await this.tokenStore.clearUserToken(service as TokenType, req.userId); + if (result) { + res.send({ok: true}); + } else { + throw new ApiError("You are not logged in", ErrCode.NotFound); + } + } else { + throw new ApiError('Service not found', ErrCode.NotFound); + } + } } diff --git a/src/Widgets/BridgeWidgetInterface.ts b/src/Widgets/BridgeWidgetInterface.ts index 3fcb390a..aa292c27 100644 --- a/src/Widgets/BridgeWidgetInterface.ts +++ b/src/Widgets/BridgeWidgetInterface.ts @@ -36,3 +36,24 @@ export interface GetConnectionsForServiceResponse { + const feedsPerRoom = new Map(); + + for (const [url, config] of Object.entries(goNebFeeds)) { + for (const roomId of config.rooms) { + const existing = feedsPerRoom.get(roomId) ?? []; + existing.push({ url }); + feedsPerRoom.set(roomId, existing); + } + } + + return feedsPerRoom; + } + + static convertGithub(roomRepos: GoNebGithubRepos): GitHubRepoConnectionState[] { + const eventMapping: { [goNebEvent: string]: GitHubAllowedEventsNames } = { + 'pull_request': 'pull_request', + 'issues': 'issue', + // 'push': ??? + }; + return Object.entries(roomRepos).map(([githubPath, { Events }]) => { + const [org, repo] = githubPath.split('/'); + const enableHooks = Events.map(goNebEvent => eventMapping[goNebEvent]).filter(e => !!e); + + return { + org, + repo, + enableHooks, + }; + }); + } + + public async getConnectionsForRoom(roomId: string, userId: string): Promise { + const feeds: MigratedFeed[] = []; + const github: MigratedGithub[] = []; + + const serviceIds = [ + ...(this.serviceIds ?? []), + ...['rssbot', 'github'].map(type => `${type}/${strictEncodeURIComponent(userId)}/${strictEncodeURIComponent(roomId)}`), + ]; + + for (const id of serviceIds) { + const endpoint = this.apiUrl + (this.apiUrl.endsWith('/') ? '' : '/') + 'admin/getService'; + let obj: GoNebService; + try { + const res = await axios.post(endpoint, { 'Id': id }); + obj = res.data as GoNebService; + } catch (err: unknown) { + if (axios.isAxiosError(err)) { + if (err.response?.status === 404) { + continue; + } + } + + throw err; + } + switch (obj.Type) { + case 'rssbot': { + const roomFeeds = GoNebMigrator.convertFeeds(obj.Config.feeds).get(roomId) ?? []; + const migratedFeeds = roomFeeds.map(f => ({ ...f, goNebId: id })); + feeds.push(...migratedFeeds); + break; + } + case 'github-webhook': { + const service = obj as GoNebGithubWebhookService; + if (service.Config.ClientUserID === userId) { + const roomRepos = service.Config.Rooms[roomId]?.Repos; + if (roomRepos) { + const githubConnections = GoNebMigrator.convertGithub(roomRepos); + const migratedGithubs = githubConnections.map(f => ({ ...f, goNebId: id })); + github.push(...migratedGithubs); + } + } + break; + } + default: { + log.warn(`Unrecognized go-neb service type (${obj.Type}), skipping`); + } + } + } + + return { + feeds, + github, + }; + } +} + +// from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#encoding_for_rfc3986 +function strictEncodeURIComponent(str: string) { + return encodeURIComponent(str) + .replace( + /[!'()*]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}` + ); +} diff --git a/src/Widgets/SetupWidget.ts b/src/Widgets/SetupWidget.ts index ad97c02d..64b00d29 100644 --- a/src/Widgets/SetupWidget.ts +++ b/src/Widgets/SetupWidget.ts @@ -16,15 +16,31 @@ export class SetupWidget { return false; } - static async SetupRoomConfigWidget(roomId: string, botIntent: Intent, config: BridgeWidgetConfig): Promise { - if (await SetupWidget.createWidgetInRoom(roomId, botIntent, config, HookshotWidgetKind.RoomConfiguration, "hookshot_room_config")) { + static async SetupRoomConfigWidget(roomId: string, botIntent: Intent, config: BridgeWidgetConfig, serviceTypes: string[]): Promise { + // If this is for a single service, scope the widget + const serviceScope = serviceTypes.length === 1 ? serviceTypes[0] : undefined; + if (await SetupWidget.createWidgetInRoom( + roomId, + botIntent, + config, + HookshotWidgetKind.RoomConfiguration, + `hookshot_room_config_${config.parsedPublicUrl.host}${serviceScope ? '_' + serviceScope : ''}`, + serviceScope, + )) { await botIntent.sendText(roomId, `Please open the ${config.branding.widgetTitle} widget to set up integrations.`); return true; } return false; } - private static async createWidgetInRoom(roomId: string, botIntent: Intent, config: BridgeWidgetConfig, kind: HookshotWidgetKind, stateKey: string): Promise { + private static async createWidgetInRoom( + roomId: string, + botIntent: Intent, + config: BridgeWidgetConfig, + kind: HookshotWidgetKind, + stateKey: string, + serviceScope?: string, + ): Promise { log.info(`Running SetupRoomConfigWidget for ${roomId}`); if (!await botIntent.underlyingClient.userHasPowerLevelFor(botIntent.userId, roomId, "im.vector.modular.widgets", true)) { throw new CommandError("Bot lacks power level to set room state", "I do not have permission to create a widget in this room. Please promote me to an Admin/Moderator."); @@ -53,15 +69,15 @@ export class SetupWidget { { "creatorUserId": botIntent.userId, "data": { - "title": config.branding.widgetTitle + "title": serviceScope ? serviceScope : config.branding.widgetTitle, }, "id": stateKey, "name": config.branding.widgetTitle, "type": "m.custom", - "url": new URL(`#/?kind=${kind}&roomId=$matrix_room_id&widgetId=$matrix_widget_id`, config.parsedPublicUrl).href, + "url": new URL(`#/?kind=${kind}&roomId=$matrix_room_id&widgetId=$matrix_widget_id${serviceScope ? `&serviceScope=${serviceScope}` : ''}`, config.parsedPublicUrl).href, "waitForIframeLoad": true, } ); return true; } -} \ No newline at end of file +} diff --git a/src/grants/GrantCheck.ts b/src/grants/GrantCheck.ts new file mode 100644 index 00000000..b3eaa49f --- /dev/null +++ b/src/grants/GrantCheck.ts @@ -0,0 +1,114 @@ +import { Logger } from "matrix-appservice-bridge"; +import { Appservice, Intent, MatrixError } from "matrix-bot-sdk"; +import { BridgeConfig, BridgePermissionLevel } from "../Config/Config"; +import { FormatUtil } from "../FormatUtil"; + +const GRANT_ACCOUNT_DATA_KEY = "uk.half-shot.matrix-hookshot.grant"; + +interface GrantContent { + granted: boolean; +} + +const log = new Logger("GrantChecker"); + +export class GrantRejectedError extends Error { + constructor(public readonly roomId: string, public readonly connectionId: string) { + super(`No grant exists for ${roomId}/${connectionId}. Rejecting`); + } +} + + +type ConnectionId = string|object; + +export class GrantChecker { + private static stringifyConnectionId(connId: cId) { + if (typeof connId === "string") { + return FormatUtil.hashId(connId.toString()); + } + return FormatUtil.hashId(Object.entries(connId as Record).map((data) => `${data[0]}:${data[1]}`).join('')); + } + + constructor(private readonly intent: Intent, protected readonly grantType: string) { } + + /** + * If the connection hasn't been previously granted, we can use this function to check + * their permissions in the moment. + * + * By default, this always returns false. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected checkFallback(_roomId: string, _connectionId: cId, _sender?: string): Promise|boolean { + return false; + } + + private getKey(connectionIdStr: string): string { + return `${GRANT_ACCOUNT_DATA_KEY}/${this.grantType}/${connectionIdStr}`.toLowerCase(); + } + + public async assertConnectionGranted(roomId: string, connectionId: cId, sender?: string) { + const connId = GrantChecker.stringifyConnectionId(connectionId); + try { + const content = await this.intent.underlyingClient.getRoomAccountData(this.getKey(connId), roomId); + if (!content.granted) { + // Previously granted but now stale. + throw new GrantRejectedError(roomId, connId); + } + } catch (ex) { + if (ex instanceof MatrixError && ex.errcode === "M_NOT_FOUND") { + if (!await this.checkFallback?.(roomId, connectionId, sender)) { + throw new GrantRejectedError(roomId, connId); + } else { + log.info(`Grant fallback succeeded for ${roomId}/${connectionId}`); + await this.grantConnection(roomId, connectionId); + } + } else { + log.warn(`Failed to check grant in ${roomId}/${connectionId}`, ex); + throw new GrantRejectedError(roomId, connId); + } + } + } + + public async grantConnection(roomId: string, connectionId: cId) { + const cidStr = GrantChecker.stringifyConnectionId(connectionId); + log.info(`Granting ${roomId}/${cidStr}`); + await this.intent.underlyingClient.setRoomAccountData( + this.getKey(cidStr), + roomId, + { granted: true } as GrantContent + ); + } + + public async ungrantConnection(roomId: string, connectionId: cId) { + const cidStr = GrantChecker.stringifyConnectionId(connectionId); + log.info(`Ungranting ${roomId}/${cidStr}`); + await this.intent.underlyingClient.setRoomAccountData( + this.getKey(cidStr), + roomId, + { granted: false } as GrantContent + ); + } +} + +/** + * Check the grant of a given connection, falling back to checking the permissions of the user + * across the bridge. + */ +export class ConfigGrantChecker extends GrantChecker { + static ConfigMinAccessLevel = BridgePermissionLevel.admin; + + constructor(grantType: string, private readonly as: Appservice, private readonly config: BridgeConfig) { + super(as.botIntent, grantType) + } + + protected checkFallback(_roomId: string, _connectionId: cId, sender?: string) { + if (!sender) { + // Cannot validate without a sender. + return false; + } + if (this.as.isNamespacedUser(sender)) { + // Bridge is always valid. + return true; + } + return this.config.checkPermission(sender, this.grantType, ConfigGrantChecker.ConfigMinAccessLevel); + } +} \ No newline at end of file diff --git a/src/provisioning/provisioner.ts b/src/provisioning/provisioner.ts index 005ac944..eac23c88 100644 --- a/src/provisioning/provisioner.ts +++ b/src/provisioning/provisioner.ts @@ -4,8 +4,9 @@ import { ConnectionManager } from "../ConnectionManager"; import { Logger } from "matrix-appservice-bridge"; import { assertUserPermissionsInRoom, GetConnectionsResponseItem, GetConnectionTypeResponseItem } from "./api"; import { ApiError, ErrCode } from "../api"; -import { Intent } from "matrix-bot-sdk"; +import { Appservice } from "matrix-bot-sdk"; import Metrics from "../Metrics"; +import BotUsersManager from "../Managers/BotUsersManager"; const log = new Logger("Provisioner"); @@ -19,7 +20,8 @@ export class Provisioner { constructor( private readonly config: BridgeConfigProvisioning, private readonly connMan: ConnectionManager, - private readonly intent: Intent, + private readonly botUsersManager: BotUsersManager, + private readonly as: Appservice, additionalRoutes: {route: string, router: Router}[]) { if (!this.config.secret) { throw Error('Missing secret in provisioning config'); @@ -96,8 +98,14 @@ export class Provisioner { private async checkUserPermission(requiredPermission: "read"|"write", req: Request<{roomId: string}, unknown, unknown, {userId: string}>, res: Response, next: NextFunction) { const userId = req.query.userId; const roomId = req.params.roomId; + + const botUser = this.botUsersManager.getBotUserInRoom(roomId); + if (!botUser) { + throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom); + } + try { - await assertUserPermissionsInRoom(userId, roomId, requiredPermission, this.intent); + await assertUserPermissionsInRoom(userId, roomId, requiredPermission, botUser.intent); next(); } catch (ex) { next(ex); @@ -130,22 +138,38 @@ export class Provisioner { } private async putConnection(req: Request<{roomId: string, type: string}, unknown, Record, {userId: string}>, res: Response, next: NextFunction) { + const roomId = req.params.roomId; + const userId = req.query.userId; + const eventType = req.params.type; + const connectionType = this.connMan.getConnectionTypeForEventType(eventType); + if (!connectionType) { + throw new ApiError("Unknown event type", ErrCode.NotFound); + } + const serviceType = connectionType.ServiceCategory; + // Need to figure out which connections are available try { if (!req.body || typeof req.body !== "object") { throw new ApiError("A JSON body must be provided", ErrCode.BadValue); } - this.connMan.validateCommandPrefix(req.params.roomId, req.body); - const result = await this.connMan.provisionConnection(req.params.roomId, req.query.userId, req.params.type, req.body); + this.connMan.validateCommandPrefix(roomId, req.body); + + const botUser = this.botUsersManager.getBotUserInRoom(roomId, serviceType); + if (!botUser) { + throw new ApiError("Bot is not joined to the room.", ErrCode.NotInRoom); + } + + const result = await this.connMan.provisionConnection(roomId, botUser.intent, userId, connectionType, req.body); if (!result.connection.getProvisionerDetails) { throw new Error('Connection supported provisioning but not getProvisionerDetails'); } + res.send({ ...result.connection.getProvisionerDetails(true), warning: result.warning, }); } catch (ex) { - log.error(`Failed to create connection for ${req.params.roomId}`, ex); + log.error(`Failed to create connection for ${roomId}`, ex); return next(ex); } } diff --git a/tests/AdminRoomTest.ts b/tests/AdminRoomTest.ts index 236c5b2d..56698c3d 100644 --- a/tests/AdminRoomTest.ts +++ b/tests/AdminRoomTest.ts @@ -10,7 +10,7 @@ import { IntentMock } from "./utils/IntentMock"; const ROOM_ID = "!foo:bar"; function createAdminRoom(data: any = {admin_user: "@admin:bar"}): [AdminRoom, IntentMock] { - const intent = IntentMock.create(); + const intent = IntentMock.create("@admin:bar"); if (!data.admin_user) { data.admin_user = "@admin:bar"; } @@ -28,4 +28,4 @@ describe("AdminRoom", () => { content: AdminRoom.helpMessage(undefined, ["Github", "Gitlab", "Jira"]), }); }); -}) \ No newline at end of file +}) diff --git a/tests/HookFilter.ts b/tests/HookFilter.ts index 83888962..f5e8821d 100644 --- a/tests/HookFilter.ts +++ b/tests/HookFilter.ts @@ -3,29 +3,51 @@ import { HookFilter } from '../src/HookFilter'; const DEFAULT_SET = ['default-allowed', 'default-allowed-but-ignored']; const ENABLED_SET = ['enabled-hook', 'enabled-but-ignored']; -const IGNORED_SET = ['ignored', 'enabled-but-ignored', 'default-allowed-but-ignored']; describe("HookFilter", () => { let filter: HookFilter; beforeEach(() => { - filter = new HookFilter(DEFAULT_SET, ENABLED_SET, IGNORED_SET); + filter = new HookFilter(ENABLED_SET); }); - it('should skip a hook named in ignoreHooks', () => { - expect(filter.shouldSkip('ignored')).to.be.true; + describe('shouldSkip', () => { + it('should allow a hook named in enabled set', () => { + expect(filter.shouldSkip('enabled-hook')).to.be.false; + }); + it('should not allow a hook not named in enabled set', () => { + expect(filter.shouldSkip('not-enabled-hook')).to.be.true; + }); }); - it('should allow a hook named in defaults', () => { - expect(filter.shouldSkip('default-allowed')).to.be.false; - }); - it('should allow a hook named in enabled', () => { - expect(filter.shouldSkip('enabled-hook')).to.be.false; - }); - it('should skip a hook named in defaults but also in ignored', () => { - expect(filter.shouldSkip('default-allowed-but-ignored')).to.be.true; - }); - it('should skip a hook named in enabled but also in ignored', () => { - expect(filter.shouldSkip('enabled-but-ignored')).to.be.true; - }); - it('should skip if any hooks are in ignored', () => { - expect(filter.shouldSkip('enabled-hook', 'enabled-but-ignored')).to.be.true; + + describe('convertIgnoredHooksToEnabledHooks', () => { + it('should correctly provide a list of default hooks', () => { + expect(HookFilter.convertIgnoredHooksToEnabledHooks([], [], DEFAULT_SET)).to.have.members(DEFAULT_SET); + }); + + it('should correctly include default and enabled hooks when ignored hooks is set', () => { + expect(HookFilter.convertIgnoredHooksToEnabledHooks(ENABLED_SET, ['my-ignored-hook'], DEFAULT_SET)).to.have.members([ + ...ENABLED_SET, ...DEFAULT_SET + ]); + }); + + it('should deduplicate', () => { + expect(HookFilter.convertIgnoredHooksToEnabledHooks(DEFAULT_SET, [], DEFAULT_SET)).to.have.members(DEFAULT_SET); + }); + + it('should correctly exclude ignored hooks', () => { + expect(HookFilter.convertIgnoredHooksToEnabledHooks([], [DEFAULT_SET[0]], DEFAULT_SET)).to.not.include([ + DEFAULT_SET[0] + ]); + }); + + it('should handle ignored root hooks', () => { + const defaultHooks = ['myhook', 'myhook.foo', 'myhook.foo.bar']; + expect(HookFilter.convertIgnoredHooksToEnabledHooks([], ['myhook.foo.bar'], defaultHooks)).to.have.members([ + 'myhook', 'myhook.foo' + ]); + expect(HookFilter.convertIgnoredHooksToEnabledHooks([], ['myhook.foo'], defaultHooks)).to.have.members([ + 'myhook' + ]); + expect(HookFilter.convertIgnoredHooksToEnabledHooks([], ['myhook'], defaultHooks)).to.be.empty; + }); }); }); diff --git a/tests/IntentUtilsTest.ts b/tests/IntentUtilsTest.ts new file mode 100644 index 00000000..1fcae634 --- /dev/null +++ b/tests/IntentUtilsTest.ts @@ -0,0 +1,58 @@ +import { IntentMock, MatrixClientMock } from "./utils/IntentMock"; +import { ensureUserIsInRoom } from "../src/IntentUtils"; +import { expect } from "chai"; +import { MatrixError } from "matrix-bot-sdk"; + +const ROOM_ID = "!foo:bar"; +const SENDER_USER_ID = "@my_target:foo"; + +describe("IntentUtils", () => { + describe("ensureUserIsInRoom", () => { + it("no-ops if the user is already joined to the room", () => { + const targetIntent = IntentMock.create(SENDER_USER_ID); + targetIntent.ensureJoined = () => { /* No-op */ }; + const matrixClient = MatrixClientMock.create(); + ensureUserIsInRoom(targetIntent, matrixClient, ROOM_ID); + }); + + it("invites the user to the room and joins", () => { + const targetIntent = IntentMock.create(SENDER_USER_ID); + const matrixClient = MatrixClientMock.create(); + let hasInvited = false; + // This should fail the first time, then pass once we've tried to invite the user + targetIntent.ensureJoined = (roomId: string) => { + if (hasInvited) { + return; + } + expect(roomId).to.equal(ROOM_ID); + throw new MatrixError({ errcode: "M_FORBIDDEN", error: "Test forced error"}, 401) + }; + + // This should invite the puppet user. + matrixClient.inviteUser = (userId: string, roomId: string) => { + expect(userId).to.equal(SENDER_USER_ID); + expect(roomId).to.equal(ROOM_ID); + hasInvited = true; + } + + ensureUserIsInRoom(targetIntent, matrixClient, ROOM_ID); + // Only pass if we've actually bothered to invite the bot. + expect(hasInvited).to.be.true; + }); + + it("invites the user to the room and joins", () => { + const targetIntent = IntentMock.create(SENDER_USER_ID); + const matrixClient = MatrixClientMock.create(); + + // This should fail the first time, then pass once we've tried to invite the user + targetIntent.ensureJoined = () => { + throw new MatrixError({ errcode: "FORCED_FAILURE", error: "Test forced error"}, 500) + }; + try { + ensureUserIsInRoom(targetIntent, matrixClient, ROOM_ID); + } catch (ex) { + expect(ex.message).to.contain(`Could not ensure that ${SENDER_USER_ID} is in ${ROOM_ID}`) + } + }); + }) +}); \ No newline at end of file diff --git a/tests/connections/GenericHookTest.ts b/tests/connections/GenericHookTest.ts index 43f35722..9b85ef88 100644 --- a/tests/connections/GenericHookTest.ts +++ b/tests/connections/GenericHookTest.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { expect } from "chai"; +import { MatrixError } from "matrix-bot-sdk"; import { BridgeConfigGenericWebhooks, BridgeGenericWebhooksConfigYAML } from "../../src/Config/Config"; import { GenericHookConnection, GenericHookConnectionState } from "../../src/Connections/GenericHook"; import { MessageSenderClient, IMatrixSendMessage } from "../../src/MatrixSender"; @@ -11,14 +12,35 @@ const ROOM_ID = "!foo:bar"; const V1TFFunction = "result = `The answer to '${data.question}' is ${data.answer}`;"; const V2TFFunction = "result = {plain: `The answer to '${data.question}' is ${data.answer}`, version: 'v2'}"; -function createGenericHook(state: GenericHookConnectionState = { - name: "some-name" -}, config: BridgeGenericWebhooksConfigYAML = { enabled: true, urlPrefix: "https://example.com/webhookurl"}): [GenericHookConnection, LocalMQ] { +async function testSimpleWebhook(connection: GenericHookConnection, mq: LocalMQ, testValue: string) { + const webhookData = {simple: testValue}; + const messagePromise = handleMessage(mq); + await connection.onGenericHook(webhookData); + expect(await messagePromise).to.deep.equal({ + roomId: ROOM_ID, + sender: connection.getUserId(), + content: { + body: "Received webhook data:\n\n```json\n\n{\n \"simple\": \"" + testValue + "\"\n}\n\n```", + format: "org.matrix.custom.html", + formatted_body: "

Received webhook data:

{\n  \"simple\": \"" + testValue + "\"\n}

", + msgtype: "m.notice", + "uk.half-shot.hookshot.webhook_data": webhookData, + }, + type: 'm.room.message', + }); +} + +function createGenericHook( + state: GenericHookConnectionState = { name: "some-name" }, + config: BridgeGenericWebhooksConfigYAML = { enabled: true, urlPrefix: "https://example.com/webhookurl"} +) { const mq = new LocalMQ(); mq.subscribe('*'); const messageClient = new MessageSenderClient(mq); - const connection = new GenericHookConnection(ROOM_ID, state, "foobar", "foobar", messageClient, new BridgeConfigGenericWebhooks(config), AppserviceMock.create()) - return [connection, mq]; + const as = AppserviceMock.create(); + const intent = as.getIntentForUserId('@webhooks:example.test'); + const connection = new GenericHookConnection(ROOM_ID, state, "foobar", "foobar", messageClient, new BridgeConfigGenericWebhooks(config), as, intent); + return [connection, mq, as, intent]; } function handleMessage(mq: LocalMQ): Promise { @@ -30,27 +52,13 @@ function handleMessage(mq: LocalMQ): Promise { data: { 'eventId': '$foo:bar' }, }); r(msg.data as IMatrixSendMessage); - })); + })); } describe("GenericHookConnection", () => { - it("will handle a simple hook event", async () => { - const webhookData = {simple: "data"}; + it("will handle simple hook events", async () => { const [connection, mq] = createGenericHook(); - const messagePromise = handleMessage(mq); - await connection.onGenericHook(webhookData); - expect(await messagePromise).to.deep.equal({ - roomId: ROOM_ID, - sender: connection.getUserId(), - content: { - body: "Received webhook data:\n\n```json\n\n{\n \"simple\": \"data\"\n}\n\n```", - format: "org.matrix.custom.html", - formatted_body: "

Received webhook data:

{\n  \"simple\": \"data\"\n}

", - msgtype: "m.notice", - "uk.half-shot.hookshot.webhook_data": webhookData, - }, - type: 'm.room.message', - }); + await testSimpleWebhook(connection, mq, "data"); }); it("will handle a hook event containing text", async () => { const webhookData = {text: "simple-message"}; @@ -247,6 +255,60 @@ describe("GenericHookConnection", () => { an_array_of: ["1.2345", "6.789"], floats: true, }); + }); + it("should handle simple hook events with user Id prefix", async () => { + const config = { enabled: true, urlPrefix: "https://example.com/webhookurl", userIdPrefix: "_webhooks_"}; + const [connection, mq] = createGenericHook(undefined, config); + await testSimpleWebhook(connection, mq, "data1"); + // regression test covering https://github.com/matrix-org/matrix-hookshot/issues/625 + await testSimpleWebhook(connection, mq, "data2"); + }); + + it("should invite a configured puppet to the room if it's unable to join", async () => { + const senderUserId = "@_webhooks_some-name:example.test"; + const config = { enabled: true, urlPrefix: "https://example.com/webhookurl", userIdPrefix: "_webhooks_"}; + const [connection, mq, as, botIntent] = createGenericHook(undefined, config); + const intent = as.getIntentForUserId(senderUserId); + let hasInvited = false; + + // This should fail the first time, then pass once we've tried to invite the user + intent.ensureJoined = (roomId: string) => { + if (hasInvited) { + return; + } + expect(roomId).to.equal(ROOM_ID); + throw new MatrixError({ errcode: "M_FORBIDDEN", error: "Test forced error"}, 401) + }; + + // This should invite the puppet user. + botIntent.underlyingClient.inviteUser = (userId: string, roomId: string) => { + expect(userId).to.equal(senderUserId); + expect(roomId).to.equal(ROOM_ID); + hasInvited = true; + } + + // regression test covering https://github.com/matrix-org/matrix-hookshot/issues/625 + await testSimpleWebhook(connection, mq, "data1"); + // Only pass if we've actually bothered to invite the bot. + expect(hasInvited).to.be.true; + }); + + it("should fail a message if a bot cannot join a room", async () => { + const senderUserId = "@_webhooks_some-name:example.test"; + const config = { enabled: true, urlPrefix: "https://example.com/webhookurl", userIdPrefix: "_webhooks_"}; + const [connection, mq, as] = createGenericHook(undefined, config); + const intent = as.getIntentForUserId(senderUserId); + + // This should fail the first time, then pass once we've tried to invite the user + intent.ensureJoined = () => { + throw new MatrixError({ errcode: "FORCED_FAILURE", error: "Test forced error"}, 500) + }; + try { + // regression test covering https://github.com/matrix-org/matrix-hookshot/issues/625 + await testSimpleWebhook(connection, mq, "data1"); + } catch (ex) { + expect(ex.message).to.contain(`Could not ensure that ${senderUserId} is in ${ROOM_ID}`) + } }); }) diff --git a/tests/connections/GithubRepoTest.ts b/tests/connections/GithubRepoTest.ts index dd871fb8..5213b796 100644 --- a/tests/connections/GithubRepoTest.ts +++ b/tests/connections/GithubRepoTest.ts @@ -5,6 +5,7 @@ import { UserTokenStore } from "../../src/UserTokenStore"; import { DefaultConfig } from "../../src/Config/Defaults"; import { AppserviceMock } from "../utils/AppserviceMock"; import { ApiError, ErrCode, ValidatorApiError } from "../../src/api"; +import { expect } from "chai"; const ROOM_ID = "!foo:bar"; @@ -39,10 +40,12 @@ function createConnection(state: Record = {}, isExistingState=f }); mq.subscribe('*'); const as = AppserviceMock.create(); + const intent = as.getIntentForUserId('@github:example.test'); const githubInstance = new GithubInstance("foo", "bar", new URL("https://github.com")); const connection = new GitHubRepoConnection( ROOM_ID, as, + intent, GitHubRepoConnection.validateState({ org: "a-fake-org", repo: "a-fake-repo", @@ -55,7 +58,7 @@ function createConnection(state: Record = {}, isExistingState=f // eslint-disable-next-line @typescript-eslint/no-non-null-assertion DefaultConfig.github! ); - return {connection, as}; + return {connection, intent}; } describe("GitHubRepoConnection", () => { @@ -64,7 +67,7 @@ describe("GitHubRepoConnection", () => { GitHubRepoConnection.validateState({ org: "foo", repo: "bar", - ignoreHooks: ["issue", "pull_request", "release"], + enableHooks: ["issue", "pull_request", "release"], commandPrefix: "!foo", showIssueRoomLink: true, prDiff: { @@ -81,6 +84,16 @@ describe("GitHubRepoConnection", () => { } } as GitHubRepoConnectionState as unknown as Record); }); + it("will convert ignoredHooks for existing state", () => { + const state = GitHubRepoConnection.validateState({ + org: "foo", + repo: "bar", + ignoreHooks: ["issue"], + enableHooks: ["issue", "pull_request", "release"], + commandPrefix: "!foo", + } as GitHubRepoConnectionState as unknown as Record, true); + expect(state.enableHooks).to.not.contain('issue'); + }); it("will disallow invalid state", () => { try { GitHubRepoConnection.validateState({ @@ -93,12 +106,12 @@ describe("GitHubRepoConnection", () => { } } }); - it("will disallow ignoreHooks to contains invalid enums if this is new state", () => { + it("will disallow enabledHooks to contains invalid enums if this is new state", () => { try { GitHubRepoConnection.validateState({ org: "foo", repo: "bar", - ignoreHooks: ["issue", "pull_request", "release", "not-real"], + enabledHooks: ["not-real"], }, false); } catch (ex) { if (ex instanceof ApiError === false || ex.errcode !== ErrCode.BadValue) { @@ -106,25 +119,25 @@ describe("GitHubRepoConnection", () => { } } }); - it("will allow ignoreHooks to contains invalid enums if this is old state", () => { + it("will allow enabledHooks to contains invalid enums if this is old state", () => { GitHubRepoConnection.validateState({ org: "foo", repo: "bar", - ignoreHooks: ["issue", "pull_request", "release", "not-real"], + enabledHooks: ["not-real"], }, true); }); }); describe("onIssueCreated", () => { it("will handle a simple issue", async () => { - const { connection, as } = createConnection(); + const { connection, intent } = createConnection(); await connection.onIssueCreated(GITHUB_ISSUE_CREATED_PAYLOAD as never); // Statement text. - as.botIntent.expectEventBodyContains('**alice** created new issue', 0); - as.botIntent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.html_url, 0); - as.botIntent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.title, 0); + intent.expectEventBodyContains('**alice** created new issue', 0); + intent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.html_url, 0); + intent.expectEventBodyContains(GITHUB_ISSUE_CREATED_PAYLOAD.issue.title, 0); }); it("will filter out issues not matching includingLabels.", async () => { - const { connection, as } = createConnection({ + const { connection, intent } = createConnection({ includingLabels: ["include-me"] }); await connection.onIssueCreated({ @@ -138,10 +151,10 @@ describe("GitHubRepoConnection", () => { } as never); // ..or issues with no labels await connection.onIssueCreated(GITHUB_ISSUE_CREATED_PAYLOAD as never); - as.botIntent.expectNoEvent(); + intent.expectNoEvent(); }); it("will filter out issues matching excludingLabels.", async () => { - const { connection, as } = createConnection({ + const { connection, intent } = createConnection({ excludingLabels: ["exclude-me"] }); await connection.onIssueCreated({ @@ -153,10 +166,10 @@ describe("GitHubRepoConnection", () => { }], } } as never); - as.botIntent.expectNoEvent(); + intent.expectNoEvent(); }); it("will include issues matching includingLabels.", async () => { - const { connection, as } = createConnection({ + const { connection, intent } = createConnection({ includingIssues: ["include-me"] }); await connection.onIssueCreated({ @@ -168,7 +181,7 @@ describe("GitHubRepoConnection", () => { }], } } as never); - as.botIntent.expectEventBodyContains('**alice** created new issue', 0); + intent.expectEventBodyContains('**alice** created new issue', 0); }); }); -}); \ No newline at end of file +}); diff --git a/tests/connections/GitlabRepoTest.ts b/tests/connections/GitlabRepoTest.ts index 1d887d4e..f310a884 100644 --- a/tests/connections/GitlabRepoTest.ts +++ b/tests/connections/GitlabRepoTest.ts @@ -3,6 +3,8 @@ import { UserTokenStore } from "../../src/UserTokenStore"; import { AppserviceMock } from "../utils/AppserviceMock"; import { ApiError, ErrCode, ValidatorApiError } from "../../src/api"; import { GitLabRepoConnection, GitLabRepoConnectionState } from "../../src/Connections"; +import { expect } from "chai"; +import { BridgeConfigGitLab } from "../../src/Config/Config"; const ROOM_ID = "!foo:bar"; @@ -37,10 +39,13 @@ function createConnection(state: Record = {}, isExistingState=f }); mq.subscribe('*'); const as = AppserviceMock.create(); + const intent = as.getIntentForUserId('@gitlab:example.test'); const connection = new GitLabRepoConnection( ROOM_ID, "state_key", as, + {} as BridgeConfigGitLab, + intent, GitLabRepoConnection.validateState({ instance: "bar", path: "foo", @@ -51,7 +56,7 @@ function createConnection(state: Record = {}, isExistingState=f url: "https://gitlab.example.com" }, ); - return {connection, as}; + return {connection, intent}; } describe("GitLabRepoConnection", () => { @@ -60,7 +65,7 @@ describe("GitLabRepoConnection", () => { GitLabRepoConnection.validateState({ instance: "foo", path: "bar/baz", - ignoreHooks: [ + enableHooks: [ "merge_request.open", "merge_request.close", "merge_request.merge", @@ -79,6 +84,17 @@ describe("GitLabRepoConnection", () => { excludingLabels: ["but-not-me"], } as GitLabRepoConnectionState as unknown as Record); }); + it("will convert ignoredHooks for existing state", () => { + const state = GitLabRepoConnection.validateState({ + instance: "foo", + path: "bar/baz", + ignoreHooks: [ + "merge_request", + ], + commandPrefix: "!gl", + } as GitLabRepoConnectionState as unknown as Record, true); + expect(state.enableHooks).to.not.contain('merge_request'); + }); it("will disallow invalid state", () => { try { GitLabRepoConnection.validateState({ @@ -91,12 +107,12 @@ describe("GitLabRepoConnection", () => { } } }); - it("will disallow ignoreHooks to contains invalid enums if this is new state", () => { + it("will disallow enabledHooks to contains invalid enums if this is new state", () => { try { GitLabRepoConnection.validateState({ instance: "bar", path: "foo", - ignoreHooks: ["issue", "pull_request", "release", "not-real"], + enabledHooks: ["not-real"], }, false); } catch (ex) { if (ex instanceof ApiError === false || ex.errcode !== ErrCode.BadValue) { @@ -104,25 +120,25 @@ describe("GitLabRepoConnection", () => { } } }); - it("will allow ignoreHooks to contains invalid enums if this is old state", () => { + it("will allow enabledHooks to contains invalid enums if this is old state", () => { GitLabRepoConnection.validateState({ instance: "bar", path: "foo", - ignoreHooks: ["issues", "merge_request", "foo"], + enabledHooks: ["not-real"], }, true); }); }); describe("onIssueCreated", () => { it("will handle a simple issue", async () => { - const { connection, as } = createConnection(); + const { connection, intent } = createConnection(); await connection.onMergeRequestOpened(GITLAB_ISSUE_CREATED_PAYLOAD as never); // Statement text. - as.botIntent.expectEventBodyContains('**alice** opened a new MR', 0); - as.botIntent.expectEventBodyContains(GITLAB_ISSUE_CREATED_PAYLOAD.object_attributes.url, 0); - as.botIntent.expectEventBodyContains(GITLAB_ISSUE_CREATED_PAYLOAD.object_attributes.title, 0); + intent.expectEventBodyContains('**alice** opened a new MR', 0); + intent.expectEventBodyContains(GITLAB_ISSUE_CREATED_PAYLOAD.object_attributes.url, 0); + intent.expectEventBodyContains(GITLAB_ISSUE_CREATED_PAYLOAD.object_attributes.title, 0); }); it("will filter out issues not matching includingLabels.", async () => { - const { connection, as } = createConnection({ + const { connection, intent } = createConnection({ includingLabels: ["include-me"] }); await connection.onMergeRequestOpened({ @@ -133,10 +149,10 @@ describe("GitLabRepoConnection", () => { } as never); // ..or issues with no labels await connection.onMergeRequestOpened(GITLAB_ISSUE_CREATED_PAYLOAD as never); - as.botIntent.expectNoEvent(); + intent.expectNoEvent(); }); it("will filter out issues matching excludingLabels.", async () => { - const { connection, as } = createConnection({ + const { connection, intent } = createConnection({ excludingLabels: ["exclude-me"] }); await connection.onMergeRequestOpened({ @@ -145,10 +161,10 @@ describe("GitLabRepoConnection", () => { title: "exclude-me", }], } as never); - as.botIntent.expectNoEvent(); + intent.expectNoEvent(); }); it("will include issues matching includingLabels.", async () => { - const { connection, as } = createConnection({ + const { connection, intent } = createConnection({ includingIssues: ["include-me"] }); await connection.onMergeRequestOpened({ @@ -157,7 +173,7 @@ describe("GitLabRepoConnection", () => { title: "include-me", }], } as never); - as.botIntent.expectEventBodyContains('**alice** opened a new MR', 0); + intent.expectEventBodyContains('**alice** opened a new MR', 0); }); }); -}); \ No newline at end of file +}); diff --git a/tests/grants/GrantChecker.spec.ts b/tests/grants/GrantChecker.spec.ts new file mode 100644 index 00000000..94c96f53 --- /dev/null +++ b/tests/grants/GrantChecker.spec.ts @@ -0,0 +1,177 @@ +import { expect } from "chai"; +import { BridgeConfig } from "../../src/Config/Config"; +import { DefaultConfigRoot } from "../../src/Config/Defaults"; +import { FormatUtil } from "../../src/FormatUtil"; +import { ConfigGrantChecker, GrantChecker, GrantRejectedError } from '../../src/grants/GrantCheck'; +import { AppserviceMock } from "../utils/AppserviceMock"; +import { IntentMock } from "../utils/IntentMock"; + +const ROOM_ID = '!a-room:bar'; +const CONNECTION_ID = '!a-room:bar'; +const ALWAYS_GRANT_USER = '@grant_me:bar'; +const GRANT_SERVICE_USER = '@grant_service_user:bar'; +const GRANT_SERVCE_LOW_PERMS = '@grant_service_user_without_perms:bar'; +const GRANT_WRONG_SERVCE_USER = '@grant_wrong_service_user:bar'; +const ALICE_USERID = '@alice:bar'; +const GRANT_SERVICE = 'example-grant'; + +async function doesAssert(checker: GrantChecker, roomId: string, connectionId: string, sender?: string) { + try { + await checker.assertConnectionGranted(roomId, connectionId, sender); + throw Error(`Expected ${roomId}/${connectionId} to have thrown an error`) + } catch (ex) { + expect(ex).instanceOf(GrantRejectedError, 'Error thrown, but was not a grant rejected error'); + expect(ex.roomId).to.equal(roomId, "Grant rejected, but roomId didn't match"); + // connectionIds are always hashed + expect(ex.connectionId).to.equal(FormatUtil.hashId(connectionId), "Grant rejected, but connectionId didn't match"); + return true; + } +} + +class TestGrantChecker extends GrantChecker { + protected checkFallback(roomId: string, connectionId: string | object, sender?: string | undefined) { + return sender === ALWAYS_GRANT_USER; + } +} + +describe("GrantChecker", () => { + describe('base grant system', () => { + let check: GrantChecker; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let intent: any; + beforeEach(() => { + intent = IntentMock.create('@foo:bar'); + check = new TestGrantChecker(intent, GRANT_SERVICE); + }); + + it('will grant a connection', async () => { + await check.grantConnection(ROOM_ID, CONNECTION_ID); + // And then to check that the grant has now been allowed. + await check.assertConnectionGranted(ROOM_ID, CONNECTION_ID); + }); + + it('will assert on a missing grant', async () => { + await doesAssert( + check, + ROOM_ID, + CONNECTION_ID + ); + }); + + it('will allow a missing grant if sender matches', async () => { + // Use the special user to grant the connection + await check.assertConnectionGranted(ROOM_ID, CONNECTION_ID, ALWAYS_GRANT_USER); + + // And then to check that the grant has now been allowed. + await check.assertConnectionGranted(ROOM_ID, CONNECTION_ID); + }); + + it('will not conflict with another connection id', async () => { + await check.grantConnection(ROOM_ID, CONNECTION_ID); + await doesAssert( + check, + ROOM_ID, + CONNECTION_ID + "2", + ); + }); + + it('will not conflict with another room', async () => { + await check.grantConnection(ROOM_ID, CONNECTION_ID); + await doesAssert( + check, + ROOM_ID + "2", + CONNECTION_ID + ); + }); + + it('will not conflict with another grant service', async () => { + const anotherchecker = new TestGrantChecker(intent, GRANT_SERVICE + "-two"); + await check.grantConnection(ROOM_ID, CONNECTION_ID); + + await doesAssert( + anotherchecker, + ROOM_ID, + CONNECTION_ID + ); + }); + }); + describe('config fallback', () => { + let check: GrantChecker; + let as: AppserviceMock; + beforeEach(() => { + const mockAs = AppserviceMock.create(); + as = mockAs; + const config = new BridgeConfig( + { + ...DefaultConfigRoot, + permissions: [{ + actor: ALWAYS_GRANT_USER, + services: [{ + service: '*', + level: "admin", + }], + }, + { + actor: GRANT_SERVICE_USER, + services: [{ + service: GRANT_SERVICE, + level: "admin", + }] + }, + { + actor: GRANT_SERVCE_LOW_PERMS, + services: [{ + service: GRANT_SERVICE, + level: 'notifications', + }] + }, + { + actor: GRANT_WRONG_SERVCE_USER, + services: [{ + service: 'another-service', + level: "admin", + }] + }], + } + ); + check = new ConfigGrantChecker(GRANT_SERVICE, mockAs, config); + }); + + it('will deny a missing grant if the sender is not provided', async () => { + await doesAssert( + check, + ROOM_ID, + CONNECTION_ID + ); + }); + + it('will deny a missing grant if the sender is not in the appservice whitelist', async () => { + await doesAssert( + check, + ROOM_ID, + CONNECTION_ID, + ALICE_USERID, + ); + }); + + it('will grant if the user is part of the appservice', async () => { + await check.assertConnectionGranted(ROOM_ID, CONNECTION_ID, as.namespace + "bot"); + }); + + it('will grant if the user has access to all services', async () => { + await check.assertConnectionGranted(ROOM_ID, CONNECTION_ID, ALWAYS_GRANT_USER); + }); + + it('will grant if the user has access to this service', async () => { + await check.assertConnectionGranted(ROOM_ID, CONNECTION_ID, GRANT_SERVICE_USER); + }); + + it('will not grant if the user has low access to this service', async () => { + await doesAssert(check, ROOM_ID, CONNECTION_ID, GRANT_SERVCE_LOW_PERMS); + }); + + it('will not grant if the user has access to a different service', async () => { + await doesAssert(check, ROOM_ID, CONNECTION_ID, GRANT_WRONG_SERVCE_USER); + }); + }); +}); diff --git a/tests/utils/AppserviceMock.ts b/tests/utils/AppserviceMock.ts index 1f43561f..c32dc363 100644 --- a/tests/utils/AppserviceMock.ts +++ b/tests/utils/AppserviceMock.ts @@ -1,21 +1,35 @@ import { IntentMock } from "./IntentMock"; export class AppserviceMock { - public readonly botIntent = IntentMock.create(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public readonly intentMap = new Map(); + public readonly botIntent = IntentMock.create(`@bot:example.com`); + public namespace = "@hookshot_"; + static create(){ // eslint-disable-next-line @typescript-eslint/no-explicit-any return new this() as any; } get botUserId() { - return `@bot:example.com`; + return this.botIntent.userId; } get botClient() { return this.botIntent.underlyingClient; } - public getIntentForUserId() { - return IntentMock.create(); + public getIntentForUserId(userId: string) { + let intent = this.intentMap.get(userId); + if (intent) { + return intent; + } + intent = IntentMock.create(userId); + this.intentMap.set(userId, intent); + return intent; + } + + public isNamespacedUser(userId: string) { + return userId.startsWith(this.namespace); } } diff --git a/tests/utils/IntentMock.ts b/tests/utils/IntentMock.ts index e256fa57..2a976060 100644 --- a/tests/utils/IntentMock.ts +++ b/tests/utils/IntentMock.ts @@ -1,18 +1,61 @@ import { expect } from "chai"; +import { MatrixError } from "matrix-bot-sdk"; export class MatrixClientMock { + + static create(){ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new this() as any; + } + + // map room Id → user Ids + private joinedMembers: Map = new Map(); + public readonly roomAccountData: Map = new Map(); + async setDisplayName() { return; } + + async getJoinedRoomMembers(roomId: string): Promise { + return this.joinedMembers.get(roomId) || []; + } + + async inviteUser(userId: string, roomId: string): Promise { + const roomMembers = this.joinedMembers.get(roomId) || []; + + if (roomMembers.includes(userId)) { + throw new Error("User already in room"); + } + + roomMembers.push(userId); + this.joinedMembers.set(roomId, roomMembers); + } + + async getRoomAccountData(key: string, roomId: string): Promise { + const data = this.roomAccountData.get(roomId+key); + if (data) { + return data; + } + throw new MatrixError({ + errcode: 'M_NOT_FOUND', + error: 'Test error: No account data', + }, 404); + } + + async setRoomAccountData(key: string, roomId: string, value: string): Promise { + this.roomAccountData.set(roomId+key, value); + } } export class IntentMock { public readonly underlyingClient = new MatrixClientMock(); public sentEvents: {roomId: string, content: any}[] = []; - static create(){ + constructor(readonly userId: string) {} + + static create(userId: string){ // eslint-disable-next-line @typescript-eslint/no-explicit-any - return new this() as any; + return new this(userId) as any; } sendText(roomId: string, noticeText: string, msgtype: string) { @@ -31,7 +74,7 @@ export class IntentMock { content, }); } - + expectNoEvent() { expect(this.sentEvents, 'Expected no events to be sent.').to.be.empty; } @@ -48,6 +91,10 @@ export class IntentMock { expect(!!this.sentEvents.find(ev => ev.content.body.includes(matcher)), `Expected any event body to match '${matcher}'`).to.be.true; } + async ensureJoined() { + return true; + } + async ensureRegistered() { return true; } diff --git a/tsconfig.json b/tsconfig.json index e2693a89..369c06da 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@tsconfig/node14/tsconfig.json", + "extends": "@tsconfig/node16/tsconfig.json", "compilerOptions": { "incremental": true, "declaration": false, diff --git a/vite.config.js b/vite.config.js index 8d2c892f..da4ef13b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,7 @@ import { defineConfig } from 'vite' import preact from '@preact/preset-vite' import svgLoader from 'vite-svg-loader' +import { resolve } from 'path' // https://vitejs.dev/config/ export default defineConfig({ @@ -9,6 +10,12 @@ export default defineConfig({ base: '', build: { outDir: '../public', + rollupOptions: { + input: { + main: resolve('web', 'index.html'), + oauth: resolve('web', 'oauth.html'), + } + }, emptyOutDir: true, }, }) diff --git a/web/App.tsx b/web/App.tsx index 2510d76c..5a4d6a11 100644 --- a/web/App.tsx +++ b/web/App.tsx @@ -1,9 +1,10 @@ /* eslint-disable no-console */ -import { h, Component } from 'preact'; +import { Component } from 'preact'; import WA, { MatrixCapabilities } from 'matrix-widget-api'; -import { BridgeAPI, BridgeAPIError } from './BridgeAPI'; +import { BridgeAPI, BridgeAPIError, EmbedType, embedTypeParameter } from './BridgeAPI'; import { BridgeRoomState } from '../src/Widgets/BridgeWidgetInterface'; -import { ErrorPane } from './components/elements'; +import { LoadingSpinner } from './components/elements/LoadingSpinner'; +import { Card, ErrorPane } from './components/elements'; import AdminSettings from './components/AdminSettings'; import RoomConfigView from './components/RoomConfigView'; @@ -19,6 +20,7 @@ interface ICompleteState extends IMinimalState { [sectionName: string]: boolean; }, serviceScope?: string, + embedType: EmbedType, kind: "invite"|"admin"|"roomConfig", } @@ -55,6 +57,7 @@ export default class App extends Component { const roomId = assertParam(qs, 'roomId'); const widgetKind = qs.get('kind') as "invite"|"admin"|"roomConfig"; const serviceScope = qs.get('serviceScope'); + const embedType = qs.get(embedTypeParameter); // Fetch via config. this.widgetApi = new WA.WidgetApi(widgetId); this.widgetApi.requestCapability(MatrixCapabilities.RequiresClient); @@ -84,6 +87,7 @@ export default class App extends Component { roomId, supportedServices, serviceScope: serviceScope || undefined, + embedType: embedType === EmbedType.IntegrationManager ? EmbedType.IntegrationManager : EmbedType.Default, kind: widgetKind, busy: false, }); @@ -110,7 +114,9 @@ export default class App extends Component { if (this.state.error) { content = {this.state.error}; } else if (this.state.busy) { - content =
; + content = + + ; } if ("kind" in this.state) { @@ -123,6 +129,7 @@ export default class App extends Component { roomId={this.state.roomId} supportedServices={this.state.supportedServices} serviceScope={this.state.serviceScope} + embedType={this.state.embedType} bridgeApi={this.bridgeApi} widgetApi={this.widgetApi} />; @@ -135,7 +142,9 @@ export default class App extends Component { } return ( -
+
{content}
); diff --git a/web/BridgeAPI.ts b/web/BridgeAPI.ts index 79a25339..d30dd577 100644 --- a/web/BridgeAPI.ts +++ b/web/BridgeAPI.ts @@ -1,4 +1,4 @@ -import { BridgeRoomState, GetConnectionsForServiceResponse } from '../src/Widgets/BridgeWidgetInterface'; +import { BridgeRoomState, GetAuthPollResponse, GetAuthResponse, GetConnectionsForServiceResponse } from '../src/Widgets/BridgeWidgetInterface'; import { GetConnectionsResponseItem } from "../src/provisioning/api"; import { ExchangeOpenAPIRequestBody, ExchangeOpenAPIResponseBody } from "matrix-appservice-bridge"; import { WidgetApi } from 'matrix-widget-api'; @@ -18,6 +18,10 @@ export class BridgeAPIError extends Error { } } +interface RequestOpts { + abortController?: AbortController; +} + export class BridgeAPI { static async getBridgeAPI(baseUrl: string, widgetApi: WidgetApi): Promise { const sessionToken = localStorage.getItem('hookshot-sessionToken'); @@ -70,9 +74,10 @@ export class BridgeAPI { this.baseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; } - async request(method: string, endpoint: string, body?: unknown) { + async request(method: string, endpoint: string, body?: unknown, opts?: RequestOpts) { const res = await fetch(`${this.baseUrl}${endpoint}`, { cache: 'no-cache', + signal: opts?.abortController?.signal, method, body: body ? JSON.stringify(body) : undefined, headers: { @@ -108,11 +113,15 @@ export class BridgeAPI { async getServiceConfig(service: string): Promise { return this.request('GET', `/widgetapi/v1/service/${service}/config`); } - + async getConnectionsForRoom(roomId: string): Promise { return this.request('GET', `/widgetapi/v1/${encodeURIComponent(roomId)}/connections`); } + async getGoNebConnectionsForRoom(roomId: string): Promise { + return this.request('GET', `/widgetapi/v1/${encodeURIComponent(roomId)}/goNebConnections`); + } + async getConnectionsForService(roomId: string, service: string): Promise> { return this.request('GET', `/widgetapi/v1/${encodeURIComponent(roomId)}/connections/${encodeURIComponent(service)}`); } @@ -129,13 +138,32 @@ export class BridgeAPI { return this.request('DELETE', `/widgetapi/v1/${encodeURIComponent(roomId)}/connections/${encodeURIComponent(connectionId)}`); } - getConnectionTargets(type: string, filters?: Record|Record): Promise { + getConnectionTargets(type: string, filters?: Record|Record, abortController?: AbortController): Promise { const searchParams = filters && !!Object.keys(filters).length && new URLSearchParams(filters); - return this.request('GET', `/widgetapi/v1/targets/${encodeURIComponent(type)}${searchParams ? `?${searchParams}` : ''}`); + return this.request('GET', `/widgetapi/v1/targets/${encodeURIComponent(type)}${searchParams ? `?${searchParams}` : ''}`, undefined, { abortController }); } + + async getAuth(service: string): Promise { + return this.request('GET', `/widgetapi/v1/service/${service}/auth`); + } + + async getAuthPoll(service: string, state: string): Promise { + return this.request('GET', `/widgetapi/v1/service/${service}/auth/${state}`); + } + + async serviceLogout(service: string): Promise { + return this.request('POST', `/widgetapi/v1/service/${service}/auth/logout`); + } +} + +export const embedTypeParameter = 'io_element_embed_type'; +export enum EmbedType { + IntegrationManager = 'integration-manager', + Default = 'default', } export type BridgeConfig = FunctionComponent<{ api: BridgeAPI, roomId: string, -}>; \ No newline at end of file + showHeader: boolean, +}>; diff --git a/web/components/AdminSettings.tsx b/web/components/AdminSettings.tsx index 0c4b0003..c9a7d99e 100644 --- a/web/components/AdminSettings.tsx +++ b/web/components/AdminSettings.tsx @@ -1,5 +1,5 @@ -import { h } from "preact"; import { useEffect, useState, useCallback } from 'preact/hooks'; +import { LoadingSpinner } from "./elements/LoadingSpinner"; import { BridgeRoomState } from "../../src/Widgets/BridgeWidgetInterface"; import GeneralConfig from './configs/GeneralConfig'; import style from "./AdminSettings.module.scss"; @@ -37,7 +37,7 @@ export default function AdminSettings(props: IProps) { ); if (busy) { return
-
+
; } return
@@ -58,4 +58,4 @@ export default function AdminSettings(props: IProps) {
; -} \ No newline at end of file +} diff --git a/web/components/ConnectionCard.tsx b/web/components/ConnectionCard.tsx index 50450629..5bd531c2 100644 --- a/web/components/ConnectionCard.tsx +++ b/web/components/ConnectionCard.tsx @@ -1,4 +1,3 @@ -import { h } from "preact"; import style from "./ConnectionCard.module.scss"; interface IProps { diff --git a/web/components/GitHubState.tsx b/web/components/GitHubState.tsx index dbdd7858..4cb773c8 100644 --- a/web/components/GitHubState.tsx +++ b/web/components/GitHubState.tsx @@ -1,4 +1,4 @@ -import { h, FunctionComponent } from 'preact'; +import { FunctionComponent } from 'preact'; import { BridgeRoomStateGitHub } from '../../src/Widgets/BridgeWidgetInterface'; import "./GitHubState.css"; diff --git a/web/components/RoomConfigView.tsx b/web/components/RoomConfigView.tsx index 116e6e51..cfe587b4 100644 --- a/web/components/RoomConfigView.tsx +++ b/web/components/RoomConfigView.tsx @@ -1,6 +1,6 @@ import { WidgetApi } from "matrix-widget-api"; import { useState } from "preact/hooks" -import { BridgeAPI, BridgeConfig } from "../BridgeAPI"; +import { BridgeAPI, BridgeConfig, EmbedType } from "../BridgeAPI"; import style from "./RoomConfigView.module.scss"; import { ConnectionCard } from "./ConnectionCard"; import { FeedsConfig } from "./roomConfig/FeedsConfig"; @@ -21,6 +21,7 @@ interface IProps { bridgeApi: BridgeAPI, supportedServices: {[service: string]: boolean}, serviceScope?: string, + embedType: EmbedType, roomId: string, } @@ -80,7 +81,11 @@ export default function RoomConfigView(props: IProps) { if (activeConnectionType) { const ConfigComponent = connections[activeConnectionType].component; - content = ; + content = ; } else { content = <>
@@ -100,13 +105,13 @@ export default function RoomConfigView(props: IProps) { } return
-
- {!serviceScope && activeConnectionType && + {!serviceScope && activeConnectionType && +
setActiveConnectionType(null)}> Browse integrations - } -
+
+ } {content}
; } diff --git a/web/components/ServiceCard.module.scss b/web/components/ServiceCard.module.scss index 3e6bfd4c..ee17923e 100644 --- a/web/components/ServiceCard.module.scss +++ b/web/components/ServiceCard.module.scss @@ -1,3 +1,7 @@ +.icon { + width: 48px; +} + .serviceCard { display: grid !important; grid-template-columns: 0.6fr 1fr 1fr; diff --git a/web/components/ServiceCard.tsx b/web/components/ServiceCard.tsx index 6402b3cf..b24534bc 100644 --- a/web/components/ServiceCard.tsx +++ b/web/components/ServiceCard.tsx @@ -1,10 +1,10 @@ -import { h, FunctionComponent } from "preact"; +import { FunctionComponent } from "preact"; import style from "./ServiceCard.module.scss"; export const ServiceCard: FunctionComponent<{serviceName: string, iconUrl: string, onConfigure: () => void}> = ({ serviceName, iconUrl, onConfigure }) => { return
- +
{serviceName} diff --git a/web/components/configs/GeneralConfig.tsx b/web/components/configs/GeneralConfig.tsx index f0c79660..1c648dc5 100644 --- a/web/components/configs/GeneralConfig.tsx +++ b/web/components/configs/GeneralConfig.tsx @@ -1,4 +1,3 @@ -import { h } from "preact"; import { Button } from "../elements"; export default function GeneralConfig() { diff --git a/web/components/elements/Button.tsx b/web/components/elements/Button.tsx index f0f6e2f0..6f0c8738 100644 --- a/web/components/elements/Button.tsx +++ b/web/components/elements/Button.tsx @@ -1,10 +1,14 @@ import { FunctionComponent, h } from "preact"; import style from "./Button.module.scss"; -export const Button: FunctionComponent = (props: { [key: string]: unknown, intent?: string}) => { +interface ButtonProps extends h.JSX.HTMLAttributes { + intent?: string; +} + +export const Button: FunctionComponent = (props: ButtonProps) => { let className = style.button; if (props.intent === "remove") { - className += ` ${ style.remove}`; + className += ` ${style.remove}`; } return ; + } + return

+ Logged in as {authState.user?.name ?? ''}. Logout +

; +}; diff --git a/web/components/roomConfig/FeedsConfig.tsx b/web/components/roomConfig/FeedsConfig.tsx index 0ff7aea1..96c9931a 100644 --- a/web/components/roomConfig/FeedsConfig.tsx +++ b/web/components/roomConfig/FeedsConfig.tsx @@ -1,8 +1,8 @@ -import { h, FunctionComponent, createRef } from "preact"; -import { useCallback } from "preact/hooks" +import { FunctionComponent, createRef } from "preact"; +import { useCallback, useEffect, useState } from "preact/hooks" import { BridgeConfig } from "../../BridgeAPI"; import { FeedConnectionState, FeedResponseItem } from "../../../src/Connections/FeedConnection"; -import { ConnectionConfigurationProps, RoomConfig } from "./RoomConfig"; +import { ConnectionConfigurationProps, IRoomConfigText, RoomConfig } from "./RoomConfig"; import { Button, ButtonSet, InputField } from "../elements"; import styles from "./FeedConnection.module.scss"; @@ -16,12 +16,11 @@ const FeedRecentResults: FunctionComponent<{item: FeedResponseItem}> = ({ item }

Recent feed results

{!item.secrets.lastResults.length && There have been no recent updates for this feed.}
    - {item.secrets.lastResults.map(item =>
  • - {new Date(item.timestamp).toLocaleString()}: + {item.secrets.lastResults.map(item =>
  • + {new Date(item.timestamp).toLocaleString()}: {item.ok && `✅ Successful fetch`} {!item.ok && `⚠️ ${item.error}`} -
  • - )} + )}
; } @@ -30,7 +29,7 @@ const ConnectionConfiguration: FunctionComponent(); const labelRef = createRef(); - const canEdit = !existingConnection || (existingConnection?.canEdit ?? false); + const canEdit = !existingConnection?.id || (existingConnection?.canEdit ?? false); const handleSave = useCallback((evt: Event) => { evt.preventDefault(); if (!canEdit) { @@ -48,16 +47,16 @@ const ConnectionConfiguration: FunctionComponent { existingConnection && } - - + + - + - + - { canEdit && } - { canEdit && existingConnection && } + { canEdit && } + { canEdit && existingConnection?.id && } ; @@ -67,7 +66,7 @@ interface ServiceConfig { pollIntervalSeconds: number, } -const RoomConfigText = { +const roomConfigText: IRoomConfigText = { header: 'RSS/Atom feeds', createNew: 'Subscribe to a feed', listCanEdit: 'Feeds subscribed to', @@ -76,15 +75,33 @@ const RoomConfigText = { const RoomConfigListItemFunc = (c: FeedResponseItem) => c.config.label || c.config.url; -export const FeedsConfig: BridgeConfig = ({ api, roomId }) => { +export const FeedsConfig: BridgeConfig = ({ api, roomId, showHeader }) => { + const [ goNebConnections, setGoNebConnections ] = useState(undefined); + + useEffect(() => { + api.getGoNebConnectionsForRoom(roomId).then((res: any) => { + if (!res) return; + setGoNebConnections(res.feeds.map((config: any) => ({ + config, + }))); + }).catch(ex => { + console.warn("Failed to fetch go neb connections", ex); + }); + }, [api, roomId]); + + const compareConnections = useCallback((goNebConnection, nativeConnection) => goNebConnection.config.url === nativeConnection.config.url, []); + return headerImg={FeedsIcon} + showHeader={showHeader} api={api} roomId={roomId} type="feeds" connectionEventType="uk.half-shot.matrix-hookshot.feed" - text={RoomConfigText} + text={roomConfigText} listItemName={RoomConfigListItemFunc} connectionConfigComponent={ConnectionConfiguration} + migrationCandidates={goNebConnections} + migrationComparator={compareConnections} />; }; diff --git a/web/components/roomConfig/GenericWebhookConfig.tsx b/web/components/roomConfig/GenericWebhookConfig.tsx index 0e16d2eb..a91cfce8 100644 --- a/web/components/roomConfig/GenericWebhookConfig.tsx +++ b/web/components/roomConfig/GenericWebhookConfig.tsx @@ -1,8 +1,8 @@ -import { h, FunctionComponent, createRef } from "preact"; +import { FunctionComponent, createRef } from "preact"; import { useCallback, useState } from "preact/hooks" import CodeMirror from '@uiw/react-codemirror'; import { javascript } from '@codemirror/lang-javascript'; -import { BridgeAPI } from "../../BridgeAPI"; +import { BridgeConfig } from "../../BridgeAPI"; import { GenericHookConnectionState, GenericHookResponseItem } from "../../../src/Connections/GenericHook"; import { ConnectionConfigurationProps, RoomConfig } from "./RoomConfig"; import { InputField, ButtonSet, Button } from "../elements"; @@ -73,11 +73,6 @@ const ConnectionConfiguration: FunctionComponent; }; -interface IGenericWebhookConfigProps { - api: BridgeAPI, - roomId: string, -} - interface ServiceConfig { allowJsTransformationFunctions: boolean } @@ -91,9 +86,10 @@ const RoomConfigText = { const RoomConfigListItemFunc = (c: GenericHookResponseItem) => c.config.name; -export const GenericWebhookConfig: FunctionComponent = ({ api, roomId }) => { +export const GenericWebhookConfig: BridgeConfig = ({ api, roomId, showHeader }) => { return headerImg={WebhookIcon} + showHeader={showHeader} api={api} roomId={roomId} type="generic" @@ -102,4 +98,4 @@ export const GenericWebhookConfig: FunctionComponent listItemName={RoomConfigListItemFunc} connectionConfigComponent={ConnectionConfiguration} />; -}; \ No newline at end of file +}; diff --git a/web/components/roomConfig/GithubRepoConfig.tsx b/web/components/roomConfig/GithubRepoConfig.tsx index 2a9dcbfa..16fa2669 100644 --- a/web/components/roomConfig/GithubRepoConfig.tsx +++ b/web/components/roomConfig/GithubRepoConfig.tsx @@ -1,200 +1,65 @@ -import { h, FunctionComponent, createRef } from "preact"; -import { useState, useCallback, useEffect, useMemo } from "preact/hooks"; -import { BridgeAPI, BridgeConfig } from "../../BridgeAPI"; -import { ConnectionConfigurationProps, RoomConfig } from "./RoomConfig"; -import { ErrCode } from "../../../src/api"; -import { GitHubRepoConnectionState, GitHubRepoResponseItem, GitHubRepoConnectionRepoTarget, GitHubTargetFilter, GitHubRepoConnectionOrgTarget } from "../../../src/Connections/GithubRepo"; -import { InputField, ButtonSet, Button, ErrorPane } from "../elements"; import GitHubIcon from "../../icons/github.png"; +import { BridgeConfig } from "../../BridgeAPI"; +import { ConnectionConfigurationProps, IRoomConfigText, RoomConfig } from "./RoomConfig"; +import { EventHookCheckbox } from '../elements/EventHookCheckbox'; +import { FunctionComponent, createRef } from "preact"; +import { GitHubRepoConnectionState, GitHubRepoResponseItem, GitHubRepoConnectionRepoTarget, GitHubRepoConnectionOrgTarget } from "../../../src/Connections/GithubRepo"; +import { InputField, ButtonSet, Button } from "../elements"; +import { useState, useCallback, useMemo, useEffect } from "preact/hooks"; +import { DropItem } from "../elements/DropdownSearch"; +import ConnectionSearch from "../elements/ConnectionSearch"; +import { ServiceAuth } from "./Auth"; +import { GetAuthResponse } from "../../../src/Widgets/BridgeWidgetInterface"; const EventType = "uk.half-shot.matrix-hookshot.github.repository"; -const NUM_REPOS_PER_PAGE = 10; function getRepoFullName(state: GitHubRepoConnectionState) { return `${state.org}/${state.repo}`; } -const ConnectionSearch: FunctionComponent<{api: BridgeAPI, onPicked: (state: GitHubRepoConnectionState) => void}> = ({api, onPicked}) => { - const [filter, setFilter] = useState({}); - const [results, setResults] = useState(null); - const [orgs, setOrgs] = useState(null); - const [isConnected, setIsConnected] = useState(null); - const [debounceTimer, setDebounceTimer] = useState(undefined); - const [currentRepo, setCurrentRepo] = useState(null); - const [searchError, setSearchError] = useState(null); - - const searchFn = useCallback(async() => { - try { - const res = await api.getConnectionTargets(EventType, filter); - setIsConnected(true); - if (!filter.orgName) { - setOrgs(res as GitHubRepoConnectionOrgTarget[]); - if (res[0]) { - setFilter({ - orgName: res[0].name, - perPage: NUM_REPOS_PER_PAGE, - }); - } - } else { - setResults((prevResults: GitHubRepoConnectionRepoTarget[]|null) => - !prevResults ? res : prevResults.concat(...res) - ); - if (res.length == NUM_REPOS_PER_PAGE) { - setFilter({ - ...filter, - page: filter.page ? filter.page + 1 : 2, - }); - } - } - } catch (ex: any) { - if (ex?.errcode === ErrCode.ForbiddenUser) { - setOrgs([]); - } else if (ex?.errcode === ErrCode.AdditionalActionRequired) { - setSearchError("You are not permitted to list GitHub installations."); - setOrgs([]); - } else { - setSearchError("There was an error fetching search results."); - // Rather than raising an error, let's just log and let the user retry a query. - console.warn(`Failed to get connection targets from query:`, ex); - } - } - }, [api, filter]); - - const updateSearchFn = useCallback((evt: InputEvent) => { - const repo = (evt.target as HTMLOptionElement).value; - const hasResult = results?.find(n => n.name == repo); - if (hasResult) { - onPicked(hasResult.state); - setCurrentRepo(hasResult.state.repo); - } - }, [onPicked, results]); - - useEffect(() => { - if (debounceTimer) { - clearTimeout(debounceTimer); - } - // Browser types - setDebounceTimer(setTimeout(searchFn, 500) as unknown as number); - return () => { - clearTimeout(debounceTimer); - } - // Things break if we depend on the thing we are clearing. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchFn, filter]); - - const onOrgPicked = useCallback((evt: InputEvent) => { - // Reset the search string. - setFilter({ - orgName: (evt.target as HTMLSelectElement).selectedOptions[0].value, - }); - }, []); - - const orgListResults = useMemo( - () => orgs?.map(i => ), - [orgs] - ); - - const repoListResults = useMemo( - () => results?.map(i =>
} + { toMigrate.length > 0 &&
+

Migrate connections

+ { serviceConfig && toMigrate.map(c => + + ) } +
} + + ; +}; diff --git a/web/index.tsx b/web/index.tsx index 68374f0b..6a48d4f6 100644 --- a/web/index.tsx +++ b/web/index.tsx @@ -1,10 +1,10 @@ -import { h, render } from 'preact'; +import { render } from 'preact'; import 'preact/devtools'; import App from './App'; import "./fonts/fonts.scss" import "./styling.scss"; -const root = document.getElementsByTagName('main')[0]; +const [ root ] = document.getElementsByTagName('main'); if (root) { render(, root); diff --git a/web/oauth.html b/web/oauth.html new file mode 100644 index 00000000..46258b1e --- /dev/null +++ b/web/oauth.html @@ -0,0 +1,18 @@ + + + + + + Account + + +
+ +
+ + + diff --git a/web/oauth.scss b/web/oauth.scss new file mode 100644 index 00000000..137faaad --- /dev/null +++ b/web/oauth.scss @@ -0,0 +1,23 @@ +a { + color: var(--primary-color); +} + +a:visited { + color: var(--primary-color); +} + +body { + margin-top: 5em; + margin-left: 5em; + margin-right: 5em; + + text-align: center; +} + +h1 { + font-size: 1.5em; +} + +p { + font-size: 1.25em; +} \ No newline at end of file diff --git a/web/oauth.tsx b/web/oauth.tsx new file mode 100644 index 00000000..d12107cf --- /dev/null +++ b/web/oauth.tsx @@ -0,0 +1,40 @@ +import "./fonts/fonts.scss" +import "./styling.scss"; +import "./oauth.scss"; +import { render } from 'preact'; +import 'preact/devtools'; + +const root = document.getElementsByTagName('main')[0]; + +const ServiceToName: Record = { + github: 'GitHub', + gitlab: 'GitLab', + default: '' +} + + +function RenderOAuth() { + const params = new URLSearchParams(window.location.search); + const service = params.get('service') ?? 'default'; + const error = params.get('error'); + const errcode = params.get('errcode'); + const oauthKind = params.get('oauth-kind') ?? 'account'; + + if (error) { + return <> +

Could not connect your { ServiceToName[service] } {oauthKind} to Hookshot.

+

+ {errcode} {error} +

+ ; + } + + return <> +

Your { ServiceToName[service] } {oauthKind} has been connected.

+

You may close this window.

+ ; +} + +if (root) { + render(, root); +} \ No newline at end of file diff --git a/web/styling.scss b/web/styling.scss index 4a75a590..cb96c104 100644 --- a/web/styling.scss +++ b/web/styling.scss @@ -1,11 +1,15 @@ - - :root { --background-color: #FFFFFF; --foreground-color: #17191C; --light-color: #737D8C; --primary-color: #0DBD8B; --primary-color-disabled: #0dbd8baf; + + background-color: #F4F6FA; + color: var(--foreground-color); + min-height: 100%; + width: 100%; + font-family: 'Inter', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Arial', 'Helvetica', sans-serif, 'Noto Color Emoji'; } // @media (prefers-color-scheme: dark) { @@ -15,16 +19,12 @@ // } // } -#root { - background-color: var(--background-color); - color: var(--foreground-color); - min-height: 100vh; - width: 100vw; - font-family: 'Inter', 'Twemoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'Arial', 'Helvetica', sans-serif, 'Noto Color Emoji'; +a { + color: var(--primary-color); } -.app { - padding: 32px; +a:visited { + color: var(--primary-color); } body { @@ -65,4 +65,4 @@ button { transform: rotate(-45deg); border-color: var(--light-color); float: right; -} \ No newline at end of file +} diff --git a/web/tsconfig.json b/web/tsconfig.json index 99fcd5d2..14ff2f7b 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -1,11 +1,12 @@ { "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "preact", "module": "commonjs", - "target": "es2019", "moduleResolution": "node", - "jsx": "preserve", - "jsxFactory": "h", - /* noEmit - Snowpack builds (emits) files, not tsc. */ + "target": "es2019", + "types": ["preact"], + /* noEmit - Vite builds (emits) files, not tsc. */ "noEmit": true, /* Additional Options */ "strict": true, diff --git a/yarn.lock b/yarn.lock index 0bf1608d..06da9cea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -658,6 +658,116 @@ enabled "2.0.x" kuler "^2.0.0" +"@esbuild/android-arm64@0.16.17": + version "0.16.17" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.16.17.tgz#cf91e86df127aa3d141744edafcba0abdc577d23" + integrity sha512-MIGl6p5sc3RDTLLkYL1MyL8BMRN4tLMRCn+yRJJmEDvYZ2M7tmAf80hx1kbNEUX2KJ50RRtxZ4JHLvCfuB6kBg== + +"@esbuild/android-arm@0.16.17": + version "0.16.17" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.16.17.tgz#025b6246d3f68b7bbaa97069144fb5fb70f2fff2" + integrity sha512-N9x1CMXVhtWEAMS7pNNONyA14f71VPQN9Cnavj1XQh6T7bskqiLLrSca4O0Vr8Wdcga943eThxnVp3JLnBMYtw== + +"@esbuild/android-x64@0.16.17": + version "0.16.17" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.16.17.tgz#c820e0fef982f99a85c4b8bfdd582835f04cd96e" + integrity sha512-a3kTv3m0Ghh4z1DaFEuEDfz3OLONKuFvI4Xqczqx4BqLyuFaFkuaG4j2MtA6fuWEFeC5x9IvqnX7drmRq/fyAQ== + +"@esbuild/darwin-arm64@0.16.17": + version "0.16.17" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.16.17.tgz#edef4487af6b21afabba7be5132c26d22379b220" + integrity sha512-/2agbUEfmxWHi9ARTX6OQ/KgXnOWfsNlTeLcoV7HSuSTv63E4DqtAc+2XqGw1KHxKMHGZgbVCZge7HXWX9Vn+w== + +"@esbuild/darwin-x64@0.16.17": + version "0.16.17" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.16.17.tgz#42829168730071c41ef0d028d8319eea0e2904b4" + integrity sha512-2By45OBHulkd9Svy5IOCZt376Aa2oOkiE9QWUK9fe6Tb+WDr8hXL3dpqi+DeLiMed8tVXspzsTAvd0jUl96wmg== + +"@esbuild/freebsd-arm64@0.16.17": + version "0.16.17" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.16.17.tgz#1f4af488bfc7e9ced04207034d398e793b570a27" + integrity sha512-mt+cxZe1tVx489VTb4mBAOo2aKSnJ33L9fr25JXpqQqzbUIw/yzIzi+NHwAXK2qYV1lEFp4OoVeThGjUbmWmdw== + +"@esbuild/freebsd-x64@0.16.17": + version "0.16.17" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.16.17.tgz#636306f19e9bc981e06aa1d777302dad8fddaf72" + integrity sha512-8ScTdNJl5idAKjH8zGAsN7RuWcyHG3BAvMNpKOBaqqR7EbUhhVHOqXRdL7oZvz8WNHL2pr5+eIT5c65kA6NHug== + +"@esbuild/linux-arm64@0.16.17": + version "0.16.17" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.16.17.tgz#a003f7ff237c501e095d4f3a09e58fc7b25a4aca" + integrity sha512-7S8gJnSlqKGVJunnMCrXHU9Q8Q/tQIxk/xL8BqAP64wchPCTzuM6W3Ra8cIa1HIflAvDnNOt2jaL17vaW+1V0g== + +"@esbuild/linux-arm@0.16.17": + version "0.16.17" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.16.17.tgz#b591e6a59d9c4fe0eeadd4874b157ab78cf5f196" + integrity sha512-iihzrWbD4gIT7j3caMzKb/RsFFHCwqqbrbH9SqUSRrdXkXaygSZCZg1FybsZz57Ju7N/SHEgPyaR0LZ8Zbe9gQ== + +"@esbuild/linux-ia32@0.16.17": + version "0.16.17" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.16.17.tgz#24333a11027ef46a18f57019450a5188918e2a54" + integrity sha512-kiX69+wcPAdgl3Lonh1VI7MBr16nktEvOfViszBSxygRQqSpzv7BffMKRPMFwzeJGPxcio0pdD3kYQGpqQ2SSg== + +"@esbuild/linux-loong64@0.16.17": + version "0.16.17" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.16.17.tgz#d5ad459d41ed42bbd4d005256b31882ec52227d8" + integrity sha512-dTzNnQwembNDhd654cA4QhbS9uDdXC3TKqMJjgOWsC0yNCbpzfWoXdZvp0mY7HU6nzk5E0zpRGGx3qoQg8T2DQ== + +"@esbuild/linux-mips64el@0.16.17": + version "0.16.17" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.16.17.tgz#4e5967a665c38360b0a8205594377d4dcf9c3726" + integrity sha512-ezbDkp2nDl0PfIUn0CsQ30kxfcLTlcx4Foz2kYv8qdC6ia2oX5Q3E/8m6lq84Dj/6b0FrkgD582fJMIfHhJfSw== + +"@esbuild/linux-ppc64@0.16.17": + version "0.16.17" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.16.17.tgz#206443a02eb568f9fdf0b438fbd47d26e735afc8" + integrity sha512-dzS678gYD1lJsW73zrFhDApLVdM3cUF2MvAa1D8K8KtcSKdLBPP4zZSLy6LFZ0jYqQdQ29bjAHJDgz0rVbLB3g== + +"@esbuild/linux-riscv64@0.16.17": + version "0.16.17" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.16.17.tgz#c351e433d009bf256e798ad048152c8d76da2fc9" + integrity sha512-ylNlVsxuFjZK8DQtNUwiMskh6nT0vI7kYl/4fZgV1llP5d6+HIeL/vmmm3jpuoo8+NuXjQVZxmKuhDApK0/cKw== + +"@esbuild/linux-s390x@0.16.17": + version "0.16.17" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.16.17.tgz#661f271e5d59615b84b6801d1c2123ad13d9bd87" + integrity sha512-gzy7nUTO4UA4oZ2wAMXPNBGTzZFP7mss3aKR2hH+/4UUkCOyqmjXiKpzGrY2TlEUhbbejzXVKKGazYcQTZWA/w== + +"@esbuild/linux-x64@0.16.17": + version "0.16.17" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.16.17.tgz#e4ba18e8b149a89c982351443a377c723762b85f" + integrity sha512-mdPjPxfnmoqhgpiEArqi4egmBAMYvaObgn4poorpUaqmvzzbvqbowRllQ+ZgzGVMGKaPkqUmPDOOFQRUFDmeUw== + +"@esbuild/netbsd-x64@0.16.17": + version "0.16.17" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.16.17.tgz#7d4f4041e30c5c07dd24ffa295c73f06038ec775" + integrity sha512-/PzmzD/zyAeTUsduZa32bn0ORug+Jd1EGGAUJvqfeixoEISYpGnAezN6lnJoskauoai0Jrs+XSyvDhppCPoKOA== + +"@esbuild/openbsd-x64@0.16.17": + version "0.16.17" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.16.17.tgz#970fa7f8470681f3e6b1db0cc421a4af8060ec35" + integrity sha512-2yaWJhvxGEz2RiftSk0UObqJa/b+rIAjnODJgv2GbGGpRwAfpgzyrg1WLK8rqA24mfZa9GvpjLcBBg8JHkoodg== + +"@esbuild/sunos-x64@0.16.17": + version "0.16.17" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.16.17.tgz#abc60e7c4abf8b89fb7a4fe69a1484132238022c" + integrity sha512-xtVUiev38tN0R3g8VhRfN7Zl42YCJvyBhRKw1RJjwE1d2emWTVToPLNEQj/5Qxc6lVFATDiy6LjVHYhIPrLxzw== + +"@esbuild/win32-arm64@0.16.17": + version "0.16.17" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.16.17.tgz#7b0ff9e8c3265537a7a7b1fd9a24e7bd39fcd87a" + integrity sha512-ga8+JqBDHY4b6fQAmOgtJJue36scANy4l/rL97W+0wYmijhxKetzZdKOJI7olaBaMhWt8Pac2McJdZLxXWUEQw== + +"@esbuild/win32-ia32@0.16.17": + version "0.16.17" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.16.17.tgz#e90fe5267d71a7b7567afdc403dfd198c292eb09" + integrity sha512-WnsKaf46uSSF/sZhwnqE4L/F89AYNMiD4YtEcYekBt9Q7nj0DiId2XH2Ng2PHM54qi5oPrQ8luuzGszqi/veig== + +"@esbuild/win32-x64@0.16.17": + version "0.16.17" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.16.17.tgz#c5a1a4bfe1b57f0c3e61b29883525c6da3e5c091" + integrity sha512-y+EHuSchhL7FjHgvQL/0fnnFmO4T1bhvWANX6gcnqTjtnKWbTvUMCpGnv2+t+31d7RzyEAYAd4u2fnIhHL6N/Q== + "@eslint/eslintrc@^1.0.5": version "1.0.5" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.0.5.tgz#33f1b838dbf1f923bfa517e008362b78ddbbf318" @@ -1101,6 +1211,11 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.2.tgz#423c77877d0569db20e1fc80885ac4118314010e" integrity sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA== +"@tsconfig/node16@^1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.3.tgz#472eaab5f15c1ffdd7f8628bd4c4f753995ec79e" + integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ== + "@types/ajv@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@types/ajv/-/ajv-1.0.0.tgz#4fb2440742f2f6c30e7fb0797b839fc6f696682a" @@ -2247,36 +2362,44 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2: shebang-command "^2.0.0" which "^2.0.1" -css-select@^4.1.3: - version "4.3.0" - resolved "https://registry.yarnpkg.com/css-select/-/css-select-4.3.0.tgz#db7129b2846662fd8628cfc496abb2b59e41529b" - integrity sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ== +css-select@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.1.0.tgz#b8ebd6554c3637ccc76688804ad3f6a6fdaea8a6" + integrity sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg== dependencies: boolbase "^1.0.0" - css-what "^6.0.1" - domhandler "^4.3.1" - domutils "^2.8.0" + css-what "^6.1.0" + domhandler "^5.0.2" + domutils "^3.0.1" nth-check "^2.0.1" -css-tree@^1.1.2, css-tree@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d" - integrity sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q== +css-tree@^2.2.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-2.3.1.tgz#10264ce1e5442e8572fc82fbe490644ff54b5c20" + integrity sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw== dependencies: - mdn-data "2.0.14" - source-map "^0.6.1" + mdn-data "2.0.30" + source-map-js "^1.0.1" -css-what@^6.0.1: +css-tree@~2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-2.2.1.tgz#36115d382d60afd271e377f9c5f67d02bd48c032" + integrity sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA== + dependencies: + mdn-data "2.0.28" + source-map-js "^1.0.1" + +css-what@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== -csso@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/csso/-/csso-4.2.0.tgz#ea3a561346e8dc9f546d6febedd50187cf389529" - integrity sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA== +csso@^5.0.5: + version "5.0.5" + resolved "https://registry.yarnpkg.com/csso/-/csso-5.0.5.tgz#f9b7fe6cc6ac0b7d90781bb16d5e9874303e2ca6" + integrity sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ== dependencies: - css-tree "^1.1.2" + css-tree "~2.2.0" dashdash@^1.12.0: version "1.14.1" @@ -2427,19 +2550,35 @@ dom-serializer@^1.0.1: domhandler "^4.2.0" entities "^2.0.0" -domelementtype@^2.0.1, domelementtype@^2.2.0: +dom-serializer@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" + integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.2" + entities "^4.2.0" + +domelementtype@^2.0.1, domelementtype@^2.2.0, domelementtype@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== -domhandler@^4.0.0, domhandler@^4.2.0, domhandler@^4.3.1: +domhandler@^4.0.0, domhandler@^4.2.0: version "4.3.1" resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.1.tgz#8d792033416f59d68bc03a5aa7b018c1ca89279c" integrity sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ== dependencies: domelementtype "^2.2.0" -domutils@^2.5.2, domutils@^2.8.0: +domhandler@^5.0.1, domhandler@^5.0.2: + version "5.0.3" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" + integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== + dependencies: + domelementtype "^2.3.0" + +domutils@^2.5.2: version "2.8.0" resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135" integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A== @@ -2448,6 +2587,15 @@ domutils@^2.5.2, domutils@^2.8.0: domelementtype "^2.2.0" domhandler "^4.2.0" +domutils@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.0.1.tgz#696b3875238338cb186b6c0612bd4901c89a4f1c" + integrity sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q== + dependencies: + dom-serializer "^2.0.0" + domelementtype "^2.3.0" + domhandler "^5.0.1" + ecc-jsbn@~0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" @@ -2510,6 +2658,11 @@ entities@^2.0.0, entities@^2.0.3: resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== +entities@^4.2.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.4.0.tgz#97bdaba170339446495e653cfd2db78962900174" + integrity sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA== + entities@~2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5" @@ -2555,131 +2708,33 @@ es6-error@^4.0.1: resolved "https://registry.yarnpkg.com/es6-error/-/es6-error-4.1.1.tgz#9e3af407459deed47e9a91f9b885a84eb05c561d" integrity sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg== -esbuild-android-64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.38.tgz#5b94a1306df31d55055f64a62ff6b763a47b7f64" - integrity sha512-aRFxR3scRKkbmNuGAK+Gee3+yFxkTJO/cx83Dkyzo4CnQl/2zVSurtG6+G86EQIZ+w+VYngVyK7P3HyTBKu3nw== - -esbuild-android-arm64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.38.tgz#78acc80773d16007de5219ccce544c036abd50b8" - integrity sha512-L2NgQRWuHFI89IIZIlpAcINy9FvBk6xFVZ7xGdOwIm8VyhX1vNCEqUJO3DPSSy945Gzdg98cxtNt8Grv1CsyhA== - -esbuild-darwin-64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.38.tgz#e02b1291f629ebdc2aa46fabfacc9aa28ff6aa46" - integrity sha512-5JJvgXkX87Pd1Og0u/NJuO7TSqAikAcQQ74gyJ87bqWRVeouky84ICoV4sN6VV53aTW+NE87qLdGY4QA2S7KNA== - -esbuild-darwin-arm64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.38.tgz#01eb6650ec010b18c990e443a6abcca1d71290a9" - integrity sha512-eqF+OejMI3mC5Dlo9Kdq/Ilbki9sQBw3QlHW3wjLmsLh+quNfHmGMp3Ly1eWm981iGBMdbtSS9+LRvR2T8B3eQ== - -esbuild-freebsd-64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.38.tgz#790b8786729d4aac7be17648f9ea8e0e16475b5e" - integrity sha512-epnPbhZUt93xV5cgeY36ZxPXDsQeO55DppzsIgWM8vgiG/Rz+qYDLmh5ts3e+Ln1wA9dQ+nZmVHw+RjaW3I5Ig== - -esbuild-freebsd-arm64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.38.tgz#b66340ab28c09c1098e6d9d8ff656db47d7211e6" - integrity sha512-/9icXUYJWherhk+y5fjPI5yNUdFPtXHQlwP7/K/zg8t8lQdHVj20SqU9/udQmeUo5pDFHMYzcEFfJqgOVeKNNQ== - -esbuild-linux-32@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.38.tgz#7927f950986fd39f0ff319e92839455912b67f70" - integrity sha512-QfgfeNHRFvr2XeHFzP8kOZVnal3QvST3A0cgq32ZrHjSMFTdgXhMhmWdKzRXP/PKcfv3e2OW9tT9PpcjNvaq6g== - -esbuild-linux-64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.14.38.tgz#4893d07b229d9cfe34a2b3ce586399e73c3ac519" - integrity sha512-uuZHNmqcs+Bj1qiW9k/HZU3FtIHmYiuxZ/6Aa+/KHb/pFKr7R3aVqvxlAudYI9Fw3St0VCPfv7QBpUITSmBR1Q== - -esbuild-linux-arm64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.38.tgz#8442402e37d0b8ae946ac616784d9c1a2041056a" - integrity sha512-HlMGZTEsBrXrivr64eZ/EO0NQM8H8DuSENRok9d+Jtvq8hOLzrxfsAT9U94K3KOGk2XgCmkaI2KD8hX7F97lvA== - -esbuild-linux-arm@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.38.tgz#d5dbf32d38b7f79be0ec6b5fb2f9251fd9066986" - integrity sha512-FiFvQe8J3VKTDXG01JbvoVRXQ0x6UZwyrU4IaLBZeq39Bsbatd94Fuc3F1RGqPF5RbIWW7RvkVQjn79ejzysnA== - -esbuild-linux-mips64le@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.38.tgz#95081e42f698bbe35d8ccee0e3a237594b337eb5" - integrity sha512-qd1dLf2v7QBiI5wwfil9j0HG/5YMFBAmMVmdeokbNAMbcg49p25t6IlJFXAeLzogv1AvgaXRXvgFNhScYEUXGQ== - -esbuild-linux-ppc64le@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.38.tgz#dceb0a1b186f5df679618882a7990bd422089b47" - integrity sha512-mnbEm7o69gTl60jSuK+nn+pRsRHGtDPfzhrqEUXyCl7CTOCLtWN2bhK8bgsdp6J/2NyS/wHBjs1x8aBWwP2X9Q== - -esbuild-linux-riscv64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.38.tgz#61fb8edb75f475f9208c4a93ab2bfab63821afd2" - integrity sha512-+p6YKYbuV72uikChRk14FSyNJZ4WfYkffj6Af0/Tw63/6TJX6TnIKE+6D3xtEc7DeDth1fjUOEqm+ApKFXbbVQ== - -esbuild-linux-s390x@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.38.tgz#34c7126a4937406bf6a5e69100185fd702d12fe0" - integrity sha512-0zUsiDkGJiMHxBQ7JDU8jbaanUY975CdOW1YDrurjrM0vWHfjv9tLQsW9GSyEb/heSK1L5gaweRjzfUVBFoybQ== - -esbuild-netbsd-64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.38.tgz#322ea9937d9e529183ee281c7996b93eb38a5d95" - integrity sha512-cljBAApVwkpnJZfnRVThpRBGzCi+a+V9Ofb1fVkKhtrPLDYlHLrSYGtmnoTVWDQdU516qYI8+wOgcGZ4XIZh0Q== - -esbuild-openbsd-64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.38.tgz#1ca29bb7a2bf09592dcc26afdb45108f08a2cdbd" - integrity sha512-CDswYr2PWPGEPpLDUO50mL3WO/07EMjnZDNKpmaxUPsrW+kVM3LoAqr/CE8UbzugpEiflYqJsGPLirThRB18IQ== - -esbuild-sunos-64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.38.tgz#c9446f7d8ebf45093e7bb0e7045506a88540019b" - integrity sha512-2mfIoYW58gKcC3bck0j7lD3RZkqYA7MmujFYmSn9l6TiIcAMpuEvqksO+ntBgbLep/eyjpgdplF7b+4T9VJGOA== - -esbuild-windows-32@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.38.tgz#f8e9b4602fd0ccbd48e5c8d117ec0ba4040f2ad1" - integrity sha512-L2BmEeFZATAvU+FJzJiRLFUP+d9RHN+QXpgaOrs2klshoAm1AE6Us4X6fS9k33Uy5SzScn2TpcgecbqJza1Hjw== - -esbuild-windows-64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.38.tgz#280f58e69f78535f470905ce3e43db1746518107" - integrity sha512-Khy4wVmebnzue8aeSXLC+6clo/hRYeNIm0DyikoEqX+3w3rcvrhzpoix0S+MF9vzh6JFskkIGD7Zx47ODJNyCw== - -esbuild-windows-arm64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.38.tgz#d97e9ac0f95a4c236d9173fa9f86c983d6a53f54" - integrity sha512-k3FGCNmHBkqdJXuJszdWciAH77PukEyDsdIryEHn9cKLQFxzhT39dSumeTuggaQcXY57UlmLGIkklWZo2qzHpw== - -esbuild@^0.14.27: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.38.tgz#99526b778cd9f35532955e26e1709a16cca2fb30" - integrity sha512-12fzJ0fsm7gVZX1YQ1InkOE5f9Tl7cgf6JPYXRJtPIoE0zkWAbHdPHVPPaLi9tYAcEBqheGzqLn/3RdTOyBfcA== +esbuild@^0.16.14: + version "0.16.17" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.16.17.tgz#fc2c3914c57ee750635fee71b89f615f25065259" + integrity sha512-G8LEkV0XzDMNwXKgM0Jwu3nY3lSTwSGY6XbxM9cr9+s0T/qSV1q1JVPBGzm3dcjhCic9+emZDmMffkwgPeOeLg== optionalDependencies: - esbuild-android-64 "0.14.38" - esbuild-android-arm64 "0.14.38" - esbuild-darwin-64 "0.14.38" - esbuild-darwin-arm64 "0.14.38" - esbuild-freebsd-64 "0.14.38" - esbuild-freebsd-arm64 "0.14.38" - esbuild-linux-32 "0.14.38" - esbuild-linux-64 "0.14.38" - esbuild-linux-arm "0.14.38" - esbuild-linux-arm64 "0.14.38" - esbuild-linux-mips64le "0.14.38" - esbuild-linux-ppc64le "0.14.38" - esbuild-linux-riscv64 "0.14.38" - esbuild-linux-s390x "0.14.38" - esbuild-netbsd-64 "0.14.38" - esbuild-openbsd-64 "0.14.38" - esbuild-sunos-64 "0.14.38" - esbuild-windows-32 "0.14.38" - esbuild-windows-64 "0.14.38" - esbuild-windows-arm64 "0.14.38" + "@esbuild/android-arm" "0.16.17" + "@esbuild/android-arm64" "0.16.17" + "@esbuild/android-x64" "0.16.17" + "@esbuild/darwin-arm64" "0.16.17" + "@esbuild/darwin-x64" "0.16.17" + "@esbuild/freebsd-arm64" "0.16.17" + "@esbuild/freebsd-x64" "0.16.17" + "@esbuild/linux-arm" "0.16.17" + "@esbuild/linux-arm64" "0.16.17" + "@esbuild/linux-ia32" "0.16.17" + "@esbuild/linux-loong64" "0.16.17" + "@esbuild/linux-mips64el" "0.16.17" + "@esbuild/linux-ppc64" "0.16.17" + "@esbuild/linux-riscv64" "0.16.17" + "@esbuild/linux-s390x" "0.16.17" + "@esbuild/linux-x64" "0.16.17" + "@esbuild/netbsd-x64" "0.16.17" + "@esbuild/openbsd-x64" "0.16.17" + "@esbuild/sunos-x64" "0.16.17" + "@esbuild/win32-arm64" "0.16.17" + "@esbuild/win32-ia32" "0.16.17" + "@esbuild/win32-x64" "0.16.17" escalade@^3.1.1: version "3.1.1" @@ -3662,6 +3717,13 @@ is-core-module@^2.8.1: dependencies: has "^1.0.3" +is-core-module@^2.9.0: + version "2.11.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.11.0.tgz#ad4cb3e3863e814523c96f3f58d26cc570ff0144" + integrity sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw== + dependencies: + has "^1.0.3" + is-date-object@^1.0.1: version "1.0.5" resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.5.tgz#0841d5536e724c25597bf6ea62e1bd38298df31f" @@ -4299,10 +4361,15 @@ matrix-widget-api@^1.0.0: "@types/events" "^3.0.0" events "^3.2.0" -mdn-data@2.0.14: - version "2.0.14" - resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" - integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow== +mdn-data@2.0.28: + version "2.0.28" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.28.tgz#5ec48e7bef120654539069e1ae4ddc81ca490eba" + integrity sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g== + +mdn-data@2.0.30: + version "2.0.30" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.30.tgz#ce4df6f80af6cfbe218ecd5c552ba13c4dfa08cc" + integrity sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA== mdurl@^1.0.1: version "1.0.1" @@ -4484,7 +4551,7 @@ nanoid@3.1.20: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.20.tgz#badc263c6b1dcf14b71efaa85f6ab4c1d6cfc788" integrity sha512-a1cQNyczgKbLX9jwbS/+d7W8fX/RfgYR7lVWwWOGIPNgK2m0MWvrGF6/m4kk6U3QcFMnZf3RIhL0v2Jgh/0Uxw== -nanoid@^3.1.30, nanoid@^3.3.3, nanoid@^3.3.4: +nanoid@^3.1.30, nanoid@^3.3.4: version "3.3.4" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== @@ -4913,12 +4980,12 @@ postcss@^8.3.11: picocolors "^1.0.0" source-map-js "^1.0.1" -postcss@^8.4.13: - version "8.4.13" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.13.tgz#7c87bc268e79f7f86524235821dfdf9f73e5d575" - integrity sha512-jtL6eTBrza5MPzy8oJLFuUscHDXTV5KcLlqAWHl5q5WYRfnNRGSmOZmOZ1T6Gy7A99mOZfqungmZMpMmCVJ8ZA== +postcss@^8.4.21: + version "8.4.21" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.21.tgz#c639b719a57efc3187b13a1d765675485f4134f4" + integrity sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg== dependencies: - nanoid "^3.3.3" + nanoid "^3.3.4" picocolors "^1.0.0" source-map-js "^1.0.2" @@ -5216,7 +5283,7 @@ resolve-from@^5.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== -resolve@^1.20.0, resolve@^1.22.0: +resolve@^1.20.0: version "1.22.0" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.0.tgz#5e0b8c67c15df57a89bdbabe603a002f21731198" integrity sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw== @@ -5225,6 +5292,15 @@ resolve@^1.20.0, resolve@^1.22.0: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" +resolve@^1.22.1: + version "1.22.1" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177" + integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw== + dependencies: + is-core-module "^2.9.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + resolve@^2.0.0-next.3: version "2.0.0-next.3" resolved "https://registry.yarnpkg.com/resolve/-/resolve-2.0.0-next.3.tgz#d41016293d4a8586a39ca5d9b5f15cbea1f55e46" @@ -5250,10 +5326,10 @@ rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" -rollup@^2.59.0: - version "2.72.0" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.72.0.tgz#f94280b003bcf9f2f1f2594059a9db5abced371e" - integrity sha512-KqtR2YcO35/KKijg4nx4STO3569aqCUeGRkKWnJ6r+AvBBrVY9L4pmf4NHVrQr4mTOq6msbohflxr2kpihhaOA== +rollup@^3.10.0: + version "3.19.1" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.19.1.tgz#2b3a31ac1ff9f3afab2e523fa687fef5b0ee20fc" + integrity sha512-lAbrdN7neYCg/8WaoWn/ckzCtz+jr70GFfYdlf50OF7387HTg+wiuiqJRFYawwSPpqfqDNYqK7smY/ks2iAudg== optionalDependencies: fsevents "~2.3.2" @@ -5542,11 +5618,6 @@ sshpk@^1.7.0: safer-buffer "^2.0.2" tweetnacl "~0.14.0" -stable@^0.1.8: - version "0.1.8" - resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf" - integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w== - stack-trace@0.0.x: version "0.0.10" resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" @@ -5700,18 +5771,17 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== -svgo@^2.7.0: - version "2.8.0" - resolved "https://registry.yarnpkg.com/svgo/-/svgo-2.8.0.tgz#4ff80cce6710dc2795f0c7c74101e6764cfccd24" - integrity sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg== +svgo@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/svgo/-/svgo-3.0.2.tgz#5e99eeea42c68ee0dc46aa16da093838c262fe0a" + integrity sha512-Z706C1U2pb1+JGP48fbazf3KxHrWOsLme6Rv7imFBn5EnuanDW1GPaA/P1/dvObE670JDePC3mnj0k0B7P0jjQ== dependencies: "@trysound/sax" "0.2.0" commander "^7.2.0" - css-select "^4.1.3" - css-tree "^1.1.3" - csso "^4.2.0" + css-select "^5.1.0" + css-tree "^2.2.1" + csso "^5.0.5" picocolors "^1.0.0" - stable "^0.1.8" tdigest@^0.1.1: version "0.1.1" @@ -5947,23 +6017,23 @@ verror@1.10.0: core-util-is "1.0.2" extsprintf "^1.2.0" -vite-svg-loader@^3.4.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/vite-svg-loader/-/vite-svg-loader-3.4.0.tgz#4638827fe86b85ecfcea1ad61dd972c351d5befd" - integrity sha512-xD3yb1FX+f4l9/TmsYIqyki8ncpcVsZ2gEJFh/wLuNNqt55C8OJ+JlcMWOA/Z9gRA+ylV/TA1wmJLxzZkCRqlA== +vite-svg-loader@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/vite-svg-loader/-/vite-svg-loader-4.0.0.tgz#1cec4337dba3c23ab13bcabb111896e251b047ac" + integrity sha512-0MMf1yzzSYlV4MGePsLVAOqXsbF5IVxbn4EEzqRnWxTQl8BJg/cfwIzfQNmNQxZp5XXwd4kyRKF1LytuHZTnqA== dependencies: "@vue/compiler-sfc" "^3.2.20" - svgo "^2.7.0" + svgo "^3.0.2" -vite@^2.9.13: - version "2.9.13" - resolved "https://registry.yarnpkg.com/vite/-/vite-2.9.13.tgz#859cb5d4c316c0d8c6ec9866045c0f7858ca6abc" - integrity sha512-AsOBAaT0AD7Mhe8DuK+/kE4aWYFMx/i0ZNi98hJclxb4e0OhQcZYUrvLjIaQ8e59Ui7txcvKMiJC1yftqpQoDw== +vite@^4.1.4: + version "4.1.4" + resolved "https://registry.yarnpkg.com/vite/-/vite-4.1.4.tgz#170d93bcff97e0ebc09764c053eebe130bfe6ca0" + integrity sha512-3knk/HsbSTKEin43zHu7jTwYWv81f8kgAL99G5NWBcA1LKvtvcVAC4JjBH1arBunO9kQka+1oGbrMKOjk4ZrBg== dependencies: - esbuild "^0.14.27" - postcss "^8.4.13" - resolve "^1.22.0" - rollup "^2.59.0" + esbuild "^0.16.14" + postcss "^8.4.21" + resolve "^1.22.1" + rollup "^3.10.0" optionalDependencies: fsevents "~2.3.2"