Merge pull request #77 from Half-Shot/hs/add-support-for-generic-webhook

Add support for handling generic webhooks
This commit is contained in:
Will Hunt 2021-11-21 12:35:25 +00:00 committed by GitHub
commit a959ce66b7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 225 additions and 44 deletions

View File

@ -9,7 +9,7 @@ bridge:
port: 9993
bindAddress: 127.0.0.1
github:
# (Optional) Configure this to enable support for GitHub
# (Optional) Configure this to enable GitHub support
#
installationId: 6854059
auth:
@ -22,13 +22,23 @@ github:
webhook:
secret: secrettoken
gitlab:
# (Optional) Configure this to enable support for GitLab
# (Optional) Configure this to enable GitLab support
#
instances:
gitlab.com:
url: https://gitlab.com
webhook:
secret: secrettoken
jira:
# (Optional) Configure this to enable Jira support
#
webhook:
secret: secrettoken
generic:
# (Optional) Support for generic webhook events. `allowJsTransformationFunctions` will allow users to write short transformation snippets in code, and thus is unsafe in untrusted environments
#
enabled: false
allowJsTransformationFunctions: false
webhook:
# HTTP webhook listener options
#

View File

@ -45,6 +45,11 @@ interface BridgeConfigJira {
};
}
interface BridgeGenericWebhooksConfig {
enabled: boolean;
allowJsTransformationFunctions?: boolean;
}
interface BridgeWidgetConfig {
port: number;
addToAdminRooms: boolean;
@ -95,6 +100,7 @@ interface BridgeConfigRoot {
jira?: BridgeConfigJira;
bot?: BridgeConfigBot;
widgets?: BridgeWidgetConfig;
generic?: BridgeGenericWebhooksConfig;
}
export class BridgeConfig {
@ -113,8 +119,10 @@ export class BridgeConfig {
public readonly github?: BridgeConfigGitHub;
@configKey("Configure this to enable GitLab support", true)
public readonly gitlab?: BridgeConfigGitLab;
@configKey("Configure this to enable Jira support")
@configKey("Configure this to enable Jira support", true)
public readonly jira?: BridgeConfigJira;
@configKey("Support for generic webhook events. `allowJsTransformationFunctions` will allow users to write short transformation snippets in code, and thus is unsafe in untrusted environments", true)
public readonly generic?: BridgeGenericWebhooksConfig;
@configKey("Define profile information for the bot user", true)
public readonly bot?: BridgeConfigBot;
@configKey("EXPERIMENTAL support for complimentary widgets", true)
@ -132,6 +140,7 @@ export class BridgeConfig {
}
this.gitlab = configData.gitlab;
this.jira = configData.jira;
this.generic = configData.generic;
this.webhook = configData.webhook;
this.passFile = configData.passFile;
assert.ok(this.webhook);

View File

@ -58,6 +58,15 @@ const DefaultConfig = new BridgeConfig({
webhook: {
secret: "secrettoken",
}
},
jira: {
webhook: {
secret: 'secrettoken'
}
},
generic: {
enabled: false,
allowJsTransformationFunctions: false,
}
}, {});

View File

@ -0,0 +1,95 @@
import { IConnection } from "./IConnection";
import LogWrapper from "../LogWrapper";
import { MessageSenderClient } from "../MatrixSender"
import markdownit from "markdown-it";
import { Script, createContext } from "vm";
import { MatrixEvent } from "../MatrixEvent";
export interface GenericHookConnectionState {
hookId: string;
transformationFunction?: string;
}
const log = new LogWrapper("GenericHookConnection");
const md = new markdownit();
const TRANSFORMATION_TIMEOUT_MS = 2000;
/**
* Handles rooms connected to a github repo.
*/
export class GenericHookConnection implements IConnection {
static readonly CanonicalEventType = "uk.half-shot.matrix-github.generic.hook";
static readonly EventTypes = [
GenericHookConnection.CanonicalEventType,
];
public get hookId() {
return this.state.hookId;
}
private transformationFunction?: Script;
constructor(public readonly roomId: string,
private state: GenericHookConnectionState,
private readonly stateKey: string,
private messageClient: MessageSenderClient,
private readonly allowJSTransformation: boolean = false) {
if (state.transformationFunction && allowJSTransformation) {
this.transformationFunction = new Script(state.transformationFunction);
}
}
public isInterestedInStateEvent(eventType: string, stateKey: string) {
return GenericHookConnection.EventTypes.includes(eventType) && this.stateKey === stateKey;
}
public async onStateUpdate(stateEv: MatrixEvent<unknown>) {
const state = stateEv.content as GenericHookConnectionState;
if (state.transformationFunction && this.allowJSTransformation) {
try {
this.transformationFunction = new Script(state.transformationFunction);
} catch (ex) {
await this.messageClient.sendMatrixText(this.roomId, 'Could not compile transformation function:' + ex);
}
}
}
public async onGenericHook(data: Record<string, unknown>) {
log.info(`onGenericHook ${this.roomId} ${this.hookId}`);
let content: string;
if (!this.transformationFunction) {
content = `Recieved webhook data:\n\n\`\`\`${JSON.stringify(data)}\`\`\``;
} else {
try {
const context = createContext({data});
this.transformationFunction.runInContext(context, {
timeout: TRANSFORMATION_TIMEOUT_MS,
breakOnSigint: true,
filename: `generic-hook.${this.hookId}`,
});
if (context.result) {
content = `Recieved webhook: ${context.result}`;
} else {
content = `No content`;
}
} catch (ex) {
content = `Webhook recieved but failed to process via transformation function`;
}
}
return this.messageClient.sendMatrixMessage(this.roomId, {
msgtype: "m.notice",
body: content,
formatted_body: md.renderInline(content),
format: "org.matrix.custom.html",
"uk.half-shot.webhook_data": data,
});
}
public toString() {
return `GenericHookConnection ${this.hookId}`;
}
}

View File

@ -316,7 +316,7 @@ export class GitHubIssueConnection implements IConnection {
}
}
public onIssueStateChange(data?: any) {
public onIssueStateChange(data: unknown) {
return this.syncIssueState();
}

View File

@ -288,7 +288,7 @@ export class GitHubRepoConnection implements IConnection {
}
const orgRepoName = event.repository.full_name;
const content = emoji.emojify(`${event.issue.user?.login} created new issue [${orgRepoName}#${event.issue.number}](${event.issue}): "${event.issue.title}"`);
const content = emoji.emojify(`${event.issue.user?.login} created new issue [${orgRepoName}#${event.issue.number}](${event.issue.html_url}): "${event.issue.title}"`);
const { labelsHtml, labelsStr } = FormatUtil.formatLabels(event.issue.labels);
await this.as.botIntent.sendEvent(this.roomId, {
msgtype: "m.notice",

View File

@ -55,7 +55,8 @@ export class GitHubUserSpace implements IConnection {
throw Error("Could not find repo");
}
let avatarState: any|undefined;
// eslint-disable-next-line camelcase
let avatarState: {type: "m.room.avatar", state_key: "", content: { url: string}}|undefined;
try {
if (avatarUrl) {
const res = await axios.get(avatarUrl, {

View File

@ -131,14 +131,6 @@ export class GitLabRepoConnection implements IConnection {
});
}
// public async onIssueCreated(event: IGitHubWebhookEvent) {
// }
// public async onIssueStateChange(event: IGitHubWebhookEvent) {
// }
public toString() {
return `GitHubRepo`;
}

View File

@ -1,36 +1,36 @@
import { AdminRoom, BRIDGE_ROOM_TYPE, AdminAccountData } from "./AdminRoom";
import { Appservice, IAppserviceRegistration, RichRepliesPreprocessor, IRichReplyMetadata, StateEvent, PantalaimonClient, MatrixClient } from "matrix-bot-sdk";
import { BridgeConfig, GitLabInstance } from "./Config/Config";
import { OAuthRequest, OAuthTokens, NotificationsEnableEvent, NotificationsDisableEvent,} from "./Webhooks";
import { BridgeWidgetApi } from "./Widgets/BridgeWidgetApi";
import { CommentProcessor } from "./CommentProcessor";
import { MessageQueue, createMessageQueue } from "./MessageQueue/MessageQueue";
import { AdminRoom, BRIDGE_ROOM_TYPE, AdminAccountData } from "./AdminRoom";
import { UserTokenStore } from "./UserTokenStore";
import { MatrixEvent, MatrixMemberContent, MatrixMessageContent } from "./MatrixEvent";
import LogWrapper from "./LogWrapper";
import { MessageSenderClient } from "./MatrixSender";
import { UserNotificationsEvent } from "./Notifications/UserNotificationWatcher";
import { RedisStorageProvider } from "./Stores/RedisStorageProvider";
import { MemoryStorageProvider } from "./Stores/MemoryStorageProvider";
import { NotificationProcessor } from "./NotificationsProcessor";
import { IBridgeStorageProvider } from "./Stores/StorageProvider";
import { retry } from "./PromiseUtil";
import { IConnection, GitHubDiscussionSpace, GitHubDiscussionConnection, GitHubUserSpace
} from "./Connections";
import { GitHubRepoConnection } from "./Connections/GithubRepo";
import { GetIssueResponse, GetIssueOpts } from "./Gitlab/Types"
import { GenericHookConnection } from "./Connections/GenericHook";
import { GithubInstance } from "./Github/GithubInstance";
import { GitHubIssueConnection } from "./Connections/GithubIssue";
import { GitHubProjectConnection } from "./Connections/GithubProject";
import { GitLabRepoConnection } from "./Connections/GitlabRepo";
import { GithubInstance } from "./Github/GithubInstance";
import { IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent } from "./Gitlab/WebhookTypes";
import { GitLabIssueConnection } from "./Connections/GitlabIssue";
import { GetIssueResponse, GetIssueOpts } from "./Gitlab/Types"
import { GitHubRepoConnection } from "./Connections/GithubRepo";
import { GitLabClient } from "./Gitlab/Client";
import { BridgeWidgetApi } from "./Widgets/BridgeWidgetApi";
import { ProjectsGetResponseData } from "./Github/Types";
import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters";
import * as GitHubWebhookTypes from "@octokit/webhooks-types";
import { JiraCommentCreatedEvent, JiraIssueEvent } from "./Jira/WebhookTypes";
import { GitLabIssueConnection } from "./Connections/GitlabIssue";
import { GitLabRepoConnection } from "./Connections/GitlabRepo";
import { IBridgeStorageProvider } from "./Stores/StorageProvider";
import { IConnection, GitHubDiscussionSpace, GitHubDiscussionConnection, GitHubUserSpace } from "./Connections";
import { IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent } from "./Gitlab/WebhookTypes";
import { JiraIssueEvent } from "./Jira/WebhookTypes";
import { JiraProjectConnection } from "./Connections/JiraProject";
import { MatrixEvent, MatrixMemberContent, MatrixMessageContent } from "./MatrixEvent";
import { MemoryStorageProvider } from "./Stores/MemoryStorageProvider";
import { MessageQueue, createMessageQueue } from "./MessageQueue/MessageQueue";
import { MessageSenderClient } from "./MatrixSender";
import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters";
import { NotificationProcessor } from "./NotificationsProcessor";
import { OAuthRequest, OAuthTokens, NotificationsEnableEvent, NotificationsDisableEvent, GenericWebhookEvent,} from "./Webhooks";
import { ProjectsGetResponseData } from "./Github/Types";
import { RedisStorageProvider } from "./Stores/RedisStorageProvider";
import { retry } from "./PromiseUtil";
import { UserNotificationsEvent } from "./Notifications/UserNotificationWatcher";
import { UserTokenStore } from "./UserTokenStore";
import * as GitHubWebhookTypes from "@octokit/webhooks-types";
import LogWrapper from "./LogWrapper";
const log = new LogWrapper("GithubBridge");
@ -137,6 +137,16 @@ export class GithubBridge {
return new JiraProjectConnection(roomId, this.as, state.content, state.stateKey, this.commentProcessor, this.messageClient);
}
if (GenericHookConnection.EventTypes.includes(state.type) && this.config.generic?.enabled) {
return new GenericHookConnection(
roomId,
state.content,
state.stateKey,
this.messageClient,
this.config.generic.allowJsTransformationFunctions
);
}
return;
}
@ -217,6 +227,11 @@ export class GithubBridge {
return this.connections.filter((c) => (c instanceof JiraProjectConnection && c.projectId === projectId)) as JiraProjectConnection[];
}
private getConnectionsForGenericWebhook(hookId: string): GenericHookConnection[] {
return this.connections.filter((c) => (c instanceof GenericHookConnection && c.hookId === hookId)) as GenericHookConnection[];
}
public stop() {
this.as.stop();
if(this.queue.stop) this.queue.stop();
@ -602,7 +617,6 @@ export class GithubBridge {
log.info(`JIRA issue created for project ${data.issue.fields.project.id}, issue id ${data.issue.id}`);
const projectId = data.issue.fields.project.id;
const connections = this.getConnectionsForJiraProject(projectId);
console.log(data.issue.fields.project);
connections.forEach(async (c) => {
try {
@ -612,6 +626,19 @@ export class GithubBridge {
}
});
});
this.queue.on<GenericWebhookEvent>("generic-webhook.event", async ({data}) => {
log.info(`Incoming generic hook ${data.hookId}`);
const connections = this.getConnectionsForGenericWebhook(data.hookId);
connections.forEach(async (c) => {
try {
await c.onGenericHook(data.hookData);
} catch (ex) {
log.warn(`Failed to handle generic-webhook.event:`, ex);
}
});
});
// Fetch all room state
let joinedRooms: string[]|undefined;

View File

@ -11,6 +11,11 @@ import { EmitterWebhookEvent, Webhooks as OctokitWebhooks } from "@octokit/webho
import { IJiraWebhookEvent, JiraIssueEvent } from "./Jira/WebhookTypes";
const log = new LogWrapper("GithubWebhooks");
export interface GenericWebhookEvent {
hookData: Record<string, unknown>;
hookId: string;
}
export interface OAuthRequest {
code: string;
state: string;
@ -55,6 +60,12 @@ export class Webhooks extends EventEmitter {
this.ghWebhooks.onAny(e => this.onGitHubPayload(e));
}
this.expressApp.all(
'/:hookId',
express.json({ type: ['application/json', 'application/x-www-form-urlencoded'] }),
this.onGenericPayload.bind(this),
);
this.expressApp.use(express.json({
verify: this.verifyRequest.bind(this),
}));
@ -81,6 +92,7 @@ export class Webhooks extends EventEmitter {
}
}
private onGitLabPayload(body: IGitLabWebhookEvent) {
log.info(`onGitLabPayload ${body.event_type}:`, body);
if (body.event_type === "merge_request") {
@ -118,6 +130,32 @@ export class Webhooks extends EventEmitter {
}
}
private onGenericPayload(req: Request, res: Response) {
if (!['PUT', 'GET', 'POST'].includes(req.method)) {
res.sendStatus(400).send({error: 'Wrong METHOD. Expecting PUT,GET,POST'});
return;
}
let body;
if (req.method === 'GET') {
body = req.query;
} else {
body = req.body;
}
res.sendStatus(200);
this.queue.push({
eventName: 'generic-webhook.event',
sender: "GithubWebhooks",
data: {
hookData: body,
hookId: req.params.hookId,
} as GenericWebhookEvent,
}).catch((err) => {
log.error(`Failed to emit payload: ${err}`);
});
}
private onPayload(req: Request, res: Response) {
log.info(`New webhook: ${req.url}`);
try {
@ -236,8 +274,6 @@ export class Webhooks extends EventEmitter {
}
return true;
}
console.log(req.body);
console.log(req.headers);
log.error(`No signature on URL. Rejecting`);
res.sendStatus(400);
throw Error("Invalid signature.");

View File

@ -24,7 +24,7 @@ describe("AdminRoom", () => {
expect(intent.sentEvents[0]).to.deep.equal({
roomId: ROOM_ID,
content: AdminRoom.helpMessage,
content: AdminRoom.helpMessage(),
});
});
})

View File

@ -2,6 +2,7 @@ import { FormatUtil } from "../src/FormatUtil";
import { expect } from "chai";
const SIMPLE_ISSUE = {
id: 123,
number: 123,
state: "open",
title: "A simple title",
@ -12,6 +13,7 @@ const SIMPLE_ISSUE = {
};
const SIMPLE_REPO = {
id: 123,
description: "A simple description",
full_name: "evilcorp/lab",
html_url: "https://github.com/evilcorp/lab/issues/123",