Update language on /outline help text

Update Slack hooks to use zod validation

closes #5768
This commit is contained in:
Tom Moor
2023-09-04 10:40:46 -04:00
parent 02cced078f
commit 1df7a42868
4 changed files with 392 additions and 315 deletions

View File

@@ -271,6 +271,7 @@ describe("#hooks.interactive", () => {
teamId: user.teamId, teamId: user.teamId,
}); });
const payload = JSON.stringify({ const payload = JSON.stringify({
type: "message_action",
token: env.SLACK_VERIFICATION_TOKEN, token: env.SLACK_VERIFICATION_TOKEN,
user: { user: {
id: user.authentications[0].providerId, id: user.authentications[0].providerId,
@@ -300,6 +301,7 @@ describe("#hooks.interactive", () => {
teamId: user.teamId, teamId: user.teamId,
}); });
const payload = JSON.stringify({ const payload = JSON.stringify({
type: "message_action",
token: env.SLACK_VERIFICATION_TOKEN, token: env.SLACK_VERIFICATION_TOKEN,
user: { user: {
id: "unknown-slack-user-id", id: "unknown-slack-user-id",
@@ -324,6 +326,7 @@ describe("#hooks.interactive", () => {
it("should error if incorrect verification token", async () => { it("should error if incorrect verification token", async () => {
const { user } = await seed(); const { user } = await seed();
const payload = JSON.stringify({ const payload = JSON.stringify({
type: "message_action",
token: "wrong-verification-token", token: "wrong-verification-token",
user: { user: {
id: user.authentications[0].providerId, id: user.authentications[0].providerId,

View File

@@ -2,10 +2,16 @@ import { t } from "i18next";
import Router from "koa-router"; import Router from "koa-router";
import escapeRegExp from "lodash/escapeRegExp"; import escapeRegExp from "lodash/escapeRegExp";
import { Op } from "sequelize"; import { Op } from "sequelize";
import { z } from "zod";
import { IntegrationService } from "@shared/types"; import { IntegrationService } from "@shared/types";
import env from "@server/env"; 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 Logger from "@server/logging/Logger";
import validate from "@server/middlewares/validate";
import { import {
UserAuthentication, UserAuthentication,
AuthenticationProvider, AuthenticationProvider,
@@ -20,9 +26,9 @@ import SearchHelper from "@server/models/helpers/SearchHelper";
import { APIContext } from "@server/types"; import { APIContext } from "@server/types";
import { safeEqual } from "@server/utils/crypto"; import { safeEqual } from "@server/utils/crypto";
import { opts } from "@server/utils/i18n"; import { opts } from "@server/utils/i18n";
import { assertPresent } from "@server/validation";
import presentMessageAttachment from "../presenters/messageAttachment"; import presentMessageAttachment from "../presenters/messageAttachment";
import * as Slack from "../slack"; import * as Slack from "../slack";
import * as T from "./schema";
const router = new Router(); const router = new Router();
@@ -39,342 +45,366 @@ function verifySlackToken(token: string) {
} }
// triggered by a user posting a getoutline.com link in Slack // triggered by a user posting a getoutline.com link in Slack
router.post("hooks.unfurl", async (ctx: APIContext) => { router.post(
const { challenge, token, event } = ctx.request.body; "hooks.unfurl",
validate(T.HooksUnfurlSchema),
// See URL verification handshake documentation on this page: async (ctx: APIContext<T.HooksUnfurlReq>) => {
// https://api.slack.com/apis/connections/events-api // See URL verification handshake documentation on this page:
if (challenge) { // https://api.slack.com/apis/connections/events-api
ctx.body = { if ("challenge" in ctx.input.body) {
challenge, ctx.body = {
}; challenge: ctx.input.body.challenge,
return; };
} 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;
} }
unfurls[link.url] = {
title: doc.title,
text: doc.getSummary(),
color: doc.collection?.color,
};
}
await Slack.post("chat.unfurl", { const { token, event } = ctx.input.body;
token: auth.token, verifySlackToken(token);
channel: event.channel,
ts: event.message_ts,
unfurls,
});
ctx.body = { const user = await User.findOne({
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,
},
include: [ include: [
{ {
where: { where: {
teamId: team.id, providerId: event.user,
}, },
model: User, model: UserAuthentication,
as: "user", 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<T.HooksInteractiveReq>) => {
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<T.HooksSlackCommandReq>) => {
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, required: true,
}, },
], ],
}); });
if (authentication) { if (team) {
user = authentication.user; const authentication = await UserAuthentication.findOne({
} where: {
} else { providerId: user_id,
// 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: [
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 }}. \nYouve 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 couldnt 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({
where: { where: {
email: response.user.profile.email,
teamId: team.id, teamId: team.id,
}, },
}); model: User,
} as: "user",
} catch (err) { required: true,
// 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.", 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 = { // Handle "help" command or no input
limit: 5, if (text.trim() === "help" || !text.trim()) {
}; ctx.body = {
response_type: "ephemeral",
// If we were able to map the request to a user then we can use their permissions text: t("How to use {{ command }}", {
// to load more documents based on the collections they have access to. Otherwise command: "/outline",
// just a generic search against team-visible documents is allowed. ...opts(user),
const { results, totalCount } = user }),
? await SearchHelper.searchForUser(user, text, options) attachments: [
: await SearchHelper.searchForTeam(team, text, options); {
text: t(
void SearchQuery.create({ "To search your knowledgebase use {{ command }}. \nType {{ command2 }} help to display this help text.",
userId: user ? user.id : null, {
teamId: team.id, command: `/outline keyword`,
source: "slack", command2: `/outline help`,
query: text, ...opts(user),
results: totalCount, }
}).catch((err) => { ),
Logger.error("Failed to create search query", err); },
}); ],
};
const haventSignedIn = t( return;
`It looks like you havent 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 = { // This should be super rare, how does someone end up being able to make a valid
text: user // request from Slack that connects to no teams in Outline.
? t(`This is what we found for "{{ term }}"`, { if (!team) {
ctx.body = {
response_type: "ephemeral",
text: t(
`Sorry, we couldnt find an integration for your team. Head to your {{ appName }} settings to set one up.`,
{
...opts(user), ...opts(user),
term: text, appName: env.APP_NAME,
}) }
: t(`This is what we found for "{{ term }}"`, { ),
term: text, };
}) + ` (${haventSignedIn})…`, return;
attachments, }
};
} else { // Try to find the user by matching the email address if it is confirmed on
ctx.body = { // Slack's side. It's always trusted on our side as it is only updatable
text: user // through the authentication provider.
? t(`No results for "{{ term }}"`, { if (!user) {
...opts(user), const auth = await IntegrationAuthentication.findOne({
term: text, where: {
}) scopes: { [Op.contains]: ["identity.email"] },
: t(`No results for "{{ term }}"`, { term: text }) + service: IntegrationService.Slack,
` (${haventSignedIn})…`, 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 havent 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; export default router;

View File

@@ -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<typeof HooksUnfurlSchema>;
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<typeof HooksSlackCommandSchema>;
export const HooksInteractiveSchema = z.object({
body: z.object({
payload: z.string(),
}),
});
export type HooksInteractiveReq = z.infer<typeof HooksInteractiveSchema>;

View File

@@ -906,7 +906,7 @@
"Connect": "Connect", "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.", "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 }}", "How to use {{ command }}": "How to use {{ command }}",
"To search your knowledgebase use {{ command }}. \nYouve already learned how to get help with {{ command2 }}.": "To search your knowledgebase use {{ command }}. \nYouve 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 couldnt find an integration for your team. Head to your {{ appName }} settings to set one up.": "Sorry, we couldnt find an integration for your team. Head to your {{ appName }} settings to set one up.", "Sorry, we couldnt find an integration for your team. Head to your {{ appName }} settings to set one up.": "Sorry, we couldnt find an integration for your team. Head to your {{ appName }} settings to set one up.",
"It looks like you havent signed in to {{ appName }} yet, so results may be limited": "It looks like you havent signed in to {{ appName }} yet, so results may be limited", "It looks like you havent signed in to {{ appName }} yet, so results may be limited": "It looks like you havent signed in to {{ appName }} yet, so results may be limited",
"Post to Channel": "Post to Channel", "Post to Channel": "Post to Channel",