Majority of a matrix-hookshot#generic_webhook implementation

This commit is contained in:
Travis Ralston 2021-12-02 19:07:22 -07:00
parent ca7f1fbbe6
commit 2a41474094
27 changed files with 811 additions and 4 deletions

View File

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

View File

@ -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<BridgeResponse[]> {
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<BridgeResponse> {
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<BridgeResponse> {
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<BridgeResponse> {
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<BridgeResponse> {
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);
}
}

View File

@ -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<HookshotWebhookRoomConfig> {
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<any> {
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");
}
}
}

View File

@ -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<HookshotWebhookBridgeRecord> {
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<string> {
const confs = await this.getAllServiceInformation();
const conf = confs.find(c => c.eventType === HookshotTypes.Webhook);
return conf?.botUserId;
}
public async isBridgingEnabled(): Promise<boolean> {
const bridges = await HookshotWebhookBridgeRecord.findAll({where: {isEnabled: true}});
return !!bridges && bridges.length > 0 && !!(await this.getBotUserId());
}
public async getRoomConfigurations(inRoomId: string): Promise<HookshotWebhookRoomConfig[]> {
return (await this.getAllRoomConfigurations(inRoomId)).filter(c => c.eventType === HookshotTypes.Webhook);
}
public async newConnection(roomId: string): Promise<HookshotWebhookRoomConfig> {
const bridge = await this.getDefaultBridge();
const body = {
};
return await this.doProvisionRequest<HookshotWebhookRoomConfig>(bridge, "PUT", `/v1/${roomId}/connections/${HookshotTypes.Webhook}`, null, body);
}
public async removeConnection(roomId: string, connectionId: string): Promise<void> {
const bridge = await this.getDefaultBridge();
await this.doProvisionRequest(bridge, "DELETE", `/v1/${roomId}/connections/${connectionId}`);
}
}

View File

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

View File

@ -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 <HookshotWebhookBridgeConfiguration>{
botUserId: botUserId,
connections: connections,
};
} else return {};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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[];
}

View File

@ -0,0 +1,41 @@
<div *ngIf="isLoading">
<my-spinner></my-spinner>
</div>
<div *ngIf="!isLoading">
<my-ibox boxTitle="Webhook Bridge Configurations">
<div class="my-ibox-content">
<p>
<a href="https://github.com/half-shot/matrix-hookshot" target="_blank">{{'matrix-hookshot' | translate}}</a>
{{'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}}
<table class="table table-striped table-condensed table-bordered">
<thead>
<tr>
<th>{{'Name' | translate}}</th>
<th class="text-center" style="width: 120px;">{{'Actions' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngIf="!configurations || configurations.length === 0">
<td colspan="3"><i>{{'No bridge configurations.' | translate}}</i></td>
</tr>
<tr *ngFor="let bridge of configurations trackById">
<td>
{{ bridge.upstreamId ? "matrix.org's bridge" : "Self-hosted bridge" }}
<span class="text-muted" style="display: inline-block;"
*ngIf="!bridge.upstreamId">({{ bridge.provisionUrl }})</span>
</td>
<td class="text-center">
<span class="editButton" (click)="editBridge(bridge)">
<i class="fa fa-pencil-alt"></i>
</span>
</td>
</tr>
</tbody>
</table>
<button type="button" class="btn btn-success btn-sm" (click)="addSelfHostedBridge()"
[disabled]="configurations && configurations.length > 0">
<i class="fa fa-plus"></i> {{'Add self-hosted bridge' | translate}}
</button>
</div>
</my-ibox>
</div>

View File

@ -0,0 +1,3 @@
.editButton {
cursor: pointer;
}

View File

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

View File

@ -0,0 +1,31 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{'self-hosted Github bridge' | translate}} ({{ isAdding ? "Add a new" : "Edit" }})</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" (click)="modal.close()"></button>
</div>
<div class="modal-body">
<p>{{'Self-hosted Webhook bridges must have' | translate}} <code>{{'provisioning' | translate}}</code> {{'enabled in the configuration.' | translate}}</p>
<label class="label-block">
{{'Provisioning URL' | translate}}
<span class="text-muted ">{{'The provisioning URL for the bridge. This is the specific address for the bridge given in the configuration.' | translate}}</span>
<input type="text" class="form-control"
placeholder="http://localhost:9000"
[(ngModel)]="provisionUrl" [disabled]="isSaving"/>
</label>
<label class="label-block">
{{'Shared Secret' | translate}}
<span class="text-muted ">{{'The shared secret defined in the configuration for provisioning.' | translate}}</span>
<input type="text" class="form-control"
placeholder="some_secret_value"
[(ngModel)]="sharedSecret" [disabled]="isSaving"/>
</label>
</div>
<div class="modal-footer">
<button type="button" (click)="add()" title="save" class="btn btn-primary btn-sm">
<i class="far fa-save"></i> {{'Save' | translate}}
</button>
<button type="button" (click)="modal.close()" title="close" class="btn btn-secondary btn-sm">
<i class="far fa-times-circle"></i> {{'Cancel' | translate}}
</button>
</div>

View File

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

View File

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

View File

@ -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"},
},
],
},
{

View File

@ -0,0 +1,54 @@
<my-bridge-config [bridgeComponent]="this">
<ng-template #bridgeParamsTemplate>
<my-ibox [isCollapsible]="true">
<h5 class="my-ibox-title">
{{'Add a new webhook' | translate}}
</h5>
<div class="my-ibox-content">
<label class="label-block">
{{'Webhook Name' | translate}}
<input title="webhook name" type="text" class="form-control form-control-sm"
[(ngModel)]="webhookName" [disabled]="isBusy">
</label>
<div style="margin-top: 25px">
<button type="button" class="btn btn-sm btn-primary" [disabled]="isBusy" (click)="newHook()">
{{'Create' | translate}}
</button>
</div>
</div>
</my-ibox>
<my-ibox [isCollapsible]="true">
<h5 class="my-ibox-title">
Webhooks
</h5>
<div class="my-ibox-content">
<table class="table table-striped table-condensed table-bordered">
<thead>
<tr>
<th>Name</th>
<th>URL</th>
<th class="actions-col">{{'Actions' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngIf="newConfig.connections.length === 0">
<td colspan="4">{{'No webhooks' | translate}}</td>
</tr>
<tr *ngFor="let hook of newConfig.connections">
<td *ngIf="hook.config.name">{{ hook.config.name }}</td>
<td *ngIf="!hook.config.name"><i>{{'No name' | translate}}</i></td>
<td class="webhook-url"><a [href]="hook.config.url" target="_blank">{{ hook.config.url }}</a></td>
<td class="actions-col">
<button type="button" class="btn btn-sm btn-outline-danger"
[disabled]="isBusy"
(click)="removeHook(hook)">
<i class="far fa-trash-alt"></i> {{'Delete' | translate}}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</my-ibox>
</ng-template>
</my-bridge-config>

View File

@ -0,0 +1,3 @@
.webhook-url {
word-break: break-word;
}

View File

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

View File

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

View File

@ -33,6 +33,7 @@ export class IntegrationsRegistry {
"slack": {},
"hookshot_github": {},
"hookshot_jira": {},
"hookshot_webhook": {},
},
"widget": {
"custom": {

View File

@ -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<FE_HookshotWebhookBridge[]> {
return this.authedGet<FE_HookshotWebhookBridge[]>("/api/v1/dimension/admin/hookshot/webhook/all").toPromise();
}
public getBridge(bridgeId: number): Promise<FE_HookshotWebhookBridge> {
return this.authedGet<FE_HookshotWebhookBridge>("/api/v1/dimension/admin/hookshot/webhook/" + bridgeId).toPromise();
}
public newFromUpstream(upstream: FE_Upstream): Promise<FE_HookshotWebhookBridge> {
return this.authedPost<FE_HookshotWebhookBridge>("/api/v1/dimension/admin/hookshot/webhook/new/upstream", {upstreamId: upstream.id}).toPromise();
}
public newSelfhosted(provisionUrl: string, sharedSecret: string): Promise<FE_HookshotWebhookBridge> {
return this.authedPost<FE_HookshotWebhookBridge>("/api/v1/dimension/admin/hookshot/webhook/new/selfhosted", {
provisionUrl: provisionUrl,
sharedSecret: sharedSecret,
}).toPromise();
}
public updateSelfhosted(bridgeId: number, provisionUrl: string, sharedSecret: string): Promise<FE_HookshotWebhookBridge> {
return this.authedPost<FE_HookshotWebhookBridge>("/api/v1/dimension/admin/hookshot/webhook/" + bridgeId, {
provisionUrl: provisionUrl,
sharedSecret: sharedSecret,
}).toPromise();
}
}

View File

@ -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<FE_HookshotWebhookConnection> {
return this.authedPost<FE_HookshotWebhookConnection>("/api/v1/dimension/hookshot/webhook/room/" + roomId + "/connect", {
name,
}).toPromise();
}
public deleteWebhook(roomId: string, webhookId: string): Promise<any> {
return this.authedDelete("/api/v1/dimension/hookshot/webhook/room/" + roomId + "/connection/" + encodeURIComponent(webhookId) + "/disconnect").toPromise();
}
}