Plugin architecture (#4861)

* wip

* Refactor, tasks, processors, routes loading

* Move Slack settings config to plugin

* Fix translations in plugins

* Move Slack auth to plugin

* test

* Move other slack-related files into plugin

* Forgot to save

* refactor
This commit is contained in:
Tom Moor
2023-02-12 13:11:30 -05:00
committed by GitHub
parent 492beedf00
commit 33afa2f029
30 changed files with 273 additions and 117 deletions

View File

@@ -0,0 +1,22 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the current text color */
color?: string;
};
export default function Icon({ size = 24, color = "currentColor" }: Props) {
return (
<svg
fill={color}
width={size}
height={size}
viewBox="0 0 24 24"
version="1.1"
>
<path d="M7.36156352,14.1107492 C7.36156352,15.0358306 6.60586319,15.7915309 5.68078176,15.7915309 C4.75570033,15.7915309 4,15.0358306 4,14.1107492 C4,13.1856678 4.75570033,12.4299674 5.68078176,12.4299674 L7.36156352,12.4299674 L7.36156352,14.1107492 Z M8.20846906,14.1107492 C8.20846906,13.1856678 8.96416938,12.4299674 9.88925081,12.4299674 C10.8143322,12.4299674 11.5700326,13.1856678 11.5700326,14.1107492 L11.5700326,18.3192182 C11.5700326,19.2442997 10.8143322,20 9.88925081,20 C8.96416938,20 8.20846906,19.2442997 8.20846906,18.3192182 C8.20846906,18.3192182 8.20846906,14.1107492 8.20846906,14.1107492 Z M9.88925081,7.36156352 C8.96416938,7.36156352 8.20846906,6.60586319 8.20846906,5.68078176 C8.20846906,4.75570033 8.96416938,4 9.88925081,4 C10.8143322,4 11.5700326,4.75570033 11.5700326,5.68078176 L11.5700326,7.36156352 L9.88925081,7.36156352 Z M9.88925081,8.20846906 C10.8143322,8.20846906 11.5700326,8.96416938 11.5700326,9.88925081 C11.5700326,10.8143322 10.8143322,11.5700326 9.88925081,11.5700326 L5.68078176,11.5700326 C4.75570033,11.5700326 4,10.8143322 4,9.88925081 C4,8.96416938 4.75570033,8.20846906 5.68078176,8.20846906 C5.68078176,8.20846906 9.88925081,8.20846906 9.88925081,8.20846906 Z M16.6384365,9.88925081 C16.6384365,8.96416938 17.3941368,8.20846906 18.3192182,8.20846906 C19.2442997,8.20846906 20,8.96416938 20,9.88925081 C20,10.8143322 19.2442997,11.5700326 18.3192182,11.5700326 L16.6384365,11.5700326 L16.6384365,9.88925081 Z M15.7915309,9.88925081 C15.7915309,10.8143322 15.0358306,11.5700326 14.1107492,11.5700326 C13.1856678,11.5700326 12.4299674,10.8143322 12.4299674,9.88925081 L12.4299674,5.68078176 C12.4299674,4.75570033 13.1856678,4 14.1107492,4 C15.0358306,4 15.7915309,4.75570033 15.7915309,5.68078176 L15.7915309,9.88925081 Z M14.1107492,16.6384365 C15.0358306,16.6384365 15.7915309,17.3941368 15.7915309,18.3192182 C15.7915309,19.2442997 15.0358306,20 14.1107492,20 C13.1856678,20 12.4299674,19.2442997 12.4299674,18.3192182 L12.4299674,16.6384365 L14.1107492,16.6384365 Z M14.1107492,15.7915309 C13.1856678,15.7915309 12.4299674,15.0358306 12.4299674,14.1107492 C12.4299674,13.1856678 13.1856678,12.4299674 14.1107492,12.4299674 L18.3192182,12.4299674 C19.2442997,12.4299674 20,13.1856678 20,14.1107492 C20,15.0358306 19.2442997,15.7915309 18.3192182,15.7915309 L14.1107492,15.7915309 Z" />
</svg>
);
}

View File

@@ -0,0 +1,176 @@
import { find } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import styled from "styled-components";
import { IntegrationType } from "@shared/types";
import Collection from "~/models/Collection";
import Integration from "~/models/Integration";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import List from "~/components/List";
import ListItem from "~/components/List/Item";
import Notice from "~/components/Notice";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import env from "~/env";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import SlackIcon from "./Icon";
import SlackButton from "./components/SlackButton";
import SlackListItem from "./components/SlackListItem";
function Slack() {
const team = useCurrentTeam();
const { collections, integrations } = useStores();
const { t } = useTranslation();
const query = useQuery();
const error = query.get("error");
React.useEffect(() => {
collections.fetchPage({
limit: 100,
});
integrations.fetchPage({
limit: 100,
});
}, [collections, integrations]);
const commandIntegration = find(
integrations.slackIntegrations,
(i) => i.type === IntegrationType.Command
);
const groupedCollections = collections.orderedData
.map<[Collection, Integration | undefined]>((collection) => {
const integration = find(integrations.slackIntegrations, {
collectionId: collection.id,
});
return [collection, integration];
})
.sort((a) => (a[1] ? -1 : 1));
const appName = env.APP_NAME;
return (
<Scene title="Slack" icon={<SlackIcon color="currentColor" />}>
<Heading>Slack</Heading>
{error === "access_denied" && (
<Notice>
<Trans>
Whoops, you need to accept the permissions in Slack to connect
{{ appName }} to your team. Try again?
</Trans>
</Notice>
)}
{error === "unauthenticated" && (
<Notice>
<Trans>
Something went wrong while authenticating your request. Please try
logging in again?
</Trans>
</Notice>
)}
<Text type="secondary">
<Trans
defaults="Get rich previews of {{ appName }} links shared in Slack and use the <em>{{ command }}</em> slash command to search for documents without leaving your chat."
values={{
command: "/outline",
appName,
}}
components={{
em: <Code />,
}}
/>
</Text>
{env.SLACK_CLIENT_ID ? (
<>
<p>
{commandIntegration ? (
<Button onClick={() => commandIntegration.delete()}>
{t("Disconnect")}
</Button>
) : (
<SlackButton
scopes={[
"commands",
"links:read",
"links:write",
// TODO: Wait forever for Slack to approve these scopes.
//"users:read",
//"users:read.email",
]}
redirectUri={`${env.URL}/auth/slack.commands`}
state={team.id}
icon={<SlackIcon color="currentColor" />}
/>
)}
</p>
<p>&nbsp;</p>
<h2>{t("Collections")}</h2>
<Text type="secondary">
<Trans>
Connect {{ appName }} collections to Slack channels. Messages will
be automatically posted to Slack when documents are published or
updated.
</Trans>
</Text>
<List>
{groupedCollections.map(([collection, integration]) => {
if (integration) {
return (
<SlackListItem
key={integration.id}
collection={collection}
integration={
integration as Integration<IntegrationType.Post>
}
/>
);
}
return (
<ListItem
key={collection.id}
title={collection.name}
image={<CollectionIcon collection={collection} />}
actions={
<SlackButton
scopes={["incoming-webhook"]}
redirectUri={`${env.URL}/auth/slack.post`}
state={collection.id}
label={t("Connect")}
/>
}
/>
);
})}
</List>
</>
) : (
<Notice>
<Trans>
The Slack integration is currently disabled. Please set the
associated environment variables and restart the server to enable
the integration.
</Trans>
</Notice>
)}
</Scene>
);
}
const Code = styled.code`
padding: 4px 6px;
margin: 0 2px;
background: ${(props) => props.theme.codeBackground};
border-radius: 4px;
`;
export default observer(Slack);

View File

@@ -0,0 +1,38 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { slackAuth } from "@shared/utils/urlHelpers";
import Button from "~/components/Button";
import env from "~/env";
type Props = {
scopes?: string[];
redirectUri: string;
icon?: React.ReactNode;
state?: string;
label?: string;
};
function SlackButton({ state = "", scopes, redirectUri, label, icon }: Props) {
const { t } = useTranslation();
const handleClick = () => {
if (!env.SLACK_CLIENT_ID) {
return;
}
window.location.href = slackAuth(
state,
scopes,
env.SLACK_CLIENT_ID,
redirectUri
);
};
return (
<Button onClick={handleClick} icon={icon} neutral>
{label || t("Add to Slack")}
</Button>
);
}
export default SlackButton;

View File

@@ -0,0 +1,118 @@
import { uniq } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import styled from "styled-components";
import { IntegrationType } from "@shared/types";
import Collection from "~/models/Collection";
import Integration from "~/models/Integration";
import Button from "~/components/Button";
import ButtonLink from "~/components/ButtonLink";
import Flex from "~/components/Flex";
import CollectionIcon from "~/components/Icons/CollectionIcon";
import ListItem from "~/components/List/Item";
import Popover from "~/components/Popover";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import useToasts from "~/hooks/useToasts";
type Props = {
integration: Integration<IntegrationType.Post>;
collection: Collection;
};
function SlackListItem({ integration, collection }: Props) {
const { t } = useTranslation();
const { showToast } = useToasts();
const handleChange = async (ev: React.ChangeEvent<HTMLInputElement>) => {
if (ev.target.checked) {
integration.events = uniq([...integration.events, ev.target.name]);
} else {
integration.events = integration.events.filter(
(n) => n !== ev.target.name
);
}
await integration.save();
showToast(t("Settings saved"), {
type: "success",
});
};
const mapping = {
"documents.publish": t("document published"),
"documents.update": t("document updated"),
};
const popover = usePopoverState({
gutter: 0,
placement: "bottom-start",
});
return (
<ListItem
key={integration.id}
title={
<Flex align="center" gap={6}>
<CollectionIcon collection={collection} /> {collection.name}
</Flex>
}
subtitle={
<>
<Trans
defaults={`Posting to the <em>{{ channelName }}</em> channel on`}
values={{
channelName: integration.settings.channel,
events: integration.events.map((ev) => mapping[ev]).join(", "),
}}
components={{
em: <strong />,
}}
/>{" "}
<PopoverDisclosure {...popover}>
{(props) => (
<ButtonLink {...props}>
{integration.events.map((ev) => mapping[ev]).join(", ")}
</ButtonLink>
)}
</PopoverDisclosure>
<Popover {...popover} aria-label={t("Settings")}>
<Events>
<h3>{t("Notifications")}</h3>
<Text type="secondary">
{t("These events should be posted to Slack")}
</Text>
<Switch
label={t("Document published")}
name="documents.publish"
checked={integration.events.includes("documents.publish")}
onChange={handleChange}
/>
<Switch
label={t("Document updated")}
name="documents.update"
checked={integration.events.includes("documents.update")}
onChange={handleChange}
/>
</Events>
</Popover>
</>
}
actions={
<Button onClick={integration.delete} neutral>
{t("Disconnect")}
</Button>
}
/>
);
}
const Events = styled.div`
color: ${(props) => props.theme.text};
margin-top: -12px;
`;
export default observer(SlackListItem);

View File

@@ -0,0 +1,5 @@
{
"name": "Slack",
"description": "Adds a Slack authentication provider, support for the /outline slash command, and link unfurling.",
"requiredEnvVars": ["SLACK_CLIENT_ID", "SLACK_CLIENT_SECRET"]
}

View File

@@ -0,0 +1,3 @@
{
"extends": "../../../server/.babelrc"
}

View File

@@ -0,0 +1,341 @@
import { IntegrationService } from "@shared/types";
import env from "@server/env";
import { IntegrationAuthentication, SearchQuery } from "@server/models";
import { buildDocument, buildIntegration } from "@server/test/factories";
import { seed, getTestServer } from "@server/test/support";
import * as Slack from "../slack";
jest.mock("@server/utils/slack", () => ({
post: jest.fn(),
}));
const server = getTestServer();
describe("#hooks.unfurl", () => {
it("should return documents", async () => {
const { user, document } = await seed();
await IntegrationAuthentication.create({
service: IntegrationService.Slack,
userId: user.id,
teamId: user.teamId,
token: "",
});
const res = await server.post("/api/hooks.unfurl", {
body: {
token: env.SLACK_VERIFICATION_TOKEN,
team_id: "TXXXXXXXX",
api_app_id: "AXXXXXXXXX",
event: {
type: "link_shared",
channel: "Cxxxxxx",
user: user.authentications[0].providerId,
message_ts: "123456789.9875",
links: [
{
domain: "getoutline.com",
url: document.url,
},
],
},
},
});
expect(res.status).toEqual(200);
expect(Slack.post).toHaveBeenCalled();
});
});
describe("#hooks.slack", () => {
it("should return no matches", async () => {
const { user, team } = await seed();
const res = await server.post("/api/hooks.slack", {
body: {
token: env.SLACK_VERIFICATION_TOKEN,
user_id: user.authentications[0].providerId,
team_id: team.authenticationProviders[0].providerId,
text: "dsfkndfskndsfkn",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.attachments).toEqual(undefined);
});
it("should return search results with summary if query is in title", async () => {
const { user, team } = await seed();
const document = await buildDocument({
title: "This title contains a search term",
userId: user.id,
teamId: user.teamId,
});
const res = await server.post("/api/hooks.slack", {
body: {
token: env.SLACK_VERIFICATION_TOKEN,
user_id: user.authentications[0].providerId,
team_id: team.authenticationProviders[0].providerId,
text: "contains",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.attachments.length).toEqual(1);
expect(body.attachments[0].title).toEqual(document.title);
expect(body.attachments[0].text).toEqual(document.getSummary());
});
it("should return search results if query is regex-like", async () => {
const { user, team } = await seed();
await buildDocument({
title: "This title contains a search term",
userId: user.id,
teamId: user.teamId,
});
const res = await server.post("/api/hooks.slack", {
body: {
token: env.SLACK_VERIFICATION_TOKEN,
user_id: user.authentications[0].providerId,
team_id: team.authenticationProviders[0].providerId,
text: "*contains",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.attachments.length).toEqual(1);
});
it("should return search results with snippet if query is in text", async () => {
const { user, team } = await seed();
const document = await buildDocument({
text: "This title contains a search term",
userId: user.id,
teamId: user.teamId,
});
const res = await server.post("/api/hooks.slack", {
body: {
token: env.SLACK_VERIFICATION_TOKEN,
user_id: user.authentications[0].providerId,
team_id: team.authenticationProviders[0].providerId,
text: "contains",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.attachments.length).toEqual(1);
expect(body.attachments[0].title).toEqual(document.title);
expect(body.attachments[0].text).toEqual(
"This title *contains* a search term"
);
});
it("should save search term, hits and source", async () => {
const { user, team } = await seed();
await server.post("/api/hooks.slack", {
body: {
token: env.SLACK_VERIFICATION_TOKEN,
user_id: user.authentications[0].providerId,
team_id: team.authenticationProviders[0].providerId,
text: "contains",
},
});
return new Promise((resolve) => {
// setTimeout is needed here because SearchQuery is saved asynchronously
// in order to not slow down the response time.
setTimeout(async () => {
const searchQuery = await SearchQuery.findAll({
where: {
query: "contains",
},
});
expect(searchQuery.length).toBe(1);
expect(searchQuery[0].results).toBe(0);
expect(searchQuery[0].source).toBe("slack");
resolve(undefined);
}, 100);
});
});
it("should respond with help content for help keyword", async () => {
const { user, team } = await seed();
const res = await server.post("/api/hooks.slack", {
body: {
token: env.SLACK_VERIFICATION_TOKEN,
user_id: user.authentications[0].providerId,
team_id: team.authenticationProviders[0].providerId,
text: "help",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.text.includes("How to use")).toEqual(true);
});
it("should respond with help content for no keyword", async () => {
const { user, team } = await seed();
const res = await server.post("/api/hooks.slack", {
body: {
token: env.SLACK_VERIFICATION_TOKEN,
user_id: user.authentications[0].providerId,
team_id: team.authenticationProviders[0].providerId,
text: "",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.text.includes("How to use")).toEqual(true);
});
it("should return search results with snippet for unknown user", async () => {
const { user, team } = await seed();
// unpublished document will not be returned
await buildDocument({
text: "This title contains a search term",
userId: user.id,
teamId: user.teamId,
publishedAt: null,
});
const document = await buildDocument({
text: "This title contains a search term",
userId: user.id,
teamId: user.teamId,
});
const res = await server.post("/api/hooks.slack", {
body: {
token: env.SLACK_VERIFICATION_TOKEN,
user_id: "unknown-slack-user-id",
team_id: team.authenticationProviders[0].providerId,
text: "contains",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.text).toContain("you havent signed in to Outline yet");
expect(body.attachments.length).toEqual(1);
expect(body.attachments[0].title).toEqual(document.title);
expect(body.attachments[0].text).toEqual(
"This title *contains* a search term"
);
});
it("should return search results with snippet for user through integration mapping", async () => {
const { user } = await seed();
const serviceTeamId = "slack_team_id";
await buildIntegration({
teamId: user.teamId,
settings: {
serviceTeamId,
},
});
const document = await buildDocument({
text: "This title contains a search term",
userId: user.id,
teamId: user.teamId,
});
const res = await server.post("/api/hooks.slack", {
body: {
token: env.SLACK_VERIFICATION_TOKEN,
user_id: "unknown-slack-user-id",
team_id: serviceTeamId,
text: "contains",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.text).toContain("you havent signed in to Outline yet");
expect(body.attachments.length).toEqual(1);
expect(body.attachments[0].title).toEqual(document.title);
expect(body.attachments[0].text).toEqual(
"This title *contains* a search term"
);
});
it("should error if incorrect verification token", async () => {
const { user, team } = await seed();
const res = await server.post("/api/hooks.slack", {
body: {
token: "wrong-verification-token",
user_id: user.authentications[0].providerId,
team_id: team.authenticationProviders[0].providerId,
text: "Welcome",
},
});
expect(res.status).toEqual(401);
});
});
describe("#hooks.interactive", () => {
it("should respond with replacement message", async () => {
const { user, team } = await seed();
const document = await buildDocument({
title: "This title contains a search term",
userId: user.id,
teamId: user.teamId,
});
const payload = JSON.stringify({
token: env.SLACK_VERIFICATION_TOKEN,
user: {
id: user.authentications[0].providerId,
},
team: {
id: team.authenticationProviders[0].providerId,
},
callback_id: document.id,
});
const res = await server.post("/api/hooks.interactive", {
body: {
payload,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.response_type).toEqual("in_channel");
expect(body.attachments.length).toEqual(1);
expect(body.attachments[0].title).toEqual(document.title);
});
it("should respond with replacement message if unknown user", async () => {
const { user, team } = await seed();
const document = await buildDocument({
title: "This title contains a search term",
userId: user.id,
teamId: user.teamId,
});
const payload = JSON.stringify({
token: env.SLACK_VERIFICATION_TOKEN,
user: {
id: "unknown-slack-user-id",
},
team: {
id: team.authenticationProviders[0].providerId,
},
callback_id: document.id,
});
const res = await server.post("/api/hooks.interactive", {
body: {
payload,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.response_type).toEqual("in_channel");
expect(body.attachments.length).toEqual(1);
expect(body.attachments[0].title).toEqual(document.title);
});
it("should error if incorrect verification token", async () => {
const { user } = await seed();
const payload = JSON.stringify({
token: "wrong-verification-token",
user: {
id: user.authentications[0].providerId,
name: user.name,
},
callback_id: "doesnt-matter",
});
const res = await server.post("/api/hooks.interactive", {
body: {
payload,
},
});
expect(res.status).toEqual(401);
});
});

View File

@@ -0,0 +1,375 @@
import crypto from "crypto";
import { t } from "i18next";
import Router from "koa-router";
import { escapeRegExp } from "lodash";
import { IntegrationService } from "@shared/types";
import env from "@server/env";
import { AuthenticationError, InvalidRequestError } from "@server/errors";
import Logger from "@server/logging/Logger";
import {
UserAuthentication,
AuthenticationProvider,
Document,
User,
Team,
SearchQuery,
Integration,
IntegrationAuthentication,
} from "@server/models";
import SearchHelper from "@server/models/helpers/SearchHelper";
import { APIContext } from "@server/types";
import { opts } from "@server/utils/i18n";
import { assertPresent } from "@server/validation";
import presentMessageAttachment from "../presenters/messageAttachment";
import * as Slack from "../slack";
const router = new Router();
function verifySlackToken(token: string) {
if (!env.SLACK_VERIFICATION_TOKEN) {
throw AuthenticationError(
"SLACK_VERIFICATION_TOKEN is not present in environment"
);
}
if (
token.length !== env.SLACK_VERIFICATION_TOKEN.length ||
!crypto.timingSafeEqual(
Buffer.from(env.SLACK_VERIFICATION_TOKEN),
Buffer.from(token)
)
) {
throw AuthenticationError("Invalid token");
}
}
// 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;
}
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", 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: [
{
where: {
teamId: team.id,
},
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: {
settings: {
serviceTeamId: team_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: "How to use /outline",
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: {
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);
SearchQuery.create({
userId: user ? user.id : null,
teamId: team.id,
source: "slack",
query: text,
results: totalCount,
});
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 }}"`, {
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})…`,
};
}
});
export default router;

View File

@@ -0,0 +1,265 @@
import passport from "@outlinewiki/koa-passport";
import type { Context } from "koa";
import Router from "koa-router";
import { Profile } from "passport";
import { Strategy as SlackStrategy } from "passport-slack-oauth2";
import { IntegrationService, IntegrationType } from "@shared/types";
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
import accountProvisioner from "@server/commands/accountProvisioner";
import env from "@server/env";
import auth from "@server/middlewares/authentication";
import passportMiddleware from "@server/middlewares/passport";
import {
IntegrationAuthentication,
Collection,
Integration,
Team,
User,
} from "@server/models";
import { AuthenticationResult } from "@server/types";
import {
getClientFromContext,
getTeamFromContext,
StateStore,
} from "@server/utils/passport";
import { assertPresent, assertUuid } from "@server/validation";
import * as Slack from "../slack";
type SlackProfile = Profile & {
team: {
id: string;
name: string;
domain: string;
image_192: string;
image_230: string;
};
user: {
id: string;
name: string;
email: string;
image_192: string;
image_230: string;
};
};
const router = new Router();
const providerName = "slack";
const scopes = [
"identity.email",
"identity.basic",
"identity.avatar",
"identity.team",
];
function redirectOnClient(ctx: Context, url: string) {
ctx.type = "text/html";
ctx.body = `
<html>
<head>
<meta http-equiv="refresh" content="0;URL='${url}'"/>
</head>`;
}
if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
const strategy = new SlackStrategy(
{
clientID: env.SLACK_CLIENT_ID,
clientSecret: env.SLACK_CLIENT_SECRET,
callbackURL: `${env.URL}/auth/slack.callback`,
passReqToCallback: true,
// @ts-expect-error StateStore
store: new StateStore(),
scope: scopes,
},
async function (
ctx: Context,
accessToken: string,
refreshToken: string,
params: { expires_in: number },
profile: SlackProfile,
done: (
err: Error | null,
user: User | null,
result?: AuthenticationResult
) => void
) {
try {
const team = await getTeamFromContext(ctx);
const client = getClientFromContext(ctx);
const result = await accountProvisioner({
ip: ctx.ip,
team: {
teamId: team?.id,
name: profile.team.name,
subdomain: profile.team.domain,
avatarUrl: profile.team.image_230,
},
user: {
name: profile.user.name,
email: profile.user.email,
avatarUrl: profile.user.image_192,
},
authenticationProvider: {
name: providerName,
providerId: profile.team.id,
},
authentication: {
providerId: profile.user.id,
accessToken,
refreshToken,
expiresIn: params.expires_in,
scopes,
},
});
return done(null, result.user, { ...result, client });
} catch (err) {
return done(err, null);
}
}
);
// For some reason the author made the strategy name capatilised, I don't know
// why but we need everything lowercase so we just monkey-patch it here.
strategy.name = providerName;
passport.use(strategy);
router.get("slack", passport.authenticate(providerName));
router.get("slack.callback", passportMiddleware(providerName));
router.get(
"slack.commands",
auth({
optional: true,
}),
async (ctx) => {
const { code, state, error } = ctx.request.query;
const { user } = ctx.state;
assertPresent(code || error, "code is required");
if (error) {
ctx.redirect(integrationSettingsPath(`slack?error=${error}`));
return;
}
// this code block accounts for the root domain being unable to
// access authentication for subdomains. We must forward to the appropriate
// subdomain to complete the oauth flow
if (!user) {
if (state) {
try {
const team = await Team.findByPk(String(state), {
rejectOnEmpty: true,
});
return redirectOnClient(
ctx,
`${team.url}/auth/slack.commands?${ctx.request.querystring}`
);
} catch (err) {
return ctx.redirect(
integrationSettingsPath(`slack?error=unauthenticated`)
);
}
} else {
return ctx.redirect(
integrationSettingsPath(`slack?error=unauthenticated`)
);
}
}
const endpoint = `${env.URL}/auth/slack.commands`;
const data = await Slack.oauthAccess(String(code), endpoint);
const authentication = await IntegrationAuthentication.create({
service: IntegrationService.Slack,
userId: user.id,
teamId: user.teamId,
token: data.access_token,
scopes: data.scope.split(","),
});
await Integration.create({
service: IntegrationService.Slack,
type: IntegrationType.Command,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
settings: {
serviceTeamId: data.team_id,
},
});
ctx.redirect(integrationSettingsPath("slack"));
}
);
router.get(
"slack.post",
auth({
optional: true,
}),
async (ctx) => {
const { code, error, state } = ctx.request.query;
const { user } = ctx.state;
assertPresent(code || error, "code is required");
const collectionId = state;
assertUuid(collectionId, "collectionId must be an uuid");
if (error) {
ctx.redirect(integrationSettingsPath(`slack?error=${error}`));
return;
}
// this code block accounts for the root domain being unable to
// access authentication for subdomains. We must forward to the
// appropriate subdomain to complete the oauth flow
if (!user) {
try {
const collection = await Collection.findOne({
where: {
id: String(state),
},
rejectOnEmpty: true,
});
const team = await Team.findByPk(collection.teamId, {
rejectOnEmpty: true,
});
return redirectOnClient(
ctx,
`${team.url}/auth/slack.post?${ctx.request.querystring}`
);
} catch (err) {
return ctx.redirect(
integrationSettingsPath(`slack?error=unauthenticated`)
);
}
}
const endpoint = `${env.URL}/auth/slack.post`;
const data = await Slack.oauthAccess(code as string, endpoint);
const authentication = await IntegrationAuthentication.create({
service: IntegrationService.Slack,
userId: user.id,
teamId: user.teamId,
token: data.access_token,
scopes: data.scope.split(","),
});
await Integration.create({
service: IntegrationService.Slack,
type: IntegrationType.Post,
userId: user.id,
teamId: user.teamId,
authenticationId: authentication.id,
collectionId,
events: ["documents.update", "documents.publish"],
settings: {
url: data.incoming_webhook.url,
channel: data.incoming_webhook.channel,
channelId: data.incoming_webhook.channel_id,
},
});
ctx.redirect(integrationSettingsPath("slack"));
}
);
}
export default router;

View File

@@ -0,0 +1,38 @@
import { traceFunction } from "@server/logging/tracing";
import { Document, Collection, Team } from "@server/models";
type Action = {
type: string;
text: string;
name: string;
value: string;
};
function presentMessageAttachment(
document: Document,
team: Team,
collection?: Collection | null,
context?: string,
actions?: Action[]
) {
// the context contains <b> tags around search terms, we convert them here
// to the markdown format that slack expects to receive.
const text = context
? context.replace(/<\/?b>/g, "*").replace(/\n/g, "")
: document.getSummary();
return {
color: collection?.color,
title: document.title,
title_link: `${team.url}${document.url}`,
footer: collection?.name,
callback_id: document.id,
text,
ts: document.getTimestamp(),
actions,
};
}
export default traceFunction({
spanName: "presenters",
})(presentMessageAttachment);

View File

@@ -0,0 +1,139 @@
import fetch from "fetch-with-proxy";
import { Op } from "sequelize";
import { IntegrationService, IntegrationType } from "@shared/types";
import env from "@server/env";
import { Document, Integration, Collection, Team } from "@server/models";
import BaseProcessor from "@server/queues/processors/BaseProcessor";
import {
DocumentEvent,
IntegrationEvent,
RevisionEvent,
Event,
} from "@server/types";
import presentMessageAttachment from "../presenters/messageAttachment";
export default class SlackProcessor extends BaseProcessor {
static applicableEvents: Event["name"][] = [
"documents.publish",
"revisions.create",
"integrations.create",
];
async perform(event: Event) {
switch (event.name) {
case "documents.publish":
case "revisions.create":
return this.documentUpdated(event);
case "integrations.create":
return this.integrationCreated(event);
default:
}
}
async integrationCreated(event: IntegrationEvent) {
const integration = (await Integration.findOne({
where: {
id: event.modelId,
service: IntegrationService.Slack,
type: IntegrationType.Post,
},
include: [
{
model: Collection,
required: true,
as: "collection",
},
],
})) as Integration<IntegrationType.Post>;
if (!integration) {
return;
}
const collection = integration.collection;
if (!collection) {
return;
}
await fetch(integration.settings.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
text: `👋 Hey there! When documents are published or updated in the *${collection.name}* collection on ${env.APP_NAME} they will be posted to this channel!`,
attachments: [
{
color: collection.color,
title: collection.name,
title_link: `${env.URL}${collection.url}`,
text: collection.description,
},
],
}),
});
}
async documentUpdated(event: DocumentEvent | RevisionEvent) {
// never send notifications when batch importing documents
// @ts-expect-error ts-migrate(2339) FIXME: Property 'data' does not exist on type 'DocumentEv... Remove this comment to see the full error message
if (event.data && event.data.source === "import") {
return;
}
const [document, team] = await Promise.all([
Document.findByPk(event.documentId),
Team.findByPk(event.teamId),
]);
if (!document || !team) {
return;
}
// never send notifications for draft documents
if (!document.publishedAt) {
return;
}
if (
event.name === "revisions.create" &&
document.updatedAt === document.publishedAt
) {
return;
}
const integration = (await Integration.findOne({
where: {
teamId: document.teamId,
collectionId: document.collectionId,
service: IntegrationService.Slack,
type: IntegrationType.Post,
events: {
[Op.contains]: [
event.name === "revisions.create" ? "documents.update" : event.name,
],
},
},
})) as Integration<IntegrationType.Post>;
if (!integration) {
return;
}
let text = `${document.updatedBy.name} updated a document`;
if (event.name === "documents.publish") {
text = `${document.createdBy.name} published a new document`;
}
await fetch(integration.settings.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
text,
attachments: [
presentMessageAttachment(document, team, document.collection),
],
}),
});
}
}

View File

@@ -0,0 +1,60 @@
import querystring from "querystring";
import fetch from "fetch-with-proxy";
import env from "@server/env";
import { InvalidRequestError } from "@server/errors";
const SLACK_API_URL = "https://slack.com/api";
export async function post(endpoint: string, body: Record<string, any>) {
let data;
const token = body.token;
try {
const response = await fetch(`${SLACK_API_URL}/${endpoint}`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
data = await response.json();
} catch (err) {
throw InvalidRequestError(err.message);
}
if (!data.ok) {
throw InvalidRequestError(data.error);
}
return data;
}
export async function request(endpoint: string, body: Record<string, any>) {
let data;
try {
const response = await fetch(
`${SLACK_API_URL}/${endpoint}?${querystring.stringify(body)}`
);
data = await response.json();
} catch (err) {
throw InvalidRequestError(err.message);
}
if (!data.ok) {
throw InvalidRequestError(data.error);
}
return data;
}
export async function oauthAccess(
code: string,
redirect_uri = `${env.URL}/auth/slack.callback`
) {
return request("oauth.access", {
client_id: env.SLACK_CLIENT_ID,
client_secret: env.SLACK_CLIENT_SECRET,
redirect_uri,
code,
});
}