From f7bc38d941b34c7a3a1466413fbba5a8abf815c5 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Wed, 14 Sep 2022 10:15:00 +0100 Subject: [PATCH] Store generic hook requests --- src/Bridge.ts | 6 +- src/Connections/GenericHook.ts | 121 +++++++++++++++++++++++++++------ src/generic/Router.ts | 2 + src/generic/types.ts | 2 + 4 files changed, 108 insertions(+), 23 deletions(-) diff --git a/src/Bridge.ts b/src/Bridge.ts index 7a0e2dd2..f887104b 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -564,12 +564,12 @@ export class Bridge { try { // TODO: Support webhook responses to more than one room if (index !== 0) { - await c.onGenericHook(data.hookData); + await c.onGenericHook(data); return; } let successful: boolean|null = null; if (this.config.generic?.waitForComplete) { - successful = await c.onGenericHook(data.hookData); + successful = await c.onGenericHook(data); } await this.queue.push({ data: {successful}, @@ -579,7 +579,7 @@ export class Bridge { }); didPush = true; if (!this.config.generic?.waitForComplete) { - await c.onGenericHook(data.hookData); + await c.onGenericHook(data); } } catch (ex) { diff --git a/src/Connections/GenericHook.ts b/src/Connections/GenericHook.ts index a8d9a052..1f496b01 100644 --- a/src/Connections/GenericHook.ts +++ b/src/Connections/GenericHook.ts @@ -10,6 +10,7 @@ import { ApiError, ErrCode } from "../api"; import { BaseConnection } from "./BaseConnection"; import { GetConnectionsResponseItem } from "../provisioning/api"; import { BridgeConfigGenericWebhooks } from "../Config/Config"; +import { GenericWebhookEvent } from "../generic/types"; export interface GenericHookConnectionState extends IConnectionState { /** @@ -32,6 +33,10 @@ export interface GenericHookSecrets { * The hookId of the webhook. */ hookId: string; + /** + * The results of webhook actions. + */ + lastResults: Array; } export type GenericHookResponseItem = GetConnectionsResponseItem; @@ -52,12 +57,33 @@ interface WebhookTransformationResult { empty?: boolean; } +interface TransformationResult { + logs: string; + content: { + plain: string; + msgtype?: string; + html?: string; + }|null, +} + +export interface LastResult { + timestamp: number; + metadata: { + userAgent?: string; + contentType?: string; + }; + logs?: string; + ok: boolean; + error?: string; +} + const log = new LogWrapper("GenericHookConnection"); const md = new markdownit(); const TRANSFORMATION_TIMEOUT_MS = 500; const SANITIZE_MAX_DEPTH = 5; const SANITIZE_MAX_BREADTH = 25; +const MAX_LAST_RESULT_ITEMS = 5; /** * Handles rooms connected to a generic webhook. @@ -192,6 +218,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection private transformationFunction?: Script; private cachedDisplayname?: string; + private readonly lastResults = new Array(); constructor(roomId: string, private state: GenericHookConnectionState, public readonly hookId: string, @@ -293,27 +320,50 @@ export class GenericHookConnection extends BaseConnection implements IConnection return msg; } - public executeTransformationFunction(data: unknown): {plain: string, html?: string, msgtype?: string}|null { + public executeTransformationFunction(data: unknown): TransformationResult { if (!this.transformationFunction) { throw Error('Transformation function not defined'); } + let logs = ""; const vm = new NodeVM({ - console: 'off', + console: 'redirect', wrapper: 'none', wasm: false, eval: false, timeout: TRANSFORMATION_TIMEOUT_MS, - }); - vm.setGlobal('HookshotApiVersion', 'v2'); - vm.setGlobal('data', data); + }) + .setGlobal('HookshotApiVersion', 'v2') + .setGlobal('data', data) + .on('console.log', (msg, ...args) => { + logs += `\n${msg} ${args.join(' ')}`; + }) + .on('console.info', (msg, ...args) => { + logs += `\ninfo: ${msg} ${args.join(' ')}`; + }) + .on('console.warn', (msg, ...args) => { + logs += `\nwarn: ${msg} ${args.join(' ')}`; + }) + .on('console.error', (msg, ...args) => { + logs += `\nerror: ${msg} ${args.join(' ')}`; + }) vm.run(this.transformationFunction); const result = vm.getGlobal('result'); // Legacy v1 api if (typeof result === "string") { - return {plain: `Received webhook: ${result}`}; + return { + content: { + plain: `Received webhook: ${result}` + }, + logs + }; } else if (typeof result !== "object") { - return {plain: `No content`}; + return { + content: { + plain: "No content" + }, + logs + }; } const transformationResult = result as WebhookTransformationResult; if (transformationResult.version !== "v2") { @@ -321,7 +371,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection } if (transformationResult.empty) { - return null; // No-op + return { logs, content: null }; // No-op } const plain = transformationResult.plain; @@ -336,10 +386,18 @@ export class GenericHookConnection extends BaseConnection implements IConnection } return { - plain: plain, - html: transformationResult.html, - msgtype: transformationResult.msgtype, - } + content: { + plain, + html: transformationResult.html, + msgtype: transformationResult.msgtype, + }, + logs + }; + } + + public addLastResult(result: LastResult) { + this.lastResults.unshift(result); + this.lastResults.splice(MAX_LAST_RESULT_ITEMS-1, 1); } /** @@ -347,22 +405,38 @@ export class GenericHookConnection extends BaseConnection implements IConnection * @param data Structured data. This may either be a string, or an object. * @returns `true` if the webhook completed, or `false` if it failed to complete */ - public async onGenericHook(data: unknown): Promise { + public async onGenericHook(event: GenericWebhookEvent): Promise { + const data = event.hookData; log.info(`onGenericHook ${this.roomId} ${this.hookId}`); let content: {plain: string, html?: string, msgtype?: string}; let success = true; + let functionResult: TransformationResult|undefined; + let error: string|undefined; + + // Matrix cannot handle float data, so make sure we parse out any floats. + const safeData = GenericHookConnection.sanitiseObjectForMatrixJSON(data); if (!this.transformationFunction) { content = this.transformHookData(data); } else { try { - const potentialContent = this.executeTransformationFunction(data); - if (potentialContent === null) { + functionResult = this.executeTransformationFunction(data); + if (functionResult.content === null) { // Explitly no action + this.addLastResult({ + ok: success, + timestamp: Date.now(), + logs: functionResult.logs, + metadata: { + userAgent: event.userAgent, + contentType: event.contentType, + } + }); return true; } - content = potentialContent; + content = functionResult.content; } catch (ex) { log.warn(`Failed to run transformation function`, ex); + error = ex.message; content = {plain: `Webhook received but failed to process via transformation function`}; success = false; } @@ -370,9 +444,6 @@ export class GenericHookConnection extends BaseConnection implements IConnection const sender = this.getUserId(); await this.ensureDisplayname(); - - // Matrix cannot handle float data, so make sure we parse out any floats. - const safeData = GenericHookConnection.sanitiseObjectForMatrixJSON(data); await this.messageClient.sendMatrixMessage(this.roomId, { msgtype: content.msgtype || "m.notice", @@ -382,8 +453,17 @@ export class GenericHookConnection extends BaseConnection implements IConnection format: "org.matrix.custom.html", "uk.half-shot.hookshot.webhook_data": safeData, }, 'm.room.message', sender); + this.addLastResult({ + ok: success, + timestamp: Date.now(), + logs: functionResult?.logs, + error, + metadata: { + userAgent: event.userAgent, + contentType: event.contentType, + } + }); return success; - } public static getProvisionerDetails(botUserId: string) { @@ -407,6 +487,7 @@ export class GenericHookConnection extends BaseConnection implements IConnection ...(showSecrets ? { secrets: { url: new URL(this.hookId, this.config.parsedUrlPrefix), hookId: this.hookId, + lastResults: this.lastResults, } as GenericHookSecrets} : undefined) } } diff --git a/src/generic/Router.ts b/src/generic/Router.ts index 3c505420..caa2fee6 100644 --- a/src/generic/Router.ts +++ b/src/generic/Router.ts @@ -33,6 +33,8 @@ export class GenericWebhooksRouter { data: { hookData: body, hookId: req.params.hookId, + userAgent: req.headers["user-agent"], + contentType: req.headers["content-type"], }, }, WEBHOOK_RESPONSE_TIMEOUT).then((response) => { if (response.notFound) { diff --git a/src/generic/types.ts b/src/generic/types.ts index 39f3a900..31dd5d97 100644 --- a/src/generic/types.ts +++ b/src/generic/types.ts @@ -1,6 +1,8 @@ export interface GenericWebhookEvent { hookData: unknown; hookId: string; + userAgent?: string; + contentType?: string; } export interface GenericWebhookEventResult {