mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
b91d9e9a72
* feat: Extract search into pluggable provider system Refactors the monolithic SearchHelper into a pluggable search provider architecture, enabling alternative search backends (Elasticsearch, Turbopuffer, etc.) while preserving PostgreSQL full-text search as the default. The SEARCH_PROVIDER env var selects the active provider. - Add BaseSearchProvider abstract class and SearchProviderManager - Add Hook.SearchProvider to the plugin system - Move PostgreSQL search logic into plugins/postgres-search/ - Add SearchIndexProcessor for event-driven index sync - Update all callers to use the provider manager directly - Keep SearchHelper as a deprecated thin wrapper for backwards compat Closes #11347 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: Remove deprecated SearchHelper wrapper All callers now use SearchProviderManager directly, so the thin delegation wrapper is no longer needed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: Rename postgres-search plugin to search-postgres Renames the plugin folder and id so that future search provider plugins (e.g. search-elasticsearch, search-turbopuffer) will be colocated alphabetically. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: Remove special-case plugin import from SearchProviderManager Make PluginManager.loadPlugins resilient to individual plugin load failures so SearchProviderManager can use the standard getHooks path without needing to directly import the search-postgres plugin. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: Add missing search provider tests for full coverage parity Adds all tests that existed in the old SearchHelper.test.ts but were missing from PostgresSearchProvider.test.ts, including searchTitlesForUser status filters, collection filtering, group memberships, and sorting tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feedback --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
433 lines
10 KiB
TypeScript
433 lines
10 KiB
TypeScript
import { t } from "i18next";
|
|
import Router from "koa-router";
|
|
import escapeRegExp from "lodash/escapeRegExp";
|
|
import queryString from "query-string";
|
|
import { z } from "zod";
|
|
import { IntegrationService, IntegrationType } from "@shared/types";
|
|
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
|
import {
|
|
AuthenticationError,
|
|
InvalidRequestError,
|
|
ValidationError,
|
|
} from "@server/errors";
|
|
import Logger from "@server/logging/Logger";
|
|
import validate from "@server/middlewares/validate";
|
|
import {
|
|
UserAuthentication,
|
|
Document,
|
|
User,
|
|
Team,
|
|
SearchQuery,
|
|
Integration,
|
|
IntegrationAuthentication,
|
|
AuthenticationProvider,
|
|
Comment,
|
|
} from "@server/models";
|
|
import SearchProviderManager from "@server/utils/SearchProviderManager";
|
|
import { can } from "@server/policies";
|
|
import type { APIContext } from "@server/types";
|
|
import { safeEqual } from "@server/utils/crypto";
|
|
import { opts } from "@server/utils/i18n";
|
|
import env from "../env";
|
|
import { presentMessageAttachment } from "../presenters/messageAttachment";
|
|
import { presentUserNotLinkedBlocks } from "../presenters/userNotLinkedBlocks";
|
|
import * as Slack from "../slack";
|
|
import * as T from "./schema";
|
|
|
|
const router = new Router();
|
|
|
|
// triggered by a user posting a getoutline.com link in Slack
|
|
router.post(
|
|
"hooks.unfurl",
|
|
validate(T.HooksUnfurlSchema),
|
|
async (ctx: APIContext<T.HooksUnfurlReq>) => {
|
|
// See URL verification handshake documentation on this page:
|
|
// https://api.slack.com/apis/connections/events-api
|
|
if ("challenge" in ctx.input.body) {
|
|
ctx.body = {
|
|
challenge: ctx.input.body.challenge,
|
|
};
|
|
return;
|
|
}
|
|
|
|
const { token, team_id, event } = ctx.input.body;
|
|
verifySlackToken(token);
|
|
|
|
const user = await findUserForRequest(team_id, event.user);
|
|
if (!user) {
|
|
Logger.debug("plugins", "No user found for Slack user ID", {
|
|
providerId: event.user,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const auth = await IntegrationAuthentication.findOne({
|
|
where: {
|
|
service: IntegrationService.Slack,
|
|
teamId: user.teamId,
|
|
},
|
|
});
|
|
|
|
if (!auth) {
|
|
Logger.debug(
|
|
"plugins",
|
|
"No Slack integration authentication found for team",
|
|
{
|
|
teamId: user.teamId,
|
|
}
|
|
);
|
|
return;
|
|
}
|
|
// get content for unfurled links
|
|
const unfurls: Record<
|
|
string,
|
|
{ title: string; text: string; color?: string | undefined }
|
|
> = {};
|
|
|
|
for (const link of event.links) {
|
|
const documentId = parseDocumentSlug(link.url);
|
|
if (documentId) {
|
|
const doc = await Document.findByPk(documentId, { userId: user.id });
|
|
|
|
if (doc && can(user, "read", doc)) {
|
|
const commentId = queryString.parse(
|
|
link.url.split("?")[1]
|
|
)?.commentId;
|
|
|
|
if (commentId) {
|
|
const comment = await Comment.findByPk(commentId as string);
|
|
if (!comment || comment.documentId !== doc.id) {
|
|
continue;
|
|
}
|
|
|
|
unfurls[link.url] = {
|
|
title: t(`Comment by {{ author }} on "{{ title }}"`, {
|
|
author: comment.createdBy.name,
|
|
title: doc.title,
|
|
...opts(user),
|
|
}),
|
|
text: comment.toPlainText(),
|
|
};
|
|
} else {
|
|
unfurls[link.url] = {
|
|
title: doc.title,
|
|
text: doc.getSummary(),
|
|
color: doc.collection?.color ?? undefined,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
await Slack.post("chat.unfurl", {
|
|
token: auth.token,
|
|
channel: event.channel,
|
|
ts: event.message_ts,
|
|
unfurls,
|
|
});
|
|
|
|
ctx.body = {
|
|
success: true,
|
|
};
|
|
}
|
|
);
|
|
|
|
// triggered by interactions with actions, dialogs, message buttons in Slack
|
|
router.post(
|
|
"hooks.interactive",
|
|
validate(T.HooksInteractiveSchema),
|
|
async (ctx: APIContext<T.HooksInteractiveReq>) => {
|
|
const { payload } = ctx.input.body;
|
|
let callback_id, token;
|
|
|
|
try {
|
|
// https://api.slack.com/interactivity/handling#payloads
|
|
const data = JSON.parse(payload);
|
|
|
|
const parsed = z
|
|
.object({
|
|
type: z.string(),
|
|
callback_id: z.string(),
|
|
token: z.string(),
|
|
})
|
|
.parse(data);
|
|
|
|
callback_id = parsed.callback_id;
|
|
token = parsed.token;
|
|
} catch (err) {
|
|
Logger.error("Failed to parse Slack interactive payload", err, {
|
|
payload,
|
|
});
|
|
throw ValidationError("Invalid payload");
|
|
}
|
|
|
|
verifySlackToken(token);
|
|
|
|
// we find the document based on the users teamId to ensure access
|
|
const document =
|
|
await Document.scope("withCollection").findByPk(callback_id);
|
|
|
|
if (!document) {
|
|
throw InvalidRequestError("Invalid callback_id");
|
|
}
|
|
|
|
const team = await Team.findByPk(document.teamId, { rejectOnEmpty: true });
|
|
|
|
// respond with a public message that will be posted in the original channel
|
|
ctx.body = {
|
|
response_type: "in_channel",
|
|
replace_original: false,
|
|
attachments: [
|
|
presentMessageAttachment(
|
|
document,
|
|
team,
|
|
document.collection,
|
|
document.getSummary()
|
|
),
|
|
],
|
|
};
|
|
}
|
|
);
|
|
|
|
// triggered by the /outline command in Slack
|
|
router.post(
|
|
"hooks.slack",
|
|
validate(T.HooksSlackCommandSchema),
|
|
async (ctx: APIContext<T.HooksSlackCommandReq>) => {
|
|
const { token, team_id, user_id, text } = ctx.input.body;
|
|
verifySlackToken(token);
|
|
|
|
const user = await findUserForRequest(team_id, user_id);
|
|
|
|
// Handle "help" command or no input
|
|
if (text.trim() === "help" || !text.trim()) {
|
|
ctx.body = {
|
|
response_type: "ephemeral",
|
|
text: t("How to use {{ command }}", {
|
|
command: "/outline",
|
|
...opts(user),
|
|
}),
|
|
attachments: [
|
|
{
|
|
text: t(
|
|
"To search your workspace use {{ command }}. \nType {{ command2 }} help to display this help text.",
|
|
{
|
|
command: `/outline keyword`,
|
|
command2: `/outline`,
|
|
...opts(user),
|
|
}
|
|
),
|
|
},
|
|
],
|
|
};
|
|
return;
|
|
}
|
|
|
|
const options = {
|
|
query: text,
|
|
limit: 5,
|
|
};
|
|
|
|
if (!user) {
|
|
const team = await findTeamForRequest(team_id);
|
|
|
|
ctx.body = {
|
|
response_type: "ephemeral",
|
|
blocks: presentUserNotLinkedBlocks(team),
|
|
};
|
|
return;
|
|
}
|
|
|
|
const { results, total } = await SearchProviderManager.getProvider().searchForUser(user, options);
|
|
|
|
await SearchQuery.create({
|
|
userId: user ? user.id : null,
|
|
teamId: user.teamId,
|
|
source: "slack",
|
|
query: text,
|
|
results: total,
|
|
});
|
|
|
|
// Map search results to the format expected by the Slack API
|
|
if (results.length) {
|
|
const attachments = [];
|
|
|
|
for (const result of results) {
|
|
const queryIsInTitle = !!result.document.title
|
|
.toLowerCase()
|
|
.match(escapeRegExp(text.toLowerCase()));
|
|
attachments.push(
|
|
presentMessageAttachment(
|
|
result.document,
|
|
user.team,
|
|
result.document.collection,
|
|
queryIsInTitle ? undefined : result.context,
|
|
env.SLACK_MESSAGE_ACTIONS
|
|
? [
|
|
{
|
|
name: "post",
|
|
text: t("Post to Channel", opts(user)),
|
|
type: "button",
|
|
value: result.document.id,
|
|
},
|
|
]
|
|
: undefined
|
|
)
|
|
);
|
|
}
|
|
|
|
ctx.body = {
|
|
text: t(`This is what we found for "{{ term }}"`, {
|
|
...opts(user),
|
|
term: text,
|
|
}),
|
|
attachments,
|
|
};
|
|
} else {
|
|
ctx.body = {
|
|
text: t(`No results for "{{ term }}"`, {
|
|
...opts(user),
|
|
term: text,
|
|
}),
|
|
};
|
|
}
|
|
}
|
|
);
|
|
|
|
function verifySlackToken(token: string) {
|
|
if (!env.SLACK_VERIFICATION_TOKEN) {
|
|
throw AuthenticationError(
|
|
"SLACK_VERIFICATION_TOKEN is not present in environment"
|
|
);
|
|
}
|
|
|
|
if (!safeEqual(env.SLACK_VERIFICATION_TOKEN, token)) {
|
|
throw AuthenticationError("Invalid token");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find a matching team for the given Slack team ID
|
|
*
|
|
* @param serviceTeamId The Slack team ID
|
|
* @returns A promise resolving to a matching team, if found
|
|
*/
|
|
async function findTeamForRequest(
|
|
serviceTeamId: string
|
|
): Promise<Team | undefined> {
|
|
const authenticationProvider = await AuthenticationProvider.findOne({
|
|
where: {
|
|
name: "slack",
|
|
providerId: serviceTeamId,
|
|
},
|
|
include: [
|
|
{
|
|
required: true,
|
|
model: Team,
|
|
as: "team",
|
|
},
|
|
],
|
|
});
|
|
|
|
if (authenticationProvider) {
|
|
return authenticationProvider.team;
|
|
}
|
|
|
|
const integration = await Integration.findOne({
|
|
where: {
|
|
service: IntegrationService.Slack,
|
|
type: IntegrationType.LinkedAccount,
|
|
settings: {
|
|
slack: {
|
|
serviceTeamId,
|
|
},
|
|
},
|
|
},
|
|
include: [
|
|
{
|
|
model: Team,
|
|
as: "team",
|
|
required: true,
|
|
},
|
|
],
|
|
});
|
|
|
|
if (integration) {
|
|
return integration.team;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
/**
|
|
* Find a matching user for the given Slack team and user ID
|
|
*
|
|
* @param serviceTeamId The Slack team ID
|
|
* @param serviceUserId The Slack user ID
|
|
* @returns A promise resolving to a matching user, if found
|
|
*/
|
|
async function findUserForRequest(
|
|
serviceTeamId: string,
|
|
serviceUserId: string
|
|
): Promise<User | undefined> {
|
|
// Prefer explicit linked account
|
|
const integration = await Integration.findOne({
|
|
where: {
|
|
service: IntegrationService.Slack,
|
|
type: IntegrationType.LinkedAccount,
|
|
settings: {
|
|
slack: {
|
|
serviceTeamId,
|
|
serviceUserId,
|
|
},
|
|
},
|
|
},
|
|
include: [
|
|
{
|
|
model: User,
|
|
as: "user",
|
|
required: true,
|
|
},
|
|
{
|
|
model: Team,
|
|
as: "team",
|
|
required: true,
|
|
},
|
|
],
|
|
order: [["createdAt", "DESC"]],
|
|
});
|
|
|
|
if (integration) {
|
|
integration.user.team = integration.team;
|
|
return integration.user;
|
|
}
|
|
|
|
// Fallback to authentication provider if the user has Slack sign-in
|
|
const user = await User.findOne({
|
|
include: [
|
|
{
|
|
where: {
|
|
providerId: serviceUserId,
|
|
},
|
|
order: [["createdAt", "DESC"]],
|
|
model: UserAuthentication,
|
|
as: "authentications",
|
|
required: true,
|
|
},
|
|
{
|
|
model: Team,
|
|
as: "team",
|
|
required: true,
|
|
},
|
|
],
|
|
});
|
|
|
|
if (user) {
|
|
return user;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
export default router;
|