From 33afa2f0295f733dd2256407d69023ba8605ef6e Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 12 Feb 2023 13:11:30 -0500 Subject: [PATCH] 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 --- app/hooks/useSettingsConfig.ts | 50 +++++++------------ app/routes/settings.tsx | 6 +-- app/typings/window.d.ts | 9 ++++ app/utils/plugins.ts | 37 ++++++++++++++ build.sh | 13 ++++- package.json | 4 +- .../slack/client/Icon.tsx | 5 +- .../slack/client/Settings.tsx | 2 +- .../slack/client}/components/SlackButton.tsx | 0 .../client}/components/SlackListItem.tsx | 0 plugins/slack/plugin.json | 5 ++ plugins/slack/server/.babelrc | 3 ++ .../slack/server}/api/hooks.test.ts | 2 +- .../slack/server}/api/hooks.ts | 8 +-- .../slack/server/auth}/slack.ts | 22 ++++---- .../server/presenters/messageAttachment.ts | 4 +- .../server}/processors/SlackProcessor.ts | 6 +-- .../utils => plugins/slack/server}/slack.ts | 2 +- server/presenters/index.ts | 2 - server/presenters/providerConfig.ts | 2 +- server/queues/processors/index.ts | 28 ++++++++--- server/queues/tasks/index.ts | 28 ++++++++--- server/routes/api/index.ts | 15 +++++- server/routes/auth/providers/index.ts | 40 ++++++++++++++- server/utils/fs.ts | 17 ++++--- shared/i18n/locales/en_US/translation.json | 30 +++++------ shared/utils/routeHelpers.ts | 7 +++ shared/utils/urlHelpers.ts | 4 -- webpack.config.js | 1 + yarn.lock | 38 ++++++++++++++ 30 files changed, 273 insertions(+), 117 deletions(-) create mode 100644 app/utils/plugins.ts rename app/components/Icons/SlackIcon.tsx => plugins/slack/client/Icon.tsx (96%) rename app/scenes/Settings/Slack.tsx => plugins/slack/client/Settings.tsx (99%) rename {app/scenes/Settings => plugins/slack/client}/components/SlackButton.tsx (100%) rename {app/scenes/Settings => plugins/slack/client}/components/SlackListItem.tsx (100%) create mode 100644 plugins/slack/plugin.json create mode 100644 plugins/slack/server/.babelrc rename {server/routes => plugins/slack/server}/api/hooks.test.ts (99%) rename {server/routes => plugins/slack/server}/api/hooks.ts (98%) rename {server/routes/auth/providers => plugins/slack/server/auth}/slack.ts (92%) rename server/presenters/slackAttachment.ts => plugins/slack/server/presenters/messageAttachment.ts (92%) rename {server/queues => plugins/slack/server}/processors/SlackProcessor.ts (94%) rename {server/utils => plugins/slack/server}/slack.ts (96%) create mode 100644 shared/utils/routeHelpers.ts diff --git a/app/hooks/useSettingsConfig.ts b/app/hooks/useSettingsConfig.ts index 6fdbc739ac..4801059ed2 100644 --- a/app/hooks/useSettingsConfig.ts +++ b/app/hooks/useSettingsConfig.ts @@ -1,3 +1,4 @@ +import { mapValues } from "lodash"; import { EmailIcon, ProfileIcon, @@ -16,6 +17,7 @@ import { } from "outline-icons"; import React from "react"; import { useTranslation } from "react-i18next"; +import { integrationSettingsPath } from "@shared/utils/routeHelpers"; import Details from "~/scenes/Settings/Details"; import Export from "~/scenes/Settings/Export"; import Features from "~/scenes/Settings/Features"; @@ -29,36 +31,18 @@ import Profile from "~/scenes/Settings/Profile"; import Security from "~/scenes/Settings/Security"; import SelfHosted from "~/scenes/Settings/SelfHosted"; import Shares from "~/scenes/Settings/Shares"; -import Slack from "~/scenes/Settings/Slack"; import Tokens from "~/scenes/Settings/Tokens"; import Webhooks from "~/scenes/Settings/Webhooks"; import Zapier from "~/scenes/Settings/Zapier"; import GoogleIcon from "~/components/Icons/GoogleIcon"; -import SlackIcon from "~/components/Icons/SlackIcon"; import ZapierIcon from "~/components/Icons/ZapierIcon"; -import env from "~/env"; import isCloudHosted from "~/utils/isCloudHosted"; +import { loadPlugins } from "~/utils/plugins"; import { accountPreferencesPath } from "~/utils/routeHelpers"; import useCurrentTeam from "./useCurrentTeam"; import usePolicy from "./usePolicy"; type SettingsGroups = "Account" | "Team" | "Integrations"; -type SettingsPage = - | "Profile" - | "Notifications" - | "Api" - | "Details" - | "Security" - | "Features" - | "Members" - | "Groups" - | "Shares" - | "Import" - | "Export" - | "Webhooks" - | "Slack" - | "Zapier" - | "GoogleAnalytics"; export type ConfigItem = { name: string; @@ -70,7 +54,7 @@ export type ConfigItem = { }; type ConfigType = { - [key in SettingsPage]: ConfigItem; + [key in string]: ConfigItem; }; const useSettingsConfig = () => { @@ -178,6 +162,16 @@ const useSettingsConfig = () => { icon: ExportIcon, }, // Integrations + ...mapValues(loadPlugins(), (plugin) => { + return { + name: plugin.config.name, + path: integrationSettingsPath(plugin.id), + group: t("Integrations"), + component: plugin.settings, + enabled: !!plugin.settings && can.update, + icon: plugin.icon, + } as ConfigItem; + }), Webhooks: { name: t("Webhooks"), path: "/settings/webhooks", @@ -188,23 +182,15 @@ const useSettingsConfig = () => { }, SelfHosted: { name: t("Self Hosted"), - path: "/settings/integrations/self-hosted", + path: integrationSettingsPath("self-hosted"), component: SelfHosted, enabled: can.update, group: t("Integrations"), icon: BuildingBlocksIcon, }, - Slack: { - name: "Slack", - path: "/settings/integrations/slack", - component: Slack, - enabled: can.update && (!!env.SLACK_CLIENT_ID || isCloudHosted), - group: t("Integrations"), - icon: SlackIcon, - }, GoogleAnalytics: { name: t("Google Analytics"), - path: "/settings/integrations/google-analytics", + path: integrationSettingsPath("google-analytics"), component: GoogleAnalytics, enabled: can.update, group: t("Integrations"), @@ -212,7 +198,7 @@ const useSettingsConfig = () => { }, Zapier: { name: "Zapier", - path: "/settings/integrations/zapier", + path: integrationSettingsPath("zapier"), component: Zapier, enabled: can.update && isCloudHosted, group: t("Integrations"), @@ -232,7 +218,7 @@ const useSettingsConfig = () => { const enabledConfigs = React.useMemo( () => Object.keys(config).reduce( - (acc, key: SettingsPage) => + (acc, key: string) => config[key].enabled ? [...acc, config[key]] : acc, [] ), diff --git a/app/routes/settings.tsx b/app/routes/settings.tsx index af81f1b4c2..1e1ac4e086 100644 --- a/app/routes/settings.tsx +++ b/app/routes/settings.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Switch, Redirect } from "react-router-dom"; +import { Switch } from "react-router-dom"; import Error404 from "~/scenes/Error404"; import Route from "~/components/ProfiledRoute"; import useSettingsConfig from "~/hooks/useSettingsConfig"; @@ -17,10 +17,6 @@ export default function SettingsRoutes() { component={config.component} /> ))} - {/* old routes */} - - - ); diff --git a/app/typings/window.d.ts b/app/typings/window.d.ts index 049fed18d7..db4c1fe8dd 100644 --- a/app/typings/window.d.ts +++ b/app/typings/window.d.ts @@ -1,4 +1,13 @@ declare global { + interface NodeRequire { + /** A special feature supported by webpack's compiler that allows you to get all matching modules starting from some base directory. */ + context: ( + directory: string, + useSubdirectories: boolean, + regExp: RegExp + ) => any; + } + interface Window { dataLayer: any[]; gtag: (...args: any[]) => void; diff --git a/app/utils/plugins.ts b/app/utils/plugins.ts new file mode 100644 index 0000000000..a4e73abcb2 --- /dev/null +++ b/app/utils/plugins.ts @@ -0,0 +1,37 @@ +interface Plugin { + id: string; + config: { + name: string; + description: string; + requiredEnvVars?: string[]; + }; + settings: React.FC; + icon: React.FC; +} + +export function loadPlugins(): { [id: string]: Plugin } { + const plugins = {}; + + function importAll(r: any, property: string) { + r.keys().forEach((key: string) => { + const id = key.split("/")[1]; + plugins[id] = plugins[id] || { + id, + }; + + const plugin = r(key); + plugins[id][property] = "default" in plugin ? plugin.default : plugin; + }); + } + importAll( + require.context("../../plugins", true, /client\/Settings\.[tj]sx?$/), + "settings" + ); + importAll( + require.context("../../plugins", true, /client\/Icon\.[tj]sx?$/), + "icon" + ); + importAll(require.context("../../plugins", true, /plugin\.json?$/), "config"); + + return plugins; +} diff --git a/build.sh b/build.sh index dc33cd202b..17a35c5bd7 100755 --- a/build.sh +++ b/build.sh @@ -1,5 +1,16 @@ #!/bin/sh -yarn concurrently "yarn babel --extensions .ts,.tsx --quiet -d ./build/server ./server" "yarn babel --extensions .ts,.tsx --quiet -d ./build/shared ./shared" + +# Compile server and shared +yarn concurrently "yarn babel --extensions .ts,.tsx --quiet -d ./build/server ./server" \ + "yarn babel --extensions .ts,.tsx --quiet -d ./build/shared ./shared" + +# Compile code in packages +for d in ./plugins/*; do + # Get the name of the folder + package=$(basename "$d") + yarn babel --extensions .ts,.tsx --quiet -d "./build/plugins/$package/server" "./plugins/$package/server" + cp ./plugins/$package/plugin.json ./build/plugins/$package/plugin.json +done # Copy static files cp ./server/collaboration/Procfile ./build/server/collaboration/Procfile diff --git a/package.json b/package.json index e0952b78bb..d0c9a73e81 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "clean": "rimraf build", "copy:i18n": "mkdir -p ./build/shared/i18n && cp -R ./shared/i18n/locales ./build/shared/i18n", - "build:i18n": "i18next --silent 'shared/**/*.tsx' 'shared/**/*.ts' 'app/**/*.tsx' 'app/**/*.ts' 'server/**/*.ts' 'server/**/*.tsx' && yarn copy:i18n", + "build:i18n": "i18next --silent '{shared,app,server,plugins}/**/*.{ts,tsx}' && yarn copy:i18n", "build:server": "./build.sh", "build:webpack": "webpack --config webpack.config.prod.js", "build": "yarn clean && yarn build:webpack && yarn build:i18n && yarn build:server", @@ -100,6 +100,7 @@ "fs-extra": "^4.0.2", "fuzzy-search": "^3.2.1", "gemoji": "6.x", + "glob": "^8.1.0", "http-errors": "2.0.0", "i18next": "^22.4.8", "i18next-fs-backend": "^2.1.1", @@ -240,6 +241,7 @@ "@types/formidable": "^2.0.5", "@types/fs-extra": "^9.0.13", "@types/fuzzy-search": "^2.1.2", + "@types/glob": "^8.0.1", "@types/google.analytics": "^0.0.42", "@types/inline-css": "^3.0.1", "@types/invariant": "^2.2.35", diff --git a/app/components/Icons/SlackIcon.tsx b/plugins/slack/client/Icon.tsx similarity index 96% rename from app/components/Icons/SlackIcon.tsx rename to plugins/slack/client/Icon.tsx index f27bff5fad..db79b9f3c7 100644 --- a/app/components/Icons/SlackIcon.tsx +++ b/plugins/slack/client/Icon.tsx @@ -7,10 +7,7 @@ type Props = { color?: string; }; -export default function SlackIcon({ - size = 24, - color = "currentColor", -}: Props) { +export default function Icon({ size = 24, color = "currentColor" }: Props) { return ( ({ post: jest.fn(), diff --git a/server/routes/api/hooks.ts b/plugins/slack/server/api/hooks.ts similarity index 98% rename from server/routes/api/hooks.ts rename to plugins/slack/server/api/hooks.ts index 30d7e84e74..b96d2ab6f4 100644 --- a/server/routes/api/hooks.ts +++ b/plugins/slack/server/api/hooks.ts @@ -17,11 +17,11 @@ import { 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"; +import presentMessageAttachment from "../presenters/messageAttachment"; +import * as Slack from "../slack"; const router = new Router(); @@ -140,7 +140,7 @@ router.post("hooks.interactive", async (ctx: APIContext) => { response_type: "in_channel", replace_original: false, attachments: [ - presentSlackAttachment( + presentMessageAttachment( document, team, document.collection, @@ -329,7 +329,7 @@ router.post("hooks.slack", async (ctx: APIContext) => { .toLowerCase() .match(escapeRegExp(text.toLowerCase())); attachments.push( - presentSlackAttachment( + presentMessageAttachment( result.document, team, result.document.collection, diff --git a/server/routes/auth/providers/slack.ts b/plugins/slack/server/auth/slack.ts similarity index 92% rename from server/routes/auth/providers/slack.ts rename to plugins/slack/server/auth/slack.ts index 1b57ce800c..79d54592f2 100644 --- a/server/routes/auth/providers/slack.ts +++ b/plugins/slack/server/auth/slack.ts @@ -4,6 +4,7 @@ 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"; @@ -21,8 +22,8 @@ import { getTeamFromContext, StateStore, } from "@server/utils/passport"; -import * as Slack from "@server/utils/slack"; import { assertPresent, assertUuid } from "@server/validation"; +import * as Slack from "../slack"; type SlackProfile = Profile & { team: { @@ -59,11 +60,6 @@ function redirectOnClient(ctx: Context, url: string) { `; } -export const config = { - name: "Slack", - enabled: !!env.SLACK_CLIENT_ID, -}; - if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) { const strategy = new SlackStrategy( { @@ -142,7 +138,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) { assertPresent(code || error, "code is required"); if (error) { - ctx.redirect(`/settings/integrations/slack?error=${error}`); + ctx.redirect(integrationSettingsPath(`slack?error=${error}`)); return; } @@ -161,12 +157,12 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) { ); } catch (err) { return ctx.redirect( - `/settings/integrations/slack?error=unauthenticated` + integrationSettingsPath(`slack?error=unauthenticated`) ); } } else { return ctx.redirect( - `/settings/integrations/slack?error=unauthenticated` + integrationSettingsPath(`slack?error=unauthenticated`) ); } } @@ -190,7 +186,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) { serviceTeamId: data.team_id, }, }); - ctx.redirect("/settings/integrations/slack"); + ctx.redirect(integrationSettingsPath("slack")); } ); @@ -208,7 +204,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) { assertUuid(collectionId, "collectionId must be an uuid"); if (error) { - ctx.redirect(`/settings/integrations/slack?error=${error}`); + ctx.redirect(integrationSettingsPath(`slack?error=${error}`)); return; } @@ -232,7 +228,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) { ); } catch (err) { return ctx.redirect( - `/settings/integrations/slack?error=unauthenticated` + integrationSettingsPath(`slack?error=unauthenticated`) ); } } @@ -261,7 +257,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) { channelId: data.incoming_webhook.channel_id, }, }); - ctx.redirect("/settings/integrations/slack"); + ctx.redirect(integrationSettingsPath("slack")); } ); } diff --git a/server/presenters/slackAttachment.ts b/plugins/slack/server/presenters/messageAttachment.ts similarity index 92% rename from server/presenters/slackAttachment.ts rename to plugins/slack/server/presenters/messageAttachment.ts index 76b7bfb703..49c37e94fd 100644 --- a/server/presenters/slackAttachment.ts +++ b/plugins/slack/server/presenters/messageAttachment.ts @@ -8,7 +8,7 @@ type Action = { value: string; }; -function presentSlackAttachment( +function presentMessageAttachment( document: Document, team: Team, collection?: Collection | null, @@ -35,4 +35,4 @@ function presentSlackAttachment( export default traceFunction({ spanName: "presenters", -})(presentSlackAttachment); +})(presentMessageAttachment); diff --git a/server/queues/processors/SlackProcessor.ts b/plugins/slack/server/processors/SlackProcessor.ts similarity index 94% rename from server/queues/processors/SlackProcessor.ts rename to plugins/slack/server/processors/SlackProcessor.ts index 3f797ecf6b..b33ebe0adf 100644 --- a/server/queues/processors/SlackProcessor.ts +++ b/plugins/slack/server/processors/SlackProcessor.ts @@ -3,14 +3,14 @@ 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 BaseProcessor from "@server/queues/processors/BaseProcessor"; import { DocumentEvent, IntegrationEvent, RevisionEvent, Event, } from "@server/types"; -import BaseProcessor from "./BaseProcessor"; +import presentMessageAttachment from "../presenters/messageAttachment"; export default class SlackProcessor extends BaseProcessor { static applicableEvents: Event["name"][] = [ @@ -131,7 +131,7 @@ export default class SlackProcessor extends BaseProcessor { body: JSON.stringify({ text, attachments: [ - presentSlackAttachment(document, team, document.collection), + presentMessageAttachment(document, team, document.collection), ], }), }); diff --git a/server/utils/slack.ts b/plugins/slack/server/slack.ts similarity index 96% rename from server/utils/slack.ts rename to plugins/slack/server/slack.ts index 1313699842..14583355f6 100644 --- a/server/utils/slack.ts +++ b/plugins/slack/server/slack.ts @@ -1,7 +1,7 @@ import querystring from "querystring"; import fetch from "fetch-with-proxy"; import env from "@server/env"; -import { InvalidRequestError } from "../errors"; +import { InvalidRequestError } from "@server/errors"; const SLACK_API_URL = "https://slack.com/api"; diff --git a/server/presenters/index.ts b/server/presenters/index.ts index 23e6b499b6..43a5c9f716 100644 --- a/server/presenters/index.ts +++ b/server/presenters/index.ts @@ -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, diff --git a/server/presenters/providerConfig.ts b/server/presenters/providerConfig.ts index 7d66637c42..b18c1e43a7 100644 --- a/server/presenters/providerConfig.ts +++ b/server/presenters/providerConfig.ts @@ -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( diff --git a/server/queues/processors/index.ts b/server/queues/processors/index.ts index fb911c4783..4ebe1dacb4 100644 --- a/server/queues/processors/index.ts +++ b/server/queues/processors/index.ts @@ -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; diff --git a/server/queues/tasks/index.ts b/server/queues/tasks/index.ts index 96e8040ee9..fe94794f05 100644 --- a/server/queues/tasks/index.ts +++ b/server/queues/tasks/index.ts @@ -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 }>(__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; diff --git a/server/routes/api/index.ts b/server/routes/api/index.ts index ab53c7426d..b4436f15bc 100644 --- a/server/routes/api/index.ts +++ b/server/routes/api/index.ts @@ -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(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()); diff --git a/server/routes/auth/providers/index.ts b/server/routes/auth/providers/index.ts index e11af66522..902db4cd0a 100644 --- a/server/routes/auth/providers/index.ts +++ b/server/routes/auth/providers/index.ts @@ -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"); diff --git a/server/utils/fs.ts b/server/utils/fs.ts index 8b59a2f36b..079d1945db 100644 --- a/server/utils/fs.ts +++ b/server/utils/fs.ts @@ -9,7 +9,7 @@ export function deserializeFilename(text: string): string { return text.replace(/%2F/g, "/").replace(/%5C/g, "\\"); } -export function requireDirectory(dirName: string): [T, string][] { +export function getFilenamesInDirectory(dirName: string): string[] { return fs .readdirSync(dirName) .filter( @@ -18,10 +18,13 @@ export function requireDirectory(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(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]; + }); } diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 89338ab950..46dd324685 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -312,8 +312,8 @@ "Groups": "Groups", "Shared Links": "Shared Links", "Import": "Import", - "Webhooks": "Webhooks", "Integrations": "Integrations", + "Webhooks": "Webhooks", "Self Hosted": "Self Hosted", "Google Analytics": "Google Analytics", "Show path to document": "Show path to document", @@ -662,14 +662,6 @@ "Last accessed": "Last accessed", "Date shared": "Date shared", "Shared nested": "Shared nested", - "Add to Slack": "Add to Slack", - "Settings saved": "Settings saved", - "document published": "document published", - "document updated": "document updated", - "Posting to the {{ channelName }} channel on": "Posting to the {{ channelName }} channel on", - "These events should be posted to Slack": "These events should be posted to Slack", - "Document updated": "Document updated", - "Disconnect": "Disconnect", "API token copied to clipboard": "API token copied to clipboard", "Revoke token": "Revoke token", "Copied": "Copied", @@ -695,6 +687,7 @@ "Subscribed events": "Subscribed events", "Edit webhook": "Edit webhook", "Webhook created": "Webhook created", + "Settings saved": "Settings saved", "Logo updated": "Logo updated", "Unable to upload new logo": "Unable to upload new logo", "These settings affect the way that your knowledge base appears to everyone on the team.": "These settings affect the way that your knowledge base appears to everyone on the team.", @@ -732,6 +725,7 @@ "Everyone that has signed into {{appName}} is listed here. It’s possible that there are other users who have access through {team.signinMethods} but haven’t signed in yet.": "Everyone that has signed into {{appName}} is listed here. It’s possible that there are other users who have access through {team.signinMethods} but haven’t signed in yet.", "Filter": "Filter", "Receive a notification whenever a new document is published": "Receive a notification whenever a new document is published", + "Document updated": "Document updated", "Receive a notification when a document you created is edited": "Receive a notification when a document you created is edited", "Collection created": "Collection created", "Receive a notification whenever a new collection is created": "Receive a notification whenever a new collection is created", @@ -795,12 +789,6 @@ "Sharing is currently disabled.": "Sharing is currently disabled.", "You can globally enable and disable public document sharing in the security settings.": "You can globally enable and disable public document sharing in the security settings.", "Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.": "Documents that have been shared are listed below. Anyone that has the public link can access a read-only version of the document until the link has been revoked.", - "Whoops, you need to accept the permissions in Slack to connect{{appName}} to your team. Try again?": "Whoops, you need to accept the permissions in Slack to connect{{appName}} to your team. Try again?", - "Something went wrong while authenticating your request. Please try logging in again?": "Something went wrong while authenticating your request. Please try logging in again?", - "Get rich previews of {{ appName }} links shared in Slack and use the {{ command }} slash command to search for documents without leaving your chat.": "Get rich previews of {{ appName }} links shared in Slack and use the {{ command }} slash command to search for documents without leaving your chat.", - "Connect {{appName}} collections to Slack channels. Messages will be automatically posted to Slack when documents are published or updated.": "Connect {{appName}} collections to Slack channels. Messages will be automatically posted to Slack when documents are published or updated.", - "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.", "New token": "New token", "You can create an unlimited amount of personal tokens to authenticate\n with the API. Tokens have the same permissions as your user account.\n For more details see the developer documentation.": "You can create an unlimited amount of personal tokens to authenticate\n with the API. Tokens have the same permissions as your user account.\n For more details see the developer documentation.", "Tokens": "Tokens", @@ -832,6 +820,18 @@ "This month": "This month", "Last month": "Last month", "This year": "This year", + "Add to Slack": "Add to Slack", + "document published": "document published", + "document updated": "document updated", + "Posting to the {{ channelName }} channel on": "Posting to the {{ channelName }} channel on", + "These events should be posted to Slack": "These events should be posted to Slack", + "Disconnect": "Disconnect", + "Whoops, you need to accept the permissions in Slack to connect{{appName}} to your team. Try again?": "Whoops, you need to accept the permissions in Slack to connect{{appName}} to your team. Try again?", + "Something went wrong while authenticating your request. Please try logging in again?": "Something went wrong while authenticating your request. Please try logging in again?", + "Get rich previews of {{ appName }} links shared in Slack and use the {{ command }} slash command to search for documents without leaving your chat.": "Get rich previews of {{ appName }} links shared in Slack and use the {{ command }} slash command to search for documents without leaving your chat.", + "Connect {{appName}} collections to Slack channels. Messages will be automatically posted to Slack when documents are published or updated.": "Connect {{appName}} collections to Slack channels. Messages will be automatically posted to Slack when documents are published or updated.", + "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.", "To search your knowledgebase use {{ command }}. \nYou’ve already learned how to get help with {{ command2 }}.": "To search your knowledgebase use {{ command }}. \nYou’ve already learned how to get help with {{ command2 }}.", "Sorry, we couldn’t find an integration for your team. Head to your {{ appName }} settings to set one up.": "Sorry, we couldn’t find an integration for your team. Head to your {{ appName }} settings to set one up.", "It looks like you haven’t signed in to {{ appName }} yet, so results may be limited": "It looks like you haven’t signed in to {{ appName }} yet, so results may be limited", diff --git a/shared/utils/routeHelpers.ts b/shared/utils/routeHelpers.ts new file mode 100644 index 0000000000..e8d19aac83 --- /dev/null +++ b/shared/utils/routeHelpers.ts @@ -0,0 +1,7 @@ +export function signin(service = "slack"): string { + return `/auth/${service}`; +} + +export function integrationSettingsPath(id: string): string { + return `/settings/integrations/${id}`; +} diff --git a/shared/utils/urlHelpers.ts b/shared/utils/urlHelpers.ts index b0a09046eb..5d97822fe8 100644 --- a/shared/utils/urlHelpers.ts +++ b/shared/utils/urlHelpers.ts @@ -48,10 +48,6 @@ export function changelogUrl(): string { return "https://www.getoutline.com/changelog"; } -export function signin(service = "slack"): string { - return `/auth/${service}`; -} - export const SLUG_URL_REGEX = /^(?:[0-9a-zA-Z-_~]*-)?([a-zA-Z0-9]{10,15})$/; export const SHARE_URL_SLUG_REGEX = /^[0-9a-z-]+$/; diff --git a/webpack.config.js b/webpack.config.js index fe82783302..c539f08fa4 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -25,6 +25,7 @@ module.exports = { include: [ path.join(__dirname, 'app'), path.join(__dirname, 'shared'), + path.join(__dirname, 'plugins'), ], options: { cacheDirectory: true diff --git a/yarn.lock b/yarn.lock index f1a2768e89..9cdc4081a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2985,6 +2985,14 @@ resolved "https://registry.yarnpkg.com/@types/fuzzy-search/-/fuzzy-search-2.1.2.tgz#d57b2af8fb723baa1792d40d511f431c4c8f75af" integrity sha512-YOqA50Z3xcycm4Br5+MBUpSumfdOAcv34A8A8yFn62zBQPTzJSXQk11qYE5w8BWQ0KrVThXUgEQh7ZLrYI1NaQ== +"@types/glob@^8.0.1": + version "8.0.1" + resolved "https://registry.yarnpkg.com/@types/glob/-/glob-8.0.1.tgz#6e3041640148b7764adf21ce5c7138ad454725b0" + integrity sha512-8bVUjXZvJacUFkJXHdyZ9iH1Eaj5V7I8c4NdH5sQJsdXkqT4CA5Dhb4yb4VE/3asyx4L9ayZr1NIhTsWHczmMw== + dependencies: + "@types/minimatch" "^5.1.2" + "@types/node" "*" + "@types/google.analytics@^0.0.42": version "0.0.42" resolved "https://registry.yarnpkg.com/@types/google.analytics/-/google.analytics-0.0.42.tgz#efe6ef9251a22ec8208dbb09f221a48a1863d720" @@ -3250,6 +3258,11 @@ resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40" integrity sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ== +"@types/minimatch@^5.1.2": + version "5.1.2" + resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-5.1.2.tgz#07508b45797cb81ec3f273011b054cd0755eddca" + integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA== + "@types/ms@*": version "0.7.31" resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197" @@ -4868,6 +4881,13 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + braces@^2.3.1, braces@^2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729" @@ -8374,6 +8394,17 @@ glob@7.2.0, glob@^7.0.0, glob@^7.1.1, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: once "^1.3.0" path-is-absolute "^1.0.0" +glob@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-8.1.0.tgz#d388f656593ef708ee3e34640fdfb99a9fd1c33e" + integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^5.0.1" + once "^1.3.0" + global@~4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/global/-/global-4.4.0.tgz#3e7b105179006a323ed71aafca3e9c57a5cc6406" @@ -11209,6 +11240,13 @@ minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" +minimatch@^5.0.1: + version "5.1.6" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" + integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== + dependencies: + brace-expansion "^2.0.1" + minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: version "1.2.7" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18"