diff --git a/.env.example b/.env.example index 257d9c3b5a0..f1a512bd284 100644 --- a/.env.example +++ b/.env.example @@ -122,8 +122,11 @@ REDIS_URL='redis://localhost:6379' # STRIPE_SECRET_KEY='' # STRIPE_PUBLISHABLE_KEY='' # STRIPE_WEBHOOK_SECRET='' +# Disable the built in Mattermost webhook integration # MATTERMOST_DISABLED='false' +# For private instances with SSO and the Mattermost plugin, set the secret and URL # MATTERMOST_SECRET='' +# MATTERMOST_URL='' # MSTEAMS_DISABLED='false' # MAIL diff --git a/packages/server/graphql/mutations/helpers/notifications/MattermostNotifier.ts b/packages/server/graphql/mutations/helpers/notifications/MattermostNotifier.ts index c4caa787d90..a90e6141055 100644 --- a/packages/server/graphql/mutations/helpers/notifications/MattermostNotifier.ts +++ b/packages/server/graphql/mutations/helpers/notifications/MattermostNotifier.ts @@ -31,14 +31,17 @@ const notifyMattermost = async ( textOrAttachmentsArray: string | unknown[], notificationText?: string ) => { - const {webhookUrl} = channel - if (!webhookUrl) { + const {webhookUrl, serverBaseUrl, sharedSecret} = channel + const notifyUrl = serverBaseUrl + ? `${serverBaseUrl}/plugins/co.parabol.action/notify/${teamId}` + : webhookUrl + if (!notifyUrl) { return 'success' } - const manager = new MattermostServerManager(webhookUrl) + const manager = new MattermostServerManager(notifyUrl, sharedSecret ?? undefined) const result = await manager.postMessage(textOrAttachmentsArray, notificationText) if (result instanceof Error) { - sendToSentry(result, {userId: user.id, tags: {teamId, event, webhookUrl}}) + sendToSentry(result, {userId: user.id, tags: {teamId, event, notifyUrl}}) return { error: result } @@ -91,10 +94,13 @@ const makeEndMeetingButtons = (meeting: AnyMeeting) => { } } -type MattermostNotificationAuth = IntegrationProviderMattermost & { +type MattermostNotificationAuth = { userId: string teamId: string channel: string | null + webhookUrl: string | null + serverBaseUrl: string | null + sharedSecret: string | null } const makeTeamPromptStartMeetingNotification = ( @@ -347,6 +353,19 @@ const MattermostNotificationHelper: NotificationIntegrationHelper { return JSON.stringify(rawObject) } +const gql = (strings: TemplateStringsArray) => strings.join('') + const eventLookup: Record< string, { @@ -29,7 +31,7 @@ const eventLookup: Record< } > = { meetingTemplates: { - query: ` + query: gql` query MeetingTemplates { viewer { availableTemplates(first: 2000) { @@ -37,6 +39,7 @@ const eventLookup: Record< node { id name + bad type illustrationUrl orgId @@ -52,7 +55,7 @@ const eventLookup: Record< retroSettings: meetingSettings(meetingType: retrospective) { id phaseTypes - ...on RetrospectiveMeetingSettings { + ... on RetrospectiveMeetingSettings { disableAnonymity } } @@ -77,11 +80,8 @@ const eventLookup: Record< } }, startRetrospective: { - query: ` - mutation StartRetrospective( - $teamId: ID! - $templateId: ID! - ) { + query: gql` + mutation StartRetrospective($teamId: ID!, $templateId: ID!) { selectTemplate(selectedTemplateId: $templateId, teamId: $teamId) { meetingSettings { id @@ -98,7 +98,7 @@ const eventLookup: Record< ` }, startCheckIn: { - query: ` + query: gql` mutation StartCheckIn($teamId: ID!) { startCheckIn(teamId: $teamId) { ... on ErrorPayload { @@ -116,11 +116,8 @@ const eventLookup: Record< ` }, startSprintPoker: { - query: ` - mutation StartSprintPokerMutation( - $teamId: ID! - $templateId: ID! - ) { + query: gql` + mutation StartSprintPokerMutation($teamId: ID!, $templateId: ID!) { selectTemplate(selectedTemplateId: $templateId, teamId: $teamId) { meetingSettings { id @@ -142,17 +139,15 @@ const eventLookup: Record< ` }, startTeamPrompt: { - query: ` - mutation StartTeamPromptMutation( - $teamId: ID! - ) { + query: gql` + mutation StartTeamPromptMutation($teamId: ID!) { startTeamPrompt(teamId: $teamId) { ... on ErrorPayload { error { message } } - ...on StartTeamPromptSuccess { + ... on StartTeamPromptSuccess { meeting { id } @@ -162,14 +157,14 @@ const eventLookup: Record< ` }, getMeetingSettings: { - query: ` + query: gql` query GetMeetingSettings($teamId: ID!, $meetingType: MeetingTypeEnum!) { viewer { team(teamId: $teamId) { meetingSettings(meetingType: $meetingType) { id phaseTypes - ...on RetrospectiveMeetingSettings { + ... on RetrospectiveMeetingSettings { disableAnonymity } } @@ -188,7 +183,7 @@ const eventLookup: Record< } }, setMeetingSettings: { - query: ` + query: gql` mutation SetMeetingSettings( $id: ID! $checkinEnabled: Boolean @@ -210,7 +205,7 @@ const eventLookup: Record< } } } - `, + `, convertResult: (data: any) => { const {settings: meetingSettings} = data.setMeetingSettings return { @@ -222,7 +217,7 @@ const eventLookup: Record< } }, getActiveMeetings: { - query: ` + query: gql` query Meetings { viewer { teams { @@ -231,9 +226,9 @@ const eventLookup: Record< teamId name meetingType - ...on RetrospectiveMeeting { + ... on RetrospectiveMeeting { phases { - ...on ReflectPhase { + ... on ReflectPhase { reflectPrompts { id question @@ -276,7 +271,7 @@ const eventLookup: Record< } } }, - query: ` + query: gql` mutation CreateReflectionMutation($input: CreateReflectionInput!) { createReflection(input: $input) { reflectionId diff --git a/packages/server/utils/MattermostServerManager.ts b/packages/server/utils/MattermostServerManager.ts index be383baf670..8f47c0bd1a0 100644 --- a/packages/server/utils/MattermostServerManager.ts +++ b/packages/server/utils/MattermostServerManager.ts @@ -1,3 +1,4 @@ +import {createSigner, httpbis} from 'http-message-signatures' // Mattermost is a server-only integration for now, unlike the Slack integration // We're following a similar manager pattern here should we wish to refactor the @@ -15,11 +16,13 @@ export interface MattermostApiResponse { abstract class MattermostManager { webhookUrl: string + secret?: string abstract fetch: typeof fetch headers: any - constructor(webhookUrl: string) { + constructor(webhookUrl: string, secret?: string) { this.webhookUrl = webhookUrl + this.secret = secret } private fetchWithTimeout = async (url: string, options: RequestInit) => { @@ -41,13 +44,41 @@ abstract class MattermostManager { // See: https://developers.mattermost.com/integrate/incoming-webhooks/ private async post(payload: any) { - const res = await this.fetchWithTimeout(this.webhookUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json' - }, - body: JSON.stringify(payload) + const url = this.webhookUrl + const method = 'POST' + const body = JSON.stringify(payload) + const digestArray = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body)) + const digest = Array.from(new Uint8Array(digestArray)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + const headers = { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'Content-Digest': 'SHA-256=' + digest + } as Record + + if (this.secret) { + const key = createSigner(this.secret, 'hmac-sha256', 'foo') + const signedRequest = await httpbis.signMessage( + { + key, + name: 'parabol', + fields: ['@request-target', 'content-digest'] + }, + { + method, + url, + headers, + body + } + ) + headers['Signature'] = signedRequest.headers['Signature']! + headers['Signature-Input'] = signedRequest.headers['Signature-Input']! + } + const res = await this.fetchWithTimeout(url, { + method, + headers, + body }) if (res instanceof Error) return res if (res.status !== 200) { @@ -86,8 +117,8 @@ abstract class MattermostManager { class MattermostServerManager extends MattermostManager { fetch = fetch - constructor(webhookUrl: string) { - super(webhookUrl) + constructor(webhookUrl: string, secret?: string) { + super(webhookUrl, secret) } }