mirror of
https://github.com/outline/outline.git
synced 2025-12-20 01:59:56 -06:00
407 lines
9.5 KiB
TypeScript
407 lines
9.5 KiB
TypeScript
import { t } from "i18next";
|
|
import Router from "koa-router";
|
|
import escapeRegExp from "lodash/escapeRegExp";
|
|
import { z } from "zod";
|
|
import { IntegrationService, IntegrationType } from "@shared/types";
|
|
import {
|
|
AuthenticationError,
|
|
InvalidRequestError,
|
|
ValidationError,
|
|
} from "@server/errors";
|
|
import Logger from "@server/logging/Logger";
|
|
import validate from "@server/middlewares/validate";
|
|
import {
|
|
UserAuthentication,
|
|
Document,
|
|
User,
|
|
Team,
|
|
SearchQuery,
|
|
Integration,
|
|
IntegrationAuthentication,
|
|
AuthenticationProvider,
|
|
} from "@server/models";
|
|
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 env from "../env";
|
|
import { presentMessageAttachment } from "../presenters/messageAttachment";
|
|
import { presentUserNotLinkedBlocks } from "../presenters/userNotLinkedBlocks";
|
|
import * as Slack from "../slack";
|
|
import * as T from "./schema";
|
|
|
|
const router = new Router();
|
|
|
|
// triggered by a user posting a getoutline.com link in Slack
|
|
router.post(
|
|
"hooks.unfurl",
|
|
validate(T.HooksUnfurlSchema),
|
|
async (ctx: APIContext<T.HooksUnfurlReq>) => {
|
|
// 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;
|
|
}
|
|
|
|
const { token, team_id, event } = ctx.input.body;
|
|
verifySlackToken(token);
|
|
|
|
const user = await findUserForRequest(team_id, event.user);
|
|
if (!user) {
|
|
Logger.debug("plugins", "No user found for Slack user ID", {
|
|
providerId: event.user,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const auth = await IntegrationAuthentication.findOne({
|
|
where: {
|
|
service: IntegrationService.Slack,
|
|
teamId: user.teamId,
|
|
},
|
|
});
|
|
|
|
if (!auth) {
|
|
Logger.debug(
|
|
"plugins",
|
|
"No Slack integration authentication found for team",
|
|
{
|
|
teamId: user.teamId,
|
|
}
|
|
);
|
|
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);
|
|
|
|
const user = await findUserForRequest(team_id, user_id);
|
|
|
|
// 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 workspace use {{ command }}. \nType {{ command2 }} help to display this help text.",
|
|
{
|
|
command: `/outline keyword`,
|
|
command2: `/outline`,
|
|
...opts(user),
|
|
}
|
|
),
|
|
},
|
|
],
|
|
};
|
|
return;
|
|
}
|
|
|
|
const options = {
|
|
limit: 5,
|
|
};
|
|
|
|
if (!user) {
|
|
const team = await findTeamForRequest(team_id);
|
|
|
|
ctx.body = {
|
|
response_type: "ephemeral",
|
|
blocks: presentUserNotLinkedBlocks(team),
|
|
};
|
|
return;
|
|
}
|
|
|
|
const { results, totalCount } = await SearchHelper.searchForUser(
|
|
user,
|
|
text,
|
|
options
|
|
);
|
|
|
|
await SearchQuery.create({
|
|
userId: user ? user.id : null,
|
|
teamId: user.teamId,
|
|
source: "slack",
|
|
query: text,
|
|
results: totalCount,
|
|
});
|
|
|
|
// 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,
|
|
user.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: t(`This is what we found for "{{ term }}"`, {
|
|
...opts(user),
|
|
term: text,
|
|
}),
|
|
attachments,
|
|
};
|
|
} else {
|
|
ctx.body = {
|
|
text: t(`No results for "{{ term }}"`, {
|
|
...opts(user),
|
|
term: text,
|
|
}),
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
function verifySlackToken(token: string) {
|
|
if (!env.SLACK_VERIFICATION_TOKEN) {
|
|
throw AuthenticationError(
|
|
"SLACK_VERIFICATION_TOKEN is not present in environment"
|
|
);
|
|
}
|
|
|
|
if (!safeEqual(env.SLACK_VERIFICATION_TOKEN, token)) {
|
|
throw AuthenticationError("Invalid token");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find a matching team for the given Slack team ID
|
|
*
|
|
* @param serviceTeamId The Slack team ID
|
|
* @returns A promise resolving to a matching team, if found
|
|
*/
|
|
async function findTeamForRequest(
|
|
serviceTeamId: string
|
|
): Promise<Team | undefined> {
|
|
const authenticationProvider = await AuthenticationProvider.findOne({
|
|
where: {
|
|
name: "slack",
|
|
providerId: serviceTeamId,
|
|
},
|
|
include: [
|
|
{
|
|
required: true,
|
|
model: Team,
|
|
as: "team",
|
|
},
|
|
],
|
|
});
|
|
|
|
if (authenticationProvider) {
|
|
return authenticationProvider.team;
|
|
}
|
|
|
|
const integration = await Integration.findOne({
|
|
where: {
|
|
service: IntegrationService.Slack,
|
|
type: IntegrationType.LinkedAccount,
|
|
settings: {
|
|
slack: {
|
|
serviceTeamId,
|
|
},
|
|
},
|
|
},
|
|
include: [
|
|
{
|
|
model: Team,
|
|
as: "team",
|
|
required: true,
|
|
},
|
|
],
|
|
});
|
|
|
|
if (integration) {
|
|
return integration.team;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
/**
|
|
* Find a matching user for the given Slack team and user ID
|
|
*
|
|
* @param serviceTeamId The Slack team ID
|
|
* @param serviceUserId The Slack user ID
|
|
* @returns A promise resolving to a matching user, if found
|
|
*/
|
|
async function findUserForRequest(
|
|
serviceTeamId: string,
|
|
serviceUserId: string
|
|
): Promise<User | undefined> {
|
|
// Prefer explicit linked account
|
|
const integration = await Integration.findOne({
|
|
where: {
|
|
service: IntegrationService.Slack,
|
|
type: IntegrationType.LinkedAccount,
|
|
settings: {
|
|
slack: {
|
|
serviceTeamId,
|
|
serviceUserId,
|
|
},
|
|
},
|
|
},
|
|
include: [
|
|
{
|
|
model: User,
|
|
as: "user",
|
|
required: true,
|
|
},
|
|
{
|
|
model: Team,
|
|
as: "team",
|
|
required: true,
|
|
},
|
|
],
|
|
order: [["createdAt", "DESC"]],
|
|
});
|
|
|
|
if (integration) {
|
|
integration.user.team = integration.team;
|
|
return integration.user;
|
|
}
|
|
|
|
// Fallback to authentication provider if the user has Slack sign-in
|
|
const user = await User.findOne({
|
|
include: [
|
|
{
|
|
where: {
|
|
providerId: serviceUserId,
|
|
},
|
|
order: [["createdAt", "DESC"]],
|
|
model: UserAuthentication,
|
|
as: "authentications",
|
|
required: true,
|
|
},
|
|
{
|
|
model: Team,
|
|
as: "team",
|
|
},
|
|
],
|
|
});
|
|
|
|
if (user) {
|
|
return user;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
export default router;
|