Merge remote-tracking branch 'origin/main' into j94/refactor-jira-oauth

This commit is contained in:
Christian Paul 2023-03-20 14:55:22 +01:00
commit cabc666855
122 changed files with 4376 additions and 1699 deletions

View File

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

View File

@ -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)
==================

View File

@ -1 +0,0 @@
Add support for end-to-bridge encryption via MSC3202.

View File

@ -1 +0,0 @@
Remove support for Pantalaimon-based encryption.

View File

@ -1 +0,0 @@
Clarify GitLab setup docs

View File

@ -1 +0,0 @@
Parent projects are now taken into account when calculating a user's access level to a GitLab project.

View File

@ -1 +0,0 @@
Ensure bridge treats published and drafted GitHub releases as different events.

View File

@ -1 +0,0 @@
RSS feed polling now uses cache headers sent by servers, which should mean we will be more conservative on resources.

View File

@ -1 +0,0 @@
Fix a bug where unknown keys in a connections state would be clobbered when updated via widget UI.

View File

@ -1 +0,0 @@
Add new GitHubRepo connection config setting `workflowRun.workflows` to filter run reports by workflow name.

View File

@ -1 +0,0 @@
Only build ARM images when merging or releasing, due to slow ARM build times.

View File

@ -1 +0,0 @@
Improve webhook code editor performance.

View File

@ -1 +0,0 @@
A11y: Add alt tags to all images.

View File

@ -1 +0,0 @@
Increase maximum size of incoming webhook payload from `100kb` to `10mb`.

View File

@ -1 +0,0 @@
Mark encryption feature as experimental (config option is now `experimentalEncryption`).

View File

@ -1 +0,0 @@
Improve startup stability by not loading all room state at once.

View File

@ -1 +0,0 @@
Cache yarn dependencies during Docker build.

View File

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

View File

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

View File

@ -27,3 +27,4 @@
- [Workers](./advanced/workers.md)
- [🔒 Encryption](./advanced/encryption.md)
- [🪀 Widgets](./advanced/widgets.md)
- [Service Bots](./advanced/service_bots.md)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"
}
}

View File

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

View File

@ -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");

View File

@ -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<string, AdminRoom> = 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,15 +117,23 @@ 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,
);
}
@ -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) => {
@ -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) {
@ -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<AdminAccountData>(
let accountData = await botUser.intent.underlyingClient.getSafeRoomAccountData<AdminAccountData>(
BRIDGE_ROOM_TYPE, roomId,
);
if (!accountData) {
accountData = await this.as.botClient.getSafeRoomAccountData<AdminAccountData>(
accountData = await botUser.intent.underlyingClient.getSafeRoomAccountData<AdminAccountData>(
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)) {
@ -720,13 +734,16 @@ export class Bridge {
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,
);
}
@ -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<MatrixMemberContent>) {
if (event.state_key !== this.as.botUserId) {
// Only interested in bot leaves.
private async onRoomLeave(roomId: string, matrixEvent: MatrixEvent<MatrixMemberContent>) {
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
const botUsersInRoom = this.botUsersManager.getBotUsersInRoom(roomId);
// Try each bot in the room until one handles the command
for (const botUser of botUsersInRoom) {
try {
await (
new SetupConnection(
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,14 +912,18 @@ 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);
);
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<MatrixMemberContent>) {
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<AdminAccountData>(
const adminAccountData = await botUser.intent.underlyingClient.getSafeRoomAccountData<AdminAccountData>(
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) ||
// 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 {
}
}
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?.[this.as.botUserId] || plEvent.defaultUserLevel;
const previousPl = plEvent.previousContent?.users?.[this.as.botUserId] || plEvent.previousContent?.users_default;
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, this.as.botIntent, this.config.widgets);
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;
}
@ -1172,13 +1254,13 @@ export class Bridge {
}
private async getOrCreateAdminRoomForUser(userId: string): Promise<AdminRoom> {
private async getOrCreateAdminRoom(intent: Intent, userId: string): Promise<AdminRoom> {
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,7 +1306,7 @@ 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,
@ -1227,17 +1314,18 @@ export class Bridge {
res,
issueInfo.projects,
this.as,
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;

View File

@ -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");
@ -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(),
}
}
}
@ -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,
@ -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 || [];
@ -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) {
@ -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<string, unknown> {
let config: undefined|Record<string, unknown>;
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;

View File

@ -1,15 +1,18 @@
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",
},
@ -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<string, unknown>, parentNode?: YAMLSeq) {
const entries = Object.entries(obj);

View File

@ -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<string, unknown>) {
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<string, unknown>,
) {
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<any>, 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<any>, 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)[] {
@ -295,6 +342,10 @@ export class ConnectionManager extends EventEmitter {
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);
}
@ -370,7 +421,7 @@ export class ConnectionManager extends EventEmitter {
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");

View File

@ -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<StateType extends IConnectionState = IConnectionState> extends BaseConnection {
protected enabledHelpCategories?: string[];
export abstract class CommandConnection<StateType extends IConnectionState = IConnectionState, ValidatedStateType extends StateType = StateType> 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<StateType extends IConnectionState = ICo
}
public async onStateUpdate(stateEv: MatrixEvent<unknown>) {
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>|ValidatedStateType;
public async onMessageEvent(ev: MatrixEvent<MatrixMessageContent>, checkPermission: PermissionCheckFn) {
const commandResult = await handleCommand(
@ -80,6 +80,6 @@ export abstract class CommandConnection<StateType extends IConnectionState = ICo
@botCommand("help", "This help text")
public async helpCommand() {
return this.botClient.sendEvent(this.roomId, 'm.room.message', this.helpMessage(this.commandPrefix, this.enabledHelpCategories, this.includeTitlesInHelp));
return this.botClient.sendEvent(this.roomId, 'm.room.message', this.helpMessage(this.commandPrefix, this.helpCategories, this.includeTitlesInHelp));
}
}

View File

@ -1,4 +1,4 @@
import {Appservice, StateEvent} from "matrix-bot-sdk";
import {Appservice, Intent, StateEvent} from "matrix-bot-sdk";
import { IConnection, IConnectionState, InstantiateConnectionOpts } from ".";
import { ApiError, ErrCode } from "../api";
import { BridgeConfigFeeds } from "../Config/Config";
@ -42,13 +42,13 @@ const MAX_LAST_RESULT_ITEMS = 5;
export class FeedConnection extends BaseConnection implements IConnection {
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.feed";
static readonly EventTypes = [ FeedConnection.CanonicalEventType ];
static readonly ServiceCategory = "feed";
static readonly ServiceCategory = "feeds";
public static createConnectionForState(roomId: string, event: StateEvent<any>, {config, as, storage}: InstantiateConnectionOpts) {
public static createConnectionForState(roomId: string, event: StateEvent<any>, {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<void> {
@ -74,7 +74,7 @@ export class FeedConnection extends BaseConnection implements IConnection {
}
}
static async provisionConnection(roomId: string, _userId: string, data: Record<string, unknown> = {}, {as, config, storage}: ProvisionConnectionOpts) {
static async provisionConnection(roomId: string, _userId: string, data: Record<string, unknown> = {}, {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<void> {
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 {

View File

@ -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");
@ -43,33 +44,36 @@ export class FigmaFileConnection extends BaseConnection implements IConnection {
}
}
public static createConnectionForState(roomId: string, event: StateEvent<any>, {config, as, storage}: InstantiateConnectionOpts) {
public static createConnectionForState(roomId: string, event: StateEvent<any>, {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<string, unknown> = {}, {as, config, storage}: ProvisionConnectionOpts) {
static async provisionConnection(roomId: string, userId: string, data: Record<string, unknown> = {}, {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,7 +112,12 @@ 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");

View File

@ -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 {
/**
@ -130,19 +131,19 @@ export class GenericHookConnection extends BaseConnection implements IConnection
};
}
static async createConnectionForState(roomId: string, event: StateEvent<Record<string, unknown>>, {as, config, messageClient}: InstantiateConnectionOpts) {
static async createConnectionForState(roomId: string, event: StateEvent<Record<string, unknown>>, {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<GenericHookAccountData>(GenericHookConnection.CanonicalEventType, roomId, {});
const acctData = await intent.underlyingClient.getSafeRoomAccountData<GenericHookAccountData>(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<string, unknown> = {}, {as, config, messageClient}: ProvisionConnectionOpts) {
static async provisionConnection(roomId: string, userId: string, data: Record<string, unknown> = {}, {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<GenericHookAccountData>(GenericHookConnection.CanonicalEventType, roomId, {});
static async ensureRoomAccountData(roomId: string, intent: Intent, hookId: string, stateKey: string, remove = false) {
const data = await intent.underlyingClient.getSafeRoomAccountData<GenericHookAccountData>(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,13 +199,16 @@ 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) {
private readonly as: Appservice,
private readonly intent: Intent,
) {
super(roomId, stateKey, GenericHookConnection.CanonicalEventType);
if (state.transformationFunction && config.allowJsTransformationFunctions) {
this.transformationFunction = new Script(state.transformationFunction);
@ -217,39 +219,37 @@ export class GenericHookConnection extends BaseConnection implements IConnection
return this.state.priority || super.priority;
}
public isInterestedInStateEvent(eventType: string, stateKey: string) {
return GenericHookConnection.EventTypes.includes(eventType) && this.stateKey === stateKey;
}
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.
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;
@ -376,7 +376,10 @@ 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);
@ -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<string, unknown>) {
// 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

View File

@ -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<any>, {
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<string>(); //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,19 +94,37 @@ 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<string, undefined>({ 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) {
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) {
@ -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 });
}
}
public async ensureGrant(sender?: string) {
await this.grantChecker.assertConnectionGranted(this.roomId, GitHubDiscussionConnection.grantKey(this.state), sender);
}
}

View File

@ -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<any>, {
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<Record<string, unknown>> {
public static async onQueryRoom(result: RegExpExecArray, opts: {githubInstance: GithubInstance, as: Appservice}): Promise<Record<string, unknown>> {
if (!result || result.length < 2) {
log.error(`Invalid alias pattern '${result}'`);
throw Error("Could not find issue");
@ -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) {
@ -168,8 +180,14 @@ export class GitHubDiscussionSpace extends BaseConnection implements IConnection
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 {

View File

@ -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<any>, {
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,15 +154,18 @@ 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,) {
private config: BridgeConfigGitHub,
) {
super(roomId, stateKey, GitHubIssueConnection.CanonicalEventType);
}
@ -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<MatrixMessageContent>, 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 });
}
}

View File

@ -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<any>, {config, as}: InstantiateConnectionOpts) {
public static createConnectionForState(roomId: string, event: StateEvent<any>, {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<GitHubProjectConnection> {
public static getGrantKey(projectId: number) {
return `${this.CanonicalEventType}/${projectId}`;
}
static async onOpenProject(project: ProjectsGetResponseData, as: Appservice, intent: Intent, config: BridgeConfig, inviteUser: string): Promise<GitHubProjectConnection> {
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,19 +61,31 @@ export class GitHubProjectConnection extends BaseConnection implements IConnecti
},
],
});
await new GrantChecker(as.botIntent, 'github').grantConnection(roomId, this.getGrantKey(project.id));
return new GitHubProjectConnection(roomId, as, state, project.url)
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) {
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) {

View File

@ -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,7 +96,7 @@ export type GitHubRepoConnectionTarget = GitHubRepoConnectionOrgTarget|GitHubRep
export type GitHubRepoResponseItem = GetConnectionsResponseItem<GitHubRepoConnectionState>;
type AllowedEventsNames =
export type AllowedEventsNames =
"issue.changed" |
"issue.created" |
"issue.edited" |
@ -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: {
@ -295,43 +323,45 @@ const WORKFLOW_CONCLUSION_TO_NOTICE: Record<WorkflowRunCompletedEvent["workflow_
const LABELED_DEBOUNCE_MS = 5000;
const CREATED_GRACE_PERIOD_MS = 6000;
const DEFAULT_HOTLINK_PREFIX = "#";
const MAX_RETURNED_TARGETS = 10;
function compareEmojiStrings(e0: string, e1: string, e0Index = 0) {
return e0.codePointAt(e0Index) === e1.codePointAt(0);
}
export interface GitHubTargetFilter {
search?: string;
orgName?: string;
page?: number;
perPage?: number;
}
/**
* Handles rooms connected to a GitHub repo.
*/
@Connection
export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnectionState> implements IConnection {
static validateState(state: unknown, isExistingState = false): GitHubRepoConnectionState {
export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnectionState, ConnectionValidatedState> 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<string, unknown>, {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<GitHubRepoConnection
const me = await octokit.users.getAuthenticated();
let permissionLevel;
try {
const repo = await octokit.repos.getCollaboratorPermissionLevel({owner: validData.org, repo: validData.repo, username: me.data.login });
permissionLevel = repo.data.permission;
const githubRepo = await octokit.repos.getCollaboratorPermissionLevel({owner: org, repo, username: me.data.login });
permissionLevel = githubRepo.data.permission;
} catch (ex) {
throw new ApiError("Could not determine if the user has access to this repository, does the repository exist?", ErrCode.ForbiddenUser);
}
@ -348,6 +378,14 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
if (permissionLevel !== "admin" && permissionLevel !== "write") {
throw new ApiError("You must at least have write permissions to bridge this repository", ErrCode.ForbiddenUser);
}
}
static async provisionConnection(roomId: string, userId: string, data: Record<string, unknown>, {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<GitHubRepoConnection
);
}
const stateEventKey = `${validData.org}/${validData.repo}`;
await as.botClient.sendStateEvent(roomId, this.CanonicalEventType, stateEventKey, validData);
await new GrantChecker(as.botIntent, 'github').grantConnection(roomId, this.getGrantKey(validData.org, validData.repo));
await intent.underlyingClient.sendStateEvent(roomId, this.CanonicalEventType, stateEventKey, validData);
return {
stateEventContent: validData,
connection: new GitHubRepoConnection(roomId, as, validData, tokenStore, stateEventKey, github, config.github),
connection: new GitHubRepoConnection(roomId, as, intent, validData, tokenStore, stateEventKey, github, config.github),
}
}
@ -377,11 +416,14 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
static readonly ServiceCategory = "github";
static readonly QueryRoomRegex = /#github_(.+)_(.+):.*/;
static async createConnectionForState(roomId: string, state: StateEvent<Record<string, unknown>>, {as, tokenStore, github, config}: InstantiateConnectionOpts) {
static async createConnectionForState(roomId: string, state: StateEvent<Record<string, unknown>>, {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<unknown> {
@ -469,9 +511,13 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
public debounceOnIssueLabeled = new Map<number, {labels: Set<string>, 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,
@ -482,16 +528,15 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
stateKey,
GitHubRepoConnection.CanonicalEventType,
state,
as.botClient,
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<GitHubRepoConnection
return this.state.priority || super.priority;
}
protected validateConnectionState(content: unknown) {
return GitHubRepoConnection.validateState(content);
public async ensureGrant(sender?: string, state = this.state) {
await this.grantChecker.assertConnectionGranted(this.roomId, state, sender);
}
protected async validateConnectionState(content: unknown) {
const state = GitHubRepoConnection.validateState(content);
// Validate the permissions of this state
await this.ensureGrant(undefined, state);
return state;
}
public async onStateUpdate(stateEv: MatrixEvent<unknown>) {
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<GitHubRepoConnection
message += ` [Issue Room](https://matrix.to/#/${this.as.getAlias(GitHubIssueConnection.generateAliasLocalpart(this.org, this.repo, issue.number))})`;
}
const content = emoji.emojify(message);
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content ,
formatted_body: md.renderInline(content),
@ -624,14 +675,14 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
event: reviewEvent,
});
} catch (ex) {
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: ev.event_id,
key: "⛔",
}
});
await this.as.botClient.sendEvent(this.roomId, 'm.room.message', {
await this.intent.underlyingClient.sendEvent(this.roomId, 'm.room.message', {
msgtype: "m.notice",
body: `Failed to submit review: ${ex.message}`,
});
@ -750,7 +801,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
const workflow = workflows.data.workflows.find(w => 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<GitHubRepoConnection
throw ex;
}
await this.as.botIntent.sendText(this.roomId, `Workflow started.`, "m.notice");
await this.intent.sendText(this.roomId, `Workflow started.`, "m.notice");
}
public async onIssueCreated(event: IssuesOpenedEvent) {
@ -808,7 +859,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
}
const content = emoji.emojify(message);
const labels = FormatUtil.formatLabels(event.issue.labels?.map(l => ({ name: l.name, description: l.description || undefined, color: l.color || undefined })));
await this.as.botIntent.sendEvent(this.roomId, {
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<GitHubRepoConnection
}
}
const content = `**${event.sender.login}** ${state} issue [${orgRepoName}#${event.issue.number}](${event.issue.html_url}): "${emoji.emojify(event.issue.title)}"${withComment}`;
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -873,7 +924,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
log.info(`onIssueEdited ${this.roomId} ${this.org}/${this.repo} #${event.issue.number}`);
const orgRepoName = event.repository.full_name;
const content = `**${event.sender.login}** edited issue [${orgRepoName}#${event.issue.number}](${event.issue.html_url}): "${emoji.emojify(event.issue.title)}"`;
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -904,7 +955,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
const orgRepoName = event.repository.full_name;
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}`: ""),
@ -962,7 +1013,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
}
const content = emoji.emojify(`**${event.sender.login}** ${verb} a new PR [${orgRepoName}#${event.pull_request.number}](${event.pull_request.html_url}): "${event.pull_request.title}"`);
const labels = FormatUtil.formatLabels(event.pull_request.labels?.map(l => ({ name: l.name, description: l.description || undefined, color: l.color || undefined })));
await this.as.botIntent.sendEvent(this.roomId, {
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<GitHubRepoConnection
}
const orgRepoName = event.repository.full_name;
const content = emoji.emojify(`**${event.sender.login}** has marked [${orgRepoName}#${event.pull_request.number}](${event.pull_request.html_url}) as ready to review "${event.pull_request.title}"`);
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -1018,7 +1069,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
return;
}
const content = emoji.emojify(`**${event.sender.login}** ${emojiForReview} ${event.review.state.toLowerCase()} [${orgRepoName}#${event.pull_request.number}](${event.pull_request.html_url}) "${event.pull_request.title}"`);
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -1064,7 +1115,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
}
const content = emoji.emojify(`**${event.sender.login}** ${verb} PR [${orgRepoName}#${event.pull_request.number}](${event.pull_request.html_url}): "${event.pull_request.title}"${withComment}`);
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -1094,7 +1145,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
if (event.release.body) {
content += `\n\n${event.release.body}`
}
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.render(content),
@ -1120,7 +1171,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
if (event.release.body) {
content += `\n\n${event.release.body}`
}
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.render(content),
@ -1153,7 +1204,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
log.info(`onWorkflowCompleted ${this.roomId} ${this.org}/${this.repo} '${workflowRun.id}'`);
const orgRepoName = event.repository.full_name;
const content = `Workflow **${event.workflow.name}** [${WORKFLOW_CONCLUSION_TO_NOTICE[workflowRun.conclusion]}](${workflowRun.html_url}) for ${orgRepoName} on branch \`${workflowRun.head_branch}\``;
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.render(content),
@ -1168,7 +1219,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
}
if (evt.type === 'm.reaction') {
const {event_id, key} = (evt.content as MatrixReactionContent)["m.relates_to"];
const ev = await this.as.botClient.getEvent(this.roomId, event_id);
const ev = await this.intent.underlyingClient.getEvent(this.roomId, event_id);
const issueContent = ev.content["uk.half-shot.matrix-hookshot.github.issue"];
if (!issueContent) {
log.debug('Reaction to event did not pertain to a issue');
@ -1225,7 +1276,7 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
public getProvisionerDetails(): GitHubRepoResponseItem {
return {
...GitHubRepoConnection.getProvisionerDetails(this.as.botUserId),
...GitHubRepoConnection.getProvisionerDetails(this.intent.userId),
id: this.connectionId,
config: {
...this.state,
@ -1233,6 +1284,48 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
}
}
public static async searchInstallationForRepos(octokit: Octokit, orgName: string, installationId: number, searchTerms?: string) {
// First, do a search on GitHub for repos. This will use the user's context so it will find user repos.
let searchRepos: string[]|null = null;
if (searchTerms) {
const terms = encodeURIComponent(searchTerms);
const searchResultsData = (await octokit.search.repos({
q: `${terms} org:${orgName} `,
per_page: MAX_RETURNED_TARGETS,
})).data;
if (searchResultsData.total_count === 0) {
return [];
}
searchRepos = searchResultsData.items.map(r => 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<GitHubRepoConnectionTarget[]> {
// Search for all repos under the user's control.
const octokit = await tokenStore.getOctokitForUser(userId);
@ -1262,37 +1355,25 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
// If we have an instance, search under it.
const ownSelf = await octokit.users.getAuthenticated();
const page = filters.page ?? 1;
const perPage = filters.perPage ?? 10;
try {
let reposPromise;
let installationId;
if (ownSelf.data.login === filters.orgName) {
const userInstallation = await githubInstance.appOctokit.apps.getUserInstallation({username: ownSelf.data.login});
reposPromise = octokit.apps.listInstallationReposForAuthenticatedUser({
page,
installation_id: userInstallation.data.id,
per_page: perPage,
});
installationId = (await githubInstance.appOctokit.apps.getUserInstallation({ username: ownSelf.data.login })).data.id;
} else {
const orgInstallation = await githubInstance.appOctokit.apps.getOrgInstallation({org: filters.orgName});
installationId = (await githubInstance.appOctokit.apps.getOrgInstallation({ org: filters.orgName })).data.id;
// Github will error if the authed user tries to list repos of a disallowed installation, even
// if we got the installation ID from the app's instance.
reposPromise = octokit.apps.listInstallationReposForAuthenticatedUser({
page,
installation_id: orgInstallation.data.id,
per_page: perPage,
});
}
const reposRes = await reposPromise;
return reposRes.data.repositories
const reposRes = await this.searchInstallationForRepos(octokit, filters.orgName, installationId, filters.search);
return reposRes
.map(r => ({
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<GitHubRepoConnection
// Apply previous state to the current config, as provisioners might not return "unknown" keys.
const newState = { ...this.state, ...config };
const validatedConfig = GitHubRepoConnection.validateState(newState);
await this.as.botClient.sendStateEvent(this.roomId, GitHubRepoConnection.CanonicalEventType, this.stateKey, validatedConfig);
await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubRepoConnection.CanonicalEventType, this.stateKey, validatedConfig);
this.state = validatedConfig;
this.hookFilter.enabledHooks = this.state.enableHooks ?? [];
this.hookFilter.ignoredHooks = this.state.ignoreHooks ?? [];
}
public async onRemove() {
log.info(`Removing ${this.toString()} for ${this.roomId}`);
await this.grantChecker.ungrantConnection(this.roomId, { org: this.org, repo: this.repo });
// Do a sanity check that the event exists.
try {
await this.as.botClient.getRoomStateEvent(this.roomId, GitHubRepoConnection.CanonicalEventType, this.stateKey);
await this.as.botClient.sendStateEvent(this.roomId, GitHubRepoConnection.CanonicalEventType, this.stateKey, { disabled: true });
await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubRepoConnection.CanonicalEventType, this.stateKey);
await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubRepoConnection.CanonicalEventType, this.stateKey, { disabled: true });
} catch (ex) {
await this.as.botClient.getRoomStateEvent(this.roomId, GitHubRepoConnection.LegacyCanonicalEventType, this.stateKey);
await this.as.botClient.sendStateEvent(this.roomId, GitHubRepoConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
await this.intent.underlyingClient.getRoomStateEvent(this.roomId, GitHubRepoConnection.LegacyCanonicalEventType, this.stateKey);
await this.intent.underlyingClient.sendStateEvent(this.roomId, GitHubRepoConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
}
}
@ -1334,6 +1415,10 @@ export class GitHubRepoConnection extends CommandConnection<GitHubRepoConnection
}
return true;
}
public static getGrantKey(org: string, repo: string) {
return `${this.CanonicalEventType}/${org}/${repo}`;
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@ -5,6 +5,8 @@ import axios from "axios";
import { GitHubDiscussionSpace } from ".";
import { GithubInstance } from "../Github/GithubInstance";
import { BaseConnection } from "./BaseConnection";
import { ConfigGrantChecker, GrantChecker } from "../grants/GrantCheck";
import { BridgeConfig } from "../Config/Config";
const log = new Logger("GitHubOwnerSpace");
@ -28,13 +30,17 @@ export class GitHubUserSpace extends BaseConnection implements IConnection {
static readonly QueryRoomRegex = /#github_(.+):.*/;
static readonly ServiceCategory = "github";
private static grantKey(state: GitHubUserSpaceConnectionState) {
return `${this.CanonicalEventType}/${state.username}`;
}
public static async createConnectionForState(roomId: string, event: StateEvent<any>, {
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
);
}
@ -137,10 +143,19 @@ 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) {

View File

@ -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<any>, {
config, as, tokenStore, commentProcessor, messageClient}: InstantiateConnectionOpts) {
public static async createConnectionForState(
roomId: string,
event: StateEvent<any>,
{ 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,16 +127,35 @@ 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) {
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) {
@ -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<MatrixMessageContent>, 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),
});
}

View File

@ -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;
@ -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<GitLabRepoConnectionState> implements IConnection {
export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnectionState, ConnectionStateValidated> implements IConnection {
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.gitlab.repository";
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.gitlab.repository";
@ -152,19 +180,30 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
static helpMessage: (cmdPrefix?: string | undefined) => 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<Record<string, unknown>>, {as, tokenStore, config}: InstantiateConnectionOpts) {
static async createConnectionForState(roomId: string, event: StateEvent<Record<string, unknown>>, {as, intent, tokenStore, config}: InstantiateConnectionOpts) {
if (!config.gitlab) {
throw Error('GitLab is not configured');
}
@ -173,18 +212,16 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
if (!instance) {
throw Error('Instance name not recognised');
}
return new GitLabRepoConnection(roomId, event.stateKey, as, state, tokenStore, instance);
return new GitLabRepoConnection(roomId, event.stateKey, as, config.gitlab, intent, state, tokenStore, instance);
}
public static async provisionConnection(roomId: string, requester: string, data: Record<string, unknown>, { 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<GitLabRepoConnection
}
let permissionLevel;
try {
permissionLevel = await client.projects.getMyAccessLevel(validData.path);
permissionLevel = await client.projects.getMyAccessLevel(path);
} catch (ex) {
throw new ApiError("Could not determine if the user has access to this project, does the project exist?", ErrCode.ForbiddenUser);
}
@ -200,11 +237,28 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
if (permissionLevel < AccessLevel.Developer) {
throw new ApiError("You must at least have developer access to bridge this project", ErrCode.ForbiddenUser);
}
return permissionLevel;
}
public static async provisionConnection(roomId: string, requester: string, data: Record<string, unknown>, { 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<GitLabRepoConnection
};
log.warn(`Not creating webhook, permission level is insufficient (${permissionLevel} < ${AccessLevel.Maintainer})`)
}
await as.botIntent.underlyingClient.sendStateEvent(roomId, this.CanonicalEventType, connection.stateKey, validData);
await new GitLabGrantChecker(as, gitlabConfig, tokenStore).grantConnection(roomId, { instance: validData.instance, path: validData.path })
await intent.underlyingClient.sendStateEvent(roomId, this.CanonicalEventType, connection.stateKey, validData);
return {connection, warning};
}
@ -257,7 +312,39 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
}
}
public static async getConnectionTargets(userId: string, tokenStore: UserTokenStore, config: BridgeConfigGitLab, filters: GitLabTargetFilter = {}): Promise<GitLabRepoConnectionTarget[]> {
public static async getBase64Avatar(avatarUrl: string, client: GitLabClient, storage: IBridgeStorageProvider): Promise<string|null> {
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<GitLabRepoConnectionTarget[]> {
// Search for all repos under the user's control.
if (!filters.instance) {
@ -278,15 +365,16 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
if (!client) {
throw new ApiError('Instance is not known or you do not have access to it.', ErrCode.NotFound);
}
const after = filters.after === undefined ? undefined : parseInt(filters.after, 10);
const allProjects = await client.projects.list(AccessLevel.Developer, filters.parent, after, filters.search);
return allProjects.map(p => ({
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<string, {
@ -306,33 +394,38 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
private readonly mergeRequestSeenDiscussionIds = new QuickLRU<string, undefined>({ maxSize: 100 });
private readonly hookFilter: HookFilter<AllowedEventsNames>;
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) {
private readonly instance: GitLabInstance,
) {
super(
roomId,
stateKey,
GitLabRepoConnection.CanonicalEventType,
state,
as.botClient,
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(
// GitLab allows all events by default
AllowedEvents,
[],
state.ignoreHooks,
state.enableHooks ?? DefaultHooks,
);
}
}
public get path() {
return this.state.path.toLowerCase();
@ -360,13 +453,18 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
}
public async onStateUpdate(stateEv: MatrixEvent<unknown>) {
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<GitLabRepoConnection
});
const content = `Created issue #${res.iid}: [${res.web_url}](${res.web_url})`;
return this.as.botIntent.sendEvent(this.roomId,{
return this.intent.sendEvent(this.roomId,{
msgtype: "m.notice",
body: content,
formatted_body: md.render(content),
@ -413,7 +511,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
});
const content = `Created confidential issue #${res.iid}: [${res.web_url}](${res.web_url})`;
return this.as.botIntent.sendEvent(this.roomId,{
return this.intent.sendEvent(this.roomId,{
msgtype: "m.notice",
body: content,
formatted_body: md.render(content),
@ -449,7 +547,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
this.validateMREvent(event);
const orgRepoName = event.project.path_with_namespace;
const content = `**${event.user.username}** opened a new MR [${orgRepoName}#${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`;
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -465,7 +563,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
this.validateMREvent(event);
const orgRepoName = event.project.path_with_namespace;
const content = `**${event.user.username}** closed MR [${orgRepoName}#${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`;
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -481,7 +579,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
this.validateMREvent(event);
const orgRepoName = event.project.path_with_namespace;
const content = `**${event.user.username}** merged MR [${orgRepoName}#${event.object_attributes.iid}](${event.object_attributes.url}): "${event.object_attributes.title}"`;
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -513,7 +611,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
// Nothing changed, drop it.
return;
}
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -532,7 +630,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
}
const url = `${event.project.homepage}/-/tree/${tagname}`;
const content = `**${event.user_name}** pushed tag [\`${tagname}\`](${url}) for ${event.project.path_with_namespace}`;
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -571,7 +669,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
}
}
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.render(content),
@ -598,7 +696,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
const message = attributes.message && ` "${attributes.message}"`;
const content = `**${data.user.username}** ${statement} "[${attributes.title}](${attributes.url})" for ${data.project.path_with_namespace} ${message}`;
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -615,7 +713,7 @@ export class GitLabRepoConnection extends CommandConnection<GitLabRepoConnection
const content = `**${data.commit.author.name}** 🪄 released [${data.name}](${data.url}) for ${orgRepoName}
${data.description}`;
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.render(content),
@ -652,7 +750,7 @@ ${data.description}`;
content += "\n\n> " + result.commentNotes.join("\n\n> ");
}
this.as.botIntent.sendEvent(this.roomId, {
this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -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
}

View File

@ -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<C extends IConnection = IConnection> {
EventTypes: string[];
ServiceCategory: string;
provisionConnection?: (roomId: string, userId: string, data: Record<string, unknown>, opts: ProvisionConnectionOpts) => Promise<{connection: C, warning?: ConnectionWarning}>;
createConnectionForState: (roomId: string, state: StateEvent<Record<string, unknown>>, opts: InstantiateConnectionOpts) => C|Promise<C>
createConnectionForState: (roomId: string, state: StateEvent<Record<string, unknown>>, opts: InstantiateConnectionOpts) => C|Promise<C>;
}
export const ConnectionDeclarations: Array<ConnectionDeclaration> = [];
export interface InstantiateConnectionOpts {
as: Appservice,
intent: Intent,
config: BridgeConfig,
tokenStore: UserTokenStore,
commentProcessor: CommentProcessor,

View File

@ -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<JiraProjectConnectionState> implements IConnection {
static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.jira.project";
static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.jira.project";
@ -106,47 +107,53 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
static botCommands: BotCommands;
static helpMessage: (cmdPrefix?: string) => MatrixMessageContent;
static async provisionConnection(roomId: string, userId: string, data: Record<string, unknown>, {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<string, unknown>, {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 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);
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;
}
} 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<Record<string, unknown>>, {config, as, tokenStore}: InstantiateConnectionOpts) {
static createConnectionForState(roomId: string, state: StateEvent<Record<string, unknown>>, {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() {
@ -199,19 +206,25 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
*/
private projectUrl?: URL;
constructor(roomId: string,
private readonly grantChecker: GrantChecker<{url: string}>;
constructor(
roomId: string,
private readonly as: Appservice,
private readonly intent: Intent,
state: JiraProjectConnectionState,
stateKey: string,
private readonly tokenStore: UserTokenStore,) {
private readonly tokenStore: UserTokenStore
) {
super(
roomId,
stateKey,
JiraProjectConnection.CanonicalEventType,
state,
as.botClient,
intent.underlyingClient,
JiraProjectConnection.botCommands,
JiraProjectConnection.helpMessage,
["jira"],
"!jira",
"jira"
);
@ -222,7 +235,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
} 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) {
@ -233,6 +246,12 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
return validateJiraConnectionState(content);
}
public ensureGrant(sender?: string) {
return this.grantChecker.assertConnectionGranted(this.roomId, {
url: this.state.url,
}, sender);
}
public async onJiraIssueCreated(data: JiraIssueEvent) {
// NOTE This is the only event type that shouldn't be skipped if the state object is missing,
// for backwards compatibility with issue creation having been the only supported Jira event type,
@ -248,7 +267,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
}
const url = generateJiraWebLinkFromIssue(data.issue);
const content = `${creator.displayName} created a new JIRA issue [${data.issue.key}](${url}): "${data.issue.fields.summary}"`;
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -262,14 +281,13 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
service: "jira",
eventType: JiraProjectConnection.CanonicalEventType,
type: "JiraProject",
// TODO: Add ability to configure the bot per connnection type.
botUserId: botUserId,
}
}
public getProvisionerDetails(): JiraProjectResponseItem {
return {
...JiraProjectConnection.getProvisionerDetails(this.as.botUserId),
...JiraProjectConnection.getProvisionerDetails(this.intent.userId),
id: this.connectionId,
config: {
...this.state,
@ -313,7 +331,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
const allProjects: JiraProjectConnectionProjectTarget[] = [];
try {
for await (const project of resClient.getAllProjects()) {
for await (const project of resClient.getAllProjects(filters.search)) {
allProjects.push({
state: {
id: project.id,
@ -351,7 +369,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
content += `\n - ` + changes.join(`\n - `);
}
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -375,7 +393,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
(this.projectKey && this.projectUrl ? ` for project [${this.projectKey}](${this.projectUrl})` : "") +
`: [${data.version.name}](${url}) (_${data.version.description}_)`;
await this.as.botIntent.sendEvent(this.roomId, {
await this.intent.sendEvent(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
@ -441,7 +459,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
const link = generateJiraWebLinkFromIssue({self: this.projectUrl?.toString() || result.self, key: result.key as string});
const content = `Created JIRA issue ${result.key}: [${link}](${link})`;
return this.as.botIntent.sendEvent(this.roomId,{
return this.intent.sendEvent(this.roomId,{
msgtype: "m.notice",
body: content,
formatted_body: md.render(content),
@ -465,7 +483,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
}
const content = `Issue types: ${(result.issueTypes || []).map((t) => 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<JiraProjectConnecti
public async onRemove() {
log.info(`Removing ${this.toString()} for ${this.roomId}`);
await this.grantChecker.ungrantConnection(this.roomId, {
url: this.state.url,
});
// Do a sanity check that the event exists.
try {
await this.as.botClient.getRoomStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, this.stateKey);
await this.as.botClient.sendStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, this.stateKey, { disabled: true });
await this.intent.underlyingClient.getRoomStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, this.stateKey);
await this.intent.underlyingClient.sendStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, this.stateKey, { disabled: true });
} catch (ex) {
await this.as.botClient.getRoomStateEvent(this.roomId, JiraProjectConnection.LegacyCanonicalEventType, this.stateKey);
await this.as.botClient.sendStateEvent(this.roomId, JiraProjectConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
await this.intent.underlyingClient.getRoomStateEvent(this.roomId, JiraProjectConnection.LegacyCanonicalEventType, this.stateKey);
await this.intent.underlyingClient.sendStateEvent(this.roomId, JiraProjectConnection.LegacyCanonicalEventType, this.stateKey, { disabled: true });
}
}
@ -513,7 +534,7 @@ export class JiraProjectConnection extends CommandConnection<JiraProjectConnecti
if (!validatedConfig.id) {
await this.updateProjectId(validatedConfig, userId);
}
await this.as.botClient.sendStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, this.stateKey, validatedConfig);
await this.intent.underlyingClient.sendStateEvent(this.roomId, JiraProjectConnection.CanonicalEventType, this.stateKey, validatedConfig);
this.state = validatedConfig;
}

View File

@ -3,7 +3,6 @@ import { BotCommands, botCommand, compileBotCommands, HelpFunction } from "../Bo
import { CommandConnection } from "./CommandConnection";
import { GenericHookConnection, GitHubRepoConnection, JiraProjectConnection, JiraProjectConnectionState } from ".";
import { CommandError } from "../errors";
import { v4 as uuid } from "uuid";
import { BridgePermissionLevel } from "../Config/Config";
import markdown from "markdown-it";
import { FigmaFileConnection } from "./FigmaFileConnection";
@ -14,6 +13,7 @@ import { AdminRoom } from "../AdminRoom";
import { GitLabRepoConnection } from "./GitlabRepo";
import { IConnection, IConnectionState, ProvisionConnectionOpts } from "./IConnection";
import { ApiError, Logger } from "matrix-appservice-bridge";
import { Intent } from "matrix-bot-sdk";
const md = new markdown();
const log = new Logger("SetupConnection");
@ -34,35 +34,40 @@ export class SetupConnection extends CommandConnection {
return this.provisionOpts.as;
}
private get intent() {
return this.provisionOpts.intent;
}
private get client() {
return this.intent.underlyingClient;
}
protected validateConnectionState(content: unknown) {
log.warn("SetupConnection has no state to be validated");
return content as IConnectionState;
}
constructor(public readonly roomId: string,
constructor(
readonly roomId: string,
readonly prefix: string,
readonly serviceTypes: string[],
readonly helpCategories: string[],
private readonly provisionOpts: ProvisionConnectionOpts,
private readonly getOrCreateAdminRoom: (userId: string) => Promise<AdminRoom>,
private readonly pushConnections: (...connections: IConnection[]) => void) {
private readonly getOrCreateAdminRoom: (intent: Intent, userId: string) => Promise<AdminRoom>,
private readonly pushConnections: (...connections: IConnection[]) => void,
) {
super(
roomId,
"",
"",
// TODO Consider storing room-specific config in state.
{},
provisionOpts.as.botClient,
provisionOpts.intent.underlyingClient,
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" : "",
];
helpCategories,
prefix,
);
this.includeTitlesInHelp = false;
}
@ -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.");
}
}

View File

@ -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<string, string>);
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}`;

View File

@ -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<GitHubGrantConnectionId> {
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;
}
}
}

View File

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

View File

@ -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<GitLabGrantConnectionId> {
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;
}
}
}

View File

@ -221,6 +221,8 @@ export interface ProjectHook extends ProjectHookOpts {
}
export interface SimpleProject {
avatar_url?: string;
description?: string;
id: string;
name: string;
path: string;

View File

@ -1,19 +1,32 @@
export class HookFilter<T extends string> {
static convertIgnoredHooksToEnabledHooks<T extends string>(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));
}
}

View File

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

View File

@ -33,7 +33,7 @@ export abstract class HookshotJiraApi extends JiraApi {
return this.res;
}
public abstract getAllProjects(): AsyncIterable<JiraProject>;
public abstract getAllProjects(query?: string, maxResults?: number): AsyncIterable<JiraProject>;
protected async apiRequest<T>(path: string, method?: Method, data?: undefined): Promise<T>
protected async apiRequest<T, R>(path: string, method: Method, data?: R): Promise<T> {

33
src/Jira/GrantChecker.ts Normal file
View File

@ -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<JiraGrantConnectionId> {
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;
}
}
}

View File

@ -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<JiraProject> {
async * getAllProjects(query?: string, maxResults = 10): AsyncIterable<JiraProject> {
let response;
let startAt = 0;
do {
response = await this.apiRequest<JiraCloudProjectSearchResponse>(`/rest/api/3/project/search?startAt=${startAt}`);
const params = qs.stringify({
startAt,
maxResults,
query
});
response = await this.apiRequest<JiraCloudProjectSearchResponse>(`/rest/api/3/project/search?${params}`);
yield* response.values;
startAt += response.maxResults;
} while(!response.isLast)

View File

@ -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<JiraProject> {
async * getAllProjects(search?: string): AsyncIterable<JiraProject> {
// 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;
}
}

View File

@ -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<string, BotUser>();
// Map of room ID to set of bot users in the room
private _botsInRooms = new Map<string, Set<BotUser>>();
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<void> {
await this.ensureProfiles();
await this.getJoinedRooms();
}
private async ensureProfiles(): Promise<void> {
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<void> {
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<BotUser>();
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<BotUser>();
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<BotUser>())
.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));
}
}

View File

@ -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<IMatrixSendMessage>("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})`);
}

View File

@ -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);
}

View File

@ -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<unknown>;
if (msg.for && msg.for !== this.myUuid) {
@ -63,7 +62,7 @@ export class RedisMQ extends EventEmitter implements MessageQueue {
public async push<T>(message: MessageQueueMessage<T>, single = false) {
if (!message.messageId) {
message.messageId = uuid();
message.messageId = randomUUID();
}
if (single) {
const recipient = await this.getRecipientForEvent(message.eventName);

View File

@ -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<string, IssuesGetResponseData> = new Map();
@ -9,6 +10,7 @@ export class MemoryStorageProvider extends MSP implements IBridgeStorageProvider
private reviewData: Map<string, string> = new Map();
private figmaCommentIds: Map<string, string> = new Map();
private widgetSessions: Map<string, ProvisionSession> = new Map();
private storedFiles = new QuickLRU<string, string>({ 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<string|null> {
return this.storedFiles.get(key) ?? null;
}
public async setStoredTempFile(key: string, value: string) {
this.storedFiles.set(key, value);
}
}

View File

@ -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<void> {
@ -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);
}
}

View File

@ -12,4 +12,6 @@ export interface IBridgeStorageProvider extends IAppserviceStorageProvider, ISto
getPRReviewData(repo: string, issueNumber: string, scope?: string): Promise<any|null>;
setFigmaCommentEventId(roomId: string, figmaCommentId: string, eventId: string): Promise<void>;
getFigmaCommentEventId(roomId: string, figmaCommentId: string): Promise<string|null>;
getStoredTempFile(key: string): Promise<string|null>;
setStoredTempFile(key: string, value: string): Promise<void>;
}

View File

@ -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<Emitter> {
}
public async clearUserToken(type: TokenType, userId: string, instanceUrl?: string): Promise<boolean> {
if (!AllowedTokenTypes.includes(type)) {
throw Error('Unknown token type');
}
const key = tokenKey(type, userId, false, instanceUrl);
const obj = await this.intent.underlyingClient.getSafeAccountData<StoredTokenData|DeletedTokenData>(key);
if (!obj || "deleted" in obj) {
@ -254,7 +257,7 @@ export class UserTokenStore extends TypedEmitter<Emitter> {
}
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),

View File

@ -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,30 +186,37 @@ export class Webhooks extends EventEmitter {
}
}
public async onGitHubGetOauth(req: Request<unknown, unknown, unknown, {error?: string, error_description?: string, code?: string, state?: string}> , res: Response) {
log.info(`Got new oauth request`, { state: req.query.state });
public async onGitHubGetOauth(req: Request<unknown, unknown, unknown, {error?: string, error_description?: string, code?: string, state?: string, setup_action?: 'install'}> , 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(`<p>Bridge is not configured with OAuth support</p>`);
throw new ApiError('Bridge is not configured with OAuth support', ErrCode.DisabledFeature);
}
if (req.query.error) {
return res.status(500).send(`<p><b>GitHub Error</b>: ${req.query.error} ${req.query.error_description}</p>`);
throw new ApiError(`GitHub Error: ${req.query.error} ${req.query.error_description}`, ErrCode.Unknown);
}
if (!req.query.state) {
return res.status(400).send(`<p>Missing state</p>`);
if (setup_action !== 'install') {
if (!state) {
throw new ApiError(`Missing state`, ErrCode.BadValue);
}
if (!req.query.code) {
return res.status(400).send(`<p>Missing code</p>`);
throw new ApiError(`Missing code`, ErrCode.BadValue);
}
const exists = await this.queue.pushWait<OAuthRequest, boolean>({
eventName: "github.oauth.response",
sender: "GithubWebhooks",
data: {
state: req.query.state,
state,
},
});
if (!exists) {
return res.status(404).send(`<p>Could not find user which authorised this request. Has it timed out?</p>`);
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,
@ -220,17 +228,35 @@ export class Webhooks extends EventEmitter {
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(`<p><b>GitHub Error</b>: ${result.error} ${result.error_description}</p>`);
throw new ApiError(`GitHub Error: ${result.error} ${result.error_description}`, ErrCode.Unknown);
}
await this.queue.push<GitHubOAuthTokenResponse>({
eventName: "github.oauth.tokens",
sender: "GithubWebhooks",
data: { ...result, state: req.query.state as string },
});
return res.send(`<p> Your account has been bridged </p>`);
} else if (oauthUrl) {
// App install.
oauthUrl.searchParams.set('oauth-kind', 'organisation');
}
} catch (ex) {
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(`<p>Encountered an error handing oauth request</p>`);
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(`<p> Your account has been bridged </p>`);
}
}

View File

@ -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<string, AdminRoom>,
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<AdminRoom> {
@ -85,23 +134,31 @@ export class BridgeWidgetApi {
}
private async getServiceConfig(req: ProvisioningRequest, res: Response<Record<string, unknown>>) {
// 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<GetAuthResponse>) {
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<GetAuthPollResponse>) {
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);
}
}
}

View File

@ -36,3 +36,24 @@ export interface GetConnectionsForServiceResponse<T extends GetConnectionsRespon
connections: T[];
canEdit: boolean;
}
export interface GetAuthResponseAuthenticated {
authenticated: true;
user: {
name: string;
}
}
export interface GetAuthResponseUnauthenticated {
authenticated: false;
authUrl: string;
stateId: string;
}
export type GetAuthResponse = GetAuthResponseAuthenticated|GetAuthResponseUnauthenticated;
export interface GetAuthPollResponse {
state: 'complete'|'waiting';
}

View File

@ -0,0 +1,149 @@
import { Logger } from "matrix-appservice-bridge";
import axios from "axios";
import { FeedConnection, FeedConnectionState, GitHubRepoConnection, GitHubRepoConnectionState } from "../Connections";
import { AllowedEvents as GitHubAllowedEvents, AllowedEventsNames as GitHubAllowedEventsNames } from "../Connections/GithubRepo";
const log = new Logger("GoNebMigrator");
interface MigratedGoNebConnection {
goNebId: string;
}
type MigratedFeed = FeedConnectionState & MigratedGoNebConnection;
type MigratedGithub = GitHubRepoConnectionState & MigratedGoNebConnection;
interface MigratedConnections {
[FeedConnection.ServiceCategory]: MigratedFeed[]|undefined,
[GitHubRepoConnection.ServiceCategory]: MigratedGithub[]|undefined;
}
interface GoNebFeedsConfig {
[url: string]: {
rooms: string[],
}
}
interface GoNebGithubRepos {
[githubPath: string]: {
Events: string[], // push, issues, pull_request, more?
}
}
interface GoNebService {
Type: string;
Config: any;
}
interface GoNebGithubWebhookService extends GoNebService {
Type: 'github-webhook';
Config: {
ClientUserID: string;
Rooms: {
[roomId: string]: { Repos: GoNebGithubRepos; }
}
};
}
export class GoNebMigrator {
constructor(
private apiUrl: string,
private serviceIds?: string[],
) {}
static convertFeeds(goNebFeeds: GoNebFeedsConfig): Map<string, FeedConnectionState[]> {
const feedsPerRoom = new Map<string, FeedConnectionState[]>();
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<MigratedConnections> {
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()}`
);
}

View File

@ -16,15 +16,31 @@ export class SetupWidget {
return false;
}
static async SetupRoomConfigWidget(roomId: string, botIntent: Intent, config: BridgeWidgetConfig): Promise<boolean> {
if (await SetupWidget.createWidgetInRoom(roomId, botIntent, config, HookshotWidgetKind.RoomConfiguration, "hookshot_room_config")) {
static async SetupRoomConfigWidget(roomId: string, botIntent: Intent, config: BridgeWidgetConfig, serviceTypes: string[]): Promise<boolean> {
// 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<boolean> {
private static async createWidgetInRoom(
roomId: string,
botIntent: Intent,
config: BridgeWidgetConfig,
kind: HookshotWidgetKind,
stateKey: string,
serviceScope?: string,
): Promise<boolean> {
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,12 +69,12 @@ 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,
}
);

114
src/grants/GrantCheck.ts Normal file
View File

@ -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<cId extends ConnectionId = ConnectionId> {
private static stringifyConnectionId<cId = ConnectionId>(connId: cId) {
if (typeof connId === "string") {
return FormatUtil.hashId(connId.toString());
}
return FormatUtil.hashId(Object.entries(connId as Record<string, unknown>).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>|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<GrantContent>(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<cId extends ConnectionId = ConnectionId> extends GrantChecker<cId> {
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);
}
}

View File

@ -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<string, unknown>, {userId: string}>, res: Response<GetConnectionsResponseItem>, 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);
}
}

View File

@ -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";
}

View File

@ -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<string>;
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;
});
it('should allow a hook named in defaults', () => {
expect(filter.shouldSkip('default-allowed')).to.be.false;
});
it('should allow a hook named in enabled', () => {
describe('shouldSkip', () => {
it('should allow a hook named in enabled set', () => {
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 not allow a hook not named in enabled set', () => {
expect(filter.shouldSkip('not-enabled-hook')).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;
});
});
});

58
tests/IntentUtilsTest.ts Normal file
View File

@ -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}`)
}
});
})
});

View File

@ -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: "<p>Received webhook data:</p><p><pre><code class=\\\"language-json\\\">{\n \"simple\": \"" + testValue + "\"\n}</code></pre></p>",
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<IMatrixSendMessage> {
@ -34,23 +56,9 @@ function handleMessage(mq: LocalMQ): Promise<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: "<p>Received webhook data:</p><p><pre><code class=\\\"language-json\\\">{\n \"simple\": \"data\"\n}</code></pre></p>",
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}`)
}
});
})

View File

@ -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<string, unknown> = {}, 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<string, unknown> = {}, 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<string, unknown>);
});
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<string, unknown>, 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);
});
});
});

View File

@ -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<string, unknown> = {}, 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<string, unknown> = {}, 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<string, unknown>);
});
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<string, unknown>, 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);
});
});
});

View File

@ -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<string>, 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<string>;
// 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<string>;
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);
});
});
});

View File

@ -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<string, any>();
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);
}
}

View File

@ -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<string, string[]> = new Map();
public readonly roomAccountData: Map<string, string> = new Map();
async setDisplayName() {
return;
}
async getJoinedRoomMembers(roomId: string): Promise<string[]> {
return this.joinedMembers.get(roomId) || [];
}
async inviteUser(userId: string, roomId: string): Promise<void> {
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<string> {
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<void> {
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) {
@ -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;
}

View File

@ -1,5 +1,5 @@
{
"extends": "@tsconfig/node14/tsconfig.json",
"extends": "@tsconfig/node16/tsconfig.json",
"compilerOptions": {
"incremental": true,
"declaration": false,

View File

@ -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,
},
})

View File

@ -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<void, IState> {
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<void, IState> {
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<void, IState> {
if (this.state.error) {
content = <ErrorPane>{this.state.error}</ErrorPane>;
} else if (this.state.busy) {
content = <div class="spinner" />;
content = <Card>
<LoadingSpinner />
</Card>;
}
if ("kind" in this.state) {
@ -123,6 +129,7 @@ export default class App extends Component<void, IState> {
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<void, IState> {
}
return (
<div className="app">
<div style={{
padding: this.state.embedType === "integration-manager" ? "0" : "16px",
}}>
{content}
</div>
);

View File

@ -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<BridgeAPI> {
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: {
@ -113,6 +118,10 @@ export class BridgeAPI {
return this.request('GET', `/widgetapi/v1/${encodeURIComponent(roomId)}/connections`);
}
async getGoNebConnectionsForRoom(roomId: string): Promise<any|undefined> {
return this.request('GET', `/widgetapi/v1/${encodeURIComponent(roomId)}/goNebConnections`);
}
async getConnectionsForService<T extends GetConnectionsResponseItem >(roomId: string, service: string): Promise<GetConnectionsForServiceResponse<T>> {
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<R>(type: string, filters?: Record<never, never>|Record<string, string>): Promise<R[]> {
getConnectionTargets<R>(type: string, filters?: Record<never, never>|Record<string, string>, abortController?: AbortController): Promise<R[]> {
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<GetAuthResponse> {
return this.request('GET', `/widgetapi/v1/service/${service}/auth`);
}
async getAuthPoll(service: string, state: string): Promise<GetAuthPollResponse> {
return this.request('GET', `/widgetapi/v1/service/${service}/auth/${state}`);
}
async serviceLogout(service: string): Promise<GetAuthResponse> {
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,
showHeader: boolean,
}>;

View File

@ -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 <div class={style.root}>
<div class="spinner" />
<LoadingSpinner />
</div>;
}
return <div class={style.root}>

View File

@ -1,4 +1,3 @@
import { h } from "preact";
import style from "./ConnectionCard.module.scss";
interface IProps {

View File

@ -1,4 +1,4 @@
import { h, FunctionComponent } from 'preact';
import { FunctionComponent } from 'preact';
import { BridgeRoomStateGitHub } from '../../src/Widgets/BridgeWidgetInterface';
import "./GitHubState.css";

View File

@ -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 = <ConfigComponent roomId={props.roomId} api={props.bridgeApi} />;
content = <ConfigComponent
roomId={props.roomId}
api={props.bridgeApi}
showHeader={props.embedType !== EmbedType.IntegrationManager}
/>;
} else {
content = <>
<section>
@ -100,13 +105,13 @@ export default function RoomConfigView(props: IProps) {
}
return <div className={style.root}>
<header>
{!serviceScope && activeConnectionType &&
<header>
<span className={style.backButton} onClick={() => setActiveConnectionType(null)}>
<span className="chevron" /> Browse integrations
</span>
}
</header>
}
{content}
</div>;
}

View File

@ -1,3 +1,7 @@
.icon {
width: 48px;
}
.serviceCard {
display: grid !important;
grid-template-columns: 0.6fr 1fr 1fr;

View File

@ -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 <div className={`card ${style.serviceCard}`}>
<img style="width: 48px;" src={iconUrl} />
<img className={style.icon} src={iconUrl} />
<div>
<span>{serviceName}</span>
<button onClick={onConfigure}>Configure</button>

View File

@ -1,4 +1,3 @@
import { h } from "preact";
import { Button } from "../elements";
export default function GeneralConfig() {

View File

@ -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<HTMLButtonElement> {
intent?: string;
}
export const Button: FunctionComponent = (props: ButtonProps) => {
let className = style.button;
if (props.intent === "remove") {
className += ` ${ style.remove}`;
className += ` ${style.remove}`;
}
return <button type="button" className={className} {...props} />;
}

View File

@ -0,0 +1,11 @@
.card {
/* Compound/Light/Background */
background: #FFFFFF;
/* Compound/Light/Quinary Content */
border: 1px solid #E3E8F0;
box-sizing: border-box;
border-radius: 8px;
padding: 32px;
}

View File

@ -0,0 +1,11 @@
import React from 'preact';
import styles from './Card.module.scss';
const Card = (props: React.ComponentProps<'div'>) =>
<div
{...props}
className={styles.card}
/>;
export { Card };

View File

@ -0,0 +1,142 @@
import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
import { BridgeAPIError } from "../../BridgeAPI";
import { DropdownSearch, DropItem } from "./DropdownSearch";
import { ErrorPane } from "./ErrorPane";
import { InputField } from "./InputField";
interface Instance {
name: string;
}
type Project = DropItem;
interface IProps {
serviceName: string;
addNewInstanceUrl?: string;
getInstances(): Promise<Instance[]>;
getProjects(currentInstance: string, searchTerm?: string, abortController?: AbortController): Promise<Project[]>;
onPicked: (instanceValue: string, projectValue: string) => void;
onClear: () => void;
}
/**
* This component is designed to generically fetch a bunch of instances for a given connection type
* and then a list of projects associated with that instance. The user should be able to pick from
* that list.
* @param props
* @returns
*/
export function ConnectionSearch({
serviceName,
addNewInstanceUrl,
onPicked,
onClear,
getInstances,
getProjects,
}: IProps) {
const [currentInstance, setCurrentInstance] = useState<string>("");
const [instances, setInstances] = useState<Instance[]|null>(null);
const [searchError, setSearchError] = useState<string|null>(null);
const [exampleProjectName, setExampleProjectName] = useState<string>("Loading...");
useEffect(() => {
getInstances().then(res => {
setInstances(res);
setCurrentInstance(res[0]?.name ?? '');
}).catch(ex => {
if (ex instanceof BridgeAPIError && ex.errcode === "HS_FORBIDDEN_USER") {
setSearchError(`You are not logged into ${serviceName}.`);
return;
}
setSearchError(`Could not load ${serviceName} instances.`);
console.warn(`Failed to get connection targets from query:`, ex);
});
}, [getInstances, serviceName]);
useEffect(() => {
if (!currentInstance) {
return;
}
getProjects(currentInstance).then(res => {
setExampleProjectName(res[0]?.value ?? "my-org/my-example-project");
}).catch(ex => {
setSearchError(`Could not load ${serviceName} projects for instance`);
console.warn(`Failed to get connection targets from query:`, ex);
});
}, [currentInstance, getProjects, serviceName]);
const searchFn = useCallback(async(terms: string, { instance }: { instance: string }, abortController: AbortController) => {
try {
const res = await getProjects(instance, terms, abortController);
return res.map((item) => ({
description: item.description,
imageSrc: item.imageSrc,
title: item.title,
value: item.value,
}) as DropItem);
} catch (ex) {
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);
return [];
}
}, [getProjects]);
const onInstancePicked = useCallback((evt: {target: EventTarget|null}) => {
// Reset everything
setCurrentInstance((evt.target as HTMLSelectElement).selectedOptions[0].value);
onClear();
}, [onClear]);
const instanceListResults = useMemo(
() => instances?.map(i => <option key={i.name}>{i.name}</option>),
[instances]
);
const onProjectPicked = useCallback((value: string|null) => {
if (value === null) {
onClear();
return;
}
if (!currentInstance) {
throw Error('Should never pick a project without an instance');
}
onPicked(currentInstance, value);
}, [currentInstance, onClear, onPicked]);
const searchProps = useMemo(() => ({ instance: currentInstance }), [currentInstance]);
let addNewInstance = null;
if (instances?.length === 0) {
if (addNewInstanceUrl) {
addNewInstance = <p> You have not connected any {serviceName} instances.
<a href={addNewInstanceUrl} rel="noreferrer" target="_blank">Add a new instances</a>.
</p>;
} else {
addNewInstance = <p> You have not connected any {serviceName} instances.</p>;
}
} else if (addNewInstanceUrl) {
addNewInstance = <p><a href={addNewInstanceUrl} rel="noreferrer" target="_blank">Add a new instances</a>.</p>
} // otherwise, empty
return <div>
{!searchError && instances === null && <p> Loading {serviceName} instances. </p>}
{searchError && <ErrorPane header="Search error"> {searchError} </ErrorPane> }
<InputField visible={!!instances?.length} label={`${serviceName} Instance`} noPadding={true}>
<select onChange={onInstancePicked}>
{instanceListResults}
</select>
</InputField>
{ addNewInstance }
{ currentInstance && <InputField label="Project" noPadding={true}>
<DropdownSearch
placeholder={`Your project name, such as ${exampleProjectName}`}
searchFn={searchFn}
searchProps={searchProps}
onChange={onProjectPicked}
/>
</InputField> }
</div>;
}
export default ConnectionSearch;

View File

@ -0,0 +1,41 @@
.dropdownItem {
padding: 0.25em 0.5em;
border-radius: 8px;
cursor: pointer;
&:hover {
background-color: rgba(54, 54, 54, 0.138);
}
}
.title {
font-weight: 600;
}
.description {
/* These work cross browser */
// https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-line-clamp
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
.value {
font-weight: 400;
color: var(--light-color);
}
.hasImg {
display: grid;
grid-template-columns: auto 1fr;
grid-gap: 1em;
}
.itemImage {
width: auto;
max-height: 48px;
margin-top: auto;
margin-bottom: auto;
}

View File

@ -0,0 +1,103 @@
import { FunctionComponent } from "preact";
import { useCallback, useEffect, useState } from "preact/hooks";
import style from "./DropdownSearch.module.scss";
interface Props<T> {
searchFn: (searchTerm: string, additionalProps: T, abortController: AbortController) => Promise<DropItem[]>;
searchProps: T,
placeholder?: string,
onChange: (value: string|null) => void;
onError?: (error: unknown) => void;
}
export interface DropItem {
value: string;
title: string;
imageSrc?: string;
description?: string;
}
const DEBOUNCE_TIMEOUT_MS = 750;
export const DropdownItem: FunctionComponent<DropItem&{onPicked?: (value: string) => void}> = ({ imageSrc, title, value, description, onPicked }) => {
// Need placeholder image.
return <li className={`card ${style.dropdownItem} ${imageSrc ? style.hasImg : ''}`} role="button" onClick={() => onPicked?.(value)}>
{ imageSrc && <img className={style.itemImage} src={imageSrc} /> }
<div>
<p className={style.title}>{title} <span className={style.value}>{value}</span></p>
<p className={style.description}>{description}</p>
</div>
</li>;
};
export const DropdownSearch = function<T>({searchFn, searchProps, onChange, onError, placeholder}: Props<T>) {
const [selectedItem, setSelectedItem] = useState<DropItem|null>();
const [searchTerm, setSearchTerm] = useState("");
const [results, setResults] = useState<DropItem[]|null>();
const [loading, setLoading] = useState(false);
// Reset if the search properties are altered.
useEffect(() => {
setSearchTerm("");
setSelectedItem(null);
}, [searchProps]);
// Search whenever the term is updated.
useEffect(() => {
if (searchTerm.trim().length === 0) {
return;
}
if (selectedItem) {
// Clear any selected items
setSelectedItem(null);
onChange(null);
}
const controller = new AbortController();
// Browser types
const debounceTimer = setTimeout(() => {
setLoading(true);
searchFn(searchTerm, searchProps, controller).then(result => {
setResults(result);
}).catch(err => {
onError?.(err);
}).finally(() => {
setLoading(false);
})
}, DEBOUNCE_TIMEOUT_MS);
return () => {
controller.abort();
clearTimeout(debounceTimer);
}
}, [searchTerm, onChange, setResults, onError, searchProps, selectedItem, searchFn]);
const onSearchInputChange = useCallback((event: {target: EventTarget|null}) => {
const terms = (event.target as HTMLInputElement).value;
setSearchTerm(terms);
}, []);
const onItemPicked = useCallback((item: DropItem) => {
onChange(item.value);
setResults([]);
setSearchTerm("");
setSelectedItem(item);
}, [onChange]);
const onItemCleared = useCallback(() => {
onChange(null);
setResults([]);
setSearchTerm("");
setSelectedItem(null);
}, [onChange]);
return <>
{selectedItem && <DropdownItem {...selectedItem} onPicked={onItemCleared} />}
{!selectedItem && <input type="search" placeholder={placeholder} onChange={onSearchInputChange} value={searchTerm} />}
{loading && <p> Searching... </p>}
{!loading && !selectedItem && searchTerm && results?.length === 0 && <p> No results found. </p>}
<ul>
{
results?.map(item => <DropdownItem key={item.value} {...item} onPicked={ () => onItemPicked(item) } />)
}
</ul>
</>;
};

View File

@ -1,4 +1,4 @@
import { h, FunctionComponent } from "preact";
import { FunctionComponent } from "preact";
import ErrorBadge from "../../icons/error-badge.svg";
import style from "./ErrorPane.module.scss";

Some files were not shown because too many files have changed in this diff Show More