diff --git a/docs/reference/scalar_server_api.md b/docs/reference/scalar_server_api.md index afaa852..abc0d87 100644 --- a/docs/reference/scalar_server_api.md +++ b/docs/reference/scalar_server_api.md @@ -322,6 +322,95 @@ None of these are officially documented, and are subject to change. } ``` +## POST `/api/bridges/gitter/_matrix/provision/getlink/?scalar_token=...` + +**Body** +``` +{ + "matrix_room_id": "!JmvocvDuPTYUfuvKgs:t2l.io" +} +``` + +**Response** +``` +{ + "replies": [{ + "rid": "...", + "response":{ + "matrix_room_id": "!JmvocvDuPTYUfuvKgs:t2l.io", + "remote_room_name": "t2bot-io/Testing" + } + }] +} +``` + +*Note*: This API is polled to check for updates, such as bridging success. This will also 404 if there is no link. + +## POST `/api/bridges/gitter/_matrix/provision/unlink?scalar_token=...` + +**Body** +``` +{ + "matrix_room_id": "!JmvocvDuPTYUfuvKgs:t2l.io", + "remote_room_name": "t2bot-io/Testing" +} +``` + +**Response** +``` +{ + "replies": [ + { + "rid": "..", + "response": {} + } + ] +} +``` + +## POST `/api/bridges/gitter/_matrix/provision/getbotid?scalar_token=...` + +**Body** +``` +{} +``` + +**Response** +``` +{ + "replies": [ + { + "rid": "..", + "response": { + "bot_user_id": "@gitterbot:matrix.org" + } + } + ] +} +``` + +## POST `/api/bridges/gitter/_matrix/provision/link?scalar_token=...` + +**Body** +``` +{ + "matrix_room_id": "!JmvocvDuPTYUfuvKgs:t2l.io", + "remote_room_name": "t2bot-io/Testing" +} +``` + +**Response** +``` +{ + "replies": [ + { + "rid": "..", + "response": {} + } + ] +} +``` + ## POST `/api/integrations/github-webhook?scalar_token=...` **Body** diff --git a/src/MemoryCache.ts b/src/MemoryCache.ts index ff4c724..c192200 100644 --- a/src/MemoryCache.ts +++ b/src/MemoryCache.ts @@ -50,4 +50,5 @@ export const CACHE_FEDERATION = "federation"; export const CACHE_IRC_BRIDGE = "irc-bridge"; export const CACHE_STICKERS = "stickers"; export const CACHE_TELEGRAM_BRIDGE = "telegram-bridge"; -export const CACHE_WEBHOOKS_BRIDGE = "webhooks-bridge"; \ No newline at end of file +export const CACHE_WEBHOOKS_BRIDGE = "webhooks-bridge"; +export const CACHE_GITTER_BRIDGE = "gitter-bridge"; \ No newline at end of file diff --git a/src/api/admin/AdminGitterService.ts b/src/api/admin/AdminGitterService.ts new file mode 100644 index 0000000..e6e0d6b --- /dev/null +++ b/src/api/admin/AdminGitterService.ts @@ -0,0 +1,114 @@ +import { GET, Path, PathParam, POST, QueryParam } from "typescript-rest"; +import { AdminService } from "./AdminService"; +import { Cache, CACHE_GITTER_BRIDGE, CACHE_INTEGRATIONS } from "../../MemoryCache"; +import { LogService } from "matrix-js-snippets"; +import { ApiError } from "../ApiError"; +import GitterBridgeRecord from "../../db/models/GitterBridgeRecord"; +import Upstream from "../../db/models/Upstream"; + +interface CreateWithUpstream { + upstreamId: number; +} + +interface CreateSelfhosted { + provisionUrl: string; +} + +interface BridgeResponse { + id: number; + upstreamId?: number; + provisionUrl?: string; + isEnabled: boolean; +} + +/** + * Administrative API for configuring Gitter bridge instances. + */ +@Path("/api/v1/dimension/admin/gitter") +export class AdminGitterService { + + @GET + @Path("all") + public async getBridges(@QueryParam("scalar_token") scalarToken: string): Promise { + await AdminService.validateAndGetAdminTokenOwner(scalarToken); + + const bridges = await GitterBridgeRecord.findAll(); + return Promise.all(bridges.map(async b => { + return { + id: b.id, + upstreamId: b.upstreamId, + provisionUrl: b.provisionUrl, + isEnabled: b.isEnabled, + }; + })); + } + + @GET + @Path(":bridgeId") + public async getBridge(@QueryParam("scalar_token") scalarToken: string, @PathParam("bridgeId") bridgeId: number): Promise { + await AdminService.validateAndGetAdminTokenOwner(scalarToken); + + const telegramBridge = await GitterBridgeRecord.findByPrimary(bridgeId); + if (!telegramBridge) throw new ApiError(404, "Gitter Bridge not found"); + + return { + id: telegramBridge.id, + upstreamId: telegramBridge.upstreamId, + provisionUrl: telegramBridge.provisionUrl, + isEnabled: telegramBridge.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 GitterBridgeRecord.findByPrimary(bridgeId); + if (!bridge) throw new ApiError(404, "Bridge not found"); + + bridge.provisionUrl = request.provisionUrl; + await bridge.save(); + + LogService.info("AdminGitterService", userId + " updated Gitter Bridge " + bridge.id); + + Cache.for(CACHE_GITTER_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 { + const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken); + + const upstream = await Upstream.findByPrimary(request.upstreamId); + if (!upstream) throw new ApiError(400, "Upstream not found"); + + const bridge = await GitterBridgeRecord.create({ + upstreamId: request.upstreamId, + isEnabled: true, + }); + LogService.info("AdminGitterService", userId + " created a new Gitter Bridge from upstream " + request.upstreamId); + + Cache.for(CACHE_GITTER_BRIDGE).clear(); + Cache.for(CACHE_INTEGRATIONS).clear(); + return this.getBridge(scalarToken, bridge.id); + } + + @POST + @Path("new/selfhosted") + public async newSelfhosted(@QueryParam("scalar_token") scalarToken: string, request: CreateSelfhosted): Promise { + const userId = await AdminService.validateAndGetAdminTokenOwner(scalarToken); + + const bridge = await GitterBridgeRecord.create({ + provisionUrl: request.provisionUrl, + isEnabled: true, + }); + LogService.info("AdminGitterService", userId + " created a new Gitter Bridge with provisioning URL " + request.provisionUrl); + + Cache.for(CACHE_GITTER_BRIDGE).clear(); + Cache.for(CACHE_INTEGRATIONS).clear(); + return this.getBridge(scalarToken, bridge.id); + } +} \ No newline at end of file diff --git a/src/api/dimension/DimensionGitterService.ts b/src/api/dimension/DimensionGitterService.ts new file mode 100644 index 0000000..68fd3f7 --- /dev/null +++ b/src/api/dimension/DimensionGitterService.ts @@ -0,0 +1,61 @@ +import { DELETE, GET, Path, PathParam, POST, QueryParam } from "typescript-rest"; +import { ScalarService } from "../scalar/ScalarService"; +import { ApiError } from "../ApiError"; +import { BridgedRoom, GitterBridge } from "../../bridges/GitterBridge"; +import { LogService } from "matrix-js-snippets"; + +interface BridgeRoomRequest { + gitterRoomName: string; +} + +/** + * API for interacting with the Gitter bridge + */ +@Path("/api/v1/dimension/gitter") +export class DimensionGitterService { + + @GET + @Path("room/:roomId/link") + public async getLink(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string): Promise { + const userId = await ScalarService.getTokenOwner(scalarToken); + + try { + const gitter = new GitterBridge(userId); + return gitter.getLink(roomId); + } catch (e) { + LogService.error("DimensionGitterService", e); + throw new ApiError(400, "Error getting bridge info"); + } + } + + @POST + @Path("room/:roomId/link") + public async bridgeRoom(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string, request: BridgeRoomRequest): Promise { + const userId = await ScalarService.getTokenOwner(scalarToken); + + try { + const gitter = new GitterBridge(userId); + await gitter.requestLink(roomId, request.gitterRoomName); + return gitter.getLink(roomId); + } catch (e) { + LogService.error("DimensionGitterService", e); + throw new ApiError(400, "Error bridging room"); + } + } + + @DELETE + @Path("room/:roomId/link") + public async unbridgeRoom(@QueryParam("scalar_token") scalarToken: string, @PathParam("roomId") roomId: string): Promise { + const userId = await ScalarService.getTokenOwner(scalarToken); + + try { + const gitter = new GitterBridge(userId); + const link = await gitter.getLink(roomId); + await gitter.removeLink(roomId, link.gitterRoomName); + return {}; // 200 OK + } catch (e) { + LogService.error("DimensionGitterService", e); + throw new ApiError(400, "Error unbridging room"); + } + } +} \ No newline at end of file diff --git a/src/bridges/GitterBridge.ts b/src/bridges/GitterBridge.ts new file mode 100644 index 0000000..398c901 --- /dev/null +++ b/src/bridges/GitterBridge.ts @@ -0,0 +1,195 @@ +import IrcBridgeRecord from "../db/models/IrcBridgeRecord"; +import Upstream from "../db/models/Upstream"; +import UserScalarToken from "../db/models/UserScalarToken"; +import { LogService } from "matrix-js-snippets"; +import * as request from "request"; +import { ModularGitterResponse } from "../models/ModularResponses"; +import GitterBridgeRecord from "../db/models/GitterBridgeRecord"; +import { BridgedRoomResponse, GetBotUserIdResponse } from "./models/gitter"; + +export interface GitterBridgeInfo { + botUserId: string; +} + +export interface BridgedRoom { + roomId: string; + gitterRoomName: string; +} + +export class GitterBridge { + + constructor(private requestingUserId: string) { + } + + private async getDefaultBridge(): Promise { + const bridges = await GitterBridgeRecord.findAll({where: {isEnabled: true}}); + if (!bridges || bridges.length !== 1) { + throw new Error("No bridges or too many bridges found"); + } + return bridges[0]; + } + + public async isBridgingEnabled(): Promise { + const bridges = await GitterBridgeRecord.findAll({where: {isEnabled: true}}); + return !!bridges; + } + + public async getBridgeInfo(): Promise { + const bridge = await this.getDefaultBridge(); + + if (bridge.upstreamId) { + const info = await this.doUpstreamRequest>(bridge, "POST", "/bridges/gitter/_matrix/provision/getbotid/", null, {}); + if (!info || !info.replies || !info.replies[0] || !info.replies[0].response) { + throw new Error("Invalid response from Modular for Gitter bot user ID"); + } + return {botUserId: info.replies[0].response.bot_user_id}; + } else { + const info = await this.doProvisionRequest(bridge, "POST", "/_matrix/provision/getbotid"); + return {botUserId: info.bot_user_id}; + } + } + + public async getLink(roomId: string): Promise { + const bridge = await this.getDefaultBridge(); + + const requestBody = { + matrix_room_id: roomId, + user_id: this.requestingUserId, + }; + try { + if (bridge.upstreamId) { + delete requestBody["user_id"]; + const link = await this.doUpstreamRequest>(bridge, "POST", "/bridges/gitter/_matrix/provision/getlink", null, requestBody); + if (!link || !link.replies || !link.replies[0] || !link.replies[0].response) { + // noinspection ExceptionCaughtLocallyJS + throw new Error("Invalid response from Modular for Gitter list links in " + roomId); + } + return { + roomId: link.replies[0].response.matrix_room_id, + gitterRoomName: link.replies[0].response.remote_room_name, + }; + } else { + const link = await this.doProvisionRequest(bridge, "POST", "/_matrix/provision/getlink", null, requestBody); + return { + roomId: link.matrix_room_id, + gitterRoomName: link.remote_room_name, + }; + } + } catch (e) { + if (e.status === 404) return null; + LogService.error("GitterBridge", e); + throw e; + } + } + + public async requestLink(roomId: string, remoteName: string): Promise { + const bridge = await this.getDefaultBridge(); + + const requestBody = { + matrix_room_id: roomId, + remote_room_name: remoteName, + user_id: this.requestingUserId, + }; + + if (bridge.upstreamId) { + delete requestBody["user_id"]; + await this.doUpstreamRequest(bridge, "POST", "/bridges/gitter/_matrix/provision/link", null, requestBody); + } else { + await this.doProvisionRequest(bridge, "POST", "/_matrix/provision/link", null, requestBody); + } + } + + public async removeLink(roomId: string, remoteName: string): Promise { + const bridge = await this.getDefaultBridge(); + + const requestBody = { + matrix_room_id: roomId, + remote_room_name: remoteName, + user_id: this.requestingUserId, + }; + + if (bridge.upstreamId) { + delete requestBody["user_id"]; + await this.doUpstreamRequest(bridge, "POST", "/bridges/gitter/_matrix/provision/unlink", null, requestBody); + } else { + await this.doProvisionRequest(bridge, "POST", "/_matrix/provision/unlink", null, requestBody); + } + } + + private async doUpstreamRequest(bridge: IrcBridgeRecord, method: string, endpoint: string, qs?: any, body?: any): Promise { + const upstream = await Upstream.findByPrimary(bridge.upstreamId); + const token = await UserScalarToken.findOne({ + where: { + upstreamId: upstream.id, + isDimensionToken: false, + userId: this.requestingUserId, + }, + }); + + if (!qs) qs = {}; + qs["scalar_token"] = token.scalarToken; + + const apiUrl = upstream.apiUrl.endsWith("/") ? upstream.apiUrl.substring(0, upstream.apiUrl.length - 1) : upstream.apiUrl; + const url = apiUrl + (endpoint.startsWith("/") ? endpoint : "/" + endpoint); + LogService.info("GitterBridge", "Doing upstream Gitter Bridge request: " + url); + + return new Promise((resolve, reject) => { + request({ + method: method, + url: url, + qs: qs, + json: body, + }, (err, res, _body) => { + if (err) { + LogService.error("GitterBridge", "Error calling " + url); + LogService.error("GitterBridge", err); + reject(err); + } else if (!res) { + LogService.error("GitterBridge", "There is no response for " + url); + reject(new Error("No response provided - is the service online?")); + } else if (res.statusCode !== 200) { + if (typeof(res.body) === "string") res.body = JSON.parse(res.body); + LogService.error("GitterBridge", "Got status code " + res.statusCode + " when calling " + url); + LogService.error("GitterBridge", res.body); + reject({body: res.body, status: res.statusCode}); + } else { + if (typeof(res.body) === "string") res.body = JSON.parse(res.body); + resolve(res.body); + } + }); + }); + } + + private async doProvisionRequest(bridge: IrcBridgeRecord, method: string, endpoint: string, qs?: any, body?: any): Promise { + const provisionUrl = bridge.provisionUrl; + const apiUrl = provisionUrl.endsWith("/") ? provisionUrl.substring(0, provisionUrl.length - 1) : provisionUrl; + const url = apiUrl + (endpoint.startsWith("/") ? endpoint : "/" + endpoint); + LogService.info("GitterBridge", "Doing provision Gitter Bridge request: " + url); + + return new Promise((resolve, reject) => { + request({ + method: method, + url: url, + qs: qs, + json: body, + }, (err, res, _body) => { + if (err) { + LogService.error("GitterBridge", "Error calling" + url); + LogService.error("GitterBridge", err); + reject(err); + } else if (!res) { + LogService.error("GitterBridge", "There is no response for " + url); + reject(new Error("No response provided - is the service online?")); + } else if (res.statusCode !== 200) { + if (typeof(res.body) === "string") res.body = JSON.parse(res.body); + LogService.error("GitterBridge", "Got status code " + res.statusCode + " when calling " + url); + LogService.error("GitterBridge", res.body); + reject({body: res.body, status: res.statusCode}); + } else { + if (typeof(res.body) === "string") res.body = JSON.parse(res.body); + resolve(res.body); + } + }); + }); + } +} \ No newline at end of file diff --git a/src/bridges/models/gitter.ts b/src/bridges/models/gitter.ts new file mode 100644 index 0000000..d4bc3f9 --- /dev/null +++ b/src/bridges/models/gitter.ts @@ -0,0 +1,8 @@ +export interface GetBotUserIdResponse { + bot_user_id: string; +} + +export interface BridgedRoomResponse { + matrix_room_id: string; + remote_room_name: string; +} \ No newline at end of file diff --git a/src/db/BridgeStore.ts b/src/db/BridgeStore.ts index d6a6e3c..229c4aa 100644 --- a/src/db/BridgeStore.ts +++ b/src/db/BridgeStore.ts @@ -1,9 +1,15 @@ -import { Bridge, TelegramBridgeConfiguration, WebhookBridgeConfiguration } from "../integrations/Bridge"; +import { + Bridge, + GitterBridgeConfiguration, + TelegramBridgeConfiguration, + WebhookBridgeConfiguration +} from "../integrations/Bridge"; import BridgeRecord from "./models/BridgeRecord"; import { IrcBridge } from "../bridges/IrcBridge"; import { LogService } from "matrix-js-snippets"; import { TelegramBridge } from "../bridges/TelegramBridge"; import { WebhooksBridge } from "../bridges/WebhooksBridge"; +import { GitterBridge } from "../bridges/GitterBridge"; export class BridgeStore { @@ -50,6 +56,8 @@ export class BridgeStore { throw new Error("Telegram bridges should be modified with the dedicated API"); } else if (integrationType === "webhooks") { throw new Error("Webhooks should be modified with the dedicated API"); + } else if (integrationType === "gitter") { + throw new Error("Gitter Bridges should be modified with the dedicated API"); } else throw new Error("Unsupported bridge"); } @@ -63,6 +71,9 @@ export class BridgeStore { } else if (record.type === "webhooks") { const webhooks = new WebhooksBridge(requestingUserId); return webhooks.isBridgingEnabled(); + } else if (record.type === "gitter") { + const gitter = new GitterBridge(requestingUserId); + return gitter.isBridgingEnabled(); } else return true; } @@ -91,6 +102,15 @@ export class BridgeStore { webhooks: hooks, botUserId: info.botUserId, }; + } else if (record.type === "gitter") { + if (!inRoomId) return {}; // The bridge's admin config is handled by other APIs + const gitter = new GitterBridge(requestingUserId); + const info = await gitter.getBridgeInfo(); + const link = await gitter.getLink(inRoomId); + return { + link: link, + botUserId: info.botUserId, + }; } else return {}; } diff --git a/src/db/DimensionStore.ts b/src/db/DimensionStore.ts index 19ced9e..0f74918 100644 --- a/src/db/DimensionStore.ts +++ b/src/db/DimensionStore.ts @@ -23,6 +23,7 @@ import Sticker from "./models/Sticker"; import UserStickerPack from "./models/UserStickerPack"; import TelegramBridgeRecord from "./models/TelegramBridgeRecord"; import WebhookBridgeRecord from "./models/WebhookBridgeRecord"; +import GitterBridgeRecord from "./models/GitterBridgeRecord"; class _DimensionStore { private sequelize: Sequelize; @@ -57,6 +58,7 @@ class _DimensionStore { UserStickerPack, TelegramBridgeRecord, WebhookBridgeRecord, + GitterBridgeRecord, ]); } diff --git a/src/db/migrations/20181021012745-AddGitterBridge.ts b/src/db/migrations/20181021012745-AddGitterBridge.ts new file mode 100644 index 0000000..bf88cb6 --- /dev/null +++ b/src/db/migrations/20181021012745-AddGitterBridge.ts @@ -0,0 +1,22 @@ +import { QueryInterface } from "sequelize"; +import { DataType } from "sequelize-typescript"; + +export default { + up: (queryInterface: QueryInterface) => { + return Promise.resolve() + .then(() => queryInterface.createTable("dimension_gitter_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}, + "isEnabled": {type: DataType.BOOLEAN, allowNull: false}, + })); + }, + down: (queryInterface: QueryInterface) => { + return Promise.resolve() + .then(() => queryInterface.dropTable("dimension_gitter_bridges")); + } +} \ No newline at end of file diff --git a/src/db/migrations/20181021120545-AddGitterBridgeRecord.ts b/src/db/migrations/20181021120545-AddGitterBridgeRecord.ts new file mode 100644 index 0000000..d7e21c1 --- /dev/null +++ b/src/db/migrations/20181021120545-AddGitterBridgeRecord.ts @@ -0,0 +1,23 @@ +import { QueryInterface } from "sequelize"; + +export default { + up: (queryInterface: QueryInterface) => { + return Promise.resolve() + .then(() => queryInterface.bulkInsert("dimension_bridges", [ + { + type: "gitter", + name: "Gitter Bridge", + avatarUrl: "/img/avatars/gitter.png", + isEnabled: true, + isPublic: true, + description: "Bridges Gitter rooms to Matrix", + }, + ])); + }, + down: (queryInterface: QueryInterface) => { + return Promise.resolve() + .then(() => queryInterface.bulkDelete("dimension_bridges", { + type: "gitter", + })); + } +} \ No newline at end of file diff --git a/src/db/models/GitterBridgeRecord.ts b/src/db/models/GitterBridgeRecord.ts new file mode 100644 index 0000000..7afa7f0 --- /dev/null +++ b/src/db/models/GitterBridgeRecord.ts @@ -0,0 +1,26 @@ +import { AllowNull, AutoIncrement, Column, ForeignKey, Model, PrimaryKey, Table } from "sequelize-typescript"; +import Upstream from "./Upstream"; + +@Table({ + tableName: "dimension_gitter_bridges", + underscoredAll: false, + timestamps: false, +}) +export default class GitterBridgeRecord extends Model { + @PrimaryKey + @AutoIncrement + @Column + id: number; + + @AllowNull + @Column + @ForeignKey(() => Upstream) + upstreamId?: number; + + @AllowNull + @Column + provisionUrl?: string; + + @Column + isEnabled: boolean; +} \ No newline at end of file diff --git a/src/integrations/Bridge.ts b/src/integrations/Bridge.ts index 4767c14..9233249 100644 --- a/src/integrations/Bridge.ts +++ b/src/integrations/Bridge.ts @@ -3,13 +3,16 @@ import BridgeRecord from "../db/models/BridgeRecord"; import { AvailableNetworks, LinkedChannels } from "../bridges/IrcBridge"; import { PortalInfo, PuppetInfo } from "../bridges/TelegramBridge"; import { WebhookConfiguration } from "../bridges/models/webhooks"; +import { BridgedRoom } from "../bridges/GitterBridge"; + +const PRIVATE_ACCESS_SUPPORTED_BRIDGES = ["webhooks", "gitter"]; export class Bridge extends Integration { constructor(bridge: BridgeRecord, public config: any) { super(bridge); this.category = "bridge"; - if (bridge.type === "webhooks") this.requirements = []; + if (PRIVATE_ACCESS_SUPPORTED_BRIDGES.indexOf(bridge.type) !== -1) this.requirements = []; else this.requirements = [{ condition: "publicRoom", expectedValue: true, @@ -36,4 +39,9 @@ export interface TelegramBridgeConfiguration { export interface WebhookBridgeConfiguration { webhooks: WebhookConfiguration[]; botUserId: string; +} + +export interface GitterBridgeConfiguration { + link: BridgedRoom, + botUserId: string; } \ No newline at end of file diff --git a/src/models/ModularResponses.ts b/src/models/ModularResponses.ts index f9bac1b..c4b31e7 100644 --- a/src/models/ModularResponses.ts +++ b/src/models/ModularResponses.ts @@ -8,4 +8,11 @@ export interface ModularIrcResponse { rid: string; response: T; }[]; +} + +export interface ModularGitterResponse { + replies: { + rid: string; + response: T; + }[]; } \ No newline at end of file diff --git a/web/app/admin/bridges/gitter/gitter.component.html b/web/app/admin/bridges/gitter/gitter.component.html new file mode 100644 index 0000000..501efdb --- /dev/null +++ b/web/app/admin/bridges/gitter/gitter.component.html @@ -0,0 +1,45 @@ +
+ +
+
+ +
+

+ matrix-appservice-gitter + is a Gitter bridge that supports bridging Gitter rooms to Matrix. Users on Matrix are represented as a single + bot user in Gitter, however Gitter users are represented as real-looking Matrix users in the 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/gitter/gitter.component.scss b/web/app/admin/bridges/gitter/gitter.component.scss new file mode 100644 index 0000000..788d7ed --- /dev/null +++ b/web/app/admin/bridges/gitter/gitter.component.scss @@ -0,0 +1,3 @@ +.editButton { + cursor: pointer; +} \ No newline at end of file diff --git a/web/app/admin/bridges/gitter/gitter.component.ts b/web/app/admin/bridges/gitter/gitter.component.ts new file mode 100644 index 0000000..be908e8 --- /dev/null +++ b/web/app/admin/bridges/gitter/gitter.component.ts @@ -0,0 +1,103 @@ +import { Component, OnInit } from "@angular/core"; +import { ToasterService } from "angular2-toaster"; +import { Modal, overlayConfigFactory } from "ngx-modialog"; +import { FE_TelegramBridge } from "../../../shared/models/telegram"; +import { + AdminGitterBridgeManageSelfhostedComponent, + ManageSelfhostedGitterBridgeDialogContext +} from "./manage-selfhosted/manage-selfhosted.component"; +import { AdminGitterApiService } from "../../../shared/services/admin/admin-gitter-api.service"; +import { FE_GitterBridge } from "../../../shared/models/gitter"; +import { FE_Upstream } from "../../../shared/models/admin-responses"; +import { AdminUpstreamApiService } from "../../../shared/services/admin/admin-upstream-api.service"; + +@Component({ + templateUrl: "./gitter.component.html", + styleUrls: ["./gitter.component.scss"], +}) +export class AdminGitterBridgeComponent implements OnInit { + + public isLoading = true; + public isUpdating = false; + public configurations: FE_GitterBridge[] = []; + + private upstreams: FE_Upstream[]; + + constructor(private gitterApi: AdminGitterApiService, + private upstreamApi: AdminUpstreamApiService, + private toaster: ToasterService, + private modal: Modal) { + } + + public ngOnInit() { + this.reload().then(() => this.isLoading = false); + } + + private async reload(): Promise { + try { + this.upstreams = await this.upstreamApi.getUpstreams(); + this.configurations = await this.gitterApi.getBridges(); + } catch (err) { + console.error(err); + this.toaster.pop("error", "Error loading bridges"); + } + } + + public addModularHostedBridge() { + this.isUpdating = true; + + const createBridge = (upstream: FE_Upstream) => { + return this.gitterApi.newFromUpstream(upstream).then(bridge => { + this.configurations.push(bridge); + this.toaster.pop("success", "matrix.org's Gitter bridge added"); + this.isUpdating = false; + }).catch(err => { + console.error(err); + this.isUpdating = false; + this.toaster.pop("error", "Error adding matrix.org's Gitter Bridge"); + }); + }; + + const vectorUpstreams = this.upstreams.filter(u => u.type === "vector"); + if (vectorUpstreams.length === 0) { + console.log("Creating default scalar upstream"); + const scalarUrl = "https://scalar.vector.im/api"; + this.upstreamApi.newUpstream("modular", "vector", scalarUrl, scalarUrl).then(upstream => { + this.upstreams.push(upstream); + createBridge(upstream); + }).catch(err => { + console.error(err); + this.toaster.pop("error", "Error creating matrix.org's Gitter Bridge"); + }); + } else createBridge(vectorUpstreams[0]); + } + + public addSelfHostedBridge() { + this.modal.open(AdminGitterBridgeManageSelfhostedComponent, overlayConfigFactory({ + isBlocking: true, + size: 'lg', + + provisionUrl: '', + }, ManageSelfhostedGitterBridgeDialogContext)).result.then(() => { + this.reload().catch(err => { + console.error(err); + this.toaster.pop("error", "Failed to get an update Gitter bridge list"); + }); + }); + } + + public editBridge(bridge: FE_TelegramBridge) { + this.modal.open(AdminGitterBridgeManageSelfhostedComponent, overlayConfigFactory({ + isBlocking: true, + size: 'lg', + + provisionUrl: bridge.provisionUrl, + bridgeId: bridge.id, + }, ManageSelfhostedGitterBridgeDialogContext)).result.then(() => { + this.reload().catch(err => { + console.error(err); + this.toaster.pop("error", "Failed to get an update Gitter bridge list"); + }); + }); + } +} diff --git a/web/app/admin/bridges/gitter/manage-selfhosted/manage-selfhosted.component.html b/web/app/admin/bridges/gitter/manage-selfhosted/manage-selfhosted.component.html new file mode 100644 index 0000000..75ec4b6 --- /dev/null +++ b/web/app/admin/bridges/gitter/manage-selfhosted/manage-selfhosted.component.html @@ -0,0 +1,24 @@ +
+
+

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

+
+
+

Self-hosted Gitter bridges already have provisioning enabled. Be careful not to expose the API to the public internet.

+ + +
+ +
\ No newline at end of file diff --git a/web/app/admin/bridges/gitter/manage-selfhosted/manage-selfhosted.component.scss b/web/app/admin/bridges/gitter/manage-selfhosted/manage-selfhosted.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/web/app/admin/bridges/gitter/manage-selfhosted/manage-selfhosted.component.ts b/web/app/admin/bridges/gitter/manage-selfhosted/manage-selfhosted.component.ts new file mode 100644 index 0000000..17b1df3 --- /dev/null +++ b/web/app/admin/bridges/gitter/manage-selfhosted/manage-selfhosted.component.ts @@ -0,0 +1,56 @@ +import { Component } from "@angular/core"; +import { ToasterService } from "angular2-toaster"; +import { DialogRef, ModalComponent } from "ngx-modialog"; +import { BSModalContext } from "ngx-modialog/plugins/bootstrap"; +import { AdminGitterApiService } from "../../../../shared/services/admin/admin-gitter-api.service"; + +export class ManageSelfhostedGitterBridgeDialogContext 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 AdminGitterBridgeManageSelfhostedComponent implements ModalComponent { + + public isSaving = false; + public provisionUrl: string; + public bridgeId: number; + public isAdding = false; + + constructor(public dialog: DialogRef, + private gitterApi: AdminGitterApiService, + private toaster: ToasterService) { + this.provisionUrl = dialog.context.provisionUrl; + this.bridgeId = dialog.context.bridgeId; + this.isAdding = !this.bridgeId; + } + + public add() { + this.isSaving = true; + if (this.isAdding) { + this.gitterApi.newSelfhosted(this.provisionUrl).then(() => { + this.toaster.pop("success", "Gitter bridge added"); + this.dialog.close(); + }).catch(err => { + console.error(err); + this.isSaving = false; + this.toaster.pop("error", "Failed to create Gitter bridge"); + }); + } else { + this.gitterApi.updateSelfhosted(this.bridgeId, this.provisionUrl).then(() => { + this.toaster.pop("success", "Gitter bridge updated"); + this.dialog.close(); + }).catch(err => { + console.error(err); + this.isSaving = false; + this.toaster.pop("error", "Failed to update Gitter bridge"); + }); + } + } +} diff --git a/web/app/app.module.ts b/web/app/app.module.ts index ca864c0..c66e69f 100644 --- a/web/app/app.module.ts +++ b/web/app/app.module.ts @@ -90,6 +90,11 @@ import { AdminWebhooksBridgeComponent } from "./admin/bridges/webhooks/webhooks. import { AdminWebhooksApiService } from "./shared/services/admin/admin-webhooks-api.service"; import { WebhooksApiService } from "./shared/services/integrations/webhooks-api.service"; import { WebhooksBridgeConfigComponent } from "./configs/bridge/webhooks/webhooks.bridge.component"; +import { AdminGitterBridgeComponent } from "./admin/bridges/gitter/gitter.component"; +import { AdminGitterBridgeManageSelfhostedComponent } from "./admin/bridges/gitter/manage-selfhosted/manage-selfhosted.component"; +import { AdminGitterApiService } from "./shared/services/admin/admin-gitter-api.service"; +import { GitterBridgeConfigComponent } from "./configs/bridge/gitter/gitter.bridge.component"; +import { GitterApiService } from "./shared/services/integrations/gitter-api.service"; @NgModule({ imports: [ @@ -165,6 +170,9 @@ import { WebhooksBridgeConfigComponent } from "./configs/bridge/webhooks/webhook AdminWebhooksBridgeManageSelfhostedComponent, AdminWebhooksBridgeComponent, WebhooksBridgeConfigComponent, + AdminGitterBridgeComponent, + AdminGitterBridgeManageSelfhostedComponent, + GitterBridgeConfigComponent, // Vendor ], @@ -188,6 +196,8 @@ import { WebhooksBridgeConfigComponent } from "./configs/bridge/webhooks/webhook TelegramApiService, AdminWebhooksApiService, WebhooksApiService, + AdminGitterApiService, + GitterApiService, {provide: Window, useValue: window}, // Vendor @@ -209,6 +219,7 @@ import { WebhooksBridgeConfigComponent } from "./configs/bridge/webhooks/webhook TelegramAskUnbridgeComponent, TelegramCannotUnbridgeComponent, AdminWebhooksBridgeManageSelfhostedComponent, + AdminGitterBridgeManageSelfhostedComponent, ] }) export class AppModule { diff --git a/web/app/app.routing.ts b/web/app/app.routing.ts index 53d2e7c..af463cd 100644 --- a/web/app/app.routing.ts +++ b/web/app/app.routing.ts @@ -31,6 +31,8 @@ import { AdminTelegramBridgeComponent } from "./admin/bridges/telegram/telegram. import { TelegramBridgeConfigComponent } from "./configs/bridge/telegram/telegram.bridge.component"; import { AdminWebhooksBridgeComponent } from "./admin/bridges/webhooks/webhooks.component"; import { WebhooksBridgeConfigComponent } from "./configs/bridge/webhooks/webhooks.bridge.component"; +import { AdminGitterBridgeComponent } from "./admin/bridges/gitter/gitter.component"; +import { GitterBridgeConfigComponent } from "./configs/bridge/gitter/gitter.bridge.component"; const routes: Routes = [ {path: "", component: HomeComponent}, @@ -101,6 +103,11 @@ const routes: Routes = [ component: AdminWebhooksBridgeComponent, data: {breadcrumb: "Webhook Bridge", name: "Webhook Bridge"}, }, + { + path: "gitter", + component: AdminGitterBridgeComponent, + data: {breadcrumb: "Gitter Bridge", name: "Gitter Bridge"}, + }, ], }, { @@ -188,6 +195,11 @@ const routes: Routes = [ component: WebhooksBridgeConfigComponent, data: {breadcrumb: "Webhook Bridge Configuration", name: "Webhook Bridge Configuration"}, }, + { + path: "gitter", + component: GitterBridgeConfigComponent, + data: {breadcrumb: "Gitter Bridge Configuration", name: "Gitter Bridge Configuration"}, + }, ], }, { diff --git a/web/app/configs/bridge/gitter/gitter.bridge.component.html b/web/app/configs/bridge/gitter/gitter.bridge.component.html new file mode 100644 index 0000000..f6f4bcf --- /dev/null +++ b/web/app/configs/bridge/gitter/gitter.bridge.component.html @@ -0,0 +1,27 @@ + + + +
+ Bridge to Gitter +
+
+
+ This room is bridged to "{{ bridge.config.link.gitterRoomName }}" on Gitter. + +
+
+ + +
+
+
+
+
\ No newline at end of file diff --git a/web/app/configs/bridge/gitter/gitter.bridge.component.scss b/web/app/configs/bridge/gitter/gitter.bridge.component.scss new file mode 100644 index 0000000..7c9eeab --- /dev/null +++ b/web/app/configs/bridge/gitter/gitter.bridge.component.scss @@ -0,0 +1,4 @@ +.actions-col { + width: 120px; + text-align: center; +} \ No newline at end of file diff --git a/web/app/configs/bridge/gitter/gitter.bridge.component.ts b/web/app/configs/bridge/gitter/gitter.bridge.component.ts new file mode 100644 index 0000000..060b9c5 --- /dev/null +++ b/web/app/configs/bridge/gitter/gitter.bridge.component.ts @@ -0,0 +1,66 @@ +import { Component } from "@angular/core"; +import { BridgeComponent } from "../bridge.component"; +import { FE_GitterLink } from "../../../shared/models/gitter"; +import { GitterApiService } from "../../../shared/services/integrations/gitter-api.service"; +import { ScalarClientApiService } from "../../../shared/services/scalar/scalar-client-api.service"; + +interface GitterConfig { + botUserId: string; + link: FE_GitterLink; +} + +@Component({ + templateUrl: "gitter.bridge.component.html", + styleUrls: ["gitter.bridge.component.scss"], +}) +export class GitterBridgeConfigComponent extends BridgeComponent { + + public gitterRoomName: string; + public isBusy: boolean; + + constructor(private gitter: GitterApiService, private scalar: ScalarClientApiService) { + super("gitter"); + } + + public get isBridged(): boolean { + return this.bridge.config.link && !!this.bridge.config.link.gitterRoomName; + } + + public async bridgeRoom(): Promise { + this.isBusy = true; + + try { + await this.scalar.inviteUser(this.roomId, this.bridge.config.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.toaster.pop("error", "Error inviting bridge"); + return; + } + } + + this.gitter.bridgeRoom(this.roomId, this.gitterRoomName).then(link => { + this.bridge.config.link = link; + this.isBusy = false; + this.toaster.pop("success", "Bridge requested"); + }).catch(error => { + this.isBusy = false; + console.error(error); + this.toaster.pop("error", "Error requesting bridge"); + }); + } + + public unbridgeRoom(): void { + this.isBusy = true; + this.gitter.unbridgeRoom(this.roomId).then(() => { + this.bridge.config.link = null; + this.isBusy = false; + this.toaster.pop("success", "Bridge removed"); + }).catch(error => { + this.isBusy = false; + console.error(error); + this.toaster.pop("error", "Error removing bridge"); + }); + } +} \ No newline at end of file diff --git a/web/app/shared/models/gitter.ts b/web/app/shared/models/gitter.ts new file mode 100644 index 0000000..ee242b5 --- /dev/null +++ b/web/app/shared/models/gitter.ts @@ -0,0 +1,11 @@ +export interface FE_GitterBridge { + id: number; + upstreamId?: number; + provisionUrl?: string; + isEnabled: boolean; +} + +export interface FE_GitterLink { + roomId: string; + gitterRoomName: string; +} \ No newline at end of file diff --git a/web/app/shared/registry/integrations.registry.ts b/web/app/shared/registry/integrations.registry.ts index cc79200..0a62bd0 100644 --- a/web/app/shared/registry/integrations.registry.ts +++ b/web/app/shared/registry/integrations.registry.ts @@ -19,6 +19,7 @@ export class IntegrationsRegistry { "irc": {}, "telegram": {}, "webhooks": {}, + "gitter": {}, }, "widget": { "custom": { diff --git a/web/app/shared/services/admin/admin-gitter-api.service.ts b/web/app/shared/services/admin/admin-gitter-api.service.ts new file mode 100644 index 0000000..2335805 --- /dev/null +++ b/web/app/shared/services/admin/admin-gitter-api.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from "@angular/core"; +import { Http } from "@angular/http"; +import { AuthedApi } from "../authed-api"; +import { FE_Upstream } from "../../models/admin-responses"; +import { FE_GitterBridge } from "../../models/gitter"; + +@Injectable() +export class AdminGitterApiService extends AuthedApi { + constructor(http: Http) { + super(http); + } + + public getBridges(): Promise { + return this.authedGet("/api/v1/dimension/admin/gitter/all").map(r => r.json()).toPromise(); + } + + public getBridge(bridgeId: number): Promise { + return this.authedGet("/api/v1/dimension/admin/gitter/" + bridgeId).map(r => r.json()).toPromise(); + } + + public newFromUpstream(upstream: FE_Upstream): Promise { + return this.authedPost("/api/v1/dimension/admin/gitter/new/upstream", {upstreamId: upstream.id}).map(r => r.json()).toPromise(); + } + + public newSelfhosted(provisionUrl: string): Promise { + return this.authedPost("/api/v1/dimension/admin/gitter/new/selfhosted", { + provisionUrl: provisionUrl, + }).map(r => r.json()).toPromise(); + } + + public updateSelfhosted(bridgeId: number, provisionUrl: string): Promise { + return this.authedPost("/api/v1/dimension/admin/gitter/" + bridgeId, { + provisionUrl: provisionUrl, + }).map(r => r.json()).toPromise(); + } +} diff --git a/web/app/shared/services/integrations/gitter-api.service.ts b/web/app/shared/services/integrations/gitter-api.service.ts new file mode 100644 index 0000000..5cb9a09 --- /dev/null +++ b/web/app/shared/services/integrations/gitter-api.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from "@angular/core"; +import { Http } from "@angular/http"; +import { AuthedApi } from "../authed-api"; +import { FE_GitterLink } from "../../models/gitter"; + +@Injectable() +export class GitterApiService extends AuthedApi { + constructor(http: Http) { + super(http); + } + + public bridgeRoom(roomId: string, gitterRoomName: string): Promise { + return this.authedPost("/api/v1/dimension/gitter/room/" + roomId + "/link", {gitterRoomName}) + .map(r => r.json()).toPromise(); + } + + public unbridgeRoom(roomId: string): Promise { + return this.authedDelete("/api/v1/dimension/gitter/room/" + roomId + "/link") + .map(r => r.json()).toPromise(); + } + + public getLink(roomId: string): Promise { + return this.authedGet("/api/v1/dimension/gitter/room/" + roomId + "/link") + .map(r => r.json()).toPromise(); + } +} \ No newline at end of file diff --git a/web/public/img/avatars/gitter.png b/web/public/img/avatars/gitter.png new file mode 100644 index 0000000..2f830c5 Binary files /dev/null and b/web/public/img/avatars/gitter.png differ