diff --git a/src/MemoryCache.ts b/src/MemoryCache.ts index 576ee75..3883844 100644 --- a/src/MemoryCache.ts +++ b/src/MemoryCache.ts @@ -52,6 +52,7 @@ export const CACHE_IRC_BRIDGE = "irc-bridge"; export const CACHE_STICKERS = "stickers"; export const CACHE_TELEGRAM_BRIDGE = "telegram-bridge"; export const CACHE_HOOKSHOT_GITHUB_BRIDGE = "hookshot-github-bridge"; +export const CACHE_HOOKSHOT_WEBHOOK_BRIDGE = "hookshot-webhook-bridge"; export const CACHE_HOOKSHOT_JIRA_BRIDGE = "hookshot-jira-bridge"; export const CACHE_WEBHOOKS_BRIDGE = "webhooks-bridge"; export const CACHE_SIMPLE_BOTS = "simple-bots"; diff --git a/src/api/admin/AdminHookshotWebhookService.ts b/src/api/admin/AdminHookshotWebhookService.ts new file mode 100644 index 0000000..354f609 --- /dev/null +++ b/src/api/admin/AdminHookshotWebhookService.ts @@ -0,0 +1,111 @@ +import { Context, GET, Path, PathParam, POST, QueryParam, Security, ServiceContext } from "typescript-rest"; +import { Cache, CACHE_HOOKSHOT_WEBHOOK_BRIDGE, CACHE_INTEGRATIONS } from "../../MemoryCache"; +import { LogService } from "matrix-bot-sdk"; +import { ApiError } from "../ApiError"; +import { ROLE_ADMIN, ROLE_USER } from "../security/MatrixSecurity"; +import HookshotGithubBridgeRecord from "../../db/models/HookshotGithubBridgeRecord"; +import HookshotWebhookBridgeRecord from "../../db/models/HookshotWebhookBridgeRecord"; + +interface CreateWithUpstream { + upstreamId: number; +} + +interface CreateSelfhosted { + provisionUrl: string; + sharedSecret: string; +} + +interface BridgeResponse { + id: number; + upstreamId?: number; + provisionUrl?: string; + sharedSecret?: string; + isEnabled: boolean; +} + +/** + * Administrative API for configuring Hookshot Webhook bridge instances. + */ +@Path("/api/v1/dimension/admin/hookshot/webhook") +export class AdminHookshotWebhookService { + + @Context + private context: ServiceContext; + + @GET + @Path("all") + @Security([ROLE_USER, ROLE_ADMIN]) + public async getBridges(): Promise { + const bridges = await HookshotWebhookBridgeRecord.findAll(); + return Promise.all(bridges.map(async b => { + return { + id: b.id, + upstreamId: b.upstreamId, + provisionUrl: b.provisionUrl, + sharedSecret: b.sharedSecret, + isEnabled: b.isEnabled, + }; + })); + } + + @GET + @Path(":bridgeId") + @Security([ROLE_USER, ROLE_ADMIN]) + public async getBridge(@PathParam("bridgeId") bridgeId: number): Promise { + const bridge = await HookshotWebhookBridgeRecord.findByPk(bridgeId); + if (!bridge) throw new ApiError(404, "Webhook Bridge not found"); + + return { + id: bridge.id, + upstreamId: bridge.upstreamId, + provisionUrl: bridge.provisionUrl, + sharedSecret: bridge.sharedSecret, + isEnabled: bridge.isEnabled, + }; + } + + @POST + @Path(":bridgeId") + @Security([ROLE_USER, ROLE_ADMIN]) + public async updateBridge(@PathParam("bridgeId") bridgeId: number, request: CreateSelfhosted): Promise { + const userId = this.context.request.user.userId; + + const bridge = await HookshotWebhookBridgeRecord.findByPk(bridgeId); + if (!bridge) throw new ApiError(404, "Bridge not found"); + + bridge.provisionUrl = request.provisionUrl; + bridge.sharedSecret = request.sharedSecret; + await bridge.save(); + + LogService.info("AdminHookshotWebhookService", userId + " updated Hookshot Webhook Bridge " + bridge.id); + + Cache.for(CACHE_HOOKSHOT_WEBHOOK_BRIDGE).clear(); + Cache.for(CACHE_INTEGRATIONS).clear(); + return this.getBridge(bridge.id); + } + + @POST + @Path("new/upstream") + @Security([ROLE_USER, ROLE_ADMIN]) + public async newConfigForUpstream(@QueryParam("scalar_token") _scalarToken: string, _request: CreateWithUpstream): Promise { + throw new ApiError(400, "Cannot create a webhook bridge from an upstream"); + } + + @POST + @Path("new/selfhosted") + @Security([ROLE_USER, ROLE_ADMIN]) + public async newSelfhosted(request: CreateSelfhosted): Promise { + const userId = this.context.request.user.userId; + + const bridge = await HookshotWebhookBridgeRecord.create({ + provisionUrl: request.provisionUrl, + sharedSecret: request.sharedSecret, + isEnabled: true, + }); + LogService.info("AdminHookshotWebhookService", userId + " created a new Hookshot Webhook Bridge with provisioning URL " + request.provisionUrl); + + Cache.for(CACHE_HOOKSHOT_WEBHOOK_BRIDGE).clear(); + Cache.for(CACHE_INTEGRATIONS).clear(); + return this.getBridge(bridge.id); + } +} diff --git a/src/api/dimension/DimensionHookshotWebhookService.ts b/src/api/dimension/DimensionHookshotWebhookService.ts new file mode 100644 index 0000000..7eb4658 --- /dev/null +++ b/src/api/dimension/DimensionHookshotWebhookService.ts @@ -0,0 +1,56 @@ +import { Context, DELETE, GET, Path, PathParam, POST, Security, ServiceContext } from "typescript-rest"; +import { ApiError } from "../ApiError"; +import { LogService } from "matrix-bot-sdk"; +import { ROLE_USER } from "../security/MatrixSecurity"; +import { + HookshotGithubOrg, + HookshotGithubRepo, + HookshotGithubRoomConfig, + HookshotWebhookRoomConfig +} from "../../bridges/models/hookshot"; +import { HookshotGithubBridge } from "../../bridges/HookshotGithubBridge"; +import { HookshotWebhookBridge } from "../../bridges/HookshotWebhookBridge"; + +interface BridgeRoomRequest { +} + +/** + * API for interacting with the Hookshot/Webhook bridge + */ +@Path("/api/v1/dimension/hookshot/webhook") +export class DimensionHookshotWebhookService { + + @Context + private context: ServiceContext; + + @POST + @Path("room/:roomId/connect") + @Security(ROLE_USER) + public async createWebhook(@PathParam("roomId") roomId: string, request: BridgeRoomRequest): Promise { + const userId = this.context.request.user.userId; + + try { + const hookshot = new HookshotWebhookBridge(userId); + return hookshot.newConnection(roomId); + } catch (e) { + LogService.error("DimensionHookshotWebhookService", e); + throw new ApiError(400, "Error bridging room"); + } + } + + @DELETE + @Path("room/:roomId/connection/:connectionId/disconnect") + @Security(ROLE_USER) + public async removeWebhook(@PathParam("roomId") roomId: string, @PathParam("connectionId") connectionId: string): Promise { + const userId = this.context.request.user.userId; + + try { + const hookshot = new HookshotWebhookBridge(userId); + await hookshot.removeConnection(roomId, connectionId); + return {}; // 200 OK + } catch (e) { + LogService.error("DimensionHookshotWebhookService", e); + throw new ApiError(400, "Error unbridging room"); + } + } +} diff --git a/src/bridges/HookshotWebhookBridge.ts b/src/bridges/HookshotWebhookBridge.ts new file mode 100644 index 0000000..d88d567 --- /dev/null +++ b/src/bridges/HookshotWebhookBridge.ts @@ -0,0 +1,49 @@ +import { + HookshotTypes, + HookshotWebhookRoomConfig +} from "./models/hookshot"; +import { HookshotBridge } from "./HookshotBridge"; +import HookshotWebhookBridgeRecord from "../db/models/HookshotWebhookBridgeRecord"; + +export class HookshotWebhookBridge extends HookshotBridge { + constructor(requestingUserId: string) { + super(requestingUserId); + } + + protected async getDefaultBridge(): Promise { + const bridges = await HookshotWebhookBridgeRecord.findAll({where: {isEnabled: true}}); + if (!bridges || bridges.length !== 1) { + throw new Error("No bridges or too many bridges found"); + } + + return bridges[0]; + } + + public async getBotUserId(): Promise { + const confs = await this.getAllServiceInformation(); + const conf = confs.find(c => c.eventType === HookshotTypes.Webhook); + return conf?.botUserId; + } + + public async isBridgingEnabled(): Promise { + const bridges = await HookshotWebhookBridgeRecord.findAll({where: {isEnabled: true}}); + return !!bridges && bridges.length > 0 && !!(await this.getBotUserId()); + } + + public async getRoomConfigurations(inRoomId: string): Promise { + return (await this.getAllRoomConfigurations(inRoomId)).filter(c => c.eventType === HookshotTypes.Webhook); + } + + public async newConnection(roomId: string): Promise { + const bridge = await this.getDefaultBridge(); + + const body = { + }; + return await this.doProvisionRequest(bridge, "PUT", `/v1/${roomId}/connections/${HookshotTypes.Webhook}`, null, body); + } + + public async removeConnection(roomId: string, connectionId: string): Promise { + const bridge = await this.getDefaultBridge(); + await this.doProvisionRequest(bridge, "DELETE", `/v1/${roomId}/connections/${connectionId}`); + } +} diff --git a/src/bridges/models/hookshot.ts b/src/bridges/models/hookshot.ts index 0d42c99..55ebcbe 100644 --- a/src/bridges/models/hookshot.ts +++ b/src/bridges/models/hookshot.ts @@ -1,6 +1,7 @@ export enum HookshotTypes { Github = "uk.half-shot.matrix-hookshot.github.repository", Jira = "uk.half-shot.matrix-hookshot.jira.project", + Webhook = "uk.half-shot.matrix-hookshot.generic.hook", } export interface HookshotConnection { @@ -90,3 +91,7 @@ export interface HookshotJiraProject { name: string; url: string; } + +export interface HookshotWebhookRoomConfig extends HookshotConnection { + config: {}; +} diff --git a/src/db/BridgeStore.ts b/src/db/BridgeStore.ts index 677b360..88435de 100644 --- a/src/db/BridgeStore.ts +++ b/src/db/BridgeStore.ts @@ -1,7 +1,7 @@ import { Bridge, HookshotGithubBridgeConfiguration, - HookshotJiraBridgeConfiguration, + HookshotJiraBridgeConfiguration, HookshotWebhookBridgeConfiguration, SlackBridgeConfiguration, TelegramBridgeConfiguration, WebhookBridgeConfiguration @@ -14,6 +14,8 @@ import { WebhooksBridge } from "../bridges/WebhooksBridge"; import { SlackBridge } from "../bridges/SlackBridge"; import { HookshotGithubBridge } from "../bridges/HookshotGithubBridge"; import { HookshotJiraBridge } from "../bridges/HookshotJiraBridge"; +import { HookshotWebhookBridge } from "../bridges/HookshotWebhookBridge"; +import HookshotWebhookBridgeRecord from "./models/HookshotWebhookBridgeRecord"; export class BridgeStore { @@ -88,6 +90,9 @@ export class BridgeStore { } else if (record.type === "hookshot_jira") { const hookshot = new HookshotJiraBridge(requestingUserId); return hookshot.isBridgingEnabled(); + } else if (record.type === "hookshot_webhook") { + const hookshot = new HookshotWebhookBridge(requestingUserId); + return hookshot.isBridgingEnabled(); } else return true; } @@ -110,6 +115,9 @@ export class BridgeStore { } else if (record.type === "hookshot_jira") { const hookshot = new HookshotJiraBridge(requestingUserId); return hookshot.isBridgingEnabled(); + } else if (record.type === "hookshot_webhook") { + const hookshot = new HookshotWebhookBridge(requestingUserId); + return hookshot.isBridgingEnabled(); } else return false; } @@ -168,6 +176,15 @@ export class BridgeStore { loggedIn: userInfo.loggedIn, instances: userInfo.instances, }; + } else if (record.type === "hookshot_webhook") { + if (!inRoomId) return {}; // The bridge's admin config is handled by other APIs + const hookshot = new HookshotWebhookBridge(requestingUserId); + const botUserId = await hookshot.getBotUserId(); + const connections = await hookshot.getRoomConfigurations(inRoomId); + return { + botUserId: botUserId, + connections: connections, + }; } else return {}; } diff --git a/src/db/DimensionStore.ts b/src/db/DimensionStore.ts index 54bc73b..64294f1 100644 --- a/src/db/DimensionStore.ts +++ b/src/db/DimensionStore.ts @@ -31,6 +31,7 @@ import TermsSignedRecord from "./models/TermsSignedRecord"; import TermsUpstreamRecord from "./models/TermsUpstreamRecord"; import HookshotGithubBridgeRecord from "./models/HookshotGithubBridgeRecord"; import HookshotJiraBridgeRecord from "./models/HookshotJiraBridgeRecord"; +import HookshotWebhookBridgeRecord from "./models/HookshotWebhookBridgeRecord"; class _DimensionStore { private sequelize: Sequelize; @@ -79,6 +80,7 @@ class _DimensionStore { TermsUpstreamRecord, HookshotGithubBridgeRecord, HookshotJiraBridgeRecord, + HookshotWebhookBridgeRecord, ]); } diff --git a/src/db/migrations/20211202181645-AddHookshotWebhookBridge.ts b/src/db/migrations/20211202181645-AddHookshotWebhookBridge.ts new file mode 100644 index 0000000..5bf2300 --- /dev/null +++ b/src/db/migrations/20211202181645-AddHookshotWebhookBridge.ts @@ -0,0 +1,23 @@ +import { QueryInterface } from "sequelize"; +import { DataType } from "sequelize-typescript"; + +export default { + up: (queryInterface: QueryInterface) => { + return Promise.resolve() + .then(() => queryInterface.createTable("dimension_hookshot_webhook_bridges", { + "id": {type: DataType.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false}, + "upstreamId": { + type: DataType.INTEGER, allowNull: true, + references: {model: "dimension_upstreams", key: "id"}, + onUpdate: "cascade", onDelete: "cascade", + }, + "provisionUrl": {type: DataType.STRING, allowNull: true}, + "sharedSecret": {type: DataType.STRING, allowNull: true}, + "isEnabled": {type: DataType.BOOLEAN, allowNull: false}, + })); + }, + down: (queryInterface: QueryInterface) => { + return Promise.resolve() + .then(() => queryInterface.dropTable("dimension_hookshot_webhook_bridges")); + } +} diff --git a/src/db/migrations/20211202181745-AddHookshotWebhookBridgeRecord.ts b/src/db/migrations/20211202181745-AddHookshotWebhookBridgeRecord.ts new file mode 100644 index 0000000..e41cbf0 --- /dev/null +++ b/src/db/migrations/20211202181745-AddHookshotWebhookBridgeRecord.ts @@ -0,0 +1,23 @@ +import { QueryInterface } from "sequelize"; + +export default { + up: (queryInterface: QueryInterface) => { + return Promise.resolve() + .then(() => queryInterface.bulkInsert("dimension_bridges", [ + { + type: "hookshot_webhook", + name: "Webhooks Bridge", + avatarUrl: "/assets/img/avatars/webhooks.png", + isEnabled: true, + isPublic: true, + description: "Webhooks to Matrix", + }, + ])); + }, + down: (queryInterface: QueryInterface) => { + return Promise.resolve() + .then(() => queryInterface.bulkDelete("dimension_bridges", { + type: "hookshot_webhook", + })); + } +} diff --git a/src/db/migrations/20211202183445-FixLegacyWebhooksBridgeName.ts b/src/db/migrations/20211202183445-FixLegacyWebhooksBridgeName.ts new file mode 100644 index 0000000..5c5af72 --- /dev/null +++ b/src/db/migrations/20211202183445-FixLegacyWebhooksBridgeName.ts @@ -0,0 +1,16 @@ +import { QueryInterface } from "sequelize"; + +export default { + up: (queryInterface: QueryInterface) => { + return Promise.resolve() + .then(() => queryInterface.bulkUpdate("dimension_bridges", { + name: "Webhooks Bridge", + }, { type: "webhooks" })); + }, + down: (queryInterface: QueryInterface) => { + return Promise.resolve() + .then(() => queryInterface.bulkUpdate("dimension_bridges", { + name: "Webhook Bridge", + }, { type: "webhooks" })); + } +} diff --git a/src/db/models/HookshotWebhookBridgeRecord.ts b/src/db/models/HookshotWebhookBridgeRecord.ts new file mode 100644 index 0000000..8dc898f --- /dev/null +++ b/src/db/models/HookshotWebhookBridgeRecord.ts @@ -0,0 +1,31 @@ +import { AllowNull, AutoIncrement, Column, ForeignKey, Model, PrimaryKey, Table } from "sequelize-typescript"; +import Upstream from "./Upstream"; +import { IHookshotBridgeRecord } from "./IHookshotBridgeRecord"; + +@Table({ + tableName: "dimension_hookshot_webhook_bridges", + underscored: false, + timestamps: false, +}) +export default class HookshotWebhookBridgeRecord extends Model implements IHookshotBridgeRecord { + @PrimaryKey + @AutoIncrement + @Column + id: number; + + @AllowNull + @Column + @ForeignKey(() => Upstream) + upstreamId?: number; + + @AllowNull + @Column + provisionUrl?: string; + + @AllowNull + @Column + sharedSecret?: string; + + @Column + isEnabled: boolean; +} diff --git a/src/integrations/Bridge.ts b/src/integrations/Bridge.ts index 2178c05..74d053f 100644 --- a/src/integrations/Bridge.ts +++ b/src/integrations/Bridge.ts @@ -4,7 +4,12 @@ import { AvailableNetworks, LinkedChannels } from "../bridges/IrcBridge"; import { PortalInfo, PuppetInfo } from "../bridges/TelegramBridge"; import { WebhookConfiguration } from "../bridges/models/webhooks"; import { BridgedChannel } from "../bridges/SlackBridge"; -import { HookshotConnection, HookshotJiraInstance } from "../bridges/models/hookshot"; +import { + HookshotConnection, HookshotGithubRoomConfig, + HookshotJiraInstance, + HookshotJiraRoomConfig, + HookshotWebhookRoomConfig +} from "../bridges/models/hookshot"; const PRIVATE_ACCESS_SUPPORTED_BRIDGES = ["webhooks"]; @@ -49,12 +54,17 @@ export interface SlackBridgeConfiguration { export interface HookshotGithubBridgeConfiguration { botUserId: string; - connections: HookshotConnection[]; + connections: HookshotGithubRoomConfig[]; } export interface HookshotJiraBridgeConfiguration { botUserId: string; - connections: HookshotConnection[]; + connections: HookshotJiraRoomConfig[]; loggedIn: boolean; instances?: HookshotJiraInstance[]; } + +export interface HookshotWebhookBridgeConfiguration { + botUserId: string; + connections: HookshotWebhookRoomConfig[]; +} diff --git a/web/app/admin/bridges/hookshot-webhook/hookshot-webhook.component.html b/web/app/admin/bridges/hookshot-webhook/hookshot-webhook.component.html new file mode 100644 index 0000000..beb8b20 --- /dev/null +++ b/web/app/admin/bridges/hookshot-webhook/hookshot-webhook.component.html @@ -0,0 +1,41 @@ +
+ +
+
+ +
+

+ {{'matrix-hookshot' | translate}} + {{'is a multi-purpose bridge which supports Generic Webhooks as an integration. If enabled in the configuration, it can be used here to offer a UI for setting up a webhook into the room.' | translate}} + + + + + + + + + + + + + + + + +
{{'Name' | translate}}{{'Actions' | translate}}
{{'No bridge configurations.' | translate}}
+ {{ bridge.upstreamId ? "matrix.org's bridge" : "Self-hosted bridge" }} + ({{ bridge.provisionUrl }}) + + + + +
+ +

+
+
diff --git a/web/app/admin/bridges/hookshot-webhook/hookshot-webhook.component.scss b/web/app/admin/bridges/hookshot-webhook/hookshot-webhook.component.scss new file mode 100644 index 0000000..788d7ed --- /dev/null +++ b/web/app/admin/bridges/hookshot-webhook/hookshot-webhook.component.scss @@ -0,0 +1,3 @@ +.editButton { + cursor: pointer; +} \ No newline at end of file diff --git a/web/app/admin/bridges/hookshot-webhook/hookshot-webhook.component.ts b/web/app/admin/bridges/hookshot-webhook/hookshot-webhook.component.ts new file mode 100644 index 0000000..6e8e11a --- /dev/null +++ b/web/app/admin/bridges/hookshot-webhook/hookshot-webhook.component.ts @@ -0,0 +1,85 @@ +import { Component, OnInit } from "@angular/core"; +import { ToasterService } from "angular2-toaster"; +import { + AdminHookshotWebhookBridgeManageSelfhostedComponent, + ManageSelfhostedHookshotWebhookBridgeDialogContext +} from "./manage-selfhosted/manage-selfhosted.component"; +import { TranslateService } from "@ngx-translate/core"; +import { NgbModal } from "@ng-bootstrap/ng-bootstrap"; +import { AdminHookshotWebhookApiService } from "../../../shared/services/admin/admin-hookshot-webhook-api.service"; +import { FE_HookshotWebhookBridge } from "../../../shared/models/hookshot_webhook"; + +@Component({ + templateUrl: "./hookshot-webhook.component.html", + styleUrls: ["./hookshot-webhook.component.scss"], +}) +export class AdminHookshotWebhookBridgeComponent implements OnInit { + + public isLoading = true; + public isUpdating = false; + public configurations: FE_HookshotWebhookBridge[] = []; + + constructor(private hookshotApi: AdminHookshotWebhookApiService, + private toaster: ToasterService, + private modal: NgbModal, + public translate: TranslateService) { + this.translate = translate; + } + + public ngOnInit() { + this.reload().then(() => this.isLoading = false); + } + + private async reload(): Promise { + try { + this.configurations = await this.hookshotApi.getBridges(); + } catch (err) { + console.error(err); + this.translate.get('Error loading bridges').subscribe((res: string) => { + this.toaster.pop("error", res); + }); + } + } + + public addSelfHostedBridge() { + const selfhostedRef = this.modal.open(AdminHookshotWebhookBridgeManageSelfhostedComponent, { + backdrop: 'static', + size: 'lg', + }); + selfhostedRef.result.then(() => { + try { + this.reload() + } catch (err) { + console.error(err); + this.translate.get('Failed to get an updated Webhook bridge list').subscribe((res: string) => { + this.toaster.pop("error", res); + }); + } + }) + const selfhostedInstance = selfhostedRef.componentInstance as ManageSelfhostedHookshotWebhookBridgeDialogContext; + selfhostedInstance.provisionUrl = ''; + selfhostedInstance.sharedSecret = ''; + } + + public editBridge(bridge: FE_HookshotWebhookBridge) { + const selfhostedRef = this.modal.open(AdminHookshotWebhookBridgeManageSelfhostedComponent, { + backdrop: 'static', + size: 'lg', + }); + selfhostedRef.result.then(() => { + try { + this.reload() + } catch (err) { + console.error(err); + this.translate.get('Failed to get an updated Webhook bridge list').subscribe((res: string) => { + this.toaster.pop("error", res); + }); + } + }) + const selfhostedInstance = selfhostedRef.componentInstance as ManageSelfhostedHookshotWebhookBridgeDialogContext; + selfhostedInstance.provisionUrl = bridge.provisionUrl; + selfhostedInstance.sharedSecret = bridge.sharedSecret; + selfhostedInstance.bridgeId = bridge.id; + selfhostedInstance.isAdding = !bridge.id; + } +} diff --git a/web/app/admin/bridges/hookshot-webhook/manage-selfhosted/manage-selfhosted.component.html b/web/app/admin/bridges/hookshot-webhook/manage-selfhosted/manage-selfhosted.component.html new file mode 100644 index 0000000..767cf02 --- /dev/null +++ b/web/app/admin/bridges/hookshot-webhook/manage-selfhosted/manage-selfhosted.component.html @@ -0,0 +1,31 @@ + + + diff --git a/web/app/admin/bridges/hookshot-webhook/manage-selfhosted/manage-selfhosted.component.scss b/web/app/admin/bridges/hookshot-webhook/manage-selfhosted/manage-selfhosted.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/web/app/admin/bridges/hookshot-webhook/manage-selfhosted/manage-selfhosted.component.ts b/web/app/admin/bridges/hookshot-webhook/manage-selfhosted/manage-selfhosted.component.ts new file mode 100644 index 0000000..8902f67 --- /dev/null +++ b/web/app/admin/bridges/hookshot-webhook/manage-selfhosted/manage-selfhosted.component.ts @@ -0,0 +1,63 @@ +import { Component } from "@angular/core"; +import { NgbActiveModal } from "@ng-bootstrap/ng-bootstrap"; +import { ToasterService } from "angular2-toaster"; +import { TranslateService } from "@ngx-translate/core"; +import { AdminHookshotWebhookApiService } from "../../../../shared/services/admin/admin-hookshot-webhook-api.service"; + +export interface ManageSelfhostedHookshotWebhookBridgeDialogContext { + provisionUrl: string; + sharedSecret: string; + bridgeId: number; + isAdding: boolean; +} + +@Component({ + templateUrl: "./manage-selfhosted.component.html", + styleUrls: ["./manage-selfhosted.component.scss"], +}) +export class AdminHookshotWebhookBridgeManageSelfhostedComponent { + + isSaving = false; + provisionUrl: string; + sharedSecret: string; + bridgeId: number; + isAdding = true; + + constructor(public modal: NgbActiveModal, + private hookshotApi: AdminHookshotWebhookApiService, + private toaster: ToasterService, + public translate: TranslateService) { + this.translate = translate; + } + + public add() { + this.isSaving = true; + if (this.isAdding) { + this.hookshotApi.newSelfhosted(this.provisionUrl, this.sharedSecret).then(() => { + this.translate.get('Webhook bridge added').subscribe((res: string) => { + this.toaster.pop("success", res); + }); + this.modal.close(); + }).catch(err => { + console.error(err); + this.isSaving = false; + this.translate.get('Failed to create Webhook bridge').subscribe((res: string) => { + this.toaster.pop("error", res); + }); + }); + } else { + this.hookshotApi.updateSelfhosted(this.bridgeId, this.provisionUrl, this.sharedSecret).then(() => { + this.translate.get('Webhook bridge updated').subscribe((res: string) => { + this.toaster.pop("success", res); + }); + this.modal.close(); + }).catch(err => { + console.error(err); + this.isSaving = false; + this.translate.get('Failed to update Webhook bridge').subscribe((res: string) => { + this.toaster.pop("error", res); + }); + }); + } + } +} diff --git a/web/app/app.module.ts b/web/app/app.module.ts index 5690b67..659effe 100644 --- a/web/app/app.module.ts +++ b/web/app/app.module.ts @@ -143,6 +143,15 @@ import { import { AdminHookshotJiraApiService } from "./shared/services/admin/admin-hookshot-jira-api.service"; import { HookshotJiraApiService } from "./shared/services/integrations/hookshot-jira-api.service"; import { HookshotJiraBridgeConfigComponent } from "./configs/bridge/hookshot-jira/hookshot-jira.bridge.component"; +import { AdminHookshotWebhookBridgeComponent } from "./admin/bridges/hookshot-webhook/hookshot-webhook.component"; +import { + AdminHookshotWebhookBridgeManageSelfhostedComponent +} from "./admin/bridges/hookshot-webhook/manage-selfhosted/manage-selfhosted.component"; +import { AdminHookshotWebhookApiService } from "./shared/services/admin/admin-hookshot-webhook-api.service"; +import { HookshotWebhookApiService } from "./shared/services/integrations/hookshot-webhook-api.service"; +import { + HookshotWebhookBridgeConfigComponent +} from "./configs/bridge/hookshot-webhook/hookshot-webhook.bridge.component"; // AoT requires an exported function for factories export function HttpLoaderFactory(http: HttpClient) { @@ -258,6 +267,9 @@ export function HttpLoaderFactory(http: HttpClient) { AdminHookshotJiraBridgeComponent, AdminHookshotJiraBridgeManageSelfhostedComponent, HookshotJiraBridgeConfigComponent, + AdminHookshotWebhookBridgeComponent, + AdminHookshotWebhookBridgeManageSelfhostedComponent, + HookshotWebhookBridgeConfigComponent, // Vendor ], @@ -291,6 +303,8 @@ export function HttpLoaderFactory(http: HttpClient) { HookshotGithubApiService, AdminHookshotJiraApiService, HookshotJiraApiService, + AdminHookshotWebhookApiService, + HookshotWebhookApiService, {provide: Window, useValue: window}, // Vendor diff --git a/web/app/app.routing.ts b/web/app/app.routing.ts index 4979cc0..a1d5866 100644 --- a/web/app/app.routing.ts +++ b/web/app/app.routing.ts @@ -54,6 +54,10 @@ import { AdminHookshotGithubBridgeComponent } from "./admin/bridges/hookshot-git import { HookshotGithubBridgeConfigComponent } from "./configs/bridge/hookshot-github/hookshot-github.bridge.component"; import { AdminHookshotJiraBridgeComponent } from "./admin/bridges/hookshot-jira/hookshot-jira.component"; import { HookshotJiraBridgeConfigComponent } from "./configs/bridge/hookshot-jira/hookshot-jira.bridge.component"; +import { AdminHookshotWebhookBridgeComponent } from "./admin/bridges/hookshot-webhook/hookshot-webhook.component"; +import { + HookshotWebhookBridgeConfigComponent +} from "./configs/bridge/hookshot-webhook/hookshot-webhook.bridge.component"; const routes: Routes = [ {path: "", component: HomeComponent}, @@ -150,6 +154,11 @@ const routes: Routes = [ component: AdminHookshotJiraBridgeComponent, data: {breadcrumb: "Jira Bridge", name: "Jira Bridge"}, }, + { + path: "hookshot_webhook", + component: AdminHookshotWebhookBridgeComponent, + data: {breadcrumb: "Webhook Bridge", name: "Webhook Bridge"}, + }, ], }, { @@ -300,6 +309,11 @@ const routes: Routes = [ component: HookshotJiraBridgeConfigComponent, data: {breadcrumb: "Jira Bridge Configuration", name: "Jira Bridge Configuration"}, }, + { + path: "hookshot_webhook", + component: HookshotWebhookBridgeConfigComponent, + data: {breadcrumb: "Webhook Bridge Configuration", name: "Webhook Bridge Configuration"}, + }, ], }, { diff --git a/web/app/configs/bridge/hookshot-webhook/hookshot-webhook.bridge.component.html b/web/app/configs/bridge/hookshot-webhook/hookshot-webhook.bridge.component.html new file mode 100644 index 0000000..95f8a74 --- /dev/null +++ b/web/app/configs/bridge/hookshot-webhook/hookshot-webhook.bridge.component.html @@ -0,0 +1,54 @@ + + + +
+ {{'Add a new webhook' | translate}} +
+
+ +
+ +
+
+
+ +
+ Webhooks +
+
+ + + + + + + + + + + + + + + + + + + +
NameURL{{'Actions' | translate}}
{{'No webhooks' | translate}}
{{ hook.config.name }}{{'No name' | translate}}{{ hook.config.url }} + +
+
+
+
+
diff --git a/web/app/configs/bridge/hookshot-webhook/hookshot-webhook.bridge.component.scss b/web/app/configs/bridge/hookshot-webhook/hookshot-webhook.bridge.component.scss new file mode 100644 index 0000000..cedec6b --- /dev/null +++ b/web/app/configs/bridge/hookshot-webhook/hookshot-webhook.bridge.component.scss @@ -0,0 +1,3 @@ +.webhook-url { + word-break: break-word; +} \ No newline at end of file diff --git a/web/app/configs/bridge/hookshot-webhook/hookshot-webhook.bridge.component.ts b/web/app/configs/bridge/hookshot-webhook/hookshot-webhook.bridge.component.ts new file mode 100644 index 0000000..bba9d6f --- /dev/null +++ b/web/app/configs/bridge/hookshot-webhook/hookshot-webhook.bridge.component.ts @@ -0,0 +1,78 @@ +import { Component } from "@angular/core"; +import { BridgeComponent } from "../bridge.component"; +import { FE_Webhook } from "../../../shared/models/webhooks"; +import { WebhooksApiService } from "../../../shared/services/integrations/webhooks-api.service"; +import { ScalarClientApiService } from "../../../shared/services/scalar/scalar-client-api.service"; +import { TranslateService } from "@ngx-translate/core"; +import { FE_HookshotJiraConnection } from "../../../shared/models/hookshot_jira"; +import { FE_HookshotWebhookConnection } from "../../../shared/models/hookshot_webhook"; +import { HookshotWebhookApiService } from "../../../shared/services/integrations/hookshot-webhook-api.service"; + +interface HookshotConfig { + botUserId: string; + connections: FE_HookshotWebhookConnection[]; +} + +@Component({ + templateUrl: "hookshot-webhook.bridge.component.html", + styleUrls: ["hookshot-webhook.bridge.component.scss"], +}) +export class HookshotWebhookBridgeConfigComponent extends BridgeComponent { + + public webhookName: string; + public isBusy = false; + + constructor(private webhooks: HookshotWebhookApiService, private scalar: ScalarClientApiService, public translate: TranslateService) { + super("hookshot_webhook", translate); + } + + public async newHook() { + this.isBusy = true; + + try { + await this.scalar.inviteUser(this.roomId, this.newConfig.botUserId); + } catch (e) { + if (!e.response || !e.response.error || !e.response.error._error || + e.response.error._error.message.indexOf("already in the room") === -1) { + this.isBusy = false; + this.translate.get('Error inviting bridge').subscribe((res: string) => { + this.toaster.pop("error", res); + }); + return; + } + } + + this.webhooks.createWebhook(this.roomId, this.webhookName).then(hook => { + this.newConfig.connections.push(hook); + this.isBusy = false; + this.webhookName = ""; + this.translate.get('Webhook created').subscribe((res: string) => { + this.toaster.pop("success", res); + }); + }).catch(err => { + console.error(err); + this.isBusy = false; + this.translate.get('Error creating webhook').subscribe((res: string) => { + this.toaster.pop("error", res); + }); + }); + } + + public removeHook(hook: FE_HookshotWebhookConnection) { + this.isBusy = true; + this.webhooks.deleteWebhook(this.roomId, hook.id).then(() => { + const idx = this.newConfig.connections.indexOf(hook); + if (idx !== -1) this.newConfig.connections.splice(idx, 1); + this.isBusy = false; + this.translate.get('Webhook deleted').subscribe((res: string) => { + this.toaster.pop("success", res); + }); + }).catch(err => { + console.error(err); + this.isBusy = false; + this.translate.get('Error deleting webhook').subscribe((res: string) => { + this.toaster.pop("error", res); + }); + }); + } +} diff --git a/web/app/shared/models/hookshot_webhook.ts b/web/app/shared/models/hookshot_webhook.ts new file mode 100644 index 0000000..8f2f14f --- /dev/null +++ b/web/app/shared/models/hookshot_webhook.ts @@ -0,0 +1,15 @@ +export interface FE_HookshotWebhookBridge { + id: number; + upstreamId?: number; + provisionUrl?: string; + sharedSecret?: string; + isEnabled: boolean; +} + +export interface FE_HookshotWebhookConnection { + id: string; + config: { + name?: string; // TODO: Update according to bridge support + url: string; + }; +} diff --git a/web/app/shared/registry/integrations.registry.ts b/web/app/shared/registry/integrations.registry.ts index 19e95b6..f10d653 100644 --- a/web/app/shared/registry/integrations.registry.ts +++ b/web/app/shared/registry/integrations.registry.ts @@ -33,6 +33,7 @@ export class IntegrationsRegistry { "slack": {}, "hookshot_github": {}, "hookshot_jira": {}, + "hookshot_webhook": {}, }, "widget": { "custom": { diff --git a/web/app/shared/services/admin/admin-hookshot-webhook-api.service.ts b/web/app/shared/services/admin/admin-hookshot-webhook-api.service.ts new file mode 100644 index 0000000..b28ecff --- /dev/null +++ b/web/app/shared/services/admin/admin-hookshot-webhook-api.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from "@angular/core"; +import { AuthedApi } from "../authed-api"; +import { FE_Upstream } from "../../models/admin-responses"; +import { HttpClient } from "@angular/common/http"; +import { FE_HookshotGithubBridge } from "../../models/hookshot_github"; +import { FE_HookshotWebhookBridge } from "../../models/hookshot_webhook"; + +@Injectable() +export class AdminHookshotWebhookApiService extends AuthedApi { + constructor(http: HttpClient) { + super(http); + } + + public getBridges(): Promise { + return this.authedGet("/api/v1/dimension/admin/hookshot/webhook/all").toPromise(); + } + + public getBridge(bridgeId: number): Promise { + return this.authedGet("/api/v1/dimension/admin/hookshot/webhook/" + bridgeId).toPromise(); + } + + public newFromUpstream(upstream: FE_Upstream): Promise { + return this.authedPost("/api/v1/dimension/admin/hookshot/webhook/new/upstream", {upstreamId: upstream.id}).toPromise(); + } + + public newSelfhosted(provisionUrl: string, sharedSecret: string): Promise { + return this.authedPost("/api/v1/dimension/admin/hookshot/webhook/new/selfhosted", { + provisionUrl: provisionUrl, + sharedSecret: sharedSecret, + }).toPromise(); + } + + public updateSelfhosted(bridgeId: number, provisionUrl: string, sharedSecret: string): Promise { + return this.authedPost("/api/v1/dimension/admin/hookshot/webhook/" + bridgeId, { + provisionUrl: provisionUrl, + sharedSecret: sharedSecret, + }).toPromise(); + } +} diff --git a/web/app/shared/services/integrations/hookshot-webhook-api.service.ts b/web/app/shared/services/integrations/hookshot-webhook-api.service.ts new file mode 100644 index 0000000..5802149 --- /dev/null +++ b/web/app/shared/services/integrations/hookshot-webhook-api.service.ts @@ -0,0 +1,22 @@ +import { Injectable } from "@angular/core"; +import { AuthedApi } from "../authed-api"; +import { HttpClient } from "@angular/common/http"; +import { FE_HookshotWebhookConnection } from "../../models/hookshot_webhook"; + +@Injectable() +export class HookshotWebhookApiService extends AuthedApi { + constructor(http: HttpClient) { + super(http); + } + + public createWebhook(roomId: string, name: string): Promise { + return this.authedPost("/api/v1/dimension/hookshot/webhook/room/" + roomId + "/connect", { + name, + }).toPromise(); + } + + public deleteWebhook(roomId: string, webhookId: string): Promise { + return this.authedDelete("/api/v1/dimension/hookshot/webhook/room/" + roomId + "/connection/" + encodeURIComponent(webhookId) + "/disconnect").toPromise(); + } + +}