diff --git a/config.sample.yml b/config.sample.yml index 0b43f666..565f601d 100644 --- a/config.sample.yml +++ b/config.sample.yml @@ -34,6 +34,10 @@ jira: # webhook: secret: secrettoken + oauth: + client_id: foo + client_secret: bar + redirect_uri: https://example.com/bridge_oauth/ generic: # (Optional) Support for generic webhook events. `allowJsTransformationFunctions` will allow users to write short transformation snippets in code, and thus is unsafe in untrusted environments # diff --git a/package.json b/package.json index f7cfd41b..a58f6ce9 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "generate-default-config": "node lib/Config/Defaults.js --config > config.sample.yml" }, "dependencies": { + "@alloc/quick-lru": "^5.2.0", "@octokit/auth-app": "^3.3.0", "@octokit/auth-token": "^2.4.5", "@octokit/rest": "^18.10.0", @@ -37,6 +38,7 @@ "cors": "^2.8.5", "express": "^4.17.1", "ioredis": "^4.28.0", + "jira-client": "^8.0.0", "markdown-it": "^12.2.0", "matrix-bot-sdk": "^0.5.19", "matrix-widget-api": "^0.1.0-beta.17", @@ -66,6 +68,7 @@ "@types/node": "^12", "@types/node-emoji": "^1.8.1", "@types/uuid": "^8.3.3", + "@types/jira-client": "^7.1.0", "@typescript-eslint/eslint-plugin": "^5.4.0", "@typescript-eslint/parser": "^5.4.0", "chai": "^4.3.4", diff --git a/src/AdminRoom.ts b/src/AdminRoom.ts index b326530a..695d50cf 100644 --- a/src/AdminRoom.ts +++ b/src/AdminRoom.ts @@ -4,7 +4,6 @@ import { UserTokenStore } from "./UserTokenStore"; import { BridgeConfig } from "./Config/Config"; import {v4 as uuid} from "uuid"; import qs from "querystring"; -import { EventEmitter } from "events"; import LogWrapper from "./LogWrapper"; import "reflect-metadata"; import markdown from "markdown-it"; @@ -18,6 +17,8 @@ import { BridgeRoomState, BridgeRoomStateGitHub } from "./Widgets/BridgeWidgetIn import { Endpoints } from "@octokit/types"; import { ProjectsListResponseData } from "./Github/Types"; import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters"; +import { JiraBotCommands } from "./Jira/AdminCommands"; +import { AdminAccountData, AdminRoomCommandHandler } from "./AdminRoomCommandHandler"; type ProjectsListForRepoResponseData = Endpoints["GET /repos/{owner}/{repo}/projects"]["response"]; type ProjectsListForUserResponseData = Endpoints["GET /users/{username}/projects"]["response"]; @@ -33,52 +34,26 @@ export const LEGACY_BRIDGE_GITLAB_NOTIF_TYPE = "uk.half-shot.matrix-github.gitla export const BRIDGE_ROOM_TYPE = "uk.half-shot.matrix-hookshot.github.room"; export const BRIDGE_NOTIF_TYPE = "uk.half-shot.matrix-hookshot.github.notif_state"; export const BRIDGE_GITLAB_NOTIF_TYPE = "uk.half-shot.matrix-hookshot.gitlab.notif_state"; -export interface AdminAccountData { - // eslint-disable-next-line camelcase - admin_user: string; - github?: { - notifications?: { - enabled: boolean; - participating?: boolean; - }; - }; - gitlab?: { - [instanceUrl: string]: { - notifications: { - enabled: boolean; - } - } - } -} - -export class AdminRoom extends EventEmitter { +export class AdminRoom extends AdminRoomCommandHandler { public static helpMessage: () => MatrixMessageContent; - private widgetAccessToken = `abcdef`; + protected widgetAccessToken = `abcdef`; static botCommands: BotCommands; - private pendingOAuthState: string|null = null; + protected pendingOAuthState: string|null = null; public readonly notifFilter: NotifFilter; - constructor(public readonly roomId: string, - private data: AdminAccountData, + constructor(roomId: string, + data: AdminAccountData, notifContent: NotificationFilterStateContent, - private botIntent: Intent, - private tokenStore: UserTokenStore, - private config: BridgeConfig) { - super(); + botIntent: Intent, + tokenStore: UserTokenStore, + config: BridgeConfig) { + super(botIntent, roomId, tokenStore, config, data); this.notifFilter = new NotifFilter(notifContent); // TODO: Move this this.backfillAccessToken(); } - public get accountData() { - return {...this.data}; - } - - public get userId() { - return this.data.admin_user; - } - public get oauthState() { return this.pendingOAuthState; } @@ -156,10 +131,6 @@ export class AdminRoom extends EventEmitter { }); } - public async sendNotice(noticeText: string) { - return this.botIntent.sendText(this.roomId, noticeText, "m.notice"); - } - @botCommand("help", "This help text") public async helpCommand() { return this.botIntent.sendEvent(this.roomId, AdminRoom.helpMessage()); @@ -654,6 +625,6 @@ export class AdminRoom extends EventEmitter { } // eslint-disable-next-line @typescript-eslint/no-explicit-any -const res = compileBotCommands(AdminRoom.prototype as any); +const res = compileBotCommands(AdminRoom.prototype as any, JiraBotCommands.prototype as any); AdminRoom.helpMessage = res.helpMessage; AdminRoom.botCommands = res.botCommands; \ No newline at end of file diff --git a/src/AdminRoomCommandHandler.ts b/src/AdminRoomCommandHandler.ts new file mode 100644 index 00000000..1189affd --- /dev/null +++ b/src/AdminRoomCommandHandler.ts @@ -0,0 +1,59 @@ +import EventEmitter from "events"; +import { Intent } from "matrix-bot-sdk"; +import { BridgeConfig } from "./Config/Config"; +import { UserTokenStore } from "./UserTokenStore"; + +export interface AdminAccountData { + // eslint-disable-next-line camelcase + admin_user: string; + github?: { + notifications?: { + enabled: boolean; + participating?: boolean; + }; + }; + gitlab?: { + [instanceUrl: string]: { + notifications: { + enabled: boolean; + } + } + } +} + + +export abstract class AdminRoomCommandHandler extends EventEmitter { + + // This needs moving to the JIRA specific impl. + protected pendingJiraOAuthState: string|null = null; + + public get jiraOAuthState() { + return this.pendingJiraOAuthState; + } + + public clearJiraOauthState() { + this.pendingJiraOAuthState = null; + } + + public get accountData() { + return {...this.data}; + } + + public get userId() { + return this.data.admin_user; + } + + constructor( + protected readonly botIntent: Intent, + public readonly roomId: string, + protected tokenStore: UserTokenStore, + protected readonly config: BridgeConfig, + protected data: AdminAccountData, + ) { + super(); + } + public async sendNotice(noticeText: string) { + return this.botIntent.sendText(this.roomId, noticeText, "m.notice"); + } + +} \ No newline at end of file diff --git a/src/App/BridgeApp.ts b/src/App/BridgeApp.ts index 47b1ec0a..d8b1fb11 100644 --- a/src/App/BridgeApp.ts +++ b/src/App/BridgeApp.ts @@ -5,6 +5,7 @@ import { BridgeConfig, parseRegistrationFile } from "../Config/Config"; import { Webhooks } from "../Webhooks"; import { MatrixSender } from "../MatrixSender"; import { UserNotificationWatcher } from "../Notifications/UserNotificationWatcher"; +import { LogLevel, LogService } from "matrix-bot-sdk"; LogWrapper.configureLogging("debug"); const log = new LogWrapper("App"); diff --git a/src/BotCommands.ts b/src/BotCommands.ts index f5ab7b23..f321bcfd 100644 --- a/src/BotCommands.ts +++ b/src/BotCommands.ts @@ -25,24 +25,26 @@ export type BotCommands = {[prefix: string]: { includeUserId: boolean, }}; -export function compileBotCommands(prototype: Record): {helpMessage: (cmdPrefix?: string) => MatrixMessageContent, botCommands: BotCommands} { +export function compileBotCommands(...prototypes: Record[]): {helpMessage: (cmdPrefix?: string) => MatrixMessageContent, botCommands: BotCommands} { let content = "Commands:\n"; const botCommands: BotCommands = {}; - Object.getOwnPropertyNames(prototype).forEach(propetyKey => { - const b = Reflect.getMetadata(botCommandSymbol, prototype, propetyKey); - if (b) { - const requiredArgs = b.requiredArgs.join(" "); - const optionalArgs = b.optionalArgs.map((arg: string) => `[${arg}]`).join(" "); - content += ` - \`££PREFIX££${b.prefix}\` ${requiredArgs} ${optionalArgs} - ${b.help}\n`; - // We know that this is safe. - botCommands[b.prefix as string] = { - fn: prototype[propetyKey], - requiredArgs: b.requiredArgs, - optionalArgs: b.optionalArgs, - includeUserId: b.includeUserId, - }; - } - }); + prototypes.forEach(prototype => { + Object.getOwnPropertyNames(prototype).forEach(propetyKey => { + const b = Reflect.getMetadata(botCommandSymbol, prototype, propetyKey); + if (b) { + const requiredArgs = b.requiredArgs.join(" "); + const optionalArgs = b.optionalArgs.map((arg: string) => `[${arg}]`).join(" "); + content += ` - \`££PREFIX££${b.prefix}\` ${requiredArgs} ${optionalArgs} - ${b.help}\n`; + // We know that this is safe. + botCommands[b.prefix as string] = { + fn: prototype[propetyKey], + requiredArgs: b.requiredArgs, + optionalArgs: b.optionalArgs, + includeUserId: b.includeUserId, + }; + } + }); + }) return { helpMessage: (cmdPrefix?: string) => ({ msgtype: "m.notice", diff --git a/src/Bridge.ts b/src/Bridge.ts index e0c4e9cb..0c2daa9c 100644 --- a/src/Bridge.ts +++ b/src/Bridge.ts @@ -1,4 +1,4 @@ -import { AdminRoom, BRIDGE_ROOM_TYPE, AdminAccountData } from "./AdminRoom"; +import { AdminRoom, BRIDGE_ROOM_TYPE, LEGACY_BRIDGE_ROOM_TYPE } from "./AdminRoom"; import { Appservice, IAppserviceRegistration, RichRepliesPreprocessor, IRichReplyMetadata, StateEvent, PantalaimonClient, MatrixClient } from "matrix-bot-sdk"; import { BridgeConfig, BridgeConfigProvisioning, GitLabInstance } from "./Config/Config"; import { BridgeWidgetApi } from "./Widgets/BridgeWidgetApi"; @@ -13,14 +13,14 @@ import { GitLabIssueConnection } from "./Connections/GitlabIssue"; import { IBridgeStorageProvider } from "./Stores/StorageProvider"; import { IConnection, GitHubDiscussionSpace, GitHubDiscussionConnection, GitHubUserSpace } from "./Connections"; import { IGitLabWebhookIssueStateEvent, IGitLabWebhookMREvent, IGitLabWebhookNoteEvent } from "./Gitlab/WebhookTypes"; -import { JiraIssueEvent } from "./Jira/WebhookTypes"; +import { JiraIssueEvent, JiraIssueUpdatedEvent } from "./Jira/WebhookTypes"; import { MatrixEvent, MatrixMemberContent, MatrixMessageContent } from "./MatrixEvent"; import { MemoryStorageProvider } from "./Stores/MemoryStorageProvider"; import { MessageQueue, createMessageQueue } from "./MessageQueue/MessageQueue"; import { MessageSenderClient } from "./MatrixSender"; import { NotifFilter, NotificationFilterStateContent } from "./NotificationFilters"; import { NotificationProcessor } from "./NotificationsProcessor"; -import { OAuthRequest, OAuthTokens, NotificationsEnableEvent, NotificationsDisableEvent, GenericWebhookEvent,} from "./Webhooks"; +import { OAuthRequest, GitHubOAuthTokens, NotificationsEnableEvent, NotificationsDisableEvent, GenericWebhookEvent,} from "./Webhooks"; import { ProjectsGetResponseData } from "./Github/Types"; import { RedisStorageProvider } from "./Stores/RedisStorageProvider"; import { retry } from "./PromiseUtil"; @@ -29,6 +29,8 @@ import { UserTokenStore } from "./UserTokenStore"; import * as GitHubWebhookTypes from "@octokit/webhooks-types"; import LogWrapper from "./LogWrapper"; import { Provisioner } from "./provisioning/provisioner"; +import { JiraOAuthResult } from "./Jira/Types"; +import { AdminAccountData } from "./AdminRoomCommandHandler"; const log = new LogWrapper("Bridge"); export class Bridge { @@ -68,7 +70,7 @@ export class Bridge { this.messageClient = new MessageSenderClient(this.queue); this.commentProcessor = new CommentProcessor(this.as, this.config.bridge.mediaUrl || this.config.bridge.url); this.notifProcessor = new NotificationProcessor(this.storage, this.messageClient); - this.tokenStore = new UserTokenStore(this.config.passFile || "./passkey.pem", this.as.botIntent); + this.tokenStore = new UserTokenStore(this.config.passFile || "./passkey.pem", this.as.botIntent, this.config); } public stop() { @@ -150,6 +152,7 @@ export class Bridge { this.queue.subscribe("notifications.user.events"); this.queue.subscribe("github.*"); this.queue.subscribe("gitlab.*"); + this.queue.subscribe("jira.*"); const validateRepoIssue = (data: GitHubWebhookTypes.IssuesEvent|GitHubWebhookTypes.IssueCommentEvent) => { if (!data.repository || !data.issue) { @@ -339,7 +342,7 @@ export class Bridge { }); }); - this.queue.on("oauth.tokens", async (msg) => { + this.queue.on("oauth.tokens", async (msg) => { const adminRoom = [...this.adminRooms.values()].find((r) => r.oauthState === msg.data.state); if (!adminRoom) { log.warn("Could not find admin room for successful tokens request. This shouldn't happen!"); @@ -436,8 +439,7 @@ export class Bridge { this.queue.on("jira.issue_created", async ({data}) => { log.info(`JIRA issue created for project ${data.issue.fields.project.id}, issue id ${data.issue.id}`); - const projectId = data.issue.fields.project.id; - const connections = connManager.getConnectionsForJiraProject(projectId, "jira.issue_created"); + const connections = connManager.getConnectionsForJiraProject(data.issue.fields.project, "jira.issue_created"); connections.forEach(async (c) => { try { @@ -447,7 +449,42 @@ export class Bridge { } }); }); + + this.queue.on("jira.issue_updated", async ({data}) => { + log.info(`JIRA issue created for project ${data.issue.fields.project.id}, issue id ${data.issue.id}`); + const connections = connManager.getConnectionsForJiraProject(data.issue.fields.project, "jira.issue_updated"); + + connections.forEach(async (c) => { + try { + await c.onJiraIssueUpdated(data as JiraIssueUpdatedEvent); + } catch (ex) { + log.warn(`Failed to handle jira.issue_updated:`, ex); + } + }); + }); + + this.queue.on("jira.oauth.response", async (msg) => { + const adminRoom = [...this.adminRooms.values()].find((r) => r.jiraOAuthState === msg.data.state); + await this.queue.push({ + data: !!(adminRoom), + sender: "Bridge", + messageId: msg.messageId, + eventName: "response.jira.oauth.response", + }); + }); + + this.queue.on("jira.oauth.tokens", async (msg) => { + const adminRoom = [...this.adminRooms.values()].find((r) => r.jiraOAuthState === msg.data.state); + if (!adminRoom) { + log.warn("Could not find admin room for successful tokens request. This shouldn't happen!"); + return; + } + adminRoom.clearJiraOauthState(); + await this.tokenStore.storeUserToken("jira", adminRoom.userId, JSON.stringify(msg.data)); + await adminRoom.sendNotice(`Logged into Jira`); + }); + this.queue.on("generic-webhook.event", async ({data}) => { log.info(`Incoming generic hook ${data.hookId}`); const connections = connManager.getConnectionsForGenericWebhook(data.hookId); @@ -512,12 +549,20 @@ export class Bridge { // TODO: Refactor this to be a connection try { - const accountData = await this.as.botIntent.underlyingClient.getSafeRoomAccountData( + let accountData = await this.as.botIntent.underlyingClient.getSafeRoomAccountData( BRIDGE_ROOM_TYPE, roomId, ); if (!accountData) { - log.debug(`Room ${roomId} has no connections and is not an admin room`); - continue; + accountData = await this.as.botIntent.underlyingClient.getSafeRoomAccountData( + LEGACY_BRIDGE_ROOM_TYPE, roomId, + ); + if (!accountData) { + log.debug(`Room ${roomId} has no connections and is not an admin room`); + continue; + } else { + // Upgrade the room + await this.as.botClient.setRoomAccountData(BRIDGE_ROOM_TYPE, roomId, accountData); + } } let notifContent; diff --git a/src/Config/Config.ts b/src/Config/Config.ts index 4de4d103..af77c0b6 100644 --- a/src/Config/Config.ts +++ b/src/Config/Config.ts @@ -39,10 +39,18 @@ interface BridgeConfigGitLab { instances: {[name: string]: GitLabInstance}; } -interface BridgeConfigJira { +export interface BridgeConfigJira { webhook: { secret: string; }; + oauth: { + // eslint-disable-next-line camelcase + client_id: string; + // eslint-disable-next-line camelcase + client_secret: string; + // eslint-disable-next-line camelcase + redirect_uri: string; + }; } interface BridgeGenericWebhooksConfig { @@ -96,18 +104,18 @@ export interface BridgeConfigProvisioning { } interface BridgeConfigRoot { + bot?: BridgeConfigBot; bridge: BridgeConfigBridge; - webhook: BridgeConfigWebhook; - queue: BridgeConfigQueue; - logging: BridgeConfigLogging; - passFile: string; + generic?: BridgeGenericWebhooksConfig; github?: BridgeConfigGitHub; gitlab?: BridgeConfigGitLab; provisioning?: BridgeConfigProvisioning; jira?: BridgeConfigJira; - bot?: BridgeConfigBot; + logging: BridgeConfigLogging; + passFile: string; + queue: BridgeConfigQueue; + webhook: BridgeConfigWebhook; widgets?: BridgeWidgetConfig; - generic?: BridgeGenericWebhooksConfig; } export class BridgeConfig { diff --git a/src/Config/Defaults.ts b/src/Config/Defaults.ts index 134bea9d..09db8372 100644 --- a/src/Config/Defaults.ts +++ b/src/Config/Defaults.ts @@ -4,7 +4,7 @@ import { getConfigKeyMetadata } from "./Decorators"; import { Node, YAMLSeq } from "yaml/types"; import { randomBytes } from "crypto"; -const DefaultConfig = new BridgeConfig({ +export const DefaultConfig = new BridgeConfig({ bridge: { domain: "example.com", url: "http://localhost:8008", @@ -62,7 +62,12 @@ const DefaultConfig = new BridgeConfig({ jira: { webhook: { secret: 'secrettoken' - } + }, + oauth: { + client_id: "foo", + client_secret: "bar", + redirect_uri: "https://example.com/bridge_oauth/", + }, }, generic: { enabled: false, diff --git a/src/ConnectionManager.ts b/src/ConnectionManager.ts index ac8b9cd7..feb4dac1 100644 --- a/src/ConnectionManager.ts +++ b/src/ConnectionManager.ts @@ -12,6 +12,7 @@ import { GenericHookConnection } from "./Connections/GenericHook"; import { JiraProjectConnection } from "./Connections/JiraProject"; import { GithubInstance } from "./Github/GithubInstance"; import { GitLabClient } from "./Gitlab/Client"; +import { JiraProject } from "./Jira/Types"; import LogWrapper from "./LogWrapper"; import { MessageSenderClient } from "./MatrixSender"; import { UserTokenStore } from "./UserTokenStore"; @@ -41,7 +42,8 @@ export class ConnectionManager { public push(...connections: IConnection[]) { // NOTE: Double loop for (const connection of connections) { - if (this.connections.find((c) => c !== connection)) { + if (!this.connections.find((c) => c === connection)) { + console.log("PUSH!"); this.connections.push(connection); } } @@ -49,8 +51,8 @@ export class ConnectionManager { } public async createConnectionForState(roomId: string, state: StateEvent) { - log.debug(`Looking to create connection for ${roomId}`); - if (state.content.disabled === false) { + log.debug(`Looking to create connection for ${roomId} ${state.type}`); + if (state.content.disabled === true) { log.debug(`${roomId} has disabled state for ${state.type}`); return; } @@ -128,10 +130,11 @@ export class ConnectionManager { } if (JiraProjectConnection.EventTypes.includes(state.type)) { + console.log("WOOF", state); if (!this.config.jira) { throw Error('JIRA is not configured'); } - return new JiraProjectConnection(roomId, this.as, state.content, state.stateKey, this.commentProcessor, this.messageClient); + return new JiraProjectConnection(roomId, this.as, state.content, state.stateKey, this.commentProcessor, this.messageClient, this.tokenStore); } if (GenericHookConnection.EventTypes.includes(state.type) && this.config.generic?.enabled) { @@ -227,10 +230,15 @@ export class ConnectionManager { return this.connections.filter((c) => (c instanceof GitLabRepoConnection && c.path === pathWithNamespace)) as GitLabRepoConnection[]; } - public getConnectionsForJiraProject(projectId: string, eventName: string): JiraProjectConnection[] { - return this.connections.filter((c) => (c instanceof JiraProjectConnection && c.projectId === projectId && c.isInterestedInHookEvent(eventName))) as JiraProjectConnection[]; + public getConnectionsForJiraProject(project: JiraProject, eventName: string): JiraProjectConnection[] { + console.log(this.connections); + return this.connections.filter((c) => + (c instanceof JiraProjectConnection && + c.interestedInProject(project) && + c.isInterestedInHookEvent(eventName))) as JiraProjectConnection[]; } + public getConnectionsForGenericWebhook(hookId: string): GenericHookConnection[] { return this.connections.filter((c) => (c instanceof GenericHookConnection && c.hookId === hookId)) as GenericHookConnection[]; } diff --git a/src/Connections/CommandConnection.ts b/src/Connections/CommandConnection.ts new file mode 100644 index 00000000..263814f9 --- /dev/null +++ b/src/Connections/CommandConnection.ts @@ -0,0 +1,54 @@ +import { botCommand, BotCommands, handleCommand } from "../BotCommands"; +import LogWrapper from "../LogWrapper"; +import { MatrixClient } from "matrix-bot-sdk"; +import { MatrixMessageContent, MatrixEvent } from "../MatrixEvent"; +const log = new LogWrapper("CommandConnection"); + +export abstract class CommandConnection { + constructor( + public readonly roomId: string, + private readonly botClient: MatrixClient, + private readonly botCommands: BotCommands, + private readonly helpMessage: (prefix: string) => MatrixMessageContent, + protected readonly stateCommandPrefix: string, + ) { } + + protected get commandPrefix() { + return this.stateCommandPrefix + " "; + } + + public async onMessageEvent(ev: MatrixEvent) { + const { error, handled, humanError } = await handleCommand(ev.sender, ev.content.body, this.botCommands, this, this.commandPrefix); + if (!handled) { + // Not for us. + return; + } + if (error) { + await this.botClient.sendEvent(this.roomId, "m.reaction", { + "m.relates_to": { + rel_type: "m.annotation", + event_id: ev.event_id, + key: "⛔", + } + }); + await this.botClient.sendEvent(this.roomId, 'm.room.message', { + msgtype: "m.notice", + body: humanError ? `Failed to handle command: ${humanError}` : "Failed to handle command", + }); + log.warn(`Failed to handle command:`, error); + return; + } + await this.botClient.sendEvent(this.roomId, "m.reaction", { + "m.relates_to": { + rel_type: "m.annotation", + event_id: ev.event_id, + key: "✅", + } + }); + } + + @botCommand("help", "This help text") + public async helpCommand() { + return this.botClient.sendEvent(this.roomId, 'm.room.message', this.helpMessage(this.commandPrefix)); + } +} \ No newline at end of file diff --git a/src/Connections/GithubRepo.ts b/src/Connections/GithubRepo.ts index 6a21fc6b..3e5d1171 100644 --- a/src/Connections/GithubRepo.ts +++ b/src/Connections/GithubRepo.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ import { Appservice } from "matrix-bot-sdk"; -import { BotCommands, handleCommand, botCommand, compileBotCommands } from "../BotCommands"; +import { BotCommands, botCommand, compileBotCommands } from "../BotCommands"; import { CommentProcessor } from "../CommentProcessor"; import { FormatUtil } from "../FormatUtil"; import { IConnection } from "./IConnection"; @@ -15,6 +15,7 @@ import axios from "axios"; import emoji from "node-emoji"; import LogWrapper from "../LogWrapper"; import markdown from "markdown-it"; +import { CommandConnection } from "./CommandConnection"; const log = new LogWrapper("GitHubRepoConnection"); const md = new markdown(); @@ -57,7 +58,7 @@ function compareEmojiStrings(e0: string, e1: string, e0Index = 0) { /** * Handles rooms connected to a github repo. */ -export class GitHubRepoConnection implements IConnection { +export class GitHubRepoConnection extends CommandConnection implements IConnection { static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.github.repository"; static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.repository"; @@ -145,12 +146,18 @@ export class GitHubRepoConnection implements IConnection { static helpMessage: (cmdPrefix: string) => MatrixMessageContent; static botCommands: BotCommands; - constructor(public readonly roomId: string, + constructor(roomId: string, private readonly as: Appservice, private readonly state: GitHubRepoConnectionState, private readonly tokenStore: UserTokenStore, private readonly stateKey: string) { - + super( + roomId, + as.botClient, + GitHubRepoConnection.botCommands, + GitHubRepoConnection.helpMessage, + state.commandPrefix || "!gh" + ); } public get org() { @@ -161,49 +168,11 @@ export class GitHubRepoConnection implements IConnection { return this.state.repo.toLowerCase(); } - private get commandPrefix() { - return (this.state.commandPrefix || "!gh") + " "; - } public isInterestedInStateEvent(eventType: string, stateKey: string) { return GitHubRepoConnection.EventTypes.includes(eventType) && this.stateKey === stateKey; } - public async onMessageEvent(ev: MatrixEvent) { - const { error, handled, humanError } = await handleCommand(ev.sender, ev.content.body, GitHubRepoConnection.botCommands, this, this.commandPrefix); - if (!handled) { - // Not for us. - return; - } - if (error) { - await this.as.botClient.sendEvent(this.roomId, "m.reaction", { - "m.relates_to": { - rel_type: "m.annotation", - event_id: ev.event_id, - key: "⛔", - } - }); - await this.as.botIntent.sendEvent(this.roomId,{ - msgtype: "m.notice", - body: humanError ? `Failed to handle command: ${humanError}` : "Failed to handle command", - }); - log.warn(`Failed to handle command:`, error); - return; - } - await this.as.botClient.sendEvent(this.roomId, "m.reaction", { - "m.relates_to": { - rel_type: "m.annotation", - event_id: ev.event_id, - key: "✅", - } - }); - } - - @botCommand("help", "This help text") - public async helpCommand() { - return this.as.botIntent.sendEvent(this.roomId, GitHubRepoConnection.helpMessage(this.commandPrefix)); - } - @botCommand("create", "Create an issue for this repo", ["title"], ["description", "labels"], true) // @ts-ignore private async onCreateIssue(userId: string, title: string, description?: string, labels?: string) { @@ -539,6 +508,6 @@ ${event.release.body}`; } // eslint-disable-next-line @typescript-eslint/no-explicit-any -const res = compileBotCommands(GitHubRepoConnection.prototype as any); +const res = compileBotCommands(GitHubRepoConnection.prototype as any, CommandConnection.prototype as any); GitHubRepoConnection.helpMessage = res.helpMessage; GitHubRepoConnection.botCommands = res.botCommands; diff --git a/src/Connections/JiraProject.ts b/src/Connections/JiraProject.ts index fcfc1711..38029180 100644 --- a/src/Connections/JiraProject.ts +++ b/src/Connections/JiraProject.ts @@ -3,16 +3,26 @@ import { Appservice } from "matrix-bot-sdk"; import LogWrapper from "../LogWrapper"; import { CommentProcessor } from "../CommentProcessor"; import { MessageSenderClient } from "../MatrixSender" -import { JiraIssueEvent } from "../Jira/WebhookTypes"; +import { JiraIssueEvent, JiraIssueUpdatedEvent } from "../Jira/WebhookTypes"; import { FormatUtil } from "../FormatUtil"; import markdownit from "markdown-it"; import { generateJiraWebLinkFromIssue } from "../Jira"; +import { JiraIssue, JiraProject } from "../Jira/Types"; +import { botCommand, BotCommands, compileBotCommands } from "../BotCommands"; +import { MatrixMessageContent } from "../MatrixEvent"; +import { CommandConnection } from "./CommandConnection"; +import { start } from "repl"; +import { UserTokenStore } from "../UserTokenStore"; +import { CommandError, NotLoggedInError } from "../errors"; type JiraAllowedEventsNames = "issue.created"; const JiraAllowedEvents: JiraAllowedEventsNames[] = ["issue.created"]; export interface JiraProjectConnectionState { - id: string; + // legacy field, prefer url + id?: string; + url?: string; events?: JiraAllowedEventsNames[], + commandPrefix?: string; } const log = new LogWrapper("JiraProjectConnection"); @@ -21,7 +31,9 @@ const md = new markdownit(); /** * Handles rooms connected to a github repo. */ -export class JiraProjectConnection implements IConnection { +export class JiraProjectConnection extends CommandConnection implements IConnection { + + static readonly CanonicalEventType = "uk.half-shot.matrix-hookshot.jira.project"; static readonly LegacyCanonicalEventType = "uk.half-shot.matrix-github.jira.project"; @@ -29,6 +41,8 @@ export class JiraProjectConnection implements IConnection { JiraProjectConnection.CanonicalEventType, JiraProjectConnection.LegacyCanonicalEventType, ]; + static botCommands: BotCommands; + static helpMessage: (cmdPrefix?: string) => MatrixMessageContent; static getTopicString(authorName: string, state: string) { `Author: ${authorName} | State: ${state === "closed" ? "closed" : "open"}` @@ -38,16 +52,58 @@ export class JiraProjectConnection implements IConnection { return this.state.id; } + public get instanceOrigin() { + return this.projectUrl?.origin; + } + + public get projectKey() { + const parts = this.projectUrl?.pathname.split('/'); + return parts ? parts[parts.length - 1] : undefined; + } + public isInterestedInHookEvent(eventName: string) { return !this.state.events || this.state.events?.includes(eventName as JiraAllowedEventsNames); } - constructor(public readonly roomId: string, + public interestedInProject(project: JiraProject) { + if (this.projectId === project.id) { + return true; + } + if (this.instanceOrigin) { + const url = new URL(project.self); + return this.instanceOrigin === url.origin && this.projectKey === project.key; + } + return false; + } + + /** + * The URL of the project + * @example https://test.atlassian.net/jira/software/c/projects/PLAY + */ + private projectUrl?: URL; + + constructor(roomId: string, private readonly as: Appservice, private state: JiraProjectConnectionState, private readonly stateKey: string, - private commentProcessor: CommentProcessor, - private messageClient: MessageSenderClient,) { + private readonly commentProcessor: CommentProcessor, + private readonly messageClient: MessageSenderClient, + private readonly tokenStore: UserTokenStore,) { + super( + roomId, + as.botClient, + JiraProjectConnection.botCommands, + JiraProjectConnection.helpMessage, + state.commandPrefix || "!jira" + ); + if (state.url) { + this.projectUrl = new URL(state.url); + } else if (state.id) { + log.warn(`Legacy ID option in use, needs to be switched to 'url'`); + } else { + throw Error('State is missing both id and url, cannot create connection'); + } + } public isInterestedInStateEvent(eventType: string, stateKey: string) { @@ -82,8 +138,156 @@ export class JiraProjectConnection implements IConnection { }, } } + + public async onJiraIssueUpdated(data: JiraIssueUpdatedEvent) { + log.info(`onJiraIssueUpdated ${this.roomId} ${this.projectId} ${data.issue.id}`); + const url = generateJiraWebLinkFromIssue(data.issue); + let content = `${data.user.displayName} updated JIRA [${data.issue.key}](${url}): `; + + const changes = data.changelog.items.map((change) => `**${change.field}** changed from '${change.fromString || "not set"}' to '${change.toString || "not set"}'`); + + if (changes.length < 0) { + // Empty changeset? + log.warn(`Empty changeset, not sending message`); + return; + } else if (changes.length === 1) { + content += changes[0]; + } else { + content += `\n - ` + changes.join(`\n - `); + } + + await this.as.botIntent.sendEvent(this.roomId, { + msgtype: "m.notice", + body: content, + formatted_body: md.renderInline(content), + format: "org.matrix.custom.html", + ...FormatUtil.getPartialBodyForJiraIssue(data.issue) + }); + } + + private async getUserClientForProject(userId: string) { + const jiraClient = await this.tokenStore.getJiraForUser(userId); + if (!jiraClient) { + throw new NotLoggedInError(); + } + const resource = (await jiraClient.getAccessibleResources()).find((r) => new URL(r.url).origin === this.instanceOrigin); + if (!resource) { + throw new CommandError("No-resource", "You do not have permission to create issues for this JIRA org"); + } + return jiraClient.getClientForResource(resource); + } + + @botCommand("create", "Create an issue for this project", ["type", "title"], ["description", "labels"], true) + public async onCreateIssue(userId: string, type: string, title: string, description?: string, labels?: string) { + const api = await this.getUserClientForProject(userId); + const keyOrId = this.projectKey || this.projectId; + if (!keyOrId) { + throw Error('Neither Key or ID are specified'); + } + const project = await api.getProject(keyOrId); + if (!project.issueTypes || project.issueTypes.length === 0) { + throw new CommandError("project has no issue types", "Cannot create issue, project has no issue types"); + } + const issueTypeId = project.issueTypes.find((issueType) => issueType.name.toLowerCase() === type.toLowerCase())?.id; + if (!issueTypeId) { + const content = project.issueTypes.map((t) => t.name).join(', '); + throw new CommandError("invalid-issuetype", `You must specify a valid issue type (one of ${content}). E.g. ${this.commandPrefix} create ${project.issueTypes[0].name}`); + } + log.info(`Creating new issue on behalf of ${userId}`); + let result: any; + try { + result = await api.addNewIssue({ + //update: {}, + fields: { + "summary": title, + "project": { + "key": this.projectKey, + }, + "issuetype": { + id: issueTypeId, + }, + ...( description ? {"description": { + "type": "doc", + "version": 1, + "content": [ + { + "type": "paragraph", + "content": [ + { + "text": description, + "type": "text" + } + ] + } + ] + }} : undefined), + ...( labels ? {"labels": labels.split(",")} : undefined), + } + }) + } catch (ex) { + log.warn("Failed to create JIRA issue:", ex); + throw new CommandError(ex.message, "Failed to create JIRA issue"); + } + + const link = generateJiraWebLinkFromIssue({self: this.projectUrl?.toString() || result.self, key: result.key as string}); + const content = `Created JIRA issue ${result.key}: [${link}](${link})`; + return this.as.botIntent.sendEvent(this.roomId,{ + msgtype: "m.notice", + body: content, + formatted_body: md.render(content), + format: "org.matrix.custom.html" + }); + } + + @botCommand("issue-types", "Get issue types for this project", [], [], true) + public async getIssueTypes(userId: string) { + const api = await this.getUserClientForProject(userId); + log.info(`Creating new issue on behalf of ${userId}`); + let result: JiraProject; + try { + const keyOrId = this.projectKey || this.projectId; + if (!keyOrId) { + throw Error('Neither Key or ID are specified'); + } + result = await api.getProject(keyOrId); + } catch (ex) { + log.warn("Failed to get issue types:", ex); + throw new CommandError(ex.message, "Failed to create JIRA issue"); + } + + const content = `Issue types: ${(result.issueTypes || []).map((t) => t.name).join(', ')}`; + return this.as.botIntent.sendEvent(this.roomId,{ + msgtype: "m.notice", + body: content, + formatted_body: md.render(content), + format: "org.matrix.custom.html" + }); + } + + @botCommand("assign", "Assign an issue to a user", ["issueKey", "user"], [], true) + public async onAssignIssue(userId: string, issueKey: string, user: string) { + const api = await this.getUserClientForProject(userId); + try { + await api.getIssue(issueKey); + } catch (ex) { + log.warn(`Failed to find issue`, ex); + throw new CommandError(ex.message, "Failed to find issue"); + } + + log.info(`Assinging issue on behalf of ${userId}`); + const searchForUser = await api.searchUsers({query: user, maxResults: 1, includeInactive: false, includeActive: true, username: ""}); + if (searchForUser.length === 0) { + throw new CommandError("not-found", `Could not find a user matching '${user}'`); + } + await api.updateAssigneeWithId(issueKey, searchForUser[0].accountId); + } public toString() { - return `JiraProjectConnection ${this.projectId}`; + return `JiraProjectConnection ${this.projectId || this.projectUrl}`; } -} \ No newline at end of file +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const res = compileBotCommands(JiraProjectConnection.prototype as any, CommandConnection.prototype as any); +JiraProjectConnection.helpMessage = res.helpMessage; +JiraProjectConnection.botCommands = res.botCommands; \ No newline at end of file diff --git a/src/Jira/AdminCommands.ts b/src/Jira/AdminCommands.ts new file mode 100644 index 00000000..de11c335 --- /dev/null +++ b/src/Jira/AdminCommands.ts @@ -0,0 +1,72 @@ +import { AdminRoomCommandHandler } from "../AdminRoomCommandHandler"; +import { botCommand } from "../BotCommands"; +import qs from "querystring"; +import {v4 as uuid} from "uuid"; +import { JiraAPIAccessibleResource } from "./Types"; +import LogWrapper from "../LogWrapper"; + +const log = new LogWrapper('JiraBotCommands'); + +const JiraOAuthScopes = [ + // Reading issues, comments + "read:jira-work", + // Creating issues, comments + "write:jira-work", + // Reading user + "read:jira-user", + "read:me", + "read:account", + // To get a refresh token + "offline_access", +]; + +export class JiraBotCommands extends AdminRoomCommandHandler { + @botCommand("jira login", "Login to JIRA") + public async loginCommand() { + if (!this.config.jira?.oauth) { + this.sendNotice(`Bot is not configured with JIRA OAuth support`); + return; + } + this.pendingJiraOAuthState = uuid(); + const options = { + audience: "api.atlassian.com", + client_id: this.config.jira.oauth.client_id, + scope: JiraOAuthScopes.join(" "), + redirect_uri: this.config.jira.oauth.redirect_uri, + state: this.pendingJiraOAuthState, + response_type: "code", + prompt: "consent", + }; + const url = `https://auth.atlassian.com/authorize?${qs.stringify(options)}`; + await this.sendNotice(`To login, open ${url} and follow the steps`); + } + + @botCommand("jira whoami", "Determine JIRA identity") + public async whoami() { + if (!this.config.jira) { + await this.sendNotice(`Bot is not configured with JIRA OAuth support`); + return; + } + const client = await this.tokenStore.getJiraForUser(this.userId); + + if (!client) { + await this.sendNotice(`You are not logged into JIRA`); + return; + } + // Get all resources for user + let resources: JiraAPIAccessibleResource[]; + try { + resources = await client.getAccessibleResources(); + } catch (ex) { + log.warn(`Could not request resources from JIRA API: `, ex); + await this.sendNotice(`Could not request JIRA resources due to an error`); + return; + } + let response = resources.length === 0 ? `You do not have any instances authorised with this bot` : 'You have access to the following instances:'; + for (const resource of resources) { + const user = await (await client.getClientForResource(resource)).getCurrentUser(); + response += `\n - ${resource.name} ${user.name} (${user.displayName || ""})`; + } + await this.sendNotice(response); + } +} \ No newline at end of file diff --git a/src/Jira/Client.ts b/src/Jira/Client.ts new file mode 100644 index 00000000..9c078ead --- /dev/null +++ b/src/Jira/Client.ts @@ -0,0 +1,113 @@ + +import axios from 'axios'; +import JiraApi, { SearchUserOptions } from 'jira-client'; +import QuickLRU from "@alloc/quick-lru"; +import { JiraAccount, JiraAPIAccessibleResource, JiraIssue, JiraOAuthResult, JiraProject } from './Types'; +import { BridgeConfigJira } from '../Config/Config'; + +const ACCESSIBLE_RESOURCE_CACHE_LIMIT = 100; +const ACCESSIBLE_RESOURCE_CACHE_TTL_MS = 60000; + +export class HookshotJiraApi extends JiraApi { + constructor(private options: JiraApi.JiraApiOptions) { + super(options); + } + + async getProject(projectIdOrKey: string) { + return await super.getProject(projectIdOrKey) as JiraProject; + } + + async getIssue(issueIdOrKey: string) { + const res = await axios.get(`https://api.atlassian.com/${this.options.base}/rest/api/3/issue/${issueIdOrKey}`, { + headers: { + Authorization: `Bearer ${this.options.bearer}` + }, + responseType: 'json', + }); + return res.data; + } + + async searchUsers(opts: SearchUserOptions): Promise { + return super.searchUsers(opts) as unknown as JiraAccount[]; + } +} + +export class JiraClient { + + /** + * Cache of accessible resources for a user. + */ + static resourceCache = new QuickLRU>({ + maxSize: ACCESSIBLE_RESOURCE_CACHE_LIMIT, + maxAge: ACCESSIBLE_RESOURCE_CACHE_TTL_MS + }); + + constructor(private oauth2State: JiraOAuthResult, private readonly onTokenRefreshed: (newData: JiraOAuthResult) => Promise, private readonly config: BridgeConfigJira) { + + } + + private get bearer() { + return this.oauth2State.access_token; + } + + async getAccessibleResources() { + try { + const existingPromise = JiraClient.resourceCache.get(this.bearer); + if (existingPromise) { + return await existingPromise; + } + } catch (ex) { + // Existing failed promise, break out and try again. + JiraClient.resourceCache.delete(this.bearer); + } + const promise = (async () => { + const res = await axios.get(`https://api.atlassian.com/oauth/token/accessible-resources`, { + headers: { + Authorization: `Bearer ${this.bearer}` + }, + responseType: 'json', + }); + return res.data as JiraAPIAccessibleResource[]; + })(); + JiraClient.resourceCache.set(this.bearer, promise); + return promise; + } + + async checkTokenAge() { + if (this.oauth2State.expires_in + 60000 > Date.now()) { + return; + } + // Refresh the token + const res = await axios.post(`https://api.atlassian.com/oauth/token`, { + grant_type: "refresh_token", + client_id: this.config.oauth.client_id, + client_secret: this.config.oauth.client_secret, + refresh_token: this.oauth2State.refresh_token, + }); + res.expires_in += Date.now() + (res.expires_in * 1000); + this.oauth2State = res; + } + + async getClientForName(name: string) { + const resources = await this.getAccessibleResources(); + const resource = resources.find((res) => res.name === name); + await this.checkTokenAge(); + if (!resource) { + throw Error('User does not have access to this resource'); + } + return this.getClientForResource(resource); + } + + async getClientForResource(res: JiraAPIAccessibleResource) { + // Check token age + await this.checkTokenAge(); + return new HookshotJiraApi({ + protocol: 'https', + host: `api.atlassian.com`, + base: `/ex/jira/${res.id}`, + apiVersion: '3', + strictSSL: true, + bearer: this.bearer, + }); + } +} \ No newline at end of file diff --git a/src/Jira/Router.ts b/src/Jira/Router.ts new file mode 100644 index 00000000..64e4a7cc --- /dev/null +++ b/src/Jira/Router.ts @@ -0,0 +1,62 @@ +import axios from "axios"; +import { Router, Request, Response } from "express"; +import { BridgeConfigJira } from "../Config/Config"; +import LogWrapper from "../LogWrapper"; +import { MessageQueue } from "../MessageQueue/MessageQueue"; +import { OAuthRequest } from "../Webhooks"; +import { JiraOAuthResult } from "./Types"; + +const log = new LogWrapper("JiraRouter"); + +export default class JiraRouter { + constructor(private readonly config: BridgeConfigJira, private readonly queue: MessageQueue) { } + + private async onOAuth(req: Request, res: Response) { + if (typeof req.query.state !== "string") { + return res.status(400).send({error: "Missing 'state' parameter"}); + } + if (typeof req.query.code !== "string") { + return res.status(400).send({error: "Missing 'state' parameter"}); + } + const state = req.query.state as string; + const code = req.query.code as string; + log.info(`Got new JIRA oauth request (${state.substring(0, 8)})`); + try { + const exists = await this.queue.pushWait({ + eventName: "jira.oauth.response", + sender: "GithubWebhooks", + data: { + state, + }, + }); + if (!exists) { + return res.status(404).send(`

Could not find user which authorised this request. Has it timed out?

`); + } + const accessTokenRes = await axios.post("https://auth.atlassian.com/oauth/token", { + client_id: this.config.oauth.client_id, + client_secret: this.config.oauth.client_secret, + code: code, + grant_type: "authorization_code", + redirect_uri: this.config.oauth.redirect_uri, + }); + const result = accessTokenRes.data as { access_token: string, scope: string, expires_in: number, refresh_token: string}; + result.expires_in = Date.now() + (result.expires_in * 1000); + log.debug("JIRA token response:", result); + await this.queue.push({ + eventName: "jira.oauth.tokens", + sender: "GithubWebhooks", + data: { state, ... result }, + }); + return res.send(`

Your account has been bridged

`); + } catch (ex) { + log.error("Failed to handle oauth request:", ex); + return res.status(500).send(`

Encountered an error handing oauth request

`); + } + } + + public getRouter() { + const router = Router(); + router.get("/oauth", this.onOAuth.bind(this)); + return router; + } +} diff --git a/src/Jira/Types.ts b/src/Jira/Types.ts index 8e7ab125..cad3a24f 100644 --- a/src/Jira/Types.ts +++ b/src/Jira/Types.ts @@ -1,3 +1,14 @@ + +export interface JiraIssueType { + self: string; + id: string; + description: string; + iconUrl: string; + name: string; + subtask: boolean; + avatarId: number; + hierachyLevel: number; +} export interface JiraProject { /** * URL @@ -9,6 +20,7 @@ export interface JiraProject { projectTypeKey: string; simplified: boolean; avatarUrls: Record; + issueTypes?: JiraIssueType[]; } export interface JiraAccount { @@ -54,4 +66,20 @@ export interface JiraIssue { status: unknown; creator?: JiraAccount; } +} + +export interface JiraOAuthResult { + state: string; + access_token: string; + refresh_token: string; + expires_in: number; + scope: string; +} + +export interface JiraAPIAccessibleResource { + id: string; + url: string, + name: string, + scopes: string[], + avatarUrl: string, } \ No newline at end of file diff --git a/src/Jira/WebhookTypes.ts b/src/Jira/WebhookTypes.ts index 7fce4411..a2a51af2 100644 --- a/src/Jira/WebhookTypes.ts +++ b/src/Jira/WebhookTypes.ts @@ -1,4 +1,4 @@ -import { JiraComment, JiraIssue } from "./Types"; +import { JiraAccount, JiraComment, JiraIssue } from "./Types"; export interface IJiraWebhookEvent { timestamp: number; @@ -15,4 +15,21 @@ export interface JiraIssueEvent extends IJiraWebhookEvent { webhookEvent: "issue_updated"|"issue_created"; comment: JiraComment; issue: JiraIssue; +} + +export interface JiraIssueUpdatedEvent extends JiraIssueEvent { + webhookEvent: "issue_updated"; + user: JiraAccount; + changelog: { + id: string; + items: { + field: string; + fieldtype: string; + fieldId: string; + from: string|null; + fromString: string|null; + to: string|null; + toString: null; + }[]; + } } \ No newline at end of file diff --git a/src/LogWrapper.ts b/src/LogWrapper.ts index d6947f33..57f39b72 100644 --- a/src/LogWrapper.ts +++ b/src/LogWrapper.ts @@ -1,4 +1,4 @@ -import { LogService } from "matrix-bot-sdk"; +import { LogLevel, LogService } from "matrix-bot-sdk"; import util from "util"; import winston from "winston"; @@ -67,6 +67,7 @@ export default class LogWrapper { log.verbose(getMessageString(messageOrObject), { module }); }, }); + LogService.setLevel(LogLevel.fromString(level)); LogService.info("LogWrapper", "Reconfigured logging"); } diff --git a/src/UserTokenStore.ts b/src/UserTokenStore.ts index f622a0c8..12a399a3 100644 --- a/src/UserTokenStore.ts +++ b/src/UserTokenStore.ts @@ -1,12 +1,17 @@ +import { GithubInstance } from "./Github/GithubInstance"; +import { GitLabClient } from "./Gitlab/Client"; import { Intent } from "matrix-bot-sdk"; import { promises as fs } from "fs"; import { publicEncrypt, privateDecrypt } from "crypto"; +import JiraApi from 'jira-client'; import LogWrapper from "./LogWrapper"; -import { GitLabClient } from "./Gitlab/Client"; -import { GithubInstance } from "./Github/GithubInstance"; +import { JiraClient } from "./Jira/Client"; +import { JiraOAuthResult } from "./Jira/Types"; +import { BridgeConfig } from "./Config/Config"; const ACCOUNT_DATA_TYPE = "uk.half-shot.matrix-hookshot.github.password-store:"; const ACCOUNT_DATA_GITLAB_TYPE = "uk.half-shot.matrix-hookshot.gitlab.password-store:"; +const ACCOUNT_DATA_JIRA_TYPE = "uk.half-shot.matrix-hookshot.jira.password-store:"; const LEGACY_ACCOUNT_DATA_TYPE = "uk.half-shot.matrix-github.password-store:"; const LEGACY_ACCOUNT_DATA_GITLAB_TYPE = "uk.half-shot.matrix-github.gitlab.password-store:"; @@ -19,16 +24,20 @@ function tokenKey(type: TokenType, userId: string, legacy = false, instanceUrl?: if (type === "github") { return `${legacy ? LEGACY_ACCOUNT_DATA_TYPE : ACCOUNT_DATA_TYPE}${userId}`; } + if (type === "jira") { + return `${ACCOUNT_DATA_JIRA_TYPE}${userId}`; + } if (!instanceUrl) { throw Error(`Expected instanceUrl for ${type}`); } return `${legacy ? LEGACY_ACCOUNT_DATA_GITLAB_TYPE : ACCOUNT_DATA_GITLAB_TYPE}${instanceUrl}${userId}`; } +const MAX_TOKEN_PART_SIZE = 128; export class UserTokenStore { private key!: Buffer; private userTokens: Map; - constructor(private keyPath: string, private intent: Intent) { + constructor(private keyPath: string, private intent: Intent, private config: BridgeConfig) { this.userTokens = new Map(); } @@ -39,8 +48,14 @@ export class UserTokenStore { public async storeUserToken(type: TokenType, userId: string, token: string, instanceUrl?: string): Promise { const key = tokenKey(type, userId, false, instanceUrl); + const tokenParts: string[] = []; + while (token && token.length > 0) { + const part = token.slice(0, MAX_TOKEN_PART_SIZE); + token = token.substring(MAX_TOKEN_PART_SIZE); + tokenParts.push(publicEncrypt(this.key, Buffer.from(part)).toString("base64")); + } const data = { - encrypted: publicEncrypt(this.key, Buffer.from(token)).toString("base64"), + encrypted: tokenParts, instance: instanceUrl, }; await this.intent.underlyingClient.setAccountData(key, data); @@ -58,16 +73,15 @@ export class UserTokenStore { try { let obj; if (AllowedTokenTypes.includes(type)) { - obj = await this.intent.underlyingClient.getSafeAccountData<{encrypted: string}>(key); + obj = await this.intent.underlyingClient.getSafeAccountData<{encrypted: string|string[]}>(key); if (!obj) { - obj = await this.intent.underlyingClient.getAccountData<{encrypted: string}>(tokenKey(type, userId, true, instanceUrl)); + obj = await this.intent.underlyingClient.getAccountData<{encrypted: string|string[]}>(tokenKey(type, userId, true, instanceUrl)); } } else { throw Error('Unknown type'); } - const encryptedTextB64 = obj.encrypted; - const encryptedText = Buffer.from(encryptedTextB64, "base64"); - const token = privateDecrypt(this.key, encryptedText).toString("utf-8"); + const encryptedParts = typeof obj.encrypted === "string" ? [obj.encrypted] : obj.encrypted; + const token = encryptedParts.map((t) => privateDecrypt(this.key, Buffer.from(t, "base64")).toString("utf-8")).join(""); this.userTokens.set(key, token); return token; } catch (ex) { @@ -94,4 +108,18 @@ export class UserTokenStore { } return new GitLabClient(instanceUrl, senderToken); } + + public async getJiraForUser(userId: string) { + if (!this.config.jira?.oauth) { + throw Error('Jira not configured'); + } + const jsonData = await this.getUserToken("jira", userId); + if (!jsonData) { + return null; + } + // TODO: Hacks + return new JiraClient(JSON.parse(jsonData) as JiraOAuthResult, (data) => { + return this.storeUserToken('jira', userId, JSON.stringify(data)); + }, this.config.jira); + } } diff --git a/src/Webhooks.ts b/src/Webhooks.ts index 49f72fa8..12b6ca21 100644 --- a/src/Webhooks.ts +++ b/src/Webhooks.ts @@ -8,7 +8,8 @@ import { Server } from "http"; import axios from "axios"; import { IGitLabWebhookEvent } from "./Gitlab/WebhookTypes"; import { EmitterWebhookEvent, Webhooks as OctokitWebhooks } from "@octokit/webhooks" -import { IJiraWebhookEvent, JiraIssueEvent } from "./Jira/WebhookTypes"; +import { IJiraWebhookEvent } from "./Jira/WebhookTypes"; +import JiraRouter from "./Jira/Router"; const log = new LogWrapper("GithubWebhooks"); export interface GenericWebhookEvent { @@ -17,11 +18,10 @@ export interface GenericWebhookEvent { } export interface OAuthRequest { - code: string; state: string; } -export interface OAuthTokens { +export interface GitHubOAuthTokens { // eslint-disable-next-line camelcase access_token: string; // eslint-disable-next-line camelcase @@ -70,7 +70,11 @@ export class Webhooks extends EventEmitter { verify: this.verifyRequest.bind(this), })); this.expressApp.post("/", this.onPayload.bind(this)); - this.expressApp.get("/oauth", this.onGetOauth.bind(this)); + this.expressApp.get("/oauth", this.onGitHubGetOauth.bind(this)); + this.queue = createMessageQueue(config); + if (this.config.jira) { + this.expressApp.use("/jira", new JiraRouter(this.config.jira, this.queue).getRouter()); + } this.queue = createMessageQueue(config); } @@ -201,7 +205,7 @@ export class Webhooks extends EventEmitter { } } - public async onGetOauth(req: Request, res: Response) { + public async onGitHubGetOauth(req: Request, res: Response) { log.info("Got new oauth request"); try { if (!this.config.github) { @@ -211,7 +215,6 @@ export class Webhooks extends EventEmitter { eventName: "oauth.response", sender: "GithubWebhooks", data: { - code: req.query.code as string, state: req.query.state as string, }, }); @@ -228,7 +231,7 @@ export class Webhooks extends EventEmitter { })}`); // eslint-disable-next-line camelcase const result = qs.parse(accessTokenRes.data) as { access_token: string, token_type: string }; - await this.queue.push({ + await this.queue.push({ eventName: "oauth.tokens", sender: "GithubWebhooks", data: { state: req.query.state as string, ... result }, diff --git a/tests/AdminRoomTest.ts b/tests/AdminRoomTest.ts index 97cc2c7d..d6dcd7f3 100644 --- a/tests/AdminRoomTest.ts +++ b/tests/AdminRoomTest.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { expect } from "chai"; import { AdminRoom } from "../src/AdminRoom"; +import { DefaultConfig } from "../src/Config/Defaults"; import { NotifFilter } from "../src/NotificationFilters"; import { UserTokenStore } from "../src/UserTokenStore"; import { IntentMock } from "./utils/IntentMock"; @@ -12,7 +13,7 @@ function createAdminRoom(data: any = {admin_user: "@admin:bar"}): [AdminRoom, In if (!data.admin_user) { data.admin_user = "@admin:bar"; } - const tokenStore = new UserTokenStore("notapath", intent); + const tokenStore = new UserTokenStore("notapath", intent, DefaultConfig); return [new AdminRoom(ROOM_ID, data, NotifFilter.getDefaultContent(), intent, tokenStore, {} as any, ), intent]; } diff --git a/yarn.lock b/yarn.lock index 8a302470..3c05063a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,11 @@ # yarn lockfile v1 +"@alloc/quick-lru@^5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz#7bf68b20c0a350f936915fcae06f58e32007ce30" + integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw== + "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.16.0": version "7.16.0" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.16.0.tgz#0dfc80309beec8411e65e706461c408b0bb9b431" @@ -181,6 +186,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.12.13" +"@babel/runtime@^7.6.0": + version "7.16.3" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.3.tgz#b86f0db02a04187a3c17caa77de69840165d42d5" + integrity sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.16.0": version "7.16.0" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.16.0.tgz#d16a35ebf4cd74e202083356fab21dd89363ddd6" @@ -623,6 +635,22 @@ "@octokit/webhooks-types" "4.15.0" aggregate-error "^3.1.0" +"@postman/form-data@~3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@postman/form-data/-/form-data-3.1.1.tgz#d0446d0d3639a291f5e800e89fa1d0d3723f9414" + integrity sha512-vjh8Q2a8S6UCm/KKs31XFJqEEgmbjBmpPNVV2eVav6905wyFAwaUOBGA1NPBI4ERH9MMZc6w0umFgM6WbEPMdg== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + +"@postman/tunnel-agent@^0.6.3": + version "0.6.3" + resolved "https://registry.yarnpkg.com/@postman/tunnel-agent/-/tunnel-agent-0.6.3.tgz#23048d8d8618d453c571f03189e944afdc2292b7" + integrity sha512-k57fzmAZ2PJGxfOA4SGR05ejorHbVAa/84Hxh/2nAztjNXc4ZjOm9NUIk6/Z6LCrBvJZqjRZbN8e/nROVUPVdg== + dependencies: + safe-buffer "^5.0.1" + "@prefresh/babel-plugin@0.4.1": version "0.4.1" resolved "https://registry.yarnpkg.com/@prefresh/babel-plugin/-/babel-plugin-0.4.1.tgz#c4e843f7c5e56c15f1185979a8559c893ffb4a35" @@ -780,6 +808,11 @@ "@types/node" "*" "@types/responselike" "*" +"@types/caseless@*": + version "0.12.2" + resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8" + integrity sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w== + "@types/chai@^4.2.22": version "4.2.22" resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.22.tgz#47020d7e4cf19194d43b5202f35f75bd2ad35ce7" @@ -843,6 +876,14 @@ dependencies: "@types/node" "*" +"@types/jira-client@^7.1.0": + version "7.1.0" + resolved "https://registry.yarnpkg.com/@types/jira-client/-/jira-client-7.1.0.tgz#f3352630eae8e19d9f90ff618b080ad9b5d82420" + integrity sha512-KU4YGAD6ls57YL9SRtOmsBSsT20rBJg/PRvVqupGGeaMiGKI+ovWU1B0/Y534wgIyfVT26LEDpVrPRU1eLJXNw== + dependencies: + "@types/node" "*" + "@types/request" "*" + "@types/json-schema@^7.0.9": version "7.0.9" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d" @@ -937,6 +978,16 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== +"@types/request@*": + version "2.48.7" + resolved "https://registry.yarnpkg.com/@types/request/-/request-2.48.7.tgz#a962d11a26e0d71d9a9913d96bb806dc4d4c2f19" + integrity sha512-GWP9AZW7foLd4YQxyFZDBepl0lPsWLMEXDZUjQ/c1gqVPDPECrRZyEzuhJdnPWioFCq3Tv0qoGpMD6U+ygd4ZA== + dependencies: + "@types/caseless" "*" + "@types/node" "*" + "@types/tough-cookie" "*" + form-data "^2.5.0" + "@types/resolve@1.17.1": version "1.17.1" resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6" @@ -959,6 +1010,11 @@ "@types/mime" "^1" "@types/node" "*" +"@types/tough-cookie@*": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.1.tgz#8f80dd965ad81f3e1bc26d6f5c727e132721ff40" + integrity sha512-Y0K95ThC3esLEYD6ZuqNek29lNX2EM1qxV8y2FTLUB0ff5wWrk7az+mLrnNFUnaXcgKye22+sFBRXOgpPILZNg== + "@types/uuid@^8.3.3": version "8.3.3" resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.3.tgz#c6a60686d953dbd1b1d45e66f4ecdbd5d471b4d0" @@ -1250,7 +1306,7 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== -base64-js@^1.3.1: +base64-js@^1.1.2, base64-js@^1.3.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== @@ -1310,6 +1366,11 @@ bl@^4.1.0: inherits "^2.0.4" readable-stream "^3.4.0" +bluebird@^2.6.2: + version "2.11.0" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-2.11.0.tgz#534b9033c022c9579c56ba3b3e5a5caafbb650e1" + integrity sha1-U0uQM8AiyVecVro7Plpcqvu2UOE= + bluebird@^3.5.0: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" @@ -1358,6 +1419,13 @@ braces@^3.0.1, braces@~3.0.2: dependencies: fill-range "^7.0.1" +brotli@~1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/brotli/-/brotli-1.3.2.tgz#525a9cad4fcba96475d7d388f6aecb13eed52f46" + integrity sha1-UlqcrU/LqWR119OI9q7LE+7VL0Y= + dependencies: + base64-js "^1.1.2" + browser-stdout@1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" @@ -1702,7 +1770,7 @@ colorspace@1.1.x: color "^3.1.3" text-hex "1.0.x" -combined-stream@^1.0.6, combined-stream@~1.0.6: +combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== @@ -2541,6 +2609,15 @@ forever-agent@~0.6.1: resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= +form-data@^2.5.0: + version "2.5.1" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4" + integrity sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.6" + mime-types "^2.1.12" + form-data@~2.3.2: version "2.3.3" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" @@ -2877,6 +2954,15 @@ http-signature@~1.2.0: jsprim "^1.2.2" sshpk "^1.7.0" +http-signature@~1.3.1: + version "1.3.6" + resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.3.6.tgz#cb6fbfdf86d1c974f343be94e87f7fc128662cf9" + integrity sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw== + dependencies: + assert-plus "^1.0.0" + jsprim "^2.0.2" + sshpk "^1.14.1" + http2-wrapper@^1.0.0-beta.5.2: version "1.0.3" resolved "https://registry.yarnpkg.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz#b8f55e0c1f25d4ebd08b3b0c2c079f9590800b3d" @@ -3198,6 +3284,14 @@ isstream@~0.1.2: resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= +jira-client@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/jira-client/-/jira-client-8.0.0.tgz#ce00c964f2aeed1f4a0bf8ed28bc3fe6f045216e" + integrity sha512-vei9WuUboBAcQRxO6b5SwO5JXEyC1Y0ydiW4JiD7tPYc65wjXSbZvGDtQWsz7IOssFG/BQNAcJdiS79qVwnAJw== + dependencies: + "@babel/runtime" "^7.6.0" + postman-request "^2.88.1-postman.30" + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -3247,6 +3341,11 @@ json-schema@0.2.3: resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= +json-schema@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" + integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== + json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" @@ -3312,6 +3411,16 @@ jsprim@^1.2.2: json-schema "0.2.3" verror "1.10.0" +jsprim@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-2.0.2.tgz#77ca23dbcd4135cd364800d22ff82c2185803d4d" + integrity sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ== + dependencies: + assert-plus "1.0.0" + extsprintf "1.3.0" + json-schema "0.4.0" + verror "1.10.0" + just-diff-apply@^3.0.0: version "3.1.2" resolved "https://registry.yarnpkg.com/just-diff-apply/-/just-diff-apply-3.1.2.tgz#710d8cda00c65dc4e692df50dbe9bac5581c2193" @@ -4375,6 +4484,34 @@ postcss@^8.3.11, postcss@^8.3.5: picocolors "^1.0.0" source-map-js "^0.6.2" +postman-request@^2.88.1-postman.30: + version "2.88.1-postman.30" + resolved "https://registry.yarnpkg.com/postman-request/-/postman-request-2.88.1-postman.30.tgz#44554fd3c19bf19e0a583e92b654d3c4f7e4669e" + integrity sha512-zsGvs8OgNeno1Q44zTgGP2IL7kCqUy4DAtl8/ms0AQpqkIoysrxzR/Zg4kM1Kz8/duBvwxt8NN717wB7SMNm6w== + dependencies: + "@postman/form-data" "~3.1.1" + "@postman/tunnel-agent" "^0.6.3" + aws-sign2 "~0.7.0" + aws4 "^1.8.0" + brotli "~1.3.2" + caseless "~0.12.0" + combined-stream "~1.0.6" + extend "~3.0.2" + forever-agent "~0.6.1" + har-validator "~5.1.3" + http-signature "~1.3.1" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.19" + oauth-sign "~0.9.0" + performance-now "^2.1.0" + qs "~6.5.2" + safe-buffer "^5.1.2" + stream-length "^1.0.2" + tough-cookie "~2.5.0" + uuid "^3.3.2" + preact@^10.5.15: version "10.5.15" resolved "https://registry.yarnpkg.com/preact/-/preact-10.5.15.tgz#6df94d8afecf3f9e10a742fd8c362ddab464225f" @@ -4577,6 +4714,11 @@ reflect-metadata@^0.1.13: resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08" integrity sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg== +regenerator-runtime@^0.13.4: + version "0.13.9" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52" + integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA== + regexpp@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2" @@ -4981,7 +5123,7 @@ sourcemap-codec@^1.4.4: resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== -sshpk@^1.7.0: +sshpk@^1.14.1, sshpk@^1.7.0: version "1.16.1" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg== @@ -5030,6 +5172,13 @@ steno@^0.4.1: dependencies: graceful-fs "^4.1.3" +stream-length@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/stream-length/-/stream-length-1.0.2.tgz#8277f3cbee49a4daabcfdb4e2f4a9b5e9f2c9f00" + integrity sha1-gnfzy+5JpNqrz9tOL0qbXp8snwA= + dependencies: + bluebird "^2.6.2" + string-argv@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da"