From 1df7a428681a369e89c52daae7287c85bd8ef959 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Mon, 4 Sep 2023 10:40:46 -0400 Subject: [PATCH] Update language on /outline help text Update Slack hooks to use zod validation closes #5768 --- plugins/slack/server/api/hooks.test.ts | 3 + plugins/slack/server/api/hooks.ts | 658 +++++++++++---------- plugins/slack/server/api/schema.ts | 44 ++ shared/i18n/locales/en_US/translation.json | 2 +- 4 files changed, 392 insertions(+), 315 deletions(-) create mode 100644 plugins/slack/server/api/schema.ts diff --git a/plugins/slack/server/api/hooks.test.ts b/plugins/slack/server/api/hooks.test.ts index 851ffac3c..71fa88375 100644 --- a/plugins/slack/server/api/hooks.test.ts +++ b/plugins/slack/server/api/hooks.test.ts @@ -271,6 +271,7 @@ describe("#hooks.interactive", () => { teamId: user.teamId, }); const payload = JSON.stringify({ + type: "message_action", token: env.SLACK_VERIFICATION_TOKEN, user: { id: user.authentications[0].providerId, @@ -300,6 +301,7 @@ describe("#hooks.interactive", () => { teamId: user.teamId, }); const payload = JSON.stringify({ + type: "message_action", token: env.SLACK_VERIFICATION_TOKEN, user: { id: "unknown-slack-user-id", @@ -324,6 +326,7 @@ describe("#hooks.interactive", () => { it("should error if incorrect verification token", async () => { const { user } = await seed(); const payload = JSON.stringify({ + type: "message_action", token: "wrong-verification-token", user: { id: user.authentications[0].providerId, diff --git a/plugins/slack/server/api/hooks.ts b/plugins/slack/server/api/hooks.ts index afa786581..0a426210b 100644 --- a/plugins/slack/server/api/hooks.ts +++ b/plugins/slack/server/api/hooks.ts @@ -2,10 +2,16 @@ import { t } from "i18next"; import Router from "koa-router"; import escapeRegExp from "lodash/escapeRegExp"; import { Op } from "sequelize"; +import { z } from "zod"; import { IntegrationService } from "@shared/types"; import env from "@server/env"; -import { AuthenticationError, InvalidRequestError } from "@server/errors"; +import { + AuthenticationError, + InvalidRequestError, + ValidationError, +} from "@server/errors"; import Logger from "@server/logging/Logger"; +import validate from "@server/middlewares/validate"; import { UserAuthentication, AuthenticationProvider, @@ -20,9 +26,9 @@ import SearchHelper from "@server/models/helpers/SearchHelper"; import { APIContext } from "@server/types"; import { safeEqual } from "@server/utils/crypto"; import { opts } from "@server/utils/i18n"; -import { assertPresent } from "@server/validation"; import presentMessageAttachment from "../presenters/messageAttachment"; import * as Slack from "../slack"; +import * as T from "./schema"; const router = new Router(); @@ -39,342 +45,366 @@ function verifySlackToken(token: string) { } // triggered by a user posting a getoutline.com link in Slack -router.post("hooks.unfurl", async (ctx: APIContext) => { - const { challenge, token, event } = ctx.request.body; - - // See URL verification handshake documentation on this page: - // https://api.slack.com/apis/connections/events-api - if (challenge) { - ctx.body = { - challenge, - }; - return; - } - - assertPresent(token, "token is required"); - verifySlackToken(token); - - const user = await User.findOne({ - include: [ - { - where: { - providerId: event.user, - }, - model: UserAuthentication, - as: "authentications", - required: true, - separate: true, - }, - ], - }); - if (!user) { - return; - } - const auth = await IntegrationAuthentication.findOne({ - where: { - service: IntegrationService.Slack, - teamId: user.teamId, - }, - }); - if (!auth) { - return; - } - // get content for unfurled links - const unfurls = {}; - - for (const link of event.links) { - const id = link.url.slice(link.url.lastIndexOf("/") + 1); - const doc = await Document.findByPk(id); - if (!doc || doc.teamId !== user.teamId) { - continue; +router.post( + "hooks.unfurl", + validate(T.HooksUnfurlSchema), + async (ctx: APIContext) => { + // See URL verification handshake documentation on this page: + // https://api.slack.com/apis/connections/events-api + if ("challenge" in ctx.input.body) { + ctx.body = { + challenge: ctx.input.body.challenge, + }; + return; } - unfurls[link.url] = { - title: doc.title, - text: doc.getSummary(), - color: doc.collection?.color, - }; - } - await Slack.post("chat.unfurl", { - token: auth.token, - channel: event.channel, - ts: event.message_ts, - unfurls, - }); + const { token, event } = ctx.input.body; + verifySlackToken(token); - ctx.body = { - success: true, - }; -}); - -// triggered by interactions with actions, dialogs, message buttons in Slack -router.post("hooks.interactive", async (ctx: APIContext) => { - const { payload } = ctx.request.body; - assertPresent(payload, "payload is required"); - - const data = JSON.parse(payload); - const { callback_id, token } = data; - - assertPresent(token, "token is required"); - assertPresent(callback_id, "callback_id is required"); - verifySlackToken(token); - - // we find the document based on the users teamId to ensure access - const document = await Document.scope("withCollection").findByPk( - data.callback_id - ); - - if (!document) { - throw InvalidRequestError("Invalid callback_id"); - } - - const team = await Team.findByPk(document.teamId, { rejectOnEmpty: true }); - - // respond with a public message that will be posted in the original channel - ctx.body = { - response_type: "in_channel", - replace_original: false, - attachments: [ - presentMessageAttachment( - document, - team, - document.collection, - document.getSummary() - ), - ], - }; -}); - -// triggered by the /outline command in Slack -router.post("hooks.slack", async (ctx: APIContext) => { - const { token, team_id, user_id, text = "" } = ctx.request.body; - assertPresent(token, "token is required"); - assertPresent(team_id, "team_id is required"); - assertPresent(user_id, "user_id is required"); - verifySlackToken(token); - - let user, team; - - // attempt to find the corresponding team for this request based on the team_id - team = await Team.findOne({ - include: [ - { - where: { - name: "slack", - providerId: team_id, - enabled: true, - }, - as: "authenticationProviders", - model: AuthenticationProvider, - required: true, - }, - ], - }); - - if (team) { - const authentication = await UserAuthentication.findOne({ - where: { - providerId: user_id, - }, + const user = await User.findOne({ include: [ { where: { - teamId: team.id, + providerId: event.user, }, - model: User, - as: "user", + model: UserAuthentication, + as: "authentications", + required: true, + separate: true, + }, + ], + }); + if (!user) { + return; + } + const auth = await IntegrationAuthentication.findOne({ + where: { + service: IntegrationService.Slack, + teamId: user.teamId, + }, + }); + if (!auth) { + return; + } + // get content for unfurled links + const unfurls = {}; + + for (const link of event.links) { + const id = link.url.slice(link.url.lastIndexOf("/") + 1); + const doc = await Document.findByPk(id); + if (!doc || doc.teamId !== user.teamId) { + continue; + } + unfurls[link.url] = { + title: doc.title, + text: doc.getSummary(), + color: doc.collection?.color, + }; + } + + await Slack.post("chat.unfurl", { + token: auth.token, + channel: event.channel, + ts: event.message_ts, + unfurls, + }); + + ctx.body = { + success: true, + }; + } +); + +// triggered by interactions with actions, dialogs, message buttons in Slack +router.post( + "hooks.interactive", + validate(T.HooksInteractiveSchema), + async (ctx: APIContext) => { + const { payload } = ctx.input.body; + let callback_id, token; + + try { + // https://api.slack.com/interactivity/handling#payloads + const data = JSON.parse(payload); + + const parsed = z + .object({ + type: z.string(), + callback_id: z.string(), + token: z.string(), + }) + .parse(data); + + callback_id = parsed.callback_id; + token = parsed.token; + } catch (err) { + Logger.error("Failed to parse Slack interactive payload", err, { + payload, + }); + throw ValidationError("Invalid payload"); + } + + verifySlackToken(token); + + // we find the document based on the users teamId to ensure access + const document = await Document.scope("withCollection").findByPk( + callback_id + ); + + if (!document) { + throw InvalidRequestError("Invalid callback_id"); + } + + const team = await Team.findByPk(document.teamId, { rejectOnEmpty: true }); + + // respond with a public message that will be posted in the original channel + ctx.body = { + response_type: "in_channel", + replace_original: false, + attachments: [ + presentMessageAttachment( + document, + team, + document.collection, + document.getSummary() + ), + ], + }; + } +); + +// triggered by the /outline command in Slack +router.post( + "hooks.slack", + validate(T.HooksSlackCommandSchema), + async (ctx: APIContext) => { + const { token, team_id, user_id, text } = ctx.input.body; + verifySlackToken(token); + + let user, team; + + // attempt to find the corresponding team for this request based on the team_id + team = await Team.findOne({ + include: [ + { + where: { + name: "slack", + providerId: team_id, + enabled: true, + }, + as: "authenticationProviders", + model: AuthenticationProvider, required: true, }, ], }); - if (authentication) { - user = authentication.user; - } - } else { - // If we couldn't find a team it's still possible that the request is from - // a team that authenticated with a different service, but connected Slack - // via integration - const integration = await Integration.findOne({ - where: { - service: IntegrationService.Slack, - settings: { - serviceTeamId: team_id, + if (team) { + const authentication = await UserAuthentication.findOne({ + where: { + providerId: user_id, }, - }, - include: [ - { - model: Team, - as: "team", - }, - ], - }); - - if (integration) { - team = integration.team; - } - } - - // Handle "help" command or no input - if (text.trim() === "help" || !text.trim()) { - ctx.body = { - response_type: "ephemeral", - text: t("How to use {{ command }}", { - command: "/outline", - ...opts(user), - }), - attachments: [ - { - text: t( - "To search your knowledgebase use {{ command }}. \nYou’ve already learned how to get help with {{ command2 }}.", - { - command: `/outline keyword`, - command2: `/outline help`, - ...opts(user), - } - ), - }, - ], - }; - return; - } - - // This should be super rare, how does someone end up being able to make a valid - // request from Slack that connects to no teams in Outline. - if (!team) { - ctx.body = { - response_type: "ephemeral", - text: t( - `Sorry, we couldn’t find an integration for your team. Head to your {{ appName }} settings to set one up.`, - { - ...opts(user), - appName: env.APP_NAME, - } - ), - }; - return; - } - - // Try to find the user by matching the email address if it is confirmed on - // Slack's side. It's always trusted on our side as it is only updatable - // through the authentication provider. - if (!user) { - const auth = await IntegrationAuthentication.findOne({ - where: { - scopes: { [Op.contains]: ["identity.email"] }, - service: IntegrationService.Slack, - teamId: team.id, - }, - }); - - if (auth) { - try { - const response = await Slack.request("users.info", { - token: auth.token, - user: user_id, - }); - - if (response.user.is_email_confirmed && response.user.profile.email) { - user = await User.findOne({ + include: [ + { where: { - email: response.user.profile.email, teamId: team.id, }, - }); - } - } catch (err) { - // Old connections do not have the correct permissions to access user info - // so errors here are expected. - Logger.info( - "utils", - "Failed requesting users.info from Slack, the Slack integration should be reconnected.", + model: User, + as: "user", + required: true, + }, + ], + }); + + if (authentication) { + user = authentication.user; + } + } else { + // If we couldn't find a team it's still possible that the request is from + // a team that authenticated with a different service, but connected Slack + // via integration + const integration = await Integration.findOne({ + where: { + service: IntegrationService.Slack, + settings: { + serviceTeamId: team_id, + }, + }, + include: [ { - teamId: auth.teamId, - } - ); + model: Team, + as: "team", + }, + ], + }); + + if (integration) { + team = integration.team; } } - } - const options = { - limit: 5, - }; - - // If we were able to map the request to a user then we can use their permissions - // to load more documents based on the collections they have access to. Otherwise - // just a generic search against team-visible documents is allowed. - const { results, totalCount } = user - ? await SearchHelper.searchForUser(user, text, options) - : await SearchHelper.searchForTeam(team, text, options); - - void SearchQuery.create({ - userId: user ? user.id : null, - teamId: team.id, - source: "slack", - query: text, - results: totalCount, - }).catch((err) => { - Logger.error("Failed to create search query", err); - }); - - const haventSignedIn = t( - `It looks like you haven’t signed in to {{ appName }} yet, so results may be limited`, - { - ...opts(user), - appName: env.APP_NAME, - } - ); - - // Map search results to the format expected by the Slack API - if (results.length) { - const attachments = []; - - for (const result of results) { - const queryIsInTitle = !!result.document.title - .toLowerCase() - .match(escapeRegExp(text.toLowerCase())); - attachments.push( - presentMessageAttachment( - result.document, - team, - result.document.collection, - queryIsInTitle ? undefined : result.context, - env.SLACK_MESSAGE_ACTIONS - ? [ - { - name: "post", - text: t("Post to Channel", opts(user)), - type: "button", - value: result.document.id, - }, - ] - : undefined - ) - ); + // Handle "help" command or no input + if (text.trim() === "help" || !text.trim()) { + ctx.body = { + response_type: "ephemeral", + text: t("How to use {{ command }}", { + command: "/outline", + ...opts(user), + }), + attachments: [ + { + text: t( + "To search your knowledgebase use {{ command }}. \nType {{ command2 }} help to display this help text.", + { + command: `/outline keyword`, + command2: `/outline help`, + ...opts(user), + } + ), + }, + ], + }; + return; } - ctx.body = { - text: user - ? t(`This is what we found for "{{ term }}"`, { + // This should be super rare, how does someone end up being able to make a valid + // request from Slack that connects to no teams in Outline. + if (!team) { + ctx.body = { + response_type: "ephemeral", + text: t( + `Sorry, we couldn’t find an integration for your team. Head to your {{ appName }} settings to set one up.`, + { ...opts(user), - term: text, - }) - : t(`This is what we found for "{{ term }}"`, { - term: text, - }) + ` (${haventSignedIn})…`, - attachments, - }; - } else { - ctx.body = { - text: user - ? t(`No results for "{{ term }}"`, { - ...opts(user), - term: text, - }) - : t(`No results for "{{ term }}"`, { term: text }) + - ` (${haventSignedIn})…`, + appName: env.APP_NAME, + } + ), + }; + return; + } + + // Try to find the user by matching the email address if it is confirmed on + // Slack's side. It's always trusted on our side as it is only updatable + // through the authentication provider. + if (!user) { + const auth = await IntegrationAuthentication.findOne({ + where: { + scopes: { [Op.contains]: ["identity.email"] }, + service: IntegrationService.Slack, + teamId: team.id, + }, + }); + + if (auth) { + try { + const response = await Slack.request("users.info", { + token: auth.token, + user: user_id, + }); + + if (response.user.is_email_confirmed && response.user.profile.email) { + user = await User.findOne({ + where: { + email: response.user.profile.email, + teamId: team.id, + }, + }); + } + } catch (err) { + // Old connections do not have the correct permissions to access user info + // so errors here are expected. + Logger.info( + "utils", + "Failed requesting users.info from Slack, the Slack integration should be reconnected.", + { + teamId: auth.teamId, + } + ); + } + } + } + + const options = { + limit: 5, }; + + // If we were able to map the request to a user then we can use their permissions + // to load more documents based on the collections they have access to. Otherwise + // just a generic search against team-visible documents is allowed. + const { results, totalCount } = user + ? await SearchHelper.searchForUser(user, text, options) + : await SearchHelper.searchForTeam(team, text, options); + + void SearchQuery.create({ + userId: user ? user.id : null, + teamId: team.id, + source: "slack", + query: text, + results: totalCount, + }).catch((err) => { + Logger.error("Failed to create search query", err); + }); + + const haventSignedIn = t( + `It looks like you haven’t signed in to {{ appName }} yet, so results may be limited`, + { + ...opts(user), + appName: env.APP_NAME, + } + ); + + // Map search results to the format expected by the Slack API + if (results.length) { + const attachments = []; + + for (const result of results) { + const queryIsInTitle = !!result.document.title + .toLowerCase() + .match(escapeRegExp(text.toLowerCase())); + attachments.push( + presentMessageAttachment( + result.document, + team, + result.document.collection, + queryIsInTitle ? undefined : result.context, + env.SLACK_MESSAGE_ACTIONS + ? [ + { + name: "post", + text: t("Post to Channel", opts(user)), + type: "button", + value: result.document.id, + }, + ] + : undefined + ) + ); + } + + ctx.body = { + text: user + ? t(`This is what we found for "{{ term }}"`, { + ...opts(user), + term: text, + }) + : t(`This is what we found for "{{ term }}"`, { + ...opts(user), + term: text, + }) + ` (${haventSignedIn})…`, + attachments, + }; + } else { + ctx.body = { + text: user + ? t(`No results for "{{ term }}"`, { + ...opts(user), + term: text, + }) + : t(`No results for "{{ term }}"`, { ...opts(user), term: text }) + + ` (${haventSignedIn})…`, + }; + } } -}); +); export default router; diff --git a/plugins/slack/server/api/schema.ts b/plugins/slack/server/api/schema.ts new file mode 100644 index 000000000..acea75cca --- /dev/null +++ b/plugins/slack/server/api/schema.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; + +export const HooksUnfurlSchema = z.object({ + body: z + .object({ + challenge: z.string(), + }) + .or( + z.object({ + token: z.string(), + event: z.object({ + channel: z.string(), + message_ts: z.string(), + links: z.array( + z.object({ + url: z.string(), + }) + ), + user: z.string(), + }), + }) + ), +}); + +export type HooksUnfurlReq = z.infer; + +export const HooksSlackCommandSchema = z.object({ + body: z.object({ + token: z.string(), + team_id: z.string(), + user_id: z.string(), + text: z.string().optional().default(""), + }), +}); + +export type HooksSlackCommandReq = z.infer; + +export const HooksInteractiveSchema = z.object({ + body: z.object({ + payload: z.string(), + }), +}); + +export type HooksInteractiveReq = z.infer; diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index f55ad02bc..7b14da6f2 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -906,7 +906,7 @@ "Connect": "Connect", "The Slack integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.": "The Slack integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.", "How to use {{ command }}": "How to use {{ command }}", - "To search your knowledgebase use {{ command }}. \nYou’ve already learned how to get help with {{ command2 }}.": "To search your knowledgebase use {{ command }}. \nYou’ve already learned how to get help with {{ command2 }}.", + "To search your knowledgebase use {{ command }}. \nType {{ command2 }} help to display this help text.": "To search your knowledgebase use {{ command }}. \nType {{ command2 }} help to display this help text.", "Sorry, we couldn’t find an integration for your team. Head to your {{ appName }} settings to set one up.": "Sorry, we couldn’t find an integration for your team. Head to your {{ appName }} settings to set one up.", "It looks like you haven’t signed in to {{ appName }} yet, so results may be limited": "It looks like you haven’t signed in to {{ appName }} yet, so results may be limited", "Post to Channel": "Post to Channel",