From 7b5285cd57bea439c775cbc1a262d5e6a1531f50 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Sat, 20 Oct 2018 14:07:30 -0600 Subject: [PATCH] Add the admin section for the webhooks bridge --- src/api/admin/AdminWebhooksService.ts | 106 ++++++++++++++++++ .../manage-selfhosted.component.html | 32 ++++++ .../manage-selfhosted.component.scss | 0 .../manage-selfhosted.component.ts | 58 ++++++++++ .../bridges/webhooks/webhooks.component.html | 41 +++++++ .../bridges/webhooks/webhooks.component.scss | 3 + .../bridges/webhooks/webhooks.component.ts | 70 ++++++++++++ web/app/app.module.ts | 7 ++ web/app/app.routing.ts | 6 + web/app/shared/models/webhooks.ts | 7 ++ .../admin/admin-webhooks-api.service.ts | 38 +++++++ 11 files changed, 368 insertions(+) create mode 100644 src/api/admin/AdminWebhooksService.ts create mode 100644 web/app/admin/bridges/webhooks/manage-selfhosted/manage-selfhosted.component.html create mode 100644 web/app/admin/bridges/webhooks/manage-selfhosted/manage-selfhosted.component.scss create mode 100644 web/app/admin/bridges/webhooks/manage-selfhosted/manage-selfhosted.component.ts create mode 100644 web/app/admin/bridges/webhooks/webhooks.component.html create mode 100644 web/app/admin/bridges/webhooks/webhooks.component.scss create mode 100644 web/app/admin/bridges/webhooks/webhooks.component.ts create mode 100644 web/app/shared/models/webhooks.ts create mode 100644 web/app/shared/services/admin/admin-webhooks-api.service.ts diff --git a/src/api/admin/AdminWebhooksService.ts b/src/api/admin/AdminWebhooksService.ts new file mode 100644 index 0000000..2aa52b1 --- /dev/null +++ b/src/api/admin/AdminWebhooksService.ts @@ -0,0 +1,106 @@ +import { GET, Path, PathParam, POST, QueryParam } from "typescript-rest"; +import { AdminService } from "./AdminService"; +import { Cache, CACHE_INTEGRATIONS, CACHE_TELEGRAM_BRIDGE } from "../../MemoryCache"; +import { LogService } from "matrix-js-snippets"; +import { ApiError } from "../ApiError"; +import WebhookBridgeRecord from "../../db/models/WebhookBridgeRecord"; + +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 Webhook bridge instances. + */ +@Path("/api/v1/dimension/admin/webhooks") +export class AdminWebhooksService { + + @GET + @Path("all") + public async getBridges(@QueryParam("scalar_token") scalarToken: string): Promise { + await AdminService.validateAndGetAdminTokenOwner(scalarToken); + + const bridges = await WebhookBridgeRecord.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") + public async getBridge(@QueryParam("scalar_token") scalarToken: string, @PathParam("bridgeId") bridgeId: number): Promise { + await AdminService.validateAndGetAdminTokenOwner(scalarToken); + + const webhookBridge = await WebhookBridgeRecord.findByPrimary(bridgeId); + if (!webhookBridge) throw new ApiError(404, "Webhook Bridge not found"); + + return { + id: webhookBridge.id, + upstreamId: webhookBridge.upstreamId, + provisionUrl: webhookBridge.provisionUrl, + sharedSecret: webhookBridge.sharedSecret, + isEnabled: webhookBridge.isEnabled, + }; + } + + @POST + @Path(":bridgeId") + public async updateBridge(@QueryParam("scalar_token") scalarToken: string, @PathParam("bridgeId") bridgeId: number, request: CreateSelfhosted): Promise { + const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken); + + const bridge = await WebhookBridgeRecord.findByPrimary(bridgeId); + if (!bridge) throw new ApiError(404, "Bridge not found"); + + bridge.provisionUrl = request.provisionUrl; + bridge.sharedSecret = request.sharedSecret; + await bridge.save(); + + LogService.info("AdminWebhooksService", userId + " updated Webhook Bridge " + bridge.id); + + Cache.for(CACHE_TELEGRAM_BRIDGE).clear(); + Cache.for(CACHE_INTEGRATIONS).clear(); + return this.getBridge(scalarToken, bridge.id); + } + + @POST + @Path("new/upstream") + 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") + public async newSelfhosted(@QueryParam("scalar_token") scalarToken: string, request: CreateSelfhosted): Promise { + const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken); + + const bridge = await WebhookBridgeRecord.create({ + provisionUrl: request.provisionUrl, + sharedSecret: request.sharedSecret, + isEnabled: true, + }); + LogService.info("AdminWebhooksService", userId + " created a new Webhook Bridge with provisioning URL " + request.provisionUrl); + + Cache.for(CACHE_TELEGRAM_BRIDGE).clear(); + Cache.for(CACHE_INTEGRATIONS).clear(); + return this.getBridge(scalarToken, bridge.id); + } +} \ No newline at end of file diff --git a/web/app/admin/bridges/webhooks/manage-selfhosted/manage-selfhosted.component.html b/web/app/admin/bridges/webhooks/manage-selfhosted/manage-selfhosted.component.html new file mode 100644 index 0000000..ee4472a --- /dev/null +++ b/web/app/admin/bridges/webhooks/manage-selfhosted/manage-selfhosted.component.html @@ -0,0 +1,32 @@ +
+
+

{{ isAdding ? "Add a new" : "Edit" }} self-hosted webhook bridge

+
+
+

Self-hosted webhook bridges must have provisioning enabled in the configuration.

+ + + + +
+ +
\ No newline at end of file diff --git a/web/app/admin/bridges/webhooks/manage-selfhosted/manage-selfhosted.component.scss b/web/app/admin/bridges/webhooks/manage-selfhosted/manage-selfhosted.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/web/app/admin/bridges/webhooks/manage-selfhosted/manage-selfhosted.component.ts b/web/app/admin/bridges/webhooks/manage-selfhosted/manage-selfhosted.component.ts new file mode 100644 index 0000000..454b876 --- /dev/null +++ b/web/app/admin/bridges/webhooks/manage-selfhosted/manage-selfhosted.component.ts @@ -0,0 +1,58 @@ +import { Component } from "@angular/core"; +import { ToasterService } from "angular2-toaster"; +import { DialogRef, ModalComponent } from "ngx-modialog"; +import { BSModalContext } from "ngx-modialog/plugins/bootstrap"; +import { AdminWebhooksApiService } from "../../../../shared/services/admin/admin-webhooks-api.service"; + +export class ManageSelfhostedWebhooksBridgeDialogContext extends BSModalContext { + public provisionUrl: string; + public sharedSecret: string; + public allowTgPuppets = false; + public allowMxPuppets = false; + public bridgeId: number; +} + +@Component({ + templateUrl: "./manage-selfhosted.component.html", + styleUrls: ["./manage-selfhosted.component.scss"], +}) +export class AdminWebhooksBridgeManageSelfhostedComponent implements ModalComponent { + + public isSaving = false; + public provisionUrl: string; + public sharedSecret: string; + public bridgeId: number; + public isAdding = false; + + constructor(public dialog: DialogRef, + private webhooksApi: AdminWebhooksApiService, + private toaster: ToasterService) { + this.provisionUrl = dialog.context.provisionUrl; + this.sharedSecret = dialog.context.sharedSecret; + this.bridgeId = dialog.context.bridgeId; + this.isAdding = !this.bridgeId; + } + + public add() { + this.isSaving = true; + if (this.isAdding) { + this.webhooksApi.newSelfhosted(this.provisionUrl, this.sharedSecret).then(() => { + this.toaster.pop("success", "Webhook bridge added"); + this.dialog.close(); + }).catch(err => { + console.error(err); + this.isSaving = false; + this.toaster.pop("error", "Failed to create Webhook bridge"); + }); + } else { + this.webhooksApi.updateSelfhosted(this.bridgeId, this.provisionUrl, this.sharedSecret).then(() => { + this.toaster.pop("success", "Webhook bridge updated"); + this.dialog.close(); + }).catch(err => { + console.error(err); + this.isSaving = false; + this.toaster.pop("error", "Failed to update Webhook bridge"); + }); + } + } +} diff --git a/web/app/admin/bridges/webhooks/webhooks.component.html b/web/app/admin/bridges/webhooks/webhooks.component.html new file mode 100644 index 0000000..f78ad23 --- /dev/null +++ b/web/app/admin/bridges/webhooks/webhooks.component.html @@ -0,0 +1,41 @@ +
+ +
+
+ +
+

+ matrix-appservice-webhooks + provides Slack-compatible webhooks for Matrix, making it easy to send updates into a room. +

+ + + + + + + + + + + + + + + + + +
NameActions
No bridge configurations.
+ {{ bridge.upstreamId ? "matrix.org's bridge" : "Self-hosted bridge" }} + ({{ bridge.provisionUrl }}) + + + + +
+ +
+
+
\ No newline at end of file diff --git a/web/app/admin/bridges/webhooks/webhooks.component.scss b/web/app/admin/bridges/webhooks/webhooks.component.scss new file mode 100644 index 0000000..788d7ed --- /dev/null +++ b/web/app/admin/bridges/webhooks/webhooks.component.scss @@ -0,0 +1,3 @@ +.editButton { + cursor: pointer; +} \ No newline at end of file diff --git a/web/app/admin/bridges/webhooks/webhooks.component.ts b/web/app/admin/bridges/webhooks/webhooks.component.ts new file mode 100644 index 0000000..d41e3a3 --- /dev/null +++ b/web/app/admin/bridges/webhooks/webhooks.component.ts @@ -0,0 +1,70 @@ +import { Component, OnInit } from "@angular/core"; +import { ToasterService } from "angular2-toaster"; +import { Modal, overlayConfigFactory } from "ngx-modialog"; +import { + AdminWebhooksBridgeManageSelfhostedComponent, + ManageSelfhostedWebhooksBridgeDialogContext +} from "./manage-selfhosted/manage-selfhosted.component"; +import { FE_WebhooksBridge } from "../../../shared/models/webhooks"; +import { AdminWebhooksApiService } from "../../../shared/services/admin/admin-webhooks-api.service"; + +@Component({ + templateUrl: "./webhooks.component.html", + styleUrls: ["./webhooks.component.scss"], +}) +export class AdminWebhooksBridgeComponent implements OnInit { + + public isLoading = true; + public isUpdating = false; + public configurations: FE_WebhooksBridge[] = []; + + constructor(private webhooksApi: AdminWebhooksApiService, + private toaster: ToasterService, + private modal: Modal) { + } + + public ngOnInit() { + this.reload().then(() => this.isLoading = false); + } + + private async reload(): Promise { + try { + this.configurations = await this.webhooksApi.getBridges(); + } catch (err) { + console.error(err); + this.toaster.pop("error", "Error loading bridges"); + } + } + + public addSelfHostedBridge() { + this.modal.open(AdminWebhooksBridgeManageSelfhostedComponent, overlayConfigFactory({ + isBlocking: true, + size: 'lg', + + provisionUrl: '', + sharedSecret: '', + allowPuppets: false, + }, ManageSelfhostedWebhooksBridgeDialogContext)).result.then(() => { + this.reload().catch(err => { + console.error(err); + this.toaster.pop("error", "Failed to get an update Webhooks bridge list"); + }); + }); + } + + public editBridge(bridge: FE_WebhooksBridge) { + this.modal.open(AdminWebhooksBridgeManageSelfhostedComponent, overlayConfigFactory({ + isBlocking: true, + size: 'lg', + + provisionUrl: bridge.provisionUrl, + sharedSecret: bridge.sharedSecret, + bridgeId: bridge.id, + }, ManageSelfhostedWebhooksBridgeDialogContext)).result.then(() => { + this.reload().catch(err => { + console.error(err); + this.toaster.pop("error", "Failed to get an update Webhooks bridge list"); + }); + }); + } +} diff --git a/web/app/app.module.ts b/web/app/app.module.ts index e959d60..70e8d34 100644 --- a/web/app/app.module.ts +++ b/web/app/app.module.ts @@ -85,6 +85,9 @@ import { TelegramApiService } from "./shared/services/integrations/telegram-api. import { TelegramBridgeConfigComponent } from "./configs/bridge/telegram/telegram.bridge.component"; import { TelegramAskUnbridgeComponent } from "./configs/bridge/telegram/ask-unbridge/ask-unbridge.component"; import { TelegramCannotUnbridgeComponent } from "./configs/bridge/telegram/cannot-unbridge/cannot-unbridge.component"; +import { AdminWebhooksBridgeManageSelfhostedComponent } from "./admin/bridges/webhooks/manage-selfhosted/manage-selfhosted.component"; +import { AdminWebhooksBridgeComponent } from "./admin/bridges/webhooks/webhooks.component"; +import { AdminWebhooksApiService } from "./shared/services/admin/admin-webhooks-api.service"; @NgModule({ imports: [ @@ -157,6 +160,8 @@ import { TelegramCannotUnbridgeComponent } from "./configs/bridge/telegram/canno TelegramBridgeConfigComponent, TelegramAskUnbridgeComponent, TelegramCannotUnbridgeComponent, + AdminWebhooksBridgeManageSelfhostedComponent, + AdminWebhooksBridgeComponent, // Vendor ], @@ -178,6 +183,7 @@ import { TelegramCannotUnbridgeComponent } from "./configs/bridge/telegram/canno StickerApiService, AdminTelegramApiService, TelegramApiService, + AdminWebhooksApiService, {provide: Window, useValue: window}, // Vendor @@ -198,6 +204,7 @@ import { TelegramCannotUnbridgeComponent } from "./configs/bridge/telegram/canno AdminTelegramBridgeManageSelfhostedComponent, TelegramAskUnbridgeComponent, TelegramCannotUnbridgeComponent, + AdminWebhooksBridgeManageSelfhostedComponent, ] }) export class AppModule { diff --git a/web/app/app.routing.ts b/web/app/app.routing.ts index 6e60ec6..fc8c79f 100644 --- a/web/app/app.routing.ts +++ b/web/app/app.routing.ts @@ -29,6 +29,7 @@ import { StickerpickerComponent } from "./configs/stickerpicker/stickerpicker.co import { StickerPickerWidgetWrapperComponent } from "./widget-wrappers/sticker-picker/sticker-picker.component"; import { AdminTelegramBridgeComponent } from "./admin/bridges/telegram/telegram.component"; import { TelegramBridgeConfigComponent } from "./configs/bridge/telegram/telegram.bridge.component"; +import { AdminWebhooksBridgeComponent } from "./admin/bridges/webhooks/webhooks.component"; const routes: Routes = [ {path: "", component: HomeComponent}, @@ -94,6 +95,11 @@ const routes: Routes = [ component: AdminTelegramBridgeComponent, data: {breadcrumb: "Telegram Bridge", name: "Telegram Bridge"}, }, + { + path: "webhooks", + component: AdminWebhooksBridgeComponent, + data: {breadcrumb: "Webhook Bridge", name: "Webhook Bridge"}, + }, ], }, { diff --git a/web/app/shared/models/webhooks.ts b/web/app/shared/models/webhooks.ts new file mode 100644 index 0000000..99cd742 --- /dev/null +++ b/web/app/shared/models/webhooks.ts @@ -0,0 +1,7 @@ +export interface FE_WebhooksBridge { + id: number; + upstreamId?: number; + provisionUrl?: string; + sharedSecret?: string; + isEnabled: boolean; +} diff --git a/web/app/shared/services/admin/admin-webhooks-api.service.ts b/web/app/shared/services/admin/admin-webhooks-api.service.ts new file mode 100644 index 0000000..bf87589 --- /dev/null +++ b/web/app/shared/services/admin/admin-webhooks-api.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from "@angular/core"; +import { Http } from "@angular/http"; +import { AuthedApi } from "../authed-api"; +import { FE_Upstream } from "../../models/admin-responses"; +import { FE_WebhooksBridge } from "../../models/webhooks"; + +@Injectable() +export class AdminWebhooksApiService extends AuthedApi { + constructor(http: Http) { + super(http); + } + + public getBridges(): Promise { + return this.authedGet("/api/v1/dimension/admin/webhooks/all").map(r => r.json()).toPromise(); + } + + public getBridge(bridgeId: number): Promise { + return this.authedGet("/api/v1/dimension/admin/webhooks/" + bridgeId).map(r => r.json()).toPromise(); + } + + public newFromUpstream(upstream: FE_Upstream): Promise { + return this.authedPost("/api/v1/dimension/admin/webhooks/new/upstream", {upstreamId: upstream.id}).map(r => r.json()).toPromise(); + } + + public newSelfhosted(provisionUrl: string, sharedSecret: string): Promise { + return this.authedPost("/api/v1/dimension/admin/webhooks/new/selfhosted", { + provisionUrl: provisionUrl, + sharedSecret: sharedSecret, + }).map(r => r.json()).toPromise(); + } + + public updateSelfhosted(bridgeId: number, provisionUrl: string, sharedSecret: string): Promise { + return this.authedPost("/api/v1/dimension/admin/webhooks/" + bridgeId, { + provisionUrl: provisionUrl, + sharedSecret: sharedSecret, + }).map(r => r.json()).toPromise(); + } +}