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

@@ -18,7 +18,6 @@ import presentProviderConfig from "./providerConfig";
import presentRevision from "./revision";
import presentSearchQuery from "./searchQuery";
import presentShare from "./share";
import presentSlackAttachment from "./slackAttachment";
import presentStar from "./star";
import presentSubscription from "./subscription";
import presentTeam from "./team";
@@ -48,7 +47,6 @@ export {
presentRevision,
presentSearchQuery,
presentShare,
presentSlackAttachment,
presentStar,
presentSubscription,
presentTeam,

View File

@@ -1,4 +1,4 @@
import { signin } from "@shared/utils/urlHelpers";
import { signin } from "@shared/utils/routeHelpers";
import { AuthenticationProviderConfig } from "@server/routes/auth/providers";
export default function presentProviderConfig(

View File

@@ -1,38 +0,0 @@
import { traceFunction } from "@server/logging/tracing";
import { Document, Collection, Team } from "@server/models";
type Action = {
type: string;
text: string;
name: string;
value: string;
};
function presentSlackAttachment(
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",
})(presentSlackAttachment);

View File

@@ -1,139 +0,0 @@
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 { presentSlackAttachment } from "@server/presenters";
import {
DocumentEvent,
IntegrationEvent,
RevisionEvent,
Event,
} from "@server/types";
import BaseProcessor from "./BaseProcessor";
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: [
presentSlackAttachment(document, team, document.collection),
],
}),
});
}
}

View File

@@ -1,16 +1,28 @@
import path from "path";
import { glob } from "glob";
import Logger from "@server/logging/Logger";
import { requireDirectory } from "@server/utils/fs";
import BaseProcessor from "./BaseProcessor";
const processors = {};
requireDirectory(__dirname).forEach(([module, id]) => {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'default' does not exist on type 'unknown'
const { default: Processor } = module;
if (id === "index") {
return;
requireDirectory<{ default: BaseProcessor }>(__dirname).forEach(
([module, id]) => {
if (id === "index") {
return;
}
processors[id] = module.default;
}
);
processors[id] = Processor;
});
glob
.sync("build/plugins/*/server/processors/!(*.test).js")
.forEach((filePath: string) => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const processor = require(path.join(process.cwd(), filePath)).default;
const name = path.basename(filePath, ".js");
processors[name] = processor;
Logger.debug("processor", `Registered processor ${name}`);
});
export default processors;

View File

@@ -1,16 +1,28 @@
import path from "path";
import { glob } from "glob";
import Logger from "@server/logging/Logger";
import { requireDirectory } from "@server/utils/fs";
import BaseTask from "./BaseTask";
const tasks = {};
requireDirectory(__dirname).forEach(([module, id]) => {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'default' does not exist on type 'unknown'
const { default: Task } = module;
if (id === "index") {
return;
requireDirectory<{ default: BaseTask<any> }>(__dirname).forEach(
([module, id]) => {
if (id === "index") {
return;
}
tasks[id] = module.default;
}
);
tasks[id] = Task;
});
glob
.sync("build/plugins/*/server/tasks/!(*.test).js")
.forEach((filePath: string) => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const task = require(path.join(process.cwd(), filePath)).default;
const name = path.basename(filePath, ".js");
tasks[name] = task;
Logger.debug("task", `Registered task ${name}`);
});
export default tasks;

View File

@@ -1,341 +0,0 @@
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 "@server/utils/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

@@ -1,375 +0,0 @@
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 { presentSlackAttachment } from "@server/presenters";
import { APIContext } from "@server/types";
import { opts } from "@server/utils/i18n";
import * as Slack from "@server/utils/slack";
import { assertPresent } from "@server/validation";
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: [
presentSlackAttachment(
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(
presentSlackAttachment(
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

@@ -1,9 +1,12 @@
import path from "path";
import glob from "glob";
import Koa, { BaseContext } from "koa";
import bodyParser from "koa-body";
import Router from "koa-router";
import userAgent, { UserAgentContext } from "koa-useragent";
import env from "@server/env";
import { NotFoundError } from "@server/errors";
import Logger from "@server/logging/Logger";
import { AppState, AppContext } from "@server/types";
import apiKeys from "./apiKeys";
import attachments from "./attachments";
@@ -16,7 +19,6 @@ import documents from "./documents";
import events from "./events";
import fileOperationsRoute from "./fileOperations";
import groups from "./groups";
import hooks from "./hooks";
import integrations from "./integrations";
import apiWrapper from "./middlewares/apiWrapper";
import editor from "./middlewares/editor";
@@ -48,6 +50,16 @@ api.use<BaseContext, UserAgentContext>(userAgent);
api.use(apiWrapper());
api.use(editor());
// register package API routes before others to allow for overrides
glob
.sync("build/plugins/*/server/api/!(*.test).js")
.forEach((filePath: string) => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pkg: Router = require(path.join(process.cwd(), filePath)).default;
router.use("/", pkg.routes());
Logger.debug("lifecycle", `Registered API routes for ${filePath}`);
});
// routes
router.use("/", auth.routes());
router.use("/", authenticationProviders.routes());
@@ -58,7 +70,6 @@ router.use("/", documents.routes());
router.use("/", pins.routes());
router.use("/", revisions.routes());
router.use("/", views.routes());
router.use("/", hooks.routes());
router.use("/", apiKeys.routes());
router.use("/", searches.routes());
router.use("/", shares.routes());

View File

@@ -1,5 +1,9 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import path from "path";
import { glob } from "glob";
import Router from "koa-router";
import { sortBy } from "lodash";
import env from "@server/env";
import { requireDirectory } from "@server/utils/fs";
export type AuthenticationProviderConfig = {
@@ -11,8 +15,10 @@ export type AuthenticationProviderConfig = {
const authenticationProviderConfigs: AuthenticationProviderConfig[] = [];
requireDirectory(__dirname).forEach(([module, id]) => {
// @ts-expect-error ts-migrate(2339) FIXME: Property 'config' does not exist on type 'unknown'... Remove this comment to see the full error message
requireDirectory<{
default: Router;
config: { name: string; enabled: boolean };
}>(__dirname).forEach(([module, id]) => {
const { config, default: router } = module;
if (id === "index") {
@@ -41,4 +47,34 @@ requireDirectory(__dirname).forEach(([module, id]) => {
}
});
// Temporarily also include plugins here until all auth methods are moved over.
glob
.sync(
(env.ENVIRONMENT === "test" ? "" : "build/") +
"plugins/*/server/auth/!(*.test).[jt]s"
)
.forEach((filePath: string) => {
const authProvider = require(path.join(process.cwd(), filePath)).default;
const id = filePath.replace("build/", "").split("/")[1];
const config = require(path.join(
process.cwd(),
env.ENVIRONMENT === "test" ? "" : "build",
"plugins",
id,
"plugin.json"
));
// Test the all required env vars are set for the auth provider
const enabled = (config.requiredEnvVars ?? []).every(
(name: string) => !!env[name]
);
authenticationProviderConfigs.push({
id,
name: config.name,
enabled,
router: authProvider,
});
});
export default sortBy(authenticationProviderConfigs, "id");

View File

@@ -1,269 +0,0 @@
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 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 * as Slack from "@server/utils/slack";
import { assertPresent, assertUuid } from "@server/validation";
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>`;
}
export const config = {
name: "Slack",
enabled: !!env.SLACK_CLIENT_ID,
};
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(`/settings/integrations/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(
`/settings/integrations/slack?error=unauthenticated`
);
}
} else {
return ctx.redirect(
`/settings/integrations/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("/settings/integrations/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(`/settings/integrations/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(
`/settings/integrations/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("/settings/integrations/slack");
}
);
}
export default router;

View File

@@ -9,7 +9,7 @@ export function deserializeFilename(text: string): string {
return text.replace(/%2F/g, "/").replace(/%5C/g, "\\");
}
export function requireDirectory<T>(dirName: string): [T, string][] {
export function getFilenamesInDirectory(dirName: string): string[] {
return fs
.readdirSync(dirName)
.filter(
@@ -18,10 +18,13 @@ export function requireDirectory<T>(dirName: string): [T, string][] {
file.match(/\.[jt]s$/) &&
file !== path.basename(__filename) &&
!file.includes(".test")
)
.map((fileName) => {
const filePath = path.join(dirName, fileName);
const name = path.basename(filePath.replace(/\.[jt]s$/, ""));
return [require(filePath), name];
});
);
}
export function requireDirectory<T>(dirName: string): [T, string][] {
return getFilenamesInDirectory(dirName).map((fileName) => {
const filePath = path.join(dirName, fileName);
const name = path.basename(filePath.replace(/\.[jt]s$/, ""));
return [require(filePath), name];
});
}

View File

@@ -1,60 +0,0 @@
import querystring from "querystring";
import fetch from "fetch-with-proxy";
import env from "@server/env";
import { InvalidRequestError } from "../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,
});
}